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
This commit is contained in:
309
apps/api/test/signature-envelope-flow.spec.ts
Normal file
309
apps/api/test/signature-envelope-flow.spec.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user