Files
support_backend/tests/integration/login-rate-limit.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

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