Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat. Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie `laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt zusaetzlich ein CHECK in der Datenbank. Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert haette jeden Codepfad betroffen, der heute vier Faelle kennt. Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt, und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index haelt sie bei mehreren Versuchen auf einmal. Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus #039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte. 1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action trifft den Router-Cache von Next und zeigt kurz den alten Stand. Closes #40 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
8.1 KiB
TypeScript
184 lines
8.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { Rolle } from "@/generated/prisma/enums";
|
||
import { RECHTE, ROLLEN_RANG, ROLLEN_TEXT, darf, staerksteRolle, type Recht } from "@/server/auth/rollen";
|
||
|
||
const ALLE_ROLLEN = Object.values(Rolle);
|
||
const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
|
||
|
||
/**
|
||
* Die vollstaendige Rechtematrix, absichtlich ausgeschrieben statt aus `RECHTE` berechnet:
|
||
* nur so faellt auf, wenn jemand einem `LESER` versehentlich Schreibrechte gibt (#007). Ein
|
||
* Test, der die Tabelle nur gegen sich selbst haelt, wuerde jede Aenderung mitmachen.
|
||
*/
|
||
const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
|
||
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true, MITARBEITER: false },
|
||
selbstauskunft: { ADMIN: false, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: true },
|
||
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false },
|
||
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false },
|
||
mitarbeiterBearbeiten: {
|
||
ADMIN: true,
|
||
REDAKTEUR: true,
|
||
TECHNIKER: true,
|
||
LESER: false,
|
||
MITARBEITER: false,
|
||
},
|
||
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||
/*
|
||
* Die Vier-Augen-Freigabe (#040) liegt bewusst **nicht** beim TECHNIKER: sonst
|
||
* haengt sie daran, dass zufaellig ein zweiter greifbar ist.
|
||
*/
|
||
freigeben: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||
};
|
||
|
||
describe("Rechtematrix", () => {
|
||
const faelle = ALLE_RECHTE.flatMap((recht) =>
|
||
ALLE_ROLLEN.map((rolle) => [recht, rolle, MATRIX[recht][rolle]] as const),
|
||
);
|
||
|
||
it("prueft jede Rolle gegen jedes Recht", () => {
|
||
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
|
||
// neun Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
|
||
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
|
||
expect(ALLE_ROLLEN).toHaveLength(5);
|
||
expect(ALLE_RECHTE).toHaveLength(9);
|
||
expect(faelle).toHaveLength(45);
|
||
});
|
||
|
||
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
|
||
expect(darf(rolle, recht)).toBe(erwartet);
|
||
});
|
||
});
|
||
|
||
describe("Rollen im Einzelnen", () => {
|
||
it("laesst LESER nur lesen", () => {
|
||
expect(darf("LESER", "lesen")).toBe(true);
|
||
for (const recht of ALLE_RECHTE.filter((r) => r !== "lesen")) {
|
||
expect(darf("LESER", recht), `LESER darf ${recht} nicht`).toBe(false);
|
||
}
|
||
});
|
||
|
||
it("gibt TECHNIKER Laeufe und Geraete, aber keine Vorlagen und keine Verwaltung", () => {
|
||
expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true);
|
||
expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true);
|
||
expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false);
|
||
expect(darf("TECHNIKER", "freigeben")).toBe(false);
|
||
expect(darf("TECHNIKER", "verwaltung")).toBe(false);
|
||
expect(darf("TECHNIKER", "archivVerwalten")).toBe(false);
|
||
});
|
||
|
||
it("gibt REDAKTEUR alles von TECHNIKER plus Vorlagen, aber keine Verwaltung", () => {
|
||
for (const recht of ALLE_RECHTE.filter((r) => darf("TECHNIKER", r))) {
|
||
expect(darf("REDAKTEUR", recht), `REDAKTEUR darf ${recht} wie ein TECHNIKER`).toBe(true);
|
||
}
|
||
expect(darf("REDAKTEUR", "vorlagenBearbeiten")).toBe(true);
|
||
expect(darf("REDAKTEUR", "verwaltung")).toBe(false);
|
||
expect(darf("REDAKTEUR", "archivVerwalten")).toBe(false);
|
||
});
|
||
|
||
it("gibt ADMIN alles – ausser der Selbstauskunft", () => {
|
||
for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) {
|
||
expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true);
|
||
}
|
||
/*
|
||
* Die eine Ausnahme, und sie ist Absicht (#035): `selbstauskunft` ist kein
|
||
* zusaetzliches Recht, sondern ein **Ersatz** fuer `lesen`. Ein ADMIN sieht
|
||
* ueber `lesen` ohnehin alles; ihm die Selbstauskunft zu geben waere ein
|
||
* zweiter Weg zu denselben Daten — und damit eine zweite Stelle, an der
|
||
* jemand die Einschraenkung auf die eigene Person vergessen kann.
|
||
*/
|
||
expect(darf("ADMIN", "selbstauskunft")).toBe(false);
|
||
});
|
||
|
||
it("laesst MITARBEITER genau eines und sonst nichts", () => {
|
||
expect(darf("MITARBEITER", "selbstauskunft")).toBe(true);
|
||
for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) {
|
||
expect(darf("MITARBEITER", recht), `MITARBEITER darf ${recht} nicht`).toBe(false);
|
||
}
|
||
// Vor allem nicht `lesen`: das waere die Akteneinsicht, die es nicht sein soll.
|
||
expect(darf("MITARBEITER", "lesen")).toBe(false);
|
||
});
|
||
|
||
it("behaelt das Zurueckholen aus dem Archiv beim ADMIN", () => {
|
||
// Steht als eigener Test da, weil die Zusage "ein archivierter Lauf aendert sich nicht"
|
||
// daran haengt.
|
||
expect(RECHTE.archivVerwalten).toStrictEqual(["ADMIN"]);
|
||
});
|
||
});
|
||
|
||
describe("Aufbau der Tabelle", () => {
|
||
it("listet fuer jedes Recht mindestens eine Rolle", () => {
|
||
for (const recht of ALLE_RECHTE) {
|
||
expect(RECHTE[recht].length, `${recht} ohne Rolle waere fuer niemanden erreichbar`).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it("nennt in jedem Recht nur bekannte Rollen, jede hoechstens einmal", () => {
|
||
for (const recht of ALLE_RECHTE) {
|
||
const rollen = RECHTE[recht] as readonly Rolle[];
|
||
expect(new Set(rollen).size, `${recht} nennt eine Rolle doppelt`).toBe(rollen.length);
|
||
for (const rolle of rollen) {
|
||
expect(ALLE_ROLLEN).toContain(rolle);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("deckt mit ROLLEN_TEXT genau die Rollen des Enums ab", () => {
|
||
expect(Object.keys(ROLLEN_TEXT).sort()).toStrictEqual([...ALLE_ROLLEN].sort());
|
||
for (const rolle of ALLE_ROLLEN) {
|
||
expect(ROLLEN_TEXT[rolle].length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it("deckt mit ROLLEN_RANG genau die Rollen des Enums ab", () => {
|
||
expect(Object.keys(ROLLEN_RANG).sort()).toStrictEqual([...ALLE_ROLLEN].sort());
|
||
});
|
||
|
||
it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und MITARBEITER unten", () => {
|
||
const raenge = ALLE_ROLLEN.map((rolle) => ROLLEN_RANG[rolle]);
|
||
expect(new Set(raenge).size).toBe(ALLE_ROLLEN.length);
|
||
expect(Math.max(...raenge)).toBe(ROLLEN_RANG.ADMIN);
|
||
expect(Math.min(...raenge)).toBe(ROLLEN_RANG.MITARBEITER);
|
||
expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR);
|
||
expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER);
|
||
expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER);
|
||
/*
|
||
* **Unter LESER.** Der Rang entscheidet in `staerksteRolle`, wer gewinnt,
|
||
* wenn jemand in mehreren abgebildeten Gruppen steht. Stuende MITARBEITER
|
||
* darueber, verdraengte eine Selbstauskunft-Gruppe die echte Rolle — und ein
|
||
* Techniker verlöre seine Rechte, weil er auch Mitarbeiter des Hauses ist.
|
||
*/
|
||
expect(ROLLEN_RANG.LESER).toBeGreaterThan(ROLLEN_RANG.MITARBEITER);
|
||
});
|
||
});
|
||
|
||
describe("staerksteRolle", () => {
|
||
it("gibt bei leerer Liste LESER zurueck", () => {
|
||
// Wer in keiner abgebildeten Gruppe ist, soll hineinsehen duerfen -- nicht scheitern
|
||
// und erst recht nicht ADMIN werden.
|
||
expect(staerksteRolle([])).toBe("LESER");
|
||
});
|
||
|
||
it.each(ALLE_ROLLEN)("gibt bei nur %s diese Rolle zurueck", (rolle) => {
|
||
expect(staerksteRolle([rolle])).toBe(rolle);
|
||
});
|
||
|
||
it.each([
|
||
["LESER und TECHNIKER", ["LESER", "TECHNIKER"], "TECHNIKER"],
|
||
["TECHNIKER und REDAKTEUR", ["TECHNIKER", "REDAKTEUR"], "REDAKTEUR"],
|
||
["REDAKTEUR und ADMIN", ["REDAKTEUR", "ADMIN"], "ADMIN"],
|
||
["alle vier", ["LESER", "TECHNIKER", "REDAKTEUR", "ADMIN"], "ADMIN"],
|
||
["ADMIN zuerst genannt", ["ADMIN", "LESER"], "ADMIN"],
|
||
["LESER zuerst genannt", ["LESER", "ADMIN"], "ADMIN"],
|
||
["dieselbe Rolle mehrfach", ["TECHNIKER", "TECHNIKER"], "TECHNIKER"],
|
||
] as const)("waehlt aus %s die Rolle %s", (_fall, rollen, erwartet) => {
|
||
expect(staerksteRolle(rollen)).toBe(erwartet);
|
||
});
|
||
|
||
it("ist von der Reihenfolge unabhaengig", () => {
|
||
const rollen: Rolle[] = ["TECHNIKER", "ADMIN", "LESER", "REDAKTEUR"];
|
||
expect(staerksteRolle(rollen)).toBe(staerksteRolle([...rollen].reverse()));
|
||
});
|
||
});
|