// Isolationsnachweis der Backup-/Restore-Engine (destruktive Lane). // // Beweisziel: Export → Wipe → Restore von Mandant `demo` lässt JEDE Tabelle des // zweiten Mandanten `demo2` (inkl. der Join-Tabellen UserRole/RolePermission) // BIT-genau unberührt. Gemessen wird per Owner-Rowcount (BYPASSRLS) über die aus // dem DMMF abgeleitete Traversierungs-Topologie — also über GENAU die Tabellen, // die die Engine anfasst. // // Lauf: npx tsx scripts/test-backup-isolation.ts // Nutzt die lokale isms-DB (geseedet: demo + demo2). Nach Erfolg ist `demo` // wiederhergestellt; bei Fehlschlag ggf. `npx tsx prisma/seed.ts` nachziehen. import "dotenv/config"; import { prisma } from "../src/server/db"; import { buildTenantTopology, assertTopologyMatchesDatabase } from "../src/server/backup/topology"; import { exportTenant } from "../src/server/backup/export"; import { restoreTenant } from "../src/server/backup/restore"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; type Fingerprint = Map; /** Owner-Rowcount je Modell für genau einen Mandanten (Join-Tabellen via id-Menge). */ async function fingerprint(tenantId: string): Promise { const topo = buildTenantTopology(); const fp: Fingerprint = new Map(); // id-Mengen der scopenden Elternteile (User/Role) einmal bestimmen. const idSets = new Map(); for (const node of topo.insertOrder) { const delegate = (prisma as unknown as Record Promise; findMany: (a: unknown) => Promise[]>; }>)[node.delegate]; let count: number; if (node.scope.by === "tenantColumn") { count = await delegate.count({ where: { tenantId } }); if (node.pk.length === 1) { const rows = await delegate.findMany({ where: { tenantId }, select: { [node.pk[0]]: true } }); idSets.set(node.model, rows.map((r) => String(r[node.pk[0]]))); } } else { const via = node.scope.via; const ids = idSets.get(via.parent) ?? []; count = ids.length ? await delegate.count({ where: { [via.fromFields[0]]: { in: ids } } }) : 0; } fp.set(node.model, count); } return fp; } function compare(before: Fingerprint, after: Fingerprint, adjust: Record = {}): string[] { const diffs: string[] = []; for (const [model, b] of before) { const expected = b + (adjust[model] ?? 0); const a = after.get(model) ?? -1; if (a !== expected) diffs.push(`${model}: erwartet ${expected}, ist ${a}`); } return diffs; } /** Destruktiver Wipe eines Mandanten (nur DB-Zeilen; Identities unangetastet). */ async function wipeTenant(tenantId: string): Promise { const topo = buildTenantTopology(); await prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica`); for (const node of topo.deleteOrder) { const del = (tx as unknown as Record Promise<{ count: number }>; findMany: (a: unknown) => Promise[]>; }>)[node.delegate]; if (node.scope.by === "tenantColumn") { await del.deleteMany({ where: { tenantId } }); } else { const via = node.scope.via; const parentNode = topo.nodes.get(via.parent)!; const parentPk = parentNode.pk[0]; const parentDelegate = (tx as unknown as Record Promise[]>; }>)[parentNode.delegate]; const parents = await parentDelegate.findMany({ where: { tenantId }, select: { [parentPk]: true } }); const ids = parents.map((p) => String(p[parentPk])); if (ids.length) await del.deleteMany({ where: { [via.fromFields[0]]: { in: ids } } }); } } }); } async function main() { // 0. Topologie-Selbstnachweis: abgeleitete FK-Ordnung == reale DB-Constraints. await assertTopologyMatchesDatabase(prisma); ok(true, "Topologie deckt sich mit den DB-FK-Constraints (fail-closed-Prüfung bestanden)"); const demo = await prisma.tenant.findUnique({ where: { slug: "demo" }, select: { id: true } }); const demo2 = await prisma.tenant.findUnique({ where: { slug: "demo2" }, select: { id: true } }); if (!demo || !demo2) { console.error("demo/demo2 nicht gefunden — bitte seeden (npx tsx prisma/seed.ts)."); process.exit(1); } // 1. Ausgangs-Fingerprints. const demoBefore = await fingerprint(demo.id); const demo2Before = await fingerprint(demo2.id); const demoTotal = [...demoBefore.values()].reduce((a, b) => a + b, 0); const demo2Total = [...demo2Before.values()].reduce((a, b) => a + b, 0); ok(demoTotal > 0, `demo hat Daten (${demoTotal} Zeilen über ${demoBefore.size} Tabellen)`); ok(demo2Total > 0, `demo2 hat Daten (${demo2Total} Zeilen über ${demo2Before.size} Tabellen)`); // Join-Tabellen explizit sichtbar machen (Prompt-Fokus). ok((demo2Before.get("UserRole") ?? 0) > 0, `demo2 UserRole-Zuweisungen vorhanden (${demo2Before.get("UserRole")})`); ok((demo2Before.get("RolePermission") ?? 0) > 0, `demo2 RolePermission-Zuordnungen vorhanden (${demo2Before.get("RolePermission")})`); // 2. Export demo (in-memory, kein Upload nötig). const snap = await exportTenant(demo.id, { persist: false, reason: "isolation-test" }); ok(snap.manifest.totalRows === demoTotal, `Export demo erfasst alle Zeilen (${snap.manifest.totalRows}/${demoTotal})`); // 3. Wipe demo (destruktiv). await wipeTenant(demo.id); const demoWiped = await fingerprint(demo.id); ok([...demoWiped.values()].every((c) => c === 0), "demo nach Wipe leer (alle Tabellen 0)"); // 3a. BEWEIS: demo2 nach dem demo-Wipe unverändert. const demo2AfterWipe = await fingerprint(demo2.id); const wipeDiffs = compare(demo2Before, demo2AfterWipe); ok(wipeDiffs.length === 0, `demo2 nach demo-Wipe UNVERÄNDERT${wipeDiffs.length ? " — " + wipeDiffs.join("; ") : ""}`); // 4. Restore demo aus dem Artefakt. const res = await restoreTenant( demo.id, { artifact: snap.artifact, manifest: snap.manifest }, { skipPreRestore: true, actorId: undefined }, ); ok(res.restoredRows === demoTotal, `Restore demo reinsertet alle Zeilen (${res.restoredRows}/${demoTotal})`); ok(res.stubIdentities.length === 0, "Restore demo ohne Identity-Stubs (Identities waren intakt)"); // 4a. demo exakt wiederhergestellt (AuditLog +1 durch den Restore-Audit-Eintrag). const demoAfter = await fingerprint(demo.id); const demoDiffs = compare(demoBefore, demoAfter, { AuditLog: 1 }); ok(demoDiffs.length === 0, `demo exakt wiederhergestellt${demoDiffs.length ? " — " + demoDiffs.join("; ") : " (AuditLog +1 = Restore-Audit)"}`); // 4b. BEWEIS (Hauptziel): demo2 über den gesamten Restore unverändert. const demo2After = await fingerprint(demo2.id); const restoreDiffs = compare(demo2Before, demo2After); ok(restoreDiffs.length === 0, `demo2 nach demo-Restore UNVERÄNDERT — JEDE Tabelle${restoreDiffs.length ? " — " + restoreDiffs.join("; ") : " (inkl. UserRole/RolePermission)"}`); } main() .then(async () => { await prisma.$disconnect(); if (failures > 0) { console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`); process.exit(1); } console.log("\nOK"); }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });