Files
support_backend/tests/integration/password-reset-flow.test.ts
T
saqib mirandClaude Sonnet 5 79bc2ef25b feat(013-auth-hardening): password reset, password strength policy, login rate-limiting
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>
2026-09-07 21:22:49 +05:30

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);
});
});