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:
Ho Ngoc Hai
2026-04-11 23:47:01 +07:00
parent 1fbe2f4e73
commit cb6664fbf9
2 changed files with 234 additions and 0 deletions

View 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();
});
});
});

View File

@@ -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);
});
});
});