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>
This commit is contained in:
121
apps/api/test/rate-limit.spec.ts
Normal file
121
apps/api/test/rate-limit.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,15 @@ describe("signature envelopes — full lifecycle", () => {
|
||||
documentId = res.body.documents[0].id;
|
||||
});
|
||||
|
||||
it("downloads the frozen document PDF via the authenticated backoffice route", async () => {
|
||||
const res = await agent(app)
|
||||
.get(`/api/v1/signature-envelopes/${envelopeId}/documents/${documentId}/pdf`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(res.headers["content-type"]).toBe("application/pdf");
|
||||
expect(res.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects creation for a company with no legal representative", async () => {
|
||||
const bareCompany = await agent(app)
|
||||
.post("/api/v1/companies")
|
||||
@@ -207,6 +216,8 @@ describe("signature envelopes — full lifecycle", () => {
|
||||
expect(state.body.signer.status).toBe("VIEWED");
|
||||
expect(state.body.documents).toHaveLength(1);
|
||||
|
||||
const pdf = await agent(app).get(`/api/v1/public-signature/${token}/documents/${documentId}/pdf`).expect(200);
|
||||
expect(pdf.headers["content-type"]).toBe("application/pdf");
|
||||
await agent(app).post(`/api/v1/public-signature/${token}/documents/${documentId}/confirm-read`).expect(201);
|
||||
await agent(app).post(`/api/v1/public-signature/${token}/confirm-data`).expect(201);
|
||||
await agent(app).post(`/api/v1/public-signature/${token}/consent`).expect(201);
|
||||
@@ -285,6 +296,15 @@ describe("signature envelopes — full lifecycle", () => {
|
||||
expect(sendMailMock.mock.calls[0]![0].to).toBe(contratanteEmail);
|
||||
});
|
||||
|
||||
it("downloads the final consolidated PDF via the authenticated backoffice route", async () => {
|
||||
const res = await agent(app)
|
||||
.get(`/api/v1/signature-envelopes/${envelopeId}/final-pdf`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(res.headers["content-type"]).toBe("application/pdf");
|
||||
expect(res.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("public verify endpoint confirms integrity for the completed envelope", async () => {
|
||||
const res = await agent(app).get(`/api/v1/public-verify/${verificationId}`).expect(200);
|
||||
expect(res.body.integrityValid).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user