import { describe, it, expect, vi } from 'vitest'; import { RequestPasswordReset, ConfirmPasswordReset, hashToken } from '../application/password-reset.js'; import { InvalidResetTokenError, isTokenUsable, RESET_TOKEN_TTL_MS } from '../domain/password-reset.js'; import type { UserRepository, PasswordResetTokenRepository, ResetRateLimiter, PasswordResetMailer, PasswordHasher, } from '../domain/ports.js'; import type { PasswordResetToken } from '../domain/password-reset.js'; function makeToken(overrides: Partial = {}): PasswordResetToken { return { id: 'tok-1', userId: 'user-1', tokenHash: 'h', expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS), usedAt: null, createdAt: new Date(), ...overrides, }; } describe('password reset', () => { it('hashToken is sha256 hex', () => { const a = hashToken('hello'); const b = hashToken('hello'); const c = hashToken('hellp'); expect(a).toBe(b); expect(a).not.toBe(c); expect(a).toMatch(/^[0-9a-f]{64}$/); }); it('isTokenUsable returns false for used tokens', () => { const t = makeToken({ usedAt: new Date() }); expect(isTokenUsable(t)).toBe(false); }); it('isTokenUsable returns false for expired tokens', () => { const t = makeToken({ expiresAt: new Date(Date.now() - 1000) }); expect(isTokenUsable(t)).toBe(false); }); it('RequestPasswordReset is silent when user does not exist (no enumeration)', async () => { const users: UserRepository = { create: vi.fn(), findByEmail: vi.fn().mockResolvedValue(undefined), findById: vi.fn(), listUsers: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), findByConfirmationToken: vi.fn(), confirmByToken: vi.fn(), }; const tokens: PasswordResetTokenRepository = { invalidateAllForUser: vi.fn(), create: vi.fn(), findByTokenHash: vi.fn(), markUsed: vi.fn(), }; const rateLimiter: ResetRateLimiter = { allow: vi.fn().mockResolvedValue(true) }; const mailer: PasswordResetMailer = { sendPasswordReset: vi.fn() }; const audit = vi.fn(); const useCase = new RequestPasswordReset({ users, tokens, rateLimiter, mailer, buildResetUrl: (t) => `https://app/r/${t}`, audit, }); await useCase.execute({ email: 'nobody@example.com' }); expect(tokens.create).not.toHaveBeenCalled(); expect(mailer.sendPasswordReset).not.toHaveBeenCalled(); expect(audit).not.toHaveBeenCalled(); }); it('RequestPasswordReset creates a token and emails when user exists', async () => { const fakeUser = { id: 'u-1', email: 'a@b.com', role: 'customer' as const, passwordHash: 'x', createdAt: new Date() }; const users: UserRepository = { create: vi.fn(), findByEmail: vi.fn().mockResolvedValue(fakeUser), findById: vi.fn(), listUsers: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), findByConfirmationToken: vi.fn(), confirmByToken: vi.fn(), }; const tokens: PasswordResetTokenRepository = { invalidateAllForUser: vi.fn().mockResolvedValue(undefined), create: vi.fn().mockResolvedValue(makeToken()), findByTokenHash: vi.fn(), markUsed: vi.fn(), }; const rateLimiter: ResetRateLimiter = { allow: vi.fn().mockResolvedValue(true) }; const mailer: PasswordResetMailer = { sendPasswordReset: vi.fn().mockResolvedValue(undefined) }; const audit = vi.fn(); const useCase = new RequestPasswordReset({ users, tokens, rateLimiter, mailer, buildResetUrl: (t) => `https://app/r/${t}`, audit, }); await useCase.execute({ email: 'a@b.com', ip: '127.0.0.1' }); expect(tokens.invalidateAllForUser).toHaveBeenCalledWith('u-1'); expect(tokens.create).toHaveBeenCalledOnce(); expect(mailer.sendPasswordReset).toHaveBeenCalledOnce(); expect(audit).toHaveBeenCalledWith(expect.objectContaining({ action: 'password_reset.requested' })); }); it('RequestPasswordReset drops the request silently when rate limited', async () => { const users: UserRepository = { create: vi.fn(), findByEmail: vi.fn(), findById: vi.fn(), listUsers: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), findByConfirmationToken: vi.fn(), confirmByToken: vi.fn(), }; const tokens: PasswordResetTokenRepository = { invalidateAllForUser: vi.fn(), create: vi.fn(), findByTokenHash: vi.fn(), markUsed: vi.fn(), }; const rateLimiter: ResetRateLimiter = { allow: vi.fn().mockResolvedValue(false) }; const mailer: PasswordResetMailer = { sendPasswordReset: vi.fn() }; const useCase = new RequestPasswordReset({ users, tokens, rateLimiter, mailer, buildResetUrl: (t) => t, }); await useCase.execute({ email: 'a@b.com' }); expect(tokens.create).not.toHaveBeenCalled(); expect(users.findByEmail).not.toHaveBeenCalled(); }); it('ConfirmPasswordReset updates password and marks token used on success', async () => { const valid = makeToken({ userId: 'u-1', tokenHash: hashToken('tok') }); const tokens: PasswordResetTokenRepository = { invalidateAllForUser: vi.fn(), create: vi.fn(), findByTokenHash: vi.fn().mockResolvedValue(valid), markUsed: vi.fn().mockResolvedValue(undefined), }; const users: UserRepository = { create: vi.fn(), findByEmail: vi.fn(), findById: vi.fn(), listUsers: vi.fn(), updateUser: vi.fn().mockResolvedValue({ id: 'u-1', email: 'a', role: 'customer', createdAt: new Date() }), deleteUser: vi.fn(), findByConfirmationToken: vi.fn(), confirmByToken: vi.fn(), }; const hasher: PasswordHasher = { hash: vi.fn().mockResolvedValue('NEWHASH'), verify: vi.fn() }; const audit = vi.fn(); const useCase = new ConfirmPasswordReset({ tokens, users, hasher, audit }); await useCase.execute({ token: 'tok', newPassword: 'newpassword123' }); expect(hasher.hash).toHaveBeenCalledWith('newpassword123'); expect(users.updateUser).toHaveBeenCalledWith('u-1', { passwordHash: 'NEWHASH' }); expect(tokens.markUsed).toHaveBeenCalledWith('tok-1'); expect(audit).toHaveBeenCalledWith(expect.objectContaining({ action: 'password_reset.confirmed' })); }); it('ConfirmPasswordReset rejects expired/used/missing tokens', async () => { const tokens: PasswordResetTokenRepository = { invalidateAllForUser: vi.fn(), create: vi.fn(), findByTokenHash: vi.fn().mockResolvedValue(undefined), markUsed: vi.fn(), }; const users: UserRepository = { create: vi.fn(), findByEmail: vi.fn(), findById: vi.fn(), listUsers: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), findByConfirmationToken: vi.fn(), confirmByToken: vi.fn(), }; const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() }; const useCase = new ConfirmPasswordReset({ tokens, users, hasher }); await expect( useCase.execute({ token: 'tok', newPassword: 'newpassword123' }), ).rejects.toBeInstanceOf(InvalidResetTokenError); }); it('ConfirmPasswordReset rejects passwords shorter than 8 chars', async () => { const tokens: PasswordResetTokenRepository = { invalidateAllForUser: vi.fn(), create: vi.fn(), findByTokenHash: vi.fn(), markUsed: vi.fn(), }; const users: UserRepository = { create: vi.fn(), findByEmail: vi.fn(), findById: vi.fn(), listUsers: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), findByConfirmationToken: vi.fn(), confirmByToken: vi.fn(), }; const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() }; const useCase = new ConfirmPasswordReset({ tokens, users, hasher }); await expect( useCase.execute({ token: 'tok', newPassword: 'short' }), ).rejects.toBeInstanceOf(InvalidResetTokenError); }); });