Files
eden/apps/api/test/signature-envelope-flow.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

330 lines
13 KiB
TypeScript

import { INestApplication } from "@nestjs/common";
import { Readable } from "node:stream";
import { PDFDocument } from "pdf-lib";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, type AuthedTokens } from "./setup.js";
/**
* Full e-signature envelope lifecycle, end to end through the real HTTP
* API — the same rigor as documents-pdf.spec.ts/anatel-pdf.spec.ts, but
* this module is security-critical enough to warrant covering the WHOLE
* state machine in one connected flow (create → send → per-signer public
* flow → sign → seal checkpoint → finalize → public verify), not just
* isolated units. `renderPdf`/`uploadObject`/`getObjectStream`/`sendMail`
* are mocked (an in-memory S3 substitute + captured emails) so the suite
* doesn't need a real Chromium/MinIO/SMTP — `mergePdfs`/`getPdfPageCount`
* are NOT mocked (`vi.importActual`), so the actual pdf-lib merge in
* EnvelopeFinalizeService.finalize() really runs against the mocked
* renderPdf's real (tiny, valid) PDF output.
*/
interface MockSendMailInput {
to: string;
subject: string;
html: string;
attachments?: Array<{ filename: string; content: Buffer; contentType?: string }>;
}
const s3Store = new Map<string, Buffer>();
const sendMailMock = vi.fn(async (_input: MockSendMailInput) => undefined);
vi.mock("@eden/integrations", async () => {
const actual = await vi.importActual<typeof import("@eden/integrations")>("@eden/integrations");
return {
...actual,
renderPdf: vi.fn(async () => {
const doc = await PDFDocument.create();
doc.addPage();
return Buffer.from(await doc.save());
}),
uploadObject: vi.fn(async (key: string, buffer: Buffer) => {
s3Store.set(key, buffer);
}),
getObjectStream: vi.fn(async (key: string) => {
const buffer = s3Store.get(key);
if (!buffer) throw new Error(`qa mock S3: no object at ${key}`);
return Readable.from([buffer]);
}),
sendMail: (input: MockSendMailInput) => sendMailMock(input),
};
});
function extractUrlFromLastEmailTo(to: string, pattern: RegExp): string {
const call = sendMailMock.mock.calls.find((c) => c[0].to === to);
const html = call?.[0].html ?? "";
const match = html.match(pattern);
if (!match?.[1]) throw new Error(`URL not found in email to ${to}`);
return match[1];
}
function extractOtpCodeFromLastEmail(to: string): string {
const calls = sendMailMock.mock.calls.filter((c) => c[0].to === to);
const html = calls.at(-1)?.[0].html ?? "";
const match = html.match(/(\d{6})/);
if (!match?.[1]) throw new Error(`OTP code not found in email to ${to}`);
return match[1];
}
describe("signature envelopes — full lifecycle", () => {
let app: INestApplication;
let admin: AuthedTokens;
let companyId: string;
let resellerId: string;
let generationId: string;
let envelopeId: string;
let documentId: string;
let contratadaEmail: string;
let contratanteEmail: string;
let contratadaToken: string;
let contratanteToken: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
contratadaEmail = qaEmail("sig-contratada");
contratanteEmail = qaEmail("sig-contratante");
const company = await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({
companyName: "qa-test- Empresa Assinatura",
legalRepName: "Representante Legal QA",
legalRepCpf: "11144477735",
legalRepEmail: contratadaEmail,
legalRepRole: "Diretor",
})
.expect(201);
companyId = company.body.id;
const reseller = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Revenda Assinatura" })
.expect(201);
resellerId = reseller.body.id;
const template = await agent(app)
.post("/api/v1/documents/templates")
.set(...bearer(admin.accessToken))
.send({ key: `qa_test_sig_${Date.now()}`, name: "qa-test- Template Assinatura", docGroup: "revenda" })
.expect(201);
const templateId = template.body.id;
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.expect(201);
await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Contrato de teste para assinatura eletrônica." } })
.expect(200);
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`)
.set(...bearer(admin.accessToken))
.expect(201);
const generation = await agent(app)
.post("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.send({ templateId, resellerId, context: {} })
.expect(201);
generationId = generation.body.id;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/resellers/${resellerId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await closeTestApp(app);
});
it("creates the envelope with an auto contratada signer + the requested contratante", async () => {
const res = await agent(app)
.post("/api/v1/signature-envelopes")
.set(...bearer(admin.accessToken))
.send({
companyId,
resellerId,
documentGenerationIds: [generationId],
signers: [{ fullName: "Cliente QA", cpf: "52998224725", email: contratanteEmail }],
})
.expect(201);
expect(res.body.status).toBe("READY");
expect(res.body.signers).toHaveLength(2);
const contratada = res.body.signers.find((s: { party: string }) => s.party === "contratada");
const contratante = res.body.signers.find((s: { party: string }) => s.party === "contratante");
expect(contratada.full_name).toBe("Representante Legal QA");
expect(contratada.email).toBe(contratadaEmail);
expect(contratante.email).toBe(contratanteEmail);
expect(res.body.documents).toHaveLength(1);
envelopeId = res.body.id;
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")
.set(...bearer(admin.accessToken))
.send({ companyName: "qa-test- Empresa Sem Rep Legal" })
.expect(201);
const res = await agent(app)
.post("/api/v1/signature-envelopes")
.set(...bearer(admin.accessToken))
.send({
companyId: bareCompany.body.id,
resellerId,
documentGenerationIds: [generationId],
signers: [{ fullName: "Cliente QA 2", cpf: "52998224725", email: qaEmail("sig-nope") }],
})
.expect(400);
expect(res.body.message).toContain("representante legal");
});
it("sends the invite to both parties and transitions to SENT", async () => {
sendMailMock.mockClear();
const res = await agent(app)
.post(`/api/v1/signature-envelopes/${envelopeId}/send`)
.set(...bearer(admin.accessToken))
.send({ targets: "both" })
.expect(201);
expect(res.body.status).toBe("SENT");
expect(sendMailMock).toHaveBeenCalledTimes(2);
contratadaToken = extractUrlFromLastEmailTo(contratadaEmail, /\/assinar\/([a-f0-9]{64})/)!;
contratanteToken = extractUrlFromLastEmailTo(contratanteEmail, /\/assinar\/([a-f0-9]{64})/)!;
expect(contratadaToken).not.toBe(contratanteToken);
});
async function runSignerFlow(token: string, email: string) {
const state = await agent(app).get(`/api/v1/public-signature/${token}`).expect(200);
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);
sendMailMock.mockClear();
await agent(app).post(`/api/v1/public-signature/${token}/otp/request`).expect(201);
const code = extractOtpCodeFromLastEmail(email);
await agent(app).post(`/api/v1/public-signature/${token}/otp/verify`).send({ code: "000000" }).expect(400);
await agent(app).post(`/api/v1/public-signature/${token}/otp/verify`).send({ code }).expect(201);
await agent(app).post(`/api/v1/public-signature/${token}/otp/verify`).send({ code }).expect(409); // single-use
await agent(app).post(`/api/v1/public-signature/${token}/sign`).expect(201);
}
it("walks the contratada through the full public flow and signs", async () => {
await runSignerFlow(contratadaToken, contratadaEmail);
});
it("envelope is still SENT with one signer left", async () => {
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.body.status).toBe("SENT");
});
it("walks the contratante through the full public flow — completing all signers flips the envelope straight to SIGNED", async () => {
await runSignerFlow(contratanteToken, contratanteEmail);
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.body.status).toBe("SIGNED");
expect(res.body.signers.every((s: { status: string }) => s.status === "SIGNED")).toBe(true);
});
it("the audit chain verifies clean before finalization", async () => {
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}/verify-audit`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.body.valid).toBe(true);
expect(res.body.eventsChecked).toBeGreaterThan(10);
});
let verificationId: string;
it("seals the evidence checkpoint — SIGNED -> FINALIZING", async () => {
const res = await agent(app)
.post(`/api/v1/signature-envelopes/${envelopeId}/seal-checkpoint`)
.set(...bearer(admin.accessToken))
.expect(201);
expect(res.body.status).toBe("FINALIZING");
expect(res.body.verification_id).toMatch(/^[a-f0-9]{32}$/);
expect(res.body.envelope_hash).toMatch(/^[a-f0-9]{64}$/);
verificationId = res.body.verification_id;
});
it("finalize renders the certificate, consolidates the final PDF, and completes the envelope", async () => {
sendMailMock.mockClear();
const res = await agent(app)
.post(`/api/v1/signature-envelopes/${envelopeId}/finalize`)
.set(...bearer(admin.accessToken))
.expect(201);
expect(res.body.status).toBe("COMPLETED");
expect(res.body.final_pdf_sha256).toMatch(/^[a-f0-9]{64}$/);
const finalBuffer = s3Store.get(res.body.final_s3_key);
expect(finalBuffer).toBeDefined();
const merged = await PDFDocument.load(finalBuffer!);
expect(merged.getPageCount()).toBe(2); // 1 frozen document page + 1 certificate page
expect(sendMailMock).toHaveBeenCalledTimes(1); // only the contratante gets the final PDF
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);
expect(res.body.signers.every((s: { name: string }) => /\*/.test(s.name))).toBe(true); // masked
});
it("public verify 404s for a non-completed or unknown verification id", async () => {
await agent(app).get("/api/v1/public-verify/0000000000000000000000000000ff").expect(404);
});
it("a revoked/unknown token is rejected on the public flow", async () => {
await agent(app).get("/api/v1/public-signature/0000000000000000000000000000000000000000000000000000000000000000").expect(404);
});
it("the envelope can no longer be deleted once SIGNED/COMPLETED", async () => {
const res = await agent(app)
.delete(`/api/v1/signature-envelopes/${envelopeId}`)
.set(...bearer(admin.accessToken))
.expect(409);
expect(res.body.message).toMatch(/cancelar/i);
});
});