Files
Laufzettel/tests/rollen.test.ts
T
Florian WildeandClaude Opus 5 d30bb6d235
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Vier-Augen-Freigabe (#040)
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>
2026-10-24 01:14:04 +02:00

184 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()));
});
});