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.
227 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
}); |