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