feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens

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>
This commit is contained in:
2026-10-12 09:23:41 +02:00
co-authored by Claude Opus 5.5
parent c3211d0fe2
commit 871bb3a77c
75 changed files with 10155 additions and 15 deletions
+102
View File
@@ -0,0 +1,102 @@
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);
});
});