Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion apps/api/test/architecture-red-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion apps/api/test/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions apps/api/test/architecture/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
64 changes: 41 additions & 23 deletions apps/web/app/(werkbank)/kosten/page.tsx
Original file line number Diff line number Diff line change
@@ -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" };

Expand All @@ -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<Record<string, string | string[] | undefined>>;
}) {
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 `<script async>`-Tags
// in den Dokumentkopf, die Kosten-Chunks erreichten den verweigerten
// Browser trotzdem. Nur Lazy-Chunks im CLIENT-Modulgraphen stehen weder als
// Route-Entry noch im Client-Referenz-Manifest der Seite und werden erst
// angefordert, wenn der gewaehrte Zweig tatsaechlich rendert; der
// Journey-Nachweis (member, /kosten und /kosten/stundensaetze) misst genau
// das.
let inhalt: ReactNode;
if (freigabe.art !== "gewaehrt") {
inhalt = <KostenGrenze freigabe={freigabe} />;
} else {
inhalt = (
// Bei einem Parameterfehler ist `snapshotId` bewusst `null`: die
// Flaeche rendert dann den Fehlerhinweis und liest die Id nie.
<KostenFlaeche
snapshotId={geprueft !== null && geprueft.success ? geprueft.data : null}
parameterfehler={geprueft !== null && !geprueft.success}
/>
);
}

return (
/*
* Derselbe Werkbankrahmen wie `/planung` (EYT-141).
Expand All @@ -53,25 +89,7 @@ export default async function KostenPage({
title="Kosten"
description="Geplante Personalkosten je Baustelle und Tag — aus veröffentlichten Planversionen, bis zur Einzelposition."
/>
<KostenZugang>
{geprueft !== null && !geprueft.success ? (
<p data-testid="kosten-parameterfehler" role="alert">
Keine gültige Snapshot-Id in der Adresse. Erwartet wird
`?snapshot=&lt;id-aus-der-Kostenansicht&gt;`.
</p>
) : (
<KostenAnsicht snapshotId={geprueft === null ? null : geprueft.data} />
)}
<Card title="Stundensätze">
<p>
Interne Netto-Stundensätze je Mitarbeiter, versioniert mit Gültigkeit — die Grundlage
jeder Kostenberechnung.
</p>
<p>
<Link href="/kosten/stundensaetze">Zur Stundensatzverwaltung</Link>
</p>
</Card>
</KostenZugang>
{inhalt}
</section>
);
}
35 changes: 29 additions & 6 deletions apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,49 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";

import { PageHeader } from "@easytree/ui";

import { KostenZugang } from "../../../../components/kosten-zugang";
import { RateManagement } from "../../../../components/rate-management";
import { KostenGrenze } from "../../../../components/kosten-grenze";
import { StundensaetzeFlaeche } from "../../../../components/stundensaetze-flaeche";
import { leseKostenFreigabe } from "../../../../lib/kosten-freigabe";

export const metadata: Metadata = { title: "Stundensätze — easyTree" };

/**
* `/kosten/stundensaetze` — Satzverwaltung (EYT-108, Basisdesign §5 Punkt 3).
* Fragment ohne eigenes `<main>`; Zugang über denselben Wächter wie /kosten.
*
* Seit EYT-113 Inkrement 2 steht davor die serverseitige Ladegrenze:
* `leseKostenFreigabe()` prueft das `costs.read` der AUSGEWAEHLTEN
* Organisation, fail-closed — jeder Verweigerungszustand rendert
* `KostenGrenze`, `RateManagement` wird dann gar nicht montiert. Die
* `headers()`/`cookies()`-Lesezugriffe in `leseKostenFreigabe` machen die
* Route dynamisch; das ist gewollt (EYT-126: nichts davon darf zur Bauzeit
* festgeschrieben werden).
*/
export default function StundensaetzePage() {
export default async function StundensaetzePage() {
const freigabe = await leseKostenFreigabe();

// Client-seitige Importgrenze (EYT-113 Inkrement 2, D4 Stufe 3): die Seite
// verweist statisch NUR auf die kostenfreie `StundensaetzeFlaeche`; deren
// `next/dynamic` laedt die Kosten-Client-Komponenten als Lazy-Chunks im
// CLIENT-Modulgraphen. Stufe 2 (`await import()` im gewaehrten Zweig)
// reichte nicht: Turbopack schreibt Route-Entry-Chunks unbedingt in den
// Dokumentkopf (Messung und Begruendung im Kommentar von `../page.tsx`).
let inhalt: ReactNode;
if (freigabe.art !== "gewaehrt") {
inhalt = <KostenGrenze freigabe={freigabe} />;
} else {
inhalt = <StundensaetzeFlaeche />;
}

return (
<>
<PageHeader
title="Stundensätze"
description="Interne Netto-Stundensätze je Mitarbeiter — unveränderliche Versionen mit Gültigkeit, nie überschrieben."
/>
<KostenZugang>
<RateManagement />
</KostenZugang>
{inhalt}
</>
);
}
2 changes: 1 addition & 1 deletion apps/web/app/(werkbank)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { redirect } from "next/navigation";

import { StartInhalt } from "../../components/start-inhalt";
import { leseServerSitzung } from "../../lib/feld/sitzung-server";
import { leseServerSitzung } from "../../lib/sitzung-server";
import { startShellFuer } from "../../lib/feld/start-shell";

/**
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/feld/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ReactNode } from "react";
import { ErrorState } from "@easytree/ui";

import { FeldShell } from "../../components/feld/feld-shell";
import { leseServerSitzung } from "../../lib/feld/sitzung-server";
import { leseServerSitzung } from "../../lib/sitzung-server";

export const metadata: Metadata = { title: "Feld — easyTree" };

Expand Down
Loading
Loading