Files
proxdeploy/tests/benutzer.test.ts
T
florian.wildeandClaude Opus 5.5 99357d59bd 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>
2026-10-12 16:49:47 +02:00

230 lines
10 KiB
TypeScript
Raw Permalink 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 { 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);
});
});