test: Add unit tests for 5 high-priority auth services

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 <noreply@anthropic.com>
This commit is contained in:
2025-10-02 19:47:52 +00:00
parent 89dc9a4080
commit 433e869ef3
5 changed files with 1277 additions and 0 deletions

View File

@@ -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<WebAuthnCredential>;
let userRepository: Repository<User>;
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>(BiometricAuthService);
webauthnCredentialRepository = module.get<Repository<WebAuthnCredential>>(
getRepositoryToken(WebAuthnCredential),
);
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
authService = module.get<AuthService>(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);
});
});
});

View File

@@ -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<DeviceRegistry>;
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>(DeviceTrustService);
deviceRepository = module.get<Repository<DeviceRegistry>>(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);
});
});
});

View File

@@ -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<User>;
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>(MFAService);
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
emailService = module.get<EmailService>(EmailService);
configService = module.get<ConfigService>(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,
);
});
});
});

View File

@@ -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<PasswordResetToken>;
let userRepository: Repository<User>;
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>(PasswordResetService);
passwordResetTokenRepository = module.get<Repository<PasswordResetToken>>(getRepositoryToken(PasswordResetToken));
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
emailService = module.get<EmailService>(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);
});
});
});

View File

@@ -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<RefreshToken>;
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>(SessionService);
refreshTokenRepository = module.get<Repository<RefreshToken>>(
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);
});
});
});