- AGENTS.md und README.md auf Craftvia umgeschrieben (Regeln, Andockpunkte, Stack mit Garage, tsx-Tests, Gate, Demo-Logins) - ISMS-Dokumente entfernt (SPEC, Prototypen, Lane-Prompts, Übergaben, Konzepte Incidents/Framework); Fundament-Doku (Deploy, Sicherheit, Backup, Identity) bleibt - Fundament-Tests an Craftvia-Rollen/Branding angepasst, Resttreffer certvia/isms in Skripten und Kommentaren bereinigt Gate: prisma generate, migrate status, tsc, lint, build, 22/22 Testskripte grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
7.4 KiB
TypeScript
166 lines
7.4 KiB
TypeScript
// 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 Craftvia-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<string, number>;
|
|
|
|
/** Owner-Rowcount je Modell für genau einen Mandanten (Join-Tabellen via id-Menge). */
|
|
async function fingerprint(tenantId: string): Promise<Fingerprint> {
|
|
const topo = buildTenantTopology();
|
|
const fp: Fingerprint = new Map();
|
|
// id-Mengen der scopenden Elternteile (User/Role) einmal bestimmen.
|
|
const idSets = new Map<string, string[]>();
|
|
for (const node of topo.insertOrder) {
|
|
const delegate = (prisma as unknown as Record<string, {
|
|
count: (a: unknown) => Promise<number>;
|
|
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
|
}>)[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, number> = {}): 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<void> {
|
|
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<string, {
|
|
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
|
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
|
}>)[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<string, {
|
|
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
|
}>)[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);
|
|
});
|