Closes the two gaps 010-identity-auth explicitly deferred (password reset, login rate-limiting), plus a shared password-strength validator both the reset-consume endpoint and admin account creation now depend on. - Password reset: single-use, paired-Redis-key tokens (never in Postgres), identical response regardless of account existence, stubbed delivery via a structured log line (no email infrastructure exists yet). - Password strength: one validatePasswordStrength() call site, wired into both POST /admin/users and the reset-consume flow. - Login rate-limiting: checkRateLimit keyed by submitted email, checked before any credential verification. Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused two fixed accounts across the whole integration suite via upsert — now rate-limited per email, that collided across ~30 files sharing one budget. Each call now gets a unique email; no call sites needed to change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import { buildApp } from '@/app';
|
|
import { prismaClient } from '@/infrastructure/database';
|
|
import { FastifyInstance } from 'fastify';
|
|
import bcrypt from 'bcryptjs';
|
|
import { logger } from '@/infrastructure/observability';
|
|
|
|
/**
|
|
* Covers specs/013-auth-hardening/quickstart.md Scenario 1 against a real Postgres/Redis — the
|
|
* full request -> (read the token from the stub's own log line) -> consume -> login-with-new-
|
|
* password flow, and the identical-response-regardless-of-existing-account behavior.
|
|
*/
|
|
describe('Password reset flow (User Story 1)', () => {
|
|
let app: FastifyInstance;
|
|
const suffix = Date.now();
|
|
const email = `reset-test-${suffix}@supporthub.test`;
|
|
const originalPassword = 'Original-Password-1!';
|
|
const newPassword = 'Brand-New-Password-2!';
|
|
let userId: string;
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
const user = await prismaClient.user.create({
|
|
data: {
|
|
email,
|
|
name: 'Reset Test User',
|
|
role: 'AGENT',
|
|
passwordHash: await bcrypt.hash(originalPassword, 10),
|
|
},
|
|
});
|
|
userId = user.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prismaClient.user.deleteMany({ where: { id: userId } });
|
|
await app.close();
|
|
});
|
|
|
|
function extractLoggedToken(): string {
|
|
const infoSpy = vi.mocked(logger.info);
|
|
const call = infoSpy.mock.calls.find(
|
|
([data]) => (data as { event?: string }).event === 'password_reset_requested',
|
|
);
|
|
if (!call) throw new Error('Expected a password_reset_requested log line, but none was found.');
|
|
|
|
const resetUrl = (call[0] as unknown as { resetUrl: string }).resetUrl;
|
|
const token = new URL(resetUrl, 'http://localhost').searchParams.get('token');
|
|
if (!token) throw new Error('Expected the logged resetUrl to carry a token query param.');
|
|
return token;
|
|
}
|
|
|
|
it('Scenario 1: request -> stub-logged token -> consume -> login with the new password', async () => {
|
|
const infoSpy = vi.spyOn(logger, 'info');
|
|
|
|
const requestRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/password-reset/request',
|
|
payload: { email },
|
|
});
|
|
expect(requestRes.statusCode).toBe(200);
|
|
expect(requestRes.json().data.message).not.toMatch(/token|[a-f0-9]{64}/i);
|
|
|
|
const nonexistentRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/password-reset/request',
|
|
payload: { email: `nobody-${suffix}@supporthub.test` },
|
|
});
|
|
expect(nonexistentRes.statusCode).toBe(200);
|
|
expect(nonexistentRes.json()).toEqual(requestRes.json());
|
|
|
|
const token = extractLoggedToken();
|
|
|
|
const consumeRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/password-reset/consume',
|
|
payload: { token, newPassword },
|
|
});
|
|
expect(consumeRes.statusCode).toBe(200);
|
|
|
|
// Single-use — the same token fails a second time.
|
|
const secondConsumeRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/password-reset/consume',
|
|
payload: { token, newPassword: 'Another-Password-3!' },
|
|
});
|
|
expect(secondConsumeRes.statusCode).toBe(400);
|
|
|
|
const loginWithNew = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email, password: newPassword },
|
|
});
|
|
expect(loginWithNew.statusCode).toBe(200);
|
|
|
|
const loginWithOld = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email, password: originalPassword },
|
|
});
|
|
expect(loginWithOld.statusCode).toBe(401);
|
|
|
|
infoSpy.mockRestore();
|
|
});
|
|
|
|
it('rejects an invalid token outright', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/password-reset/consume',
|
|
payload: { token: 'not-a-real-token', newPassword: 'Whatever-Password-1!' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|