// L1 Stammdaten — Dokumentenablage (ARCHITEKTUR §4.3, Spec §24, §27.4): // (1) Scanner/Typprüfung: Magic Bytes, Allowlist, Typ-Mismatch // (2) Dateinamen-Normalisierung // (3) storeFile: falscher Magic Byte / leer / zu groß / Sichtbarkeit → abgelehnt; SHA-256, Versionierung // (4) Download-Autorisierung: backoffice_only für Monteur verweigert, fremder Mandant verweigert, // Auftrags-Scope, Objekt-Scope, Teamleiter-Sichtbarkeit // // Lauf: npx tsx scripts/test-stammdaten-documents.ts import "dotenv/config"; // must run before any module that constructs the Prisma client import { createHash } from "node:crypto"; import { detectMime, MagicByteScanner } from "../src/server/services/documents/scanner"; import { normalizeFileName, storeFile, SIZE_LIMITS } from "../src/server/services/documents/store"; import { authorizeDocumentAccess, deleteDocument, getDownloadUrl, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access"; import { checker, cleanupTenants, createTeamWithMember, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; const SLUG_A = "zz-l1-doc-a"; const SLUG_B = "zz-l1-doc-b"; const DOMAIN = "zz-l1-doc.test"; const c = checker("Dokumente"); const PDF = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n"); const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); const JPEG_HEAD = [0xff, 0xd8, 0xff, 0xe0]; const EXE = new TextEncoder().encode("MZ\x90\x00this is not an image"); async function main() { console.log("— (1) Scanner —"); const scanner = new MagicByteScanner(); c.ok(detectMime(PDF) === "application/pdf" && detectMime(PNG) === "image/png" && detectMime(new Uint8Array(JUMP())) === "image/jpeg", "Magic Bytes PDF/PNG/JPEG erkannt"); c.ok(detectMime(EXE) === null, "unbekannte Signatur (EXE) → nicht erkannt"); const okPdf = await scanner.scan({ bytes: PDF, declaredMime: "application/pdf", fileName: "a.pdf" }); c.ok(okPdf.ok && okPdf.kind === "pdf", "PDF mit passendem Typ akzeptiert"); const mismatch = await scanner.scan({ bytes: PNG, declaredMime: "application/pdf", fileName: "a.pdf" }); c.ok(!mismatch.ok && mismatch.reason === "type_mismatch", "PNG-Bytes als PDF deklariert → type_mismatch"); const exeAsPng = await scanner.scan({ bytes: EXE, declaredMime: "image/png", fileName: "x.png" }); c.ok(!exeAsPng.ok && exeAsPng.reason === "type_mismatch", "EXE als PNG deklariert → type_mismatch"); const txt = await scanner.scan({ bytes: PDF, declaredMime: "text/html", fileName: "x.html" }); c.ok(!txt.ok && txt.reason === "unsupported_type", "nicht erlaubter Typ → unsupported_type"); const jpgAlias = await scanner.scan({ bytes: new Uint8Array(JUMP()), declaredMime: "image/jpg", fileName: "x.jpg" }); c.ok(jpgAlias.ok && jpgAlias.detectedMime === "image/jpeg", "MIME-Alias image/jpg → image/jpeg"); console.log("\n— (2) Dateinamen —"); c.ok(normalizeFileName("../../etc/passwd") === "passwd", "Pfadanteile entfernt"); c.ok(normalizeFileName("C:\\Users\\x\\Plan .pdf") === "Plan _v2_.pdf", "Windows-Pfad und reservierte Zeichen"); c.ok(normalizeFileName("a\u0000b\u001f.pdf") === "ab.pdf", "Steuerzeichen entfernt"); c.ok(normalizeFileName(" ") === "datei" && normalizeFileName(".hidden") === "hidden", "leer → „datei“, führender Punkt entfernt"); const long = normalizeFileName(`${"x".repeat(300)}.pdf`); c.ok(long.length === 180 && long.endsWith(".pdf"), "Länge begrenzt, Endung erhalten"); c.ok(normalizeFileName("Grundriss Erdgeschoß.pdf") === "Grundriss Erdgeschoß.pdf", "Umlaute und Leerzeichen bleiben lesbar"); console.log("\n— (3) storeFile —"); await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); const tA = await createTenant(SLUG_A, "L1 Dokumente A"); const tB = await createTenant(SLUG_B, "L1 Dokumente B"); const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X"); const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team"); const lead = await createUser(tA.id, `lead@${DOMAIN}`, "Teamleiter X"); const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); const ctxA = ctxFor(tA.id, boA.id, "backoffice"); const ctxT1 = ctxFor(tA.id, tech1.id, "technician"); const ctxT2 = ctxFor(tA.id, tech2.id, "technician"); const ctxLead = ctxFor(tA.id, lead.id, "team-lead"); const ctxB = ctxFor(tB.id, boB.id, "backoffice"); const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Dokukunde" } }); const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Objekt mit Plänen" } }); const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id, lead.id); const teamY = await createTeamWithMember(tA.id, "Team Y", null); const woX = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id }); const woY = await createWorkOrder(tA.id, { customerId: customer.id, assignedTeamId: teamY.id }); await c.expectServiceError( () => storeFile(ctxA, { bytes: PNG, fileName: "plan.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", links: { siteId: site.id } }), "invalid", "falscher Magic Byte (PNG als PDF) → abgelehnt", "type_mismatch", ); await c.expectServiceError( () => storeFile(ctxA, { bytes: EXE, fileName: "virus.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), "invalid", "EXE mit .pdf-Endung → abgelehnt", "type_mismatch", ); await c.expectServiceError( () => storeFile(ctxA, { bytes: new Uint8Array(0), fileName: "leer.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), "invalid", "leere Datei → abgelehnt", "empty_file", ); const bigJpeg = new Uint8Array(SIZE_LIMITS.image + 1); bigJpeg.set(JUMP()); await c.expectServiceError( () => storeFile(ctxA, { bytes: bigJpeg, fileName: "gross.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { siteId: site.id } }), "invalid", "Bild über 15 MB → abgelehnt", "too_large", ); await c.expectServiceError( () => storeFile(ctxT1, { bytes: PDF, fileName: "intern.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }), "invalid", "Monteur darf keine backoffice_only-Datei ablegen", "visibility_not_allowed", ); await c.expectServiceError( () => storeFile(ctxT1, { bytes: PDF, fileName: "objekt.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), "forbidden", "Monteur ohne document:write darf nicht am Objekt ablegen", ); await c.expectServiceError( () => storeFile(ctxT1, { bytes: PDF, fileName: "fremd.pdf", declaredMime: "application/pdf", category: "photo", visibility: "team", links: { workOrderId: woY.id } }), "not_found", "Monteur legt an nicht sichtbarem Auftrag ab → not_found", ); await c.expectServiceError( () => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), "invalid", "Mandant B legt an Objekt von A ab → abgelehnt", "site_not_found", ); const planV1 = await storeFile(ctxA, { bytes: PDF, fileName: "../Grundriss EG.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", title: "Grundriss EG", links: { siteId: site.id } }); c.ok(planV1.version === 1 && planV1.fileName === "Grundriss EG.pdf" && planV1.mimeType === "application/pdf", "PDF gespeichert, Name normalisiert"); c.ok(planV1.checksum === createHash("sha256").update(PDF).digest("hex") && planV1.fileSize === PDF.byteLength, "SHA-256-Prüfsumme und Größe"); c.ok(planV1.storageKey.startsWith(`${tA.id}/`) || planV1.storageKey.startsWith("stub://"), "Storage-Key mandantenpräfixiert"); c.ok(planV1.uploadedById === boA.id, "Ersteller gespeichert"); c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "document", entityId: planV1.id, action: "create" } })), "Audit create"); const planV2 = await storeFile(ctxA, { bytes: PDF, fileName: "Grundriss EG v2.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", lineageId: planV1.lineageId }); c.ok(planV2.version === 2 && planV2.lineageId === planV1.lineageId && planV2.siteId === site.id, "neue Version: version 2, gleiche lineage, Zuordnung übernommen"); await c.expectServiceError( () => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", lineageId: planV1.lineageId }), "invalid", "Mandant B kann keine Version eines A-Dokuments anlegen", "lineage_not_found", ); console.log("\n— (4) Download-Autorisierung —"); const internal = await storeFile(ctxA, { bytes: PDF, fileName: "kalkulation.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }); const leadOnly = await storeFile(ctxA, { bytes: PDF, fileName: "teamleitung.pdf", declaredMime: "application/pdf", category: "other", visibility: "team_lead", links: { workOrderId: woX.id } }); const teamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "montage.pdf", declaredMime: "application/pdf", category: "assembly_instructions", visibility: "team", links: { workOrderId: woX.id } }); const otherTeamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "fremdteam.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: woY.id } }); const photo = await storeFile(ctxT1, { bytes: new Uint8Array(JUMP()), fileName: "foto.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woX.id } }); c.ok(photo.uploadedById === tech1.id, "Monteur legt Foto an sichtbarem Auftrag ab (field:execute)"); await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, internal.id), "not_found", "backoffice_only für Monteur verweigert"); await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, leadOnly.id), "not_found", "team_lead-Dokument für Monteur verweigert"); c.ok((await authorizeDocumentAccess(ctxLead, leadOnly.id)).id === leadOnly.id, "team_lead-Dokument für Teamleiter erlaubt"); await c.expectServiceError(() => authorizeDocumentAccess(ctxLead, internal.id), "not_found", "backoffice_only auch für Teamleiter verweigert"); c.ok((await authorizeDocumentAccess(ctxT1, teamDoc.id)).id === teamDoc.id, "Team-Dokument am eigenen Auftrag erlaubt"); await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, otherTeamDoc.id), "not_found", "Team-Dokument an fremdem Auftrag verweigert (Auftrags-Scope)"); c.ok((await authorizeDocumentAccess(ctxT1, planV2.id)).id === planV2.id, "Objekt-Dokument erlaubt, wenn Objekt über Teamauftrag erreichbar"); await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, planV2.id), "not_found", "Objekt-Dokument für Monteur ohne Zuweisung verweigert"); await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, teamDoc.id), "not_found", "Auftrags-Dokument für Monteur ohne Zuweisung verweigert"); await c.expectServiceError(() => authorizeDocumentAccess(ctxB, teamDoc.id), "not_found", "fremder Mandant verweigert (Team-Dokument)"); await c.expectServiceError(() => authorizeDocumentAccess(ctxB, internal.id), "not_found", "fremder Mandant verweigert (Backoffice-Dokument)"); await c.expectServiceError(() => openDocumentContent(ctxB, planV1.id), "not_found", "fremder Mandant erhält keinen Inhalt"); c.ok((await authorizeDocumentAccess(ctxA, internal.id)).id === internal.id, "Backoffice mit document:read_internal liest backoffice_only"); await c.expectServiceError( () => authorizeDocumentAccess(ctxFor(tA.id, boA.id, "backoffice", { remove: ["document:read"] }), teamDoc.id), "not_found", "ohne document:read → verweigert", ); c.ok((await getDownloadUrl(ctxT1, teamDoc.id)) === `/files/${teamDoc.id}`, "Download-Link ist die interne Route /files/"); const t1List = await listDocuments(ctxT1, { pageSize: 100 }); const t1Ids = new Set(t1List.items.map((d) => d.id)); c.ok(t1Ids.has(teamDoc.id) && t1Ids.has(planV2.id) && t1Ids.has(photo.id), "Monteur-Liste enthält sichtbare Dokumente"); c.ok(!t1Ids.has(internal.id) && !t1Ids.has(leadOnly.id) && !t1Ids.has(otherTeamDoc.id), "Monteur-Liste ohne interne/fremde Dokumente"); const latest = await listDocuments(ctxA, { siteId: site.id, latestOnly: true }); c.ok(latest.items.some((d) => d.id === planV2.id) && !latest.items.some((d) => d.id === planV1.id), "latestOnly zeigt nur die neueste Version"); const byCustomer = await listDocuments(ctxA, { customerId: customer.id, pageSize: 100 }); c.ok(byCustomer.items.some((d) => d.id === planV1.id) && byCustomer.items.some((d) => d.id === teamDoc.id), "Filter Kunde umfasst Objekt- und Auftragsdokumente"); c.ok((await listDocuments(ctxB, { pageSize: 100 })).total === 0, "Mandant B sieht keine Dokumente von A"); if (planV1.storageKey.startsWith(`${tA.id}/`)) { const { content } = await openDocumentContent(ctxT1, planV1.id); const buf = Buffer.from(await new Response(content.stream).arrayBuffer()); c.ok(buf.equals(Buffer.from(PDF)), "Inhalt aus dem Objektspeicher byte-identisch"); } else { console.log("↷ Byte-Roundtrip übersprungen (kein S3 konfiguriert, Stub-Adapter)"); } await c.expectServiceError(() => updateDocumentMeta(ctxT1, teamDoc.id, { title: "x" }), "forbidden", "Monteur darf Metadaten nicht ändern"); await c.expectServiceError(() => updateDocumentMeta(ctxLead, teamDoc.id, { visibility: "backoffice_only" }), "forbidden", "Teamleiter ohne document:write → forbidden"); const meta = await updateDocumentMeta(ctxA, teamDoc.id, { title: "Montageanleitung", visibility: "team_lead" }); c.ok(meta.visibility === "team_lead", "Backoffice ändert Sichtbarkeit"); await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, teamDoc.id), "not_found", "nach Umstellung auf team_lead für Monteur verborgen"); await c.expectServiceError(() => deleteDocument(ctxB, planV2.id), "not_found", "Mandant B löscht Dokument von A → not_found"); await deleteDocument(ctxA, planV2.id); await c.expectServiceError(() => authorizeDocumentAccess(ctxA, planV2.id), "not_found", "soft-gelöschtes Dokument nicht mehr abrufbar"); c.ok(!!(await prisma.document.findUnique({ where: { id: planV2.id } })), "Datensatz bleibt physisch erhalten (Soft Delete)"); } /** Minimal JPEG header bytes. */ function JUMP(): number[] { return [...JPEG_HEAD, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]; } main() .catch((err) => { console.error(err); c.ok(false, "unerwarteter Fehler"); }) .finally(async () => { await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); const failures = c.finish(); await disconnect(); process.exit(failures === 0 ? 0 : 1); });