Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus Laufzettel übernommen, weil dort derselbe Stack erprobt ist. Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das helle Theme setzt dieselben Namen neu. Amber und Rot sind dort abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide Themes gegen 4,5:1. Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank, Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf 1440 px und 390 px. Closes #001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.2 KiB
TypeScript
103 lines
4.2 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { POST as anmeldenPost } from "@/app/api/v1/auth/anmelden/route";
|
|
import { GET as ichGet } from "@/app/api/v1/ich/route";
|
|
import { passwortHashen } from "@/lib/passwort";
|
|
import { ersterAdminFallsLeer, sperrenZuruecksetzen } from "@/server/services/anmeldung";
|
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
|
|
|
function anmeldung(benutzername: string, passwort: string): Request {
|
|
return new Request("http://localhost/api/v1/auth/anmelden", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ benutzername, passwort }),
|
|
});
|
|
}
|
|
|
|
describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
|
|
beforeEach(async () => {
|
|
await datenbankLeeren();
|
|
sperrenZuruecksetzen();
|
|
await testPrisma.benutzer.create({
|
|
data: {
|
|
benutzername: "florian",
|
|
anzeigename: "Florian",
|
|
rolle: "OPERATOR",
|
|
passwortHash: await passwortHashen("richtiges-passwort"),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("setzt bei richtigem Passwort ein HttpOnly-Cookie und /ich kennt den Benutzer", async () => {
|
|
const antwort = await anmeldenPost(anmeldung("Florian", "richtiges-passwort"));
|
|
expect(antwort.status).toBe(200);
|
|
const cookie = antwort.headers.get("set-cookie") ?? "";
|
|
expect(cookie).toMatch(/pd_sitzung=/);
|
|
expect(cookie).toMatch(/HttpOnly/i);
|
|
// Über http (ohne Proxy) darf das Cookie nicht Secure sein, sonst schickt der Browser es nie zurück.
|
|
expect(cookie).not.toMatch(/Secure/i);
|
|
|
|
const ich = await ichGet(
|
|
new Request("http://localhost/api/v1/ich", { headers: { cookie: cookie.split(";")[0]! } }),
|
|
);
|
|
expect(ich.status).toBe(200);
|
|
expect(await ich.json()).toMatchObject({ benutzername: "florian", rolle: "OPERATOR" });
|
|
});
|
|
|
|
it("meldet falsches Passwort und unbekannten Benutzer gleich", async () => {
|
|
const falsch = await anmeldenPost(anmeldung("florian", "falsch"));
|
|
const unbekannt = await anmeldenPost(anmeldung("niemand", "falsch"));
|
|
expect(falsch.status).toBe(401);
|
|
expect(await falsch.json()).toEqual(await unbekannt.json());
|
|
});
|
|
|
|
it("weist deaktivierte Benutzer ab", async () => {
|
|
await testPrisma.benutzer.update({ where: { benutzername: "florian" }, data: { aktiv: false } });
|
|
expect((await anmeldenPost(anmeldung("florian", "richtiges-passwort"))).status).toBe(401);
|
|
});
|
|
|
|
it("sperrt nach zehn Fehlversuchen, auch für das richtige Passwort", async () => {
|
|
for (let i = 0; i < 10; i += 1) await anmeldenPost(anmeldung("florian", "falsch"));
|
|
const antwort = await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
|
|
expect(antwort.status).toBe(429);
|
|
expect((await antwort.json()).fehler.hinweis).toMatch(/15 Minuten/);
|
|
});
|
|
|
|
it("protokolliert Erfolg und Fehlschlag", async () => {
|
|
await anmeldenPost(anmeldung("florian", "falsch"));
|
|
await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
|
|
const aktionen = (await testPrisma.protokoll.findMany({ orderBy: { id: "asc" } })).map((e) => e.aktion);
|
|
expect(aktionen).toEqual(["Anmeldung fehlgeschlagen", "Angemeldet"]);
|
|
});
|
|
|
|
it("antwortet auf kaputtes JSON mit 400 statt 500", async () => {
|
|
const antwort = await anmeldenPost(
|
|
new Request("http://localhost/api/v1/auth/anmelden", { method: "POST", body: "{kaputt" }),
|
|
);
|
|
expect(antwort.status).toBe(400);
|
|
});
|
|
|
|
it("/ich ohne Sitzung ist 401", async () => {
|
|
expect((await ichGet(new Request("http://localhost/api/v1/ich"))).status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe.runIf(datenbankVerfuegbar)("Erster Administrator", () => {
|
|
beforeEach(datenbankLeeren);
|
|
|
|
it("wird nur angelegt, solange es keinen Benutzer gibt", async () => {
|
|
process.env["ADMIN_BENUTZER"] = "Admin";
|
|
process.env["ADMIN_PASSWORT"] = "ein-langes-startpasswort";
|
|
await ersterAdminFallsLeer();
|
|
await ersterAdminFallsLeer();
|
|
const alle = await testPrisma.benutzer.findMany();
|
|
expect(alle).toHaveLength(1);
|
|
expect(alle[0]).toMatchObject({ benutzername: "admin", rolle: "ADMIN" });
|
|
});
|
|
|
|
it("legt bei zu kurzem Passwort niemanden an", async () => {
|
|
process.env["ADMIN_PASSWORT"] = "kurz";
|
|
await ersterAdminFallsLeer();
|
|
expect(await testPrisma.benutzer.count()).toBe(0);
|
|
});
|
|
});
|