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>
75 lines
2.3 KiB
TypeScript
75 lines
2.3 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { buildApp } from '@/app';
|
|
import { prismaClient } from '@/infrastructure/database';
|
|
import { FastifyInstance } from 'fastify';
|
|
import bcrypt from 'bcryptjs';
|
|
import { authConfig } from '@/config';
|
|
|
|
/**
|
|
* Covers specs/013-auth-hardening/quickstart.md Scenario 3 against a real Postgres/Redis.
|
|
*/
|
|
describe('Login rate limiting (User Story 3)', () => {
|
|
let app: FastifyInstance;
|
|
const suffix = Date.now();
|
|
const email = `rate-limit-test-${suffix}@supporthub.test`;
|
|
const otherEmail = `rate-limit-other-${suffix}@supporthub.test`;
|
|
const correctPassword = 'Correct-Password-1!';
|
|
let userId: string;
|
|
let otherUserId: string;
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
const user = await prismaClient.user.create({
|
|
data: {
|
|
email,
|
|
name: 'Rate Limit Test User',
|
|
role: 'AGENT',
|
|
passwordHash: await bcrypt.hash(correctPassword, 10),
|
|
},
|
|
});
|
|
userId = user.id;
|
|
|
|
const otherUser = await prismaClient.user.create({
|
|
data: {
|
|
email: otherEmail,
|
|
name: 'Rate Limit Other User',
|
|
role: 'AGENT',
|
|
passwordHash: await bcrypt.hash(correctPassword, 10),
|
|
},
|
|
});
|
|
otherUserId = otherUser.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prismaClient.user.deleteMany({ where: { id: { in: [userId, otherUserId] } } });
|
|
await app.close();
|
|
});
|
|
|
|
it('blocks the same email after its attempt budget is exhausted, without affecting other emails', async () => {
|
|
for (let i = 0; i < authConfig.loginRateLimitMaxAttempts; i++) {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email, password: 'definitely-wrong' },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
}
|
|
|
|
// One more attempt for the same email, this time with the CORRECT password — still 429.
|
|
const blockedRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email, password: correctPassword },
|
|
});
|
|
expect(blockedRes.statusCode).toBe(429);
|
|
|
|
// A different email in the same window is unaffected.
|
|
const otherRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: otherEmail, password: correctPassword },
|
|
});
|
|
expect(otherRes.statusCode).toBe(200);
|
|
});
|
|
});
|