Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
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<T>(
|
|
organisationId: string | null,
|
|
fn: (db: PrismaClient) => Promise<T>,
|
|
): Promise<T> {
|
|
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();
|
|
});
|
|
});
|