OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
5.9 KiB
TypeScript
144 lines
5.9 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 },
|
|
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
|
|
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
|
|
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false },
|
|
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
|
|
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: 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 unten wirklich vollstaendig ist: vier Rollen, sechs Rechte.
|
|
expect(ALLE_ROLLEN).toHaveLength(4);
|
|
expect(ALLE_RECHTE).toHaveLength(6);
|
|
expect(faelle).toHaveLength(24);
|
|
});
|
|
|
|
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", "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", () => {
|
|
for (const recht of ALLE_RECHTE) {
|
|
expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
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 LESER 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.LESER);
|
|
expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR);
|
|
expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER);
|
|
expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER);
|
|
});
|
|
});
|
|
|
|
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()));
|
|
});
|
|
});
|