A board with its complete circuit list can be downloaded as a leistungsbilanz.distribution-board file and imported into any project as a new board. The import reuses distribution-board.insert-subtree, so it is one revision with restart-safe Undo/Redo. All ids are new; project-bound references (project devices with override metadata, room ids, floor, logical feed, a disabled supply type and the Revit quantity share) are dropped, circuit voltages follow the target project, and the response lists these adjustments. The project page offers export per board and import in the board card header. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
6.5 KiB
TypeScript
142 lines
6.5 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}/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;
|