mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
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:
parent
b7d1d2ec9a
commit
e0e9ede885
2 changed files with 1092 additions and 0 deletions
480
services/mana-core-auth/src/auth/passkey-controller.spec.ts
Normal file
480
services/mana-core-auth/src/auth/passkey-controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue