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;

View File

@@ -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) });
});
}

36
tests/e2e/global-setup.ts Normal file
View File

@@ -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<void> {
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;

View File

@@ -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/);
}
}
});

View File

@@ -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();
});
});

View File

@@ -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([]);
});

View File

@@ -0,0 +1,110 @@
/**
* Kanonisches Routen-Manifest für die Auth-Gating-Garantie (Definition of
* Done #1, Querschnittsstandard 13).
*
* 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<string> = 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",
]);

View File

@@ -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();
});

View File

@@ -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://<host>/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);
});
});

View File

@@ -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([]);
});