// L5 Berichte — PDF-Render-Smoke: Freigabe → generateReportPdf → Datei beginnt mit %PDF, Checksumme gespeichert, // PDF unveränderlich (zweiter Lauf überspringt), Download nur im eigenen Mandanten. // Ohne startbaren Chromium/Chrome wird der Render-Teil mit klarer Meldung übersprungen (Exit 0). // // Lauf: npx tsx scripts/test-berichte-pdf.ts import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; import { ServiceError, type ServiceCtx } from "../src/server/services/context"; import { pdfRendererAvailable } from "../src/server/pdf/render"; import { createDailyReport } from "../src/server/services/reports/create"; import { updateReportTexts } from "../src/server/services/reports/edit"; import { submitReport } from "../src/server/services/reports/submit"; import { approveReport } from "../src/server/services/reports/approve"; import { generateReportPdf } from "../src/server/services/reports/pdf"; import { openReportFile } from "../src/server/services/reports/files"; import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; const SLUG_A = "zz-berichte-pdf-a"; const SLUG_B = "zz-berichte-pdf-b"; const PNG_1PX = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64"); async function cleanup() { const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); const ids = tenants.map((t) => t.id); if (ids.length) { const w = { where: { tenantId: { in: ids } } }; await prisma.signature.deleteMany(w); await prisma.report.deleteMany(w); await prisma.photo.deleteMany(w); await prisma.workOrderStatusChange.deleteMany(w); await prisma.workOrderAssignee.deleteMany(w); await prisma.document.deleteMany(w); await prisma.workOrder.deleteMany(w); await prisma.customer.deleteMany(w); await prisma.numberSequence.deleteMany(w); await prisma.auditLog.deleteMany(w); await prisma.tenantSettings.deleteMany(w); await prisma.user.deleteMany(w); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-berichte-pdf.test" } } }); } const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions), }); async function main() { await cleanup(); const available = await pdfRendererAvailable(); if (!available.ok) { console.log(`⚠ ÜBERSPRUNGEN: kein Chromium startbar (${available.reason}). Abhilfe: PDF_CHROMIUM_PATH setzen oder "npx playwright-core install chromium".`); return; } const tA = await prisma.tenant.create({ data: { name: "PDF A", slug: SLUG_A } }); const tB = await prisma.tenant.create({ data: { name: "PDF B", slug: SLUG_B } }); await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb PDF GmbH", address: "Hafenstraße 12, 20457 Hamburg", phone: "+49 40 1", email: "info@pdf.example" } }); const mk = async (tenantId: string, key: string) => { const identity = await prisma.identity.create({ data: { email: `${key}@zz-berichte-pdf.test`, passwordHash: "x" } }); return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name: `Nutzer ${key}` } }); }; const tech = await mk(tA.id, "tech"); const office = await mk(tA.id, "office"); const officeB = await mk(tB.id, "officeb"); const techCtx = ctxOf(tA.id, tech.id, "technician"); const officeCtx = ctxOf(tA.id, office.id, "backoffice"); const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice"); const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kundin & Söhne " } }); const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number: "A-PDF1", customerId: customer.id, title: "Heizung warten", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } }, }); for (let i = 0; i < 3; i++) { const doc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: `foto-${i}.png`, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } }); await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: doc.id, phase: "after", comment: `Foto ${i}`, takenAt: new Date() } }); } const { report } = await createDailyReport(techCtx, { workOrderId: wo.id }); await updateReportTexts(techCtx, { reportId: report.id, texts: { workPerformed: "Brenner gereinigt.\nDruck geprüft.".repeat(40), hints: "Filter in 6 Monaten tauschen" } }); await submitReport(techCtx, { reportId: report.id }); const approved = await approveReport(officeCtx, { reportId: report.id }, { dispatchPdf: async () => {} }); ok(approved.status === "approved" && !approved.pdfDocumentId, "Bericht freigegeben, PDF noch nicht erzeugt"); const t0 = Date.now(); const res = await generateReportPdf(officeCtx, report.id); ok(!res.skipped, `PDF gerendert (${Date.now() - t0} ms)`); const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); const doc = await prisma.document.findUniqueOrThrow({ where: { id: stored.pdfDocumentId! } }); ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`); ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag"); ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert"); const bytes = await readFileBytes(doc.storageKey); if (bytes) { ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF"); ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes"); } else { console.log("⚠ Storage-Stub ohne Bytes (S3_* nicht gesetzt) — Byte-Prüfung übersprungen"); } const again = await generateReportPdf(officeCtx, report.id); ok(again.skipped && again.documentId === doc.id, "Zweiter Lauf überschreibt das freigegebene PDF nicht"); if (bytes) { const file = await openReportFile(officeCtx, report.id, "pdf"); ok(file.mimeType === "application/pdf", "Download im eigenen Mandanten möglich"); } try { await openReportFile(officeBCtx, report.id, "pdf"); ok(false, "Mandant B: PDF von A → not_found erwartet"); } catch (err) { ok(err instanceof ServiceError && err.code === "not_found", "Mandant B: PDF von A → not_found"); } try { await openReportFile(officeCtx, report.id, doc.id === "x" ? "y" : "unrelated-document-id"); ok(false, "Nicht referenziertes Dokument → not_found erwartet"); } catch (err) { ok(err instanceof ServiceError && err.code === "not_found", "Nicht referenziertes Dokument über Berichtsroute → not_found"); } } main() .catch((err) => { console.error(err); failures++; }) .finally(async () => { await cleanup().catch((e) => console.error("cleanup failed", e)); await prisma.$disconnect(); console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ PDF-Smoke abgeschlossen"); process.exit(failures ? 1 : 0); });