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>
212 lines
15 KiB
TypeScript
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);
|
|
});
|