import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { anmeldenameSchema, einmalPasswortErzeugen, lokaleKontenAktiv, passwortFehler, } from "@/lib/lokale-konten"; import { passwortHashen } from "@/lib/passwort-hash"; import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp"; import { AnmeldeFehler, eigenesPasswortSetzen, kontoAnmelden, totpEinrichtungAbschliessen, } from "@/server/services/anmeldung"; import { kontoAktivSetzen, kontoZuruecksetzen, lokalesKontoAnlegen, rolleAendern, } from "@/server/services/benutzer"; import { FachFehler } from "@/server/services/fachfehler"; import { datenbankLeeren } from "./hilfen/db"; /** Lokale Konten (#035): Regeln, erste Anmeldung, Verwaltung, Abschalten. */ const EIGENES = "ein-eigenes-langes-passwort"; let admin: { id: string; name: string }; beforeEach(async () => { await datenbankLeeren(); delete process.env["SSHKV_LOKALE_KONTEN"]; admin = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN", oidcSub: "ada" }, select: { id: true, name: true }, }); }); afterEach(() => { delete process.env["SSHKV_LOKALE_KONTEN"]; }); const code = (geheimnis: string, zeit: Date) => totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime())); /** Anlegen, Einmal-Passwort ersetzen, TOTP einrichten – wie bei der ersten Anmeldung. */ async function eingerichtetesKonto(anmeldename = "anna") { const { id, einmalPasswort } = await lokalesKontoAnlegen( { anmeldename, name: "Anna", rolle: "OPERATOR" }, admin, ); expect((await kontoAnmelden(anmeldename, einmalPasswort, "")).art).toBe("passwortSetzen"); await eigenesPasswortSetzen(id, EIGENES); expect((await kontoAnmelden(anmeldename, EIGENES, "")).art).toBe("totpEinrichten"); const geheimnis = totpGeheimnisErzeugen(); const t0 = new Date("2026-10-12T10:00:00Z"); await totpEinrichtungAbschliessen(id, geheimnis, code(geheimnis, t0), t0); return { id, geheimnis, einmalPasswort }; } describe("Regeln", () => { it("erzeugt gut abtippbare, verschiedene Einmal-Passwoerter", () => { const pw = einmalPasswortErzeugen(); expect(pw).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/); expect(pw).not.toMatch(/[01OlI]/); expect(new Set(Array.from({ length: 50 }, einmalPasswortErzeugen)).size).toBe(50); }); it("prueft Anmeldename und Passwort", () => { expect(anmeldenameSchema.parse(" Anna.Muster ")).toBe("anna.muster"); expect(anmeldenameSchema.safeParse("1anna").success).toBe(false); expect(anmeldenameSchema.safeParse("a").success).toBe(false); expect(anmeldenameSchema.safeParse("anna muster").success).toBe(false); expect(passwortFehler("kurz")).toMatch(/12 Zeichen/); expect(passwortFehler("xx-anna-xx-geheim", "Anna")).toMatch(/Anmeldenamen/); expect(passwortFehler(EIGENES, "anna")).toBeNull(); }); it("schaltet lokale Konten nur mit ausdruecklichem Aus ab", () => { expect(lokaleKontenAktiv({})).toBe(true); expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: "1" })).toBe(true); for (const aus of ["0", "false", "nein", "AUS"]) expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: aus })).toBe(false); }); }); describe("Erste Anmeldung", () => { it("verlangt erst ein eigenes Passwort, dann TOTP, dann geht es hinein", async () => { const { id, geheimnis, einmalPasswort } = await eingerichtetesKonto(); const t1 = new Date("2026-10-12T10:01:00Z"); // Das Einmal-Passwort gilt nicht mehr, Gross-/Kleinschreibung des Namens egal. await expect(kontoAnmelden("anna", einmalPasswort, code(geheimnis, t1), t1)).rejects.toThrow( AnmeldeFehler, ); const ergebnis = await kontoAnmelden("Anna", EIGENES, code(geheimnis, t1), t1); expect(ergebnis).toMatchObject({ art: "angemeldet", benutzer: { id, rolle: "OPERATOR" } }); expect(await prisma.protokoll.count({ where: { aktion: "anmeldung.lokal" } })).toBe(1); }); it("lehnt schwache und unveraenderte Passwoerter ab und laesst nichts im Protokoll", async () => { const { id, einmalPasswort } = await lokalesKontoAnlegen( { anmeldename: "bert", name: "Bert", rolle: "LESER" }, admin, ); await expect(eigenesPasswortSetzen(id, "zu-kurz")).rejects.toThrow(/12 Zeichen/); await expect(eigenesPasswortSetzen(id, einmalPasswort)).rejects.toThrow(/anderes Passwort/); await eigenesPasswortSetzen(id, EIGENES); // Kein zweites Mal ohne Zuruecksetzen. await expect(eigenesPasswortSetzen(id, `${EIGENES}-2`)).rejects.toThrow(/kein Passwortwechsel/); const protokoll = JSON.stringify(await prisma.protokoll.findMany(), (_k, v: unknown) => typeof v === "bigint" ? v.toString() : v, ); expect(protokoll).not.toContain(einmalPasswort); expect(protokoll).not.toContain(EIGENES); }); it("kennt keinen doppelten Anmeldenamen – auch nicht den des Notfall-Admins", async () => { await prisma.benutzer.create({ data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" }, }); await lokalesKontoAnlegen({ anmeldename: "anna", name: "Anna", rolle: "LESER" }, admin); for (const name of ["anna", "notfall"]) { await expect( lokalesKontoAnlegen({ anmeldename: name, name: "X", rolle: "LESER" }, admin), ).rejects.toThrow(FachFehler); } }); }); describe("Verwaltung", () => { it("aendert Rollen nur bei lokalen Konten und nie am eigenen", async () => { const { id } = await eingerichtetesKonto(); await rolleAendern(id, "ADMIN", admin); expect((await prisma.benutzer.findUniqueOrThrow({ where: { id } })).rolle).toBe("ADMIN"); // OIDC-Konto: Rolle kommt aus den Gruppen. await expect(rolleAendern(admin.id, "LESER", { id: id, name: "Anna" })).rejects.toThrow(/Gruppen/); await expect(rolleAendern(id, "LESER", { id, name: "Anna" })).rejects.toThrow(/eigene Konto/); const notfall = await prisma.benutzer.create({ data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" }, }); await expect(rolleAendern(notfall.id, "LESER", admin)).rejects.toThrow(/Notfall-Admin/); await expect(kontoAktivSetzen(notfall.id, false, admin)).rejects.toThrow(/Notfall-Admin/); }); it("sperrt sofort: Sitzungen enden, Anmeldung scheitert", async () => { const { id, geheimnis } = await eingerichtetesKonto(); const vorher = (await prisma.benutzer.findUniqueOrThrow({ where: { id } })).sitzungenGueltigAb; await kontoAktivSetzen(id, false, admin); const gesperrt = await prisma.benutzer.findUniqueOrThrow({ where: { id } }); expect(gesperrt.aktiv).toBe(false); expect(gesperrt.sitzungenGueltigAb.getTime()).toBeGreaterThan(vorher.getTime()); const t = new Date("2026-10-12T10:02:00Z"); await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler); await kontoAktivSetzen(id, true, admin); expect((await kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).art).toBe("angemeldet"); }); it("setzt Passwort und TOTP zurueck", async () => { const { id } = await eingerichtetesKonto(); const { einmalPasswort } = await kontoZuruecksetzen(id, admin); const konto = await prisma.benutzer.findUniqueOrThrow({ where: { id } }); expect(konto).toMatchObject({ passwortWechselNoetig: true, totpGeheimnis: null, totpEingerichtetAm: null, }); expect((await kontoAnmelden("anna", einmalPasswort, "")).art).toBe("passwortSetzen"); await expect(kontoZuruecksetzen(admin.id, { id, name: "Anna" })).rejects.toThrow(/lokale Konten/); }); }); describe("Abgeschaltet (SSHKV_LOKALE_KONTEN=0)", () => { it("laesst lokale Konten nicht mehr hinein, den Notfall-Admin schon", async () => { const { geheimnis } = await eingerichtetesKonto(); await prisma.benutzer.create({ data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: await passwortHashen("notfall-test-passwort-123"), }, }); process.env["SSHKV_LOKALE_KONTEN"] = "0"; const t = new Date("2026-10-12T10:03:00Z"); await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler); expect((await kontoAnmelden("notfall", "notfall-test-passwort-123", "")).art).toBe("totpEinrichten"); await expect( lokalesKontoAnlegen({ anmeldename: "carl", name: "Carl", rolle: "LESER" }, admin), ).rejects.toThrow(/abgeschaltet/); }); });