Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine Kreisebene, die zusieht. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto, und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig. Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit). Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase 0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft. Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht: `personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat, und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen die Kreisaufsicht nichts verloren hat. Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst, minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie neue Leserechte automatisch und neue Schreibrechte nie. Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand zu hinterlassen. Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte ausloesen, bevor eine Seite ueberhaupt geladen war. 581 Unit-Tests und 266 E2E-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
9.7 KiB
TypeScript
288 lines
9.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { RolleSchluessel } from "@prisma/client";
|
|
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 & { personId: string } {
|
|
return {
|
|
personId: "person-1",
|
|
organisationId,
|
|
name: "Testperson",
|
|
rollen,
|
|
anmeldeArt: "LOKAL",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Eine Sitzung der Kreisaufsicht: kein Konto in dieser Wehr, keine Akte.
|
|
*
|
|
* Seit Phase 10 gibt es diesen Zustand, und er ist der einzige, in dem
|
|
* `personId` fehlt.
|
|
*/
|
|
function aufsicht(organisationId = "org-2"): Sitzung {
|
|
return {
|
|
personId: null,
|
|
organisationId,
|
|
name: "Kreisbrandinspektion",
|
|
rollen: ["AUFSICHT"],
|
|
anmeldeArt: "LOKAL",
|
|
nurLesend: true,
|
|
};
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("Kreisaufsicht", () => {
|
|
/**
|
|
* Der wichtigste Test dieser Datei.
|
|
*
|
|
* Die Kreisebene sieht Wehren, denen sie nicht angehört. Bekäme sie dort
|
|
* versehentlich ein Schreibrecht, wäre das kein Bedienfehler, sondern ein
|
|
* Loch in der Mandantentrennung — und zwar eines, das niemandem auffällt,
|
|
* weil in der Oberfläche alles normal aussieht.
|
|
*
|
|
* Die Zusicherung ist deshalb bewusst allgemein formuliert: nicht "diese
|
|
* zwölf Rechte", sondern "kein einziges, das nicht lesen heißt". So gilt
|
|
* sie auch für Module, die es noch gar nicht gibt.
|
|
*/
|
|
it("hat ausschließlich Leserechte — auch für künftige Module", () => {
|
|
const rechte = [...rechteVon(["AUFSICHT"])];
|
|
|
|
expect(rechte.length).toBeGreaterThan(0);
|
|
expect(rechte.filter((recht) => !recht.endsWith(".lesen"))).toEqual([]);
|
|
});
|
|
|
|
it("sieht die Kasse nicht", () => {
|
|
// Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht — und
|
|
// wäre nur deshalb dabei, weil das Recht zufällig auf ".lesen" endet.
|
|
expect(hatRecht(aufsicht(), "kasse.lesen")).toBe(false);
|
|
});
|
|
|
|
it("sieht weder Audit-Log noch KI-Protokoll", () => {
|
|
// Wer wann was getan hat, bleibt innerhalb der Wehr.
|
|
expect(hatRecht(aufsicht(), "system.auditlog")).toBe(false);
|
|
expect(hatRecht(aufsicht(), "ki.protokoll")).toBe(false);
|
|
});
|
|
|
|
it("kann keine Personendaten exportieren", () => {
|
|
// Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt.
|
|
expect(hatRecht(aufsicht(), "person.export")).toBe(false);
|
|
});
|
|
|
|
it("sieht den Atemschutz — dafür gibt es sie", () => {
|
|
expect(hatRecht(aufsicht(), "atemschutz.lesen")).toBe(true);
|
|
expect(hatRecht(aufsicht(), "atemschutz.bearbeiten")).toBe(false);
|
|
expect(hatRecht(aufsicht(), "atemschutz.ueberwachung")).toBe(false);
|
|
});
|
|
|
|
it("hat in dieser Wehr keine eigene Akte", () => {
|
|
// Daran hängt, dass sie in keiner Mitgliederliste auftaucht und als
|
|
// Urheber keiner Änderung eingetragen werden kann.
|
|
expect(aufsicht().personId).toBeNull();
|
|
expect(aufsicht().nurLesend).toBe(true);
|
|
});
|
|
|
|
it("sieht fremde Personen nur über person.lesen, nie über die eigene Akte", () => {
|
|
// `darfPersonSehen` lässt jeden die eigenen Daten sehen. Ohne Akte darf
|
|
// dieser Weg nicht versehentlich greifen: sonst entschiede ein Vergleich
|
|
// von null mit null darüber, wer wen sieht.
|
|
const kreis = aufsicht();
|
|
expect(darfPersonSehen(kreis, "irgendeine-person")).toBe(true);
|
|
expect(hatRecht(kreis, "person.lesen")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Rollenkatalog und Mandantentrennung", () => {
|
|
it("führt AUFSICHT nicht als vergebbare Rolle in der Datenbank", () => {
|
|
// Sie ergibt sich aus der Mitgliedschaftsart, nicht aus einer Zuweisung.
|
|
// Wäre sie vergebbar, könnte jemand innerhalb einer Wehr Rechte an einer
|
|
// anderen verteilen.
|
|
expect(RolleSchluessel).not.toHaveProperty("AUFSICHT");
|
|
});
|
|
});
|