Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// 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 (Container isms-tool-postgres-1); .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);
|
||||
});
|
||||
Reference in New Issue
Block a user