diff --git a/docs/reference/sicherheitshaertung-checkliste.md b/docs/reference/sicherheitshaertung-checkliste.md new file mode 100644 index 0000000..1e79675 --- /dev/null +++ b/docs/reference/sicherheitshaertung-checkliste.md @@ -0,0 +1,47 @@ +# Sicherheitshärtung — Checkliste mit Verifikation + +Jeder Punkt der Härtung ist durch genau einen Test oder Befehl belegbar +(Definition of Done #8). Befehle, die einen laufenden Server oder eine +erreichbare Datenbank benötigen, sind als **(server/db)** markiert und in der +Sandbox **deferred**; ihre statisch prüfbare Grundlage ist jeweils zusätzlich +durch einen Offline-Unit-Test abgesichert. + +| # | Härtungspunkt | Verifikation (Test / Befehl) | Sandbox | +|---|---|---|---| +| 1 | **Auth-Gating (oberstes Prinzip).** Jede Seite → Redirect `/login` mit `callbackUrl`; jede API → `401` ohne Daten-Leak. | `npm run test:e2e:gating` (genau ein Fall je `ROUTES`-Eintrag). | deferred (server/db) | +| 2 | **Driftschutz Routen.** Keine ungetestete neue Route unter `src/app/**`. | `npx vitest run tests/unit/routes-manifest.test.ts` (offline). Negativ-Probe: `src/app/(app)/leak/page.tsx` → rot. | offline | +| 3 | **Default-Deny Server Actions.** Jede `"use server"`-Funktion ruft als erste Anweisung einen Guard. | `npx vitest run tests/unit/server-actions-guard.test.ts` (offline). Negativ-Probe: einen Guard entfernen → rot. | offline | +| 4 | **Rollen-/Wehr-Scoping.** `wehr_read` schreibt nicht (403); `wehr_admin` A ändert Wehr B nicht (403/404); eigene Ressource (200). | `npm run test:e2e -- rbac-scoping.spec.ts`. | deferred (server/db) | +| 5 | **Security-Header.** `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, CSP `frame-ancestors 'none'` + `form-action 'self'`, HSTS. | Offline: `npx vitest run src/lib/security/headers.test.ts`. Live: `curl -sI https:///login \| grep -i x-frame-options` → `DENY`; bzw. `npm run test:e2e -- security-headers.spec.ts`. | offline + deferred | +| 6 | **CSP in der App verdrahtet.** `SECURITY_HEADERS` ist in `next.config.ts` (`headers()`) eingehängt. | `npm run build` (Header-Konfiguration wird validiert); Live-Beleg via security-headers.spec.ts. | offline (build) | +| 7 | **Session-Cookie-Flags.** `httpOnly`, `sameSite=lax`; `secure` + `__Secure-`-Präfix nur unter `https://` (Querschnittsstandard 9). | `npm run test:e2e -- security-headers.spec.ts` (Cookie-Assertion). | deferred (server/db) | +| 8 | **argon2id OWASP-Minima.** `type=argon2id` (2), `memoryCost ≥ 19456`, `timeCost ≥ 2`, `parallelism ≥ 1`; Hash beginnt mit `$argon2id$`; Roundtrip. | `npx vitest run src/lib/auth/__tests__/password.test.ts` (offline). | offline | +| 9 | **Login-Rate-Limit im `authorize`-Pfad.** 5 Fehlversuche / 15 min pro Key; Drosselung ab Versuch 6. | Offline-Policy: `npx vitest run src/lib/auth/__tests__/rate-limit.test.ts`. Live: `npm run test:e2e -- login-ratelimit.spec.ts`. | offline + deferred | +| 10 | **CSRF.** State-Changing `POST` ohne gültiges CSRF-Token erzeugt keine Session (Auth.js-CSRF im Credentials-Flow). | Live: `POST /api/auth/callback/credentials` ohne `csrfToken` → keine Session; `GET /api/auth/session` liefert leeres Objekt. | deferred (server) | +| 11 | **Audit-Logging.** Schreib-Aktionen schreiben `audit_log` (eine `writeAudit`-Signatur, optionaler `tx`). Nach `merkmal.promote` existiert eine Zeile. | Live: `select aktion, ziel_typ from audit_log order by zeitpunkt desc limit 1` → `merkmal.promote \| merkmal`. Offline: Server-Action-Unit-Tests prüfen `writeAudit`-Aufruf. | deferred (db) + offline | +| 12 | **API gibt 401/403, kein HTML-Redirect; kein Daten-Leak.** | `npm run test:e2e:gating` (API-Fälle: `expect(status).toBe(401)`, Body ohne Fachbegriffe). | deferred (server) | +| 13 | **`/api/health` anonym 200 (Allowlist).** | Live: `curl -s https:///api/health` → `{"status":"ok"}`. Offline: `tests/unit/routes-manifest.test.ts` belegt `/api/health` in `PUBLIC_ALLOWLIST`. | offline + deferred | +| 14 | **argon2 nicht im Edge-/Middleware-Pfad.** `@node-rs/argon2` wird nur server-seitig importiert. | `npm run build` (Edge-Bundle bricht sonst); Code-Review von `middleware.ts`. | offline (build) | + +## Negativ-Proben (Beweis, dass die Tests greifen) + +- **Layout-Guard entfernen** (z. B. `await requireSession()` aus + `src/app/(app)/layout.tsx`): `test:e2e:gating` wird rot (Seiten erreichbar). +- **Manifest-Route entfernen**: Driftschutz `routes-manifest.test.ts` wird rot. +- **Server-Action-Guard entfernen**: `server-actions-guard.test.ts` wird rot. +- **Route ohne Manifest-Eintrag anlegen** (`src/app/(app)/leak/page.tsx`): + Driftschutz rot; nach Entfernen wieder grün. + +## Offline vs. deferred (Sandbox-Hinweis) + +In dieser Umgebung gibt es **kein** Postgres und **keinen** laufenden Server. +Verifiziert wurden daher ausschließlich die Offline-Belege: + +- `npx tsc --noEmit` (Typprüfung inkl. aller Tests). +- `npx vitest run` (alle reinen Unit-Tests; DB-Roundtrips werden bewusst + übersprungen). +- `npm run build` (Next.js-Standalone-Build inkl. Header-Verdrahtung). + +Die mit **(server/db)** markierten E2E-Punkte werden im CI bzw. lokal gegen +einen geseedeten Server über `npm run test:e2e` / `npm run test:e2e:gating` +ausgeführt. diff --git a/package.json b/package.json index e8025f9..4ccbd16 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,14 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:unit": "vitest run", + "test:coverage": "vitest run --coverage", "db:generate": "drizzle-kit generate", "db:migrate": "tsx scripts/migrate.ts", "db:seed-auth": "tsx scripts/seed-auth.ts", "db:seed": "tsx src/db/seed/index.ts", - "test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts", + "test:e2e": "playwright test", + "test:e2e:gating": "playwright test --project=chromium tests/e2e/auth-gating.spec.ts", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:check": "drizzle-kit check" diff --git a/playwright.config.ts b/playwright.config.ts index 2602b34..c52687a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,12 +13,22 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? "github" : "list", + // Migration + deterministischer Seed (deferred ohne DATABASE_URL). + globalSetup: "./tests/e2e/global-setup.ts", use: { baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000", trace: "on-first-retry", }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + // 1. Echter Login je Konto -> storageState (tests/e2e/.auth/*.json). + { name: "setup", testMatch: /fixtures\/auth\.setup\.ts/ }, + // 2. Eigentliche Suiten; hängen vom Login-Setup ab. + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + testIgnore: /fixtures\/auth\.setup\.ts/, + dependencies: ["setup"], + }, ], webServer: process.env.E2E_BASE_URL ? undefined diff --git a/src/lib/security/headers.test.ts b/src/lib/security/headers.test.ts new file mode 100644 index 0000000..1c42d7b --- /dev/null +++ b/src/lib/security/headers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { SECURITY_HEADERS } from "./headers"; + +/** + * Security-Header (Definition of Done #8, Querschnittsstandard 1). Offline + * lauffähig: prüft den statischen Header-Satz, der in next.config.ts + * eingehängt wird. Die HTTP-seitige Verifikation (curl gegen Live-Server) ist + * deferred (kein Server in der Sandbox) und in security-headers.spec.ts gegen + * einen laufenden Server abgedeckt. + */ +describe("SECURITY_HEADERS", () => { + it("setzt X-Frame-Options auf DENY", () => { + expect(SECURITY_HEADERS["X-Frame-Options"]).toBe("DENY"); + }); + + it("setzt X-Content-Type-Options auf nosniff", () => { + expect(SECURITY_HEADERS["X-Content-Type-Options"]).toBe("nosniff"); + }); + + it("setzt HSTS mit includeSubDomains", () => { + const hsts = SECURITY_HEADERS["Strict-Transport-Security"]; + expect(hsts).toMatch(/max-age=\d+/); + expect(hsts).toContain("includeSubDomains"); + }); + + it("erlaubt Geolocation nur für self (Permissions-Policy)", () => { + expect(SECURITY_HEADERS["Permissions-Policy"]).toContain( + "geolocation=(self)", + ); + }); + + it("hat eine CSP mit default-src 'self', frame-ancestors 'none', form-action 'self'", () => { + const csp = SECURITY_HEADERS["Content-Security-Policy"]; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("form-action 'self'"); + }); + + it("erlaubt img-src self/data/blob und worker-src self/blob (für Karten/Web-Worker)", () => { + const csp = SECURITY_HEADERS["Content-Security-Policy"]; + expect(csp).toContain("img-src 'self' data: blob:"); + expect(csp).toContain("worker-src 'self' blob:"); + }); + + it("setzt eine Referrer-Policy", () => { + expect(SECURITY_HEADERS["Referrer-Policy"]).toBeTruthy(); + }); +}); diff --git a/tests/e2e/auth-gating.spec.ts b/tests/e2e/auth-gating.spec.ts index b9f68ad..ddf6170 100644 --- a/tests/e2e/auth-gating.spec.ts +++ b/tests/e2e/auth-gating.spec.ts @@ -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; diff --git a/tests/e2e/fixtures/auth.setup.ts b/tests/e2e/fixtures/auth.setup.ts new file mode 100644 index 0000000..0415490 --- /dev/null +++ b/tests/e2e/fixtures/auth.setup.ts @@ -0,0 +1,63 @@ +import { test as setup, expect } from "@playwright/test"; +import path from "node:path"; + +/** + * Auth-Setup (Plan WS11 Aufgabe 3): echter Credentials-Login je Konto -> + * storageState. Wird als Playwright-Projekt "setup" ausgeführt; die übrigen + * Projekte hängen davon ab und laden den jeweiligen storageState. + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. + * + * Erzeugt vier Storage-States passend zu den vier Seed-Konten: + * - platform_admin + * - wehr_admin (Wehr A) + * - wehr_admin (Wehr B) + * - wehr_read (Wehr A) + */ +export const AUTH_DIR = path.join(process.cwd(), "tests/e2e/.auth"); + +interface Account { + email: string; + file: string; + envVar: string; +} + +const PASSWORD = process.env.E2E_TEST_PASSWORD ?? "Test-Passwort-1234"; + +const ACCOUNTS: Account[] = [ + { + email: "platform-admin@example.test", + file: "platform-admin.json", + envVar: "E2E_PLATFORM_ADMIN_STATE", + }, + { + email: "wehr-admin-a@example.test", + file: "wehr-admin-a.json", + envVar: "E2E_WEHR_ADMIN_STATE", + }, + { + email: "wehr-admin-b@example.test", + file: "wehr-admin-b.json", + envVar: "E2E_WEHR_ADMIN_B_STATE", + }, + { + email: "wehr-read-a@example.test", + file: "wehr-read-a.json", + envVar: "E2E_WEHR_READ_STATE", + }, +]; + +for (const account of ACCOUNTS) { + setup(`Login ${account.email}`, async ({ page }) => { + await page.goto("/login"); + await page.getByLabel(/E-Mail/i).fill(account.email); + await page.getByLabel(/Passwort/i).fill(PASSWORD); + await page.getByRole("button", { name: /Anmelden/i }).click(); + // Erfolgreicher Login verlässt /login. + await page.waitForURL((url) => !url.pathname.startsWith("/login")); + await expect(page).not.toHaveURL(/\/login/); + await page + .context() + .storageState({ path: path.join(AUTH_DIR, account.file) }); + }); +} diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..65f2de8 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,36 @@ +import { execSync } from "node:child_process"; + +/** + * Playwright Global-Setup (Definition of Done #1, Plan WS11 Aufgabe 2). + * + * NICHT in der Sandbox ausführbar (kein Postgres) — deferred. Läuft im CI/lokal + * gegen eine erreichbare DB: + * 1. Migrationen anwenden (idempotent). + * 2. Deterministischen Seed laden (Katalog) + Test-Fixtures (Wehren A/B mit + * Koordinaten, je Fahrzeug/Gerät mit festen UUIDs, vier Benutzer mit + * argon2id-Test-Passwort). + * + * Wird nur ausgeführt, wenn KEIN externer E2E_BASE_URL gesetzt ist (dann ist die + * Ziel-Umgebung bereits provisioniert) und DATABASE_URL existiert. + */ +async function globalSetup(): Promise { + if (process.env.E2E_BASE_URL) { + // Externe Umgebung: bereits provisioniert, nichts tun. + return; + } + if (!process.env.DATABASE_URL) { + console.warn( + "[global-setup] DATABASE_URL fehlt — Migration/Seed übersprungen (deferred).", + ); + return; + } + const run = (cmd: string) => + execSync(cmd, { stdio: "inherit", env: process.env }); + + run("npm run db:migrate"); + run("npm run db:seed"); + // Deterministische E2E-Fixtures (vier Konten + Wehren A/B + feste Asset-UUIDs). + run("npm run db:seed-auth"); +} + +export default globalSetup; diff --git a/tests/e2e/login-ratelimit.spec.ts b/tests/e2e/login-ratelimit.spec.ts new file mode 100644 index 0000000..7b81cda --- /dev/null +++ b/tests/e2e/login-ratelimit.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test"; + +/** + * Login-Rate-Limit (Definition of Done #8, Querschnittsstandard 8). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über + * `npm run test:e2e` gegen einen laufenden, geseedeten Server ausgeführt. + * + * Beweist: Der Rate-Limit greift im `authorize`-Callback (5 Fehlversuche / + * 15 min, src/lib/auth/rate-limit.ts) und damit auf dem Pfad, der über die + * Credentials-Login-Action (loginAction) tatsächlich durchläuft. Ab dem 6. + * Versuch wird gedrosselt; login_attempts.fail >= 5. + */ +test.use({ storageState: { cookies: [], origins: [] } }); + +test("7x falsches Passwort -> Drosselung ab Versuch 6", async ({ page }) => { + const email = "wehr-admin-a@example.test"; + for (let attempt = 1; attempt <= 7; attempt++) { + await page.goto("/login"); + await page.getByLabel(/E-Mail/i).fill(email); + await page.getByLabel(/Passwort/i).fill(`falsch-${attempt}`); + await page.getByRole("button", { name: /Anmelden/i }).click(); + + // Bleibt auf /login (kein erfolgreicher Login). + await expect(page).toHaveURL(/\/login/); + const text = await page.locator("body").innerText(); + if (attempt >= 6) { + // Drosselung: generische Fehlermeldung, weiterhin kein Zugang. + expect(text.toLowerCase()).toMatch(/fehlgeschlagen|zu viele|gesperrt|versuch/); + } + } +}); diff --git a/tests/e2e/rbac-scoping.spec.ts b/tests/e2e/rbac-scoping.spec.ts new file mode 100644 index 0000000..5cf8f69 --- /dev/null +++ b/tests/e2e/rbac-scoping.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test"; + +/** + * Rollen-/Wehr-Scoping (Definition of Done #3, Plan WS11 Aufgabe 6 / Verifikation + * 6). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über + * `npm run test:e2e` gegen einen geseedeten Server mit Storage-States aus dem + * Auth-Setup ausgeführt. + * + * Garantien: + * - wehr_read kann NICHT schreiben (Status-Änderung -> 403/forbidden). + * - wehr_admin A kann Wehr B NICHT ändern (fremdes Asset -> 403/404, + * Datensatz bleibt unverändert). + * - eigene Ressource: wehr_admin A kann Status setzen (-> 200, status='wartung'). + * + * Storage-States kommen aus tests/e2e/fixtures/auth.setup.ts. Feste Asset-UUIDs + * stammen aus dem deterministischen Seed (global-setup.ts). + */ + +const VEHICLE_A = process.env.E2E_VEHICLE_A_ID ?? ""; +const VEHICLE_B = process.env.E2E_VEHICLE_B_ID ?? ""; + +test.describe("wehr_read darf nicht schreiben", () => { + test.skip(!process.env.E2E_WEHR_READ_STATE, "benötigt wehr_read-Fixture"); + test.use({ storageState: process.env.E2E_WEHR_READ_STATE }); + + test("Aufruf der Verwaltungs-Schreibseite -> 403", async ({ page }) => { + const res = await page.goto("/verwaltung/fahrzeuge/neu"); + expect(res?.status()).toBe(403); + }); +}); + +test.describe("wehr_admin A darf Wehr B nicht ändern", () => { + test.skip( + !process.env.E2E_WEHR_ADMIN_STATE || !VEHICLE_B, + "benötigt wehr_admin-A-Fixture + Wehr-B-Fahrzeug-ID", + ); + test.use({ storageState: process.env.E2E_WEHR_ADMIN_STATE }); + + test("fremdes Fahrzeug (Wehr B) -> 404, unverändert", async ({ page }) => { + const res = await page.goto(`/verwaltung/fahrzeuge/${VEHICLE_B}`); + expect(res?.status()).toBe(404); + }); +}); + +test.describe("wehr_admin A darf eigenes Fahrzeug ändern", () => { + test.skip( + !process.env.E2E_WEHR_ADMIN_STATE || !VEHICLE_A, + "benötigt wehr_admin-A-Fixture + Wehr-A-Fahrzeug-ID", + ); + test.use({ storageState: process.env.E2E_WEHR_ADMIN_STATE }); + + test("eigenes Fahrzeug ist erreichbar (200) und editierbar", async ({ + page, + }) => { + const res = await page.goto(`/verwaltung/fahrzeuge/${VEHICLE_A}`); + expect(res?.status()).toBeLessThan(400); + // Status auf 'wartung' setzen (Verifikation 6: eigenes -> 200). + await page.getByLabel(/Status/i).selectOption("wartung"); + await page.getByRole("button", { name: /Speichern/i }).click(); + await expect(page.getByText(/gespeichert|wartung/i)).toBeVisible(); + }); +}); diff --git a/tests/e2e/routes.manifest.spec.ts b/tests/e2e/routes.manifest.spec.ts new file mode 100644 index 0000000..8d75a11 --- /dev/null +++ b/tests/e2e/routes.manifest.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "@playwright/test"; +import { discoverAppRoutes, findUndeclaredRoutes } from "../support/route-scan"; +import { DECLARED_ROUTE_TEMPLATES } from "./routes.manifest"; + +/** + * Driftschutz (Definition of Done #1): verhindert ungetestete neue Routen. + * + * STATISCHER Check — braucht weder Server noch DB; lauffähig offline. Die + * identische Logik ist zusätzlich als Vitest-Unit-Test + * (tests/unit/routes-manifest.test.ts) abgesichert. + * + * Negativ-Probe: Eine neue Route src/app/(app)/leak/page.tsx ohne + * Manifest-Eintrag macht diesen Test rot; Entfernen -> grün. + */ +test("jede Route unter src/app ist im Manifest oder öffentlich", () => { + 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([]); +}); diff --git a/tests/e2e/routes.manifest.ts b/tests/e2e/routes.manifest.ts new file mode 100644 index 0000000..42c0a19 --- /dev/null +++ b/tests/e2e/routes.manifest.ts @@ -0,0 +1,110 @@ +/** + * Kanonisches Routen-Manifest für die Auth-Gating-Garantie (Definition of + * Done #1, Querschnittsstandard 1–3). + * + * EINZIGE Quelle der Wahrheit darüber, welche Routen geschützt sind und wie ein + * ANONYMER Aufruf sich verhalten muss: + * - Seiten -> "redirect" (auf /login, mit callbackUrl) + * - APIs -> "401" (ohne Daten-Leak) + * + * Der Driftschutz (routes.manifest.spec.ts + tests/unit/routes-manifest.test.ts) + * stellt sicher, dass jede neue Route unter src/app/** entweder hier steht oder + * in PUBLIC_ALLOWLIST. Ungetestete Routen sind damit ausgeschlossen. + * + * Beispiel-UUIDs für dynamische Segmente: anonyme Aufrufe werden VOR jeder + * DB-Abfrage abgewiesen, daher müssen diese IDs nicht existieren. + */ +export { PUBLIC_ALLOWLIST } from "../support/route-scan"; + +export type AnonExpectation = "redirect" | "401"; + +export interface RouteEntry { + /** Konkreter URL-Pfad (dynamische Segmente bereits aufgelöst). */ + path: string; + /** Erwartetes Verhalten bei anonymem Zugriff. */ + expectWhenAnon: AnonExpectation; + /** true für API-Routen (request statt page-Navigation). */ + api?: boolean; +} + +const EX_VEHICLE = "00000000-0000-0000-0000-0000000000a1"; +const EX_EQUIP = "00000000-0000-0000-0000-0000000000a2"; +const EX_BRIGADE = "00000000-0000-0000-0000-0000000000a3"; +const EX_TEMPLATE = "00000000-0000-0000-0000-0000000000a4"; +const EX_CATEGORY = "00000000-0000-0000-0000-0000000000a5"; + +export const ROUTES: readonly RouteEntry[] = [ + // (app) – Lese-Oberflächen + { path: "/", expectWhenAnon: "redirect" }, + { path: "/start", expectWhenAnon: "redirect" }, + { path: "/fahrzeuge", expectWhenAnon: "redirect" }, + { path: `/fahrzeuge/${EX_VEHICLE}`, expectWhenAnon: "redirect" }, + { path: "/geraete", expectWhenAnon: "redirect" }, + { path: `/geraete/${EX_EQUIP}`, expectWhenAnon: "redirect" }, + { path: "/wehren", expectWhenAnon: "redirect" }, + { path: `/wehren/${EX_BRIGADE}`, expectWhenAnon: "redirect" }, + // (app)/verwaltung – Wehr-Bereich + { path: "/verwaltung/benutzer", expectWhenAnon: "redirect" }, + { path: "/verwaltung/profil", expectWhenAnon: "redirect" }, + { path: "/verwaltung/fahrzeuge", expectWhenAnon: "redirect" }, + { path: "/verwaltung/fahrzeuge/neu", expectWhenAnon: "redirect" }, + { path: `/verwaltung/fahrzeuge/${EX_VEHICLE}`, expectWhenAnon: "redirect" }, + { path: "/verwaltung/geraete", expectWhenAnon: "redirect" }, + { path: "/verwaltung/geraete/neu", expectWhenAnon: "redirect" }, + { path: `/verwaltung/geraete/${EX_EQUIP}`, expectWhenAnon: "redirect" }, + // (admin) – Plattform-Verwaltung + { path: "/admin", expectWhenAnon: "redirect" }, + { path: "/admin/audit", expectWhenAnon: "redirect" }, + { path: "/admin/merkmale", expectWhenAnon: "redirect" }, + { path: "/admin/merkmale/proposals", expectWhenAnon: "redirect" }, + { path: "/admin/vorlagen", expectWhenAnon: "redirect" }, + { path: `/admin/vorlagen/${EX_TEMPLATE}`, expectWhenAnon: "redirect" }, + { path: "/admin/geraete-kategorien", expectWhenAnon: "redirect" }, + { + path: `/admin/geraete-kategorien/${EX_CATEGORY}`, + expectWhenAnon: "redirect", + }, + { path: "/admin/wehren", expectWhenAnon: "redirect" }, + { path: "/admin/wehren/neu", expectWhenAnon: "redirect" }, + { path: `/admin/wehren/${EX_BRIGADE}`, expectWhenAnon: "redirect" }, + // APIs (kein /api/auth, /api/health -> öffentlich/Allowlist) + { path: "/api/geo/geocode", expectWhenAnon: "401", api: true }, + { path: "/api/geo/health", expectWhenAnon: "401", api: true }, +]; + +/** + * Die Routen-Vorlagen (dynamische Segmente als Platzhalter), wie sie im + * Dateisystem erscheinen. Wird vom Driftschutz mit discoverAppRoutes() + * abgeglichen. + */ +export const DECLARED_ROUTE_TEMPLATES: ReadonlySet = new Set([ + "/", + "/start", + "/fahrzeuge", + "/fahrzeuge/[id]", + "/geraete", + "/geraete/[id]", + "/wehren", + "/wehren/[id]", + "/verwaltung/benutzer", + "/verwaltung/profil", + "/verwaltung/fahrzeuge", + "/verwaltung/fahrzeuge/neu", + "/verwaltung/fahrzeuge/[id]", + "/verwaltung/geraete", + "/verwaltung/geraete/neu", + "/verwaltung/geraete/[id]", + "/admin", + "/admin/audit", + "/admin/merkmale", + "/admin/merkmale/proposals", + "/admin/vorlagen", + "/admin/vorlagen/[id]", + "/admin/geraete-kategorien", + "/admin/geraete-kategorien/[id]", + "/admin/wehren", + "/admin/wehren/neu", + "/admin/wehren/[id]", + "/api/geo/geocode", + "/api/geo/health", +]); diff --git a/tests/e2e/search-eta.spec.ts b/tests/e2e/search-eta.spec.ts new file mode 100644 index 0000000..d0c723c --- /dev/null +++ b/tests/e2e/search-eta.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; + +/** + * Suche & Eintreffzeit-Sortierung (Definition of Done #6, Plan WS11 Aufgabe 7 / + * Verifikation 7). + * + * NICHT in der Sandbox ausführbar (kein Server/DB/OSRM) — deferred. Wird über + * `npm run test:e2e` gegen einen geseedeten Server mit authentifizierter + * Session ausgeführt. + * + * Garantien: + * - Dynamische Filter (UND-verknüpft) liefern korrekte Teilmengen. + * - Treffer sind nach ETA aufsteigend sortiert. + * - OSRM-Ausfall (E2E_FORCE_HAVERSINE=1) -> sichtbarer "Luftlinie"-Fallback. + */ +test.skip( + !process.env.E2E_AUTH_STATE && !process.env.E2E_WEHR_READ_STATE, + "benötigt authentifizierte Session (Auth-Setup)", +); +test.use({ + storageState: + process.env.E2E_AUTH_STATE ?? process.env.E2E_WEHR_READ_STATE ?? undefined, +}); + +test("Filter grenzt Treffer ein (UND-Verknüpfung)", async ({ page }) => { + await page.goto("/fahrzeuge"); + const before = await page.getByText(/Treffer/).first().innerText(); + await page.getByLabel("Nur einsatzbereit").click(); + await expect(page).toHaveURL(/bereit=1/); + const after = await page.getByText(/Treffer/).first().innerText(); + // Teilmenge: Anzahl sinkt nicht und Filter ist in der URL aktiv. + expect(after).not.toBe(before); +}); + +test("Treffer mit Standort sind aufsteigend nach ETA sortiert", async ({ + page, +}) => { + await page.goto("/fahrzeuge?ort=St.+P%C3%B6lten"); + await page.waitForLoadState("networkidle"); + const etaTexts = await page + .locator("[data-testid='eta-minutes']") + .allInnerTexts(); + const minutes = etaTexts.map((t) => parseInt(t.replace(/\D/g, ""), 10)); + const sorted = [...minutes].sort((a, b) => a - b); + expect(minutes).toEqual(sorted); +}); + +test("OSRM-Ausfall zeigt Luftlinie-Fallback", async ({ page }) => { + test.skip( + process.env.E2E_FORCE_HAVERSINE !== "1", + "nur mit E2E_FORCE_HAVERSINE=1", + ); + await page.goto("/fahrzeuge?ort=St.+P%C3%B6lten"); + await expect(page.getByText(/Luftlinie/i).first()).toBeVisible(); +}); diff --git a/tests/e2e/security-headers.spec.ts b/tests/e2e/security-headers.spec.ts new file mode 100644 index 0000000..086e359 --- /dev/null +++ b/tests/e2e/security-headers.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "@playwright/test"; + +/** + * Security-Header & Cookie-Flags (Definition of Done #8, Querschnittsstandard + * 1, 9, 11). + * + * NICHT in der Sandbox ausführbar (kein Server) — deferred. Wird über + * `npm run test:e2e` gegen einen laufenden Server ausgeführt. Der statische + * Header-Satz ist zusätzlich offline durch src/lib/security/headers.test.ts + * abgesichert. + * + * Verifikation entspricht: `curl -sI https:///login | grep -i x-frame-options`. + */ +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("Security-Header auf /login", () => { + test("setzt X-Frame-Options, nosniff, CSP frame-ancestors none, HSTS", async ({ + request, + }) => { + const res = await request.get("/login"); + const h = res.headers(); + expect(h["x-frame-options"]).toBe("DENY"); + expect(h["x-content-type-options"]).toBe("nosniff"); + expect(h["content-security-policy"]).toContain("frame-ancestors 'none'"); + expect(h["strict-transport-security"]).toMatch(/max-age=\d+/); + }); +}); + +test.describe("Session-Cookie-Flags", () => { + test("nach Login: Session-Cookie ist httpOnly + sameSite", async ({ + context, + page, + }) => { + // Erwartet einen funktionierenden Credentials-Login (Seed). Deferred. + await page.goto("/login"); + await page.getByLabel(/E-Mail/i).fill("wehr-admin-a@example.test"); + await page.getByLabel(/Passwort/i).fill("Test-Passwort-1234"); + await page.getByRole("button", { name: /Anmelden/i }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login")); + + const cookies = await context.cookies(); + const session = cookies.find((c) => /authjs|next-auth|__Secure-/.test(c.name)); + expect(session, "Session-Cookie gesetzt").toBeTruthy(); + expect(session?.httpOnly).toBe(true); + expect(session?.sameSite).toMatch(/Lax|Strict/); + // `secure` nur unter https (Querschnittsstandard 9). Lokal (http) false. + const isHttps = (process.env.E2E_BASE_URL ?? "").startsWith("https://"); + expect(session?.secure).toBe(isHttps); + }); +}); diff --git a/tests/e2e/server-actions-guard.spec.ts b/tests/e2e/server-actions-guard.spec.ts new file mode 100644 index 0000000..6b2359a --- /dev/null +++ b/tests/e2e/server-actions-guard.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test"; +import { findUnguardedActionsInRepo } from "../support/guard-scan"; + +/** + * Default-Deny-Beweis für Server Actions (Definition of Done #2, + * Querschnittsstandard 3): JEDE "use server"-Funktion ruft als erste Anweisung + * einen Guard (require{Session,Role,OwnBrigade,PlatformAdmin,WehrAdmin}). + * + * STATISCHER Check — braucht weder Server noch DB; lauffähig offline. Identisch + * als Vitest-Unit-Test (tests/unit/server-actions-guard.test.ts) abgesichert. + * + * Negativ-Probe: Entfernen eines Guards aus einer Action macht diesen Test rot. + * + * Genuin öffentliche Login-Actions (vor der Authentifizierung) sind in + * PUBLIC_ACTION_ALLOWLIST (guard-scan.ts) ausgenommen. + */ +test('jede "use server"-Funktion ruft einen Guard', () => { + const offenders = findUnguardedActionsInRepo(); + expect( + offenders, + `Server Actions ohne Guard:\n${offenders.join("\n")}`, + ).toEqual([]); +}); diff --git a/tests/support/guard-scan.ts b/tests/support/guard-scan.ts new file mode 100644 index 0000000..2d4fc3d --- /dev/null +++ b/tests/support/guard-scan.ts @@ -0,0 +1,68 @@ +import { globSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Statischer Default-Deny-Scanner für Server Actions (Querschnittsstandard 3). + * + * Reine Funktionen, damit der Beweis sowohl im Vitest-Unit-Test (offline, ohne + * Server/DB) als auch in der Playwright-Suite (`server-actions-guard.spec.ts`) + * wiederverwendet werden kann. + */ + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** Erkennt einen der fünf kanonischen Guards (guards.ts). */ +export const GUARD_REGEX = + /require(Session|Role|OwnBrigade|PlatformAdmin|WehrAdmin)\s*\(/; + +/** + * Genuin öffentliche "use server"-Funktionen (Login VOR der Authentifizierung). + * Diese DÜRFEN keinen Session-Guard haben — sie sind der Einstieg. Schlüssel: + * `:`. + */ +export const PUBLIC_ACTION_ALLOWLIST: ReadonlySet = new Set([ + "src/app/(auth)/login/actions.ts:loginAction", + "src/app/(auth)/login/actions.ts:authentikLoginAction", +]); + +/** + * Prüft den Quelltext EINER Datei. Gibt eine Liste von Verstößen + * (`: `) zurück. Dateien ohne "use server"-Direktive + * liefern nie Verstöße. + */ +export function findUnguardedActions( + file: string, + src: string, + allowlist: ReadonlySet = PUBLIC_ACTION_ALLOWLIST, +): string[] { + if (!src.includes('"use server"')) return []; + const offenders: string[] = []; + const fns = src.split(/export async function /).slice(1); + for (const body of fns) { + const name = body.slice(0, body.indexOf("(")).trim(); + if (allowlist.has(`${file}:${name}`)) continue; + // Erste ~600 Zeichen des Funktionskörpers müssen einen Guard enthalten. + if (!GUARD_REGEX.test(body.slice(0, 600))) { + offenders.push(`${file}: ${name}`); + } + } + return offenders; +} + +/** + * Scannt das echte Repository (src/**) nach ungeschützten Server Actions. + */ +export function findUnguardedActionsInRepo(): string[] { + const files = globSync("src/**/*.{ts,tsx}", { cwd: REPO_ROOT }); + const offenders: string[] = []; + for (const rel of files) { + const abs = resolve(REPO_ROOT, rel); + const src = readFileSync(abs, "utf8"); + if (!src.includes('"use server"')) continue; + const repoRel = relative(REPO_ROOT, abs); + offenders.push(...findUnguardedActions(repoRel, src)); + } + return offenders; +} diff --git a/tests/support/route-scan.ts b/tests/support/route-scan.ts new file mode 100644 index 0000000..a0005ce --- /dev/null +++ b/tests/support/route-scan.ts @@ -0,0 +1,69 @@ +import { globSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Routen-Discovery aus dem Dateisystem für den Driftschutz (Definition of + * Done #1). Reine Logik, offline lauffähig (kein Server/DB). + */ + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** + * Öffentliche Präfixe (Middleware-Allowlist). Routen, die mit einem dieser + * Präfixe beginnen, brauchen KEINEN Manifest-Eintrag, weil sie absichtlich + * anonym erreichbar sind. + */ +export const PUBLIC_ALLOWLIST: readonly string[] = [ + "/login", + "/api/auth", + "/api/health", + "/_next", + "/favicon.ico", + "/robots.txt", +]; + +/** + * Wandelt einen Datei-Pfad (page.tsx | route.ts) in den zugehörigen URL-Pfad + * um. Route-Groups `(name)` werden entfernt, dynamische Segmente `[id]` + * bleiben als Platzhalter erhalten. + */ +export function filePathToRoute(filePath: string): string { + const withoutPrefix = filePath + .replace(/^.*src\/app\//, "") + .replace(/^(page|route)\.(tsx?|jsx?)$/, "") + .replace(/\/(page|route)\.(tsx?|jsx?)$/, ""); + const segments = withoutPrefix + .split("/") + .filter((seg) => seg.length > 0 && !/^\(.*\)$/.test(seg)); + return "/" + segments.join("/"); +} + +function isPublic(route: string): boolean { + return PUBLIC_ALLOWLIST.some( + (p) => route === p || route.startsWith(p + "/") || route.startsWith(p), + ); +} + +/** + * Findet Routen, die im Dateisystem existieren, aber weder im Manifest + * deklariert noch öffentlich sind. + */ +export function findUndeclaredRoutes( + discovered: readonly string[], + declared: ReadonlySet, +): string[] { + return discovered + .filter((route) => !isPublic(route)) + .filter((route) => !declared.has(route)); +} + +/** Scannt src/app/** nach page.tsx und route.ts und liefert URL-Pfade. */ +export function discoverAppRoutes(): string[] { + const files = globSync("src/app/**/{page,route}.{ts,tsx}", { + cwd: REPO_ROOT, + }); + const routes = new Set(); + for (const f of files) routes.add(filePathToRoute(f)); + return [...routes].sort(); +} diff --git a/tests/unit/routes-manifest.test.ts b/tests/unit/routes-manifest.test.ts new file mode 100644 index 0000000..8a77c64 --- /dev/null +++ b/tests/unit/routes-manifest.test.ts @@ -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(); + 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); + }); +}); diff --git a/tests/unit/server-actions-guard.test.ts b/tests/unit/server-actions-guard.test.ts new file mode 100644 index 0000000..149e97c --- /dev/null +++ b/tests/unit/server-actions-guard.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + GUARD_REGEX, + PUBLIC_ACTION_ALLOWLIST, + findUnguardedActions, + findUnguardedActionsInRepo, +} from "../support/guard-scan"; + +/** + * Statischer Default-Deny-Beweis für Server Actions (Querschnittsstandard 3, + * Definition of Done #2): JEDE "use server"-Funktion ruft als erste Anweisung + * einen Guard. Dieser Test ist OHNE Server/DB lauffähig und prüft das echte + * Repository. + */ +describe("findUnguardedActions (pure)", () => { + it("flaggt eine exportierte Action ohne Guard", () => { + const src = `"use server"; +export async function tut_was(x: unknown) { + return doThing(x); +}`; + expect(findUnguardedActions("x.ts", src)).toEqual([ + "x.ts: tut_was", + ]); + }); + + it("akzeptiert eine Action, die mit requireWehrAdmin beginnt", () => { + const src = `"use server"; +export async function tut_was(x: unknown) { + const s = await requireWehrAdmin(); + return doThing(x, s); +}`; + expect(findUnguardedActions("x.ts", src)).toEqual([]); + }); + + it("akzeptiert requirePlatformAdmin / requireSession / requireRole / requireOwnBrigade", () => { + for (const guard of [ + "requirePlatformAdmin", + "requireSession", + "requireRole", + "requireOwnBrigade", + ]) { + const src = `"use server"; +export async function f(x: unknown) { + await ${guard}(); + return x; +}`; + expect(findUnguardedActions("x.ts", src)).toEqual([]); + } + }); + + it("ignoriert Dateien ohne \"use server\"-Direktive", () => { + const src = `export async function f(x: unknown) { return x; }`; + expect(findUnguardedActions("x.ts", src)).toEqual([]); + }); + + it("respektiert die Allowlist genuin öffentlicher Actions", () => { + const src = `"use server"; +export async function loginAction(x: unknown) { return x; }`; + expect( + findUnguardedActions( + "src/app/(auth)/login/actions.ts", + src, + new Set(["src/app/(auth)/login/actions.ts:loginAction"]), + ), + ).toEqual([]); + }); + + it("hat die Login-Actions in der Default-Allowlist", () => { + expect(PUBLIC_ACTION_ALLOWLIST.has("src/app/(auth)/login/actions.ts:loginAction")).toBe(true); + expect( + PUBLIC_ACTION_ALLOWLIST.has("src/app/(auth)/login/actions.ts:authentikLoginAction"), + ).toBe(true); + }); + + it("GUARD_REGEX matcht alle fünf Guard-Namen", () => { + expect(GUARD_REGEX.test("requireSession(")).toBe(true); + expect(GUARD_REGEX.test("requirePlatformAdmin(")).toBe(true); + expect(GUARD_REGEX.test("doSomething(")).toBe(false); + }); +}); + +describe("findUnguardedActionsInRepo (echtes Repo)", () => { + it("findet KEINE ungeschützten Server Actions im echten src/", () => { + const offenders = findUnguardedActionsInRepo(); + expect( + offenders, + `Server Actions ohne Guard:\n${offenders.join("\n")}`, + ).toEqual([]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e43e17b..01d67d6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,5 +16,17 @@ export default defineConfig({ "src/**/__tests__/**/*.test.ts", "tests/unit/**/*.test.ts", ], + coverage: { + provider: "v8", + // Querschnitt-Kern muss hoch abgedeckt sein (Definition of Done #7): + // src/lib/search und src/lib/geo >= 90 %. + include: ["src/lib/search/**", "src/lib/geo/**"], + thresholds: { + lines: 90, + functions: 90, + statements: 90, + branches: 80, + }, + }, }, });