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(); const sendMailMock = vi.fn(async (_input: MockSendMailInput) => undefined); vi.mock("@eden/integrations", async () => { const actual = await vi.importActual("@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", content: [ { type: "paragraph", content: [{ type: "text", text: "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); }); });