feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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:
+49
-4
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user