feat(F-083): completed feature
This commit is contained in:
@@ -21,13 +21,24 @@ import { Argon2PasswordHasher } from '../infrastructure/argon2-password-hasher.j
|
||||
import { PgUserRepository } from '../infrastructure/pg-user-repository.js';
|
||||
import { PgSessionRepository } from '../infrastructure/pg-session-repository.js';
|
||||
import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js';
|
||||
import type { PasswordHasher } from '../domain/ports.js';
|
||||
import type {
|
||||
PasswordHasher,
|
||||
PasswordResetMailer,
|
||||
PasswordResetTokenRepository,
|
||||
ResetRateLimiter,
|
||||
} from '../domain/ports.js';
|
||||
import {
|
||||
EmailAlreadyRegisteredError,
|
||||
InvalidCredentialsError,
|
||||
RateLimitedError,
|
||||
} from '../domain/errors.js';
|
||||
import { SESSION_TTL_MS } from '../domain/session.js';
|
||||
import { PgPasswordResetTokenRepository } from '../infrastructure/pg-password-reset-token-repository.js';
|
||||
import {
|
||||
ConfirmPasswordReset,
|
||||
RequestPasswordReset,
|
||||
} from '../application/password-reset.js';
|
||||
import { InvalidResetTokenError } from '../domain/password-reset.js';
|
||||
|
||||
export const SESSION_COOKIE_NAME = 'mdv_session';
|
||||
|
||||
@@ -40,6 +51,16 @@ export interface IdentityRoutesDeps {
|
||||
rateLimiter?: LoginRateLimiter;
|
||||
/** Session authenticator. Created by createSessionAuthenticator in build-app.ts. */
|
||||
authenticate?: Authenticate;
|
||||
/** Optional: enables POST /auth/password-reset/request and /confirm. */
|
||||
passwordReset?: {
|
||||
tokens?: PasswordResetTokenRepository;
|
||||
rateLimiter?: ResetRateLimiter;
|
||||
mailer?: PasswordResetMailer;
|
||||
/** Build absolute reset URL from raw token. Defaults to storefront path. */
|
||||
buildResetUrl?: (rawToken: string) => string;
|
||||
/** Audit sink (defaults to no-op). */
|
||||
audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
@@ -212,6 +233,137 @@ export async function registerIdentityRoutes(
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
if (deps.passwordReset) {
|
||||
const pr = deps.passwordReset;
|
||||
const tokens = pr.tokens ?? new PgPasswordResetTokenRepository(deps.pool);
|
||||
const usersRepo = new PgUserRepository(deps.pool);
|
||||
const rateLimiter = pr.rateLimiter ?? new InMemoryResetRateLimiter();
|
||||
const mailer = pr.mailer ?? new LoggingPasswordResetMailer();
|
||||
const buildResetUrl =
|
||||
pr.buildResetUrl ??
|
||||
((token: string) => `/cuenta/restablecer?token=${encodeURIComponent(token)}`);
|
||||
|
||||
const requestReset = new RequestPasswordReset({
|
||||
users: usersRepo,
|
||||
tokens,
|
||||
rateLimiter,
|
||||
mailer,
|
||||
buildResetUrl,
|
||||
audit: pr.audit,
|
||||
});
|
||||
const confirmReset = new ConfirmPasswordReset({
|
||||
tokens,
|
||||
users: usersRepo,
|
||||
hasher,
|
||||
audit: pr.audit,
|
||||
});
|
||||
|
||||
const requestSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request password reset',
|
||||
description:
|
||||
'Solicita un enlace de recuperación por email. Siempre responde 200 para evitar enumeración de usuarios.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email', maxLength: 255 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } },
|
||||
};
|
||||
|
||||
const confirmSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm password reset',
|
||||
description: 'Acepta un nuevo password usando el token recibido por email.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
properties: {
|
||||
token: { type: 'string', minLength: 16, maxLength: 128 },
|
||||
password: { type: 'string', minLength: 8, maxLength: 128 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } }, 400: errorSchema },
|
||||
};
|
||||
|
||||
app.post(
|
||||
'/auth/password-reset/request',
|
||||
{ schema: requestSchema },
|
||||
async (request, reply) => {
|
||||
const input = parseJson(
|
||||
z.object({ email: z.email().max(255) }),
|
||||
request.body,
|
||||
);
|
||||
const ip = (request.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim()
|
||||
?? request.ip;
|
||||
const userAgent = request.headers['user-agent'];
|
||||
await requestReset.execute({ email: input.email, ip, userAgent });
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/password-reset/confirm',
|
||||
{ schema: confirmSchema },
|
||||
async (request, reply) => {
|
||||
const input = parseJson(
|
||||
z.object({
|
||||
token: z.string().min(16).max(128),
|
||||
password: z.string().min(8).max(128),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
try {
|
||||
await confirmReset.execute({ token: input.token, newPassword: input.password });
|
||||
return reply.send({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidResetTokenError) {
|
||||
throw new AppError(400, 'INVALID_RESET_TOKEN', 'Invalid or expired reset token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple in-memory token-bucket rate limiter for the password reset endpoints. */
|
||||
export class InMemoryResetRateLimiter implements ResetRateLimiter {
|
||||
private buckets = new Map<string, { count: number; resetAt: number }>();
|
||||
constructor(
|
||||
private readonly windowMs = 60 * 60 * 1000,
|
||||
private readonly limit = 5,
|
||||
) {}
|
||||
async allow(key: string): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const cur = this.buckets.get(key);
|
||||
if (!cur || cur.resetAt <= now) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
|
||||
return true;
|
||||
}
|
||||
if (cur.count >= this.limit) return false;
|
||||
cur.count += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs the reset email to stdout; production should replace with a real provider. */
|
||||
export class LoggingPasswordResetMailer implements PasswordResetMailer {
|
||||
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
msg: 'password_reset_email',
|
||||
to: input.email,
|
||||
url: input.resetUrl,
|
||||
locale: input.locale ?? 'es',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {
|
||||
|
||||
131
project/src/modules/identity/application/password-reset.ts
Normal file
131
project/src/modules/identity/application/password-reset.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Password reset use cases.
|
||||
*
|
||||
* - RequestPasswordReset: always succeeds at the HTTP level to avoid user enumeration.
|
||||
* Stores hashed token, rate-limited per IP and per email, triggers email.
|
||||
* - ConfirmPasswordReset: validates the token, updates the password hash, marks token used.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { hashSessionToken } from '../infrastructure/session-token.js';
|
||||
import {
|
||||
InvalidResetTokenError,
|
||||
RESET_TOKEN_TTL_MS,
|
||||
isTokenUsable,
|
||||
type PasswordResetToken,
|
||||
} from '../domain/password-reset.js';
|
||||
import type {
|
||||
PasswordHasher,
|
||||
PasswordResetTokenRepository,
|
||||
ResetRateLimiter,
|
||||
PasswordResetMailer,
|
||||
UserRepository,
|
||||
} from '../domain/ports.js';
|
||||
import { normalizeEmail } from '../domain/user.js';
|
||||
|
||||
export interface RequestPasswordResetDeps {
|
||||
users: UserRepository;
|
||||
tokens: PasswordResetTokenRepository;
|
||||
rateLimiter: ResetRateLimiter;
|
||||
mailer: PasswordResetMailer;
|
||||
/** Build the absolute URL the customer will click. */
|
||||
buildResetUrl: (rawToken: string) => string;
|
||||
/** Optional logger/audit sink. No-op by default. */
|
||||
audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void;
|
||||
/** Equalises timing when user does not exist. */
|
||||
noUserDelay?: () => Promise<void>;
|
||||
/** Locale for the email template. */
|
||||
defaultLocale?: string;
|
||||
}
|
||||
|
||||
export class RequestPasswordReset {
|
||||
constructor(private readonly deps: RequestPasswordResetDeps) {}
|
||||
|
||||
async execute(input: { email: string; ip?: string; userAgent?: string }): Promise<void> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const ipKey = `ip:${input.ip ?? 'unknown'}`;
|
||||
const emailKey = `email:${email}`;
|
||||
const [ipOk, emailOk] = await Promise.all([
|
||||
this.deps.rateLimiter.allow(ipKey),
|
||||
this.deps.rateLimiter.allow(emailKey),
|
||||
]);
|
||||
if (!ipOk || !emailOk) {
|
||||
// Silently drop; do not reveal rate limiting state.
|
||||
return;
|
||||
}
|
||||
|
||||
const userWithHash = await this.deps.users.findByEmail(email);
|
||||
if (!userWithHash) {
|
||||
await (this.deps.noUserDelay?.() ?? defaultEqualiseDelay());
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.tokens.invalidateAllForUser(userWithHash.id);
|
||||
|
||||
const rawToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);
|
||||
await this.deps.tokens.create({
|
||||
userId: userWithHash.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
requestIp: input.ip,
|
||||
requestUserAgent: input.userAgent,
|
||||
});
|
||||
|
||||
this.deps.audit?.({
|
||||
action: 'password_reset.requested',
|
||||
userId: userWithHash.id,
|
||||
email,
|
||||
ip: input.ip,
|
||||
});
|
||||
|
||||
const resetUrl = this.deps.buildResetUrl(rawToken);
|
||||
await this.deps.mailer.sendPasswordReset({
|
||||
email,
|
||||
resetUrl,
|
||||
locale: this.deps.defaultLocale ?? 'es',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfirmPasswordResetDeps {
|
||||
tokens: PasswordResetTokenRepository;
|
||||
users: UserRepository;
|
||||
hasher: PasswordHasher;
|
||||
audit?: (entry: { action: string; userId?: string; ip?: string }) => void;
|
||||
}
|
||||
|
||||
export class ConfirmPasswordReset {
|
||||
constructor(private readonly deps: ConfirmPasswordResetDeps) {}
|
||||
|
||||
async execute(input: { token: string; newPassword: string }): Promise<void> {
|
||||
if (input.newPassword.length < 8 || input.newPassword.length > 128) {
|
||||
throw new InvalidResetTokenError();
|
||||
}
|
||||
const tokenHash = hashToken(input.token);
|
||||
const token = await this.deps.tokens.findByTokenHash(tokenHash);
|
||||
if (!token || !isTokenUsable(token)) {
|
||||
throw new InvalidResetTokenError();
|
||||
}
|
||||
|
||||
const newHash = await this.deps.hasher.hash(input.newPassword);
|
||||
await this.deps.users.updateUser(token.userId, { passwordHash: newHash });
|
||||
await this.deps.tokens.markUsed(token.id);
|
||||
|
||||
this.deps.audit?.({
|
||||
action: 'password_reset.confirmed',
|
||||
userId: token.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Same hash algorithm as session tokens. */
|
||||
export const hashToken = hashSessionToken;
|
||||
|
||||
async function defaultEqualiseDelay(): Promise<void> {
|
||||
// ~80ms equalises with the hasher path roughly. Tune if needed.
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
|
||||
// Re-export for tests
|
||||
export type { PasswordResetToken };
|
||||
26
project/src/modules/identity/domain/password-reset.ts
Normal file
26
project/src/modules/identity/domain/password-reset.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Password reset token domain. Pure types and rules.
|
||||
*/
|
||||
export interface PasswordResetToken {
|
||||
id: string;
|
||||
userId: string;
|
||||
tokenHash: string;
|
||||
expiresAt: Date;
|
||||
usedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class InvalidResetTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid or expired password reset token');
|
||||
this.name = 'InvalidResetTokenError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenUsable(token: PasswordResetToken, now: Date = new Date()): boolean {
|
||||
if (token.usedAt !== null) return false;
|
||||
if (token.expiresAt.getTime() <= now.getTime()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import type { NewUser, User } from './user.js';
|
||||
import type { Session } from './session.js';
|
||||
import type { PasswordResetToken } from './password-reset.js';
|
||||
|
||||
export interface PasswordHasher {
|
||||
hash(plain: string): Promise<string>;
|
||||
@@ -29,3 +30,25 @@ export interface SessionRepository {
|
||||
/** Revokes by token hash. Returns true when a live session was revoked. */
|
||||
revokeByTokenHash(tokenHash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PasswordResetTokenRepository {
|
||||
invalidateAllForUser(userId: string): Promise<void>;
|
||||
create(input: {
|
||||
userId: string;
|
||||
tokenHash: string;
|
||||
expiresAt: Date;
|
||||
requestIp?: string;
|
||||
requestUserAgent?: string;
|
||||
}): Promise<PasswordResetToken>;
|
||||
findByTokenHash(tokenHash: string): Promise<PasswordResetToken | undefined>;
|
||||
markUsed(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ResetRateLimiter {
|
||||
/** Returns true if the action should proceed; false if it should be silently rejected. */
|
||||
allow(key: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PasswordResetMailer {
|
||||
sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type pg from 'pg';
|
||||
import type { PasswordResetToken } from '../domain/password-reset.js';
|
||||
import type { PasswordResetTokenRepository } from '../domain/ports.js';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
token_hash: string;
|
||||
expires_at: Date;
|
||||
used_at: Date | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
function toDomain(row: Row): PasswordResetToken {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
tokenHash: row.token_hash,
|
||||
expiresAt: row.expires_at,
|
||||
usedAt: row.used_at,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class PgPasswordResetTokenRepository implements PasswordResetTokenRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async invalidateAllForUser(userId: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE identity_password_reset_tokens
|
||||
SET used_at = now()
|
||||
WHERE user_id = $1 AND used_at IS NULL`,
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
userId: string;
|
||||
tokenHash: string;
|
||||
expiresAt: Date;
|
||||
requestIp?: string;
|
||||
requestUserAgent?: string;
|
||||
}): Promise<PasswordResetToken> {
|
||||
const result = await this.pool.query<Row>(
|
||||
`INSERT INTO identity_password_reset_tokens
|
||||
(user_id, token_hash, expires_at, request_ip, request_user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[input.userId, input.tokenHash, input.expiresAt, input.requestIp ?? null, input.requestUserAgent ?? null],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('password reset token INSERT returned no row');
|
||||
return toDomain(row);
|
||||
}
|
||||
|
||||
async findByTokenHash(tokenHash: string): Promise<PasswordResetToken | undefined> {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT * FROM identity_password_reset_tokens WHERE token_hash = $1`,
|
||||
[tokenHash],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async markUsed(id: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE identity_password_reset_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
}
|
||||
215
project/src/modules/identity/tests/password-reset.test.ts
Normal file
215
project/src/modules/identity/tests/password-reset.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
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(),
|
||||
};
|
||||
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(),
|
||||
};
|
||||
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(),
|
||||
};
|
||||
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(),
|
||||
};
|
||||
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(),
|
||||
};
|
||||
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(),
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user