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);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Regeln, die in der Datenbank selbst stehen – sie gelten auch, wenn jemand an der
* Service-Schicht vorbei schreibt. Jede ist hier namentlich aufgeführt.
*/
describe.runIf(datenbankVerfuegbar)("Datenbankregeln", () => {
beforeEach(datenbankLeeren);
it("protokoll_nur_anhaengen: Einträge lassen sich weder ändern noch löschen", async () => {
const eintrag = await testPrisma.protokoll.create({ data: { quelle: "SYSTEM", aktion: "Test" } });
await expect(
testPrisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "Gefälscht" } }),
).rejects.toThrow(/nur anhängbar/);
await expect(testPrisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/nur anhängbar/);
});
it("protokoll_nur_anhaengen: das Löschen eines Benutzers lässt seine Einträge stehen", async () => {
const benutzer = await testPrisma.benutzer.create({ data: { benutzername: "weg", anzeigename: "Weg" } });
await testPrisma.protokoll.create({
data: { quelle: "WEB", aktion: "Angemeldet", benutzerId: benutzer.id },
});
await testPrisma.benutzer.delete({ where: { id: benutzer.id } });
const eintraege = await testPrisma.protokoll.findMany();
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.benutzerId).toBeNull();
});
it("benutzer_benutzername_format: nur Kleinbuchstaben, Ziffern, . _ -", async () => {
await expect(
testPrisma.benutzer.create({ data: { benutzername: "Florian Wilde", anzeigename: "x" } }),
).rejects.toThrow(/benutzer_benutzername_format/);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { expect, test, type Page } from "@playwright/test";
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
async function anmelden(page: Page) {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await page.getByLabel("Benutzername").fill(BENUTZER);
await page.getByLabel("Passwort").fill(PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
async function keinQuerscrollen(page: Page) {
const breiten = await page.evaluate(() => ({
inhalt: document.documentElement.scrollWidth,
fenster: window.innerWidth,
}));
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
}
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
await page.goto("/anmelden");
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
await page.getByLabel("Benutzername").fill("e2e-unbekannt");
await page.getByLabel("Passwort").fill("falsch-falsch");
await page.getByRole("button", { name: "Anmelden" }).click();
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
await expect(page.getByText("✗")).toBeVisible();
});
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
await anmelden(page);
await keinQuerscrollen(page);
await page.screenshot({ path: `test-results/uebersicht-${test.info().project.name}.png`, fullPage: true });
if (test.info().project.name === "handy") {
await page.getByRole("button", { name: "Navigation öffnen" }).click();
}
await page.getByRole("button", { name: "Abmelden" }).click();
await expect(page).toHaveURL(/\/anmelden/);
});
test("Theme-Wechsel bleibt nach dem Neuladen erhalten", async ({ page }) => {
await anmelden(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "dunkel");
if (test.info().project.name === "handy") {
await page.getByRole("button", { name: "Navigation öffnen" }).click();
}
await page.getByRole("button", { name: "Helles Theme einschalten" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
await page.screenshot({
path: `test-results/uebersicht-hell-${test.info().project.name}.png`,
fullPage: true,
});
});
test("Anmeldeseite ohne Querscrollen", async ({ page }) => {
await page.goto("/anmelden");
await keinQuerscrollen(page);
});
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from "vitest";
import { entschluesseln, GeheimnisFehler, verschluesseln } from "@/lib/geheimnisse";
const ursprung = process.env["PROXDEPLOY_SCHLUESSEL"];
afterEach(() => {
process.env["PROXDEPLOY_SCHLUESSEL"] = ursprung;
});
describe("Geheimnisse (AES-256-GCM)", () => {
it("verschlüsselt und entschlüsselt verlustfrei, auch Umlaute und Zeilenumbrüche", () => {
const klar = "-----BEGIN OPENSSH PRIVATE KEY-----\nÄÖÜ ß\n-----END-----\n";
expect(entschluesseln(verschluesseln(klar))).toBe(klar);
});
it("erzeugt bei gleichem Klartext jedes Mal einen anderen Geheimtext (neuer IV)", () => {
expect(verschluesseln("geheim")).not.toBe(verschluesseln("geheim"));
});
it("trägt das Versionspräfix v1", () => {
expect(verschluesseln("x").startsWith("v1.")).toBe(true);
});
it("wirft bei verändertem Geheimtext", () => {
const teile = verschluesseln("geheim").split(".");
const daten = Buffer.from(teile[3]!, "base64");
daten[0] = daten[0]! ^ 0xff;
teile[3] = daten.toString("base64");
expect(() => entschluesseln(teile.join("."))).toThrow(GeheimnisFehler);
});
it("wirft mit anderem Schlüssel", () => {
const geheim = verschluesseln("geheim");
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 9).toString("base64");
expect(() => entschluesseln(geheim)).toThrow(/nicht mehr derselbe/);
});
it("verlangt genau 32 Byte Schlüssel und nennt den Befehl zum Erzeugen", () => {
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(16).toString("base64");
expect(() => verschluesseln("x")).toThrow(/openssl rand -base64 32/);
});
it("lehnt unbekannte Formate und Versionen ab", () => {
expect(() => entschluesseln("klartext")).toThrow(/unbekanntes Format/);
expect(() => entschluesseln("v9.a.b.c")).toThrow(/unbekannte Version/);
});
});
+18
View File
@@ -0,0 +1,18 @@
import "dotenv/config";
/**
* Spielt die Migrationen in die Testdatenbank ein. Ohne TEST_DATABASE_URL überspringen sich die
* Datenbanktests – und sagen das laut, damit ein grüner Lauf nicht mehr verspricht, als er prüft.
*/
export default async function aufbau(): Promise<void> {
const url = process.env["TEST_DATABASE_URL"];
if (!url) {
console.warn("\n TEST_DATABASE_URL fehlt – Datenbanktests werden ÜBERSPRUNGEN. Start: pnpm db:up\n");
return;
}
const { execFileSync } = await import("node:child_process");
execFileSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
stdio: "inherit",
env: { ...process.env, DATABASE_URL: url },
});
}
+28
View File
@@ -0,0 +1,28 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const url = process.env["TEST_DATABASE_URL"];
// In CI darf nichts still übersprungen werden – dort lieber hart abbrechen.
if (!url && process.env["CI"]) {
throw new Error("TEST_DATABASE_URL fehlt. In CI dürfen die Datenbanktests nicht übersprungen werden.");
}
export const datenbankVerfuegbar = Boolean(url);
export const testPrisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5437/proxdeploy_test" }),
});
/**
* Leert alle Tabellen. Das Protokoll ist per Trigger nur anhängbar – TRUNCATE löst
* Zeilen-Trigger nicht aus und ist deshalb der einzige Weg, es zu leeren.
*/
export async function datenbankLeeren(): Promise<void> {
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
`;
if (tabellen.length === 0) return;
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
await testPrisma.$executeRawUnsafe(`TRUNCATE ${liste} RESTART IDENTITY CASCADE`);
}
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* Kontrast mindestens 4,5:1 (WCAG AA) für Text auf Grund und Panel – in beiden Themes.
* Liest die Tokens direkt aus globals.css, damit eine Farbänderung hier auffällt.
*/
const css = readFileSync("src/app/globals.css", "utf8");
function block(selektor: string): Record<string, string> {
const anfang = css.indexOf(selektor);
const inhalt = css.slice(css.indexOf("{", anfang) + 1, css.indexOf("}", anfang));
return Object.fromEntries(
[...inhalt.matchAll(/--farbe-([a-z-]+):\s*(#[0-9a-f]{6})/gi)].map((t) => [t[1]!, t[2]!]),
);
}
function leuchtdichte(hex: string): number {
const kanal = (i: number) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * kanal(1) + 0.7152 * kanal(3) + 0.0722 * kanal(5);
}
export function kontrast(a: string, b: string): number {
const [hell, dunkel] = [leuchtdichte(a), leuchtdichte(b)].sort((x, y) => y - x) as [number, number];
return (hell + 0.05) / (dunkel + 0.05);
}
const THEMES = { dunkel: block('[data-theme="dunkel"]'), hell: block('[data-theme="hell"]') };
const TEXTFARBEN = ["text", "muted", "akzent", "laeuft", "fehler"];
describe("Kontrast der Theme-Farben", () => {
for (const [name, farben] of Object.entries(THEMES)) {
for (const flaeche of ["grund", "panel", "panel-hoch"]) {
it.each(TEXTFARBEN)(`${name}: %s auf ${flaeche} ≥ 4,5:1`, (textfarbe) => {
expect(kontrast(farben[textfarbe]!, farben[flaeche]!)).toBeGreaterThanOrEqual(4.5);
});
}
it(`${name}: Text auf Akzentfläche ≥ 4,5:1`, () => {
expect(kontrast(farben["akzent-text"]!, farben["akzent"]!)).toBeGreaterThanOrEqual(4.5);
});
}
});
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
if (process.env["TEST_DATABASE_URL"]) {
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
}
// Feste Testgeheimnisse, damit kein Test eine .env verlangt.
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 7).toString("base64");
process.env["SITZUNG_GEHEIMNIS"] = "test-sitzungsgeheimnis-mit-mindestens-32-zeichen";
process.env["LOG_LEVEL"] = "silent";
process.env["TRUST_PROXY"] = "false";
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { sicherheitsrichtlinie } from "@/proxy";
describe("Content-Security-Policy", () => {
it("ist im gebauten Stand streng", () => {
const csp = sicherheitsrichtlinie("abc", false);
expect(csp).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
expect(csp).not.toContain("unsafe-eval");
expect(csp).not.toContain("ws:");
expect(csp).toContain("frame-ancestors 'none'");
});
it("lockert nur im Entwicklungsmodus", () => {
expect(sicherheitsrichtlinie("abc", true)).toContain("'unsafe-eval'");
});
});
+31
View File
@@ -0,0 +1,31 @@
import { SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
describe("Sitzungscookie", () => {
it("liest ein selbst ausgestelltes Token zurück", async () => {
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
expect(await sitzungLesen(jwt)).toMatchObject({ benutzerId: "b1", herkunft: "passwort" });
});
it("verwirft fehlende, verfälschte und fremd signierte Tokens", async () => {
expect(await sitzungLesen(undefined)).toBeUndefined();
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
expect(await sitzungLesen(jwt.slice(0, -2) + "xx")).toBeUndefined();
const fremd = await new SignJWT({ herkunft: "passwort" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("b1")
.setIssuer("proxdeploy")
.setAudience("proxdeploy-web")
.setIssuedAt()
.setExpirationTime("1h")
.sign(new TextEncoder().encode("ein-ganz-anderes-geheimnis-mit-genug-zeichen"));
expect(await sitzungLesen(fremd)).toBeUndefined();
});
it("enthält keine Rolle – die kommt je Anfrage aus der Datenbank", async () => {
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "oidc" });
const inhalt = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
expect(inhalt).not.toHaveProperty("rolle");
});
});
+53
View File
@@ -0,0 +1,53 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { istOeffentlich, OEFFENTLICH } from "@/proxy";
/**
* Strukturelle Prüfung: jede Seite und jeder Route Handler prüft sein Recht selbst.
* Ausnahmen stehen hier namentlich mit Begründung – ein neuer Handler ohne Prüfung soll
* jemanden zwingen, sie aufzuschreiben.
*/
const AUSNAHMEN: Record<string, string> = {
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
"src/app/anmelden/page.tsx": "Anmeldeseite",
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
};
function dateien(verzeichnis: string): string[] {
return readdirSync(verzeichnis).flatMap((name) => {
const pfad = join(verzeichnis, name);
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
});
}
const wurzel = process.cwd();
const kandidaten = dateien(join(wurzel, "src/app"))
.map((pfad) => relative(wurzel, pfad))
.filter((pfad) => pfad.endsWith("/page.tsx") || pfad.endsWith("/route.ts"));
describe("Zugangsprüfung", () => {
it.each(kandidaten.filter((pfad) => !(pfad in AUSNAHMEN)))("%s prüft sein Recht", (pfad) => {
const quelltext = readFileSync(join(wurzel, pfad), "utf8");
expect(quelltext).toMatch(/seiteSchuetzen\(|rechtErforderlich\(/);
});
it("jede Ausnahme gibt es noch", () => {
for (const pfad of Object.keys(AUSNAHMEN)) expect(kandidaten).toContain(pfad);
});
it("öffentliche Pfade sind genau die bekannten", () => {
expect(OEFFENTLICH).toEqual([
"/anmelden",
"/api/v1/auth",
"/api/v1/gesundheit",
"/brand",
"/manifest.webmanifest",
]);
expect(istOeffentlich("/api/v1/auth/anmelden")).toBe(true);
expect(istOeffentlich("/api/v1/authentifizieren")).toBe(false);
expect(istOeffentlich("/server")).toBe(false);
});
});