import { PrismaClient } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; /** * Prüft die Mandantentrennung dort, wo sie zählt: in der Datenbank. * * Der Test legt zwei Wehren an und stellt sicher, dass die Anwendungsrolle * (florian_app, NOBYPASSRLS) nur die Wehr sieht, die per * app.current_organisation gesetzt ist — und ohne gesetzten Wert gar nichts. * * Er braucht eine laufende Datenbank. Ist keine erreichbar, wird er * übersprungen statt fehlzuschlagen, 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 app = new PrismaClient({ datasources: { db: { url: APP_URL } } }); const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); let erreichbar = false; let orgA = ""; let orgB = ""; const SLUG_A = "test-rls-wehr-a"; const SLUG_B = "test-rls-wehr-b"; 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: "Testwehr A", kurzname: "TW A" }, }); const b = await system.organisation.upsert({ where: { slug: SLUG_B }, update: {}, create: { slug: SLUG_B, name: "Testwehr B", kurzname: "TW B" }, }); orgA = a.id; orgB = b.id; await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId: orgA, mitgliedsnummer: "T001" } }, update: {}, create: { organisationId: orgA, mitgliedsnummer: "T001", vorname: "Anna", nachname: "AusWehrA", }, }); await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId: orgB, mitgliedsnummer: "T001" } }, update: {}, create: { organisationId: orgB, mitgliedsnummer: "T001", vorname: "Bertram", nachname: "AusWehrB", }, }); }); afterAll(async () => { if (erreichbar) { // Aufräumen über die Systemverbindung. Der Schalter app.audit_bereinigung // ist nötig, weil die Kaskade auch Audit-Einträge mitnimmt und die sonst // vom Trigger geschützt sind. await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } }); }); } await Promise.allSettled([app.$disconnect(), system.$disconnect()]); }); /** Führt eine Abfrage über die Anwendungsrolle im Kontext einer Wehr aus. */ async function imKontext( organisationId: string | null, fn: (db: PrismaClient) => Promise, ): Promise { return app.$transaction(async (tx) => { if (organisationId) { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; } return fn(tx as unknown as PrismaClient); }); } describe.runIf(process.env.DATABASE_URL)("Row Level Security", () => { it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => { if (!erreichbar) return skip(); const personen = await imKontext(null, (db) => db.person.findMany()); expect(personen).toHaveLength(0); const organisationen = await imKontext(null, (db) => db.organisation.findMany()); expect(organisationen).toHaveLength(0); }); it("zeigt im Kontext von Wehr A nur Personen aus Wehr A", async ({ skip }) => { if (!erreichbar) return skip(); const personen = await imKontext(orgA, (db) => db.person.findMany()); expect(personen.length).toBeGreaterThan(0); expect(personen.every((person) => person.organisationId === orgA)).toBe(true); expect(personen.some((person) => person.nachname === "AusWehrB")).toBe(false); }); it("zeigt im Kontext von Wehr B nur Personen aus Wehr B", async ({ skip }) => { if (!erreichbar) return skip(); const personen = await imKontext(orgB, (db) => db.person.findMany()); expect(personen.every((person) => person.organisationId === orgB)).toBe(true); expect(personen.some((person) => person.nachname === "AusWehrA")).toBe(false); }); it("findet einen Datensatz der fremden Wehr auch bei gezielter Abfrage nicht", async ({ skip, }) => { if (!erreichbar) return skip(); const fremde = await system.person.findFirst({ where: { organisationId: orgB } }); expect(fremde).not.toBeNull(); // Dieselbe ID, aber im Kontext der anderen Wehr: RLS blendet sie aus. const versuch = await imKontext(orgA, (db) => db.person.findUnique({ where: { id: fremde!.id } }), ); expect(versuch).toBeNull(); }); it("lässt keinen Datensatz für eine fremde Wehr anlegen", async ({ skip }) => { if (!erreichbar) return skip(); await expect( imKontext(orgA, (db) => db.person.create({ data: { organisationId: orgB, mitgliedsnummer: "T999", vorname: "Schmuggel", nachname: "Versuch", }, }), ), ).rejects.toThrow(); }); it("hält den Audit-Log unveränderlich", async ({ skip }) => { if (!erreichbar) return skip(); const eintrag = await system.auditLog.create({ data: { organisationId: orgA, aktion: "ERSTELLT", entitaet: "Test", beschreibung: "Testeintrag für die Unveränderlichkeit", }, }); // Selbst über die Systemverbindung greift der Trigger. await expect( system.auditLog.update({ where: { id: eintrag.id }, data: { beschreibung: "nachträglich geändert" }, }), ).rejects.toThrow(); await expect(system.auditLog.delete({ where: { id: eintrag.id } })).rejects.toThrow(); }); it("erlaubt das Löschen nur im gekennzeichneten Aufbewahrungslauf", async ({ skip }) => { if (!erreichbar) return skip(); const eintrag = await system.auditLog.create({ data: { organisationId: orgA, aktion: "ERSTELLT", entitaet: "Test", beschreibung: "Eintrag für den Aufbewahrungslauf", }, }); await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.auditLog.delete({ where: { id: eintrag.id } }); }); const weg = await system.auditLog.findUnique({ where: { id: eintrag.id } }); expect(weg).toBeNull(); }); it("lässt auch im Aufbewahrungslauf kein UPDATE zu", async ({ skip }) => { if (!erreichbar) return skip(); const eintrag = await system.auditLog.create({ data: { organisationId: orgA, aktion: "ERSTELLT", entitaet: "Test", beschreibung: "Eintrag, der nicht verfälscht werden darf", }, }); await expect( system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.auditLog.update({ where: { id: eintrag.id }, data: { beschreibung: "verfälscht" }, }); }), ).rejects.toThrow(); }); });