feat(documents): renderização real de PDF via Chromium headless
Substitui a geração de PDF simulada (pdf_s3_key/pdf_sha256/pdf_size_bytes sempre nulos) por Chromium real (Playwright), portado do chromiumPdfRenderer.js do legado: SSRF impossível por construção (setContent + bloqueio de toda requisição de rede da página), browser único reaproveitado com relançamento automático, semáforo de concorrência + timeout duro. Infra: MinIO (compose.yaml, primeiro consumidor real do adapter S3) e instalação condicional do Chromium só na imagem da API (Dockerfile.node). DocumentsService.createGeneration agora renderiza, faz upload e persiste hash/tamanho de verdade; novo endpoint de download GET /v1/documents/generations/:id/pdf. Verificado ao vivo através do container real: PDF gerado, baixado, e SHA-256 conferido byte a byte contra o valor persistido no Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Header, Param, Patch, Post, Query, Res, StreamableFile, UseGuards } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { DocumentsService } from "./documents.service";
|
||||
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
@@ -65,6 +66,15 @@ export class DocumentsController {
|
||||
return this.documents.createGeneration(dto, user);
|
||||
}
|
||||
|
||||
@Get("generations/:id/pdf")
|
||||
@RequirePermission("documents", "view")
|
||||
@Header("Content-Type", "application/pdf")
|
||||
async downloadGenerationPdf(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
|
||||
const { generation, stream } = await this.documents.getGenerationPdf(id);
|
||||
res.set({ "Content-Disposition": `inline; filename="${generation.id}.pdf"` });
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Get("generations")
|
||||
@RequirePermission("documents", "view")
|
||||
listGenerations(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Readable } from "node:stream";
|
||||
import { auditRepo, documentsRepo } from "@eden/database";
|
||||
import type { DocumentGeneration } from "@eden/database";
|
||||
import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
import { mergeVariables } from "./merge-engine";
|
||||
import { renderEditorContentToHtml, resolveSourceText } from "./editor-content-renderer";
|
||||
import { renderEditorContentToHtml } from "./editor-content-renderer";
|
||||
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||
import type { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||
@@ -148,22 +152,39 @@ export class DocumentsService {
|
||||
throw new NotFoundException("Este template ainda não tem uma versão publicada");
|
||||
}
|
||||
|
||||
// Fluxo simplificado desta leva (eden.md §4 descreve o pipeline
|
||||
// completo com renderToHtml/sanitizeDocumentHtml/Chromium/S3): roda o
|
||||
// merge engine sobre o texto extraído de editor_content.plainText da
|
||||
// versão publicada — sem renderização real de PDF (ver comentário na
|
||||
// migration e no repositório documents.ts).
|
||||
const sourceText = resolveSourceText(published.version.editor_content);
|
||||
const { resolved, missingVariables } = mergeVariables(sourceText, dto.context ?? {});
|
||||
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
|
||||
// não reimplementado nesta leva (editor_content ainda não valida a
|
||||
// árvore Tiptap real, ver editor-content-renderer.ts); mas a
|
||||
// renderização de PDF em si (Chromium) e o upload para S3 já são
|
||||
// reais a partir daqui.
|
||||
const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {});
|
||||
|
||||
const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null };
|
||||
|
||||
// Evita renderizar/subir um PDF à toa quando o resultado vai ser
|
||||
// descartado de qualquer forma pela idempotência (mesmo par
|
||||
// template_version + origem já finalizado).
|
||||
const existing = await documentsRepo.findFinalGeneration(published.version.id, origin);
|
||||
if (existing) {
|
||||
return { ...existing, missingVariables, deduped: true };
|
||||
}
|
||||
|
||||
const pdfBuffer = await renderPdf({ html: resolved });
|
||||
const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex");
|
||||
const pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
|
||||
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");
|
||||
|
||||
const { generation, deduped } = await documentsRepo.createGenerationIdempotent({
|
||||
templateId: dto.templateId,
|
||||
templateVersionId: published.version.id,
|
||||
clientRegistrationId: dto.clientRegistrationId ?? null,
|
||||
resellerId: dto.resellerId ?? null,
|
||||
clientRegistrationId: origin.clientRegistrationId,
|
||||
resellerId: origin.resellerId,
|
||||
renderContext: dto.context ?? {},
|
||||
renderedHtml: resolved,
|
||||
generatedBy: user.id,
|
||||
pdfS3Key,
|
||||
pdfSha256,
|
||||
pdfSizeBytes: pdfBuffer.length,
|
||||
});
|
||||
|
||||
if (!deduped) {
|
||||
@@ -180,6 +201,14 @@ export class DocumentsService {
|
||||
return { ...generation, missingVariables, deduped };
|
||||
}
|
||||
|
||||
async getGenerationPdf(id: string): Promise<{ generation: DocumentGeneration; stream: Readable }> {
|
||||
const generation = await documentsRepo.getGenerationById(id);
|
||||
if (!generation) throw new NotFoundException("Geração de documento não encontrada");
|
||||
if (!generation.pdf_s3_key) throw new NotFoundException("Esta geração ainda não tem um PDF disponível");
|
||||
const stream = await getObjectStream(generation.pdf_s3_key);
|
||||
return { generation, stream };
|
||||
}
|
||||
|
||||
async listGenerations(filter: { templateId?: string; clientRegistrationId?: string; resellerId?: string }) {
|
||||
return documentsRepo.listGenerations(filter);
|
||||
}
|
||||
|
||||
132
apps/api/test/documents-pdf.spec.ts
Normal file
132
apps/api/test/documents-pdf.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { Readable } from "node:stream";
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
|
||||
|
||||
/**
|
||||
* `renderPdf`/`uploadObject`/`getObjectStream` are mocked (the real call
|
||||
* path through documents.service.ts still runs) — a real Chromium render +
|
||||
* a real MinIO round trip isn't worth the flakiness/runtime cost in this
|
||||
* suite; renderer.ts itself is exercised by the manual smoke test noted in
|
||||
* docs/progress.md. What's under test here is documents.service.ts's own
|
||||
* logic: it must actually call the renderer with the merged HTML, hash and
|
||||
* persist the result, skip re-rendering on dedup, and stream the right S3
|
||||
* key back out on download.
|
||||
*/
|
||||
const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake pdf bytes"));
|
||||
const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined);
|
||||
const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)]));
|
||||
vi.mock("@eden/integrations", () => ({
|
||||
renderPdf: (input: { html: string }) => renderPdfMock(input),
|
||||
uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType),
|
||||
getObjectStream: (key: string) => getObjectStreamMock(key),
|
||||
}));
|
||||
|
||||
describe("documents — PDF generation + download", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
let templateId: string;
|
||||
let resellerId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
|
||||
const template = await agent(app)
|
||||
.post("/api/v1/documents/templates")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ key: `qa_test_pdf_${Date.now()}`, name: "qa-test- Template PDF", docGroup: "revenda" })
|
||||
.expect(201);
|
||||
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: "Revenda: {{revenda.nome}}" } })
|
||||
.expect(200);
|
||||
await agent(app)
|
||||
.post(`/api/v1/documents/templates/${templateId}/publish`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(201);
|
||||
|
||||
const reseller = await agent(app)
|
||||
.post("/api/v1/resellers")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: "qa-test- Revenda PDF" })
|
||||
.expect(201);
|
||||
resellerId = reseller.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("renders a real PDF, uploads it, and persists the hash/size/key", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/documents/generations")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ templateId, resellerId, context: { "revenda.nome": "ACME" } })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.deduped).toBe(false);
|
||||
expect(res.body.missingVariables).toEqual([]);
|
||||
expect(res.body.pdf_s3_key).toMatch(/^documents\/generations\/.+\.pdf$/);
|
||||
expect(res.body.pdf_size_bytes).toBeGreaterThan(0);
|
||||
|
||||
// The HTML actually handed to the renderer had the variable resolved.
|
||||
expect(renderPdfMock).toHaveBeenCalledTimes(1);
|
||||
const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
|
||||
expect(renderedHtml).toContain("Revenda: ACME");
|
||||
expect(renderedHtml).not.toContain("{{revenda.nome}}");
|
||||
|
||||
// Uploaded buffer's hash matches the mock's output and what got persisted.
|
||||
const uploadCall = uploadObjectMock.mock.calls[0]!;
|
||||
expect(uploadCall[0]).toBe(res.body.pdf_s3_key);
|
||||
expect(uploadCall[2]).toBe("application/pdf");
|
||||
expect(createHash("sha256").update(uploadCall[1]).digest("hex")).toBe(res.body.pdf_sha256);
|
||||
});
|
||||
|
||||
it("dedupes an identical (template version, origin) request without re-rendering", async () => {
|
||||
renderPdfMock.mockClear();
|
||||
uploadObjectMock.mockClear();
|
||||
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/documents/generations")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ templateId, resellerId, context: { "revenda.nome": "ACME" } })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.deduped).toBe(true);
|
||||
expect(renderPdfMock).not.toHaveBeenCalled();
|
||||
expect(uploadObjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams the stored PDF back from the download endpoint", async () => {
|
||||
const listRes = await agent(app)
|
||||
.get("/api/v1/documents/generations")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.query({ resellerId })
|
||||
.expect(200);
|
||||
const generation = listRes.body.find((g: { reseller_id: string | null }) => g.reseller_id === resellerId);
|
||||
expect(generation).toBeTruthy();
|
||||
|
||||
const download = await agent(app)
|
||||
.get(`/api/v1/documents/generations/${generation.id}/pdf`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
|
||||
expect(download.headers["content-type"]).toBe("application/pdf");
|
||||
expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key);
|
||||
expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user