feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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>
This commit was merged in pull request #21.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
|
||||
import type { Sitzung } from "@/lib/sitzung";
|
||||
import {
|
||||
kundenkuerzelAusGruppen,
|
||||
oidcAktiv,
|
||||
oidcName,
|
||||
rolleAusGruppen,
|
||||
rueckrufAdresse,
|
||||
} from "@/server/auth/oidc";
|
||||
|
||||
/**
|
||||
* Die Entscheidungen rund um die Anmeldung, die ohne Datenbank und ohne Next
|
||||
* pruefbar sind (#007).
|
||||
*/
|
||||
|
||||
const UMGEBUNG = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...UMGEBUNG };
|
||||
});
|
||||
|
||||
describe("Ruecksprungziel", () => {
|
||||
it("nimmt einen Pfad innerhalb der Anwendung an", () => {
|
||||
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
|
||||
expect(sicheresZiel("/admin/benutzer?seite=2")).toBe("/admin/benutzer?seite=2");
|
||||
});
|
||||
|
||||
it("weist eine Weiterleitung nach draussen ab", () => {
|
||||
// Ohne diese Pruefung waere ein Anmeldelink verschickbar, der nach
|
||||
// erfolgreicher Anmeldung auf einer fremden Seite landet.
|
||||
for (const boesartig of [
|
||||
"https://fremd.example/phishing",
|
||||
"//fremd.example",
|
||||
"/\\fremd.example",
|
||||
"http://fremd.example",
|
||||
"javascript:alert(1)",
|
||||
]) {
|
||||
expect(sicheresZiel(boesartig)).toBe("/");
|
||||
}
|
||||
});
|
||||
|
||||
it("faellt ohne Angabe auf die Startseite zurueck", () => {
|
||||
expect(sicheresZiel(undefined)).toBe("/");
|
||||
expect(sicheresZiel(null)).toBe("/");
|
||||
expect(sicheresZiel("")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rollen aus OIDC-Gruppen", () => {
|
||||
beforeEach(() => {
|
||||
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
|
||||
process.env["OIDC_GROUP_EDITOR"] = "lz-redakteur";
|
||||
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-techniker";
|
||||
process.env["OIDC_GROUP_VIEWER"] = "lz-leser";
|
||||
});
|
||||
|
||||
it("bildet jede Gruppe auf ihre Rolle ab", () => {
|
||||
expect(rolleAusGruppen(["lz-admin"])).toBe("ADMIN");
|
||||
expect(rolleAusGruppen(["lz-redakteur"])).toBe("REDAKTEUR");
|
||||
expect(rolleAusGruppen(["lz-techniker"])).toBe("TECHNIKER");
|
||||
expect(rolleAusGruppen(["lz-leser"])).toBe("LESER");
|
||||
});
|
||||
|
||||
it("gibt bei mehreren Gruppen die staerkste Rolle", () => {
|
||||
expect(rolleAusGruppen(["lz-leser", "lz-admin", "lz-techniker"])).toBe("ADMIN");
|
||||
expect(rolleAusGruppen(["lz-techniker", "lz-redakteur"])).toBe("REDAKTEUR");
|
||||
});
|
||||
|
||||
it("gibt ohne Treffer LESER, nie ADMIN und nie einen Fehler", () => {
|
||||
expect(rolleAusGruppen([])).toBe("LESER");
|
||||
expect(rolleAusGruppen(["irgendwas", "ganz-anderes"])).toBe("LESER");
|
||||
});
|
||||
|
||||
it("achtet auf Gross- und Kleinschreibung der Gruppennamen", () => {
|
||||
// Verzeichnisse unterscheiden hier, also darf die Zuordnung es auch.
|
||||
expect(rolleAusGruppen(["LZ-ADMIN"])).toBe("LESER");
|
||||
});
|
||||
|
||||
it("ordnet nichts zu, wenn die Variablen fehlen", () => {
|
||||
process.env = { ...UMGEBUNG };
|
||||
delete process.env["OIDC_GROUP_ADMIN"];
|
||||
expect(rolleAusGruppen(["lz-admin"])).toBe("LESER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kundenbindung aus OIDC-Gruppen", () => {
|
||||
it("liefert ohne Praefix nichts, damit keine Einschraenkung entsteht", () => {
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
expect(kundenkuerzelAusGruppen(["lz-kunde-MUS"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("liest die Kuerzel hinter dem Praefix", () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
expect(kundenkuerzelAusGruppen(["lz-kunde-MUS", "lz-kunde-rei", "lz-admin"])).toEqual(["MUS", "REI"]);
|
||||
});
|
||||
|
||||
it("verwirft Kuerzel, die nicht zur Form passen", () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
// Dieselbe Form wie die Bedingung Kunde_kuerzel_form in der Datenbank.
|
||||
expect(kundenkuerzelAusGruppen(["lz-kunde-X", "lz-kunde-ZULANG7", "lz-kunde-MU_S"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC-Konfiguration", () => {
|
||||
it("gilt erst als eingerichtet, wenn alle drei Angaben da sind", () => {
|
||||
process.env["OIDC_ISSUER"] = "https://authentik.example/application/o/laufzettel/";
|
||||
process.env["OIDC_CLIENT_ID"] = "kennung";
|
||||
delete process.env["OIDC_CLIENT_SECRET"];
|
||||
expect(oidcAktiv()).toBe(false);
|
||||
|
||||
process.env["OIDC_CLIENT_SECRET"] = "geheim";
|
||||
expect(oidcAktiv()).toBe(true);
|
||||
});
|
||||
|
||||
it("leitet die Rueckruf-Adresse aus APP_URL ab, nicht aus der Anfrage", () => {
|
||||
// Hinter einem Reverse Proxy traegt die Anfrage den internen Host; die beim
|
||||
// Anbieter hinterlegte Adresse ist aber die oeffentliche.
|
||||
process.env["APP_URL"] = "https://laufzettel.example.de";
|
||||
expect(rueckrufAdresse()).toBe("https://laufzettel.example.de/api/auth/oidc/rueckruf");
|
||||
});
|
||||
|
||||
it("wirft mit klarer Meldung, wenn APP_URL fehlt", () => {
|
||||
delete process.env["APP_URL"];
|
||||
expect(() => rueckrufAdresse()).toThrow(/APP_URL/);
|
||||
});
|
||||
|
||||
it("nennt den Anbieter beim Namen, sonst neutral", () => {
|
||||
process.env["OIDC_NAME"] = "authentik";
|
||||
expect(oidcName()).toBe("authentik");
|
||||
delete process.env["OIDC_NAME"];
|
||||
expect(oidcName()).toBe("Single Sign-on");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gueltigkeit einer Sitzung", () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
const sitzung: Sitzung = {
|
||||
benutzerId: "benutzer-1",
|
||||
email: "techniker@firma.example",
|
||||
name: "Techniker",
|
||||
herkunft: "passwort",
|
||||
ausgestelltAm: jetzt,
|
||||
};
|
||||
|
||||
it("traegt bei aktivem Benutzer", () => {
|
||||
expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: true, sitzungenUngueltigAb: null })).toEqual({
|
||||
gueltig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("faellt, wenn es den Benutzer nicht mehr gibt", () => {
|
||||
expect(sitzungBewerten(sitzung, null)).toEqual({ gueltig: false, grund: "unbekannt" });
|
||||
});
|
||||
|
||||
it("faellt, wenn das Cookie auf einen anderen Benutzer zeigt", () => {
|
||||
expect(sitzungBewerten(sitzung, { id: "benutzer-2", aktiv: true, sitzungenUngueltigAb: null })).toEqual({
|
||||
gueltig: false,
|
||||
grund: "unbekannt",
|
||||
});
|
||||
});
|
||||
|
||||
it("faellt sofort bei einem deaktivierten Konto", () => {
|
||||
// Genau dafuer wird pro Anfrage aus der Datenbank gelesen: sonst liefe das
|
||||
// Cookie noch bis zu zwoelf Stunden weiter.
|
||||
expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: false, sitzungenUngueltigAb: null })).toEqual({
|
||||
gueltig: false,
|
||||
grund: "deaktiviert",
|
||||
});
|
||||
});
|
||||
|
||||
it("faellt, wenn die Sitzung vor dem Widerruf ausgestellt wurde", () => {
|
||||
expect(
|
||||
sitzungBewerten(sitzung, {
|
||||
id: "benutzer-1",
|
||||
aktiv: true,
|
||||
sitzungenUngueltigAb: new Date((jetzt + 60) * 1000),
|
||||
}),
|
||||
).toEqual({ gueltig: false, grund: "widerrufen" });
|
||||
});
|
||||
|
||||
it("traegt, wenn die Sitzung nach dem Widerruf ausgestellt wurde", () => {
|
||||
expect(
|
||||
sitzungBewerten(sitzung, {
|
||||
id: "benutzer-1",
|
||||
aktiv: true,
|
||||
sitzungenUngueltigAb: new Date((jetzt - 60) * 1000),
|
||||
}),
|
||||
).toEqual({ gueltig: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Oeffentliche Adressen", () => {
|
||||
it("baut Umleitungen aus APP_URL, nicht aus der Anfrage", () => {
|
||||
// Hinter einem Reverse Proxy traegt die Anfrage den internen Host. Eine
|
||||
// daraus gebaute Umleitung schickt den Benutzer an eine Adresse, die er gar
|
||||
// nicht erreicht.
|
||||
process.env["APP_URL"] = "https://laufzettel.example.de";
|
||||
expect(oeffentlicheAdresse("/anmelden", "http://10.0.0.7:3000").toString()).toBe(
|
||||
"https://laufzettel.example.de/anmelden",
|
||||
);
|
||||
});
|
||||
|
||||
it("behaelt Abfrageparameter", () => {
|
||||
process.env["APP_URL"] = "https://laufzettel.example.de";
|
||||
expect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", "http://intern").search).toBe("?fehler=oidc-aus");
|
||||
});
|
||||
|
||||
it("faellt ohne APP_URL auf die Herkunft der Anfrage zurueck", () => {
|
||||
delete process.env["APP_URL"];
|
||||
expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").toString()).toBe(
|
||||
"http://127.0.0.1:3100/anmelden",
|
||||
);
|
||||
});
|
||||
|
||||
it("faellt auch bei unbrauchbarer APP_URL zurueck, statt stehen zu bleiben", () => {
|
||||
process.env["APP_URL"] = "keine-adresse";
|
||||
expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").host).toBe("127.0.0.1:3100");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
aktivSetzen,
|
||||
benutzerAusOidcUebernehmen,
|
||||
lokalAnmelden,
|
||||
lokalenBenutzerAnlegen,
|
||||
lokalerLoginAktiv,
|
||||
passwortHashen,
|
||||
rolleSetzen,
|
||||
} from "@/server/auth/benutzer";
|
||||
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Anmeldung und Benutzerpflege gegen die echte Datenbank (#007).
|
||||
*
|
||||
* Hier haengen mehrere Akzeptanzkriterien dran: JIT-Provisioning, dass die Rolle
|
||||
* bei jeder Anmeldung neu gesetzt wird, dass ein deaktiviertes Konto abgewiesen
|
||||
* wird und dass Passwoerter nur als Hash in der Datenbank stehen.
|
||||
*/
|
||||
|
||||
const UMGEBUNG = { ...process.env };
|
||||
|
||||
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
|
||||
return {
|
||||
sub: "anbieter-sub-1",
|
||||
email: "maria.huber@firma.example",
|
||||
emailBestaetigt: true,
|
||||
name: "Maria Huber",
|
||||
gruppen: [],
|
||||
ziel: "/",
|
||||
...ueberschreiben,
|
||||
};
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
process.env["AUTH_LOCAL_ENABLED"] = "true";
|
||||
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
|
||||
process.env["OIDC_GROUP_EDITOR"] = "lz-redakteur";
|
||||
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-techniker";
|
||||
process.env["OIDC_GROUP_VIEWER"] = "lz-leser";
|
||||
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...UMGEBUNG };
|
||||
});
|
||||
|
||||
describe("JIT-Provisioning", () => {
|
||||
it("legt einen unbekannten Benutzer beim ersten Login an", async () => {
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] }));
|
||||
|
||||
expect(benutzer.email).toBe("maria.huber@firma.example");
|
||||
expect(benutzer.name).toBe("Maria Huber");
|
||||
expect(benutzer.rolle).toBe("TECHNIKER");
|
||||
expect(benutzer.aktiv).toBe(true);
|
||||
expect(benutzer.passwortHash).toBeNull();
|
||||
expect(await testPrisma.benutzer.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("haelt den Vorgang im Protokoll fest", async () => {
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
|
||||
const eintraege = await testPrisma.protokoll.findMany({
|
||||
where: { entitaet: "Benutzer", entitaetId: benutzer.id },
|
||||
});
|
||||
expect(eintraege).toHaveLength(1);
|
||||
expect(eintraege[0]?.aktion).toContain("angelegt");
|
||||
});
|
||||
|
||||
it("legt beim zweiten Login kein zweites Konto an", async () => {
|
||||
await benutzerAusOidcUebernehmen(anspruecke());
|
||||
await benutzerAusOidcUebernehmen(anspruecke());
|
||||
expect(await testPrisma.benutzer.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("erkennt den Benutzer an der Kennung wieder, auch wenn die Adresse wechselt", async () => {
|
||||
// Die Adresse eines Kontos kann sich aendern, die Kennung beim Anbieter nicht.
|
||||
const erst = await benutzerAusOidcUebernehmen(anspruecke());
|
||||
const dann = await benutzerAusOidcUebernehmen(
|
||||
anspruecke({ email: "m.huber@firma.example", name: "Maria Huber-Schmitt" }),
|
||||
);
|
||||
expect(dann.id).toBe(erst.id);
|
||||
expect(dann.email).toBe("m.huber@firma.example");
|
||||
expect(dann.name).toBe("Maria Huber-Schmitt");
|
||||
expect(await testPrisma.benutzer.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => {
|
||||
// Sonst waere ein Anbieter mit frei waehlbarer Adresse der Weg, den
|
||||
// lokalen Notfall-Administrator zu uebernehmen.
|
||||
await testPrisma.benutzer.create({
|
||||
data: {
|
||||
email: "maria.huber@firma.example",
|
||||
name: "Maria Huber",
|
||||
rolle: "ADMIN",
|
||||
passwortHash: await passwortHashen("ein-langes-passwort"),
|
||||
},
|
||||
});
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke({ emailBestaetigt: false }))).rejects.toBeInstanceOf(
|
||||
AnmeldeFehler,
|
||||
);
|
||||
// Das bestehende Konto bleibt unangetastet.
|
||||
const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({
|
||||
where: { email: "maria.huber@firma.example" },
|
||||
});
|
||||
expect(unveraendert.oidcSub).toBeNull();
|
||||
expect(unveraendert.rolle).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("uebernimmt ein bestehendes lokales Konto mit derselben Adresse", async () => {
|
||||
const lokal = await testPrisma.benutzer.create({
|
||||
data: {
|
||||
email: "maria.huber@firma.example",
|
||||
name: "Maria Huber",
|
||||
rolle: "LESER",
|
||||
passwortHash: await passwortHashen("ein-langes-passwort"),
|
||||
},
|
||||
});
|
||||
const nachOidc = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
|
||||
expect(nachOidc.id).toBe(lokal.id);
|
||||
expect(nachOidc.oidcSub).toBe("anbieter-sub-1");
|
||||
// Der Notfallzugang bleibt erhalten.
|
||||
expect(nachOidc.passwortHash).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rolle aus den Gruppen", () => {
|
||||
it("setzt die Rolle bei jeder Anmeldung neu", async () => {
|
||||
const erst = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] }));
|
||||
expect(erst.rolle).toBe("TECHNIKER");
|
||||
|
||||
const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
expect(befoerdert.rolle).toBe("ADMIN");
|
||||
|
||||
// Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle.
|
||||
const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
|
||||
expect(entzogen.rolle).toBe("LESER");
|
||||
});
|
||||
|
||||
it("protokolliert jede Rollenaenderung aus den Gruppen", async () => {
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] }));
|
||||
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
|
||||
const aenderungen = await testPrisma.protokoll.findMany({
|
||||
where: { entitaetId: benutzer.id, aktion: { contains: "Rolle" } },
|
||||
});
|
||||
expect(aenderungen).toHaveLength(1);
|
||||
expect(aenderungen[0]?.vorher).toEqual({ rolle: "TECHNIKER" });
|
||||
expect(aenderungen[0]?.nachher).toEqual({ rolle: "ADMIN" });
|
||||
});
|
||||
|
||||
it("weist ein deaktiviertes Konto ab, auch bei gueltiger Anmeldung beim Anbieter", async () => {
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
|
||||
await testPrisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: false } });
|
||||
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toBeInstanceOf(AnmeldeFehler);
|
||||
});
|
||||
|
||||
it("schreibt nichts, bevor ein deaktiviertes Konto abgewiesen wird", async () => {
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
await testPrisma.benutzer.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { aktiv: false, letzteAnmeldungAm: null },
|
||||
});
|
||||
|
||||
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toBeInstanceOf(AnmeldeFehler);
|
||||
|
||||
const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } });
|
||||
expect(danach.letzteAnmeldungAm).toBeNull();
|
||||
expect(danach.rolle).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("laesst die Rolle unangetastet, wenn keine Gruppen-Zuordnung eingerichtet ist", async () => {
|
||||
// Sonst waere jede OIDC-Anmeldung eine stille Herabstufung auf LESER – bei
|
||||
// einem einzigen Administrator koennte danach niemand mehr etwas richten.
|
||||
const admin = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||||
expect(admin.rolle).toBe("ADMIN");
|
||||
|
||||
for (const name of [
|
||||
"OIDC_GROUP_ADMIN",
|
||||
"OIDC_GROUP_EDITOR",
|
||||
"OIDC_GROUP_TECHNICIAN",
|
||||
"OIDC_GROUP_VIEWER",
|
||||
]) {
|
||||
delete process.env[name];
|
||||
}
|
||||
|
||||
const nochmal = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
|
||||
expect(nochmal.rolle).toBe("ADMIN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kundenbindung", () => {
|
||||
it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
|
||||
await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } });
|
||||
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
|
||||
const bindungen = await testPrisma.benutzerKunde.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
});
|
||||
expect(bindungen).toHaveLength(1);
|
||||
expect(bindungen[0]?.kundeId).toBe(mus.id);
|
||||
});
|
||||
|
||||
it("schreibt die Bindung bei jeder Anmeldung neu", async () => {
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
|
||||
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
|
||||
const rei = await testPrisma.kunde.create({
|
||||
data: { name: "Baeckerei Reiter", kuerzel: "REI" },
|
||||
});
|
||||
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
|
||||
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] }));
|
||||
|
||||
const bindungen = await testPrisma.benutzerKunde.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
});
|
||||
expect(bindungen).toHaveLength(1);
|
||||
expect(bindungen[0]?.kundeId).toBe(rei.id);
|
||||
});
|
||||
|
||||
it("bindet ohne Praefix niemanden ein", async () => {
|
||||
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
|
||||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
|
||||
expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lokale Anmeldung", () => {
|
||||
const PASSWORT = "ein-hinreichend-langes-passwort";
|
||||
|
||||
async function kontoAnlegen(email = "admin@firma.example") {
|
||||
return testPrisma.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Administrator",
|
||||
rolle: "ADMIN",
|
||||
passwortHash: await passwortHashen(PASSWORT),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("laesst ein richtiges Passwort durch", async () => {
|
||||
const konto = await kontoAnlegen();
|
||||
const angemeldet = await lokalAnmelden("admin@firma.example", PASSWORT);
|
||||
expect(angemeldet.id).toBe(konto.id);
|
||||
});
|
||||
|
||||
it("merkt sich den Zeitpunkt der Anmeldung", async () => {
|
||||
await kontoAnlegen();
|
||||
const angemeldet = await lokalAnmelden("admin@firma.example", PASSWORT);
|
||||
expect(angemeldet.letzteAnmeldungAm).toBeNull();
|
||||
|
||||
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: angemeldet.id } });
|
||||
expect(frisch.letzteAnmeldungAm).not.toBeNull();
|
||||
});
|
||||
|
||||
it("nimmt die Adresse unabhaengig von Gross- und Kleinschreibung", async () => {
|
||||
await kontoAnlegen();
|
||||
await expect(lokalAnmelden(" Admin@Firma.Example ", PASSWORT)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("weist ein falsches Passwort ab", async () => {
|
||||
await kontoAnlegen();
|
||||
await expect(lokalAnmelden("admin@firma.example", "falsch")).rejects.toBeInstanceOf(AnmeldeFehler);
|
||||
});
|
||||
|
||||
it("verraet nicht, ob es die Adresse gibt", async () => {
|
||||
await kontoAnlegen();
|
||||
const beiFalschemPasswort = await lokalAnmelden("admin@firma.example", "falsch").catch(
|
||||
(f: Error) => f.message,
|
||||
);
|
||||
const beiUnbekannterAdresse = await lokalAnmelden("gibtsnicht@firma.example", "egal").catch(
|
||||
(f: Error) => f.message,
|
||||
);
|
||||
expect(beiFalschemPasswort).toBe(beiUnbekannterAdresse);
|
||||
});
|
||||
|
||||
it("weist ein Konto ohne Passwort ab, auch mit leerem Passwort", async () => {
|
||||
await testPrisma.benutzer.create({
|
||||
data: { email: "nur-oidc@firma.example", name: "Nur OIDC", oidcSub: "sub-x" },
|
||||
});
|
||||
await expect(lokalAnmelden("nur-oidc@firma.example", "")).rejects.toBeInstanceOf(AnmeldeFehler);
|
||||
});
|
||||
|
||||
it("weist ein deaktiviertes Konto ab", async () => {
|
||||
const konto = await kontoAnlegen();
|
||||
await testPrisma.benutzer.update({ where: { id: konto.id }, data: { aktiv: false } });
|
||||
await expect(lokalAnmelden("admin@firma.example", PASSWORT)).rejects.toThrow(/deaktiviert/);
|
||||
});
|
||||
|
||||
it("ist abgeschaltet, solange AUTH_LOCAL_ENABLED nicht true ist", async () => {
|
||||
await kontoAnlegen();
|
||||
for (const wert of ["false", "", "1", "ja"]) {
|
||||
process.env["AUTH_LOCAL_ENABLED"] = wert;
|
||||
expect(lokalerLoginAktiv()).toBe(false);
|
||||
await expect(lokalAnmelden("admin@firma.example", PASSWORT)).rejects.toThrow(/abgeschaltet/);
|
||||
}
|
||||
});
|
||||
|
||||
it("speichert das Passwort nur als argon2-Hash", async () => {
|
||||
const konto = await kontoAnlegen();
|
||||
expect(konto.passwortHash).toMatch(/^\$argon2/);
|
||||
expect(konto.passwortHash).not.toContain(PASSWORT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verwaltung", () => {
|
||||
async function adminAnlegen() {
|
||||
return testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
|
||||
});
|
||||
}
|
||||
|
||||
it("legt ein lokales Konto an und protokolliert es", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
const neu = await lokalenBenutzerAnlegen(
|
||||
{
|
||||
email: " Neu@Firma.Example ",
|
||||
name: " Neue Kollegin ",
|
||||
rolle: "TECHNIKER",
|
||||
passwort: "ein-langes-passwort",
|
||||
},
|
||||
admin.id,
|
||||
);
|
||||
|
||||
expect(neu.email).toBe("neu@firma.example");
|
||||
expect(neu.name).toBe("Neue Kollegin");
|
||||
expect(neu.passwortHash).toMatch(/^\$argon2/);
|
||||
|
||||
const protokoll = await testPrisma.protokoll.findFirst({
|
||||
where: { entitaetId: neu.id, aktion: "lokales Konto angelegt" },
|
||||
});
|
||||
expect(protokoll?.benutzerId).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("weist eine doppelte Adresse ab", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
const eingabe = {
|
||||
email: "doppelt@firma.example",
|
||||
name: "Doppelt",
|
||||
rolle: "LESER" as const,
|
||||
passwort: "ein-langes-passwort",
|
||||
};
|
||||
await lokalenBenutzerAnlegen(eingabe, admin.id);
|
||||
await expect(lokalenBenutzerAnlegen(eingabe, admin.id)).rejects.toBeInstanceOf(AnmeldeFehler);
|
||||
});
|
||||
|
||||
it("protokolliert eine Rollenaenderung mit Vorher und Nachher", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
const ziel = await testPrisma.benutzer.create({
|
||||
data: { email: "ziel@firma.example", name: "Ziel", rolle: "LESER" },
|
||||
});
|
||||
|
||||
await rolleSetzen(ziel.id, "REDAKTEUR", admin.id);
|
||||
|
||||
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } });
|
||||
expect(frisch.rolle).toBe("REDAKTEUR");
|
||||
const protokoll = await testPrisma.protokoll.findFirst({
|
||||
where: { entitaetId: ziel.id, aktion: "Rolle geaendert" },
|
||||
});
|
||||
expect(protokoll?.vorher).toEqual({ rolle: "LESER" });
|
||||
expect(protokoll?.nachher).toEqual({ rolle: "REDAKTEUR" });
|
||||
});
|
||||
|
||||
it("macht beim Deaktivieren zugleich alle laufenden Sitzungen ungueltig", async () => {
|
||||
// Sonst koennte sich der Betroffene mit einem gueltigen Cookie noch bis zu
|
||||
// zwoelf Stunden weiterbewegen.
|
||||
const admin = await adminAnlegen();
|
||||
const ziel = await testPrisma.benutzer.create({
|
||||
data: { email: "ziel@firma.example", name: "Ziel", rolle: "TECHNIKER" },
|
||||
});
|
||||
|
||||
await aktivSetzen(ziel.id, false, admin.id);
|
||||
|
||||
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } });
|
||||
expect(frisch.aktiv).toBe(false);
|
||||
expect(frisch.sitzungenUngueltigAb).not.toBeNull();
|
||||
});
|
||||
|
||||
it("laesst den letzten aktiven Administrator nicht herabstufen", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
await expect(rolleSetzen(admin.id, "LESER", admin.id)).rejects.toThrow(/letzte aktive Administrator/);
|
||||
const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
|
||||
expect(unveraendert.rolle).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("laesst den letzten aktiven Administrator nicht deaktivieren", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
await expect(aktivSetzen(admin.id, false, admin.id)).rejects.toThrow(/letzte aktive Administrator/);
|
||||
});
|
||||
|
||||
it("erlaubt die Aenderung, sobald ein zweiter Administrator da ist", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
await testPrisma.benutzer.create({
|
||||
data: { email: "zweiter@firma.example", name: "Zweiter", rolle: "ADMIN" },
|
||||
});
|
||||
await rolleSetzen(admin.id, "LESER", admin.id);
|
||||
const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
|
||||
expect(danach.rolle).toBe("LESER");
|
||||
});
|
||||
|
||||
it("zaehlt einen deaktivierten Administrator nicht als Rueckweg", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
await testPrisma.benutzer.create({
|
||||
data: { email: "inaktiv@firma.example", name: "Inaktiv", rolle: "ADMIN", aktiv: false },
|
||||
});
|
||||
await expect(rolleSetzen(admin.id, "LESER", admin.id)).rejects.toThrow(/letzte aktive Administrator/);
|
||||
});
|
||||
|
||||
it("schreibt nichts, wenn sich nichts aendert", async () => {
|
||||
const admin = await adminAnlegen();
|
||||
const ziel = await testPrisma.benutzer.create({
|
||||
data: { email: "ziel@firma.example", name: "Ziel", rolle: "LESER" },
|
||||
});
|
||||
|
||||
await rolleSetzen(ziel.id, "LESER", admin.id);
|
||||
await aktivSetzen(ziel.id, true, admin.id);
|
||||
|
||||
expect(await testPrisma.protokoll.count({ where: { entitaetId: ziel.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navigationFuer } from "@/lib/navigation";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
describe("Seitenleiste", () => {
|
||||
/** Was die Seitenleiste je Betriebsmodus und Rolle zeigt (#005, #007). */
|
||||
|
||||
function pfadeFuer(modus: "INTERN" | "DIENSTLEISTUNG", rolle: Rolle): string[] {
|
||||
return navigationFuer(modus, (recht) => darf(rolle, recht)).map((e) => e.pfad);
|
||||
}
|
||||
|
||||
describe("Seitenleiste nach Betriebsmodus", () => {
|
||||
it("zeigt im Modus Intern die Kunden-Ebene nicht", () => {
|
||||
const pfade = navigationFuer("INTERN").map((e) => e.pfad);
|
||||
const pfade = pfadeFuer("INTERN", "ADMIN");
|
||||
expect(pfade).not.toContain("/kunden");
|
||||
expect(pfade).toContain("/mitarbeiter");
|
||||
});
|
||||
|
||||
it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => {
|
||||
const pfade = navigationFuer("DIENSTLEISTUNG").map((e) => e.pfad);
|
||||
const pfade = pfadeFuer("DIENSTLEISTUNG", "ADMIN");
|
||||
expect(pfade).toContain("/kunden");
|
||||
expect(pfade).not.toContain("/mitarbeiter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Seitenleiste nach Rolle", () => {
|
||||
it("zeigt Admin nur dem Administrator", () => {
|
||||
expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin/benutzer");
|
||||
for (const rolle of ["REDAKTEUR", "TECHNIKER", "LESER"] as const) {
|
||||
expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin/benutzer");
|
||||
}
|
||||
});
|
||||
|
||||
it("zeigt einem Leser die fachlichen Bereiche, aber nichts Verwaltendes", () => {
|
||||
const pfade = pfadeFuer("INTERN", "LESER");
|
||||
expect(pfade).toContain("/");
|
||||
expect(pfade).toContain("/laeufe");
|
||||
expect(pfade).toContain("/archiv");
|
||||
expect(pfade).not.toContain("/admin/benutzer");
|
||||
});
|
||||
|
||||
it("blendet ohne bekannte Rolle alles Rechtegebundene aus", () => {
|
||||
// So sieht die Huelle aus, solange niemand angemeldet ist.
|
||||
const pfade = navigationFuer("INTERN", () => false).map((e) => e.pfad);
|
||||
expect(pfade).not.toContain("/admin/benutzer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Aufbau der Eintraege", () => {
|
||||
it("verlinkt keinen Eintrag, dessen Seite es noch nicht gibt", () => {
|
||||
for (const eintrag of navigationFuer("INTERN")) {
|
||||
if (!eintrag.verfuegbar) {
|
||||
@@ -21,4 +54,11 @@ describe("Seitenleiste", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hat keine doppelten Pfade je Modus", () => {
|
||||
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
|
||||
const pfade = pfadeFuer(modus, "ADMIN");
|
||||
expect(new Set(pfade).size).toBe(pfade.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { SignJWT } from "jose";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
SITZUNG_COOKIE,
|
||||
SITZUNG_LAUFZEIT_STUNDEN,
|
||||
SitzungsFehler,
|
||||
sitzungErzeugen,
|
||||
sitzungLesen,
|
||||
type Sitzung,
|
||||
} from "@/lib/sitzung";
|
||||
|
||||
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
|
||||
// wieder her, damit die Reihenfolge der Tests nichts aendert.
|
||||
const APP_SECRET_VORHER = process.env["APP_SECRET"];
|
||||
|
||||
function appSecretSetzen(wert: string): void {
|
||||
process.env["APP_SECRET"] = wert;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (APP_SECRET_VORHER === undefined) {
|
||||
delete process.env["APP_SECRET"];
|
||||
} else {
|
||||
process.env["APP_SECRET"] = APP_SECRET_VORHER;
|
||||
}
|
||||
});
|
||||
|
||||
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
|
||||
benutzerId: "b-0815",
|
||||
email: "anna.mueller@example.org",
|
||||
name: "Anna Müller",
|
||||
herkunft: "passwort",
|
||||
};
|
||||
|
||||
function schluessel(quelle: string): Uint8Array {
|
||||
return new TextEncoder().encode(quelle);
|
||||
}
|
||||
|
||||
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
|
||||
async function tokenBauen(optionen: {
|
||||
aussteller?: string;
|
||||
zielgruppe?: string;
|
||||
ausgestelltAm?: number;
|
||||
laeuftAbAm?: number;
|
||||
geheimnis?: string;
|
||||
inhalt?: Record<string, unknown>;
|
||||
}): Promise<string> {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
return await new SignJWT({
|
||||
email: DATEN.email,
|
||||
name: DATEN.name,
|
||||
herkunft: DATEN.herkunft,
|
||||
...optionen.inhalt,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(DATEN.benutzerId)
|
||||
.setIssuer(optionen.aussteller ?? "laufzettel")
|
||||
.setAudience(optionen.zielgruppe ?? "laufzettel")
|
||||
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
|
||||
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
|
||||
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
|
||||
}
|
||||
|
||||
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
|
||||
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
|
||||
const teile = jwt.split(".");
|
||||
const teil = teile[abschnitt];
|
||||
if (teil === undefined || teil.length === 0) {
|
||||
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
|
||||
}
|
||||
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
|
||||
return teile.join(".");
|
||||
}
|
||||
|
||||
describe("Sitzung erzeugen und lesen", () => {
|
||||
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
const sitzung = await sitzungLesen(jwt);
|
||||
|
||||
expect(sitzung).toBeDefined();
|
||||
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
||||
expect(sitzung?.email).toBe(DATEN.email);
|
||||
expect(sitzung?.name).toBe("Anna Müller");
|
||||
expect(sitzung?.herkunft).toBe("passwort");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
|
||||
["franzoesische Akzente", "Zoé Lefèvre"],
|
||||
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
|
||||
])("behaelt %s im Namen", async (_fall, name) => {
|
||||
const jwt = await sitzungErzeugen({ ...DATEN, name });
|
||||
expect((await sitzungLesen(jwt))?.name).toBe(name);
|
||||
});
|
||||
|
||||
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
|
||||
const vorher = Math.floor(Date.now() / 1000);
|
||||
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
||||
const nachher = Math.floor(Date.now() / 1000);
|
||||
|
||||
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
|
||||
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
|
||||
});
|
||||
|
||||
it("uebernimmt beide Herkuenfte", async () => {
|
||||
for (const herkunft of ["passwort", "oidc"] as const) {
|
||||
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
|
||||
expect(sitzung?.herkunft).toBe(herkunft);
|
||||
}
|
||||
});
|
||||
|
||||
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
const [kopfTeil, inhaltTeil] = jwt.split(".");
|
||||
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
|
||||
alg: string;
|
||||
};
|
||||
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
|
||||
iat: number;
|
||||
exp: number;
|
||||
iss: string;
|
||||
aud: string;
|
||||
};
|
||||
|
||||
expect(kopf.alg).toBe("HS256");
|
||||
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
|
||||
expect(inhalt.iss).toBe("laufzettel");
|
||||
expect(inhalt.aud).toBe("laufzettel");
|
||||
});
|
||||
|
||||
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
|
||||
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
|
||||
expect(SITZUNG_COOKIE).toBe("lz_sitzung");
|
||||
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(12);
|
||||
});
|
||||
|
||||
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
|
||||
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
|
||||
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
expect(jwt.split(".")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sitzung lesen bei kaputtem Token", () => {
|
||||
it("gibt bei undefined undefined zurueck", async () => {
|
||||
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["leerer Wert", ""],
|
||||
["nur Leerzeichen", " "],
|
||||
["Klartext ohne Punkte", "keinTokenSondernText"],
|
||||
["zu wenige Abschnitte", "aaa.bbb"],
|
||||
["kein base64", "!!!.???.###"],
|
||||
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
|
||||
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
|
||||
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
|
||||
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
|
||||
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
|
||||
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
|
||||
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
|
||||
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei einem Token undefined zurueck, dessen APP_SECRET inzwischen rotiert wurde", async () => {
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
|
||||
|
||||
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
|
||||
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
|
||||
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
const abgelaufen = await tokenBauen({
|
||||
ausgestelltAm: jetzt - 13 * 3600,
|
||||
laeuftAbAm: jetzt - 3600,
|
||||
});
|
||||
|
||||
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("nimmt ein Token an, das gerade noch laeuft", async () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000);
|
||||
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
|
||||
|
||||
expect(await sitzungLesen(knapp)).toBeDefined();
|
||||
});
|
||||
|
||||
it("gibt bei falschem Aussteller undefined zurueck", async () => {
|
||||
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
|
||||
// Sitzung durchrutschen.
|
||||
const fremd = await tokenBauen({ aussteller: "proxledger" });
|
||||
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
|
||||
const fremd = await tokenBauen({ zielgruppe: "einladung" });
|
||||
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["fehlende E-Mail", { email: undefined }],
|
||||
["fehlender Name", { name: undefined }],
|
||||
["fehlende Herkunft", { herkunft: undefined }],
|
||||
["unbekannte Herkunft", { herkunft: "ldap" }],
|
||||
["Name als Zahl", { name: 42 }],
|
||||
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
|
||||
const luecke = await tokenBauen({ inhalt });
|
||||
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("APP_SECRET pruefen", () => {
|
||||
it.each([
|
||||
["fehlendes", ""],
|
||||
["viel zu kurzes", "kurz"],
|
||||
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
||||
])("wirft beim Erzeugen bei %s APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
|
||||
appSecretSetzen(wert);
|
||||
|
||||
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
|
||||
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
|
||||
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
|
||||
});
|
||||
|
||||
it("wirft auch beim Lesen bei zu kurzem APP_SECRET", async () => {
|
||||
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
|
||||
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
|
||||
const jwt = await sitzungErzeugen(DATEN);
|
||||
appSecretSetzen("kurz");
|
||||
|
||||
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
|
||||
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
|
||||
});
|
||||
|
||||
it("nimmt genau 32 Zeichen an", async () => {
|
||||
appSecretSetzen("b".repeat(32));
|
||||
|
||||
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
||||
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
||||
});
|
||||
|
||||
it("stellt den Standardwert aus setup-env wieder her", async () => {
|
||||
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
|
||||
// Reihenfolge unterschiedlich ausgehen.
|
||||
expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
||||
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SignJWT } from "jose";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { istOeffentlich } from "@/proxy";
|
||||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
|
||||
|
||||
/**
|
||||
* Die Zugangswege in die Anwendung (#007).
|
||||
*
|
||||
* Diese Datei haelt die Zusicherungen fest, die ein unabhaengiger Review als
|
||||
* Luecken benannt hat: das Ruecksprungziel gegen Steuerzeichen, die Allowlist des
|
||||
* Proxys, die Trennung der beiden Token-Arten und dass wirklich jede Server
|
||||
* Action mit einer Rechtepruefung beginnt.
|
||||
*/
|
||||
|
||||
const UMGEBUNG = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...UMGEBUNG };
|
||||
});
|
||||
|
||||
describe("Ruecksprungziel gegen Steuerzeichen", () => {
|
||||
// Browser entfernen Tabulator, Zeilenumbruch und Wagenruecklauf beim
|
||||
// Verarbeiten einer URL. Aus "/<TAB>/fremd.example" wird dabei
|
||||
// "//fremd.example" – also ein fremder Host. Eine Pruefung auf Praefixe
|
||||
// erkennt das nicht.
|
||||
const angriffe = [
|
||||
"/\t/fremd.example",
|
||||
"/\n//fremd.example",
|
||||
"/\r/fremd.example",
|
||||
"/\t\t//fremd.example",
|
||||
"//fremd.example",
|
||||
"/\\fremd.example",
|
||||
"/..//fremd.example",
|
||||
"/../..//fremd.example",
|
||||
"https://fremd.example",
|
||||
"//FREMD.example",
|
||||
"javascript:alert(1)",
|
||||
];
|
||||
|
||||
it.each(angriffe)("weist %j ab", (angriff) => {
|
||||
const ergebnis = sicheresZiel(angriff);
|
||||
expect(ergebnis).toBe("/");
|
||||
});
|
||||
|
||||
it("gibt nie etwas zurueck, das als Adresse einen fremden Host ergaebe", () => {
|
||||
for (const angriff of angriffe) {
|
||||
const aufgeloest = new URL(sicheresZiel(angriff), "http://laufzettel.example");
|
||||
expect(aufgeloest.origin, `bei ${JSON.stringify(angriff)}`).toBe("http://laufzettel.example");
|
||||
}
|
||||
});
|
||||
|
||||
it("laesst gewoehnliche Ziele unveraendert durch", () => {
|
||||
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
|
||||
expect(sicheresZiel("/laeufe?filter=offen#abschnitt-2")).toBe("/laeufe?filter=offen#abschnitt-2");
|
||||
});
|
||||
|
||||
it("laesst prozentkodierte Steuerzeichen als Pfad stehen, ohne den Host zu wechseln", () => {
|
||||
// %09 wird beim Aufloesen nicht dekodiert, bleibt also ein eigenartiger Pfad
|
||||
// auf der eigenen Herkunft – harmlos, im Gegensatz zum echten Tabulator.
|
||||
const ergebnis = sicheresZiel("/%09/fremd.example");
|
||||
expect(new URL(ergebnis, "http://laufzettel.example").origin).toBe("http://laufzettel.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Allowlist des Proxys", () => {
|
||||
it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => {
|
||||
for (const pfad of [
|
||||
"/anmelden",
|
||||
"/api/auth/oidc/start",
|
||||
"/api/auth/oidc/rueckruf",
|
||||
"/api/auth/abmelden",
|
||||
"/api/gesundheit",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
]) {
|
||||
expect(istOeffentlich(pfad), pfad).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("laesst sich nicht durch einen angehaengten Namen austricksen", () => {
|
||||
// Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen.
|
||||
for (const pfad of [
|
||||
"/anmeldenX",
|
||||
"/anmelden-geheim",
|
||||
"/api/authX",
|
||||
"/api/gesundheitlich",
|
||||
"/admin/benutzer",
|
||||
"/",
|
||||
"/laeufe",
|
||||
"/t/LZ-7K3F9Q",
|
||||
]) {
|
||||
expect(istOeffentlich(pfad), pfad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trennung von Sitzung und Kurzzeit-Token", () => {
|
||||
it("laesst ein Kurzzeit-Token nicht als Sitzung durchgehen", async () => {
|
||||
const kurz = await kurzzeitTokenErzeugen("oidc-fluss", { ziel: "/" }, 60);
|
||||
expect(await sitzungLesen(kurz)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("laesst eine Sitzung nicht als Kurzzeit-Token durchgehen", async () => {
|
||||
const sitzung = await sitzungErzeugen({
|
||||
benutzerId: "b1",
|
||||
email: "a@b.example",
|
||||
name: "A",
|
||||
herkunft: "passwort",
|
||||
});
|
||||
expect(await kurzzeitTokenLesen("oidc-fluss", sitzung)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("laesst ein Kurzzeit-Token fuer einen anderen Zweck nicht durchgehen", async () => {
|
||||
const kurz = await kurzzeitTokenErzeugen("ganz-anderer-zweck", { ziel: "/" }, 60);
|
||||
expect(await kurzzeitTokenLesen("oidc-fluss", kurz)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("weist ein Token ab, das mit dem rohen APP_SECRET signiert wurde", async () => {
|
||||
// Das Kurzzeit-Modul leitet seinen Schluessel per scrypt ab. Wer nur das
|
||||
// Geheimnis kennt, kann damit kein gueltiges Fluss-Token bauen.
|
||||
const roh = new TextEncoder().encode(process.env["APP_SECRET"] ?? "");
|
||||
const gefaelscht = await new SignJWT({ ziel: "/" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setIssuer("laufzettel")
|
||||
.setAudience("oidc-fluss")
|
||||
.setExpirationTime("60s")
|
||||
.sign(roh);
|
||||
expect(await kurzzeitTokenLesen("oidc-fluss", gefaelscht)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rechtepruefung an den Einstiegspunkten", () => {
|
||||
const verzeichnis = "src/server/actions";
|
||||
|
||||
/** Aktionen, die absichtlich ohne Anmeldung erreichbar sind. */
|
||||
const OEFFENTLICHE_AKTIONEN = new Set(["anmeldenAktion"]);
|
||||
|
||||
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
|
||||
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
|
||||
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
|
||||
const befunde: string[] = [];
|
||||
|
||||
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
|
||||
const quelle = readFileSync(join(verzeichnis, name), "utf8");
|
||||
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
|
||||
|
||||
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
|
||||
const [, funktion, rumpf] = treffer;
|
||||
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
|
||||
if (!rumpf?.includes("rechtErforderlich(")) {
|
||||
befunde.push(`${name}: ${funktion} prueft kein Recht`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(befunde).toEqual([]);
|
||||
});
|
||||
|
||||
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
|
||||
const aktionen = readdirSync(verzeichnis)
|
||||
.filter((n) => n.endsWith(".ts"))
|
||||
.flatMap((n) => [
|
||||
...readFileSync(join(verzeichnis, n), "utf8").matchAll(/export async function (\w+)/g),
|
||||
])
|
||||
.map((t) => t[1]);
|
||||
expect(aktionen.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user