Substitui o textarea de texto puro por um editor visual real, seguindo o
print de referência do usuário do editor "Contrato de Software" do
OrçaFácil legado: toolbar completa (negrito/itálico/sublinhado/tachado,
alinhamento, listas, tabela, imagem, quebra de página), variáveis inseridas
como chip inline via catálogo fechado (nunca {{texto livre}}), painel
"Variáveis usadas neste modelo" (com badge obrigatório ao vivo) e
"Histórico de versões".
Backend: editor-content-renderer.ts e merge-engine.ts portados/reescritos
a partir do renderEditorContent.js/variableRegistry.js reais do legado
(achados em .tmp/orcafacil-reference) — resolve a árvore Tiptap
diretamente em vez de regex sobre HTML cacheado. Novo catálogo de
variáveis + GET /documents/variable-catalog.
Fora de escopo desta leva (documentado em docs/progress.md): nó
`conditional`, repetição de linha de tabela, bloqueio por variável
obrigatória ausente, e auto-resolução de dados reais do cadastro na hora
de gerar o documento.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
337 lines
13 KiB
TypeScript
337 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",
|
|
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);
|
|
});
|
|
});
|