feat(F-083): completed feature
This commit is contained in:
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