tsx runs tests without type checking and tsconfig.json covers only src, so 71 type errors had accumulated unnoticed. tsconfig.tests.json now checks every test and helper, and npm run typecheck includes it. Fixtures gain the fields the DTOs have grown since they were written (manualQuantity, circuit apparent power/cos phi, downstream board loads, section totals, building, cover and board info fields). Repository lookups that return "row ?? null" now declare the null in their type, which their callers already handle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
6.2 KiB
TypeScript
136 lines
6.2 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" },
|
|
] 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;
|