Phase 0: Fundament der Feuerwehr-Verwaltungsplattform

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>
This commit is contained in:
Florian Wilde
2026-09-03 00:13:53 +02:00
co-authored by Claude Opus 5
commit 845942f36c
84 changed files with 21433 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import { expect, test } from "@playwright/test";
/**
* Beispiel-E2E-Test für Phase 0.
*
* Geprüft wird der Weg, ohne den nichts anderes funktioniert: jemand ruft die
* Anwendung auf, wird zur Anmeldung geschickt, meldet sich an und landet auf
* der Übersicht seiner Wehr.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
* docker compose up -d && npm run db:migrate && npm run db:seed
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
test.describe("Anmeldung", () => {
test("schickt nicht angemeldete Aufrufe zur Anmeldeseite", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await expect(page.getByRole("heading", { name: "FLORIAN" })).toBeVisible();
});
test("weist eine falsche Kombination ab und nennt den Grund auf Deutsch", async ({ page }) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill("definitiv-das-falsche-passwort");
await page.getByRole("button", { name: "Anmelden" }).click();
// Nicht ueber die Rolle "alert" suchen: Next.js haengt einen eigenen
// Routen-Ansager mit derselben Rolle in die Seite.
const meldung = page.getByTestId("anmeldefehler");
await expect(meldung).toBeVisible();
await expect(meldung).toContainText("stimmt nicht");
// Und niemand ist hineingekommen.
await expect(page).toHaveURL(/\/anmelden/);
});
test("lässt den Administrator hinein und zeigt die Übersicht seiner Wehr", async ({ page }) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
// Die Wehr aus dem Seed.
await expect(
page.getByRole("heading", { name: "Freiwillige Feuerwehr Musterhausen" }),
).toBeVisible();
// Die Kennzahlen aus dem Seed: 15 Mitglieder, 3 Fahrzeuge, 40 Geräte.
await expect(page.getByText("Aktive Mitglieder")).toBeVisible();
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Geräte", { exact: true }).first()).toBeVisible();
// Der Sicherheitshinweis muss stehen, solange nichts freigegeben ist.
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
});
test("zeigt die Navigation und markiert künftige Module als noch nicht verfügbar", async ({
page,
}) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
// Atemschutz kommt erst in Phase 3 — der Eintrag ist sichtbar, aber kein Link.
await expect(navigation.getByText("Atemschutz")).toBeVisible();
await expect(navigation.getByRole("link", { name: "Atemschutz" })).toHaveCount(0);
});
});
test.describe("Erreichbarkeit", () => {
test("meldet den Zustand der Randsysteme unter /api/health", async ({ request }) => {
const antwort = await request.get("/api/health");
expect(antwort.status()).toBe(200);
const daten = (await antwort.json()) as {
status: string;
dienste: { datenbank: boolean; speicher: boolean; warteschlange: boolean };
};
expect(daten.status).toBe("ok");
expect(daten.dienste.datenbank).toBe(true);
expect(daten.dienste.speicher).toBe(true);
expect(daten.dienste.warteschlange).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import "dotenv/config";
/**
* Wird vor jedem Testlauf ausgeführt.
*
* Setzt eine vollständige, aber unschädliche Umgebung, damit lib/env.ts
* durchläuft, ohne dass eine echte .env vorhanden sein muss.
*
* Ist eine .env vorhanden, wird sie oben geladen — dann laufen auch die Tests
* mit, die eine echte Datenbank brauchen (Mandantentrennung). Ohne .env greifen
* die Platzhalter, und diese Tests überspringen sich selbst.
*/
// NODE_ENV ist in den Node-Typen schreibgeschützt, zur Laufzeit aber setzbar.
const umgebung = process.env as Record<string, string | undefined>;
umgebung.NODE_ENV = "test";
umgebung.TZ = "Europe/Berlin";
umgebung.SKIP_ENV_VALIDATION = "1";
umgebung.APP_URL ??= "http://localhost:3100";
umgebung.DATABASE_URL ??= "postgresql://test:test@localhost:5442/test?schema=public";
umgebung.DIRECT_DATABASE_URL ??= umgebung.DATABASE_URL;
umgebung.REDIS_URL ??= "redis://localhost:6389";
umgebung.S3_ENDPOINT ??= "localhost";
umgebung.S3_ACCESS_KEY ??= "test";
umgebung.S3_SECRET_KEY ??= "test";
umgebung.S3_BUCKET ??= "test";
umgebung.AUTH_SECRET ??= "testgeheimnis_mindestens_32_zeichen_lang_ok";
umgebung.KIOSK_DEVICE_SECRET ??= "testgeraetegeheimnis";
// bcrypt mit weniger Runden — im Test zählt Tempo, nicht Härte.
umgebung.AUTH_BCRYPT_ROUNDS ??= "4";
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
ALLE_RECHTE,
KeinZugriffFehler,
ROLLEN,
ROLLEN_BEZEICHNUNG,
ROLLEN_RECHTE,
alsRolle,
darfPersonSehen,
gehoertZurOrganisation,
hatAlleRechte,
hatEinesDerRechte,
hatRecht,
rechteVon,
verlangeRecht,
type Sitzung,
} from "@/lib/authz";
/**
* Beispieltest für Phase 0.
*
* Geprüft wird das, was am teuersten schiefgeht: dass der Gerätewart nicht in
* die Kasse kommt, dass der Administrator wirklich alles darf, und dass das
* Terminal nur das Nötigste kann.
*/
function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung {
return {
personId: "person-1",
organisationId,
name: "Testperson",
rollen,
anmeldeArt: "LOKAL",
};
}
describe("Rollenkatalog", () => {
it("hat für jede Rolle eine deutsche Bezeichnung", () => {
for (const rolle of ROLLEN) {
expect(ROLLEN_BEZEICHNUNG[rolle]).toBeTruthy();
}
});
it("vergibt in ROLLEN_RECHTE nur Rechte, die es im Katalog gibt", () => {
for (const [rolle, rechte] of Object.entries(ROLLEN_RECHTE)) {
for (const recht of rechte) {
expect(ALLE_RECHTE, `${rolle} verweist auf unbekanntes Recht ${recht}`).toContain(
recht,
);
}
}
});
});
describe("hatRecht", () => {
it("gibt dem Administrator ausnahmslos alles", () => {
const admin = sitzung(["SUPERADMIN"]);
for (const recht of ALLE_RECHTE) {
expect(hatRecht(admin, recht)).toBe(true);
}
});
it("lässt den Gerätewart Geräte bearbeiten", () => {
expect(hatRecht(sitzung(["GERAETEWART"]), "geraet.bearbeiten")).toBe(true);
expect(hatRecht(sitzung(["GERAETEWART"]), "pruefung.durchfuehren")).toBe(true);
});
it("lässt den Gerätewart NICHT an die Kasse", () => {
expect(hatRecht(sitzung(["GERAETEWART"]), "kasse.bearbeiten")).toBe(false);
expect(hatRecht(sitzung(["GERAETEWART"]), "kasse.abschluss")).toBe(false);
});
it("lässt den Kassier nicht an Geräte und den Atemschutz", () => {
expect(hatRecht(sitzung(["KASSIER"]), "geraet.bearbeiten")).toBe(false);
expect(hatRecht(sitzung(["KASSIER"]), "atemschutz.ueberwachung")).toBe(false);
});
it("gibt dem einfachen Mitglied nur Lese- und eigene Rechte", () => {
const mitglied = sitzung(["MITGLIED"]);
expect(hatRecht(mitglied, "person.eigene")).toBe(true);
expect(hatRecht(mitglied, "geraet.lesen")).toBe(true);
expect(hatRecht(mitglied, "person.bearbeiten")).toBe(false);
expect(hatRecht(mitglied, "geraet.bearbeiten")).toBe(false);
});
it("beschränkt das Terminal auf Anmelden und Erfassen", () => {
const kiosk = sitzung(["KIOSK"]);
expect(hatRecht(kiosk, "kiosk.anmelden")).toBe(true);
expect(hatRecht(kiosk, "kiosk.erfassen")).toBe(true);
expect(hatRecht(kiosk, "person.bearbeiten")).toBe(false);
expect(hatRecht(kiosk, "system.einstellungen")).toBe(false);
expect(hatRecht(kiosk, "kasse.lesen")).toBe(false);
});
it("verweigert ohne Sitzung alles", () => {
for (const recht of ALLE_RECHTE) {
expect(hatRecht(null, recht)).toBe(false);
}
});
it("addiert die Rechte mehrerer Rollen", () => {
const doppelrolle = sitzung(["GERAETEWART", "KASSIER"]);
expect(hatRecht(doppelrolle, "geraet.bearbeiten")).toBe(true);
expect(hatRecht(doppelrolle, "kasse.bearbeiten")).toBe(true);
});
});
describe("rechteVon", () => {
it("liefert für den Administrator den vollständigen Katalog", () => {
expect(rechteVon(["SUPERADMIN"]).size).toBe(ALLE_RECHTE.length);
});
it("liefert für das Terminal deutlich weniger als für den Kommandanten", () => {
expect(rechteVon(["KIOSK"]).size).toBeLessThan(rechteVon(["KOMMANDANT"]).size);
});
});
describe("hatEinesDerRechte / hatAlleRechte", () => {
const geraetewart = sitzung(["GERAETEWART"]);
it("genügt ein Treffer bei hatEinesDerRechte", () => {
expect(hatEinesDerRechte(geraetewart, ["kasse.bearbeiten", "geraet.bearbeiten"])).toBe(
true,
);
});
it("verlangt hatAlleRechte jeden einzelnen Treffer", () => {
expect(hatAlleRechte(geraetewart, ["kasse.bearbeiten", "geraet.bearbeiten"])).toBe(false);
expect(hatAlleRechte(geraetewart, ["geraet.lesen", "geraet.bearbeiten"])).toBe(true);
});
});
describe("verlangeRecht", () => {
it("lässt durch, wenn das Recht vorliegt", () => {
expect(() => verlangeRecht(sitzung(["KOMMANDANT"]), "person.bearbeiten")).not.toThrow();
});
it("wirft KeinZugriffFehler, wenn das Recht fehlt", () => {
expect(() => verlangeRecht(sitzung(["MITGLIED"]), "person.bearbeiten")).toThrow(
KeinZugriffFehler,
);
});
it("nennt im Fehler das fehlende Recht", () => {
try {
verlangeRecht(sitzung(["MITGLIED"]), "kasse.abschluss");
expect.unreachable("hätte werfen müssen");
} catch (fehler) {
expect(fehler).toBeInstanceOf(KeinZugriffFehler);
expect((fehler as Error).message).toContain("kasse.abschluss");
}
});
});
describe("Mandantentrennung", () => {
it("erkennt Datensätze der eigenen Wehr", () => {
expect(gehoertZurOrganisation(sitzung(["MITGLIED"], "org-1"), { organisationId: "org-1" })).toBe(
true,
);
});
it("weist Datensätze einer fremden Wehr ab — auch dem Administrator", () => {
expect(
gehoertZurOrganisation(sitzung(["SUPERADMIN"], "org-1"), { organisationId: "org-2" }),
).toBe(false);
});
it("weist null-Datensätze ab", () => {
expect(gehoertZurOrganisation(sitzung(["KOMMANDANT"]), null)).toBe(false);
});
});
describe("darfPersonSehen", () => {
it("lässt jeden die eigenen Daten sehen, auch ohne person.lesen", () => {
const kiosk = sitzung(["KIOSK"]);
expect(hatRecht(kiosk, "person.lesen")).toBe(false);
expect(darfPersonSehen(kiosk, kiosk.personId)).toBe(true);
});
it("verlangt für fremde Personen das Recht person.lesen", () => {
expect(darfPersonSehen(sitzung(["KIOSK"]), "andere-person")).toBe(false);
expect(darfPersonSehen(sitzung(["MITGLIED"]), "andere-person")).toBe(true);
});
});
describe("alsRolle", () => {
it("erkennt Rollen unabhängig von Groß- und Kleinschreibung", () => {
expect(alsRolle("kommandant")).toBe("KOMMANDANT");
expect(alsRolle(" GeraeteWart ")).toBe("GERAETEWART");
});
it("gibt bei Unsinn null zurück", () => {
expect(alsRolle("oberbrandmeister")).toBeNull();
expect(alsRolle("")).toBeNull();
expect(alsRolle(null)).toBeNull();
});
});
+226
View File
@@ -0,0 +1,226 @@
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();
});
});