Files
craftvia/scripts/test-stammdaten-documents.ts
msolarczekandClaude Opus 5 6423351035 L1 Stammdaten: Tests für Dubletten, Kunden, Objekte, Teams und Dokumente
Kernlogik, Mandantentrennung (Mandant B liest/ändert nichts von A) und
Rollen/Scope (Monteur ohne Zuweisung → not_found/forbidden).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:26:27 +02:00

212 lines
15 KiB
TypeScript

// 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 <v2>.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/<id>");
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);
});