From e0e9ede885762f719983206bbdf6276324ffc960 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 26 Mar 2026 20:35:06 +0100 Subject: [PATCH] test(auth): add passkey and 2FA controller tests (35 tests) PasskeyService tests (21): - Registration/authentication flows with challenge management - DB operations (store, update counter, delete, rename) - Error cases (expired challenge, duplicate credential, deleted user) - Challenge TTL expiry and single-use consumption Controller tests (14): - All 7 passkey endpoints (register, authenticate, list, delete, rename) - Security event logging on sensitive operations - Guard configuration (protected vs public endpoints) - 2FA redirect passthrough in signIn flow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/auth/passkey-controller.spec.ts | 480 ++++++++++++++ .../src/auth/services/passkey.service.spec.ts | 612 ++++++++++++++++++ 2 files changed, 1092 insertions(+) create mode 100644 services/mana-core-auth/src/auth/passkey-controller.spec.ts create mode 100644 services/mana-core-auth/src/auth/services/passkey.service.spec.ts diff --git a/services/mana-core-auth/src/auth/passkey-controller.spec.ts b/services/mana-core-auth/src/auth/passkey-controller.spec.ts new file mode 100644 index 000000000..109200186 --- /dev/null +++ b/services/mana-core-auth/src/auth/passkey-controller.spec.ts @@ -0,0 +1,480 @@ +/** + * AuthController Passkey + 2FA Unit Tests + * + * Tests all passkey (WebAuthn) endpoints on the AuthController: + * + * - POST /auth/passkeys/register/options - Generate registration options + * - POST /auth/passkeys/register/verify - Verify and store passkey + * - POST /auth/passkeys/authenticate/options - Generate auth options (public) + * - POST /auth/passkeys/authenticate/verify - Verify and return JWT tokens + * - GET /auth/passkeys - List user's passkeys + * - DELETE /auth/passkeys/:id - Delete a passkey + * - PATCH /auth/passkeys/:id - Rename a passkey + * + * Also tests 2FA-related behavior in signIn. + */ + +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { AuthController } from './auth.controller'; +import { BetterAuthService } from './services/better-auth.service'; +import { PasskeyService } from './services/passkey.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security'; + +describe('AuthController - Passkey Endpoints', () => { + let controller: AuthController; + let passkeyService: jest.Mocked; + let betterAuthService: jest.Mocked; + let securityEventsService: jest.Mocked; + + const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' }; + const mockReq = { + headers: { 'user-agent': 'test-agent' }, + ip: '127.0.0.1', + } as any; + + beforeEach(async () => { + const mockPasskeyService = { + generateRegistrationOptions: jest.fn(), + verifyRegistration: jest.fn(), + generateAuthenticationOptions: jest.fn(), + verifyAuthentication: jest.fn(), + listPasskeys: jest.fn(), + deletePasskey: jest.fn(), + renamePasskey: jest.fn(), + }; + + 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(), + createSessionAndTokens: jest.fn(), + requestPasswordReset: jest.fn(), + resetPassword: jest.fn(), + resendVerificationEmail: jest.fn(), + getProfile: jest.fn(), + updateProfile: jest.fn(), + changePassword: jest.fn(), + deleteAccount: jest.fn(), + sessionToToken: jest.fn(), + getJwks: jest.fn(), + updateOrganization: jest.fn(), + deleteOrganization: jest.fn(), + updateMemberRole: jest.fn(), + listOrganizationInvitations: jest.fn(), + listUserInvitations: jest.fn(), + cancelInvitation: jest.fn(), + rejectInvitation: jest.fn(), + }; + + const mockSecurityEventsService = { + logEvent: jest.fn().mockResolvedValue(undefined), + logEventWithRequest: jest.fn().mockResolvedValue(undefined), + extractRequestInfo: jest.fn().mockReturnValue({ + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + }), + }; + + const mockAccountLockoutService = { + checkLockout: jest.fn().mockResolvedValue({ locked: false }), + recordAttempt: jest.fn().mockResolvedValue(undefined), + clearAttempts: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])], + controllers: [AuthController], + providers: [ + { provide: BetterAuthService, useValue: mockBetterAuthService }, + { provide: PasskeyService, useValue: mockPasskeyService }, + { provide: SecurityEventsService, useValue: mockSecurityEventsService }, + { provide: AccountLockoutService, useValue: mockAccountLockoutService }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .overrideGuard(ThrottlerGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + controller = module.get(AuthController); + passkeyService = module.get(PasskeyService); + betterAuthService = module.get(BetterAuthService); + securityEventsService = module.get(SecurityEventsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================ + // POST /auth/passkeys/register/options + // ============================================================================ + + describe('POST /auth/passkeys/register/options', () => { + it('should call generateRegistrationOptions with user.userId', async () => { + const expectedResult = { + options: { + challenge: 'test-challenge', + rp: { name: 'ManaCore', id: 'localhost' }, + }, + challengeId: 'challenge-id-123', + }; + + passkeyService.generateRegistrationOptions.mockResolvedValue(expectedResult as any); + + const result = await controller.passkeyRegisterOptions(mockUser as any); + + expect(result).toEqual(expectedResult); + expect(passkeyService.generateRegistrationOptions).toHaveBeenCalledWith('user-123'); + }); + + it('should return options and challengeId', async () => { + const expectedResult = { + options: { challenge: 'abc', rp: { name: 'ManaCore', id: 'localhost' } }, + challengeId: 'ch-456', + }; + + passkeyService.generateRegistrationOptions.mockResolvedValue(expectedResult as any); + + const result = await controller.passkeyRegisterOptions(mockUser as any); + + expect(result.options).toBeDefined(); + expect(result.challengeId).toBe('ch-456'); + }); + }); + + // ============================================================================ + // POST /auth/passkeys/register/verify + // ============================================================================ + + describe('POST /auth/passkeys/register/verify', () => { + it('should verify and return passkey info', async () => { + const body = { + challengeId: 'challenge-123', + credential: { id: 'cred-1', response: {} }, + friendlyName: 'My Passkey', + }; + + const expectedResult = { + id: 'pk-123', + credentialId: 'cred-1', + deviceType: 'multiPlatform', + friendlyName: 'My Passkey', + createdAt: new Date(), + }; + + passkeyService.verifyRegistration.mockResolvedValue(expectedResult); + + const result = await controller.passkeyRegisterVerify(mockUser as any, body, mockReq); + + expect(result).toEqual(expectedResult); + expect(passkeyService.verifyRegistration).toHaveBeenCalledWith( + 'challenge-123', + body.credential, + 'My Passkey' + ); + }); + + it('should log security event on successful registration', async () => { + const body = { + challengeId: 'challenge-123', + credential: { id: 'cred-1', response: {} }, + }; + + passkeyService.verifyRegistration.mockResolvedValue({ + id: 'pk-123', + credentialId: 'cred-1', + deviceType: 'singleDevice', + friendlyName: null, + createdAt: new Date(), + }); + + await controller.passkeyRegisterVerify(mockUser as any, body, mockReq); + + expect(securityEventsService.logEvent).toHaveBeenCalledWith({ + userId: 'user-123', + eventType: SecurityEventType.PASSKEY_REGISTERED, + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + metadata: { passkeyId: 'pk-123' }, + }); + }); + }); + + // ============================================================================ + // POST /auth/passkeys/authenticate/options + // ============================================================================ + + describe('POST /auth/passkeys/authenticate/options', () => { + it('should return options (no auth required)', async () => { + const expectedResult = { + options: { challenge: 'auth-challenge', rpId: 'localhost' }, + challengeId: 'auth-ch-123', + }; + + passkeyService.generateAuthenticationOptions.mockResolvedValue(expectedResult); + + const result = await controller.passkeyAuthOptions(); + + expect(result).toEqual(expectedResult); + expect(passkeyService.generateAuthenticationOptions).toHaveBeenCalled(); + }); + }); + + // ============================================================================ + // POST /auth/passkeys/authenticate/verify + // ============================================================================ + + describe('POST /auth/passkeys/authenticate/verify', () => { + it('should verify, create session+tokens, return tokens', async () => { + const body = { + challengeId: 'auth-ch-123', + credential: { id: 'cred-1', response: {} }, + }; + + const mockAuthUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + role: 'user' as const, + twoFactorEnabled: null, + deletedAt: null, + }; + + passkeyService.verifyAuthentication.mockResolvedValue({ + user: mockAuthUser as any, + passkeyId: 'pk-123', + }); + + const tokenResult = { + user: { id: 'user-123', email: 'test@example.com', name: 'Test User', role: 'user' }, + accessToken: 'jwt-access-token', + refreshToken: 'jwt-refresh-token', + expiresIn: 900, + }; + + betterAuthService.createSessionAndTokens.mockResolvedValue(tokenResult); + + const result = await controller.passkeyAuthVerify(body, mockReq); + + expect(result).toEqual(tokenResult); + expect(passkeyService.verifyAuthentication).toHaveBeenCalledWith( + 'auth-ch-123', + body.credential + ); + expect(betterAuthService.createSessionAndTokens).toHaveBeenCalledWith(mockAuthUser, { + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + }); + }); + + it('should log security event on success', async () => { + const body = { + challengeId: 'auth-ch-123', + credential: { id: 'cred-1', response: {} }, + }; + + passkeyService.verifyAuthentication.mockResolvedValue({ + user: { + id: 'user-123', + email: 'test@example.com', + name: 'Test', + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + role: 'user' as const, + twoFactorEnabled: null, + deletedAt: null, + } as any, + passkeyId: 'pk-456', + }); + + betterAuthService.createSessionAndTokens.mockResolvedValue({ + user: { id: 'user-123', email: 'test@example.com', name: 'Test', role: 'user' }, + accessToken: 'token', + refreshToken: 'refresh', + expiresIn: 900, + }); + + await controller.passkeyAuthVerify(body, mockReq); + + expect(securityEventsService.logEvent).toHaveBeenCalledWith({ + userId: 'user-123', + eventType: SecurityEventType.PASSKEY_LOGIN_SUCCESS, + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + metadata: { passkeyId: 'pk-456' }, + }); + }); + }); + + // ============================================================================ + // GET /auth/passkeys + // ============================================================================ + + describe('GET /auth/passkeys', () => { + it("should return user's passkeys", async () => { + const mockPasskeys = [ + { + id: 'pk-1', + credentialId: 'cred-1', + deviceType: 'multiPlatform', + backedUp: true, + friendlyName: 'MacBook', + lastUsedAt: new Date(), + createdAt: new Date(), + }, + { + id: 'pk-2', + credentialId: 'cred-2', + deviceType: 'singleDevice', + backedUp: false, + friendlyName: null, + lastUsedAt: null, + createdAt: new Date(), + }, + ]; + + passkeyService.listPasskeys.mockResolvedValue(mockPasskeys); + + const result = await controller.listPasskeys(mockUser as any); + + expect(result).toEqual(mockPasskeys); + expect(passkeyService.listPasskeys).toHaveBeenCalledWith('user-123'); + }); + }); + + // ============================================================================ + // DELETE /auth/passkeys/:id + // ============================================================================ + + describe('DELETE /auth/passkeys/:id', () => { + it('should delete and log security event', async () => { + passkeyService.deletePasskey.mockResolvedValue(undefined); + + await controller.deletePasskey(mockUser as any, 'pk-123', mockReq); + + expect(passkeyService.deletePasskey).toHaveBeenCalledWith('user-123', 'pk-123'); + expect(securityEventsService.logEvent).toHaveBeenCalledWith({ + userId: 'user-123', + eventType: SecurityEventType.PASSKEY_DELETED, + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + metadata: { passkeyId: 'pk-123' }, + }); + }); + + it('should return void (204 status handled by decorator)', async () => { + passkeyService.deletePasskey.mockResolvedValue(undefined); + + const result = await controller.deletePasskey(mockUser as any, 'pk-456', mockReq); + + expect(result).toBeUndefined(); + }); + }); + + // ============================================================================ + // PATCH /auth/passkeys/:id + // ============================================================================ + + describe('PATCH /auth/passkeys/:id', () => { + it('should rename passkey', async () => { + passkeyService.renamePasskey.mockResolvedValue(undefined); + + const result = await controller.renamePasskey(mockUser as any, 'pk-123', { + friendlyName: 'Work Laptop', + }); + + expect(result).toEqual({ success: true }); + expect(passkeyService.renamePasskey).toHaveBeenCalledWith( + 'user-123', + 'pk-123', + 'Work Laptop' + ); + }); + }); + + // ============================================================================ + // 2FA behavior in signIn + // ============================================================================ + + describe('2FA in signIn', () => { + it('should pass through twoFactorRedirect when returned by BetterAuthService', async () => { + const loginDto = { + email: 'user@example.com', + password: 'SecurePassword123!', + deviceId: undefined, + deviceName: undefined, + }; + + const twoFactorResult = { + twoFactorRedirect: true, + message: 'Two-factor authentication required', + }; + + betterAuthService.signIn.mockResolvedValue(twoFactorResult as any); + + const result = await controller.login(loginDto, mockReq); + + expect(result).toEqual(twoFactorResult); + expect((result as any).twoFactorRedirect).toBe(true); + }); + }); + + // ============================================================================ + // Guard Tests for Passkey Endpoints + // ============================================================================ + + describe('Passkey Guard Configuration', () => { + it('should have JwtAuthGuard on protected passkey endpoints', () => { + const protectedEndpoints: (keyof AuthController)[] = [ + 'passkeyRegisterOptions', + 'passkeyRegisterVerify', + 'listPasskeys', + 'deletePasskey', + 'renamePasskey', + ]; + + 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 passkey endpoints', () => { + const publicEndpoints: (keyof AuthController)[] = ['passkeyAuthOptions', 'passkeyAuthVerify']; + + publicEndpoints.forEach((endpoint) => { + const guards = Reflect.getMetadata( + '__guards__', + AuthController.prototype[endpoint as keyof AuthController] + ); + expect(guards).toBeUndefined(); + }); + }); + }); +}); diff --git a/services/mana-core-auth/src/auth/services/passkey.service.spec.ts b/services/mana-core-auth/src/auth/services/passkey.service.spec.ts new file mode 100644 index 000000000..c96a99dcb --- /dev/null +++ b/services/mana-core-auth/src/auth/services/passkey.service.spec.ts @@ -0,0 +1,612 @@ +/** + * PasskeyService Unit Tests + * + * Tests WebAuthn passkey registration, authentication, and management. + */ + +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server'; +import { PasskeyService } from './passkey.service'; +import { getDb } from '../../db/connection'; +import { nanoid } from 'nanoid'; +import { LoggerService } from '../../common/logger'; + +jest.mock('@simplewebauthn/server', () => ({ + generateRegistrationOptions: jest.fn(), + verifyRegistrationResponse: jest.fn(), + generateAuthenticationOptions: jest.fn(), + verifyAuthenticationResponse: jest.fn(), +})); + +jest.mock('../../db/connection', () => ({ + getDb: jest.fn(), +})); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'mock-nanoid-id'), +})); + +const createMockDb = () => { + let results: any[] = []; + let resultIndex = 0; + + const db: any = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + then: jest.fn((resolve) => resolve(results[resultIndex++] || [])), + setResults: (...r: any[]) => { + results = r; + resultIndex = 0; + }, + }; + return db; +}; + +describe('PasskeyService', () => { + let service: PasskeyService; + let mockDb: ReturnType; + + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + 'database.url': 'postgresql://test:test@localhost:5432/test', + WEBAUTHN_RP_ID: 'localhost', + WEBAUTHN_ORIGINS: 'http://localhost:5173', + }; + return config[key] || defaultValue || ''; + }), + }; + + const mockLoggerService = { + setContext: jest.fn().mockReturnThis(), + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + beforeEach(async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + + mockDb = createMockDb(); + (getDb as jest.Mock).mockReturnValue(mockDb); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PasskeyService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: LoggerService, useValue: mockLoggerService }, + ], + }).compile(); + + service = module.get(PasskeyService); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + // ============================================================================ + // generateRegistrationOptions + // ============================================================================ + + describe('generateRegistrationOptions', () => { + it('should return options and challengeId for a valid user', async () => { + const mockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + }; + + const mockOptions = { + challenge: 'mock-challenge-string', + rp: { name: 'ManaCore', id: 'localhost' }, + user: { id: 'user-123', name: 'test@example.com', displayName: 'Test User' }, + }; + + // First query: get user; Second query: get existing passkeys + mockDb.setResults([mockUser], []); + (generateRegistrationOptions as jest.Mock).mockResolvedValue(mockOptions); + + const result = await service.generateRegistrationOptions('user-123'); + + expect(result.options).toEqual(mockOptions); + expect(result.challengeId).toBe('mock-nanoid-id'); + expect(generateRegistrationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + rpName: 'ManaCore', + rpID: 'localhost', + userName: 'test@example.com', + userDisplayName: 'Test User', + attestationType: 'none', + excludeCredentials: [], + }) + ); + }); + + it('should exclude existing passkeys', async () => { + const mockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + }; + + const existingPasskeys = [ + { credentialId: 'cred-1', transports: ['usb', 'ble'] }, + { credentialId: 'cred-2', transports: ['internal'] }, + ]; + + mockDb.setResults([mockUser], existingPasskeys); + (generateRegistrationOptions as jest.Mock).mockResolvedValue({ + challenge: 'mock-challenge', + }); + + await service.generateRegistrationOptions('user-123'); + + expect(generateRegistrationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + excludeCredentials: [ + { id: 'cred-1', transports: ['usb', 'ble'] }, + { id: 'cred-2', transports: ['internal'] }, + ], + }) + ); + }); + + it('should throw NotFoundException for non-existent user', async () => { + mockDb.setResults([]); + + await expect(service.generateRegistrationOptions('nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + // ============================================================================ + // verifyRegistration + // ============================================================================ + + describe('verifyRegistration', () => { + const mockCredential = { + id: 'cred-id-123', + rawId: 'raw-id', + response: { attestationObject: 'obj', clientDataJSON: 'json' }, + type: 'public-key', + }; + + it('should store passkey on successful verification', async () => { + // First, generate a registration to store a challenge + const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' }; + mockDb.setResults([mockUser], []); + (generateRegistrationOptions as jest.Mock).mockResolvedValue({ + challenge: 'test-challenge', + }); + const { challengeId } = await service.generateRegistrationOptions('user-123'); + + // Reset DB mock for verification calls + const publicKeyBytes = new Uint8Array([1, 2, 3, 4]); + const mockVerification = { + verified: true, + registrationInfo: { + credential: { + id: 'new-cred-id', + publicKey: publicKeyBytes, + counter: 0, + transports: ['internal'], + }, + credentialDeviceType: 'multiPlatform', + credentialBackedUp: true, + }, + }; + + (verifyRegistrationResponse as jest.Mock).mockResolvedValue(mockVerification); + + const newPasskey = { + id: 'mock-nanoid-id', + credentialId: 'new-cred-id', + deviceType: 'multiPlatform', + friendlyName: 'My Key', + createdAt: new Date(), + }; + + // duplicate check (empty), insert returning + mockDb.setResults([], [newPasskey]); + + const result = await service.verifyRegistration(challengeId, mockCredential as any, 'My Key'); + + expect(result.id).toBe('mock-nanoid-id'); + expect(result.credentialId).toBe('new-cred-id'); + expect(result.deviceType).toBe('multiPlatform'); + expect(result.friendlyName).toBe('My Key'); + expect(verifyRegistrationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedChallenge: 'test-challenge', + expectedOrigin: ['http://localhost:5173'], + expectedRPID: 'localhost', + }) + ); + }); + + it('should throw BadRequestException for expired/invalid challenge', async () => { + await expect( + service.verifyRegistration('nonexistent-challenge', mockCredential as any) + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when verification fails', async () => { + // Store a challenge first + const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' }; + mockDb.setResults([mockUser], []); + (generateRegistrationOptions as jest.Mock).mockResolvedValue({ + challenge: 'test-challenge', + }); + const { challengeId } = await service.generateRegistrationOptions('user-123'); + + (verifyRegistrationResponse as jest.Mock).mockResolvedValue({ + verified: false, + registrationInfo: null, + }); + + await expect(service.verifyRegistration(challengeId, mockCredential as any)).rejects.toThrow( + BadRequestException + ); + }); + + it('should throw ConflictException for duplicate credentialId', async () => { + // Store a challenge first + const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' }; + mockDb.setResults([mockUser], []); + (generateRegistrationOptions as jest.Mock).mockResolvedValue({ + challenge: 'test-challenge', + }); + const { challengeId } = await service.generateRegistrationOptions('user-123'); + + const publicKeyBytes = new Uint8Array([1, 2, 3, 4]); + (verifyRegistrationResponse as jest.Mock).mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: 'existing-cred', + publicKey: publicKeyBytes, + counter: 0, + transports: [], + }, + credentialDeviceType: 'singleDevice', + credentialBackedUp: false, + }, + }); + + // Duplicate check returns existing passkey + mockDb.setResults([{ id: 'existing-pk', credentialId: 'existing-cred' }]); + + await expect(service.verifyRegistration(challengeId, mockCredential as any)).rejects.toThrow( + ConflictException + ); + }); + }); + + // ============================================================================ + // generateAuthenticationOptions + // ============================================================================ + + describe('generateAuthenticationOptions', () => { + it('should return options and challengeId (discoverable credentials)', async () => { + const mockOptions = { + challenge: 'auth-challenge', + rpId: 'localhost', + }; + + (generateAuthenticationOptions as jest.Mock).mockResolvedValue(mockOptions); + + const result = await service.generateAuthenticationOptions(); + + expect(result.options).toEqual(mockOptions); + expect(result.challengeId).toBe('mock-nanoid-id'); + expect(generateAuthenticationOptions).toHaveBeenCalledWith({ + rpID: 'localhost', + userVerification: 'preferred', + }); + }); + }); + + // ============================================================================ + // verifyAuthentication + // ============================================================================ + + describe('verifyAuthentication', () => { + const mockAuthCredential = { + id: 'cred-id-123', + rawId: 'raw-id', + response: { authenticatorData: 'data', clientDataJSON: 'json', signature: 'sig' }, + type: 'public-key', + }; + + it('should return user on successful authentication', async () => { + // Store challenge + (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ + challenge: 'auth-challenge', + }); + const { challengeId } = await service.generateAuthenticationOptions(); + + const mockPasskey = { + id: 'pk-123', + userId: 'user-123', + credentialId: 'cred-id-123', + publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'), + counter: 5, + transports: ['internal'], + }; + + const mockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + deletedAt: null, + }; + + (verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 6 }, + }); + + // find passkey, update counter, get user + mockDb.setResults([mockPasskey], [], [mockUser]); + + const result = await service.verifyAuthentication(challengeId, mockAuthCredential as any); + + expect(result.user).toEqual(mockUser); + expect(result.passkeyId).toBe('pk-123'); + expect(verifyAuthenticationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedChallenge: 'auth-challenge', + expectedOrigin: ['http://localhost:5173'], + expectedRPID: 'localhost', + }) + ); + }); + + it('should update counter and lastUsedAt', async () => { + (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ + challenge: 'auth-challenge', + }); + const { challengeId } = await service.generateAuthenticationOptions(); + + const mockPasskey = { + id: 'pk-123', + userId: 'user-123', + credentialId: 'cred-id-123', + publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'), + counter: 5, + transports: [], + }; + + const mockUser = { id: 'user-123', email: 'test@example.com', deletedAt: null }; + + (verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 10 }, + }); + + mockDb.setResults([mockPasskey], [], [mockUser]); + + await service.verifyAuthentication(challengeId, mockAuthCredential as any); + + // Verify update was called (set is chained) + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + counter: 10, + }) + ); + }); + + it('should throw BadRequestException for unknown credential', async () => { + (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ + challenge: 'auth-challenge', + }); + const { challengeId } = await service.generateAuthenticationOptions(); + + // No passkey found + mockDb.setResults([]); + + await expect( + service.verifyAuthentication(challengeId, mockAuthCredential as any) + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for expired challenge', async () => { + await expect( + service.verifyAuthentication('invalid-challenge', mockAuthCredential as any) + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for deleted user', async () => { + (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ + challenge: 'auth-challenge', + }); + const { challengeId } = await service.generateAuthenticationOptions(); + + const mockPasskey = { + id: 'pk-123', + userId: 'user-123', + credentialId: 'cred-id-123', + publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'), + counter: 5, + transports: [], + }; + + const deletedUser = { + id: 'user-123', + email: 'test@example.com', + deletedAt: new Date(), + }; + + (verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 6 }, + }); + + mockDb.setResults([mockPasskey], [], [deletedUser]); + + await expect( + service.verifyAuthentication(challengeId, mockAuthCredential as any) + ).rejects.toThrow(BadRequestException); + }); + }); + + // ============================================================================ + // listPasskeys + // ============================================================================ + + describe('listPasskeys', () => { + it('should return all passkeys for a user', async () => { + const mockPasskeys = [ + { + id: 'pk-1', + credentialId: 'cred-1', + deviceType: 'multiPlatform', + backedUp: true, + friendlyName: 'My Key', + lastUsedAt: null, + createdAt: new Date(), + }, + { + id: 'pk-2', + credentialId: 'cred-2', + deviceType: 'singleDevice', + backedUp: false, + friendlyName: null, + lastUsedAt: new Date(), + createdAt: new Date(), + }, + ]; + + mockDb.setResults(mockPasskeys); + + const result = await service.listPasskeys('user-123'); + + expect(result).toEqual(mockPasskeys); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should return empty array for user with no passkeys', async () => { + mockDb.setResults([]); + + const result = await service.listPasskeys('user-no-passkeys'); + + expect(result).toEqual([]); + }); + }); + + // ============================================================================ + // deletePasskey + // ============================================================================ + + describe('deletePasskey', () => { + it('should delete passkey owned by user', async () => { + const mockPasskey = { id: 'pk-123', userId: 'user-123', credentialId: 'cred-1' }; + + // First call: find passkey, second call: delete + mockDb.setResults([mockPasskey], []); + + await service.deletePasskey('user-123', 'pk-123'); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent passkey', async () => { + mockDb.setResults([]); + + await expect(service.deletePasskey('user-123', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + // ============================================================================ + // renamePasskey + // ============================================================================ + + describe('renamePasskey', () => { + it('should update friendly name', async () => { + const mockPasskey = { id: 'pk-123', userId: 'user-123', friendlyName: 'Old Name' }; + + mockDb.setResults([mockPasskey], []); + + await service.renamePasskey('user-123', 'pk-123', 'New Name'); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith({ friendlyName: 'New Name' }); + }); + + it('should throw NotFoundException for non-existent passkey', async () => { + mockDb.setResults([]); + + await expect(service.renamePasskey('user-123', 'nonexistent', 'Name')).rejects.toThrow( + NotFoundException + ); + }); + }); + + // ============================================================================ + // Challenge management + // ============================================================================ + + describe('Challenge management', () => { + it('should clean up expired challenges (5-minute TTL)', async () => { + // Generate a challenge + (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ + challenge: 'temp-challenge', + }); + const { challengeId } = await service.generateAuthenticationOptions(); + + // Advance time past the 5-minute TTL + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + // The challenge should now be expired + mockDb.setResults([]); + + await expect( + service.verifyAuthentication(challengeId, { id: 'cred' } as any) + ).rejects.toThrow(BadRequestException); + }); + + it('should consume challenge on use (one-time use)', async () => { + (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ + challenge: 'one-time-challenge', + }); + const { challengeId } = await service.generateAuthenticationOptions(); + + // First use: passkey not found (throws different error), but challenge is consumed + mockDb.setResults([]); + + await expect( + service.verifyAuthentication(challengeId, { id: 'cred' } as any) + ).rejects.toThrow(BadRequestException); + + // Second use: challenge already consumed + await expect( + service.verifyAuthentication(challengeId, { id: 'cred' } as any) + ).rejects.toThrow(BadRequestException); + }); + }); +});