Files
Laufzettel/tests/benutzer.test.ts
T
florian.wildeandClaude Opus 5 ecc3c579df
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 05:03:11 +00:00

430 lines
17 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 { 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 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);
});
});
});