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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user