Files
mercadodevida/project/src/modules/identity/tests/password-reset.test.ts
chattie 18a518e58b F-201 F-202 F-203: POS cash close report + email + PIN admin
F-201: GET /pos/reports/cash-close/:id with financial summary, sales
  by state, payments breakdown, items sold. Extended /pos/sessions/:id.

F-202: Cash close email sent on session close to smtpReportEmail
  (best-effort). smtpReportEmail field added to admin SMTP settings.

F-203: Admin POS terminal config: selfpayMode, closeSessionRequiresPin,
  closeSessionPin (4-6 digits) with dedicated settings section.
2026-08-23 09:24:37 +02:00

227 lines
7.8 KiB
TypeScript

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> = {}): 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);
});
});