From cb6664fbf91d58cfb5552888bf4b2e317fb25101 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 11 Apr 2026 23:47:01 +0700 Subject: [PATCH] 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 --- .../auth/domain/__tests__/user-mfa.spec.ts | 107 +++++++++++++++ .../__tests__/mfa.service.spec.ts | 127 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 apps/api/src/modules/auth/domain/__tests__/user-mfa.spec.ts create mode 100644 apps/api/src/modules/auth/infrastructure/__tests__/mfa.service.spec.ts diff --git a/apps/api/src/modules/auth/domain/__tests__/user-mfa.spec.ts b/apps/api/src/modules/auth/domain/__tests__/user-mfa.spec.ts new file mode 100644 index 0000000..790a632 --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/user-mfa.spec.ts @@ -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 = {}): 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(); + }); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/mfa.service.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/mfa.service.spec.ts new file mode 100644 index 0000000..564a0d9 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/mfa.service.spec.ts @@ -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); + }); + }); +});