import { INestApplication } from "@nestjs/common"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { API, agent, closeTestApp, createTestApp, qaEmail } from "./setup.js"; import { RateLimiterService } from "../src/common/rate-limit/rate-limiter.service.js"; /** * Protects the rate-limiting layer added across auth.controller.ts / * public-signature.controller.ts / public-verify.controller.ts (see * apps/api/src/common/rate-limit/) — previously a documented, real gap * (docs/security/threat-model.md): every public route was unthrottled. * * Split in two halves deliberately: * - RateLimiterService itself, tested directly with no DB/HTTP at all * (same "pure function" style as signature-hash-chain.spec.ts) — this * is the only place that can honestly prove per-key isolation (e.g. * two different source IPs, or two different signed-link tokens, never * sharing a budget), since supertest's requests all originate from the * same loopback socket and this API doesn't set Express's `trust * proxy` (deliberately — trusting an X-Forwarded-For header without a * known, single reverse-proxy hop in front would let a client spoof * its own rate-limit key). * - The real HTTP surface (real app, real Postgres, same pattern as * every other spec here) proving an actual limit trips end-to-end and * that hitting one route's bucket dry doesn't affect a different * route's bucket from the same IP. */ describe("RateLimiterService — pure in-memory fixed window, no DB/HTTP", () => { it("allows exactly `limit` consumes per key, then blocks", () => { const limiter = new RateLimiterService(); for (let i = 0; i < 5; i++) { expect(limiter.consume("rule:key", 5, 60_000).allowed).toBe(true); } const blocked = limiter.consume("rule:key", 5, 60_000); expect(blocked.allowed).toBe(false); expect(blocked.retryAfterSeconds).toBeGreaterThan(0); }); it("different keys never share a bucket (e.g. two source IPs, or two signed-link tokens, under the same rule name)", () => { const limiter = new RateLimiterService(); for (let i = 0; i < 5; i++) { expect(limiter.consume("auth-login:203.0.113.1", 5, 60_000).allowed).toBe(true); } // Exhausted for this key... expect(limiter.consume("auth-login:203.0.113.1", 5, 60_000).allowed).toBe(false); // ...but a different key under the SAME rule starts with its own fresh budget. expect(limiter.consume("auth-login:198.51.100.7", 5, 60_000).allowed).toBe(true); // Same reasoning applies to token-scoped rules (pubsig-otp-verify:) — // the mechanism is identical, only the string plugged into the key differs. expect(limiter.consume("pubsig-otp-verify:tokenA", 3, 60_000).allowed).toBe(true); expect(limiter.consume("pubsig-otp-verify:tokenB", 3, 60_000).allowed).toBe(true); }); it("resets once the window elapses", () => { vi.useFakeTimers(); try { const limiter = new RateLimiterService(); expect(limiter.consume("k", 1, 1000).allowed).toBe(true); expect(limiter.consume("k", 1, 1000).allowed).toBe(false); vi.advanceTimersByTime(1001); expect(limiter.consume("k", 1, 1000).allowed).toBe(true); } finally { vi.useRealTimers(); } }); }); /** * Real app, real Postgres (createTestApp/closeTestApp, same as every other * spec in this suite) — no fixtures needed at all: wrong-credential login * attempts against a non-existent account already return a real 401 * (auth.spec.ts's anti-enumeration invariant), which is exactly what a * credential-stuffing attempt looks like from this endpoint's point of * view. One app instance per file, isolated from every other spec file * (each gets a fresh Nest DI container -> a fresh RateLimiterService), and * vitest.config.ts runs spec files sequentially (fileParallelism: false) * so this file's own bucket state can't race with another file's. */ describe("rate limiting — real HTTP surface", () => { let app: INestApplication; beforeAll(async () => { app = await createTestApp(); }); afterAll(async () => { await closeTestApp(app); }); it("POST /v1/auth/login: the request after the configured limit is rejected with 429, not 401, and carries Retry-After", async () => { // AUTH_LOGIN_RULE = 10/min per IP (rate-limit-rules.ts) — exhaust it first. for (let i = 0; i < 10; i++) { await agent(app) .post(`${API}/v1/auth/login`) .send({ email: qaEmail("ratelimit-login"), password: "definitely-wrong", applicationKey: "core" }) .expect(401); } const blocked = await agent(app) .post(`${API}/v1/auth/login`) .send({ email: qaEmail("ratelimit-login"), password: "definitely-wrong", applicationKey: "core" }) .expect(429); expect(blocked.body.message).toBe("Muitas requisições. Tente novamente mais tarde."); expect(Number(blocked.headers["retry-after"])).toBeGreaterThan(0); }); it("the exhausted login bucket does not block a different public route from the same IP (buckets are namespaced per rule name)", async () => { // Still within the same 60s window as the previous test — login stays blocked... await agent(app) .post(`${API}/v1/auth/login`) .send({ email: qaEmail("ratelimit-login-2"), password: "definitely-wrong", applicationKey: "core" }) .expect(429); // ...but forgot-password (a completely separate rule: "auth-forgot-password") // from the exact same IP is untouched. await agent(app) .post(`${API}/v1/auth/forgot-password`) .send({ email: qaEmail("ratelimit-forgot") }) .expect(200); }); });