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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 20:35:06 +01:00
parent b7d1d2ec9a
commit e0e9ede885
2 changed files with 1092 additions and 0 deletions

View file

@ -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<PasskeyService>;
let betterAuthService: jest.Mocked<BetterAuthService>;
let securityEventsService: jest.Mocked<SecurityEventsService>;
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>(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();
});
});
});
});

View file

@ -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<typeof createMockDb>;
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: string) => {
const config: Record<string, string> = {
'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>(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);
});
});
});