/** * App-spezifische Konfiguration für baseline.test.ts (Leistungsbilanz, Express 5). * * Identität: Session-Cookie `lb_session` nach POST /api/auth/login. Jeder Schreibzugriff * braucht zusätzlich den Header `X-Leistungsbilanz-Request: 1` (CSRF-Schutz). * Die App wird nur DYNAMISCH in createApp() importiert, damit das Routen-Inventar greift. * Testdaten sind synthetisch; die Datenbank liegt nur im Speicher dieses Testprozesses. */ import path from "node:path"; import type { Express } from "express"; type Headers = Record; type Case = { method: string; path: string; body?: unknown }; export const WRITE_HEADER = { "x-leistungsbilanz-request": "1" }; // Synthetische Testkonten, nur in der In-Memory-Datenbank des Testlaufs. export const START_PASSWORD = "start-passwort-nur-test"; const TEST_PASSWORD = "baseline-passwort-nur-test"; const TEST_USERS = ["planer-a", "planer-b"] as const; function sessionCookie(res: Response): string { // Das letzte Cookie zählt: der Auth-Guard verlängert zuerst die alte Session, danach setzt // z. B. der Passwortwechsel eine neue. const cookie = res.headers.getSetCookie().filter((c) => c.startsWith("lb_session=")).at(-1); if (!cookie) throw new Error(`Kein Session-Cookie (${res.status})`); return cookie.split(";")[0]; } /** Loggt einen Testnutzer ein, setzt das Startpasswort neu und liefert die Request-Header. */ export async function signIn(baseUrl: string, username: string): Promise { const post = (url: string, body: unknown, cookie?: string) => fetch(baseUrl + url, { method: "POST", headers: { ...WRITE_HEADER, "content-type": "application/json", ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body), }); const login = await post("/api/auth/login", { username, password: START_PASSWORD }); if (!login.ok) throw new Error(`Login ${username}: ${login.status} ${await login.text()}`); const changed = await post( "/api/auth/change-password", { currentPassword: START_PASSWORD, newPassword: TEST_PASSWORD }, sessionCookie(login), ); if (!changed.ok) throw new Error(`Passwortwechsel ${username}: ${changed.status} ${await changed.text()}`); return { ...WRITE_HEADER, cookie: sessionCookie(changed) }; } const config = { async createApp(): Promise { process.env.LB_DATABASE_PATH = ":memory:"; process.env.LOG_LEVEL ??= "error"; delete process.env.AUTH_BOOTSTRAP_USERNAME; delete process.env.AUTH_BOOTSTRAP_PASSWORT; const { migrate } = await import("drizzle-orm/better-sqlite3/migrator"); const { db } = await import("../../src/db/client.js"); migrate(db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const { userService } = await import("../../src/server/composition/auth.js"); for (const username of TEST_USERS) { await userService.createUser({ username, name: username, role: "user", initialPassword: START_PASSWORD }); } const { createApp } = await import("../../src/server/app.js"); return createApp(); }, // Ohne Login erreichbar: Healthcheck und die offenen Pfade von createAuthGuard. publicRoutes: [ "GET /health", "GET /api/auth/status", "POST /api/auth/login", "POST /api/auth/logout", ] as string[], // Untergrenze geschützter Routen (Leerlauf-Sperre), Stand beim Einbau. minProtectedRoutes: 86, // Zwei Nutzer derselben Rolle ("user"). ownerHeaders: (baseUrl: string): Promise => signIn(baseUrl, TEST_USERS[0]), otherHeaders: (baseUrl: string): Promise => signIn(baseUrl, TEST_USERS[1]), // Gefälschte Identität: erfundenes Session-Cookie und Proxy-Header, aber mit Schreib-Header. spoofedHeaders: { ...WRITE_HEADER, cookie: "lb_session=gefaelschte-session", "x-remote-user": "admin", } as Headers, async createOwnedObject(baseUrl: string, headers: Headers): Promise> { const res = await fetch(`${baseUrl}/api/projects`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ name: "Testprojekt" }), }); if (res.status !== 201) throw new Error(`createOwnedObject: ${res.status} ${await res.text()}`); return (await res.json()) as Record; }, // BEWUSSTE LÜCKE (Design-Entscheidung D1 in docs/THREATMODEL.md, Julian, 17.10.2026): // Projekte haben keinen Besitzer, jeder angemeldete Nutzer sieht und bearbeitet alle // Projekte, weil die Software nur im eigenen Team läuft. Die IDOR-Tests laufen trotzdem // und zeigen den Ist-Zustand, zählen aber als TODO statt als Fehler. Bevor Externe ein // Konto bekommen: Besitz bzw. Freigabe pro Projekt einführen, diese Zeile entfernen, // dann müssen sie grün werden. idorGap: "Projekte sind für alle angemeldeten Nutzer freigegeben (kein Besitz pro Projekt)", idorCases: [ { method: "GET", path: "/api/projects/{id}" }, { method: "GET", path: "/api/projects/{id}/export" }, { method: "GET", path: "/api/projects/{id}/history" }, ] as Case[], listCases: [{ path: "/api/projects", key: "id" }], // Felder, die der Client nicht setzen darf. massAssignmentCases: [ { method: "POST", path: "/api/projects", body: { name: "x", id: "fremde-id" } }, { method: "POST", path: "/api/projects", body: { name: "x", revision: 99 } }, { method: "POST", path: "/api/projects", body: { name: "x", role: "admin" } }, ] as Case[], invalidInputCases: [ { method: "POST", path: "/api/projects", body: { name: 123 } }, { method: "POST", path: "/api/projects", body: {} }, { method: "POST", path: "/api/projects", body: "{kaputt" }, { method: "PUT", path: "/api/projects/{id}", body: {} }, { method: "PUT", path: "/api/projects/{id}", body: "{kaputt" }, ] as Case[], // Keine Cross-Origin-Freigabe: Browser erreichen die API nur same-origin über den // Next.js-Rewrite. Deshalb gibt es keine erlaubte Origin für die Gegenprobe. allowedOrigin: undefined as string | undefined, forbiddenOrigin: "https://evil.example.test", expectSecurityHeaders: true, }; export default config;