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:
@@ -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 1–3, 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;
|
||||
|
||||
Reference in New Issue
Block a user