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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||
|
||||
test("Admin legt einen Benutzer an, stuft ihn hoch, sperrt ihn; das Audit-Log zeigt es", async ({ page }) => {
|
||||
const name = `${E2E_PRAEFIX}-benutzer-${test.info().project.name}`;
|
||||
await anmelden(page, "/einstellungen");
|
||||
|
||||
const vorhanden = (await (await page.request.get("/api/v1/benutzer")).json()) as Array<{
|
||||
id: string;
|
||||
benutzername: string;
|
||||
}>;
|
||||
const alt = vorhanden.find((b) => b.benutzername === name);
|
||||
if (alt) {
|
||||
// Vom letzten Lauf: zurück auf Operator und aktiv.
|
||||
await page.request.patch(`/api/v1/benutzer/${alt.id}`, { data: { rolle: "OPERATOR", aktiv: true } });
|
||||
await page.reload();
|
||||
} else {
|
||||
await page.locator("#b-name").fill(name);
|
||||
await page.locator("#b-anzeige").fill("E2E Test");
|
||||
await page.locator("#b-passwort").fill("e2e-passwort-lang");
|
||||
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
|
||||
await expect(page.getByText(`${name} angelegt.`)).toBeVisible();
|
||||
}
|
||||
await keinQuerscrollen(page);
|
||||
|
||||
await page.getByLabel(`Rolle von ${name}`).selectOption("ADMIN");
|
||||
await expect(page.getByText(`${name} ist jetzt Admin.`)).toBeVisible();
|
||||
await page.getByRole("button", { name: `${name} sperren` }).click();
|
||||
await expect(page.getByText(`${name} gesperrt – Sitzungen beendet.`)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: `${name} entsperren` })).toBeVisible();
|
||||
|
||||
// Am Handy steckt die Navigation in der Schublade.
|
||||
const menue = page.getByRole("button", { name: "Navigation öffnen" });
|
||||
if (await menue.isVisible()) await menue.click();
|
||||
await page.getByRole("link", { name: "Audit-Log" }).filter({ visible: true }).click();
|
||||
await expect(page).toHaveURL(/\/protokoll/);
|
||||
await page.getByLabel("Suche").fill(`benutzer:${name}`);
|
||||
await page.getByRole("button", { name: "Filtern" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`suche=benutzer%3A${name}`));
|
||||
await expect(page.getByText("Benutzer gesperrt").first()).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
});
|
||||
Reference in New Issue
Block a user