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