Files
eden/apps/api/test/signature-envelope-flow.spec.ts
Matheus (Handix) 20b114a0bf feat(signatures): módulo completo de assinatura eletrônica
Porta o motor de assinatura do legado com fidelidade algorítmica
exata — hash-chain (SHA256 encadeado, seq bigserial define a ordem,
não timestamp), OTP (6 dígitos via crypto.randomInt, HMAC-SHA256 com
SIGNATURE_OTP_SECRET, TTL 5min, 5 tentativas, único ativo por vez) e
a máquina de estados de 19 estados do envelope + 8 do signatário —
tudo re-verificado contra o código-fonte real do legado por um
subagente dedicado antes de qualquer linha ser escrita, cruzado com
eden.md módulo 4.

5 tabelas novas (signature_envelopes/signers/documents/
otp_challenges/audit_events, esta última append-only via a mesma GUC
eden.allow_audit_mutation que já protege audit_log desde a Fase 1 —
canonicalização de hash extraída para packages/database/src/
hash-chain.ts, compartilhada entre as duas). Três adaptações
deliberadas ao EDEN (documentadas na migration): documentos vêm de
document_generations em vez de um módulo de anexo genérico que não
existe ainda; reseller_id em vez de reseller_registration_id; só a
versão correta (v2) da canonicalização, sem a v1 do legado (existia
lá só para tolerar hashes pré-correção de bug, EDEN nunca teve isso).

Certificado de conclusão renderizado no servidor via Chromium (não
client-side como no legado) — elimina o upload multipart do
certificado pronto. Os dois passos autenticados de finalização
(seal-checkpoint, finalize) continuam explícitos e manuais, não
automáticos, mesmo sendo tecnicamente possível agora — esse
checkpoint humano antes de uma finalização irreversível é
comportamento de negócio herdado do legado, não um detalhe do
desenho client-side do certificado que deixou de existir.

Fluxo público (token do link, nunca envelopeId/signerId do corpo da
requisição) e verificação pública (só para COMPLETED, recomputa
envelope_hash ao vivo, nomes mascarados) inteiros.

Verificado: 28 testes novos (15 puros — hash-chain/máquina de
estados/OTP/token — e 13 de ciclo de vida completo via HTTP real,
incluindo merge de PDF de verdade via pdf-lib contra saída real do
Chromium mockado). Suíte completa: 70/70 verdes. Testado ao vivo
através do container real, ponta a ponta, sem nenhum mock: criação →
convite → fluxo público completo dos dois signatários (OTP
recuperado via HMAC com o segredo real, já que SMTP não está
configurado neste ambiente) → SIGNED → seal-checkpoint → finalize
(certificado real via Chromium, merge real, upload real no MinIO) →
public-verify — SHA-256 do PDF final baixado bate byte a byte com o
gravado no Postgres, integrityValid:true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 13:44:43 -03:00

310 lines
12 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("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);
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("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);
});
});