import { PrismaClient } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; /** * Konto und Mitgliedschaft — geprüft dort, wo die Trennung durchgesetzt wird. * * Phase 10 hat das Anmeldekonto von der Personenakte gelöst. Damit steht zum * ersten Mal eine Tabelle über allen Mandanten (`benutzer`), und die Frage * „wer darf welche Wehr sehen" hängt nicht mehr an der Akte, sondern an einer * eigenen Zuordnung. * * Das ist der Punkt, an dem eine Mandantentrennung normalerweise leckt: eine * Zuordnung, die auf eine Akte aus einer anderen Wehr zeigt, und schon steht * in der Sitzung eine Personenkennung, die dort niemandem gehört. Genau * dagegen laufen die Prüfungen hier — und zwar über den Systemzugang: kommt * nicht einmal der Eigentümer der Datenbank daran vorbei, kommt niemand * vorbei. * * Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt * er sich selbst, damit "npm run test" auch ohne Docker durchläuft. */ const APP_URL = process.env.DATABASE_URL ?? ""; const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL; const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); const SLUG_A = "test-mandant-a"; const SLUG_B = "test-mandant-b"; let erreichbar = false; let wehrA = ""; let wehrB = ""; let akteA = ""; let akteB = ""; let konto = ""; beforeAll(async () => { try { await system.$queryRaw`SELECT 1`; erreichbar = true; } catch { return; } const a = await system.organisation.upsert({ where: { slug: SLUG_A }, update: {}, create: { slug: SLUG_A, name: "Mandant A", kurzname: "A" }, }); wehrA = a.id; const b = await system.organisation.upsert({ where: { slug: SLUG_B }, update: {}, create: { slug: SLUG_B, name: "Mandant B", kurzname: "B" }, }); wehrB = b.id; const personA = await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId: wehrA, mitgliedsnummer: "A-1" } }, update: {}, create: { organisationId: wehrA, vorname: "Anna", nachname: "Aktiv", mitgliedsnummer: "A-1", eintrittsdatum: new Date("2015-01-01"), }, }); akteA = personA.id; const personB = await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId: wehrB, mitgliedsnummer: "B-1" } }, update: {}, create: { organisationId: wehrB, vorname: "Bernd", nachname: "Beisitzer", mitgliedsnummer: "B-1", eintrittsdatum: new Date("2015-01-01"), }, }); akteB = personB.id; const benutzer = await system.benutzer.upsert({ where: { email: "doppel@test-mandant.de" }, update: {}, create: { email: "doppel@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }); konto = benutzer.id; }); afterAll(async () => { if (erreichbar) { await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } }); await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } }); }); } await system.$disconnect(); }); describe.runIf(process.env.DATABASE_URL)("Doppelmitgliedschaft", () => { it("lässt ein Konto zu zwei Wehren gehören", async ({ skip }) => { if (!erreichbar) return skip(); // Das ist der ganze Zweck der Phase: ein Mensch, ein Passwort, zwei // Wehren. Vorher hätte es zwei Konten gebraucht, die auseinanderlaufen. await system.mitgliedschaft.create({ data: { organisationId: wehrA, benutzerId: konto, personId: akteA, art: "MITGLIED" }, }); await system.mitgliedschaft.create({ data: { organisationId: wehrB, benutzerId: konto, art: "AUFSICHT" }, }); const alle = await system.mitgliedschaft.findMany({ where: { benutzerId: konto } }); expect(alle).toHaveLength(2); }); it("nimmt ein Konto nur einmal je Wehr auf", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.mitgliedschaft.create({ data: { organisationId: wehrA, benutzerId: konto, personId: akteA, art: "MITGLIED" }, }), ).rejects.toThrow(); }); it("hängt eine Personenakte an höchstens ein Konto", async ({ skip }) => { if (!erreichbar) return skip(); // Sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat. const zweites = await system.benutzer.create({ data: { email: "zweites@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }); await expect( system.mitgliedschaft.create({ data: { organisationId: wehrA, benutzerId: zweites.id, personId: akteA, art: "MITGLIED" }, }), ).rejects.toThrow(); }); }); describe.runIf(process.env.DATABASE_URL)("Die Akte gehört zur Wehr", () => { it("weist eine Zuordnung über Wehrgrenzen hinweg ab", async ({ skip }) => { if (!erreichbar) return skip(); // Der eigentliche Mandantenschutz dieser Phase. Ein Fremdschlüssel prüft // nur, dass es die Akte gibt — nicht, wo sie liegt. Ohne den Trigger // ließe sich ein Konto über eine Akte aus Wehr A in Wehr B hängen, und // die Sitzung stünde dort mit einer Kennung da, die niemandem gehört. const fremd = await system.benutzer.create({ data: { email: "fremd@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }); await expect( system.mitgliedschaft.create({ data: { organisationId: wehrB, benutzerId: fremd.id, // Die Akte liegt in Wehr A. personId: akteA, art: "MITGLIED", }, }), ).rejects.toThrow(/andere Wehr|gehoert/i); }); it("weist auch das nachträgliche Umhängen ab", async ({ skip }) => { if (!erreichbar) return skip(); const eigene = await system.mitgliedschaft.findFirst({ where: { benutzerId: konto, organisationId: wehrA }, }); expect(eigene).not.toBeNull(); await expect( system.mitgliedschaft.update({ where: { id: eigene!.id }, data: { personId: akteB }, }), ).rejects.toThrow(/andere Wehr|gehoert/i); }); }); describe.runIf(process.env.DATABASE_URL)("Was die Datenbank gar nicht erst annimmt", () => { it("keine Mitgliedschaft ohne Akte", async ({ skip }) => { if (!erreichbar) return skip(); const ohne = await system.benutzer.create({ data: { email: "ohneakte@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }); await expect( system.mitgliedschaft.create({ data: { organisationId: wehrA, benutzerId: ohne.id, art: "MITGLIED" }, }), ).rejects.toThrow(/akte_passend/i); }); it("keine Kreisaufsicht mit Akte", async ({ skip }) => { if (!erreichbar) return skip(); // Sie gehört der Wehr nicht an und soll in keiner Mitgliederliste stehen. const aufsicht = await system.benutzer.create({ data: { email: "aufsicht@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }); await expect( system.mitgliedschaft.create({ data: { organisationId: wehrB, benutzerId: aufsicht.id, personId: akteB, art: "AUFSICHT" }, }), ).rejects.toThrow(/akte_passend/i); }); it("kein Konto ohne Anmeldeweg", async ({ skip }) => { if (!erreichbar) return skip(); // Weder Passwort noch OIDC — damit ist es eine Karteileiche, die erst // auffällt, wenn sich jemand damit anmelden will. await expect( system.benutzer.create({ data: { email: "leer@test-mandant.de" } }), ).rejects.toThrow(/anmeldeweg_vorhanden/i); }); it("keine E-Mail, die keine ist", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.benutzer.create({ data: { email: "kein-at-zeichen", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }), ).rejects.toThrow(/email_plausibel/i); }); it("keine zwei Konten mit derselben Adresse", async ({ skip }) => { if (!erreichbar) return skip(); // Global eindeutig — das ist der Unterschied zur E-Mail an der Akte, die // je Wehr eindeutig ist. Vorher meldete sich, wer in zwei Wehren stand, // in einer zufälligen an. await expect( system.benutzer.create({ data: { email: "doppel@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, }), ).rejects.toThrow(); }); }); describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Zuordnungen", () => { it("zeigt einer Wehr nicht, in welchen anderen ein Konto noch geführt wird", async ({ skip, }) => { if (!erreichbar) return skip(); if (!APP_URL || APP_URL === SYSTEM_URL) return skip(); // `mitgliedschaft` trägt `organisationId` und unterliegt damit Row Level // Security wie jede andere Tabelle. Wer in Wehr A angemeldet ist, sieht // die Zuordnungen von Wehr A — und erfährt insbesondere nicht, dass // dasselbe Konto nebenan Kreisaufsicht ist. const app = new PrismaClient({ datasources: { db: { url: APP_URL } } }); try { const inA = await app.$transaction(async (tx) => { await tx.$executeRawUnsafe(`SELECT set_config('app.current_organisation', '${wehrA}', true)`); return tx.mitgliedschaft.findMany({ where: { benutzerId: konto } }); }); expect(inA).toHaveLength(1); expect(inA[0]?.organisationId).toBe(wehrA); } finally { await app.$disconnect(); } }); });