Files
eden/apps/api/test/rate-limit.spec.ts
Matheus (Handix) 9c14671e19 feat(signatures): UI completa do módulo + rate limiting em rotas públicas
Fecha as duas pendências da leva anterior (módulo de Assinatura
Eletrônica): tela de backoffice para gerenciar envelopes (criação,
envio, cancelamento, checkpoint/finalização, auditoria) e as duas
páginas públicas (/assinar/:token, /verificar/:verificationId).

Backend: novos endpoints de streaming de PDF (documento congelado e
PDF final consolidado, autenticados e via token público) que faltavam
para a UI funcionar — o signatário não tinha como ver o próprio
documento antes de confirmar leitura. Rate limiting (guard próprio,
em memória, chaves por IP e por token) em todas as rotas sem sessão:
auth (login/mfa/forgot-password/reset-password/refresh/logout) e todo
o fluxo público de assinatura/verificação.

Suíte: 77/77 verdes. pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:51:18 -03:00

122 lines
5.6 KiB
TypeScript

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:<token>) —
// 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);
});
});