mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 23:26:41 +02:00
🔀 merge: auth/complete branch with Better Auth implementation
Merged auth/complete into main with resolved conflicts: - Kept Better Auth system (EdDSA JWT via JWKS) - Removed all Coolify references - Added dev:auth and dev:chat:full scripts for auth development - Combined zitare scripts from main with auth scripts - Exported both feedback.schema and organizations.schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
8a43bbfc25
84 changed files with 13452 additions and 6778 deletions
363
services/mana-core-auth/src/__tests__/utils/mock-factories.ts
Normal file
363
services/mana-core-auth/src/__tests__/utils/mock-factories.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* Mock Factories for Testing
|
||||
*
|
||||
* Centralized factory functions for creating test data
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* Mock User Factory
|
||||
*/
|
||||
export const mockUserFactory = {
|
||||
create: (overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
email: `test-${nanoid(6)}@example.com`,
|
||||
emailVerified: true,
|
||||
name: 'Test User',
|
||||
avatarUrl: null,
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createMany: (count: number, overrides: Partial<any> = {}) => {
|
||||
return Array.from({ length: count }, () => mockUserFactory.create(overrides));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Session Factory
|
||||
*/
|
||||
export const mockSessionFactory = {
|
||||
create: (userId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
token: nanoid(),
|
||||
refreshToken: nanoid(64),
|
||||
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
deviceId: null,
|
||||
deviceName: null,
|
||||
lastActivityAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Password Factory
|
||||
*/
|
||||
export const mockPasswordFactory = {
|
||||
create: async (userId: string, password: string = 'TestPassword123!') => ({
|
||||
userId,
|
||||
hashedPassword: await bcrypt.hash(password, 12),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
|
||||
createSync: (userId: string, password: string = 'TestPassword123!') => ({
|
||||
userId,
|
||||
hashedPassword: bcrypt.hashSync(password, 12),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Balance Factory
|
||||
*/
|
||||
export const mockBalanceFactory = {
|
||||
create: (userId: string, overrides: Partial<any> = {}) => ({
|
||||
userId,
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150,
|
||||
dailyFreeCredits: 5,
|
||||
lastDailyResetAt: new Date(),
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
version: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
withBalance: (userId: string, balance: number, freeCredits: number = 0) => {
|
||||
return mockBalanceFactory.create(userId, {
|
||||
balance,
|
||||
freeCreditsRemaining: freeCredits,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Transaction Factory
|
||||
*/
|
||||
export const mockTransactionFactory = {
|
||||
create: (userId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -10,
|
||||
balanceBefore: 100,
|
||||
balanceAfter: 90,
|
||||
appId: 'test-app',
|
||||
description: 'Test transaction',
|
||||
metadata: null,
|
||||
idempotencyKey: null,
|
||||
createdAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createMany: (userId: string, count: number) => {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
mockTransactionFactory.create(userId, {
|
||||
amount: -(i + 1) * 10,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Package Factory
|
||||
*/
|
||||
export const mockPackageFactory = {
|
||||
create: (overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
name: 'Test Package',
|
||||
description: '100 credits',
|
||||
credits: 100,
|
||||
priceEuroCents: 100,
|
||||
stripePriceId: `price_${nanoid()}`,
|
||||
active: true,
|
||||
sortOrder: 0,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createMany: (count: number) => {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
mockPackageFactory.create({
|
||||
name: `Package ${i + 1}`,
|
||||
credits: (i + 1) * 100,
|
||||
priceEuroCents: (i + 1) * 100,
|
||||
sortOrder: i,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Purchase Factory
|
||||
*/
|
||||
export const mockPurchaseFactory = {
|
||||
create: (userId: string, packageId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
packageId,
|
||||
credits: 100,
|
||||
priceEuroCents: 100,
|
||||
stripePaymentIntentId: `pi_${nanoid()}`,
|
||||
stripeCustomerId: `cus_${nanoid()}`,
|
||||
status: 'completed',
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock DTO Factory
|
||||
*/
|
||||
export const mockDtoFactory = {
|
||||
register: (overrides: Partial<any> = {}) => ({
|
||||
email: `test-${nanoid(6)}@example.com`,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
login: (overrides: Partial<any> = {}) => ({
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: undefined,
|
||||
deviceName: undefined,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
useCredits: (overrides: Partial<any> = {}) => ({
|
||||
amount: 10,
|
||||
appId: 'test-app',
|
||||
description: 'Test operation',
|
||||
metadata: undefined,
|
||||
idempotencyKey: undefined,
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock JWT Tokens
|
||||
*/
|
||||
export const mockTokenFactory = {
|
||||
validPayload: (overrides: Partial<any> = {}) => ({
|
||||
sub: nanoid(),
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
sessionId: nanoid(),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
expiredPayload: (overrides: Partial<any> = {}) => ({
|
||||
sub: nanoid(),
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
sessionId: nanoid(),
|
||||
iat: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
||||
exp: Math.floor(Date.now() / 1000) - 1800, // 30 minutes ago (expired)
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Organization Factory
|
||||
*/
|
||||
export const mockOrganizationFactory = {
|
||||
create: (overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
name: 'Test Organization',
|
||||
slug: `test-org-${nanoid(6)}`,
|
||||
logo: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Organization Balance Factory
|
||||
*/
|
||||
export const mockOrganizationBalanceFactory = {
|
||||
create: (organizationId: string, overrides: Partial<any> = {}) => ({
|
||||
organizationId,
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
version: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
withBalance: (organizationId: string, balance: number, allocated: number = 0) => {
|
||||
return mockOrganizationBalanceFactory.create(organizationId, {
|
||||
balance,
|
||||
allocatedCredits: allocated,
|
||||
availableCredits: balance - allocated,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Member Factory
|
||||
*/
|
||||
export const mockMemberFactory = {
|
||||
create: (organizationId: string, userId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
organizationId,
|
||||
userId,
|
||||
role: 'member',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createOwner: (organizationId: string, userId: string) => {
|
||||
return mockMemberFactory.create(organizationId, userId, {
|
||||
role: 'owner',
|
||||
});
|
||||
},
|
||||
|
||||
createEmployee: (organizationId: string, userId: string) => {
|
||||
return mockMemberFactory.create(organizationId, userId, {
|
||||
role: 'member',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Credit Allocation Factory
|
||||
*/
|
||||
export const mockCreditAllocationFactory = {
|
||||
create: (organizationId: string, employeeId: string, allocatedBy: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 100,
|
||||
allocatedBy,
|
||||
reason: 'Credit allocation',
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 100,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Database Responses
|
||||
*/
|
||||
export const mockDbFactory = {
|
||||
createSelectMock: () => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
for: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
}),
|
||||
|
||||
createInsertMock: () => ({
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
}),
|
||||
|
||||
createUpdateMock: () => ({
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
}),
|
||||
|
||||
createTransactionMock: () => ({
|
||||
transaction: jest.fn((callback) => callback(mockDbFactory.createSelectMock())),
|
||||
}),
|
||||
|
||||
createFullMock: () => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
for: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
transaction: jest.fn((callback) => callback(this)),
|
||||
}),
|
||||
};
|
||||
293
services/mana-core-auth/src/__tests__/utils/test-helpers.ts
Normal file
293
services/mana-core-auth/src/__tests__/utils/test-helpers.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Test Helper Utilities
|
||||
*
|
||||
* Common utilities for writing tests
|
||||
*/
|
||||
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Create mock ConfigService
|
||||
*/
|
||||
export const createMockConfigService = (overrides: Record<string, any> = {}): ConfigService => {
|
||||
const defaultConfig: Record<string, any> = {
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
'jwt.privateKey': 'mock-private-key',
|
||||
'jwt.publicKey': 'mock-public-key',
|
||||
'jwt.accessTokenExpiry': '15m',
|
||||
'jwt.refreshTokenExpiry': '7d',
|
||||
'jwt.issuer': 'mana-core',
|
||||
'jwt.audience': 'mana-universe',
|
||||
'credits.signupBonus': 150,
|
||||
'credits.dailyFreeCredits': 5,
|
||||
'redis.host': 'localhost',
|
||||
'redis.port': 6379,
|
||||
'redis.password': 'test',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
get: jest.fn((key: string) => defaultConfig[key]),
|
||||
getOrThrow: jest.fn((key: string) => {
|
||||
if (!defaultConfig[key]) {
|
||||
throw new Error(`Configuration key ${key} not found`);
|
||||
}
|
||||
return defaultConfig[key];
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a test date with specific offset
|
||||
*/
|
||||
export const createTestDate = (offsetMs: number = 0): Date => {
|
||||
return new Date(Date.now() + offsetMs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock timer utilities
|
||||
*/
|
||||
export const timerUtils = {
|
||||
/**
|
||||
* Fast-forward time
|
||||
*/
|
||||
advance: (ms: number) => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
},
|
||||
|
||||
/**
|
||||
* Use fake timers
|
||||
*/
|
||||
useFake: () => {
|
||||
jest.useFakeTimers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Use real timers
|
||||
*/
|
||||
useReal: () => {
|
||||
jest.useRealTimers();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert helpers for common patterns
|
||||
*/
|
||||
export const assertHelpers = {
|
||||
/**
|
||||
* Assert that a function throws a specific error
|
||||
*/
|
||||
assertThrowsAsync: async (fn: () => Promise<any>, expectedError: string | RegExp) => {
|
||||
await expect(fn()).rejects.toThrow(expectedError);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that an object has specific properties
|
||||
*/
|
||||
assertHasProperties: (obj: any, properties: string[]) => {
|
||||
properties.forEach((prop) => {
|
||||
expect(obj).toHaveProperty(prop);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that an object does NOT have specific properties
|
||||
*/
|
||||
assertLacksProperties: (obj: any, properties: string[]) => {
|
||||
properties.forEach((prop) => {
|
||||
expect(obj).not.toHaveProperty(prop);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a value is a valid UUID
|
||||
*/
|
||||
assertIsUuid: (value: string) => {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
expect(value).toMatch(uuidRegex);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a date is recent (within last N seconds)
|
||||
*/
|
||||
assertIsRecent: (date: Date, withinSeconds: number = 5) => {
|
||||
const now = Date.now();
|
||||
const dateMs = date.getTime();
|
||||
const diff = Math.abs(now - dateMs);
|
||||
expect(diff).toBeLessThan(withinSeconds * 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a value is between min and max
|
||||
*/
|
||||
assertBetween: (value: number, min: number, max: number) => {
|
||||
expect(value).toBeGreaterThanOrEqual(min);
|
||||
expect(value).toBeLessThanOrEqual(max);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Database test helpers
|
||||
*/
|
||||
export const dbTestHelpers = {
|
||||
/**
|
||||
* Create a mock database connection
|
||||
*/
|
||||
createMockDb: () => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
for: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mock successful query result
|
||||
*/
|
||||
mockSuccessResult: (data: any) => ({
|
||||
data,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mock error result
|
||||
*/
|
||||
mockErrorResult: (error: Error) => ({
|
||||
data: null,
|
||||
error,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Security test helpers
|
||||
*/
|
||||
export const securityTestHelpers = {
|
||||
/**
|
||||
* Common SQL injection payloads
|
||||
*/
|
||||
sqlInjectionPayloads: [
|
||||
"'; DROP TABLE users; --",
|
||||
"' OR '1'='1",
|
||||
"' OR '1'='1' --",
|
||||
"' OR '1'='1' /*",
|
||||
"admin'--",
|
||||
"' UNION SELECT NULL--",
|
||||
],
|
||||
|
||||
/**
|
||||
* Common XSS payloads
|
||||
*/
|
||||
xssPayloads: [
|
||||
'<script>alert("xss")</script>',
|
||||
'<img src=x onerror=alert("xss")>',
|
||||
'<svg onload=alert("xss")>',
|
||||
'javascript:alert("xss")',
|
||||
],
|
||||
|
||||
/**
|
||||
* Test for timing attacks
|
||||
*/
|
||||
measureExecutionTime: async (fn: () => Promise<any>): Promise<number> => {
|
||||
const start = process.hrtime.bigint();
|
||||
await fn();
|
||||
const end = process.hrtime.bigint();
|
||||
return Number(end - start) / 1_000_000; // Convert to milliseconds
|
||||
},
|
||||
|
||||
/**
|
||||
* Test for constant-time comparison
|
||||
*/
|
||||
isConstantTime: async (
|
||||
fn1: () => Promise<any>,
|
||||
fn2: () => Promise<any>,
|
||||
threshold: number = 10
|
||||
): Promise<boolean> => {
|
||||
const time1 = await securityTestHelpers.measureExecutionTime(fn1);
|
||||
const time2 = await securityTestHelpers.measureExecutionTime(fn2);
|
||||
const diff = Math.abs(time1 - time2);
|
||||
return diff < threshold;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock HTTP request/response
|
||||
*/
|
||||
export const httpMockHelpers = {
|
||||
/**
|
||||
* Create mock Express request
|
||||
*/
|
||||
createMockRequest: (overrides: Partial<any> = {}) => ({
|
||||
headers: {},
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
ip: '127.0.0.1',
|
||||
user: null,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create mock Express response
|
||||
*/
|
||||
createMockResponse: () => {
|
||||
const res: any = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
};
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create mock NestJS ExecutionContext
|
||||
*/
|
||||
createMockExecutionContext: (request: any) => ({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => httpMockHelpers.createMockResponse(),
|
||||
}),
|
||||
getClass: () => ({}),
|
||||
getHandler: () => ({}),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Performance test helpers
|
||||
*/
|
||||
export const performanceHelpers = {
|
||||
/**
|
||||
* Run a function N times and measure average execution time
|
||||
*/
|
||||
benchmark: async (fn: () => Promise<any>, iterations: number = 100): Promise<number> => {
|
||||
const times: number[] = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
await fn();
|
||||
const end = process.hrtime.bigint();
|
||||
times.push(Number(end - start) / 1_000_000);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
return avg;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert function execution is under a time limit
|
||||
*/
|
||||
assertExecutionTime: async (fn: () => Promise<any>, maxMs: number) => {
|
||||
const start = process.hrtime.bigint();
|
||||
await fn();
|
||||
const end = process.hrtime.bigint();
|
||||
const duration = Number(end - start) / 1_000_000;
|
||||
expect(duration).toBeLessThan(maxMs);
|
||||
},
|
||||
};
|
||||
699
services/mana-core-auth/src/auth/auth.controller.spec.ts
Normal file
699
services/mana-core-auth/src/auth/auth.controller.spec.ts
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
/**
|
||||
* AuthController Unit Tests
|
||||
*
|
||||
* Tests all authentication controller endpoints using BetterAuthService:
|
||||
*
|
||||
* B2C Endpoints:
|
||||
* - POST /auth/register - User registration
|
||||
* - POST /auth/login - User login
|
||||
* - POST /auth/logout - User logout
|
||||
* - POST /auth/refresh - Token refresh
|
||||
* - GET /auth/session - Get current session
|
||||
* - POST /auth/validate - Token validation
|
||||
*
|
||||
* B2B Endpoints:
|
||||
* - POST /auth/register/b2b - Organization registration
|
||||
* - GET /auth/organizations - List organizations
|
||||
* - GET /auth/organizations/:id - Get organization
|
||||
* - GET /auth/organizations/:id/members - Get organization members
|
||||
* - POST /auth/organizations/:id/invite - Invite employee
|
||||
* - POST /auth/organizations/accept-invitation - Accept invitation
|
||||
* - DELETE /auth/organizations/:id/members/:memberId - Remove member
|
||||
* - POST /auth/organizations/set-active - Set active organization
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { mockDtoFactory } from '../__tests__/utils/mock-factories';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let betterAuthService: jest.Mocked<BetterAuthService>;
|
||||
|
||||
// Common test data
|
||||
const mockAuthHeader = 'Bearer valid-jwt-token';
|
||||
const mockToken = 'valid-jwt-token';
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock BetterAuthService with all methods
|
||||
const mockBetterAuthService = {
|
||||
registerB2C: jest.fn(),
|
||||
registerB2B: jest.fn(),
|
||||
signIn: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
listOrganizations: jest.fn(),
|
||||
getOrganization: jest.fn(),
|
||||
getOrganizationMembers: jest.fn(),
|
||||
inviteEmployee: jest.fn(),
|
||||
acceptInvitation: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
setActiveOrganization: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
validateToken: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: BetterAuthService,
|
||||
useValue: mockBetterAuthService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
betterAuthService = module.get(BetterAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/register (B2C)
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('should successfully register a new B2C user', async () => {
|
||||
const registerDto = mockDtoFactory.register({
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'New User',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: registerDto.email,
|
||||
name: registerDto.name,
|
||||
},
|
||||
token: 'jwt-token',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.register(registerDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle registration without name', async () => {
|
||||
const registerDto = {
|
||||
email: 'noname@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
user: { id: 'user-456', email: registerDto.email, name: '' },
|
||||
token: 'jwt-token',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.register(registerDto as any);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate ConflictException when user exists', async () => {
|
||||
const registerDto = mockDtoFactory.register({ email: 'existing@example.com' });
|
||||
|
||||
betterAuthService.registerB2C.mockRejectedValue(
|
||||
new ConflictException('User with this email already exists')
|
||||
);
|
||||
|
||||
await expect(controller.register(registerDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/login
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('should successfully login a user', async () => {
|
||||
const loginDto = mockDtoFactory.login({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: loginDto.email,
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
},
|
||||
accessToken: 'jwt-access-token',
|
||||
refreshToken: 'session-refresh-token',
|
||||
expiresIn: 900,
|
||||
};
|
||||
|
||||
betterAuthService.signIn.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.login(loginDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.signIn).toHaveBeenCalledWith({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: undefined,
|
||||
deviceName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass device info when provided', async () => {
|
||||
const loginDto = {
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: 'device-abc-123',
|
||||
deviceName: 'iPhone 15 Pro',
|
||||
};
|
||||
|
||||
betterAuthService.signIn.mockResolvedValue({
|
||||
user: { id: '123', email: 'user@example.com', name: 'Test', role: 'user' },
|
||||
accessToken: 'jwt-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
await controller.login(loginDto);
|
||||
|
||||
expect(betterAuthService.signIn).toHaveBeenCalledWith({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: 'device-abc-123',
|
||||
deviceName: 'iPhone 15 Pro',
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate UnauthorizedException for invalid credentials', async () => {
|
||||
const loginDto = mockDtoFactory.login({ password: 'WrongPassword' });
|
||||
|
||||
betterAuthService.signIn.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid email or password')
|
||||
);
|
||||
|
||||
await expect(controller.login(loginDto)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/logout
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should successfully logout a user', async () => {
|
||||
const expectedResult = { success: true, message: 'Signed out successfully' };
|
||||
|
||||
betterAuthService.signOut.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.logout(mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should extract token from Bearer header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
|
||||
|
||||
await controller.logout('Bearer my-secret-token');
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token');
|
||||
});
|
||||
|
||||
it('should handle raw token without Bearer prefix', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
|
||||
|
||||
await controller.logout('raw-token');
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/refresh
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
it('should successfully refresh tokens', async () => {
|
||||
const refreshTokenDto = { refreshToken: 'valid-refresh-token' };
|
||||
|
||||
const expectedResult = {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 900,
|
||||
tokenType: 'Bearer',
|
||||
user: { id: 'user-123', email: 'user@example.com', name: 'Test', role: 'user' as const },
|
||||
};
|
||||
|
||||
betterAuthService.refreshToken.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.refresh(refreshTokenDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.refreshToken).toHaveBeenCalledWith('valid-refresh-token');
|
||||
});
|
||||
|
||||
it('should propagate UnauthorizedException for invalid refresh token', async () => {
|
||||
const refreshTokenDto = { refreshToken: 'invalid-token' };
|
||||
|
||||
betterAuthService.refreshToken.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid refresh token')
|
||||
);
|
||||
|
||||
await expect(controller.refresh(refreshTokenDto)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/session
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/session', () => {
|
||||
it('should return current session', async () => {
|
||||
const expectedResult = {
|
||||
user: { id: 'user-123', email: 'user@example.com', name: 'Test' },
|
||||
session: { id: 'session-123', activeOrganizationId: null },
|
||||
};
|
||||
|
||||
betterAuthService.getSession.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.getSession(mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.getSession).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should propagate UnauthorizedException for invalid session', async () => {
|
||||
betterAuthService.getSession.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid or expired session')
|
||||
);
|
||||
|
||||
await expect(controller.getSession(mockAuthHeader)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/validate
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/validate', () => {
|
||||
it('should return valid for a valid token', async () => {
|
||||
const body = { token: 'valid-jwt-token' };
|
||||
|
||||
const expectedResult = {
|
||||
valid: true,
|
||||
payload: { sub: 'user-123', email: 'user@example.com', role: 'user' },
|
||||
};
|
||||
|
||||
betterAuthService.validateToken.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.validate(body);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.validateToken).toHaveBeenCalledWith(body.token);
|
||||
});
|
||||
|
||||
it('should return invalid for expired token', async () => {
|
||||
const body = { token: 'expired-token' };
|
||||
|
||||
betterAuthService.validateToken.mockResolvedValue({ valid: false, error: 'Token expired' } as any);
|
||||
|
||||
const result = await controller.validate(body);
|
||||
|
||||
expect((result as any).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/register/b2b
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/register/b2b', () => {
|
||||
it('should successfully register a B2B organization', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@acme.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
user: { id: 'user-123', email: registerDto.ownerEmail, name: registerDto.ownerName },
|
||||
organization: { id: 'org-456', name: 'Acme Corporation', slug: 'acme-corporation' },
|
||||
token: 'jwt-token',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2B.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.registerB2B(registerDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2B).toHaveBeenCalledWith(registerDto);
|
||||
});
|
||||
|
||||
it('should propagate ConflictException when owner email exists', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'existing@acme.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John',
|
||||
organizationName: 'Acme',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2B.mockRejectedValue(
|
||||
new ConflictException('Owner email already exists')
|
||||
);
|
||||
|
||||
await expect(controller.registerB2B(registerDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/organizations
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/organizations', () => {
|
||||
it('should list user organizations', async () => {
|
||||
const expectedResult = {
|
||||
organizations: [
|
||||
{ id: 'org-1', name: 'Org One', slug: 'org-one' },
|
||||
{ id: 'org-2', name: 'Org Two', slug: 'org-two' },
|
||||
],
|
||||
};
|
||||
|
||||
betterAuthService.listOrganizations.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.listOrganizations(mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.listOrganizations).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no organizations', async () => {
|
||||
betterAuthService.listOrganizations.mockResolvedValue({ organizations: [] });
|
||||
|
||||
const result = await controller.listOrganizations(mockAuthHeader);
|
||||
|
||||
expect(result.organizations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/organizations/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/organizations/:id', () => {
|
||||
it('should get organization details', async () => {
|
||||
const orgId = 'org-123';
|
||||
const expectedResult = {
|
||||
id: orgId,
|
||||
name: 'Acme Corp',
|
||||
slug: 'acme-corp',
|
||||
members: [{ id: 'member-1', userId: 'user-1', role: 'owner' }],
|
||||
};
|
||||
|
||||
betterAuthService.getOrganization.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.getOrganization(orgId, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.getOrganization).toHaveBeenCalledWith(orgId, mockToken);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when organization not found', async () => {
|
||||
betterAuthService.getOrganization.mockRejectedValue(
|
||||
new NotFoundException('Organization not found')
|
||||
);
|
||||
|
||||
await expect(controller.getOrganization('invalid-id', mockAuthHeader)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/organizations/:id/members
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/organizations/:id/members', () => {
|
||||
it('should get organization members', async () => {
|
||||
const orgId = 'org-123';
|
||||
const expectedMembers = [
|
||||
{ id: 'member-1', userId: 'user-1', organizationId: orgId, role: 'owner' },
|
||||
{ id: 'member-2', userId: 'user-2', organizationId: orgId, role: 'member' },
|
||||
];
|
||||
|
||||
betterAuthService.getOrganizationMembers.mockResolvedValue(expectedMembers as any);
|
||||
|
||||
const result = await controller.getOrganizationMembers(orgId);
|
||||
|
||||
expect(result).toEqual(expectedMembers);
|
||||
expect(betterAuthService.getOrganizationMembers).toHaveBeenCalledWith(orgId);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/organizations/:id/invite
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/organizations/:id/invite', () => {
|
||||
it('should invite an employee to organization', async () => {
|
||||
const orgId = 'org-123';
|
||||
const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const };
|
||||
|
||||
const expectedResult = {
|
||||
id: 'invitation-123',
|
||||
email: 'employee@acme.com',
|
||||
organizationId: orgId,
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
betterAuthService.inviteEmployee.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.inviteEmployee(orgId, inviteDto, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.inviteEmployee).toHaveBeenCalledWith({
|
||||
organizationId: orgId,
|
||||
employeeEmail: 'employee@acme.com',
|
||||
role: 'member',
|
||||
inviterToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when inviter lacks permission', async () => {
|
||||
const orgId = 'org-123';
|
||||
const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const };
|
||||
|
||||
betterAuthService.inviteEmployee.mockRejectedValue(
|
||||
new ForbiddenException('You do not have permission to invite members')
|
||||
);
|
||||
|
||||
await expect(controller.inviteEmployee(orgId, inviteDto, mockAuthHeader)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/organizations/accept-invitation
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/organizations/accept-invitation', () => {
|
||||
it('should accept an invitation', async () => {
|
||||
const acceptDto = { invitationId: 'invitation-123' };
|
||||
|
||||
const expectedResult = {
|
||||
member: { id: 'member-123', userId: 'user-456', organizationId: 'org-123', role: 'member' },
|
||||
organization: { id: 'org-123', name: 'Acme Corp' },
|
||||
};
|
||||
|
||||
betterAuthService.acceptInvitation.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.acceptInvitation(acceptDto, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.acceptInvitation).toHaveBeenCalledWith({
|
||||
invitationId: 'invitation-123',
|
||||
userToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when invitation not found', async () => {
|
||||
const acceptDto = { invitationId: 'invalid-invitation' };
|
||||
|
||||
betterAuthService.acceptInvitation.mockRejectedValue(
|
||||
new NotFoundException('Invitation not found or expired')
|
||||
);
|
||||
|
||||
await expect(controller.acceptInvitation(acceptDto, mockAuthHeader)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /auth/organizations/:id/members/:memberId
|
||||
// ============================================================================
|
||||
|
||||
describe('DELETE /auth/organizations/:id/members/:memberId', () => {
|
||||
it('should remove a member from organization', async () => {
|
||||
const orgId = 'org-123';
|
||||
const memberId = 'member-456';
|
||||
|
||||
const expectedResult = { success: true, message: 'Member removed successfully' };
|
||||
|
||||
betterAuthService.removeMember.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.removeMember(orgId, memberId, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.removeMember).toHaveBeenCalledWith({
|
||||
organizationId: orgId,
|
||||
memberId,
|
||||
removerToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when remover lacks permission', async () => {
|
||||
betterAuthService.removeMember.mockRejectedValue(
|
||||
new ForbiddenException('You do not have permission to remove members')
|
||||
);
|
||||
|
||||
await expect(controller.removeMember('org-123', 'member-456', mockAuthHeader)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/organizations/set-active
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/organizations/set-active', () => {
|
||||
it('should set active organization', async () => {
|
||||
const setActiveDto = { organizationId: 'org-123' };
|
||||
|
||||
const expectedResult = {
|
||||
userId: 'user-123',
|
||||
activeOrganizationId: 'org-123',
|
||||
};
|
||||
|
||||
betterAuthService.setActiveOrganization.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.setActiveOrganization(setActiveDto, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.setActiveOrganization).toHaveBeenCalledWith({
|
||||
organizationId: 'org-123',
|
||||
userToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not a member', async () => {
|
||||
const setActiveDto = { organizationId: 'org-999' };
|
||||
|
||||
betterAuthService.setActiveOrganization.mockRejectedValue(
|
||||
new NotFoundException('Organization not found or you are not a member')
|
||||
);
|
||||
|
||||
await expect(controller.setActiveOrganization(setActiveDto, mockAuthHeader)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guard Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Guards', () => {
|
||||
it('should have JwtAuthGuard on protected endpoints', () => {
|
||||
const protectedEndpoints: (keyof AuthController)[] = [
|
||||
'logout',
|
||||
'getSession',
|
||||
'listOrganizations',
|
||||
'getOrganization',
|
||||
'getOrganizationMembers',
|
||||
'inviteEmployee',
|
||||
'acceptInvitation',
|
||||
'removeMember',
|
||||
'setActiveOrganization',
|
||||
];
|
||||
|
||||
protectedEndpoints.forEach((endpoint) => {
|
||||
const guards = Reflect.getMetadata(
|
||||
'__guards__',
|
||||
AuthController.prototype[endpoint as keyof AuthController]
|
||||
);
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toContain(JwtAuthGuard);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT have JwtAuthGuard on public endpoints', () => {
|
||||
const publicEndpoints: (keyof AuthController)[] = [
|
||||
'register',
|
||||
'login',
|
||||
'refresh',
|
||||
'validate',
|
||||
'registerB2B',
|
||||
];
|
||||
|
||||
publicEndpoints.forEach((endpoint) => {
|
||||
const guards = Reflect.getMetadata(
|
||||
'__guards__',
|
||||
AuthController.prototype[endpoint as keyof AuthController]
|
||||
);
|
||||
expect(guards).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Token Extraction Helper
|
||||
// ============================================================================
|
||||
|
||||
describe('Token Extraction', () => {
|
||||
it('should extract token from Bearer authorization header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
|
||||
|
||||
await controller.logout('Bearer my-token-123');
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123');
|
||||
});
|
||||
|
||||
it('should handle missing authorization header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
|
||||
|
||||
await controller.logout('');
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,53 +1,295 @@
|
|||
import { Controller, Post, Body, UseGuards, Req, Ip, Headers } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterB2BDto } from './dto/register-b2b.dto';
|
||||
import { InviteEmployeeDto } from './dto/invite-employee.dto';
|
||||
import { AcceptInvitationDto } from './dto/accept-invitation.dto';
|
||||
import { SetActiveOrganizationDto } from './dto/set-active-organization.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* Auth Controller
|
||||
*
|
||||
* Handles authentication and organization management endpoints.
|
||||
*
|
||||
* B2C Endpoints:
|
||||
* - POST /auth/register - Register individual user
|
||||
* - POST /auth/login - Sign in with email/password
|
||||
* - POST /auth/logout - Sign out
|
||||
* - POST /auth/refresh - Refresh access token
|
||||
* - GET /auth/session - Get current session
|
||||
*
|
||||
* B2B Endpoints:
|
||||
* - POST /auth/register/b2b - Register organization with owner
|
||||
* - GET /auth/organizations - List user's organizations
|
||||
* - GET /auth/organizations/:id - Get organization details
|
||||
* - POST /auth/organizations/:id/invite - Invite employee
|
||||
* - POST /auth/organizations/accept-invitation - Accept invitation
|
||||
* - DELETE /auth/organizations/:id/members/:memberId - Remove member
|
||||
* - POST /auth/organizations/set-active - Switch active organization
|
||||
*/
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
|
||||
// =========================================================================
|
||||
// B2C Authentication Endpoints
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Register a new B2C user (individual)
|
||||
*
|
||||
* Creates a user account and initializes their credit balance.
|
||||
*/
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() registerDto: RegisterDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string
|
||||
) {
|
||||
return this.authService.register(registerDto, ipAddress, userAgent);
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.betterAuthService.registerB2C({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name || '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*
|
||||
* Returns user data and JWT token.
|
||||
*/
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() loginDto: LoginDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string
|
||||
) {
|
||||
return this.authService.login(loginDto, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
async refresh(
|
||||
@Body() refreshTokenDto: RefreshTokenDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string
|
||||
) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken, ipAddress, userAgent);
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.betterAuthService.signIn({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: loginDto.deviceId,
|
||||
deviceName: loginDto.deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out current user
|
||||
*
|
||||
* Invalidates the user's session.
|
||||
*/
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout(@Req() req: Request & { user: CurrentUserData }) {
|
||||
// Extract sessionId from JWT (would need to be added to the CurrentUserData interface)
|
||||
// For now, we'll use a placeholder
|
||||
return this.authService.logout('session-id');
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(@Headers('authorization') authorization: string) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.signOut(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*
|
||||
* Uses refresh token rotation to issue new access and refresh tokens.
|
||||
*/
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.betterAuthService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*
|
||||
* Returns the current user and session data.
|
||||
*/
|
||||
@Get('session')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getSession(@Headers('authorization') authorization: string) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.getSession(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a token
|
||||
*
|
||||
* Checks if a token is valid and returns the payload.
|
||||
*/
|
||||
@Post('validate')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async validate(@Body() body: { token: string }) {
|
||||
return this.authService.validateToken(body.token);
|
||||
return this.betterAuthService.validateToken(body.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWKS (JSON Web Key Set)
|
||||
*
|
||||
* Returns public keys for JWT verification.
|
||||
* This is a passthrough to Better Auth's JWKS.
|
||||
*/
|
||||
@Get('jwks')
|
||||
async getJwks() {
|
||||
return this.betterAuthService.getJwks();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// B2B Registration
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Register a new B2B organization
|
||||
*
|
||||
* Creates an organization with the registering user as owner.
|
||||
* Also creates organization credit balance.
|
||||
*/
|
||||
@Post('register/b2b')
|
||||
async registerB2B(@Body() registerDto: RegisterB2BDto) {
|
||||
return this.betterAuthService.registerB2B(registerDto);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Organization Management Endpoints
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List user's organizations
|
||||
*
|
||||
* Returns all organizations the current user is a member of.
|
||||
*/
|
||||
@Get('organizations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listOrganizations(@Headers('authorization') authorization: string) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.listOrganizations(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization details
|
||||
*
|
||||
* Returns full organization info including members.
|
||||
*/
|
||||
@Get('organizations/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getOrganization(
|
||||
@Param('id') organizationId: string,
|
||||
@Headers('authorization') authorization: string
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.getOrganization(organizationId, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization members
|
||||
*
|
||||
* Returns all members of an organization with their roles.
|
||||
*/
|
||||
@Get('organizations/:id/members')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getOrganizationMembers(@Param('id') organizationId: string) {
|
||||
return this.betterAuthService.getOrganizationMembers(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite employee to organization
|
||||
*
|
||||
* Sends an invitation email to join the organization.
|
||||
* Requires owner or admin role.
|
||||
*/
|
||||
@Post('organizations/:id/invite')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async inviteEmployee(
|
||||
@Param('id') organizationId: string,
|
||||
@Body() inviteDto: InviteEmployeeDto,
|
||||
@Headers('authorization') authorization: string
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.inviteEmployee({
|
||||
organizationId,
|
||||
employeeEmail: inviteDto.employeeEmail,
|
||||
role: inviteDto.role,
|
||||
inviterToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept organization invitation
|
||||
*
|
||||
* Accepts a pending invitation and adds user to organization.
|
||||
*/
|
||||
@Post('organizations/accept-invitation')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async acceptInvitation(
|
||||
@Body() acceptDto: AcceptInvitationDto,
|
||||
@Headers('authorization') authorization: string
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.acceptInvitation({
|
||||
invitationId: acceptDto.invitationId,
|
||||
userToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member from organization
|
||||
*
|
||||
* Removes a member from the organization.
|
||||
* Requires owner or admin role.
|
||||
*/
|
||||
@Delete('organizations/:id/members/:memberId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async removeMember(
|
||||
@Param('id') organizationId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
@Headers('authorization') authorization: string
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.removeMember({
|
||||
organizationId,
|
||||
memberId,
|
||||
removerToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization
|
||||
*
|
||||
* Switches the user's active organization context.
|
||||
* Affects JWT claims and credit balance.
|
||||
*/
|
||||
@Post('organizations/set-active')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async setActiveOrganization(
|
||||
@Body() setActiveDto: SetActiveOrganizationDto,
|
||||
@Headers('authorization') authorization: string
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.setActiveOrganization({
|
||||
organizationId: setActiveDto.organizationId,
|
||||
userToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
*/
|
||||
private extractToken(authorization: string): string {
|
||||
if (!authorization) {
|
||||
return '';
|
||||
}
|
||||
// Handle both "Bearer token" and raw token formats
|
||||
if (authorization.startsWith('Bearer ')) {
|
||||
return authorization.substring(7);
|
||||
}
|
||||
return authorization;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
providers: [BetterAuthService],
|
||||
exports: [BetterAuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
|||
|
|
@ -1,291 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getDb } from '../db/connection';
|
||||
import { users, passwords, sessions } from '../db/schema';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, registerDto.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
throw new ConflictException('User with this email already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||
|
||||
// Create user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: registerDto.email.toLowerCase(),
|
||||
name: registerDto.name,
|
||||
role: 'user',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Store password
|
||||
await db.insert(passwords).values({
|
||||
userId: newUser.id,
|
||||
hashedPassword,
|
||||
});
|
||||
|
||||
// Initialize credit balance (done via trigger or separate service call)
|
||||
// This will be handled by the credits service
|
||||
|
||||
return {
|
||||
id: newUser.id,
|
||||
email: newUser.email,
|
||||
name: newUser.name,
|
||||
createdAt: newUser.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Find user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, loginDto.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if user is soft-deleted
|
||||
if (user.deletedAt) {
|
||||
throw new UnauthorizedException('Account has been deleted');
|
||||
}
|
||||
|
||||
// Get password
|
||||
const [passwordRecord] = await db
|
||||
.select()
|
||||
.from(passwords)
|
||||
.where(eq(passwords.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!passwordRecord) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokenData = await this.generateTokens(
|
||||
user.id,
|
||||
user.email,
|
||||
user.role,
|
||||
loginDto.deviceId,
|
||||
loginDto.deviceName,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
...tokenData,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Find session by refresh token
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Check if refresh token is expired
|
||||
if (new Date() > session.refreshTokenExpiresAt) {
|
||||
throw new UnauthorizedException('Refresh token expired');
|
||||
}
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Revoke old session (refresh token rotation)
|
||||
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
|
||||
|
||||
// Generate new tokens
|
||||
const tokenData = await this.generateTokens(
|
||||
user.id,
|
||||
user.email,
|
||||
user.role,
|
||||
session.deviceId ?? undefined,
|
||||
session.deviceName ?? undefined,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
...tokenData,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(sessionId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, sessionId));
|
||||
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
private async generateTokens(
|
||||
userId: string,
|
||||
email: string,
|
||||
role: string,
|
||||
deviceId?: string,
|
||||
deviceName?: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
) {
|
||||
const db = this.getDb();
|
||||
|
||||
const privateKeyRaw = this.configService.get<string>('jwt.privateKey');
|
||||
if (!privateKeyRaw) {
|
||||
throw new Error('JWT private key not configured');
|
||||
}
|
||||
// Convert escaped newlines to actual newlines (for Docker env vars)
|
||||
const privateKey: string = privateKeyRaw.replace(/\\n/g, '\n');
|
||||
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
|
||||
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
|
||||
// Generate session ID (must be UUID for database)
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// Create session record
|
||||
const refreshTokenString = nanoid(64);
|
||||
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
token: sessionId,
|
||||
refreshToken: refreshTokenString,
|
||||
refreshTokenExpiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceId,
|
||||
deviceName,
|
||||
expiresAt: accessTokenExpiresAt,
|
||||
});
|
||||
|
||||
// Generate JWT payload
|
||||
const tokenPayload: Record<string, unknown> = {
|
||||
sub: userId,
|
||||
email,
|
||||
role,
|
||||
sessionId,
|
||||
...(deviceId && { deviceId }),
|
||||
};
|
||||
|
||||
// Sign access token
|
||||
const accessToken = jwt.sign(tokenPayload, privateKey, {
|
||||
algorithm: 'RS256' as const,
|
||||
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
||||
...(issuer && { issuer }),
|
||||
...(audience && { audience }),
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenString,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
}
|
||||
|
||||
async validateToken(token: string) {
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
throw new Error('JWT public key not configured');
|
||||
}
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
issuer,
|
||||
}) as TokenPayload;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
211
services/mana-core-auth/src/auth/better-auth.config.ts
Normal file
211
services/mana-core-auth/src/auth/better-auth.config.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Better Auth Configuration
|
||||
*
|
||||
* This file configures Better Auth with:
|
||||
* - Email/password authentication
|
||||
* - Organization plugin for B2B (multi-tenant)
|
||||
* - JWT plugin with minimal claims
|
||||
* - Drizzle adapter for PostgreSQL
|
||||
*
|
||||
* ARCHITECTURE DECISION (2024-12):
|
||||
* We use MINIMAL JWT claims. Organization and credit data should be fetched
|
||||
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
|
||||
*
|
||||
* @see https://www.better-auth.com/docs
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { jwt } from 'better-auth/plugins/jwt';
|
||||
import { organization } from 'better-auth/plugins/organization';
|
||||
import { getDb } from '../db/connection';
|
||||
import {
|
||||
organizations,
|
||||
members,
|
||||
invitations,
|
||||
} from '../db/schema/organizations.schema';
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
jwks,
|
||||
} from '../db/schema/auth.schema';
|
||||
import type { JWTPayloadContext } from './types/better-auth.types';
|
||||
|
||||
/**
|
||||
* JWT Custom Payload Interface
|
||||
*
|
||||
* MINIMAL claims only. Organization context and credits are available via:
|
||||
* - GET /organization/get-active-member - org membership & role
|
||||
* - GET /api/v1/credits/balance - credit balance
|
||||
*
|
||||
* Why minimal claims?
|
||||
* 1. Credit balance changes frequently - JWT would be stale
|
||||
* 2. Organization context available via Better Auth org plugin APIs
|
||||
* 3. Smaller tokens = better performance
|
||||
* 4. Follows Better Auth's session-based design
|
||||
*/
|
||||
export interface JWTCustomPayload {
|
||||
/** User ID (standard JWT claim) */
|
||||
sub: string;
|
||||
|
||||
/** User email */
|
||||
email: string;
|
||||
|
||||
/** User role (user, admin, service) */
|
||||
role: string;
|
||||
|
||||
/** Session ID for reference */
|
||||
sid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Better Auth instance
|
||||
*
|
||||
* @param databaseUrl - PostgreSQL connection URL
|
||||
* @returns Better Auth instance
|
||||
*/
|
||||
export function createBetterAuth(databaseUrl: string) {
|
||||
const db = getDb(databaseUrl);
|
||||
|
||||
return betterAuth({
|
||||
// Database adapter (Drizzle with PostgreSQL)
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
// Auth tables (actual Drizzle table objects)
|
||||
user: users,
|
||||
session: sessions,
|
||||
account: accounts,
|
||||
verification: verificationTokens,
|
||||
|
||||
// Organization tables
|
||||
organization: organizations,
|
||||
member: members,
|
||||
invitation: invitations,
|
||||
|
||||
// JWT plugin table
|
||||
jwks: jwks,
|
||||
},
|
||||
}),
|
||||
|
||||
// Email/password authentication only
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // Can enable later
|
||||
minPasswordLength: 12,
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update session once per day
|
||||
},
|
||||
|
||||
// Base URL for callbacks and redirects
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3001',
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
/**
|
||||
* Organization Plugin (B2B)
|
||||
*
|
||||
* Provides complete organization management:
|
||||
* - Create/update/delete organizations
|
||||
* - Invite/add/remove members
|
||||
* - Role-based access control
|
||||
* - Active organization tracking (session.activeOrganizationId)
|
||||
*
|
||||
* Client apps use these endpoints for org context:
|
||||
* - GET /organization/get-active-member
|
||||
* - GET /organization/get-active-member-role
|
||||
* - POST /organization/set-active
|
||||
*/
|
||||
organization({
|
||||
// Allow users to create their own organizations
|
||||
allowUserToCreateOrganization: true,
|
||||
|
||||
// Email invitation handler
|
||||
async sendInvitationEmail(data) {
|
||||
const { email, organization } = data;
|
||||
|
||||
// TODO: Implement email sending service
|
||||
console.log('TODO: Send invitation email', {
|
||||
to: email,
|
||||
organization: organization.name,
|
||||
invitationId: data.id,
|
||||
});
|
||||
},
|
||||
|
||||
// Custom roles and permissions
|
||||
organizationRole: {
|
||||
owner: {
|
||||
permissions: [
|
||||
'organization:update',
|
||||
'organization:delete',
|
||||
'members:invite',
|
||||
'members:remove',
|
||||
'members:update_role',
|
||||
'credits:allocate',
|
||||
'credits:view_all',
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
permissions: [
|
||||
'organization:update',
|
||||
'members:invite',
|
||||
'members:remove',
|
||||
'credits:view_all',
|
||||
],
|
||||
},
|
||||
member: {
|
||||
permissions: ['credits:view_own'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* JWT Plugin
|
||||
*
|
||||
* Generates JWT tokens with MINIMAL claims.
|
||||
*
|
||||
* DO NOT add complex claims like:
|
||||
* - credit_balance (stale after 15min, fetch via API instead)
|
||||
* - organization details (use Better Auth org plugin APIs)
|
||||
* - customer_type (derive from activeOrganizationId presence)
|
||||
*
|
||||
* Apps should call APIs for dynamic data:
|
||||
* - Credits: GET /api/v1/credits/balance
|
||||
* - Org info: GET /organization/get-active-member
|
||||
*/
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
expirationTime: '15m',
|
||||
|
||||
/**
|
||||
* Define minimal JWT payload
|
||||
*
|
||||
* Only includes static user info that doesn't change frequently.
|
||||
*/
|
||||
definePayload({ user, session }: JWTPayloadContext) {
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as { role?: string }).role || 'user',
|
||||
sid: session.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export type for Better Auth instance
|
||||
*/
|
||||
export type BetterAuthInstance = ReturnType<typeof createBetterAuth>;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for accepting an organization invitation
|
||||
*/
|
||||
export class AcceptInvitationDto {
|
||||
@IsString()
|
||||
invitationId: string;
|
||||
}
|
||||
16
services/mana-core-auth/src/auth/dto/index.ts
Normal file
16
services/mana-core-auth/src/auth/dto/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Auth DTOs Index
|
||||
*
|
||||
* Re-exports all authentication-related DTOs
|
||||
*/
|
||||
|
||||
// Core auth DTOs
|
||||
export { RegisterDto } from './register.dto';
|
||||
export { LoginDto } from './login.dto';
|
||||
export { RefreshTokenDto } from './refresh-token.dto';
|
||||
|
||||
// B2B organization DTOs
|
||||
export { RegisterB2BDto } from './register-b2b.dto';
|
||||
export { InviteEmployeeDto } from './invite-employee.dto';
|
||||
export { AcceptInvitationDto } from './accept-invitation.dto';
|
||||
export { SetActiveOrganizationDto } from './set-active-organization.dto';
|
||||
18
services/mana-core-auth/src/auth/dto/invite-employee.dto.ts
Normal file
18
services/mana-core-auth/src/auth/dto/invite-employee.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { IsEmail, IsString, IsIn } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for inviting an employee to an organization
|
||||
*
|
||||
* Only owners and admins can invite new members.
|
||||
*/
|
||||
export class InviteEmployeeDto {
|
||||
@IsString()
|
||||
organizationId: string;
|
||||
|
||||
@IsEmail()
|
||||
employeeEmail: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['admin', 'member'])
|
||||
role: 'admin' | 'member';
|
||||
}
|
||||
25
services/mana-core-auth/src/auth/dto/register-b2b.dto.ts
Normal file
25
services/mana-core-auth/src/auth/dto/register-b2b.dto.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for B2B organization registration
|
||||
*
|
||||
* Creates an organization with the registering user as owner.
|
||||
*/
|
||||
export class RegisterB2BDto {
|
||||
@IsEmail()
|
||||
ownerEmail: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
ownerName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(255)
|
||||
organizationName: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for setting the active organization
|
||||
*
|
||||
* Used to switch between organizations for users with multiple memberships.
|
||||
*/
|
||||
export class SetActiveOrganizationDto {
|
||||
@IsString()
|
||||
organizationId: string;
|
||||
}
|
||||
566
services/mana-core-auth/src/auth/jwt-validation.spec.ts
Normal file
566
services/mana-core-auth/src/auth/jwt-validation.spec.ts
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
/**
|
||||
* JWT Token Validation Tests (Minimal Claims)
|
||||
*
|
||||
* Tests for JWT token validation with minimal claims:
|
||||
* - sub (user ID)
|
||||
* - email
|
||||
* - role
|
||||
* - sid (session ID)
|
||||
*
|
||||
* ARCHITECTURE DECISION (2024-12):
|
||||
* We use MINIMAL JWT claims. Organization and credit data should be fetched
|
||||
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
|
||||
*
|
||||
* Why minimal claims?
|
||||
* 1. Credit balance changes frequently - JWT would be stale
|
||||
* 2. Organization context available via Better Auth org plugin APIs
|
||||
* 3. Smaller tokens = better performance
|
||||
* 4. Follows Better Auth's session-based design
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { JWTCustomPayload } from './better-auth.config';
|
||||
import { createMockConfigService } from '../__tests__/utils/test-helpers';
|
||||
import { mockUserFactory } from '../__tests__/utils/mock-factories';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('../db/connection');
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
||||
}));
|
||||
|
||||
describe('JWT Token Validation (Minimal Claims)', () => {
|
||||
let configService: ConfigService;
|
||||
let mockDb: any;
|
||||
let secret: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use HS256 for testing (symmetric key) for simplicity
|
||||
// In production, mana-core uses RS256 (asymmetric)
|
||||
secret = 'test-secret-key-for-jwt-validation';
|
||||
|
||||
// Create mock database
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock getDb
|
||||
const { getDb } = require('../db/connection');
|
||||
getDb.mockReturnValue(mockDb);
|
||||
|
||||
configService = createMockConfigService({
|
||||
'jwt.secret': secret,
|
||||
'jwt.issuer': 'mana-core',
|
||||
'jwt.audience': 'manacore',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Minimal JWT Claims Structure', () => {
|
||||
it('should generate token with minimal claims only', () => {
|
||||
const user = mockUserFactory.create({
|
||||
id: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
sid: 'session-abc-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
expect(decoded).toMatchObject({
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-abc-123',
|
||||
});
|
||||
|
||||
// Verify NO complex claims are present
|
||||
expect((decoded as any).customer_type).toBeUndefined();
|
||||
expect((decoded as any).organization).toBeUndefined();
|
||||
expect((decoded as any).credit_balance).toBeUndefined();
|
||||
expect((decoded as any).app_id).toBeUndefined();
|
||||
expect((decoded as any).device_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include standard JWT claims (sub, iat, exp, iss, aud)', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded: any = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
// Standard JWT claims
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect(decoded.iat).toBeGreaterThanOrEqual(now);
|
||||
expect(decoded.exp).toBeGreaterThan(decoded.iat);
|
||||
expect(decoded.iss).toBe('mana-core');
|
||||
expect(decoded.aud).toBe('manacore');
|
||||
});
|
||||
|
||||
it('should support different user roles', () => {
|
||||
const roles = ['user', 'admin', 'service'];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: `${role}-user-123`,
|
||||
email: `${role}@example.com`,
|
||||
role,
|
||||
sid: `session-${role}`,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
expect(decoded.role).toBe(role);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Validation - Security', () => {
|
||||
it('should validate HS256 signature correctly', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Should successfully verify with correct secret
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject expired tokens', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
// Create token that expires immediately
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '0s', // Expired immediately
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Wait a moment to ensure expiry
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt expired');
|
||||
resolve(true);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject tokens with wrong issuer', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'wrong-issuer', // Wrong issuer
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'mana-core', // Expect correct issuer
|
||||
audience: 'manacore',
|
||||
});
|
||||
}).toThrow('jwt issuer invalid');
|
||||
});
|
||||
|
||||
it('should reject tokens with wrong audience', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'wrong-audience', // Wrong audience
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore', // Expect correct audience
|
||||
});
|
||||
}).toThrow('jwt audience invalid');
|
||||
});
|
||||
|
||||
it('should reject tampered tokens', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Tamper with the token - try to change role to admin
|
||||
const parts = token.split('.');
|
||||
const tamperedPayload = Buffer.from(
|
||||
JSON.stringify({ ...payload, role: 'admin' })
|
||||
).toString('base64url');
|
||||
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(tamperedToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('invalid signature');
|
||||
});
|
||||
|
||||
it('should reject tokens signed with wrong secret', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
// Sign with different secret
|
||||
const token = jwt.sign(payload, 'wrong-secret-key', {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Try to verify with correct secret
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Expiration Times', () => {
|
||||
it('should use 15 minutes for access tokens', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded: any = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
const expiryTime = decoded.exp - decoded.iat;
|
||||
expect(expiryTime).toBe(15 * 60); // 15 minutes = 900 seconds
|
||||
});
|
||||
|
||||
it('should validate token is not yet valid (nbf claim)', () => {
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
notBefore: futureTime, // Not valid until 1 hour from now
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt not active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle malformed JWT gracefully', () => {
|
||||
const malformedToken = 'this.is.not.a.valid.jwt';
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(malformedToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt malformed');
|
||||
});
|
||||
|
||||
it('should handle empty token', () => {
|
||||
expect(() => {
|
||||
jwt.verify('', secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt must be provided');
|
||||
});
|
||||
|
||||
it('should handle token with missing required claims', () => {
|
||||
// Token with only sub (missing email, role, sid)
|
||||
const minimalPayload = { sub: 'user-123' };
|
||||
|
||||
const token = jwt.sign(minimalPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Token is technically valid, but application should validate claims
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect(decoded.email).toBeUndefined();
|
||||
expect(decoded.role).toBeUndefined();
|
||||
expect(decoded.sid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Refresh Behavior', () => {
|
||||
it('should issue new token with same user claims', () => {
|
||||
const originalPayload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-original',
|
||||
};
|
||||
|
||||
const originalToken = jwt.sign(originalPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Refresh creates new token with new session ID
|
||||
const refreshedPayload: JWTCustomPayload = {
|
||||
...originalPayload,
|
||||
sid: 'session-refreshed', // New session ID
|
||||
};
|
||||
|
||||
const refreshedToken = jwt.sign(refreshedPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(refreshedToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
// User claims should be maintained
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect(decoded.email).toBe('user@example.com');
|
||||
expect(decoded.role).toBe('user');
|
||||
// Session ID should be new
|
||||
expect(decoded.sid).toBe('session-refreshed');
|
||||
});
|
||||
|
||||
it('should maintain user role across refreshes', () => {
|
||||
const adminPayload: JWTCustomPayload = {
|
||||
sub: 'admin-123',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(adminPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
// Admin role should be preserved
|
||||
expect(decoded.role).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Architecture Decision Documentation', () => {
|
||||
/**
|
||||
* This test documents what is NOT in the JWT by design.
|
||||
* See docs/AUTHENTICATION_ARCHITECTURE.md for full explanation.
|
||||
*/
|
||||
it('should NOT contain organization data (fetch via API instead)', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
// Organization data should be fetched via:
|
||||
// - session.activeOrganizationId (from Better Auth session)
|
||||
// - GET /organization/get-active-member (for details)
|
||||
expect(decoded.organization).toBeUndefined();
|
||||
expect(decoded.organizationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT contain credit balance (fetch via API instead)', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
// Credit balance should be fetched via:
|
||||
// - GET /api/v1/credits/balance
|
||||
// Credit balance changes too frequently to embed in JWT
|
||||
expect(decoded.credit_balance).toBeUndefined();
|
||||
expect(decoded.credits).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT contain customer_type (derive from session instead)', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
// Customer type should be derived from:
|
||||
// - B2B = session.activeOrganizationId != null
|
||||
// - B2C = session.activeOrganizationId == null
|
||||
expect(decoded.customer_type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,999 @@
|
|||
/**
|
||||
* BetterAuthService Unit Tests
|
||||
*
|
||||
* Tests all Better Auth integration flows:
|
||||
* - B2C user registration
|
||||
* - B2B organization registration
|
||||
* - Organization member management
|
||||
* - Employee invitations
|
||||
* - Credit balance initialization
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { BetterAuthService } from './better-auth.service';
|
||||
import { createMockConfigService } from '../../__tests__/utils/test-helpers';
|
||||
|
||||
// Mock nanoid before importing factories
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
||||
}));
|
||||
|
||||
// Mock database connection
|
||||
jest.mock('../../db/connection');
|
||||
|
||||
// Import after mocks
|
||||
import { mockUserFactory } from '../../__tests__/utils/mock-factories';
|
||||
|
||||
// Mock Better Auth configuration
|
||||
const mockAuthApi = {
|
||||
signUpEmail: jest.fn(),
|
||||
createOrganization: jest.fn(),
|
||||
inviteMember: jest.fn(),
|
||||
acceptInvitation: jest.fn(),
|
||||
getFullOrganization: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
setActiveOrganization: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../better-auth.config', () => ({
|
||||
createBetterAuth: jest.fn(() => ({
|
||||
api: mockAuthApi,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('BetterAuthService', () => {
|
||||
let service: BetterAuthService;
|
||||
let configService: ConfigService;
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock database
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock getDb
|
||||
const { getDb } = require('../../db/connection');
|
||||
getDb.mockReturnValue(mockDb);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BetterAuthService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: createMockConfigService({
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BetterAuthService>(BetterAuthService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerB2C', () => {
|
||||
it('should register a new B2C user successfully', async () => {
|
||||
const registerDto = {
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'New User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({
|
||||
id: 'user-123',
|
||||
email: registerDto.email,
|
||||
name: registerDto.name,
|
||||
});
|
||||
|
||||
// Mock Better Auth signup response
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
|
||||
// Mock credit balance creation (success)
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
const result = await service.registerB2C(registerDto);
|
||||
|
||||
// Verify Better Auth API was called correctly
|
||||
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify personal credit balance was created
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-123',
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150,
|
||||
dailyFreeCredits: 5,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
})
|
||||
);
|
||||
|
||||
// Verify response structure
|
||||
expect(result).toEqual({
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
},
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if user already exists', async () => {
|
||||
const registerDto = {
|
||||
email: 'existing@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Existing User',
|
||||
};
|
||||
|
||||
// Mock Better Auth error for existing user
|
||||
mockAuthApi.signUpEmail.mockRejectedValue(
|
||||
new Error('User with this email already exists')
|
||||
);
|
||||
|
||||
await expect(service.registerB2C(registerDto)).rejects.toThrow(ConflictException);
|
||||
await expect(service.registerB2C(registerDto)).rejects.toThrow(
|
||||
'User with this email already exists'
|
||||
);
|
||||
|
||||
// Verify no credit balance was created
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should normalize email to lowercase', async () => {
|
||||
const registerDto = {
|
||||
email: 'NewUser@EXAMPLE.COM',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'New User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({
|
||||
email: 'NewUser@EXAMPLE.COM', // Better Auth should handle normalization
|
||||
});
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
});
|
||||
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2C(registerDto);
|
||||
|
||||
// Verify email was passed as-is (Better Auth normalizes internally)
|
||||
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
email: 'NewUser@EXAMPLE.COM',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create personal credit balance with signup bonus', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
});
|
||||
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2C(registerDto);
|
||||
|
||||
// Verify credit balance initialization
|
||||
expect(mockDb.values).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150, // Signup bonus
|
||||
dailyFreeCredits: 5,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue registration even if credit balance creation fails', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
});
|
||||
|
||||
// Mock database error for credit balance creation
|
||||
mockDb.returning.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Should not throw - registration should complete
|
||||
const result = await service.registerB2C(registerDto);
|
||||
|
||||
expect(result.user.id).toBe('user-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerB2B', () => {
|
||||
it('should register organization owner successfully', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({
|
||||
id: 'owner-123',
|
||||
email: registerDto.ownerEmail,
|
||||
name: registerDto.ownerName,
|
||||
});
|
||||
|
||||
const mockOrg = {
|
||||
id: 'org-123',
|
||||
name: 'Acme Corporation',
|
||||
slug: 'acme-corporation',
|
||||
};
|
||||
|
||||
// Mock user creation
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
|
||||
// Mock organization creation
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
|
||||
// Mock credit balance creation
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
const result = await service.registerB2B(registerDto);
|
||||
|
||||
// Verify user creation
|
||||
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: registerDto.ownerEmail,
|
||||
password: registerDto.password,
|
||||
name: registerDto.ownerName,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'Acme Corporation',
|
||||
slug: 'acme-corporation',
|
||||
},
|
||||
headers: {
|
||||
authorization: 'Bearer mock-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify both credit balances were created
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify response structure
|
||||
expect(result).toEqual({
|
||||
user: mockUser,
|
||||
organization: mockOrg,
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create organization credit balance', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify organization credit balance was created
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organizationId: 'org-123',
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle organization creation failure', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
|
||||
// Mock organization creation failure
|
||||
mockAuthApi.createOrganization.mockRejectedValue(
|
||||
new Error('Failed to create organization')
|
||||
);
|
||||
|
||||
await expect(service.registerB2B(registerDto)).rejects.toThrow(
|
||||
'Failed to create organization'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate valid slug from organization name', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'My Awesome Company!!!',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'My Awesome Company' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify slug was sanitized
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'my-awesome-company',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if owner email already exists', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'existing@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
mockAuthApi.signUpEmail.mockRejectedValue(
|
||||
new Error('User with this email already exists')
|
||||
);
|
||||
|
||||
await expect(service.registerB2B(registerDto)).rejects.toThrow(ConflictException);
|
||||
await expect(service.registerB2B(registerDto)).rejects.toThrow('Owner email already exists');
|
||||
|
||||
// Verify organization was never created
|
||||
expect(mockAuthApi.createOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create both organization and personal credit balances', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify two credit balances were created
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call: organization balance
|
||||
expect(mockDb.values).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
organizationId: 'org-123',
|
||||
})
|
||||
);
|
||||
|
||||
// Second call: personal balance
|
||||
expect(mockDb.values).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
userId: 'owner-123',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inviteEmployee', () => {
|
||||
it('should send invitation successfully', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'employee@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'inviter-session-token',
|
||||
};
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation-123',
|
||||
email: 'employee@example.com',
|
||||
organizationId: 'org-123',
|
||||
role: 'member',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockResolvedValue(mockInvitation);
|
||||
|
||||
const result = await service.inviteEmployee(inviteDto);
|
||||
|
||||
// Verify Better Auth API was called
|
||||
expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({
|
||||
body: {
|
||||
organizationId: 'org-123',
|
||||
email: 'employee@example.com',
|
||||
role: 'member',
|
||||
},
|
||||
headers: {
|
||||
authorization: 'Bearer inviter-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockInvitation);
|
||||
});
|
||||
|
||||
it('should pass correct role to Better Auth API', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'admin@example.com',
|
||||
role: 'admin' as const,
|
||||
inviterToken: 'inviter-token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockResolvedValue({});
|
||||
|
||||
await service.inviteEmployee(inviteDto);
|
||||
|
||||
expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
role: 'admin',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invitation to existing member', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'existing@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'inviter-token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockRejectedValue(
|
||||
new Error('User is already a member')
|
||||
);
|
||||
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
|
||||
'User is already a member'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if inviter lacks permission', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'employee@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'invalid-token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockRejectedValue(
|
||||
new Error('You do not have permission to invite members')
|
||||
);
|
||||
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(ForbiddenException);
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
|
||||
'You do not have permission to invite members'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptInvitation', () => {
|
||||
it('should accept invitation and add user to org', async () => {
|
||||
const acceptDto = {
|
||||
invitationId: 'invitation-123',
|
||||
userToken: 'user-session-token',
|
||||
};
|
||||
|
||||
const mockMembership = {
|
||||
userId: 'user-123',
|
||||
organizationId: 'org-123',
|
||||
role: 'member',
|
||||
};
|
||||
|
||||
mockAuthApi.acceptInvitation.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.acceptInvitation(acceptDto);
|
||||
|
||||
// Verify Better Auth API was called
|
||||
expect(mockAuthApi.acceptInvitation).toHaveBeenCalledWith({
|
||||
body: { invitationId: 'invitation-123' },
|
||||
headers: {
|
||||
authorization: 'Bearer user-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockMembership);
|
||||
});
|
||||
|
||||
it('should handle expired invitation', async () => {
|
||||
const acceptDto = {
|
||||
invitationId: 'expired-invitation',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
mockAuthApi.acceptInvitation.mockRejectedValue(
|
||||
new Error('Invitation expired')
|
||||
);
|
||||
|
||||
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(
|
||||
'Invitation not found or expired'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle already accepted invitation', async () => {
|
||||
const acceptDto = {
|
||||
invitationId: 'used-invitation',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
mockAuthApi.acceptInvitation.mockRejectedValue(
|
||||
new Error('Invitation not found')
|
||||
);
|
||||
|
||||
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationMembers', () => {
|
||||
it('should return list of members', async () => {
|
||||
const mockMembers = [
|
||||
{
|
||||
userId: 'user-1',
|
||||
organizationId: 'org-123',
|
||||
role: 'owner',
|
||||
name: 'John Owner',
|
||||
email: 'owner@example.com',
|
||||
},
|
||||
{
|
||||
userId: 'user-2',
|
||||
organizationId: 'org-123',
|
||||
role: 'member',
|
||||
name: 'Jane Member',
|
||||
email: 'member@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
mockAuthApi.getFullOrganization.mockResolvedValue({ members: mockMembers });
|
||||
|
||||
const result = await service.getOrganizationMembers('org-123');
|
||||
|
||||
expect(mockAuthApi.getFullOrganization).toHaveBeenCalledWith({
|
||||
query: { organizationId: 'org-123' },
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockMembers);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty organization', async () => {
|
||||
mockAuthApi.getFullOrganization.mockResolvedValue({ members: [] });
|
||||
|
||||
const result = await service.getOrganizationMembers('org-123');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockAuthApi.getFullOrganization.mockRejectedValue(
|
||||
new Error('Database error')
|
||||
);
|
||||
|
||||
const result = await service.getOrganizationMembers('org-123');
|
||||
|
||||
// Should not throw, but return empty array
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('should remove member successfully', async () => {
|
||||
const removeDto = {
|
||||
organizationId: 'org-123',
|
||||
memberId: 'user-456',
|
||||
removerToken: 'admin-token',
|
||||
};
|
||||
|
||||
mockAuthApi.removeMember.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await service.removeMember(removeDto);
|
||||
|
||||
expect(mockAuthApi.removeMember).toHaveBeenCalledWith({
|
||||
body: {
|
||||
memberIdOrEmail: 'user-456',
|
||||
organizationId: 'org-123',
|
||||
},
|
||||
headers: {
|
||||
authorization: 'Bearer admin-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Member removed successfully',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle removing non-existent member', async () => {
|
||||
const removeDto = {
|
||||
organizationId: 'org-123',
|
||||
memberId: 'non-existent',
|
||||
removerToken: 'admin-token',
|
||||
};
|
||||
|
||||
mockAuthApi.removeMember.mockRejectedValue(
|
||||
new Error('Member not found')
|
||||
);
|
||||
|
||||
await expect(service.removeMember(removeDto)).rejects.toThrow(
|
||||
'Member not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if remover lacks permission', async () => {
|
||||
const removeDto = {
|
||||
organizationId: 'org-123',
|
||||
memberId: 'user-456',
|
||||
removerToken: 'member-token', // Regular member cannot remove
|
||||
};
|
||||
|
||||
mockAuthApi.removeMember.mockRejectedValue(
|
||||
new Error('You do not have permission to remove members')
|
||||
);
|
||||
|
||||
await expect(service.removeMember(removeDto)).rejects.toThrow(ForbiddenException);
|
||||
await expect(service.removeMember(removeDto)).rejects.toThrow(
|
||||
'You do not have permission to remove members'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveOrganization', () => {
|
||||
it('should switch organization successfully', async () => {
|
||||
const setActiveDto = {
|
||||
organizationId: 'org-456',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
userId: 'user-123',
|
||||
activeOrganizationId: 'org-456',
|
||||
};
|
||||
|
||||
mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await service.setActiveOrganization(setActiveDto);
|
||||
|
||||
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
|
||||
body: { organizationId: 'org-456' },
|
||||
headers: {
|
||||
authorization: 'Bearer user-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should update session context', async () => {
|
||||
const setActiveDto = {
|
||||
organizationId: 'org-789',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
userId: 'user-123',
|
||||
activeOrganizationId: 'org-789',
|
||||
metadata: {
|
||||
previousOrg: 'org-456',
|
||||
},
|
||||
};
|
||||
|
||||
mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await service.setActiveOrganization(setActiveDto);
|
||||
|
||||
expect(result.activeOrganizationId).toBe('org-789');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for invalid organization', async () => {
|
||||
const setActiveDto = {
|
||||
organizationId: 'non-existent-org',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
mockAuthApi.setActiveOrganization.mockRejectedValue(
|
||||
new Error('Organization not found or you are not a member')
|
||||
);
|
||||
|
||||
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
|
||||
'Organization not found or you are not a member'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify (private method)', () => {
|
||||
it('should convert organization name to lowercase slug', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'My Company',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'my-company',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove special characters from slug', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Company #1 (Best!)',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'company-1-best',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace spaces with hyphens', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Multi Word Company Name',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'multi-word-company-name',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive spaces', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Company With Spaces',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'company-with-spaces',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credit Balance Initialization', () => {
|
||||
it('should initialize B2C user with signup bonus credits', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2C(registerDto);
|
||||
|
||||
// Verify credit balance was initialized with correct values
|
||||
expect(mockDb.values).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150,
|
||||
dailyFreeCredits: 5,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize organization balance with zero credits', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify organization balance was initialized
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organizationId: 'org-123',
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not fail registration if credit balance creation errors', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
|
||||
// Mock database error
|
||||
mockDb.insert.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
// Should not throw - registration should complete despite credit error
|
||||
const result = await service.registerB2C(registerDto);
|
||||
|
||||
expect(result.user.id).toBe('user-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle generic errors from Better Auth', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
mockAuthApi.signUpEmail.mockRejectedValue(
|
||||
new Error('Unexpected server error')
|
||||
);
|
||||
|
||||
await expect(service.registerB2C(registerDto)).rejects.toThrow(
|
||||
'Unexpected server error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate network errors', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'employee@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockRejectedValue(
|
||||
new Error('Network timeout')
|
||||
);
|
||||
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
|
||||
'Network timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
955
services/mana-core-auth/src/auth/services/better-auth.service.ts
Normal file
955
services/mana-core-auth/src/auth/services/better-auth.service.ts
Normal file
|
|
@ -0,0 +1,955 @@
|
|||
/**
|
||||
* Better Auth Service
|
||||
*
|
||||
* NestJS service that wraps Better Auth functionality for:
|
||||
* - B2C user registration
|
||||
* - B2B organization registration
|
||||
* - Organization member management
|
||||
* - Employee invitations
|
||||
*
|
||||
* This service uses Better Auth's organization plugin for all B2B operations,
|
||||
* eliminating the need to build custom organization management.
|
||||
*
|
||||
* @see BETTER_AUTH_FINAL_PLAN.md
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createBetterAuth, type BetterAuthInstance } from '../better-auth.config';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { balances, organizationBalances } from '../../db/schema/credits.schema';
|
||||
import {
|
||||
hasUser,
|
||||
hasToken,
|
||||
hasMember,
|
||||
hasMembers,
|
||||
hasSession,
|
||||
} from '../types/better-auth.types';
|
||||
import type {
|
||||
RegisterB2CDto,
|
||||
RegisterB2BDto,
|
||||
InviteEmployeeDto,
|
||||
AcceptInvitationDto,
|
||||
RemoveMemberDto,
|
||||
SetActiveOrganizationDto,
|
||||
SignInDto,
|
||||
RegisterB2CResult,
|
||||
RegisterB2BResult,
|
||||
InviteEmployeeResult,
|
||||
AcceptInvitationResult,
|
||||
RemoveMemberResult,
|
||||
SetActiveOrganizationResult,
|
||||
SignInResult,
|
||||
SignOutResult,
|
||||
GetSessionResult,
|
||||
ListOrganizationsResult,
|
||||
RefreshTokenResult,
|
||||
ValidateTokenResult,
|
||||
TokenPayload,
|
||||
OrganizationMember,
|
||||
Organization,
|
||||
BetterAuthAPI,
|
||||
SignUpResponse,
|
||||
SignInResponse,
|
||||
CreateOrganizationResponse,
|
||||
BetterAuthUser,
|
||||
BetterAuthSession,
|
||||
} from '../types/better-auth.types';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
// Re-export DTOs and result types for external use
|
||||
export type {
|
||||
RegisterB2CDto,
|
||||
RegisterB2BDto,
|
||||
InviteEmployeeDto,
|
||||
AcceptInvitationDto,
|
||||
RemoveMemberDto,
|
||||
SetActiveOrganizationDto,
|
||||
SignInDto,
|
||||
SignInResult,
|
||||
SignOutResult,
|
||||
GetSessionResult,
|
||||
ListOrganizationsResult,
|
||||
RefreshTokenResult,
|
||||
ValidateTokenResult,
|
||||
TokenPayload,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BetterAuthService {
|
||||
private auth: BetterAuthInstance;
|
||||
private databaseUrl: string;
|
||||
|
||||
/**
|
||||
* Typed accessor for organization plugin API methods
|
||||
* Better Auth's organization plugin adds methods dynamically, so we provide
|
||||
* a typed accessor to avoid casting throughout the service.
|
||||
*/
|
||||
private get orgApi(): BetterAuthAPI {
|
||||
return this.auth.api as unknown as BetterAuthAPI;
|
||||
}
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.databaseUrl = this.configService.get<string>('database.url')!;
|
||||
this.auth = createBetterAuth(this.databaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a B2C user (individual)
|
||||
*
|
||||
* Creates a new user account with email/password and initializes their
|
||||
* personal credit balance.
|
||||
*
|
||||
* @param dto - Registration data
|
||||
* @returns User data and session
|
||||
* @throws ConflictException if email already exists
|
||||
*/
|
||||
async registerB2C(dto: RegisterB2CDto): Promise<RegisterB2CResult> {
|
||||
try {
|
||||
// Create user via Better Auth
|
||||
const result = await this.auth.api.signUpEmail({
|
||||
body: {
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
name: dto.name,
|
||||
},
|
||||
});
|
||||
|
||||
// Use type guards for safe access
|
||||
if (!hasUser(result)) {
|
||||
throw new Error('Invalid response from Better Auth: missing user');
|
||||
}
|
||||
|
||||
const { user } = result;
|
||||
|
||||
// Create personal credit balance
|
||||
await this.createPersonalCreditBalance(user.id);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
token: hasToken(result) ? result.token : undefined,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message?.includes('already exists')) {
|
||||
throw new ConflictException('User with this email already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a B2B organization (company)
|
||||
*
|
||||
* Creates:
|
||||
* 1. Owner user account
|
||||
* 2. Organization (via Better Auth organization plugin)
|
||||
* 3. Automatic owner membership (Better Auth handles this)
|
||||
* 4. Organization credit balance
|
||||
*
|
||||
* @param dto - Organization registration data
|
||||
* @returns User, organization, and session data
|
||||
* @throws ConflictException if owner email already exists
|
||||
*/
|
||||
async registerB2B(dto: RegisterB2BDto): Promise<RegisterB2BResult> {
|
||||
try {
|
||||
// Step 1: Create owner user account
|
||||
const userResult = await this.auth.api.signUpEmail({
|
||||
body: {
|
||||
email: dto.ownerEmail,
|
||||
password: dto.password,
|
||||
name: dto.ownerName,
|
||||
},
|
||||
});
|
||||
|
||||
// Use type guards for safe access
|
||||
if (!hasUser(userResult)) {
|
||||
throw new Error('Invalid response from Better Auth: missing user');
|
||||
}
|
||||
|
||||
const { user } = userResult;
|
||||
const ownerId = user.id;
|
||||
const sessionToken = hasToken(userResult) ? userResult.token : '';
|
||||
|
||||
// Step 2: Create organization (Better Auth handles owner membership automatically)
|
||||
// Note: createOrganization is typed via BetterAuthAPI but we need to cast for org plugin methods
|
||||
const orgResult = (await this.auth.api.createOrganization({
|
||||
body: {
|
||||
name: dto.organizationName,
|
||||
slug: this.slugify(dto.organizationName),
|
||||
},
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
})) as CreateOrganizationResponse;
|
||||
|
||||
const organizationId = orgResult.id;
|
||||
|
||||
// Step 3: Create organization credit balance
|
||||
await this.createOrganizationCreditBalance(organizationId);
|
||||
|
||||
// Step 4: Create owner's personal balance (for when they use credits)
|
||||
await this.createPersonalCreditBalance(ownerId);
|
||||
|
||||
return {
|
||||
user,
|
||||
organization: orgResult,
|
||||
token: sessionToken,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message?.includes('already exists')) {
|
||||
throw new ConflictException('Owner email already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite employee to organization
|
||||
*
|
||||
* Uses Better Auth organization plugin to:
|
||||
* 1. Validate inviter has permission (owner/admin)
|
||||
* 2. Create invitation record
|
||||
* 3. Send invitation email
|
||||
*
|
||||
* @param dto - Invitation data
|
||||
* @returns Invitation record
|
||||
* @throws ForbiddenException if inviter lacks permission
|
||||
*/
|
||||
async inviteEmployee(dto: InviteEmployeeDto): Promise<InviteEmployeeResult> {
|
||||
try {
|
||||
// Better Auth organization plugin uses auth.api.inviteMember
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.orgApi.inviteMember({
|
||||
body: {
|
||||
email: dto.employeeEmail,
|
||||
role: dto.role,
|
||||
organizationId: dto.organizationId,
|
||||
},
|
||||
headers: {
|
||||
authorization: `Bearer ${dto.inviterToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
|
||||
throw new ForbiddenException('You do not have permission to invite members');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept organization invitation
|
||||
*
|
||||
* When a user accepts an invitation, Better Auth:
|
||||
* 1. Adds user to organization as member
|
||||
* 2. Sets the role from invitation
|
||||
* 3. Marks invitation as accepted
|
||||
*
|
||||
* After acceptance, we create the user's personal balance for tracking
|
||||
* their allocated credits from the organization.
|
||||
*
|
||||
* @param dto - Acceptance data
|
||||
* @returns Membership data
|
||||
* @throws NotFoundException if invitation not found or expired
|
||||
*/
|
||||
async acceptInvitation(dto: AcceptInvitationDto): Promise<AcceptInvitationResult> {
|
||||
try {
|
||||
// Better Auth organization plugin uses auth.api.acceptInvitation
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.orgApi.acceptInvitation({
|
||||
body: { invitationId: dto.invitationId },
|
||||
headers: {
|
||||
authorization: `Bearer ${dto.userToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Extract user ID from the result to create their personal balance
|
||||
// Use type guard for safe access
|
||||
const userId = hasMember(result) ? result.member.userId : undefined;
|
||||
if (userId) {
|
||||
await this.createPersonalCreditBalance(userId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes('not found') || error.message?.includes('expired')) {
|
||||
throw new NotFoundException('Invitation not found or expired');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization members
|
||||
*
|
||||
* Lists all members of an organization with their roles.
|
||||
* Uses getFullOrganization which returns org details with members.
|
||||
*
|
||||
* @param organizationId - Organization ID
|
||||
* @returns List of members
|
||||
*/
|
||||
async getOrganizationMembers(organizationId: string): Promise<OrganizationMember[]> {
|
||||
try {
|
||||
// Better Auth uses getFullOrganization to get org with members
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.orgApi.getFullOrganization({
|
||||
query: { organizationId },
|
||||
});
|
||||
|
||||
// Use type guard for safe access
|
||||
return hasMembers(result) ? result.members : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization members:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member from organization
|
||||
*
|
||||
* Uses Better Auth to:
|
||||
* 1. Validate remover has permission (owner/admin)
|
||||
* 2. Remove member from organization
|
||||
* 3. Clean up member's access
|
||||
*
|
||||
* @param dto - Remove member data
|
||||
* @returns Success status
|
||||
* @throws ForbiddenException if remover lacks permission
|
||||
*/
|
||||
async removeMember(dto: RemoveMemberDto): Promise<RemoveMemberResult> {
|
||||
try {
|
||||
// Better Auth organization plugin uses auth.api.removeMember
|
||||
// Accepts memberIdOrEmail parameter
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
await this.orgApi.removeMember({
|
||||
body: {
|
||||
memberIdOrEmail: dto.memberId,
|
||||
organizationId: dto.organizationId,
|
||||
},
|
||||
headers: {
|
||||
authorization: `Bearer ${dto.removerToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: 'Member removed successfully' };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
|
||||
throw new ForbiddenException('You do not have permission to remove members');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization for user
|
||||
*
|
||||
* For users who belong to multiple organizations, this switches
|
||||
* the active organization context. The active organization is used
|
||||
* for JWT claims and credit balance calculations.
|
||||
*
|
||||
* @param dto - Active organization data
|
||||
* @returns Updated session data
|
||||
*/
|
||||
async setActiveOrganization(dto: SetActiveOrganizationDto): Promise<SetActiveOrganizationResult> {
|
||||
try {
|
||||
// Better Auth organization plugin uses auth.api.setActiveOrganization
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.orgApi.setActiveOrganization({
|
||||
body: { organizationId: dto.organizationId },
|
||||
headers: {
|
||||
authorization: `Bearer ${dto.userToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes('not found') || error.message?.includes('not a member')) {
|
||||
throw new NotFoundException('Organization not found or you are not a member');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Authentication Methods (Sign In / Sign Out / Session)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Sign in user with email and password
|
||||
*
|
||||
* Authenticates a user and returns their session with JWT token.
|
||||
*
|
||||
* @param dto - Sign in credentials
|
||||
* @returns User data and authentication token
|
||||
* @throws UnauthorizedException if credentials are invalid
|
||||
*/
|
||||
async signIn(dto: SignInDto): Promise<SignInResult> {
|
||||
try {
|
||||
const result = await this.auth.api.signInEmail({
|
||||
body: {
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasUser(result)) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const { user } = result;
|
||||
|
||||
// Get session token (used as refresh token)
|
||||
const session = hasSession(result) ? result.session : null;
|
||||
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
|
||||
|
||||
// Generate JWT access token using Better Auth's JWT plugin
|
||||
let accessToken = '';
|
||||
try {
|
||||
const api = this.auth.api as any;
|
||||
|
||||
// Use Better Auth's signJWT with the jwks table
|
||||
const jwtResult = await api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
accessToken = jwtResult?.token || '';
|
||||
|
||||
// Fallback to manual JWT if Better Auth fails
|
||||
if (!accessToken) {
|
||||
throw new Error('Better Auth signJWT returned empty token');
|
||||
}
|
||||
} catch (jwtError) {
|
||||
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
|
||||
|
||||
// Fallback: Generate JWT manually using jsonwebtoken
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[signIn] Private key exists:', !!privateKey);
|
||||
console.log('[signIn] Private key length:', privateKey?.length);
|
||||
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
|
||||
console.log('[signIn] Issuer:', issuer);
|
||||
console.log('[signIn] Audience:', audience);
|
||||
|
||||
if (privateKey) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
};
|
||||
|
||||
accessToken = jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '15m',
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
|
||||
// Decode to verify
|
||||
const decoded = jwt.decode(accessToken, { complete: true });
|
||||
console.log('[signIn] Generated JWT header:', decoded?.header);
|
||||
console.log('[signIn] Generated JWT payload:', decoded?.payload);
|
||||
} else {
|
||||
console.error('[signIn] No JWT private key configured');
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: (user as BetterAuthUser).role,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken: sessionToken,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
error.message?.includes('credentials') ||
|
||||
error.message?.includes('not found')
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out user
|
||||
*
|
||||
* Invalidates the user's session.
|
||||
*
|
||||
* @param token - User's authentication token
|
||||
* @returns Success status
|
||||
*/
|
||||
async signOut(token: string): Promise<SignOutResult> {
|
||||
try {
|
||||
// Better Auth uses auth.api.signOut
|
||||
await (this.auth.api as any).signOut({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: 'Signed out successfully' };
|
||||
} catch (error: unknown) {
|
||||
// Even if signOut fails, we treat it as success for the user
|
||||
// The session will expire naturally
|
||||
console.error('Error during sign out:', error);
|
||||
return { success: true, message: 'Signed out successfully' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*
|
||||
* Retrieves the current user's session data.
|
||||
*
|
||||
* @param token - User's authentication token
|
||||
* @returns User and session data
|
||||
* @throws UnauthorizedException if session is invalid
|
||||
*/
|
||||
async getSession(token: string): Promise<GetSessionResult> {
|
||||
try {
|
||||
// Better Auth uses auth.api.getSession
|
||||
const result = await (this.auth.api as any).getSession({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasSession(result)) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: result.session,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
error.message?.includes('expired') ||
|
||||
error.message?.includes('not found')
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's organizations
|
||||
*
|
||||
* Returns all organizations the user is a member of.
|
||||
*
|
||||
* @param token - User's authentication token
|
||||
* @returns List of organizations
|
||||
*/
|
||||
async listOrganizations(token: string): Promise<ListOrganizationsResult> {
|
||||
try {
|
||||
const result = await this.orgApi.listOrganizations({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Result is an array of organizations
|
||||
const organizations = Array.isArray(result) ? result : [];
|
||||
|
||||
return { organizations };
|
||||
} catch (error: unknown) {
|
||||
console.error('Error listing organizations:', error);
|
||||
return { organizations: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization by ID
|
||||
*
|
||||
* Returns the full organization details including members.
|
||||
*
|
||||
* @param organizationId - Organization ID
|
||||
* @param token - User's authentication token (optional for public orgs)
|
||||
* @returns Organization with members
|
||||
* @throws NotFoundException if organization not found
|
||||
*/
|
||||
async getOrganization(
|
||||
organizationId: string,
|
||||
token?: string
|
||||
): Promise<Organization & { members?: OrganizationMember[] }> {
|
||||
try {
|
||||
const result = await this.orgApi.getFullOrganization({
|
||||
query: { organizationId },
|
||||
...(token && {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
} as any);
|
||||
|
||||
if (!result || !result.id) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
slug: result.slug,
|
||||
logo: result.logo,
|
||||
metadata: result.metadata,
|
||||
createdAt: result.createdAt,
|
||||
members: hasMembers(result) ? result.members : undefined,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes('not found')) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Management Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*
|
||||
* Validates the refresh token and issues new access/refresh tokens.
|
||||
* Implements refresh token rotation for security.
|
||||
*
|
||||
* @param refreshToken - The refresh token to validate
|
||||
* @returns New access token, refresh token, and user data
|
||||
* @throws UnauthorizedException if refresh token is invalid or expired
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<RefreshTokenResult> {
|
||||
const db = getDb(this.databaseUrl);
|
||||
|
||||
try {
|
||||
// Import sessions schema for refresh token lookup
|
||||
const { sessions } = await import('../../db/schema');
|
||||
const { users } = await import('../../db/schema');
|
||||
const { eq, and, isNull } = await import('drizzle-orm');
|
||||
const { nanoid } = await import('nanoid');
|
||||
const { randomUUID } = await import('crypto');
|
||||
|
||||
// Find session by refresh token
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Check if refresh token is expired
|
||||
if (!session.refreshTokenExpiresAt || new Date() > session.refreshTokenExpiresAt) {
|
||||
throw new UnauthorizedException('Refresh token expired');
|
||||
}
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Revoke old session (refresh token rotation)
|
||||
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
|
||||
|
||||
// Generate new session
|
||||
const sessionId = randomUUID();
|
||||
const newRefreshToken = nanoid(64);
|
||||
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
token: sessionId,
|
||||
refreshToken: newRefreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
deviceId: session.deviceId,
|
||||
deviceName: session.deviceName,
|
||||
expiresAt: accessTokenExpiresAt,
|
||||
});
|
||||
|
||||
// Generate new JWT
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
if (!privateKey) {
|
||||
throw new Error('JWT private key not configured');
|
||||
}
|
||||
|
||||
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
|
||||
const tokenPayload: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
sessionId,
|
||||
...(session.deviceId && { deviceId: session.deviceId }),
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(tokenPayload, privateKey, {
|
||||
algorithm: 'RS256' as const,
|
||||
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
||||
...(issuer && { issuer }),
|
||||
...(audience && { audience }),
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
error.message?.includes('expired') ||
|
||||
error.message?.includes('not found')
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a JWT token
|
||||
*
|
||||
* Verifies the token signature and expiration.
|
||||
* Returns the decoded payload if valid.
|
||||
*
|
||||
* @param token - The JWT token to validate
|
||||
* @returns Validation result with payload or error
|
||||
*/
|
||||
async validateToken(token: string): Promise<ValidateTokenResult> {
|
||||
try {
|
||||
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
// Decode to check the algorithm
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
console.log('[validateToken] Decoded header:', decoded?.header);
|
||||
|
||||
// Use our JWKS endpoint (NestJS prefix: /api/v1)
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
|
||||
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
|
||||
|
||||
// Create JWKS fetcher
|
||||
const JWKS = createRemoteJWKSet(jwksUrl);
|
||||
|
||||
// Get issuer/audience from config (Better Auth uses BASE_URL by default)
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
|
||||
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
|
||||
|
||||
console.log('[validateToken] Issuer:', issuer);
|
||||
console.log('[validateToken] Audience:', audience);
|
||||
|
||||
// Verify using jose library with Better Auth's JWKS
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[validateToken] Verification SUCCESS');
|
||||
console.log('[validateToken] Payload:', payload);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
payload: payload as unknown as TokenPayload,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[validateToken] Verification FAILED:', errorMessage);
|
||||
return {
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWKS (JSON Web Key Set)
|
||||
*
|
||||
* Returns public keys for JWT verification.
|
||||
* Proxies to Better Auth's internal JWKS.
|
||||
*
|
||||
* @returns JWKS with public keys
|
||||
*/
|
||||
async getJwks(): Promise<{ keys: unknown[] }> {
|
||||
try {
|
||||
// Better Auth exposes JWKS via auth.api
|
||||
const api = this.auth.api as any;
|
||||
|
||||
// Try to get JWKS from Better Auth
|
||||
if (api.getJwks) {
|
||||
const result = await api.getJwks();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: read from jwks table directly
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { jwks } = await import('../../db/schema/auth.schema');
|
||||
const keys = await db.select().from(jwks);
|
||||
|
||||
// Convert to JWKS format (EdDSA public keys)
|
||||
return {
|
||||
keys: keys.map((key) => {
|
||||
try {
|
||||
return JSON.parse(key.publicKey);
|
||||
} catch {
|
||||
return { kid: key.id, publicKey: key.publicKey };
|
||||
}
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getJwks] Error:', error);
|
||||
return { keys: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create personal credit balance for user
|
||||
*
|
||||
* Initializes a user's credit balance with:
|
||||
* - 0 purchased credits
|
||||
* - 150 free signup credits
|
||||
* - 5 daily free credits
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @private
|
||||
*/
|
||||
private async createPersonalCreditBalance(userId: string) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
|
||||
try {
|
||||
await db.insert(balances).values({
|
||||
userId: userId as any, // Cast to handle UUID type
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150, // Signup bonus
|
||||
dailyFreeCredits: 5,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating personal credit balance:', error);
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization credit balance
|
||||
*
|
||||
* Initializes an organization's credit pool with:
|
||||
* - 0 purchased credits
|
||||
* - 0 allocated credits
|
||||
* - 0 available credits
|
||||
*
|
||||
* The organization owner must purchase credits before allocating to employees.
|
||||
*
|
||||
* @param organizationId - Organization ID
|
||||
* @private
|
||||
*/
|
||||
private async createOrganizationCreditBalance(organizationId: string) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
|
||||
try {
|
||||
await db.insert(organizationBalances).values({
|
||||
organizationId,
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating organization credit balance:', error);
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create URL-safe slugs
|
||||
*
|
||||
* Converts organization name to lowercase, URL-safe slug.
|
||||
* Example: "Acme Corporation" -> "acme-corporation"
|
||||
*
|
||||
* @param text - Text to slugify
|
||||
* @returns URL-safe slug
|
||||
* @private
|
||||
*/
|
||||
private slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/--+/g, '-') // Replace multiple hyphens with single
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
600
services/mana-core-auth/src/auth/types/better-auth.types.ts
Normal file
600
services/mana-core-auth/src/auth/types/better-auth.types.ts
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
/**
|
||||
* Better Auth Type Definitions
|
||||
*
|
||||
* This file provides types for Better Auth integration.
|
||||
*
|
||||
* STRATEGY: Import base types from Better Auth packages, extend only when needed.
|
||||
*
|
||||
* From 'better-auth/types':
|
||||
* - User, Session, Account, Auth, BetterAuthOptions, etc.
|
||||
*
|
||||
* From 'better-auth/plugins/organization':
|
||||
* - Organization, Member, Invitation, OrganizationRole, InvitationStatus
|
||||
*
|
||||
* This file defines:
|
||||
* 1. Extended types (adding fields Better Auth doesn't have)
|
||||
* 2. API response/request types for our service layer
|
||||
* 3. Service-specific DTOs and result types
|
||||
* 4. Type guards for runtime safety
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/concepts/typescript
|
||||
* @see https://www.better-auth.com/docs/plugins/organization
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Import core types from Better Auth packages
|
||||
// =============================================================================
|
||||
import type { User, Session } from 'better-auth/types';
|
||||
import type {
|
||||
Organization as BetterAuthOrganization,
|
||||
Member as BetterAuthMember,
|
||||
Invitation as BetterAuthInvitation,
|
||||
OrganizationRole as BetterAuthOrganizationRole,
|
||||
InvitationStatus as BetterAuthInvitationStatus,
|
||||
} from 'better-auth/plugins/organization';
|
||||
|
||||
// Re-export base types for convenience
|
||||
export type { User, Session };
|
||||
export type {
|
||||
BetterAuthOrganization,
|
||||
BetterAuthMember,
|
||||
BetterAuthInvitation,
|
||||
BetterAuthOrganizationRole,
|
||||
BetterAuthInvitationStatus,
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended User type with our additional fields
|
||||
* Better Auth's User type is the base, we extend it for our app
|
||||
*/
|
||||
export interface BetterAuthUser extends User {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended Session type with organization support
|
||||
* Better Auth's Session type is the base, organization plugin adds activeOrganizationId
|
||||
*/
|
||||
export interface BetterAuthSession extends Session {
|
||||
activeOrganizationId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Payload context passed to definePayload
|
||||
*/
|
||||
export interface JWTPayloadContext {
|
||||
user: BetterAuthUser;
|
||||
session: BetterAuthSession;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Types (aligned with Better Auth but with explicit fields)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Organization entity - mirrors Better Auth's Organization type
|
||||
* We define explicitly to ensure type safety in our service layer
|
||||
*/
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization member - mirrors Better Auth's Member type
|
||||
*/
|
||||
export interface OrganizationMember {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization role types - aligned with Better Auth defaults
|
||||
*/
|
||||
export type OrganizationRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
/**
|
||||
* Organization invitation - mirrors Better Auth's Invitation type
|
||||
*/
|
||||
export interface OrganizationInvitation {
|
||||
id: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'expired';
|
||||
inviterId: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Response Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sign up response from Better Auth
|
||||
*/
|
||||
export interface SignUpResponse {
|
||||
user: BetterAuthUser;
|
||||
token?: string;
|
||||
session?: BetterAuthSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in response from Better Auth
|
||||
*/
|
||||
export interface SignInResponse {
|
||||
user: BetterAuthUser;
|
||||
token: string;
|
||||
session: BetterAuthSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization response
|
||||
*/
|
||||
export interface CreateOrganizationResponse extends Organization {
|
||||
// Organization fields are returned directly
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite member response
|
||||
*/
|
||||
export interface InviteMemberResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
status: 'pending';
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation response
|
||||
*/
|
||||
export interface AcceptInvitationResponse {
|
||||
member: OrganizationMember;
|
||||
organization: Organization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full organization response
|
||||
*/
|
||||
export interface GetFullOrganizationResponse extends Organization {
|
||||
members: Array<OrganizationMember & { user?: BetterAuthUser }>;
|
||||
invitations?: OrganizationInvitation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization response
|
||||
*/
|
||||
export interface SetActiveOrganizationResponse {
|
||||
userId: string;
|
||||
activeOrganizationId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
session?: BetterAuthSession;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Request Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sign up request body
|
||||
*/
|
||||
export interface SignUpEmailBody {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization request body
|
||||
*/
|
||||
export interface CreateOrganizationBody {
|
||||
name: string;
|
||||
slug: string;
|
||||
logo?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite member request body
|
||||
*/
|
||||
export interface InviteMemberBody {
|
||||
email: string;
|
||||
role: OrganizationRole;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation request body
|
||||
*/
|
||||
export interface AcceptInvitationBody {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member request body
|
||||
*/
|
||||
export interface RemoveMemberBody {
|
||||
memberIdOrEmail: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization request body
|
||||
*/
|
||||
export interface SetActiveOrganizationBody {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full organization query
|
||||
*/
|
||||
export interface GetFullOrganizationQuery {
|
||||
organizationId?: string;
|
||||
organizationSlug?: string;
|
||||
membersLimit?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Method Types (with headers)
|
||||
// =============================================================================
|
||||
|
||||
export interface AuthenticatedRequest<TBody = unknown, TQuery = unknown> {
|
||||
body?: TBody;
|
||||
query?: TQuery;
|
||||
headers: {
|
||||
authorization: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Better Auth API Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Typed Better Auth API interface
|
||||
*
|
||||
* This interface describes the methods available on auth.api
|
||||
* when using the organization plugin.
|
||||
*/
|
||||
export interface BetterAuthAPI {
|
||||
// Core auth methods
|
||||
signUpEmail(params: { body: SignUpEmailBody }): Promise<SignUpResponse>;
|
||||
signInEmail(params: { body: { email: string; password: string } }): Promise<SignInResponse>;
|
||||
|
||||
// Organization methods
|
||||
createOrganization(
|
||||
params: AuthenticatedRequest<CreateOrganizationBody>
|
||||
): Promise<CreateOrganizationResponse>;
|
||||
|
||||
inviteMember(params: AuthenticatedRequest<InviteMemberBody>): Promise<InviteMemberResponse>;
|
||||
|
||||
acceptInvitation(
|
||||
params: AuthenticatedRequest<AcceptInvitationBody>
|
||||
): Promise<AcceptInvitationResponse>;
|
||||
|
||||
getFullOrganization(params: {
|
||||
query: GetFullOrganizationQuery;
|
||||
}): Promise<GetFullOrganizationResponse>;
|
||||
|
||||
removeMember(params: AuthenticatedRequest<RemoveMemberBody>): Promise<{ success: boolean }>;
|
||||
|
||||
setActiveOrganization(
|
||||
params: AuthenticatedRequest<SetActiveOrganizationBody>
|
||||
): Promise<SetActiveOrganizationResponse>;
|
||||
|
||||
listOrganizations(params: AuthenticatedRequest): Promise<Organization[]>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Service Response Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* B2C Registration result
|
||||
*/
|
||||
export interface RegisterB2CResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* B2B Registration result
|
||||
*/
|
||||
export interface RegisterB2BResult {
|
||||
user: BetterAuthUser;
|
||||
organization: Organization;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite employee result
|
||||
*/
|
||||
export interface InviteEmployeeResult {
|
||||
id: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
status: 'pending';
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation result
|
||||
*/
|
||||
export interface AcceptInvitationResult {
|
||||
member: OrganizationMember;
|
||||
organization?: Organization;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member result
|
||||
*/
|
||||
export interface RemoveMemberResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization result
|
||||
* Returns session data with the active organization ID
|
||||
*/
|
||||
export interface SetActiveOrganizationResult {
|
||||
userId: string;
|
||||
activeOrganizationId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
session?: BetterAuthSession;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DTO Types (for NestJS controllers)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* DTO for B2C user registration
|
||||
*/
|
||||
export interface RegisterB2CDto {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for B2B organization registration
|
||||
*/
|
||||
export interface RegisterB2BDto {
|
||||
ownerEmail: string;
|
||||
password: string;
|
||||
ownerName: string;
|
||||
organizationName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for employee invitation
|
||||
*/
|
||||
export interface InviteEmployeeDto {
|
||||
organizationId: string;
|
||||
employeeEmail: string;
|
||||
role: 'admin' | 'member';
|
||||
inviterToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for accepting invitation
|
||||
*/
|
||||
export interface AcceptInvitationDto {
|
||||
invitationId: string;
|
||||
userToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for removing organization member
|
||||
*/
|
||||
export interface RemoveMemberDto {
|
||||
organizationId: string;
|
||||
memberId: string;
|
||||
removerToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for setting active organization
|
||||
*/
|
||||
export interface SetActiveOrganizationDto {
|
||||
organizationId: string;
|
||||
userToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for user sign in
|
||||
*/
|
||||
export interface SignInDto {
|
||||
email: string;
|
||||
password: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in result
|
||||
*/
|
||||
export interface SignInResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role?: string;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for sign out
|
||||
*/
|
||||
export interface SignOutDto {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out result
|
||||
*/
|
||||
export interface SignOutResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session result
|
||||
*/
|
||||
export interface GetSessionResult {
|
||||
user: BetterAuthUser;
|
||||
session: BetterAuthSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* List user organizations result
|
||||
*/
|
||||
export interface ListOrganizationsResult {
|
||||
organizations: Organization[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for refresh token
|
||||
*/
|
||||
export interface RefreshTokenDto {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token result
|
||||
*/
|
||||
export interface RefreshTokenResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role?: string;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for token validation
|
||||
*/
|
||||
export interface ValidateTokenDto {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token payload structure (JWT claims)
|
||||
*/
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId: string;
|
||||
deviceId?: string;
|
||||
organizationId?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token result
|
||||
*/
|
||||
export interface ValidateTokenResult {
|
||||
valid: boolean;
|
||||
payload?: TokenPayload;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type Guards
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to check if response has user property
|
||||
*/
|
||||
export function hasUser(response: unknown): response is { user: BetterAuthUser } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'user' in response &&
|
||||
typeof (response as { user: unknown }).user === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has token property
|
||||
*/
|
||||
export function hasToken(response: unknown): response is { token: string } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'token' in response &&
|
||||
typeof (response as { token: unknown }).token === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has member property
|
||||
*/
|
||||
export function hasMember(response: unknown): response is { member: OrganizationMember } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'member' in response &&
|
||||
typeof (response as { member: unknown }).member === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has members array
|
||||
*/
|
||||
export function hasMembers(response: unknown): response is { members: OrganizationMember[] } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'members' in response &&
|
||||
Array.isArray((response as { members: unknown }).members)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has session property
|
||||
*/
|
||||
export function hasSession(
|
||||
response: unknown
|
||||
): response is { user: BetterAuthUser; session: BetterAuthSession } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'user' in response &&
|
||||
'session' in response &&
|
||||
typeof (response as { user: unknown }).user === 'object' &&
|
||||
typeof (response as { session: unknown }).session === 'object'
|
||||
);
|
||||
}
|
||||
7
services/mana-core-auth/src/auth/types/index.ts
Normal file
7
services/mana-core-auth/src/auth/types/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Auth Types Index
|
||||
*
|
||||
* Re-exports all authentication-related types
|
||||
*/
|
||||
|
||||
export * from './better-auth.types';
|
||||
|
|
@ -7,8 +7,9 @@ export default () => ({
|
|||
},
|
||||
|
||||
jwt: {
|
||||
publicKey: process.env.JWT_PUBLIC_KEY || '',
|
||||
privateKey: process.env.JWT_PRIVATE_KEY || '',
|
||||
// Convert \n string literals to actual newlines for PEM format
|
||||
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
|
|
|
|||
764
services/mana-core-auth/src/credits/credits.controller.spec.ts
Normal file
764
services/mana-core-auth/src/credits/credits.controller.spec.ts
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
/**
|
||||
* CreditsController Unit Tests
|
||||
*
|
||||
* Tests all credits controller endpoints:
|
||||
*
|
||||
* B2C (Personal) Endpoints:
|
||||
* - GET /credits/balance - Get user balance
|
||||
* - POST /credits/use - Use credits
|
||||
* - GET /credits/transactions - Get transaction history
|
||||
* - GET /credits/purchases - Get purchase history
|
||||
* - GET /credits/packages - Get available packages
|
||||
*
|
||||
* B2B (Organization) Endpoints:
|
||||
* - POST /credits/organization/allocate - Allocate credits to employee
|
||||
* - GET /credits/organization/:orgId/balance - Get org balance
|
||||
* - GET /credits/organization/:orgId/employee/:empId/balance - Get employee balance
|
||||
* - POST /credits/organization/:orgId/use - Use credits with org tracking
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { CreditsController } from './credits.controller';
|
||||
import { CreditsService } from './credits.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
mockBalanceFactory,
|
||||
mockTransactionFactory,
|
||||
mockPackageFactory,
|
||||
mockPurchaseFactory,
|
||||
mockOrganizationBalanceFactory,
|
||||
mockDtoFactory,
|
||||
} from '../__tests__/utils/mock-factories';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
describe('CreditsController', () => {
|
||||
let controller: CreditsController;
|
||||
let creditsService: jest.Mocked<CreditsService>;
|
||||
|
||||
// Common test user data
|
||||
const mockUser: CurrentUserData = {
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
const mockOrgOwner: CurrentUserData = {
|
||||
userId: 'owner-456',
|
||||
email: 'owner@company.com',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock CreditsService
|
||||
const mockCreditsService = {
|
||||
getBalance: jest.fn(),
|
||||
useCredits: jest.fn(),
|
||||
getTransactionHistory: jest.fn(),
|
||||
getPurchaseHistory: jest.fn(),
|
||||
getPackages: jest.fn(),
|
||||
allocateCredits: jest.fn(),
|
||||
getOrganizationBalance: jest.fn(),
|
||||
getEmployeeCreditBalance: jest.fn(),
|
||||
deductCredits: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CreditsController],
|
||||
providers: [
|
||||
{
|
||||
provide: CreditsService,
|
||||
useValue: mockCreditsService,
|
||||
},
|
||||
],
|
||||
})
|
||||
// Override the guard to allow all requests in tests
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<CreditsController>(CreditsController);
|
||||
creditsService = module.get(CreditsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// B2C ENDPOINTS - Personal Credits
|
||||
// ============================================================================
|
||||
|
||||
describe('B2C Endpoints', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// GET /credits/balance
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('GET /credits/balance', () => {
|
||||
it('should return user balance', async () => {
|
||||
const expectedBalance = mockBalanceFactory.withBalance(mockUser.userId, 500, 100);
|
||||
|
||||
creditsService.getBalance.mockResolvedValue(expectedBalance);
|
||||
|
||||
const result = await controller.getBalance(mockUser);
|
||||
|
||||
expect(result).toEqual(expectedBalance);
|
||||
expect(creditsService.getBalance).toHaveBeenCalledWith(mockUser.userId);
|
||||
});
|
||||
|
||||
it('should return zero balance for new user', async () => {
|
||||
const newUserBalance = mockBalanceFactory.create(mockUser.userId, {
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150,
|
||||
});
|
||||
|
||||
creditsService.getBalance.mockResolvedValue(newUserBalance);
|
||||
|
||||
const result = await controller.getBalance(mockUser);
|
||||
|
||||
expect(result.balance).toBe(0);
|
||||
expect(result.freeCreditsRemaining).toBe(150);
|
||||
});
|
||||
|
||||
it('should handle balance with daily free credits', async () => {
|
||||
const balanceWithDailyCredits = mockBalanceFactory.create(mockUser.userId, {
|
||||
balance: 100,
|
||||
freeCreditsRemaining: 50,
|
||||
dailyFreeCredits: 5,
|
||||
});
|
||||
|
||||
creditsService.getBalance.mockResolvedValue(balanceWithDailyCredits);
|
||||
|
||||
const result = await controller.getBalance(mockUser);
|
||||
|
||||
expect(result.dailyFreeCredits).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// POST /credits/use
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('POST /credits/use', () => {
|
||||
it('should successfully use credits', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 10,
|
||||
appId: 'memoro',
|
||||
description: 'AI transcription',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
success: true,
|
||||
transaction: mockTransactionFactory.create(mockUser.userId, {
|
||||
amount: -10,
|
||||
appId: 'memoro',
|
||||
}),
|
||||
newBalance: 90,
|
||||
};
|
||||
|
||||
creditsService.useCredits.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.useCredits(mockUser, useCreditsDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(creditsService.useCredits).toHaveBeenCalledWith(mockUser.userId, useCreditsDto);
|
||||
});
|
||||
|
||||
it('should pass idempotency key for duplicate prevention', async () => {
|
||||
const idempotencyKey = `idempotency-${nanoid()}`;
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 25,
|
||||
appId: 'chat',
|
||||
description: 'Message generation',
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
creditsService.useCredits.mockResolvedValue({ success: true } as any);
|
||||
|
||||
await controller.useCredits(mockUser, useCreditsDto);
|
||||
|
||||
expect(creditsService.useCredits).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
expect.objectContaining({ idempotencyKey })
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate BadRequestException for insufficient credits', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 1000,
|
||||
appId: 'picture',
|
||||
description: 'Image generation',
|
||||
});
|
||||
|
||||
creditsService.useCredits.mockRejectedValue(
|
||||
new BadRequestException('Insufficient credits')
|
||||
);
|
||||
|
||||
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle metadata in credit usage', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 5,
|
||||
appId: 'wisekeep',
|
||||
description: 'Video analysis',
|
||||
metadata: {
|
||||
videoId: 'vid-123',
|
||||
duration: 120,
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
creditsService.useCredits.mockResolvedValue({ success: true } as any);
|
||||
|
||||
await controller.useCredits(mockUser, useCreditsDto);
|
||||
|
||||
expect(creditsService.useCredits).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
videoId: 'vid-123',
|
||||
duration: 120,
|
||||
model: 'gpt-4',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GET /credits/transactions
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('GET /credits/transactions', () => {
|
||||
it('should return transaction history with default pagination', async () => {
|
||||
const transactions = mockTransactionFactory.createMany(mockUser.userId, 5);
|
||||
|
||||
creditsService.getTransactionHistory.mockResolvedValue(transactions as any);
|
||||
|
||||
const result = await controller.getTransactionHistory(mockUser);
|
||||
|
||||
expect(result).toEqual(transactions);
|
||||
expect(creditsService.getTransactionHistory).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass limit parameter', async () => {
|
||||
const limit = 10;
|
||||
|
||||
creditsService.getTransactionHistory.mockResolvedValue([]);
|
||||
|
||||
await controller.getTransactionHistory(mockUser, limit);
|
||||
|
||||
expect(creditsService.getTransactionHistory).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
limit,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass offset parameter', async () => {
|
||||
const limit = 20;
|
||||
const offset = 40;
|
||||
|
||||
creditsService.getTransactionHistory.mockResolvedValue([]);
|
||||
|
||||
await controller.getTransactionHistory(mockUser, limit, offset);
|
||||
|
||||
expect(creditsService.getTransactionHistory).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array for user with no transactions', async () => {
|
||||
creditsService.getTransactionHistory.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getTransactionHistory(mockUser);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GET /credits/purchases
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('GET /credits/purchases', () => {
|
||||
it('should return purchase history', async () => {
|
||||
const packageId = 'pkg-123';
|
||||
const purchases = [
|
||||
mockPurchaseFactory.create(mockUser.userId, packageId, {
|
||||
credits: 100,
|
||||
priceEuroCents: 100,
|
||||
}),
|
||||
mockPurchaseFactory.create(mockUser.userId, packageId, {
|
||||
credits: 500,
|
||||
priceEuroCents: 450,
|
||||
}),
|
||||
];
|
||||
|
||||
creditsService.getPurchaseHistory.mockResolvedValue(purchases as any);
|
||||
|
||||
const result = await controller.getPurchaseHistory(mockUser);
|
||||
|
||||
expect(result).toEqual(purchases);
|
||||
expect(creditsService.getPurchaseHistory).toHaveBeenCalledWith(mockUser.userId);
|
||||
});
|
||||
|
||||
it('should return empty array for user with no purchases', async () => {
|
||||
creditsService.getPurchaseHistory.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getPurchaseHistory(mockUser);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GET /credits/packages
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('GET /credits/packages', () => {
|
||||
it('should return all available packages', async () => {
|
||||
const packages = mockPackageFactory.createMany(3);
|
||||
|
||||
creditsService.getPackages.mockResolvedValue(packages);
|
||||
|
||||
const result = await controller.getPackages();
|
||||
|
||||
expect(result).toEqual(packages);
|
||||
expect(creditsService.getPackages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return only active packages', async () => {
|
||||
const activePackages = mockPackageFactory.createMany(2).map((pkg) => ({
|
||||
...pkg,
|
||||
active: true,
|
||||
}));
|
||||
|
||||
creditsService.getPackages.mockResolvedValue(activePackages);
|
||||
|
||||
const result = await controller.getPackages();
|
||||
|
||||
expect(result.every((pkg: any) => pkg.active === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array when no packages available', async () => {
|
||||
creditsService.getPackages.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getPackages();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// B2B ENDPOINTS - Organization Credits
|
||||
// ============================================================================
|
||||
|
||||
describe('B2B Endpoints', () => {
|
||||
const organizationId = 'org-123';
|
||||
const employeeId = 'emp-789';
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// POST /credits/organization/allocate
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('POST /credits/organization/allocate', () => {
|
||||
it('should successfully allocate credits to employee', async () => {
|
||||
const allocateDto = {
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 100,
|
||||
reason: 'Monthly allocation',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
success: true,
|
||||
allocation: {
|
||||
id: 'alloc-123',
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 100,
|
||||
allocatedBy: mockOrgOwner.userId,
|
||||
},
|
||||
newOrgBalance: 900,
|
||||
newEmployeeBalance: 100,
|
||||
};
|
||||
|
||||
creditsService.allocateCredits.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.allocateCredits(mockOrgOwner, allocateDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(creditsService.allocateCredits).toHaveBeenCalledWith(
|
||||
mockOrgOwner.userId,
|
||||
allocateDto
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate ForbiddenException for non-owners', async () => {
|
||||
const allocateDto = {
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 50,
|
||||
};
|
||||
|
||||
creditsService.allocateCredits.mockRejectedValue(
|
||||
new ForbiddenException('Only organization owners can allocate credits')
|
||||
);
|
||||
|
||||
await expect(controller.allocateCredits(mockUser, allocateDto)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate BadRequestException for insufficient org credits', async () => {
|
||||
const allocateDto = {
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 10000,
|
||||
};
|
||||
|
||||
creditsService.allocateCredits.mockRejectedValue(
|
||||
new BadRequestException('Insufficient organization credits')
|
||||
);
|
||||
|
||||
await expect(controller.allocateCredits(mockOrgOwner, allocateDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass optional reason parameter', async () => {
|
||||
const allocateDto = {
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 200,
|
||||
reason: 'Bonus for project completion',
|
||||
};
|
||||
|
||||
creditsService.allocateCredits.mockResolvedValue({ success: true } as any);
|
||||
|
||||
await controller.allocateCredits(mockOrgOwner, allocateDto);
|
||||
|
||||
expect(creditsService.allocateCredits).toHaveBeenCalledWith(
|
||||
mockOrgOwner.userId,
|
||||
expect.objectContaining({ reason: 'Bonus for project completion' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GET /credits/organization/:organizationId/balance
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('GET /credits/organization/:organizationId/balance', () => {
|
||||
it('should return organization balance', async () => {
|
||||
const expectedBalance = mockOrganizationBalanceFactory.withBalance(
|
||||
organizationId,
|
||||
1000,
|
||||
300
|
||||
);
|
||||
|
||||
creditsService.getOrganizationBalance.mockResolvedValue(expectedBalance as any);
|
||||
|
||||
const result = await controller.getOrganizationBalance(organizationId);
|
||||
|
||||
expect(result).toEqual(expectedBalance);
|
||||
expect(creditsService.getOrganizationBalance).toHaveBeenCalledWith(organizationId);
|
||||
});
|
||||
|
||||
it('should return balance breakdown with allocations', async () => {
|
||||
const orgBalance = mockOrganizationBalanceFactory.create(organizationId, {
|
||||
balance: 5000,
|
||||
allocatedCredits: 2000,
|
||||
availableCredits: 3000,
|
||||
totalPurchased: 6000,
|
||||
totalAllocated: 3500,
|
||||
});
|
||||
|
||||
creditsService.getOrganizationBalance.mockResolvedValue(orgBalance as any);
|
||||
|
||||
const result = await controller.getOrganizationBalance(organizationId);
|
||||
|
||||
expect(result.balance).toBe(5000);
|
||||
expect(result.allocatedCredits).toBe(2000);
|
||||
expect(result.availableCredits).toBe(3000);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException for non-existent org', async () => {
|
||||
creditsService.getOrganizationBalance.mockRejectedValue(
|
||||
new NotFoundException('Organization not found')
|
||||
);
|
||||
|
||||
await expect(controller.getOrganizationBalance('non-existent-org')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GET /credits/organization/:organizationId/employee/:employeeId/balance
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('GET /credits/organization/:organizationId/employee/:employeeId/balance', () => {
|
||||
it('should return employee balance within organization', async () => {
|
||||
const expectedBalance = {
|
||||
employeeId,
|
||||
organizationId,
|
||||
balance: 250,
|
||||
allocatedTotal: 500,
|
||||
usedTotal: 250,
|
||||
};
|
||||
|
||||
creditsService.getEmployeeCreditBalance.mockResolvedValue(expectedBalance as any);
|
||||
|
||||
const result = await controller.getEmployeeBalance(organizationId, employeeId);
|
||||
|
||||
expect(result).toEqual(expectedBalance);
|
||||
expect(creditsService.getEmployeeCreditBalance).toHaveBeenCalledWith(
|
||||
employeeId,
|
||||
organizationId
|
||||
);
|
||||
});
|
||||
|
||||
it('should return zero for employee with no allocations', async () => {
|
||||
const zeroBalance = {
|
||||
employeeId,
|
||||
organizationId,
|
||||
balance: 0,
|
||||
allocatedTotal: 0,
|
||||
usedTotal: 0,
|
||||
};
|
||||
|
||||
creditsService.getEmployeeCreditBalance.mockResolvedValue(zeroBalance as any);
|
||||
|
||||
const result = await controller.getEmployeeBalance(organizationId, employeeId);
|
||||
|
||||
expect(result!.balance).toBe(0);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException for non-existent employee', async () => {
|
||||
creditsService.getEmployeeCreditBalance.mockRejectedValue(
|
||||
new NotFoundException('Employee not found in organization')
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.getEmployeeBalance(organizationId, 'non-existent-emp')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// POST /credits/organization/:organizationId/use
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('POST /credits/organization/:organizationId/use', () => {
|
||||
it('should deduct credits with organization tracking', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 15,
|
||||
appId: 'chat',
|
||||
description: 'Team chat usage',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
success: true,
|
||||
transaction: mockTransactionFactory.create(mockUser.userId, {
|
||||
amount: -15,
|
||||
organizationId,
|
||||
}),
|
||||
newBalance: 85,
|
||||
};
|
||||
|
||||
creditsService.deductCredits.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.deductCreditsWithOrgTracking(
|
||||
mockUser,
|
||||
organizationId,
|
||||
useCreditsDto
|
||||
);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(creditsService.deductCredits).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
useCreditsDto,
|
||||
organizationId
|
||||
);
|
||||
});
|
||||
|
||||
it('should track organization ID in transaction', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 20,
|
||||
appId: 'picture',
|
||||
description: 'Image generation for team',
|
||||
});
|
||||
|
||||
creditsService.deductCredits.mockResolvedValue({ success: true } as any);
|
||||
|
||||
await controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto);
|
||||
|
||||
expect(creditsService.deductCredits).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
useCreditsDto,
|
||||
organizationId
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate BadRequestException for insufficient employee credits', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 500,
|
||||
appId: 'wisekeep',
|
||||
description: 'Video analysis',
|
||||
});
|
||||
|
||||
creditsService.deductCredits.mockRejectedValue(
|
||||
new BadRequestException('Insufficient credits')
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should handle idempotency for organization credit usage', async () => {
|
||||
const idempotencyKey = `org-usage-${nanoid()}`;
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 30,
|
||||
appId: 'memoro',
|
||||
description: 'Voice transcription',
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
creditsService.deductCredits.mockResolvedValue({ success: true } as any);
|
||||
|
||||
await controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto);
|
||||
|
||||
expect(creditsService.deductCredits).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
expect.objectContaining({ idempotencyKey }),
|
||||
organizationId
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guard Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Guards', () => {
|
||||
it('should have JwtAuthGuard applied at class level', async () => {
|
||||
const guards = Reflect.getMetadata('__guards__', CreditsController);
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toContain(JwtAuthGuard);
|
||||
});
|
||||
|
||||
it('should require authentication for all endpoints', () => {
|
||||
// All credits endpoints require authentication
|
||||
// This is handled at the class level with @UseGuards(JwtAuthGuard)
|
||||
const classGuards = Reflect.getMetadata('__guards__', CreditsController);
|
||||
expect(classGuards).toContain(JwtAuthGuard);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling
|
||||
// ============================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should propagate service errors correctly', async () => {
|
||||
const error = new Error('Database connection failed');
|
||||
creditsService.getBalance.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getBalance(mockUser)).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
|
||||
it('should handle concurrent request errors', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({ amount: 10 });
|
||||
|
||||
creditsService.useCredits.mockRejectedValue(
|
||||
new BadRequestException('Concurrent modification detected, please retry')
|
||||
);
|
||||
|
||||
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle validation errors in allocation', async () => {
|
||||
const invalidDto = {
|
||||
organizationId: '',
|
||||
employeeId: 'emp-123',
|
||||
amount: -100, // Invalid negative amount
|
||||
};
|
||||
|
||||
creditsService.allocateCredits.mockRejectedValue(
|
||||
new BadRequestException('Amount must be positive')
|
||||
);
|
||||
|
||||
await expect(controller.allocateCredits(mockOrgOwner, invalidDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero credit usage', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({ amount: 0 });
|
||||
|
||||
creditsService.useCredits.mockRejectedValue(
|
||||
new BadRequestException('Amount must be greater than zero')
|
||||
);
|
||||
|
||||
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle very large credit amounts', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 999999999,
|
||||
appId: 'test',
|
||||
description: 'Large transaction',
|
||||
});
|
||||
|
||||
creditsService.useCredits.mockRejectedValue(new BadRequestException('Amount exceeds limit'));
|
||||
|
||||
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in description', async () => {
|
||||
const useCreditsDto = mockDtoFactory.useCredits({
|
||||
amount: 5,
|
||||
appId: 'chat',
|
||||
description: 'Test with émojis 🎉 and "quotes"',
|
||||
});
|
||||
|
||||
creditsService.useCredits.mockResolvedValue({ success: true } as any);
|
||||
|
||||
await controller.useCredits(mockUser, useCreditsDto);
|
||||
|
||||
expect(creditsService.useCredits).toHaveBeenCalledWith(
|
||||
mockUser.userId,
|
||||
expect.objectContaining({
|
||||
description: 'Test with émojis 🎉 and "quotes"',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe, Param } from '@nestjs/common';
|
||||
import { CreditsService } from './credits.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { UseCreditsDto } from './dto/use-credits.dto';
|
||||
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
|
||||
|
||||
@Controller('credits')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CreditsController {
|
||||
constructor(private readonly creditsService: CreditsService) {}
|
||||
|
||||
// ============================================================================
|
||||
// PERSONAL / B2C ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
@Get('balance')
|
||||
async getBalance(@CurrentUser() user: CurrentUserData) {
|
||||
return this.creditsService.getBalance(user.userId);
|
||||
|
|
@ -37,4 +42,51 @@ export class CreditsController {
|
|||
async getPackages() {
|
||||
return this.creditsService.getPackages();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORGANIZATION / B2B ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Allocate credits from organization to employee
|
||||
* Only organization owners can allocate credits
|
||||
*/
|
||||
@Post('organization/allocate')
|
||||
async allocateCredits(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() allocateDto: AllocateCreditsDto
|
||||
) {
|
||||
return this.creditsService.allocateCredits(user.userId, allocateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization credit balance and allocation stats
|
||||
*/
|
||||
@Get('organization/:organizationId/balance')
|
||||
async getOrganizationBalance(@Param('organizationId') organizationId: string) {
|
||||
return this.creditsService.getOrganizationBalance(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get employee's credit balance within an organization context
|
||||
*/
|
||||
@Get('organization/:organizationId/employee/:employeeId/balance')
|
||||
async getEmployeeBalance(
|
||||
@Param('organizationId') organizationId: string,
|
||||
@Param('employeeId') employeeId: string
|
||||
) {
|
||||
return this.creditsService.getEmployeeCreditBalance(employeeId, organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct credits with organization tracking (for B2B usage)
|
||||
*/
|
||||
@Post('organization/:organizationId/use')
|
||||
async deductCreditsWithOrgTracking(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('organizationId') organizationId: string,
|
||||
@Body() useCreditsDto: UseCreditsDto
|
||||
) {
|
||||
return this.creditsService.deductCredits(user.userId, useCreditsDto, organizationId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1887
services/mana-core-auth/src/credits/credits.service.spec.ts
Normal file
1887
services/mana-core-auth/src/credits/credits.service.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,12 +3,24 @@ import {
|
|||
BadRequestException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, sql, desc } from 'drizzle-orm';
|
||||
import { eq, and, sql, desc, sum } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { balances, transactions, purchases, packages, usageStats } from '../db/schema';
|
||||
import {
|
||||
balances,
|
||||
transactions,
|
||||
purchases,
|
||||
packages,
|
||||
usageStats,
|
||||
organizationBalances,
|
||||
creditAllocations,
|
||||
members,
|
||||
organizations,
|
||||
} from '../db/schema';
|
||||
import { UseCreditsDto } from './dto/use-credits.dto';
|
||||
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CreditsService {
|
||||
|
|
@ -269,4 +281,405 @@ export class CreditsService {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORGANIZATION CREDIT METHODS (B2B)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create organization credit balance
|
||||
* Called when a new organization is created
|
||||
*/
|
||||
async createOrganizationCreditBalance(organizationId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check if balance already exists
|
||||
const [existingBalance] = await db
|
||||
.select()
|
||||
.from(organizationBalances)
|
||||
.where(eq(organizationBalances.organizationId, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (existingBalance) {
|
||||
return existingBalance;
|
||||
}
|
||||
|
||||
// Create initial balance
|
||||
const [balance] = await db
|
||||
.insert(organizationBalances)
|
||||
.values({
|
||||
organizationId,
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create personal credit balance (B2C user)
|
||||
* Alias for initializeUserBalance for clarity
|
||||
*/
|
||||
async createPersonalCreditBalance(userId: string) {
|
||||
return this.initializeUserBalance(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate credits from organization to employee
|
||||
* Only organization owners can allocate credits
|
||||
*/
|
||||
async allocateCredits(allocatorUserId: string, allocateDto: AllocateCreditsDto) {
|
||||
const db = this.getDb();
|
||||
const { organizationId, employeeId, amount, reason } = allocateDto;
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
// 1. Verify allocator has 'owner' role in the organization
|
||||
const [member] = await tx
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.organizationId, organizationId),
|
||||
eq(members.userId, allocatorUserId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!member || member.role !== 'owner') {
|
||||
throw new ForbiddenException(
|
||||
'Only organization owners can allocate credits'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Get organization balance with row lock
|
||||
const [orgBalance] = await tx
|
||||
.select()
|
||||
.from(organizationBalances)
|
||||
.where(eq(organizationBalances.organizationId, organizationId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!orgBalance) {
|
||||
throw new NotFoundException('Organization balance not found');
|
||||
}
|
||||
|
||||
// 3. Check if organization has sufficient available credits
|
||||
if (orgBalance.availableCredits < amount) {
|
||||
throw new BadRequestException(
|
||||
`Insufficient organization credits. Available: ${orgBalance.availableCredits}, Requested: ${amount}`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Get or create employee balance with row lock
|
||||
let employeeBalance = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, employeeId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!employeeBalance) {
|
||||
// Initialize employee balance within the transaction
|
||||
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
|
||||
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
|
||||
|
||||
const [newBalance] = await tx
|
||||
.insert(balances)
|
||||
.values({
|
||||
userId: employeeId,
|
||||
balance: 0,
|
||||
freeCreditsRemaining: signupBonus,
|
||||
dailyFreeCredits,
|
||||
lastDailyResetAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
employeeBalance = newBalance;
|
||||
}
|
||||
|
||||
const currentEmployeeBalance = employeeBalance.balance;
|
||||
const newEmployeeBalance = currentEmployeeBalance + amount;
|
||||
|
||||
// 5. Update organization balance
|
||||
const newAllocatedCredits = orgBalance.allocatedCredits + amount;
|
||||
const newAvailableCredits = orgBalance.balance - newAllocatedCredits;
|
||||
|
||||
const updateOrgResult = await tx
|
||||
.update(organizationBalances)
|
||||
.set({
|
||||
allocatedCredits: newAllocatedCredits,
|
||||
availableCredits: newAvailableCredits,
|
||||
totalAllocated: orgBalance.totalAllocated + amount,
|
||||
version: orgBalance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(organizationBalances.organizationId, organizationId),
|
||||
eq(organizationBalances.version, orgBalance.version)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updateOrgResult.length === 0) {
|
||||
throw new ConflictException(
|
||||
'Organization balance was modified by another transaction. Please retry.'
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Update employee balance
|
||||
const updateEmployeeResult = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newEmployeeBalance,
|
||||
totalEarned: employeeBalance.totalEarned + amount,
|
||||
version: employeeBalance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(balances.userId, employeeId),
|
||||
eq(balances.version, employeeBalance.version)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updateEmployeeResult.length === 0) {
|
||||
throw new ConflictException(
|
||||
'Employee balance was modified by another transaction. Please retry.'
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Create allocation record (audit trail)
|
||||
const [allocation] = await tx
|
||||
.insert(creditAllocations)
|
||||
.values({
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount,
|
||||
allocatedBy: allocatorUserId,
|
||||
reason: reason || 'Credit allocation',
|
||||
balanceBefore: currentEmployeeBalance,
|
||||
balanceAfter: newEmployeeBalance,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 8. Create transaction record for employee
|
||||
await tx.insert(transactions).values({
|
||||
userId: employeeId,
|
||||
type: 'bonus',
|
||||
status: 'completed',
|
||||
amount,
|
||||
balanceBefore: currentEmployeeBalance,
|
||||
balanceAfter: newEmployeeBalance,
|
||||
appId: 'organization',
|
||||
description: `Credit allocation from organization: ${reason || 'N/A'}`,
|
||||
organizationId,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
allocation,
|
||||
organizationBalance: {
|
||||
balance: orgBalance.balance,
|
||||
allocatedCredits: newAllocatedCredits,
|
||||
availableCredits: newAvailableCredits,
|
||||
},
|
||||
employeeBalance: {
|
||||
balance: newEmployeeBalance,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get employee's credit balance (allocated from organization)
|
||||
* Returns the employee's personal balance
|
||||
*/
|
||||
async getEmployeeCreditBalance(userId: string, organizationId?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get employee's personal balance
|
||||
const [balance] = await db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!balance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
balance: balance.balance,
|
||||
freeCreditsRemaining: balance.freeCreditsRemaining,
|
||||
totalEarned: balance.totalEarned,
|
||||
totalSpent: balance.totalSpent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personal credit balance (B2C user)
|
||||
* Alias for getBalance for clarity
|
||||
*/
|
||||
async getPersonalCreditBalance(userId: string) {
|
||||
return this.getBalance(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization balance and allocation statistics
|
||||
*/
|
||||
async getOrganizationBalance(organizationId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get organization balance
|
||||
const [orgBalance] = await db
|
||||
.select()
|
||||
.from(organizationBalances)
|
||||
.where(eq(organizationBalances.organizationId, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (!orgBalance) {
|
||||
throw new NotFoundException('Organization balance not found');
|
||||
}
|
||||
|
||||
// Get allocation statistics
|
||||
const allocations = await db
|
||||
.select()
|
||||
.from(creditAllocations)
|
||||
.where(eq(creditAllocations.organizationId, organizationId))
|
||||
.orderBy(desc(creditAllocations.createdAt))
|
||||
.limit(10); // Last 10 allocations
|
||||
|
||||
return {
|
||||
balance: orgBalance.balance,
|
||||
allocatedCredits: orgBalance.allocatedCredits,
|
||||
availableCredits: orgBalance.availableCredits,
|
||||
totalPurchased: orgBalance.totalPurchased,
|
||||
totalAllocated: orgBalance.totalAllocated,
|
||||
recentAllocations: allocations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct credits with organization tracking
|
||||
* Enhanced version of useCredits that tracks organization_id for B2B users
|
||||
*/
|
||||
async deductCredits(
|
||||
userId: string,
|
||||
useCreditsDto: UseCreditsDto,
|
||||
organizationId?: string
|
||||
) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check for idempotency
|
||||
if (useCreditsDto.idempotencyKey) {
|
||||
const [existingTransaction] = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey))
|
||||
.limit(1);
|
||||
|
||||
if (existingTransaction) {
|
||||
return {
|
||||
success: true,
|
||||
transaction: existingTransaction,
|
||||
message: 'Transaction already processed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use a transaction for atomicity
|
||||
return await db.transaction(async (tx) => {
|
||||
// Get current balance with row lock
|
||||
const [currentBalance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!currentBalance) {
|
||||
throw new NotFoundException('User balance not found');
|
||||
}
|
||||
|
||||
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
|
||||
|
||||
if (totalAvailable < useCreditsDto.amount) {
|
||||
throw new BadRequestException('Insufficient credits');
|
||||
}
|
||||
|
||||
// Calculate deduction from free and paid credits
|
||||
let freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
|
||||
let paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
|
||||
|
||||
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
|
||||
const newBalance = currentBalance.balance - paidCreditsUsed;
|
||||
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
|
||||
|
||||
// Update balance
|
||||
const updateResult = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
freeCreditsRemaining: newFreeCredits,
|
||||
totalSpent: newTotalSpent,
|
||||
version: currentBalance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version)))
|
||||
.returning();
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
throw new ConflictException('Balance was modified by another transaction. Please retry.');
|
||||
}
|
||||
|
||||
// Create transaction record with organization_id
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -useCreditsDto.amount,
|
||||
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
|
||||
balanceAfter: newBalance + newFreeCredits,
|
||||
appId: useCreditsDto.appId,
|
||||
description: useCreditsDto.description,
|
||||
organizationId: organizationId || null, // Track organization for B2B
|
||||
metadata: useCreditsDto.metadata,
|
||||
idempotencyKey: useCreditsDto.idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Track usage stats
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
await tx.insert(usageStats).values({
|
||||
userId,
|
||||
appId: useCreditsDto.appId,
|
||||
creditsUsed: useCreditsDto.amount,
|
||||
date: today,
|
||||
metadata: useCreditsDto.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction,
|
||||
newBalance: {
|
||||
balance: newBalance,
|
||||
freeCreditsRemaining: newFreeCredits,
|
||||
totalSpent: newTotalSpent,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { IsUUID, IsInt, IsString, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class AllocateCreditsDto {
|
||||
@IsString()
|
||||
organizationId: string;
|
||||
|
||||
@IsUUID()
|
||||
employeeId: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { config } from 'dotenv';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
async function runMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
console.log('Running migrations...');
|
||||
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
console.log('Migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
CREATE SCHEMA "auth";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "credits";
|
||||
--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin', 'service');--> statement-breakpoint
|
||||
CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'failed', 'cancelled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."transaction_type" AS ENUM('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment');--> statement-breakpoint
|
||||
CREATE TABLE "auth"."accounts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"provider_account_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"token_type" text,
|
||||
"scope" text,
|
||||
"id_token" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."passwords" (
|
||||
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||
"hashed_password" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."security_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"event_type" text NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"refresh_token_expires_at" timestamp with time zone NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"device_id" text,
|
||||
"device_name" text,
|
||||
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
CONSTRAINT "sessions_token_unique" UNIQUE("token"),
|
||||
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."two_factor_auth" (
|
||||
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||
"secret" text NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"backup_codes" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"enabled_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"name" text,
|
||||
"avatar_url" text,
|
||||
"role" "user_role" DEFAULT 'user' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth"."verification_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
CONSTRAINT "verification_tokens_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "credits"."balances" (
|
||||
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||
"balance" integer DEFAULT 0 NOT NULL,
|
||||
"free_credits_remaining" integer DEFAULT 150 NOT NULL,
|
||||
"daily_free_credits" integer DEFAULT 5 NOT NULL,
|
||||
"last_daily_reset_at" timestamp with time zone DEFAULT now(),
|
||||
"total_earned" integer DEFAULT 0 NOT NULL,
|
||||
"total_spent" integer DEFAULT 0 NOT NULL,
|
||||
"version" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "credits"."packages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"credits" integer NOT NULL,
|
||||
"price_euro_cents" integer NOT NULL,
|
||||
"stripe_price_id" text,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "packages_stripe_price_id_unique" UNIQUE("stripe_price_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "credits"."purchases" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"package_id" uuid,
|
||||
"credits" integer NOT NULL,
|
||||
"price_euro_cents" integer NOT NULL,
|
||||
"stripe_payment_intent_id" text,
|
||||
"stripe_customer_id" text,
|
||||
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "purchases_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "credits"."transactions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"type" "transaction_type" NOT NULL,
|
||||
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
|
||||
"amount" integer NOT NULL,
|
||||
"balance_before" integer NOT NULL,
|
||||
"balance_after" integer NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"idempotency_key" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "transactions_idempotency_key_unique" UNIQUE("idempotency_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "credits"."usage_stats" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"credits_used" integer NOT NULL,
|
||||
"date" timestamp with time zone NOT NULL,
|
||||
"metadata" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."passwords" ADD CONSTRAINT "passwords_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."security_events" ADD CONSTRAINT "security_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."two_factor_auth" ADD CONSTRAINT "two_factor_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "auth"."verification_tokens" ADD CONSTRAINT "verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credits"."balances" ADD CONSTRAINT "balances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "credits"."packages"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credits"."transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credits"."usage_stats" ADD CONSTRAINT "usage_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "purchases_user_id_idx" ON "credits"."purchases" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "purchases_stripe_payment_intent_id_idx" ON "credits"."purchases" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_user_id_idx" ON "credits"."transactions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_app_id_idx" ON "credits"."transactions" USING btree ("app_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_created_at_idx" ON "credits"."transactions" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_idempotency_key_idx" ON "credits"."transactions" USING btree ("idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX "usage_stats_user_id_date_idx" ON "credits"."usage_stats" USING btree ("user_id","date");--> statement-breakpoint
|
||||
CREATE INDEX "usage_stats_app_id_date_idx" ON "credits"."usage_stats" USING btree ("app_id","date");
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1764089133415,
|
||||
"tag": "0000_lush_ironclad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1764448681401,
|
||||
"tag": "0001_zippy_ma_gnuci",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,78 +1,83 @@
|
|||
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const authSchema = pgSchema('auth');
|
||||
|
||||
// Enum for user roles
|
||||
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
|
||||
|
||||
// Users table
|
||||
// Users table (Better Auth schema)
|
||||
export const users = authSchema.table('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
name: text('name').notNull(),
|
||||
email: text('email').unique().notNull(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: userRoleEnum('role').default('user').notNull(),
|
||||
image: text('image'), // Better Auth uses 'image' not 'avatarUrl'
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
// Custom fields (not required by Better Auth)
|
||||
role: userRoleEnum('role').default('user').notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Sessions table
|
||||
// Sessions table (Better Auth schema)
|
||||
export const sessions = authSchema.table('sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
refreshToken: text('refresh_token').unique().notNull(),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
// Custom fields (not required by Better Auth)
|
||||
refreshToken: text('refresh_token').unique(),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||||
deviceId: text('device_id'),
|
||||
deviceName: text('device_name'),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Accounts table (for OAuth providers)
|
||||
// Accounts table (for OAuth providers and credentials - Better Auth schema)
|
||||
export const accounts = authSchema.table('accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
accountId: text('account_id').notNull(), // Better Auth field
|
||||
providerId: text('provider_id').notNull(), // Better Auth field (was 'provider')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
|
||||
providerAccountId: text('provider_account_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
tokenType: text('token_type'),
|
||||
scope: text('scope'),
|
||||
idToken: text('id_token'),
|
||||
metadata: jsonb('metadata'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||||
scope: text('scope'),
|
||||
password: text('password'), // Better Auth stores hashed password here for credential provider
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Verification tokens (for email verification, password reset)
|
||||
export const verificationTokens = authSchema.table('verification_tokens', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
type: text('type').notNull(), // 'email_verification', 'password_reset'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
});
|
||||
// Verification table (Better Auth schema - for email verification, password reset)
|
||||
export const verificationTokens = authSchema.table(
|
||||
'verification',
|
||||
{
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
identifier: text('identifier').notNull(), // Better Auth uses identifier (e.g., email)
|
||||
value: text('value').notNull(), // Better Auth uses value (the token)
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
identifierIdx: index('verification_identifier_idx').on(table.identifier),
|
||||
})
|
||||
);
|
||||
|
||||
// Password table (separate for security)
|
||||
export const passwords = authSchema.table('passwords', {
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
hashedPassword: text('hashed_password').notNull(),
|
||||
|
|
@ -82,23 +87,31 @@ export const passwords = authSchema.table('passwords', {
|
|||
|
||||
// Two-factor authentication
|
||||
export const twoFactorAuth = authSchema.table('two_factor_auth', {
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
secret: text('secret').notNull(),
|
||||
enabled: boolean('enabled').default(false).notNull(),
|
||||
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
|
||||
backupCodes: jsonb('backup_codes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
enabledAt: timestamp('enabled_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Security events log
|
||||
export const securityEvents = authSchema.table('security_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
|
||||
id: uuid('id').primaryKey().defaultRandom(), // Our table, can keep UUID
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
eventType: text('event_type').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// JWKS table (Better Auth JWT plugin - stores signing keys)
|
||||
export const jwks = authSchema.table('jwks', {
|
||||
id: text('id').primaryKey(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
privateKey: text('private_key').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
boolean,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth.schema';
|
||||
import { organizations } from './organizations.schema';
|
||||
|
||||
export const creditsSchema = pgSchema('credits');
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ export const transactionStatusEnum = pgEnum('transaction_status', [
|
|||
|
||||
// Credit balances (one per user)
|
||||
export const balances = creditsSchema.table('balances', {
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
|
|
@ -42,7 +43,7 @@ export const balances = creditsSchema.table('balances', {
|
|||
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
|
||||
totalEarned: integer('total_earned').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(), // For optimistic locking
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -52,7 +53,7 @@ export const transactions = creditsSchema.table(
|
|||
'transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
type: transactionTypeEnum('type').notNull(),
|
||||
|
|
@ -60,9 +61,10 @@ export const transactions = creditsSchema.table(
|
|||
amount: integer('amount').notNull(),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
|
||||
appId: text('app_id').notNull(),
|
||||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'), // Additional context
|
||||
organizationId: text('organization_id').references(() => organizations.id),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
|
|
@ -70,6 +72,7 @@ export const transactions = creditsSchema.table(
|
|||
(table) => ({
|
||||
userIdIdx: index('transactions_user_id_idx').on(table.userId),
|
||||
appIdIdx: index('transactions_app_id_idx').on(table.appId),
|
||||
organizationIdIdx: index('transactions_organization_id_idx').on(table.organizationId),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
})
|
||||
|
|
@ -80,8 +83,8 @@ export const packages = creditsSchema.table('packages', {
|
|||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
credits: integer('credits').notNull(), // Number of credits
|
||||
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
|
||||
credits: integer('credits').notNull(),
|
||||
priceEuroCents: integer('price_euro_cents').notNull(),
|
||||
stripePriceId: text('stripe_price_id').unique(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
|
|
@ -95,7 +98,7 @@ export const purchases = creditsSchema.table(
|
|||
'purchases',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
packageId: uuid('package_id').references(() => packages.id),
|
||||
|
|
@ -121,7 +124,7 @@ export const usageStats = creditsSchema.table(
|
|||
'usage_stats',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
appId: text('app_id').notNull(),
|
||||
|
|
@ -134,3 +137,47 @@ export const usageStats = creditsSchema.table(
|
|||
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
|
||||
})
|
||||
);
|
||||
|
||||
// Organization credit balances (B2B)
|
||||
export const organizationBalances = creditsSchema.table('organization_balances', {
|
||||
organizationId: text('organization_id')
|
||||
.primaryKey()
|
||||
.references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
allocatedCredits: integer('allocated_credits').default(0).notNull(),
|
||||
availableCredits: integer('available_credits').default(0).notNull(),
|
||||
totalPurchased: integer('total_purchased').default(0).notNull(),
|
||||
totalAllocated: integer('total_allocated').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Credit allocations (B2B - tracking allocations from org to employees)
|
||||
export const creditAllocations = creditsSchema.table(
|
||||
'credit_allocations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
organizationId: text('organization_id')
|
||||
.references(() => organizations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
employeeId: text('employee_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
allocatedBy: text('allocated_by')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
reason: text('reason'),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
organizationIdIdx: index('credit_allocations_organization_id_idx').on(table.organizationId),
|
||||
employeeIdIdx: index('credit_allocations_employee_id_idx').on(table.employeeId),
|
||||
allocatedByIdx: index('credit_allocations_allocated_by_idx').on(table.allocatedBy),
|
||||
createdAtIdx: index('credit_allocations_created_at_idx').on(table.createdAt),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './auth.schema';
|
||||
export * from './credits.schema';
|
||||
export * from './feedback.schema';
|
||||
export * from './organizations.schema';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { pgSchema, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
|
||||
import { authSchema, users } from './auth.schema';
|
||||
|
||||
/**
|
||||
* Better Auth Organization Tables
|
||||
* These tables follow Better Auth's organization plugin schema requirements
|
||||
* @see https://www.better-auth.com/docs/plugins/organization
|
||||
*
|
||||
* Note: Better Auth uses TEXT for IDs (nanoid/ULID), but we use UUID for users.
|
||||
* The foreign key constraints will be added via raw SQL migration to handle the type difference.
|
||||
*/
|
||||
|
||||
// Organizations table
|
||||
export const organizations = authSchema.table(
|
||||
'organizations',
|
||||
{
|
||||
id: text('id').primaryKey(), // Better Auth uses TEXT IDs (ULIDs/nanoids)
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').unique(),
|
||||
logo: text('logo'),
|
||||
metadata: jsonb('metadata'), // Additional organization data
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
slugIdx: index('organizations_slug_idx').on(table.slug),
|
||||
})
|
||||
);
|
||||
|
||||
// Members table (links users to organizations with roles)
|
||||
export const members = authSchema.table(
|
||||
'members',
|
||||
{
|
||||
id: text('id').primaryKey(), // Better Auth uses TEXT IDs
|
||||
organizationId: text('organization_id')
|
||||
.references(() => organizations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(), // References auth.users.id (UUID cast to TEXT)
|
||||
role: text('role').notNull(), // 'owner', 'admin', 'member', or custom roles
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
organizationIdIdx: index('members_organization_id_idx').on(table.organizationId),
|
||||
userIdIdx: index('members_user_id_idx').on(table.userId),
|
||||
organizationUserIdx: index('members_organization_user_idx').on(
|
||||
table.organizationId,
|
||||
table.userId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// Invitations table (for inviting users to organizations)
|
||||
export const invitations = authSchema.table(
|
||||
'invitations',
|
||||
{
|
||||
id: text('id').primaryKey(), // Better Auth uses TEXT IDs
|
||||
organizationId: text('organization_id')
|
||||
.references(() => organizations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
email: text('email').notNull(),
|
||||
role: text('role').notNull(), // Role they'll have when they accept
|
||||
status: text('status').notNull(), // 'pending', 'accepted', 'rejected', 'canceled'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
inviterId: text('inviter_id'), // References auth.users.id (UUID cast to TEXT)
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
organizationIdIdx: index('invitations_organization_id_idx').on(table.organizationId),
|
||||
emailIdx: index('invitations_email_idx').on(table.email),
|
||||
statusIdx: index('invitations_status_idx').on(table.status),
|
||||
})
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue