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:
103
tests/unit/routes-manifest.test.ts
Normal file
103
tests/unit/routes-manifest.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
tests/unit/server-actions-guard.test.ts
Normal file
90
tests/unit/server-actions-guard.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user