Files
craftvia/scripts/test-identity-schema.ts
msolarczekandClaude Opus 5 1701db0a62 Fundament: Doku, Testanpassungen, Restbereinigung
- 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>
2026-09-14 11:44:58 +02:00

124 lines
5.7 KiB
TypeScript

// 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<unknown>, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Throw`);
} catch {
ok(true, msg);
}
}
async function count(sql: string): Promise<number> {
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);
});