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

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
PUBLIC_ALLOWLIST,
filePathToRoute,
findUndeclaredRoutes,
discoverAppRoutes,
} from "../support/route-scan";
import {
DECLARED_ROUTE_TEMPLATES,
ROUTES,
} from "../e2e/routes.manifest";
/**
* Driftschutz für das Routen-Manifest (Definition of Done #1): jede neue Route
* unter src/app/** muss im Manifest geführt (oder explizit öffentlich) sein,
* sonst bleibt sie ungetestet im Auth-Gating. Reine Logik, offline lauffähig.
*/
describe("filePathToRoute", () => {
it("mappt page.tsx einer Route-Group auf den URL-Pfad ohne Gruppe", () => {
expect(filePathToRoute("src/app/(app)/fahrzeuge/page.tsx")).toBe(
"/fahrzeuge",
);
});
it("mappt die Wurzel-Page der (app)-Gruppe auf /", () => {
expect(filePathToRoute("src/app/(app)/page.tsx")).toBe("/");
});
it("ersetzt dynamische Segmente durch einen Platzhalter", () => {
expect(filePathToRoute("src/app/(app)/fahrzeuge/[id]/page.tsx")).toBe(
"/fahrzeuge/[id]",
);
});
it("mappt route.ts auf den API-Pfad", () => {
expect(filePathToRoute("src/app/api/health/route.ts")).toBe("/api/health");
});
it("mappt die Root-Page src/app/page.tsx auf /", () => {
expect(filePathToRoute("src/app/page.tsx")).toBe("/");
});
it("löst catch-all-Segmente auf", () => {
expect(filePathToRoute("src/app/api/auth/[...nextauth]/route.ts")).toBe(
"/api/auth/[...nextauth]",
);
});
});
describe("findUndeclaredRoutes", () => {
it("flaggt eine Route, die weder im Manifest noch öffentlich ist", () => {
const declared = new Set(["/fahrzeuge"]);
const discovered = ["/fahrzeuge", "/leak"];
expect(findUndeclaredRoutes(discovered, declared)).toEqual(["/leak"]);
});
it("ignoriert Routen mit öffentlichem Präfix", () => {
const declared = new Set<string>();
const discovered = ["/login", "/api/health", "/api/auth/[...nextauth]"];
expect(findUndeclaredRoutes(discovered, declared)).toEqual([]);
});
it("PUBLIC_ALLOWLIST enthält /api/health und /login", () => {
expect(PUBLIC_ALLOWLIST).toContain("/api/health");
expect(PUBLIC_ALLOWLIST).toContain("/login");
});
});
describe("discoverAppRoutes (echtes Repo)", () => {
it("findet die bekannten Seiten", () => {
const routes = discoverAppRoutes();
expect(routes).toContain("/fahrzeuge");
expect(routes).toContain("/admin");
expect(routes).toContain("/api/health");
});
});
describe("Driftschutz: Manifest deckt alle Routen ab (Definition of Done #1)", () => {
it("KEINE Route unter src/app/** fehlt im Manifest oder in der Allowlist", () => {
const discovered = discoverAppRoutes();
const undeclared = findUndeclaredRoutes(
discovered,
DECLARED_ROUTE_TEMPLATES,
);
expect(
undeclared,
`Ungetestete Routen (im Manifest ergänzen oder als öffentlich markieren):\n${undeclared.join(
"\n",
)}`,
).toEqual([]);
});
it("jeder ROUTES-Eintrag entspricht einer existierenden Route-Vorlage", () => {
const discovered = new Set(discoverAppRoutes());
for (const template of DECLARED_ROUTE_TEMPLATES) {
expect(discovered, `Manifest-Eintrag ${template} fehlt im Dateisystem`).toContain(
template,
);
}
// Sanity: jede konkrete ROUTES-Zeile ist nicht leer.
for (const r of ROUTES) expect(r.path.startsWith("/")).toBe(true);
});
});