import { Logger } from '@nestjs/common'; import { RecoveryService } from '../src/identity/recovery.service'; import { AuthStore } from '../src/identity/auth.store'; import { RecoveryStore } from '../src/identity/recovery.store'; import { RecoveryMailer } from '../src/identity/recovery-mailer'; import { RateLimitService } from '../src/identity/rate-limit.service'; import { PasswordService } from '../src/identity/password.service'; import { parseEnvironment } from '../src/config/environment'; import { hashToken } from '../src/identity/tokens'; describe('password recovery policy', () => { const users = { findUser: jest.fn() }; const store = { create: jest.fn(), discard: jest.fn(), reset: jest.fn() }; const mailer = { assertConfigured: jest.fn(), send: jest.fn() }; const limits = { consume: jest.fn() }; const passwords = { hash: jest.fn() }; const service = new RecoveryService( users as unknown as AuthStore, store as unknown as RecoveryStore, mailer as unknown as RecoveryMailer, limits as unknown as RateLimitService, passwords as unknown as PasswordService, parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }), ); const input = { organizationId: 'org', email: 'user@example.com' }; beforeEach(() => { jest.resetAllMocks(); users.findUser.mockResolvedValue({ id: 'user', email: input.email, status: 'ACTIVE', }); store.create.mockResolvedValue(true); }); afterEach(() => jest.restoreAllMocks()); it.each([null, { status: 'PENDING' }, { status: 'SUSPENDED' }])( 'does not disclose or deliver for ineligible accounts %j', async (user) => { users.findUser.mockResolvedValue(user); await expect(service.request(input)).resolves.toBeUndefined(); expect(mailer.send).not.toHaveBeenCalled(); }, ); it('does not deliver if eligibility changes during the request', async () => { store.create.mockResolvedValue(false); await service.request(input); expect(mailer.send).not.toHaveBeenCalled(); }); it('discards undelivered tokens without logging the raw SMTP error', async () => { const logger = jest.spyOn(Logger.prototype, 'error').mockImplementation(); mailer.send.mockRejectedValue(new Error('smtp-password-secret')); await service.request(input); expect(store.discard).toHaveBeenCalledWith( expect.stringMatching(/^[a-f0-9]{64}$/), ); expect(JSON.stringify(logger.mock.calls)).not.toContain( 'smtp-password-secret', ); }); it('hashes both password and token before reset persistence', async () => { passwords.hash.mockResolvedValue('hash'); await service.reset('token', 'new password'); expect(store.reset).toHaveBeenCalledWith(hashToken('token'), 'hash'); }); });