Workstream 11: Tests & Sicherheitshärtung (Phase 7)

Beweist die Auth-Gating-Garantie und härtet das System ab (Definition of
Done #1, #2, #3, #7, #8):

- Routen-Manifest (tests/e2e/routes.manifest.ts) als einzige Quelle der
  Wahrheit; anonyme Seite -> Redirect /login, anonyme API -> 401.
- Kritische auth-gating.spec.ts: genau ein Fall je Manifest-Eintrag, ohne
  Daten-Leak.
- Driftschutz (routes.manifest.spec.ts + tests/unit/routes-manifest.test.ts):
  keine ungetestete neue Route unter src/app/**.
- Default-Deny-Beweis für Server Actions (server-actions-guard.spec.ts +
  tests/unit/server-actions-guard.test.ts): jede "use server"-Funktion ruft
  als erste Anweisung einen Guard; Login-Actions per Allowlist ausgenommen.
- Wiederverwendbare reine Scanner unter tests/support (route-scan, guard-scan)
  — offline lauffähig, in Vitest und Playwright geteilt.
- rbac-scoping, search-eta, login-ratelimit, security-headers Specs (gegen
  geseedeten Server; in der Sandbox deferred, per test.skip abgesichert).
- global-setup (Migration + Seed) und auth.setup (Login je Konto ->
  storageState); Playwright-Projekte setup -> chromium verdrahtet.
- src/lib/security/headers.test.ts: statischer Beleg für CSP, HSTS,
  X-Frame-Options DENY, nosniff, Permissions-Policy.
- vitest.config.ts: Coverage-Schwellen (>=90 %) für src/lib/search + src/lib/geo.
- package.json: Scripts test:unit, test:coverage, test:e2e, test:e2e:gating.
- docs/reference/sicherheitshaertung-checkliste.md: jeder Härtungspunkt mit
  Test/Befehl und Negativ-Probe.

Offline verifiziert: tsc --noEmit (0), vitest run (229 passed / 7 db-skipped),
drizzle-kit check (ok), next build (exit 0), next lint (0 Fehler),
playwright --list (98 Tests, 15 Dateien). DB-/Server-/Browser-abhängige
E2E-Läufe sind deferred (kein Postgres/Server in der Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 14:17:10 +02:00
parent 9927711192
commit c099b3acd9
19 changed files with 957 additions and 52 deletions

View File

@@ -1,79 +1,77 @@
import { test, expect } from "@playwright/test";
import { ROUTES } from "./routes.manifest";
/**
* Auth-Gating-Garantie (Definition of Done, oberstes Prinzip).
* KRITISCHE Auth-Gating-Suite (Definition of Done #1, oberstes Prinzip).
*
* Erzeugt GENAU EINEN Testfall pro Manifest-Eintrag (ROUTES.length):
* - Seiten -> Redirect auf /login (mit callbackUrl), kein Daten-Leak.
* - API -> 401 ohne Fachdaten im Body.
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
*
* Kerngarantie (Querschnittsstandard 13, default-deny dreifach):
* - Anonyme Aufrufe von Seiten -> Redirect auf /login (mit callbackUrl).
* - Anonyme Aufrufe von API-Routen -> 401 OHNE Daten-Leak.
* - Öffentliche Routen (Login, Health) bleiben erreichbar.
*
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus
* `(app)/layout.tsx` muss diese Suite rot machen.
* Negativ-Probe (CI): Entfernen eines Layout-Guards oder einer Manifest-Route
* muss diese Suite rot machen.
*/
// Geschützte Seiten (Redirect-Manifest). Neue Seiten hier ergänzen.
const PROTECTED_PAGES = [
"/",
"/start",
"/fahrzeuge",
"/fahrzeuge/00000000-0000-0000-0000-000000000001",
"/geraete/00000000-0000-0000-0000-000000000002",
"/wehren/00000000-0000-0000-0000-000000000003",
"/geraete",
"/wehren",
"/verwaltung",
"/admin",
// Erzwingt anonymen Zustand: keine gespeicherte Session.
test.use({ storageState: { cookies: [], origins: [] } });
// Fachbegriffe, die in einem 401/Redirect-Body NIE auftauchen dürfen.
const LEAK_TERMS = [
"funkrufname",
"wehrfuehrer",
"einsatzbereit",
"passwort",
"kennzeichen",
];
// Geschützte API-Routen (401-Manifest). Neue API-Routen hier ergänzen.
const PROTECTED_API = ["/api/fahrzeuge", "/api/geraete", "/api/verwaltung"];
function assertNoLeak(body: string) {
const lower = body.toLowerCase();
for (const term of LEAK_TERMS) {
expect(lower, `Daten-Leak: Body enthält "${term}"`).not.toContain(term);
}
}
// Öffentliche Routen (Middleware-Allowlist).
const PUBLIC_ROUTES = ["/login", "/api/health"];
test.describe("Default-deny: geschützte Seiten", () => {
for (const path of PROTECTED_PAGES) {
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
for (const route of ROUTES) {
if (route.expectWhenAnon === "redirect") {
test(`Seite ${route.path}: anonym -> Redirect auf /login`, async ({
page,
}) => {
await page.goto(path);
const response = await page.goto(route.path);
await expect(page).toHaveURL(/\/login/);
// callbackUrl bewahrt das ursprüngliche Ziel.
expect(page.url()).toContain("callbackUrl");
const body = await page.content();
assertNoLeak(body);
// Kein 500 o. ä.
if (response) expect(response.status()).toBeLessThan(500);
});
}
});
test.describe("Default-deny: geschützte API-Routen", () => {
for (const path of PROTECTED_API) {
test(`anonymer Aufruf von ${path} liefert 401 ohne Daten-Leak`, async ({
} else {
test(`API ${route.path}: anonym -> 401 ohne Daten-Leak`, async ({
request,
}) => {
const res = await request.get(path);
// /api/geo/geocode ist POST-only; health ist GET. Beide gehen durch apiAuth().
const res = route.path.includes("geocode")
? await request.post(route.path, { data: { address: "x" } })
: await request.get(route.path);
expect(res.status()).toBe(401);
const body = await res.text();
// Kein Daten-Leak: nur eine generische Fehlermeldung.
expect(body).not.toContain("brigade");
expect(body).not.toContain("passwort");
assertNoLeak(await res.text());
});
}
});
}
test.describe("Öffentliche Routen bleiben erreichbar", () => {
test("Login-Seite ist anonym erreichbar", async ({ page }) => {
await page.goto("/login");
await expect(
page.getByRole("heading", { name: "Anmelden" }),
).toBeVisible();
test.describe("Öffentliche Routen bleiben anonym erreichbar", () => {
test("Login-Seite ist anonym 200", async ({ page }) => {
const res = await page.goto("/login");
expect(res?.status()).toBeLessThan(400);
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
});
test("Health-Check ist anonym 200", async ({ request }) => {
test("Container-Health ist anonym 200", async ({ request }) => {
const res = await request.get("/api/health");
expect(res.status()).toBe(200);
expect(await res.json()).toEqual({ status: "ok" });
});
});
void PUBLIC_ROUTES;