feat(benutzer): Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht

Admins verwalten Benutzer (anlegen, Rolle, sperren, Passwort setzen); jeder ändert sein Passwort; das Audit-Log ist filterbar einsehbar. Sperre und Passwortwechsel beenden ältere Sitzungen über Benutzer.sitzungenAb. Der letzte Admin und das eigene Konto sind geschützt.

Closes #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:49:47 +02:00
co-authored by Claude Opus 5.5
parent 7f6cf00a0e
commit 99357d59bd
21 changed files with 1292 additions and 22 deletions
+229
View File
@@ -0,0 +1,229 @@
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<string> {
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<ReturnType<typeof angemeldet>>;
let operator: Awaited<ReturnType<typeof angemeldet>>;
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);
});
});