test: add MFA service and UserEntity MFA unit tests
Add comprehensive unit tests for TOTP-based MFA: - MfaService: generateSetup, verifyTotp, backup code generation/verification - UserEntity: enableTotp, disableTotp, consumeBackupCode, createNew MFA defaults Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
107
apps/api/src/modules/auth/domain/__tests__/user-mfa.spec.ts
Normal file
107
apps/api/src/modules/auth/domain/__tests__/user-mfa.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { UserEntity, type UserProps } from '../entities/user.entity';
|
||||
import { HashedPassword } from '../value-objects/hashed-password.vo';
|
||||
import { Phone } from '../value-objects/phone.vo';
|
||||
|
||||
function createTestUser(overrides: Partial<UserProps> = {}): UserEntity {
|
||||
const phone = Phone.create('+84912345678').unwrap();
|
||||
const passwordHash = HashedPassword.fromHash('$2b$12$test-hash');
|
||||
|
||||
return new UserEntity('user-1', {
|
||||
email: null,
|
||||
phone,
|
||||
passwordHash,
|
||||
fullName: 'Test User',
|
||||
avatarUrl: null,
|
||||
role: 'AGENT',
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('UserEntity MFA methods', () => {
|
||||
describe('enableTotp', () => {
|
||||
it('sets TOTP secret and enables MFA', () => {
|
||||
const user = createTestUser();
|
||||
|
||||
expect(user.totpEnabled).toBe(false);
|
||||
expect(user.totpSecret).toBeNull();
|
||||
|
||||
user.enableTotp('JBSWY3DPEHPK3PXP', ['hash1', 'hash2']);
|
||||
|
||||
expect(user.totpEnabled).toBe(true);
|
||||
expect(user.totpSecret).toBe('JBSWY3DPEHPK3PXP');
|
||||
expect(user.totpBackupCodes).toEqual(['hash1', 'hash2']);
|
||||
expect(user.totpEnabledAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableTotp', () => {
|
||||
it('clears TOTP fields', () => {
|
||||
const user = createTestUser({
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
totpEnabled: true,
|
||||
totpBackupCodes: ['hash1'],
|
||||
totpEnabledAt: new Date(),
|
||||
});
|
||||
|
||||
user.disableTotp();
|
||||
|
||||
expect(user.totpEnabled).toBe(false);
|
||||
expect(user.totpSecret).toBeNull();
|
||||
expect(user.totpBackupCodes).toEqual([]);
|
||||
expect(user.totpEnabledAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumeBackupCode', () => {
|
||||
it('removes the backup code at the given index', () => {
|
||||
const user = createTestUser({
|
||||
totpBackupCodes: ['hash0', 'hash1', 'hash2', 'hash3'],
|
||||
});
|
||||
|
||||
user.consumeBackupCode(1);
|
||||
|
||||
expect(user.totpBackupCodes).toEqual(['hash0', 'hash2', 'hash3']);
|
||||
});
|
||||
|
||||
it('removes the first backup code', () => {
|
||||
const user = createTestUser({
|
||||
totpBackupCodes: ['hash0', 'hash1', 'hash2'],
|
||||
});
|
||||
|
||||
user.consumeBackupCode(0);
|
||||
|
||||
expect(user.totpBackupCodes).toEqual(['hash1', 'hash2']);
|
||||
});
|
||||
|
||||
it('removes the last backup code', () => {
|
||||
const user = createTestUser({
|
||||
totpBackupCodes: ['hash0', 'hash1', 'hash2'],
|
||||
});
|
||||
|
||||
user.consumeBackupCode(2);
|
||||
|
||||
expect(user.totpBackupCodes).toEqual(['hash0', 'hash1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNew', () => {
|
||||
it('initializes MFA fields to default values', () => {
|
||||
const phone = Phone.create('+84912345678').unwrap();
|
||||
const passwordHash = HashedPassword.fromHash('$2b$12$test');
|
||||
|
||||
const user = UserEntity.createNew('new-1', phone, 'New User', passwordHash);
|
||||
|
||||
expect(user.totpSecret).toBeNull();
|
||||
expect(user.totpEnabled).toBe(false);
|
||||
expect(user.totpBackupCodes).toEqual([]);
|
||||
expect(user.totpEnabledAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MfaService } from '../../infrastructure/services/mfa.service';
|
||||
|
||||
describe('MfaService', () => {
|
||||
let mfaService: MfaService;
|
||||
|
||||
beforeEach(() => {
|
||||
mfaService = new MfaService();
|
||||
});
|
||||
|
||||
describe('generateSetup', () => {
|
||||
it('generates a secret, otpauth URL, and QR code data URL', async () => {
|
||||
const result = await mfaService.generateSetup('user@example.com');
|
||||
|
||||
expect(result.secret).toBeDefined();
|
||||
expect(result.secret.length).toBeGreaterThan(0);
|
||||
expect(result.otpauthUrl).toContain('otpauth://totp/');
|
||||
expect(result.otpauthUrl).toContain('GoodGo%20Platform');
|
||||
expect(result.otpauthUrl).toContain('user%40example.com');
|
||||
expect(result.qrCodeDataUrl).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it('generates unique secrets on each call', async () => {
|
||||
const result1 = await mfaService.generateSetup('user@example.com');
|
||||
const result2 = await mfaService.generateSetup('user@example.com');
|
||||
|
||||
expect(result1.secret).not.toEqual(result2.secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTotp', () => {
|
||||
it('returns false for an invalid code', async () => {
|
||||
const setup = await mfaService.generateSetup('user@example.com');
|
||||
const isValid = await mfaService.verifyTotp('000000', setup.secret);
|
||||
|
||||
// This will almost always be false — a random code is extremely unlikely to match
|
||||
expect(typeof isValid).toBe('boolean');
|
||||
});
|
||||
|
||||
it('returns false for empty code', async () => {
|
||||
const setup = await mfaService.generateSetup('user@example.com');
|
||||
const isValid = await mfaService.verifyTotp('', setup.secret);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBackupCodes', () => {
|
||||
it('generates 10 backup codes', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
|
||||
expect(result.plainCodes).toHaveLength(10);
|
||||
expect(result.hashedCodes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('generates unique codes', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
const uniqueCodes = new Set(result.plainCodes);
|
||||
|
||||
expect(uniqueCodes.size).toBe(10);
|
||||
});
|
||||
|
||||
it('generates 8-character alphanumeric codes', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
|
||||
for (const code of result.plainCodes) {
|
||||
expect(code.length).toBe(8);
|
||||
expect(code).toMatch(/^[A-Z2-9]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('hashed codes are hex strings', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
|
||||
for (const hash of result.hashedCodes) {
|
||||
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 = 64 hex chars
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBackupCode', () => {
|
||||
it('verifies a valid backup code and returns its index', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
const firstCode = result.plainCodes[0]!;
|
||||
|
||||
const index = mfaService.verifyBackupCode(firstCode, result.hashedCodes);
|
||||
|
||||
expect(index).toBe(0);
|
||||
});
|
||||
|
||||
it('returns -1 for invalid backup code', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
|
||||
const index = mfaService.verifyBackupCode('INVALID!', result.hashedCodes);
|
||||
|
||||
expect(index).toBe(-1);
|
||||
});
|
||||
|
||||
it('normalizes code by removing spaces and dashes', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
const firstCode = result.plainCodes[0]!;
|
||||
|
||||
// Add spaces and dashes
|
||||
const codeWithSpaces = `${firstCode.slice(0, 4)} ${firstCode.slice(4)}`;
|
||||
const index = mfaService.verifyBackupCode(codeWithSpaces, result.hashedCodes);
|
||||
|
||||
expect(index).toBe(0);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
const firstCode = result.plainCodes[0]!;
|
||||
|
||||
const index = mfaService.verifyBackupCode(firstCode.toLowerCase(), result.hashedCodes);
|
||||
|
||||
expect(index).toBe(0);
|
||||
});
|
||||
|
||||
it('verifies the correct code among multiple', () => {
|
||||
const result = mfaService.generateBackupCodes();
|
||||
const fifthCode = result.plainCodes[4]!;
|
||||
|
||||
const index = mfaService.verifyBackupCode(fifthCode, result.hashedCodes);
|
||||
|
||||
expect(index).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user