import { beforeEach, describe, expect, it, vi } from "vitest"; import * as benutzerListe from "@/app/api/v1/benutzer/route"; import * as benutzerEinzeln from "@/app/api/v1/benutzer/[id]/route"; import * as benutzerPasswort from "@/app/api/v1/benutzer/[id]/passwort/route"; import * as ich from "@/app/api/v1/ich/route"; import * as ichPasswort from "@/app/api/v1/ich/passwort/route"; import * as profile from "@/app/api/v1/profile/route"; import * as protokoll from "@/app/api/v1/protokoll/route"; import * as server from "@/app/api/v1/server/route"; import * as tokens from "@/app/api/v1/tokens/route"; import { passwortHashen } from "@/lib/passwort"; import { SITZUNG_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { protokollFilterAus } from "@/server/services/protokoll"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { angemeldet, anfrage, kontext } from "./helpers/anfragen"; /** Eine Sitzung, die vor zehn Sekunden ausgestellt wurde – damit `sitzungenAb` sie sicher trifft. */ async function alteSitzung(benutzerId: string): Promise { vi.useFakeTimers({ toFake: ["Date"], now: Date.now() - 10_000 }); try { return `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId, herkunft: "passwort" })}`; } finally { vi.useRealTimers(); } } const wer = async (cookie: string) => (await ich.GET(anfrage("GET", "/api/v1/ich", cookie))).status; describe("Audit-Log-Filter", () => { it("übernimmt Gültiges, verwirft Unsinn", () => { expect(protokollFilterAus({ quelle: "CLI", von: "2026-10-01", suche: "Server", seite: "2" })).toEqual({ quelle: "CLI", von: "2026-10-01", suche: "Server", seite: 2, }); expect(protokollFilterAus({ quelle: "FAX", von: "gestern" })).toEqual({ seite: 1 }); }); }); describe.runIf(datenbankVerfuegbar)("Benutzer, Rollen, Audit-Log", () => { let admin: Awaited>; let operator: Awaited>; beforeEach(async () => { await datenbankLeeren(); admin = await angemeldet("ADMIN", "chefin"); operator = await angemeldet("OPERATOR", "florian"); }); const patch = (cookie: string, id: string, rumpf: unknown) => benutzerEinzeln.PATCH(anfrage("PATCH", `/api/v1/benutzer/${id}`, cookie, rumpf), kontext({ id })); it("Operator darf weder Benutzer noch Server noch Katalog ändern und kein Audit-Log lesen", async () => { const c = operator.cookie; const versuche = [ await benutzerListe.GET(anfrage("GET", "/api/v1/benutzer", c)), await benutzerListe.POST( anfrage("POST", "/api/v1/benutzer", c, { benutzername: "x", anzeigename: "x", passwort: "x".repeat(12), }), ), await patch(c, admin.benutzer.id, { rolle: "OPERATOR" }), await patch(c, operator.benutzer.id, { rolle: "ADMIN" }), await benutzerPasswort.POST( anfrage("POST", `/api/v1/benutzer/${admin.benutzer.id}/passwort`, c, { passwort: "y".repeat(12) }), kontext({ id: admin.benutzer.id }), ), await server.POST(anfrage("POST", "/api/v1/server", c, { name: "x" })), await profile.POST(anfrage("POST", "/api/v1/profile", c, { quelltext: "id: x" })), await protokoll.GET(anfrage("GET", "/api/v1/protokoll", c)), ]; expect(versuche.map((v) => v.status)).toEqual([403, 403, 403, 403, 403, 403, 403, 403]); expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: operator.benutzer.id } })).rolle).toBe( "OPERATOR", ); }); it("Admin legt an, ändert die Rolle; doppelte Namen und kurze Passwörter sind Feldfehler", async () => { const neu = await benutzerListe.POST( anfrage("POST", "/api/v1/benutzer", admin.cookie, { benutzername: "Max.Muster", anzeigename: "Max Muster", email: "", rolle: "OPERATOR", passwort: "ein-langes-passwort", }), ); expect(neu.status).toBe(201); const sicht = (await neu.json()) as { id: string; benutzername: string; passwort: boolean; email: null }; expect(sicht).toMatchObject({ benutzername: "max.muster", passwort: true, email: null }); expect(JSON.stringify(sicht)).not.toContain("argon2"); const doppelt = await benutzerListe.POST( anfrage("POST", "/api/v1/benutzer", admin.cookie, { benutzername: "max.muster", anzeigename: "x", passwort: "ein-langes-passwort", }), ); expect(doppelt.status).toBe(409); expect((await doppelt.json()).fehler.felder.benutzername).toBeTruthy(); const kurz = await benutzerListe.POST( anfrage("POST", "/api/v1/benutzer", admin.cookie, { benutzername: "kurz", anzeigename: "x", passwort: "123", }), ); expect((await kurz.json()).fehler.felder.passwort).toMatch(/12 Zeichen/); expect((await patch(admin.cookie, sicht.id, { rolle: "ADMIN" })).status).toBe(200); const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Benutzer geändert" } }); expect(eintrag.details).toEqual({ rolle: ["OPERATOR", "ADMIN"] }); }); it("der letzte Admin bleibt Admin; niemand sperrt sich selbst", async () => { const herab = await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" }); expect(herab.status).toBe(409); expect((await herab.json()).fehler.code).toBe("letzter_admin"); expect((await patch(admin.cookie, admin.benutzer.id, { aktiv: false })).status).toBe(409); // Mit zweitem Admin geht es. await patch(admin.cookie, operator.benutzer.id, { rolle: "ADMIN" }); expect((await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" })).status).toBe(200); }); it("Sperren beendet Sitzungen und Tokens; Entsperren lässt neue Anmeldungen zu", async () => { const alt = await alteSitzung(operator.benutzer.id); const token = (await ( await tokens.POST(anfrage("POST", "/api/v1/tokens", alt, { name: "cli" })) ).json()) as { token: string; }; const mitToken = anfrage("GET", "/api/v1/ich", null); mitToken.headers.set("authorization", `Bearer ${token.token}`); expect(await wer(alt)).toBe(200); expect((await patch(admin.cookie, operator.benutzer.id, { aktiv: false })).status).toBe(200); expect(await wer(alt)).toBe(401); expect((await ich.GET(mitToken)).status).toBe(401); await patch(admin.cookie, operator.benutzer.id, { aktiv: true }); expect(await wer(alt)).toBe(401); // die alte Sitzung bleibt beendet const frisch = `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId: operator.benutzer.id, herkunft: "passwort" })}`; expect(await wer(frisch)).toBe(200); expect( ( await testPrisma.protokoll.findMany({ where: { ziel: "benutzer:florian" }, orderBy: { id: "asc" } }) ).map((p) => p.aktion), ).toEqual(["Benutzer gesperrt", "Benutzer entsperrt"]); }); it("Passwort zurücksetzen (Admin) und selbst ändern beenden alte Sitzungen", async () => { await testPrisma.benutzer.update({ where: { id: operator.benutzer.id }, data: { passwortHash: await passwortHashen("das-alte-passwort") }, }); const alt = await alteSitzung(operator.benutzer.id); const falsch = await ichPasswort.POST( anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "falsch", neu: "das-neue-passwort" }), ); expect(falsch.status).toBe(400); expect((await falsch.json()).fehler.felder.alt).toBeTruthy(); const geaendert = await ichPasswort.POST( anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "das-alte-passwort", neu: "das-neue-passwort" }), ); expect(geaendert.status).toBe(204); const neuesCookie = geaendert.headers.get("set-cookie")!.split(";")[0]!; expect(await wer(alt)).toBe(401); expect(await wer(neuesCookie)).toBe(200); const alt2 = await alteSitzung(operator.benutzer.id); // alt2 ist älter als der Passwortwechsel – schon ungültig. Nach dem Zurücksetzen auch das neue Cookie: expect(await wer(alt2)).toBe(401); vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 2000 }); try { const zurueck = await benutzerPasswort.POST( anfrage("POST", `/api/v1/benutzer/${operator.benutzer.id}/passwort`, admin.cookie, { passwort: "vom-admin-gesetzt", }), kontext({ id: operator.benutzer.id }), ); expect(zurueck.status).toBe(204); expect(await wer(neuesCookie)).toBe(401); } finally { vi.useRealTimers(); } }); it("Audit-Log: neueste zuerst, filterbar nach Benutzer, Quelle und Text", async () => { await testPrisma.protokoll.createMany({ data: [ { benutzerId: admin.benutzer.id, quelle: "WEB", aktion: "Server angelegt", ziel: "server:rz1" }, { benutzerId: operator.benutzer.id, quelle: "CLI", aktion: "Ausrollen gestartet", ziel: "auftrag:d-abcde", }, { benutzerId: null, quelle: "SYSTEM", aktion: "Erster Administrator angelegt" }, ], }); const holen = async (abfrage: string) => (await (await protokoll.GET(anfrage("GET", `/api/v1/protokoll?${abfrage}`, admin.cookie))).json()) as { eintraege: Array<{ aktion: string; benutzer: string | null }>; gesamt: number; }; expect((await holen("")).eintraege.map((e) => e.aktion)).toEqual([ "Erster Administrator angelegt", "Ausrollen gestartet", "Server angelegt", ]); expect((await holen("benutzer=florian")).eintraege).toEqual([ expect.objectContaining({ aktion: "Ausrollen gestartet", benutzer: "florian" }), ]); expect((await holen("quelle=SYSTEM")).gesamt).toBe(1); expect((await holen("suche=d-abc")).gesamt).toBe(1); expect((await holen("suche=server")).gesamt).toBe(1); // Der Filter rechnet in lokaler Zeit – also auch „heute“ lokal bilden. const d = new Date(); const heute = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; expect((await holen(`von=${heute}&bis=${heute}`)).gesamt).toBe(3); expect((await holen("bis=2000-01-01")).gesamt).toBe(0); }); });