leistungsbilanz-ts/tests/security/security.config.ts
Julian Appel fd45a1caeb Add atomic Revit assignment-plan command and route
- external-object.apply-assignment-plan creates planned circuits (BMK,
  default protection), external-only rows and quantity changes with all
  object links in one revision; the history-only revert removes only
  unchanged created structure.
- POST /external-model/objects/assignment-plan validates strictly with
  zod, re-checks rows with the shared planning rules and requires explicit
  confirmation of planning conflicts and socket-limit overruns.
- The compact object list carries the socket limit and socket device types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:28:56 +01:00

143 lines
6.6 KiB
TypeScript

/**
* 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 type { Express } from "express";
import type { collectRoutes } from "./route-inventory.ts";
// Express 5 typisiert app.router nur als Router; zur Laufzeit trägt er den
// Layer-Stack, den das generische Routen-Inventar durchläuft.
type InventoriedExpress = Express & Parameters<typeof collectRoutes>[0];
type Headers = Record<string, string>;
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<Headers> {
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<InventoriedExpress> {
process.env.LB_DATABASE_PATH = ":memory:";
process.env.LOG_LEVEL ??= "error";
delete process.env.AUTH_BOOTSTRAP_USERNAME;
delete process.env.AUTH_BOOTSTRAP_PASSWORT;
const { db } = await import("../../src/db/client.js");
const { migrateDatabase } = await import("../support/migrate-database.js");
migrateDatabase(db);
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() as InventoriedExpress;
},
// 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<Headers> => signIn(baseUrl, TEST_USERS[0]),
otherHeaders: (baseUrl: string): Promise<Headers> => 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<Record<string, unknown>> {
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<string, unknown>;
},
// 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" },
{ method: "POST", path: "/api/projects/{id}/distribution-boards/import", body: {} },
{ method: "POST", path: "/api/projects/{id}/external-model/objects/assignment-plan", body: {} },
{
method: "POST",
path: "/api/projects/{id}/distribution-boards/import",
body: { expectedRevision: 0, transfer: { format: "fremd" } },
},
] 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;