From 433e869ef3b727dfd74ed8ea9359721ca662eb6a Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 2 Oct 2025 19:47:52 +0000 Subject: [PATCH] test: Add unit tests for 5 high-priority auth services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created comprehensive test suites for authentication services: - MFA service (477 lines, 28 tests): TOTP setup, email MFA, backup codes - Biometric auth service (287 lines, 18 tests): WebAuthn/FIDO2 credentials - Session service (237 lines, 16 tests): Multi-device session management - Device trust service (134 lines, 10 tests): Device registry and trust - Password reset service (142 lines, 9 tests): Token generation and validation Total: 1,277 lines, 81 test cases Coverage: 27% → ~46% service coverage (12/26 services) All tests follow NestJS testing patterns with: - Mocked repositories and services - Success, error, and edge case coverage - TypeORM repository pattern testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../auth/biometric-auth.service.spec.ts | 287 +++++++++++ .../modules/auth/device-trust.service.spec.ts | 134 +++++ .../src/modules/auth/mfa.service.spec.ts | 477 ++++++++++++++++++ .../auth/password-reset.service.spec.ts | 142 ++++++ .../src/modules/auth/session.service.spec.ts | 237 +++++++++ 5 files changed, 1277 insertions(+) create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.spec.ts create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.spec.ts create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/mfa.service.spec.ts create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.spec.ts create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/session.service.spec.ts diff --git a/maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.spec.ts new file mode 100644 index 0000000..5cbd5a6 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.spec.ts @@ -0,0 +1,287 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { BiometricAuthService } from './biometric-auth.service'; +import { WebAuthnCredential } from './entities/webauthn-credential.entity'; +import { User } from '../../database/entities/user.entity'; +import { AuthService } from './auth.service'; + +describe('BiometricAuthService', () => { + let service: BiometricAuthService; + let webauthnCredentialRepository: Repository; + let userRepository: Repository; + let authService: AuthService; + + const mockUser = { + id: 'user_123', + email: 'test@example.com', + name: 'Test User', + }; + + const mockCredential = { + id: 'cred_123', + userId: 'user_123', + credentialId: 'credential_abc', + publicKey: 'public_key_data', + counter: 0, + transports: ['internal'], + friendlyName: 'iPhone 14', + lastUsedAt: new Date(), + createdAt: new Date(), + }; + + const mockWebAuthnCredentialRepository = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + }; + + const mockAuthService = { + generateTokens: jest.fn().mockResolvedValue({ + accessToken: 'access_token', + refreshToken: 'refresh_token', + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BiometricAuthService, + { + provide: getRepositoryToken(WebAuthnCredential), + useValue: mockWebAuthnCredentialRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: AuthService, + useValue: mockAuthService, + }, + ], + }).compile(); + + service = module.get(BiometricAuthService); + webauthnCredentialRepository = module.get>( + getRepositoryToken(WebAuthnCredential), + ); + userRepository = module.get>(getRepositoryToken(User)); + authService = module.get(AuthService); + + // Reset mocks + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateRegistrationOptions', () => { + it('should generate registration options for a user', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockWebAuthnCredentialRepository.find.mockResolvedValue([]); + + const result = await service.generateRegistrationOptions({ + userId: 'user_123', + friendlyName: 'iPhone 14', + }); + + expect(result).toHaveProperty('challenge'); + expect(result).toHaveProperty('rp'); + expect(result).toHaveProperty('user'); + expect(result.rp.name).toBe('Maternal Care App'); + expect(result.user.name).toBe(mockUser.email); + expect(result.user.displayName).toBe(mockUser.name); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user_123' }, + }); + }); + + it('should exclude existing credentials', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockWebAuthnCredentialRepository.find.mockResolvedValue([mockCredential]); + + const result = await service.generateRegistrationOptions({ + userId: 'user_123', + }); + + expect(result.excludeCredentials).toHaveLength(1); + expect(result.excludeCredentials[0].id).toBe(mockCredential.credentialId); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect( + service.generateRegistrationOptions({ userId: 'user_123' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getUserCredentials', () => { + it('should return all credentials for a user', async () => { + mockWebAuthnCredentialRepository.find.mockResolvedValue([mockCredential]); + + const result = await service.getUserCredentials('user_123'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(mockCredential); + expect(webauthnCredentialRepository.find).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + order: { createdAt: 'DESC' }, + }); + }); + + it('should return empty array if no credentials', async () => { + mockWebAuthnCredentialRepository.find.mockResolvedValue([]); + + const result = await service.getUserCredentials('user_123'); + + expect(result).toHaveLength(0); + }); + }); + + describe('deleteCredential', () => { + it('should delete a credential', async () => { + mockWebAuthnCredentialRepository.findOne.mockResolvedValue(mockCredential); + mockWebAuthnCredentialRepository.delete.mockResolvedValue({ affected: 1 }); + + const result = await service.deleteCredential('user_123', 'cred_123'); + + expect(result.success).toBe(true); + expect(result.message).toContain('deleted successfully'); + expect(webauthnCredentialRepository.delete).toHaveBeenCalledWith( + 'cred_123', + ); + }); + + it('should throw NotFoundException if credential not found', async () => { + mockWebAuthnCredentialRepository.findOne.mockResolvedValue(null); + + await expect( + service.deleteCredential('user_123', 'cred_123'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if credential belongs to another user', async () => { + mockWebAuthnCredentialRepository.findOne.mockResolvedValue({ + ...mockCredential, + userId: 'other_user', + }); + + await expect( + service.deleteCredential('user_123', 'cred_123'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('updateCredentialName', () => { + it('should update credential friendly name', async () => { + mockWebAuthnCredentialRepository.findOne.mockResolvedValue(mockCredential); + mockWebAuthnCredentialRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.updateCredentialName( + 'user_123', + 'cred_123', + 'My New Phone', + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('updated successfully'); + expect(webauthnCredentialRepository.update).toHaveBeenCalledWith( + 'cred_123', + { friendlyName: 'My New Phone' }, + ); + }); + + it('should throw NotFoundException if credential not found', async () => { + mockWebAuthnCredentialRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateCredentialName('user_123', 'cred_123', 'New Name'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if credential belongs to another user', async () => { + mockWebAuthnCredentialRepository.findOne.mockResolvedValue({ + ...mockCredential, + userId: 'other_user', + }); + + await expect( + service.updateCredentialName('user_123', 'cred_123', 'New Name'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('hasCredentials', () => { + it('should return true if user has credentials', async () => { + mockWebAuthnCredentialRepository.find.mockResolvedValue([mockCredential]); + + const result = await service.hasCredentials('user_123'); + + expect(result).toBe(true); + }); + + it('should return false if user has no credentials', async () => { + mockWebAuthnCredentialRepository.find.mockResolvedValue([]); + + const result = await service.hasCredentials('user_123'); + + expect(result).toBe(false); + }); + }); + + describe('generateAuthenticationOptions', () => { + it('should generate authentication options with email', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockWebAuthnCredentialRepository.find.mockResolvedValue([mockCredential]); + + const result = await service.generateAuthenticationOptions({ + email: 'test@example.com', + }); + + expect(result).toHaveProperty('challenge'); + expect(result).toHaveProperty('allowCredentials'); + expect(result.allowCredentials).toHaveLength(1); + expect(result.allowCredentials[0].id).toBe(mockCredential.credentialId); + }); + + it('should generate authentication options without email', async () => { + const result = await service.generateAuthenticationOptions({}); + + expect(result).toHaveProperty('challenge'); + expect(result.allowCredentials).toBeUndefined(); + }); + + it('should throw BadRequestException if user has no credentials', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockWebAuthnCredentialRepository.find.mockResolvedValue([]); + + await expect( + service.generateAuthenticationOptions({ email: 'test@example.com' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect( + service.generateAuthenticationOptions({ email: 'test@example.com' }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.spec.ts new file mode 100644 index 0000000..575be3a --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { DeviceTrustService } from './device-trust.service'; +import { DeviceRegistry } from './entities/device-registry.entity'; + +describe('DeviceTrustService', () => { + let service: DeviceTrustService; + let deviceRepository: Repository; + + const mockDevice = { + id: 'device_123', + userId: 'user_123', + deviceId: 'fingerprint_abc', + isTrusted: false, + deviceInfo: { + platform: 'ios', + model: 'iPhone 14', + osVersion: '17.0', + }, + lastSeenAt: new Date(), + createdAt: new Date(), + }; + + const mockDeviceRepository = { + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeviceTrustService, + { + provide: getRepositoryToken(DeviceRegistry), + useValue: mockDeviceRepository, + }, + ], + }).compile(); + + service = module.get(DeviceTrustService); + deviceRepository = module.get>(getRepositoryToken(DeviceRegistry)); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getUserDevices', () => { + it('should return all devices for a user', async () => { + mockDeviceRepository.find.mockResolvedValue([mockDevice]); + const result = await service.getUserDevices('user_123'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ + id: 'device_123', + deviceId: 'fingerprint_abc', + isTrusted: false, + })); + }); + }); + + describe('getTrustedDevices', () => { + it('should return only trusted devices', async () => { + const trustedDevice = { ...mockDevice, isTrusted: true }; + mockDeviceRepository.find.mockResolvedValue([trustedDevice]); + const result = await service.getTrustedDevices('user_123'); + expect(result).toHaveLength(1); + expect(result[0].isTrusted).toBe(true); + }); + }); + + describe('trustDevice', () => { + it('should mark device as trusted', async () => { + mockDeviceRepository.findOne.mockResolvedValue(mockDevice); + mockDeviceRepository.update.mockResolvedValue({ affected: 1 }); + const result = await service.trustDevice('user_123', 'device_123'); + expect(result.success).toBe(true); + expect(deviceRepository.update).toHaveBeenCalledWith('device_123', { isTrusted: true }); + }); + + it('should throw NotFoundException if device not found', async () => { + mockDeviceRepository.findOne.mockResolvedValue(null); + await expect(service.trustDevice('user_123', 'device_123')).rejects.toThrow(NotFoundException); + }); + }); + + describe('revokeDeviceTrust', () => { + it('should revoke device trust', async () => { + mockDeviceRepository.findOne.mockResolvedValue({ ...mockDevice, isTrusted: true }); + mockDeviceRepository.update.mockResolvedValue({ affected: 1 }); + const result = await service.revokeDeviceTrust('user_123', 'device_123'); + expect(result.success).toBe(true); + expect(deviceRepository.update).toHaveBeenCalledWith('device_123', { isTrusted: false }); + }); + }); + + describe('removeDevice', () => { + it('should remove a device', async () => { + mockDeviceRepository.findOne.mockResolvedValue(mockDevice); + mockDeviceRepository.delete.mockResolvedValue({ affected: 1 }); + const result = await service.removeDevice('user_123', 'device_123'); + expect(result.success).toBe(true); + expect(deviceRepository.delete).toHaveBeenCalledWith('device_123'); + }); + }); + + describe('getDeviceCount', () => { + it('should return device counts', async () => { + mockDeviceRepository.count.mockResolvedValueOnce(5).mockResolvedValueOnce(3); + const result = await service.getDeviceCount('user_123'); + expect(result.total).toBe(5); + expect(result.trusted).toBe(3); + }); + }); + + describe('isDeviceTrusted', () => { + it('should return true for trusted device', async () => { + mockDeviceRepository.findOne.mockResolvedValue({ ...mockDevice, isTrusted: true }); + const result = await service.isDeviceTrusted('user_123', 'device_123'); + expect(result).toBe(true); + }); + + it('should return false for untrusted device', async () => { + mockDeviceRepository.findOne.mockResolvedValue(mockDevice); + const result = await service.isDeviceTrusted('user_123', 'device_123'); + expect(result).toBe(false); + }); + }); +}); diff --git a/maternal-app/maternal-app-backend/src/modules/auth/mfa.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/auth/mfa.service.spec.ts new file mode 100644 index 0000000..a8992dc --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/mfa.service.spec.ts @@ -0,0 +1,477 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { MFAService } from './mfa.service'; +import { User } from '../../database/entities'; +import { EmailService } from '../../common/services/email.service'; +import { ConfigService } from '@nestjs/config'; +import { authenticator } from 'otplib'; +import * as bcrypt from 'bcrypt'; + +describe('MFAService', () => { + let service: MFAService; + let userRepository: Repository; + let emailService: EmailService; + let configService: ConfigService; + + const mockUser = { + id: 'user_123', + email: 'test@example.com', + name: 'Test User', + mfaEnabled: false, + mfaMethod: null, + totpSecret: null, + mfaBackupCodes: [], + emailMfaCode: null, + emailMfaCodeExpiresAt: null, + }; + + const mockUserRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + const mockEmailService = { + sendEmail: jest.fn().mockResolvedValue(true), + }; + + const mockConfigService = { + get: jest.fn().mockReturnValue('Maternal App'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MFAService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: EmailService, + useValue: mockEmailService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(MFAService); + userRepository = module.get>(getRepositoryToken(User)); + emailService = module.get(EmailService); + configService = module.get(ConfigService); + + // Reset mocks + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getMFAStatus', () => { + it('should return MFA status for a user', async () => { + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'totp', + mfaBackupCodes: ['hashed_code_1', 'hashed_code_2'], + }); + + const result = await service.getMFAStatus('user_123'); + + expect(result).toEqual({ + enabled: true, + method: 'totp', + hasBackupCodes: true, + }); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user_123' }, + select: ['id', 'mfaEnabled', 'mfaMethod', 'mfaBackupCodes'], + }); + }); + + it('should return disabled status when MFA is not enabled', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.getMFAStatus('user_123'); + + expect(result).toEqual({ + enabled: false, + method: undefined, + hasBackupCodes: false, + }); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.getMFAStatus('user_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('setupTOTP', () => { + it('should generate TOTP secret, QR code, and backup codes', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.setupTOTP('user_123'); + + expect(result).toHaveProperty('secret'); + expect(result).toHaveProperty('qrCodeUrl'); + expect(result).toHaveProperty('backupCodes'); + expect(result.secret).toBeTruthy(); + expect(result.qrCodeUrl).toContain('data:image/png;base64'); + expect(result.backupCodes).toHaveLength(10); + expect(userRepository.update).toHaveBeenCalledWith( + 'user_123', + expect.objectContaining({ + totpSecret: expect.any(String), + mfaBackupCodes: expect.any(Array), + }), + ); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.setupTOTP('user_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('verifyAndEnableTOTP', () => { + it('should enable TOTP MFA with valid code', async () => { + const totpSecret = authenticator.generateSecret(); + const validCode = authenticator.generate(totpSecret); + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + totpSecret, + }); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.verifyAndEnableTOTP('user_123', validCode); + + expect(result.success).toBe(true); + expect(result.message).toContain('enabled successfully'); + expect(userRepository.update).toHaveBeenCalledWith('user_123', { + mfaEnabled: true, + mfaMethod: 'totp', + }); + }); + + it('should throw BadRequestException for invalid TOTP code', async () => { + const totpSecret = authenticator.generateSecret(); + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + totpSecret, + }); + + await expect( + service.verifyAndEnableTOTP('user_123', '000000'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException if TOTP not set up', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + await expect( + service.verifyAndEnableTOTP('user_123', '123456'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect( + service.verifyAndEnableTOTP('user_123', '123456'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('setupEmailMFA', () => { + it('should enable Email MFA and send confirmation email', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.setupEmailMFA('user_123'); + + expect(result.success).toBe(true); + expect(result.message).toContain('enabled successfully'); + expect(userRepository.update).toHaveBeenCalledWith( + 'user_123', + expect.objectContaining({ + mfaEnabled: true, + mfaMethod: 'email', + mfaBackupCodes: expect.any(Array), + }), + ); + expect(emailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: mockUser.email, + subject: expect.stringContaining('Two-Factor Authentication'), + }), + ); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.setupEmailMFA('user_123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should still succeed if email sending fails', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + mockEmailService.sendEmail.mockRejectedValue( + new Error('Email service down'), + ); + + const result = await service.setupEmailMFA('user_123'); + + expect(result.success).toBe(true); + // MFA should still be enabled even if email fails + expect(userRepository.update).toHaveBeenCalled(); + }); + }); + + describe('sendEmailMFACode', () => { + it('should generate and send 6-digit code via email', async () => { + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'email', + }); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.sendEmailMFACode('user_123'); + + expect(result.success).toBe(true); + expect(result.message).toContain('Verification code sent'); + expect(userRepository.update).toHaveBeenCalledWith( + 'user_123', + expect.objectContaining({ + emailMfaCode: expect.any(String), + emailMfaCodeExpiresAt: expect.any(Date), + }), + ); + expect(emailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: mockUser.email, + subject: expect.stringContaining('verification code'), + }), + ); + }); + + it('should throw BadRequestException if Email MFA not enabled', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + await expect(service.sendEmailMFACode('user_123')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.sendEmailMFACode('user_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('verifyMFACode', () => { + it('should verify valid TOTP code', async () => { + const totpSecret = authenticator.generateSecret(); + const validCode = authenticator.generate(totpSecret); + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'totp', + totpSecret, + }); + + const result = await service.verifyMFACode('user_123', validCode); + + expect(result.success).toBe(true); + }); + + it('should reject invalid TOTP code', async () => { + const totpSecret = authenticator.generateSecret(); + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'totp', + totpSecret, + }); + + await expect(service.verifyMFACode('user_123', '000000')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should verify valid email MFA code', async () => { + const code = '123456'; + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + 5); + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'email', + emailMfaCode: code, + emailMfaCodeExpiresAt: expiresAt, + }); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.verifyMFACode('user_123', code); + + expect(result.success).toBe(true); + // Code should be cleared after use + expect(userRepository.update).toHaveBeenCalledWith('user_123', { + emailMfaCode: null, + emailMfaCodeExpiresAt: null, + }); + }); + + it('should reject expired email MFA code', async () => { + const code = '123456'; + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() - 1); // Expired 1 minute ago + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'email', + emailMfaCode: code, + emailMfaCodeExpiresAt: expiresAt, + }); + + await expect(service.verifyMFACode('user_123', code)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should verify valid backup code and mark it as used', async () => { + const backupCode = 'BACKUP-123456'; + const hashedBackupCode = await bcrypt.hash(backupCode, 10); + + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'totp', + mfaBackupCodes: [hashedBackupCode, 'other_hashed_code'], + }); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.verifyMFACode('user_123', backupCode); + + expect(result.success).toBe(true); + // Backup code should be removed after use + expect(userRepository.update).toHaveBeenCalledWith( + 'user_123', + expect.objectContaining({ + mfaBackupCodes: expect.any(Array), + }), + ); + }); + + it('should throw BadRequestException if MFA not enabled', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + await expect(service.verifyMFACode('user_123', '123456')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('disableMFA', () => { + it('should disable MFA and clear all MFA data', async () => { + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'totp', + }); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.disableMFA('user_123'); + + expect(result.success).toBe(true); + expect(result.message).toContain('disabled successfully'); + expect(userRepository.update).toHaveBeenCalledWith('user_123', { + mfaEnabled: false, + mfaMethod: null, + totpSecret: null, + emailMfaCode: null, + emailMfaCodeExpiresAt: null, + mfaBackupCodes: [], + }); + }); + + it('should throw BadRequestException if MFA not enabled', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + await expect(service.disableMFA('user_123')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.disableMFA('user_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('regenerateBackupCodes', () => { + it('should generate new backup codes', async () => { + mockUserRepository.findOne.mockResolvedValue({ + ...mockUser, + mfaEnabled: true, + mfaMethod: 'totp', + }); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.regenerateBackupCodes('user_123'); + + expect(result).toHaveLength(10); + expect(result[0]).toMatch(/^BACKUP-[A-Z0-9]{6}$/); + expect(userRepository.update).toHaveBeenCalledWith( + 'user_123', + expect.objectContaining({ + mfaBackupCodes: expect.any(Array), + }), + ); + }); + + it('should throw BadRequestException if MFA not enabled', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + await expect(service.regenerateBackupCodes('user_123')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.regenerateBackupCodes('user_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.spec.ts new file mode 100644 index 0000000..b71f254 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { PasswordResetService } from './password-reset.service'; +import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { User } from '../../database/entities/user.entity'; +import { EmailService } from '../../common/services/email.service'; +import * as bcrypt from 'bcrypt'; + +describe('PasswordResetService', () => { + let service: PasswordResetService; + let passwordResetTokenRepository: Repository; + let userRepository: Repository; + let emailService: EmailService; + + const mockUser = { + id: 'user_123', + email: 'test@example.com', + name: 'Test User', + passwordHash: 'old_hash', + }; + + const mockToken = { + id: 'reset_123', + userId: 'user_123', + token: 'reset_token_abc', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + used: false, + createdAt: new Date(), + }; + + const mockPasswordResetTokenRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + const mockEmailService = { + sendEmail: jest.fn().mockResolvedValue(true), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PasswordResetService, + { provide: getRepositoryToken(PasswordResetToken), useValue: mockPasswordResetTokenRepository }, + { provide: getRepositoryToken(User), useValue: mockUserRepository }, + { provide: EmailService, useValue: mockEmailService }, + ], + }).compile(); + + service = module.get(PasswordResetService); + passwordResetTokenRepository = module.get>(getRepositoryToken(PasswordResetToken)); + userRepository = module.get>(getRepositoryToken(User)); + emailService = module.get(EmailService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('requestPasswordReset', () => { + it('should create reset token and send email', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockPasswordResetTokenRepository.create.mockReturnValue(mockToken); + mockPasswordResetTokenRepository.save.mockResolvedValue(mockToken); + + const result = await service.requestPasswordReset('test@example.com'); + + expect(result.success).toBe(true); + expect(passwordResetTokenRepository.save).toHaveBeenCalled(); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ + to: mockUser.email, + subject: expect.stringContaining('Password Reset'), + })); + }); + + it('should not reveal if email does not exist', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + const result = await service.requestPasswordReset('nonexistent@example.com'); + expect(result.success).toBe(true); + expect(emailService.sendEmail).not.toHaveBeenCalled(); + }); + }); + + describe('verifyResetToken', () => { + it('should verify valid token', async () => { + mockPasswordResetTokenRepository.findOne.mockResolvedValue(mockToken); + const result = await service.verifyResetToken('reset_token_abc'); + expect(result.valid).toBe(true); + }); + + it('should reject expired token', async () => { + const expiredToken = { ...mockToken, expiresAt: new Date(Date.now() - 1000) }; + mockPasswordResetTokenRepository.findOne.mockResolvedValue(expiredToken); + const result = await service.verifyResetToken('reset_token_abc'); + expect(result.valid).toBe(false); + }); + + it('should reject used token', async () => { + mockPasswordResetTokenRepository.findOne.mockResolvedValue({ ...mockToken, used: true }); + const result = await service.verifyResetToken('reset_token_abc'); + expect(result.valid).toBe(false); + }); + }); + + describe('resetPassword', () => { + it('should reset password with valid token', async () => { + mockPasswordResetTokenRepository.findOne.mockResolvedValue(mockToken); + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + mockPasswordResetTokenRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.resetPassword('reset_token_abc', 'NewPassword123!'); + + expect(result.success).toBe(true); + expect(userRepository.update).toHaveBeenCalledWith('user_123', expect.objectContaining({ + passwordHash: expect.any(String), + })); + expect(passwordResetTokenRepository.update).toHaveBeenCalledWith('reset_123', { used: true }); + }); + + it('should throw BadRequestException for invalid token', async () => { + mockPasswordResetTokenRepository.findOne.mockResolvedValue(null); + await expect(service.resetPassword('invalid_token', 'NewPassword123!')).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for expired token', async () => { + const expiredToken = { ...mockToken, expiresAt: new Date(Date.now() - 1000) }; + mockPasswordResetTokenRepository.findOne.mockResolvedValue(expiredToken); + await expect(service.resetPassword('reset_token_abc', 'NewPassword123!')).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/maternal-app/maternal-app-backend/src/modules/auth/session.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/auth/session.service.spec.ts new file mode 100644 index 0000000..2b8abf3 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/session.service.spec.ts @@ -0,0 +1,237 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { SessionService } from './session.service'; +import { RefreshToken } from './entities/refresh-token.entity'; + +describe('SessionService', () => { + let service: SessionService; + let refreshTokenRepository: Repository; + + const mockRefreshToken = { + id: 'token_123', + userId: 'user_123', + token: 'refresh_token_abc', + deviceId: 'device_123', + deviceInfo: { + platform: 'ios', + browser: 'Safari', + os: 'iOS 17', + ipAddress: '192.168.1.1', + }, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + lastUsedAt: new Date(), + createdAt: new Date(), + }; + + const mockRefreshTokenRepository = { + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionService, + { + provide: getRepositoryToken(RefreshToken), + useValue: mockRefreshTokenRepository, + }, + ], + }).compile(); + + service = module.get(SessionService); + refreshTokenRepository = module.get>( + getRepositoryToken(RefreshToken), + ); + + // Reset mocks + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getUserSessions', () => { + it('should return all active sessions for a user', async () => { + const sessions = [ + mockRefreshToken, + { ...mockRefreshToken, id: 'token_456', deviceId: 'device_456' }, + ]; + mockRefreshTokenRepository.find.mockResolvedValue(sessions); + + const result = await service.getUserSessions('user_123'); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('id'); + expect(result[0]).toHaveProperty('deviceInfo'); + expect(result[0]).toHaveProperty('lastUsedAt'); + expect(refreshTokenRepository.find).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + order: { lastUsedAt: 'DESC' }, + }); + }); + + it('should return empty array if no sessions', async () => { + mockRefreshTokenRepository.find.mockResolvedValue([]); + + const result = await service.getUserSessions('user_123'); + + expect(result).toHaveLength(0); + }); + + it('should format session data correctly', async () => { + mockRefreshTokenRepository.find.mockResolvedValue([mockRefreshToken]); + + const result = await service.getUserSessions('user_123'); + + expect(result[0]).toEqual({ + id: mockRefreshToken.id, + deviceId: mockRefreshToken.deviceId, + deviceInfo: mockRefreshToken.deviceInfo, + lastUsedAt: mockRefreshToken.lastUsedAt, + createdAt: mockRefreshToken.createdAt, + expiresAt: mockRefreshToken.expiresAt, + isCurrent: false, + }); + }); + + it('should mark current session correctly', async () => { + mockRefreshTokenRepository.find.mockResolvedValue([mockRefreshToken]); + + const result = await service.getUserSessions('user_123', 'token_123'); + + expect(result[0].isCurrent).toBe(true); + }); + }); + + describe('revokeSession', () => { + it('should revoke a specific session', async () => { + mockRefreshTokenRepository.findOne.mockResolvedValue(mockRefreshToken); + mockRefreshTokenRepository.delete.mockResolvedValue({ affected: 1 }); + + const result = await service.revokeSession('user_123', 'token_123'); + + expect(result.success).toBe(true); + expect(result.message).toContain('Session revoked successfully'); + expect(refreshTokenRepository.delete).toHaveBeenCalledWith({ + id: 'token_123', + }); + }); + + it('should throw NotFoundException if session not found', async () => { + mockRefreshTokenRepository.findOne.mockResolvedValue(null); + + await expect( + service.revokeSession('user_123', 'token_123'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if session belongs to another user', async () => { + mockRefreshTokenRepository.findOne.mockResolvedValue({ + ...mockRefreshToken, + userId: 'other_user', + }); + + await expect( + service.revokeSession('user_123', 'token_123'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('revokeAllSessions', () => { + it('should revoke all sessions for a user', async () => { + mockRefreshTokenRepository.delete.mockResolvedValue({ affected: 3 }); + + const result = await service.revokeAllSessions('user_123'); + + expect(result.success).toBe(true); + expect(result.sessionsRevoked).toBe(3); + expect(result.message).toContain('3 sessions revoked successfully'); + expect(refreshTokenRepository.delete).toHaveBeenCalledWith({ + userId: 'user_123', + }); + }); + + it('should succeed even if no sessions to revoke', async () => { + mockRefreshTokenRepository.delete.mockResolvedValue({ affected: 0 }); + + const result = await service.revokeAllSessions('user_123'); + + expect(result.success).toBe(true); + expect(result.sessionsRevoked).toBe(0); + }); + + it('should exclude current session if sessionId provided', async () => { + mockRefreshTokenRepository.delete.mockResolvedValue({ affected: 2 }); + + const result = await service.revokeAllSessions('user_123', 'token_current'); + + expect(result.success).toBe(true); + expect(result.sessionsRevoked).toBe(2); + // Should delete all except current + expect(refreshTokenRepository.delete).toHaveBeenCalled(); + }); + }); + + describe('getActiveSessionCount', () => { + it('should return count of active sessions', async () => { + mockRefreshTokenRepository.count.mockResolvedValue(5); + + const result = await service.getActiveSessionCount('user_123'); + + expect(result).toBe(5); + expect(refreshTokenRepository.count).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + }); + }); + + it('should return 0 if no active sessions', async () => { + mockRefreshTokenRepository.count.mockResolvedValue(0); + + const result = await service.getActiveSessionCount('user_123'); + + expect(result).toBe(0); + }); + }); + + describe('isSessionValid', () => { + it('should return true for valid session', async () => { + mockRefreshTokenRepository.findOne.mockResolvedValue(mockRefreshToken); + + const result = await service.isSessionValid('token_123', 'user_123'); + + expect(result).toBe(true); + expect(refreshTokenRepository.findOne).toHaveBeenCalledWith({ + where: { + id: 'token_123', + userId: 'user_123', + }, + }); + }); + + it('should return false for non-existent session', async () => { + mockRefreshTokenRepository.findOne.mockResolvedValue(null); + + const result = await service.isSessionValid('token_123', 'user_123'); + + expect(result).toBe(false); + }); + + it('should return false for expired session', async () => { + const expiredToken = { + ...mockRefreshToken, + expiresAt: new Date(Date.now() - 1000), // Expired 1 second ago + }; + mockRefreshTokenRepository.findOne.mockResolvedValue(expiredToken); + + const result = await service.isSessionValid('token_123', 'user_123'); + + expect(result).toBe(false); + }); + }); +});