feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+49 -4
View File
@@ -15,7 +15,7 @@
*/
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { chromium, type Browser } from "@playwright/test";
import { chromium, type Browser, type Cookie } from "@playwright/test";
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
@@ -24,9 +24,18 @@ const ANSICHTEN = [
{ name: "handy", breite: 390, hoehe: 844 },
] as const;
type Seite = {
readonly name: string;
readonly pfad: string;
/** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */
readonly ohneAnmeldung?: boolean;
};
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
const SEITEN: readonly { readonly name: string; readonly pfad: string }[] = [
const SEITEN: readonly Seite[] = [
{ name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true },
{ name: "uebersicht", pfad: "/" },
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
];
function argument(name: string): string | undefined {
@@ -47,6 +56,37 @@ async function warteAufApp(): Promise<void> {
throw new Error(`Die App antwortet nicht auf ${APP_URL}. Erst "pnpm start" laufen lassen.`);
}
/**
* Meldet sich mit dem Seed-Administrator an und gibt den Sitzungs-Cookie zurueck.
*
* Geht ueber das echte Formular statt ueber einen gesetzten Cookie: so faellt
* sofort auf, wenn die Anmeldung selbst kaputt ist.
*/
async function anmelden(browser: Browser): Promise<Cookie[]> {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) {
throw new Error(
"SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD werden gebraucht, um die geschuetzten Seiten " +
"aufzunehmen. Siehe .env.example.",
);
}
const kontext = await browser.newContext({ baseURL: APP_URL });
const tab = await kontext.newPage();
await tab.goto("/anmelden", { waitUntil: "load" });
await tab.fill('input[name="email"]', email);
await tab.fill('input[name="passwort"]', passwort);
await Promise.all([tab.waitForURL("**/"), tab.click('button[type="submit"]')]);
const kekse = await kontext.cookies();
if (!kekse.some((keks) => keks.name === "lz_sitzung")) {
throw new Error("Die Anmeldung hat kein Sitzungscookie gesetzt.");
}
await kontext.close();
return kekse;
}
/** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */
async function kachelnErzeugen(browser: Browser): Promise<void> {
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
@@ -72,7 +112,10 @@ const browser = await chromium.launch();
try {
await kachelnErzeugen(browser);
for (const seite of SEITEN.filter((s) => !nur || s.name === nur)) {
const auswahl = SEITEN.filter((s) => !nur || s.name === nur);
const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : [];
for (const seite of auswahl) {
for (const ansicht of ANSICHTEN) {
const kontext = await browser.newContext({
viewport: { width: ansicht.breite, height: ansicht.hoehe },
@@ -81,10 +124,12 @@ try {
timezoneId: "Europe/Berlin",
deviceScaleFactor: 2,
});
if (!seite.ohneAnmeldung) await kontext.addCookies(kekse);
const tab = await kontext.newPage();
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "networkidle" });
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" });
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
await tab.evaluate(() => document.fonts.ready);
await tab.waitForLoadState("domcontentloaded");
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });