diff --git a/apps/api/test/architecture-red-case.test.ts b/apps/api/test/architecture-red-case.test.ts index eac5085..a85ddb9 100644 --- a/apps/api/test/architecture-red-case.test.ts +++ b/apps/api/test/architecture-red-case.test.ts @@ -21,7 +21,7 @@ import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { evaluate, findStarReExports } from "./architecture/rules"; +import { evaluate, findKostenSeitenOhneServerGate, findStarReExports } from "./architecture/rules"; import { collectSourceFiles, extractImports } from "./architecture/scan"; let root: string; @@ -292,6 +292,53 @@ describe("Rot-Fall", () => { rmSync(join(root, "packages/ui"), { recursive: true, force: true }); }); + it("faengt eine Kosten-Seite ohne Server-Gate — und laesst die mit Gate durch", () => { + // Der Gate-Bypass (EYT-113 Inkrement 2): eine Kosten-Seite referenziert + // Kosteninhalt, ruft aber die Ladegrenze nie. Sie bekommt bewusst einen + // ANDEREN Import — die Seite ist nicht importfrei, das Gate fehlt gezielt. + write("apps/web/lib/kosten-freigabe.ts", "export const leseKostenFreigabe = 1;\n"); + write("apps/web/components/kosten-flaeche.tsx", "export const KostenFlaeche = 1;\n"); + write( + "apps/web/app/(werkbank)/kosten/page.tsx", + 'import { leseKostenFreigabe } from "../../../lib/kosten-freigabe";\nexport default leseKostenFreigabe;\n', + ); + write( + "apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx", + 'import { KostenFlaeche } from "../../../../components/kosten-flaeche";\nexport default KostenFlaeche;\n', + ); + const dateien = [...collectSourceFiles(root, "apps"), ...collectSourceFiles(root, "packages")]; + const found = findKostenSeitenOhneServerGate(dateien, extractImports(root, dateien)); + expect(found.map((v) => `${v.file} [${v.rule}]`)).toEqual([ + "apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx [kosten-server-gate]", + ]); + // Gegenprobe: MIT Gate (ueber den relativen, vom Scanner AUFGELOESTEN + // Pfad) ist die Liste leer — eine Regel, die alles meldet, waere sonst + // genauso "rot-faehig". + write( + "apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx", + 'import { leseKostenFreigabe } from "../../../../lib/kosten-freigabe";\nexport default leseKostenFreigabe;\n', + ); + const dateien2 = [...collectSourceFiles(root, "apps"), ...collectSourceFiles(root, "packages")]; + expect(findKostenSeitenOhneServerGate(dateien2, extractImports(root, dateien2))).toEqual([]); + rmSync(join(root, "apps/web"), { recursive: true, force: true }); + }); + + it("geht rot, wenn unter kosten/ weniger als zwei Seiten liegen (Nicht-Leerlauf)", () => { + // Umbenennung/Glob-Rost: das Verzeichnis heisst ploetzlich anders, die + // Regel sieht 0 statt 2 Seiten — und meldet GENAU das, statt still gruen + // an einem leeren Geltungsbereich vorbeizulaufen. + write("apps/web/lib/kosten-freigabe.ts", "export const leseKostenFreigabe = 1;\n"); + write( + "apps/web/app/(werkbank)/kostenrechnung/page.tsx", + 'import { leseKostenFreigabe } from "../../../lib/kosten-freigabe";\nexport default leseKostenFreigabe;\n', + ); + const dateien = [...collectSourceFiles(root, "apps"), ...collectSourceFiles(root, "packages")]; + const found = findKostenSeitenOhneServerGate(dateien, extractImports(root, dateien)); + expect(found.map((v) => v.rule)).toEqual(["kosten-server-gate"]); + expect(found[0]?.message).toContain("mindestens 2"); + rmSync(join(root, "apps/web"), { recursive: true, force: true }); + }); + it("ist am sauberen Baum gruen — der Rot-Fall kommt von den Verstoessen, nicht vom Aufbau", () => { // Steht bewusst am ENDE des Blocks: als Schlussbilanz faengt sie eine // Fixture, die ein frueherer Fall liegen gelassen hat. Weiter oben wuerde diff --git a/apps/api/test/architecture.test.ts b/apps/api/test/architecture.test.ts index 70943d0..6e24744 100644 --- a/apps/api/test/architecture.test.ts +++ b/apps/api/test/architecture.test.ts @@ -17,7 +17,12 @@ import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { MODULE_SLUGS, SCAFFOLDED_MODULES, TABLE_OWNERSHIP } from "../src/modules/module-catalogue"; -import { evaluate, findStarReExports, RULES } from "./architecture/rules"; +import { + evaluate, + findKostenSeitenOhneServerGate, + findStarReExports, + RULES, +} from "./architecture/rules"; import { collectFilesNamed, collectSourceFiles, @@ -248,6 +253,20 @@ describe("Architekturgrenzen", () => { expect(starReExports.map((v) => `${v.file}:${v.line}`)).toEqual([]); }); + it("haelt das Server-Gate auf JEDER Kosten-Seite (EYT-113)", () => { + // `kosten-server-gate` steht wie `index-named-exports-only` NICHT in + // RULES: eine Anwesenheits-PFLICHT braucht die Dateiliste, nicht den + // einzelnen Import (Begruendung in rules.ts). Der Nicht-Leerlauf + // (mindestens 2 Kosten-Seiten gesehen) liegt IN der Regel selbst und + // landet bei Glob-Rost hier als eigene Verletzung — die `>= 1`-Schleife + // ueber RULES weiter oben kann diese Regel nicht erfassen. + expect( + findKostenSeitenOhneServerGate(files, refs).map( + (v) => `${v.file}:${v.line} [${v.rule}] ${v.message}`, + ), + ).toEqual([]); + }); + it("meldet keine Grenzverletzung", () => { const rendered = violations.map((v) => `${v.file}:${v.line} [${v.rule}] ${v.message}`); // Greppbare Zeile, wie beim Tenant-Gate. Kein eslint-disable noetig: diff --git a/apps/api/test/architecture/rules.ts b/apps/api/test/architecture/rules.ts index 4156b0a..9e73079 100644 --- a/apps/api/test/architecture/rules.ts +++ b/apps/api/test/architecture/rules.ts @@ -444,6 +444,66 @@ export function evaluate(refs: readonly ImportRef[]): { return { violations, scopeCounts }; } +/** + * EYT-113 Inkrement 2 — das Server-Gate der Kosten-Seiten. + * + * Jede `page.tsx` unter `apps/web/app/(werkbank)/kosten/` MUSS + * `lib/kosten-freigabe` importieren: dort liegt `leseKostenFreigabe()`, die + * serverseitige, fail-closed Ladegrenze (costs.read der AUSGEWAEHLTEN + * Organisation). Eine Kosten-Seite ohne diesen Import rendert Kosteninhalt + * ohne Grenze — genau der Gate-Bypass, den EYT-113 verbietet. + * + * Wie `findStarReExports` bewusst KEIN Eintrag in `RULES`: das dortige + * `check(ref)` sieht einen einzelnen Import und kann Anwesenheit nur + * VERBIETEN. Eine Anwesenheits-PFLICHT braucht den Blick auf die ganze Datei + * samt Dateiliste — eine Seite ohne den Import erzeugt schlicht keinen `ref`, + * an dem eine ref-basierte Regel feuern koennte. + * + * Geprueft wird ueber den AUFGELOESTEN Importgraphen (`ref.resolved`), nicht + * ueber rohen Quelltext: `../../../lib/kosten-freigabe` und + * `../../../../lib/kosten-freigabe` treffen beide dieselbe Datei, und ein + * auskommentierter oder stringfoermiger Treffer zaehlt dank + * `ts.preProcessFile` gar nicht erst als Import. + * + * Nicht-Leerlauf: unter dem Muster MUESSEN mindestens 2 Seiten liegen + * (`/kosten` und `/kosten/stundensaetze`). Faellt die Zahl darunter — + * Umbenennung, Glob-Rost, SKIP_DIRS-Fehler —, meldet die Regel das als + * eigene Verletzung, statt still gruen zu bleiben. + */ +const KOSTEN_SEITEN_MUSTER = /^apps\/web\/app\/\(werkbank\)\/kosten\/(?:.+\/)?page\.tsx$/; +const KOSTEN_FREIGABE_DATEI = "apps/web/lib/kosten-freigabe.ts"; +const KOSTEN_SEITEN_MINDESTZAHL = 2; + +export function findKostenSeitenOhneServerGate( + files: readonly string[], + refs: readonly ImportRef[], +): Violation[] { + const out: Violation[] = []; + const seiten = files.filter((file) => KOSTEN_SEITEN_MUSTER.test(file)); + if (seiten.length < KOSTEN_SEITEN_MINDESTZAHL) { + out.push({ + rule: "kosten-server-gate", + file: "apps/web/app/(werkbank)/kosten/", + line: 0, + message: `Nur ${seiten.length} Kosten-Seite(n) unter apps/web/app/(werkbank)/kosten/ gefunden, erwartet sind mindestens ${KOSTEN_SEITEN_MINDESTZAHL}. Entweder wurden Seiten verschoben/umbenannt (dann Muster UND Mindestzahl bewusst nachziehen) oder der Scanner sieht sie nicht mehr — in beiden Faellen darf die Regel nicht still gruen bleiben (EYT-113 Inkrement 2).`, + }); + } + for (const seite of seiten) { + const hatGate = refs.some( + (ref) => ref.from === seite && ref.resolved === KOSTEN_FREIGABE_DATEI, + ); + if (!hatGate) { + out.push({ + rule: "kosten-server-gate", + file: seite, + line: 1, + message: `${seite} importiert lib/kosten-freigabe nicht — jede Kosten-Seite MUSS die serverseitige Ladegrenze leseKostenFreigabe() vor jedem Kosteninhalt rufen (EYT-113 Inkrement 2, fail-closed).`, + }); + } + } + return out; +} + /** * `export *` in einer Modul-`index.ts` traegt das gesamte Domainmodell ueber die * Grenze und macht aus mehreren Modulen ein globales Modell (EYT-46 AK 5). diff --git a/apps/web/app/(werkbank)/kosten/page.tsx b/apps/web/app/(werkbank)/kosten/page.tsx index 19d00db..9691288 100644 --- a/apps/web/app/(werkbank)/kosten/page.tsx +++ b/apps/web/app/(werkbank)/kosten/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import type { ReactNode } from "react"; import { IdSchema } from "@easytree/contracts"; -import { Card, PageHeader } from "@easytree/ui"; +import { PageHeader } from "@easytree/ui"; -import { KostenAnsicht } from "../../../components/kosten-ansicht"; -import { KostenZugang } from "../../../components/kosten-zugang"; +import { KostenFlaeche } from "../../../components/kosten-flaeche"; +import { KostenGrenze } from "../../../components/kosten-grenze"; +import { leseKostenFreigabe } from "../../../lib/kosten-freigabe"; export const metadata: Metadata = { title: "Kosten — easyTree" }; @@ -23,17 +24,52 @@ export const metadata: Metadata = { title: "Kosten — easyTree" }; * Kein stiller Default: ein unbrauchbarer Parameter wird SICHTBAR abgelehnt und * das Gateway gar nicht erst gerufen (dasselbe Muster wie `/planung`). Ein * mehrfach angegebener Parameter ist keine Id, sondern eine mehrdeutige Angabe. + * + * Seit EYT-113 Inkrement 2 steht VOR jedem Kosteninhalt die serverseitige + * Ladegrenze: `leseKostenFreigabe()` prueft das `costs.read` der + * AUSGEWAEHLTEN Organisation, fail-closed — jeder Verweigerungszustand + * rendert `KostenGrenze` statt der Kosten-Client-Komponenten. Die + * `headers()`/`cookies()`-Lesezugriffe in `leseKostenFreigabe` machen die + * Route dynamisch; das ist gewollt (EYT-126: nichts davon darf zur Bauzeit + * festgeschrieben werden). */ export default async function KostenPage({ searchParams, }: { searchParams: Promise>; }) { + const freigabe = await leseKostenFreigabe(); const params = await searchParams; const roh = params["snapshot"]; const geprueft = roh === undefined ? null : IdSchema.safeParse(typeof roh === "string" ? roh : ""); + // Client-seitige Importgrenze (EYT-113 Inkrement 2, D4 Stufe 3): die Seite + // verweist statisch NUR auf die kostenfreie `KostenFlaeche`; die + // eigentlichen Kosten-Client-Komponenten laedt erst deren `next/dynamic`. + // Stufe 2 (`await import()` im gewaehrten Zweig) reichte nicht — gemessen + // am 29.08.2026: Next 16.2.11/Turbopack schreibt die gesamte + // Client-Referenz-Chunkliste der Route als unbedingte `