// WS0-Abnahmetest (Option C — "Zentrale Identität mit Mandanten-Mitgliedschaften"). // // Weist die "Goldenen Regeln" des Fundament-Umbaus auf DB-Ebene nach: // 1. `identities` ist GLOBAL: KEIN tenant_id, KEINE RLS-Policy. // 2. Jede Mitgliedschaft (`users`) verweist auf genau eine Identity (identity_id NOT NULL). // 3. Eine Person hat je Mandant höchstens EINE Mitgliedschaft (@@unique(tenant,identity)). // 4. Multi-Membership-Fixture: eine Identity ist in ZWEI Mandanten mit je eigener Rolle. // 5. Login-Quelle ist Identity: identity.findUnique({email}) liefert passwordHash // über den rohen (Owner-)`prisma`-Client — ohne Mandantenkontext. // 6. Expand/Contract: WebAuthn hängt in WS0 noch an `users` (tenant-gebunden) — der // Umzug auf die Identity ist bewusst WS4 (dieser Test darf nach WS4 angepasst werden). // // Lauf: npx tsx scripts/test-identity-schema.ts // Nutzt die lokale Postgres-DB (Compose-Service postgres); .env liegt im Worktree. // Setzt den Demo-Seed voraus (npx tsx prisma/seed.ts). import "dotenv/config"; import { prisma } from "../src/server/db"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; async function expectThrow(fn: () => Promise, msg: string) { try { await fn(); ok(false, `${msg} — kein Throw`); } catch { ok(true, msg); } } async function count(sql: string): Promise { const r = await prisma.$queryRawUnsafe<{ c: number }[]>(sql); return Number(r[0].c); } async function main() { console.log("\n— Regel 1: identities ist global (kein tenant_id, keine RLS-Policy) —"); const hasTenantCol = await count( `SELECT count(*)::int AS c FROM information_schema.columns WHERE table_name = 'identities' AND column_name = 'tenant_id'` ); ok(hasTenantCol === 0, "identities hat KEINE tenant_id-Spalte"); const policies = await count(`SELECT count(*)::int AS c FROM pg_policies WHERE tablename = 'identities'`); ok(policies === 0, "identities hat KEINE RLS-Policy"); const rlsForced = await count( `SELECT count(*)::int AS c FROM pg_class WHERE relname = 'identities' AND (relrowsecurity OR relforcerowsecurity)` ); ok(rlsForced === 0, "identities hat RLS weder ENABLED noch FORCED"); console.log("\n— Regel 2: jede Mitgliedschaft hat eine Identity —"); const orphan = await count(`SELECT count(*)::int AS c FROM users WHERE identity_id IS NULL`); ok(orphan === 0, "keine users-Zeile ohne identity_id"); const memberships = await prisma.user.count(); ok(memberships > 0, `Mitgliedschaften vorhanden (${memberships})`); console.log("\n— Regel 3: @@unique(tenant_id, identity_id) verhindert Doppel-Mitgliedschaft —"); const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } }); const multi = await prisma.identity.findUniqueOrThrow({ where: { email: "multi@demo.example" } }); // multi@ ist bereits Mitglied in "demo" → ein zweiter Datensatz muss am Unique scheitern. await expectThrow( () => prisma.user.create({ data: { tenantId: demo.id, identityId: multi.id, email: "dup-membership@demo.example", name: "Duplikat", }, }), "zweite Mitgliedschaft (demo, multi) → Unique-Verstoß" ); console.log("\n— Regel 4: Multi-Membership-Fixture über zwei Mandanten —"); const multiFull = await prisma.identity.findUniqueOrThrow({ where: { email: "multi@demo.example" }, include: { memberships: { include: { tenant: true, userRoles: { include: { role: true } } } } }, }); const slugs = multiFull.memberships.map((m) => m.tenant.slug).sort(); ok(slugs.length >= 2 && slugs.includes("demo") && slugs.includes("demo2"), `multi@ ist in ≥2 Mandanten (${slugs.join(", ")})`); const rolesByTenant = Object.fromEntries( multiFull.memberships.map((m) => [m.tenant.slug, m.userRoles.map((r) => r.role.key).sort()]) ); ok( JSON.stringify(rolesByTenant.demo) !== JSON.stringify(rolesByTenant.demo2), `Rollen je Mandant verschieden (demo=${JSON.stringify(rolesByTenant.demo)}, demo2=${JSON.stringify(rolesByTenant.demo2)})` ); console.log("\n— Regel 5: Login-Quelle ist Identity (Owner-Client, ohne Mandantenkontext) —"); const admin = await prisma.identity.findUnique({ where: { email: "admin@demo.example" } }); ok(!!admin && admin.passwordHash.length > 0, "identity.findUnique({email}) liefert passwordHash"); const singleAdminMemberships = await prisma.user.count({ where: { identity: { email: "admin@demo.example" } } }); ok(singleAdminMemberships === 1, `admin@ bleibt single-membership (Ein-Schritt-Login heil): ${singleAdminMemberships}`); console.log("\n— Regel 6: WebAuthn ist identitätsgebunden (WS4b) —"); const waTenantCol = await count( `SELECT count(*)::int AS c FROM information_schema.columns WHERE table_name = 'webauthn_credentials' AND column_name = 'tenant_id'` ); ok(waTenantCol === 0, "webauthn_credentials hat KEINE tenant_id mehr"); const waIdentityCol = await count( `SELECT count(*)::int AS c FROM information_schema.columns WHERE table_name = 'webauthn_credentials' AND column_name = 'identity_id'` ); ok(waIdentityCol === 1, "webauthn_credentials trägt identity_id"); const waPolicies = await count(`SELECT count(*)::int AS c FROM pg_policies WHERE tablename = 'webauthn_credentials'`); ok(waPolicies === 0, "webauthn_credentials hat KEINE RLS-Policy mehr"); } 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); });