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 }; /** * Standard-Anspruechge **mit** einer Rollengruppe. * * Seit #035 ist „angemeldet, aber in keiner Rollengruppe“ kein stilles `LESER` * mehr, sondern fuehrt in die Selbstauskunft — und ohne passende Person im * Verzeichnis wird die Anmeldung abgewiesen. Die Tests hier pruefen * JIT-Provisioning, Protokoll und Kundenbindung; sie sollen nicht nebenbei an * dieser Weiche haengen. Wer die Weiche selbst prueft, findet das in * `tests/selbstauskunft.test.ts`. */ function anspruecke(ueberschreiben: Partial = {}): OidcAnspruecke { return { sub: "anbieter-sub-1", email: "maria.huber@firma.example", emailBestaetigt: true, name: "Maria Huber", gruppen: ["lz-techniker"], // Der UPN haengt an der Selbstauskunft (#035); im AD ist er oft nicht die // Mailadresse, deshalb hier bewusst ein anderer Wert. upn: "mhuber@firma.local", 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. **Seit #035 ist das kein `LESER` mehr:** wem die * Rollengruppe entzogen wird und wer zu keiner Person im Verzeichnis * passt, kommt nicht mehr herein. * * Das ist strenger als vorher und Absicht. Vorher behielt ein * ausgeschiedener Techniker Lesezugriff auf **alle** Laeufe des Hauses, * solange niemand sein Konto deaktivierte — und genau das vergisst man. * Der Notfallzugang dagegen bleibt: `AUTH_LOCAL_ENABLED` haengt nicht an * den Gruppen. */ await expect(benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }))).rejects.toThrow(AnmeldeFehler); // Die Rolle in der Datenbank bleibt dabei, was sie war – abgewiesen heisst // abgewiesen, nicht herabgestuft. const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: erst.id } }); expect(unveraendert.rolle).toBe("ADMIN"); }); 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", () => { /* * **Rollengruppe und Kundengruppe sind zwei Dinge** (#035). Diese Tests gaben * vorher nur `lz-kunde-MUS` mit und bekamen dadurch stillschweigend `LESER`. * Seit #035 fuehrt „keine Rollengruppe“ in die Selbstauskunft — eine * Kundengruppe allein sagt eben nichts darueber, was jemand duerfen soll. * Wer lesen soll, gehoert in die Leser-Gruppe; dafuer gibt es * `OIDC_GROUP_VIEWER`. */ 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-leser", "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-leser", "lz-kunde-MUS"] }), ); await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-leser", "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-leser", "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 geändert" }, }); 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); }); }); });