From 3ad9fda358af7b239a6e105ced44c330a6983465 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 22:18:14 +0200 Subject: [PATCH 01/11] refactor(web): EYT-113 Sitzungs-Helper aus der Feld-Ecke in lib/ heben --- apps/web/app/(werkbank)/page.tsx | 2 +- apps/web/app/feld/layout.tsx | 2 +- apps/web/lib/{feld => }/sitzung-server.ts | 2 +- apps/web/test/sitzung-server.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename apps/web/lib/{feld => }/sitzung-server.ts (97%) diff --git a/apps/web/app/(werkbank)/page.tsx b/apps/web/app/(werkbank)/page.tsx index f3b2ed3..f0be722 100644 --- a/apps/web/app/(werkbank)/page.tsx +++ b/apps/web/app/(werkbank)/page.tsx @@ -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"; /** diff --git a/apps/web/app/feld/layout.tsx b/apps/web/app/feld/layout.tsx index 90106f1..9489233 100644 --- a/apps/web/app/feld/layout.tsx +++ b/apps/web/app/feld/layout.tsx @@ -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" }; diff --git a/apps/web/lib/feld/sitzung-server.ts b/apps/web/lib/sitzung-server.ts similarity index 97% rename from apps/web/lib/feld/sitzung-server.ts rename to apps/web/lib/sitzung-server.ts index bf54ab0..f78ebe4 100644 --- a/apps/web/lib/feld/sitzung-server.ts +++ b/apps/web/lib/sitzung-server.ts @@ -1,6 +1,6 @@ import { API_BASE_PATH, SessionDtoSchema, type SessionDto } from "@easytree/contracts"; -import { aktuellesProxyziel } from "../proxy-durchreichen"; +import { aktuellesProxyziel } from "./proxy-durchreichen"; /** * Serverseitige Sitzungslesung (EYT-113). diff --git a/apps/web/test/sitzung-server.test.ts b/apps/web/test/sitzung-server.test.ts index a3a21f9..a299daf 100644 --- a/apps/web/test/sitzung-server.test.ts +++ b/apps/web/test/sitzung-server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { leseServerSitzung } from "../lib/feld/sitzung-server"; +import { leseServerSitzung } from "../lib/sitzung-server"; /** * Serverseitige Sitzungslesung (EYT-113): Server Components fragen die echte From c7c3c8c8c367eb7707ecec83ffab521a2cb6c2f0 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 22:22:22 +0200 Subject: [PATCH 02/11] feat(web): EYT-113 Selector-Cookie der Organisationsauswahl Co-Authored-By: Claude Fable 5 --- apps/web/lib/organisations-auswahl-cookie.ts | 37 +++++++ .../test/organisations-auswahl-cookie.test.ts | 96 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 apps/web/lib/organisations-auswahl-cookie.ts create mode 100644 apps/web/test/organisations-auswahl-cookie.test.ts diff --git a/apps/web/lib/organisations-auswahl-cookie.ts b/apps/web/lib/organisations-auswahl-cookie.ts new file mode 100644 index 0000000..b1fd0e2 --- /dev/null +++ b/apps/web/lib/organisations-auswahl-cookie.ts @@ -0,0 +1,37 @@ +/** + * Selector-Cookie der Organisationsauswahl (EYT-113 Inkrement 2). + * + * Der Wert ist eine AUSWAHL, kein Geheimnis und keine Autorisierung: der + * Server prueft ihn ausschliesslich gegen die real verifizierte Session + * (lib/kosten-freigabe.ts) — eine fremde Id faellt dort wirkungslos. + * + * Bewusst NICHT HttpOnly (der Client schreibt es), KEIN Secure (laeuft + * lokal auf blankem http; der Wert ist kein Geheimnis), KEIN Max-Age beim + * Schreiben (Session-Cookie). + */ +export const ORG_AUSWAHL_COOKIE = "eyt_org"; + +export function liesOrgAuswahl(cookieHeader: string | null): string | null { + if (cookieHeader === null || cookieHeader === "") return null; + for (const teil of cookieHeader.split(";")) { + const gleich = teil.indexOf("="); + if (gleich === -1) continue; + if (teil.slice(0, gleich).trim() !== ORG_AUSWAHL_COOKIE) continue; + const wert = teil.slice(gleich + 1).trim(); + return wert === "" ? null : decodeURIComponent(wert); + } + return null; +} + +export function liesOrgAuswahlAusDokument(): string | null { + if (typeof document === "undefined") return null; + return liesOrgAuswahl(document.cookie); +} + +export function schreibeOrgAuswahl(id: string | null): void { + if (typeof document === "undefined") return; + document.cookie = + id === null + ? `${ORG_AUSWAHL_COOKIE}=; path=/; SameSite=Lax; Max-Age=0` + : `${ORG_AUSWAHL_COOKIE}=${encodeURIComponent(id)}; path=/; SameSite=Lax`; +} diff --git a/apps/web/test/organisations-auswahl-cookie.test.ts b/apps/web/test/organisations-auswahl-cookie.test.ts new file mode 100644 index 0000000..11826e3 --- /dev/null +++ b/apps/web/test/organisations-auswahl-cookie.test.ts @@ -0,0 +1,96 @@ +/** + * Selector-Cookie der Organisationsauswahl (EYT-113 Inkrement 2). + * + * jsdoms document.cookie zeigt beim Lesen keine Attribute; die + * Attributzusicherung laeuft deshalb ueber einen Spy auf dem + * cookie-Setter, der die volle geschriebene Zeile sieht. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { + ORG_AUSWAHL_COOKIE, + liesOrgAuswahl, + liesOrgAuswahlAusDokument, + schreibeOrgAuswahl, +} from "../lib/organisations-auswahl-cookie"; + +describe("liesOrgAuswahl", () => { + it("liefert null fuer fehlenden oder leeren Header", () => { + expect(liesOrgAuswahl(null)).toBeNull(); + expect(liesOrgAuswahl("")).toBeNull(); + }); + + it("liefert null, wenn das Cookie im Header fehlt", () => { + expect(liesOrgAuswahl("eyt_access=abc; other=1")).toBeNull(); + }); + + it("findet das Cookie zwischen anderen", () => { + expect( + liesOrgAuswahl("eyt_access=abc; eyt_org=00000000-0000-4000-8000-00000000e201; x=1"), + ).toBe("00000000-0000-4000-8000-00000000e201"); + }); + + it("liefert null fuer einen leeren Wert", () => { + expect(liesOrgAuswahl("eyt_org=")).toBeNull(); + }); + + it("dekodiert URL-kodierte Werte", () => { + // Gegenmutation: decodeURIComponent weglassen -> rot. + expect(liesOrgAuswahl("eyt_org=a%20b%2Fc")).toBe("a b/c"); + }); + + it("verlangt den exakten Namen — ein Suffixtreffer zaehlt nicht", () => { + // Gegenmutation: Namensvergleich auf endsWith umstellen -> rot. + expect(liesOrgAuswahl("xeyt_org=evil")).toBeNull(); + }); +}); + +describe("Dokument-Funktionen", () => { + afterEach(() => { + // jsdom haelt document.cookie ueber alle Tests dieser Datei hinweg. + document.cookie = `${ORG_AUSWAHL_COOKIE}=; path=/; Max-Age=0`; + }); + + it("schreibt die Auswahl und liest sie zurueck", () => { + schreibeOrgAuswahl("abc-id"); + expect(document.cookie).toContain(`${ORG_AUSWAHL_COOKIE}=abc-id`); + expect(liesOrgAuswahlAusDokument()).toBe("abc-id"); + }); + + it("loescht die Auswahl mit null", () => { + schreibeOrgAuswahl("abc-id"); + schreibeOrgAuswahl(null); + expect(liesOrgAuswahlAusDokument()).toBeNull(); + }); + + it("schreibt path=/ und SameSite=Lax, Loeschung zusaetzlich Max-Age=0", () => { + // Gegenmutation: eines der Attribute in schreibeOrgAuswahl streichen -> rot. + const original = Object.getOwnPropertyDescriptor(Document.prototype, "cookie"); + if (!original?.get || !original.set) { + throw new Error("jsdom ohne cookie-Accessor — Spy nicht moeglich"); + } + const liesRoh = original.get; + const schreibeRoh = original.set; + const geschrieben: string[] = []; + Object.defineProperty(document, "cookie", { + configurable: true, + get(): string { + return liesRoh.call(document) as string; + }, + set(wert: string) { + geschrieben.push(wert); + schreibeRoh.call(document, wert); + }, + }); + try { + schreibeOrgAuswahl("abc-id"); + schreibeOrgAuswahl(null); + expect(geschrieben).toEqual([ + `${ORG_AUSWAHL_COOKIE}=abc-id; path=/; SameSite=Lax`, + `${ORG_AUSWAHL_COOKIE}=; path=/; SameSite=Lax; Max-Age=0`, + ]); + } finally { + Reflect.deleteProperty(document, "cookie"); + } + }); +}); From 28b31fb12a61102a6df511ae249fa045cf653936 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 22:27:19 +0200 Subject: [PATCH 03/11] =?UTF-8?q?feat(web):=20EYT-113=20kostenFreigabe=20?= =?UTF-8?q?=E2=80=94=20Selector=20verifiziert,=20fail=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/web/lib/kosten-freigabe.ts | 57 +++++++++++++++ apps/web/test/kosten-freigabe.test.ts | 101 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 apps/web/lib/kosten-freigabe.ts create mode 100644 apps/web/test/kosten-freigabe.test.ts diff --git a/apps/web/lib/kosten-freigabe.ts b/apps/web/lib/kosten-freigabe.ts new file mode 100644 index 0000000..3a598b0 --- /dev/null +++ b/apps/web/lib/kosten-freigabe.ts @@ -0,0 +1,57 @@ +import { cookies, headers } from "next/headers"; + +import type { SessionOrganisation } from "@easytree/contracts"; + +import { ORG_AUSWAHL_COOKIE } from "./organisations-auswahl-cookie"; +import { leseServerSitzung, type ServerSitzung } from "./sitzung-server"; + +/** + * Serverseitige Kosten-Ladegrenze (EYT-113 Inkrement 2, PO-Entscheidung + * 29.08.2026): massgeblich ist das verifizierte `costs.read` der AUSGEWAEHLTEN + * Organisation. Die Selector-Id waehlt nur aus — verifiziert wird sie gegen + * die real geprueften Mitgliedschaften der Session. Eine Any-Org-Pruefung + * ist ausdruecklich verboten: Recht in Organisation A gewaehrt nichts, + * solange Organisation B ausgewaehlt ist. + * + * Fail-closed: unbekannte Session, fremde/ungueltige Selector-Id, fehlende + * Auswahl bei mehreren Organisationen und fehlendes Recht laden nichts. + */ +export type KostenFreigabe = + | { art: "gewaehrt"; organisation: SessionOrganisation } + | { art: "verboten"; organisation: SessionOrganisation } + | { art: "keine-auswahl" } + | { art: "abgemeldet" } + | { art: "unbekannt" }; + +export function kostenFreigabe(sitzung: ServerSitzung, orgSelektor: string | null): KostenFreigabe { + if (sitzung.zustand === "unbekannt") return { art: "unbekannt" }; + if (sitzung.zustand === "abgemeldet") return { art: "abgemeldet" }; + + const orgs = sitzung.session.organisations; + let organisation: SessionOrganisation | null = null; + if (orgSelektor !== null && orgSelektor !== "") { + // Eine Id, die NICHT in der Session steht, faellt ersatzlos — kein + // Rueckfall auf die einzige Organisation, sonst wuerde ein fremder + // Selector still uminterpretiert statt abgelehnt. + organisation = orgs.find((org) => org.id === orgSelektor) ?? null; + } else if (orgs.length === 1) { + organisation = orgs[0] ?? null; + } + + if (organisation === null) return { art: "keine-auswahl" }; + return organisation.permissions.includes("costs.read") + ? { art: "gewaehrt", organisation } + : { art: "verboten", organisation }; +} + +/** + * Duenner Server-Einstieg: die EINZIGE Stelle, die die beiden + * Anfrage-Eingaben (Cookie-Header fuer die Session, Auswahl-Cookie fuer den + * Selector) liest. Beide Kosten-Seiten rufen genau diese Funktion. + */ +export async function leseKostenFreigabe(): Promise { + const kopfzeilen = await headers(); + const keks = await cookies(); + const sitzung = await leseServerSitzung(kopfzeilen.get("cookie")); + return kostenFreigabe(sitzung, keks.get(ORG_AUSWAHL_COOKIE)?.value ?? null); +} diff --git a/apps/web/test/kosten-freigabe.test.ts b/apps/web/test/kosten-freigabe.test.ts new file mode 100644 index 0000000..1096c1e --- /dev/null +++ b/apps/web/test/kosten-freigabe.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionOrganisation } from "@easytree/contracts"; + +import { kostenFreigabe } from "../lib/kosten-freigabe"; +import type { ServerSitzung } from "../lib/sitzung-server"; + +/** + * Pures Server-Gate `kostenFreigabe` (EYT-113 Inkrement 2, PO-Entscheidung + * 29.08.2026): das Kostenrecht bleibt organisationsgebunden. Massgeblich ist + * `costs.read` der AUSGEWAEHLTEN Organisation — die Selector-Id waehlt nur + * aus und wird gegen die real verifizierte Session geprueft. Die + * Aufweichung "Recht in IRGENDEINER Organisation genuegt" ist verboten. + * + * Gegenmutationen, die diese Suite rot machen: Any-Org-Pruefung + * (`orgs.some(...)` statt der ausgewaehlten Organisation — Fall 7), Rueckfall + * einer fremden Selector-Id auf die einzige Organisation (Fall 8), stilles + * Auswaehlen der ersten von mehreren Organisationen (Fall 5), "unbekannt" + * oder "abgemeldet" auf ein Ergebnis mit Organisation mappen (Faelle 1/2). + */ +function org(id: string, name: string, permissions: string[]): SessionOrganisation { + return { id, name, role: "member", permissions }; +} + +function angemeldet(organisations: SessionOrganisation[]): ServerSitzung { + return { + zustand: "angemeldet", + session: { + userId: "00000000-0000-4000-8000-000000000001", + organisations, + }, + }; +} + +const MIT_RECHT = org("00000000-0000-4000-8000-00000000000a", "Baumpflege Nord", ["costs.read"]); +const OHNE_RECHT = org("00000000-0000-4000-8000-00000000000b", "Baumpflege Sued", []); +const FREMDE_ID = "00000000-0000-4000-8000-0000000000ff"; + +describe("kostenFreigabe (EYT-113)", () => { + it("liefert 'unbekannt' bei unbekannter Session", () => { + expect(kostenFreigabe({ zustand: "unbekannt" }, null)).toEqual({ + art: "unbekannt", + }); + }); + + it("liefert 'abgemeldet' bei abgemeldeter Session", () => { + expect(kostenFreigabe({ zustand: "abgemeldet" }, null)).toEqual({ + art: "abgemeldet", + }); + }); + + it("gewaehrt bei genau einer Organisation MIT costs.read ohne Selector", () => { + expect(kostenFreigabe(angemeldet([MIT_RECHT]), null)).toEqual({ + art: "gewaehrt", + organisation: MIT_RECHT, + }); + }); + + it("verbietet bei genau einer Organisation OHNE costs.read ohne Selector", () => { + expect(kostenFreigabe(angemeldet([OHNE_RECHT]), null)).toEqual({ + art: "verboten", + organisation: OHNE_RECHT, + }); + }); + + it("liefert 'keine-auswahl' bei zwei Organisationen ohne Selector — kein stilles Auswaehlen", () => { + expect(kostenFreigabe(angemeldet([MIT_RECHT, OHNE_RECHT]), null)).toEqual({ + art: "keine-auswahl", + }); + }); + + it("gewaehrt bei zwei Organisationen, wenn der Selector die MIT costs.read waehlt", () => { + expect(kostenFreigabe(angemeldet([MIT_RECHT, OHNE_RECHT]), MIT_RECHT.id)).toEqual({ + art: "gewaehrt", + organisation: MIT_RECHT, + }); + }); + + it("verbietet, wenn der Selector die Organisation OHNE costs.read waehlt, obwohl die ANDERE es hat (PO-Kern: keine Any-Org-Pruefung)", () => { + expect(kostenFreigabe(angemeldet([MIT_RECHT, OHNE_RECHT]), OHNE_RECHT.id)).toEqual({ + art: "verboten", + organisation: OHNE_RECHT, + }); + }); + + it("liefert 'keine-auswahl' bei fremder Selector-Id — kein Rueckfall auf die einzige Organisation", () => { + expect(kostenFreigabe(angemeldet([MIT_RECHT]), FREMDE_ID)).toEqual({ + art: "keine-auswahl", + }); + }); + + it("behandelt Selector '' wie null: einzige Organisation gewaehrt, zwei Organisationen keine-auswahl", () => { + expect(kostenFreigabe(angemeldet([MIT_RECHT]), "")).toEqual({ + art: "gewaehrt", + organisation: MIT_RECHT, + }); + expect(kostenFreigabe(angemeldet([MIT_RECHT, OHNE_RECHT]), "")).toEqual({ + art: "keine-auswahl", + }); + }); +}); From d3ff4e12dcfa53b6ec714c5c4c7454ac6f8dda0b Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 22:42:11 +0200 Subject: [PATCH 04/11] test(web): EYT-113 rote Ladegrenzen-Beweise vor der Implementierung Co-Authored-By: Claude Fable 5 --- apps/web/e2e/auth-journey/journey.pwtest.ts | 137 +++++++++++- apps/web/test/kosten-page-gate.test.tsx | 228 ++++++++++++++++++++ 2 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 apps/web/test/kosten-page-gate.test.tsx diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts index 7199013..318c996 100644 --- a/apps/web/e2e/auth-journey/journey.pwtest.ts +++ b/apps/web/e2e/auth-journey/journey.pwtest.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import AxeBuilder from "@axe-core/playwright"; @@ -397,6 +397,44 @@ async function pruefeBarrierefreiheit( const HIER = __dirname; const ARTEFAKTE = join(HIER, "..", "..", "test-results", "auth-journey"); +/** + * EYT-113 Inkrement 2 — die Kosten-Chunkmenge des LAUFENDEN Builds. + * + * Die serverseitige Ladegrenze verspricht: in einem Verweigerungszustand + * referenziert die Kostenroute KEINE Kosten-Client-Komponente — also weder + * eine Chunk-Anfrage noch eine Chunk-Referenz im Dokument. Welche Dateien + * das sind, wechselt mit jedem Build (die Namen tragen Hashes); abgeleitet + * wird die Menge deshalb zur LAUFZEIT aus `.next/static/chunks/*.js`, ueber + * drei Marker, die je genau EINE Quelldatei besitzt: + * + * - `eyt-kosten-ansicht` (components/kosten-ansicht.tsx) + * - `kosten-laedt` (components/kosten-zugang.tsx) + * - `saetze-laedt` (components/rate-management.tsx) + * + * Leere Menge => Wurf, nicht leere Rueckgabe: ein Waechter, der nichts findet, + * wachte sonst still ueber nichts (`guard-exists-but-never-visits-the-surface`). + * Gegenmutation: einen der drei Marker verschreiben — bei nur einem faellt die + * Menge auf eine Datei zusammen und die Positivkontrolle unten schrumpft, bei + * allen dreien wirft diese Funktion. + */ +function kostenChunkDateien(): string[] { + const chunkVerzeichnis = join(HIER, "..", "..", ".next", "static", "chunks"); + const marker = ["eyt-kosten-ansicht", "kosten-laedt", "saetze-laedt"] as const; + const dateien = readdirSync(chunkVerzeichnis) + .filter((name) => name.endsWith(".js")) + .filter((name) => { + const inhalt = readFileSync(join(chunkVerzeichnis, name), "utf8"); + return marker.some((m) => inhalt.includes(m)); + }); + if (dateien.length === 0) { + throw new Error( + "[auth-journey] EYT-113: kein Chunk unter .next/static/chunks traegt einen der drei " + + "Kosten-Marker — die Ladegrenzen-Zusicherungen waeren vakuos.", + ); + } + return dateien; +} + const ORG_ID = "00000000-0000-4000-8000-00000000e201"; const ORG_NAME = "E2E Reiseorganisation"; const MITARBEITER_NAME = "E2E-Mitarbeiter Reise"; @@ -868,6 +906,17 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ if (pfad.startsWith("/api/")) apiAufrufe.push(`${anfrage.method()} ${pfad}`); }); + /** + * EYT-113: jede angeforderte Chunk-Datei — die Positivkontrolle der + * Ladegrenze. VOR der ersten Navigation registriert, sonst fehlte genau + * die Anfrage, die beim Einstieg in `/kosten` faellt. + */ + const chunkAnfragen: string[] = []; + page.on("request", (anfrage) => { + const pfad = new URL(anfrage.url()).pathname; + if (pfad.startsWith("/_next/static/chunks/")) chunkAnfragen.push(pfad); + }); + const bericht: Record = { ticket: "EYT-106", paket: "B", @@ -920,6 +969,30 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ await expect(page.getByLabel("Von Woche")).toBeVisible(); }); + await test.step("3b — EYT-113 Positivkontrolle: der Berechtigte laedt die Kosten-Chunks", async () => { + // Die Gegenseite des member-Nachweises in der Feld-Reise unten: nur wenn + // die abgeleitete Chunkmenge beim BERECHTIGTEN nachweislich angefordert + // wird, sagt ihr Ausbleiben beim Unberechtigten etwas aus. Gegenmutation: + // einen Marker in `kostenChunkDateien()` verschreiben — die Ableitung + // wirft, oder diese Schnittmenge wird leer. + const kostenChunks = kostenChunkDateien(); + const geladeneKostenChunks = chunkAnfragen.filter((pfad) => + kostenChunks.some((datei) => pfad.endsWith(`/${datei}`)), + ); + expect( + geladeneKostenChunks.length, + `Kosten-Chunks [${kostenChunks.join(", ")}] — keiner wurde angefordert`, + ).toBeGreaterThan(0); + await page.screenshot({ + path: test.info().outputPath("eyt113-kosten-positiv.png"), + fullPage: true, + }); + schritte["3b_eyt113_positivkontrolle"] = { + kosten_chunks: kostenChunks, + angefordert: geladeneKostenChunks, + }; + }); + await test.step("4 — Sicherheitsnachweis: beide Sitzungscookies sind HttpOnly und Strict", async () => { const cookies = await context.cookies(); const access = cookie(cookies, "eyt_access"); @@ -3354,6 +3427,68 @@ test("EYT-113: ein member erreicht die Feld-Shell, die Werkbank bleibt zu", asyn erwartet: 403, }; }); + + await test.step("EYT-113 Inkrement 2: die Kostenrouten liefern dem member keine Kosten-Chunks", async () => { + // Serverseitige LADEGRENZE, nicht nur Anzeige-Grenze: fuer einen member + // ohne `costs.read` darf die Kostenroute die Kosten-Client-Komponenten + // gar nicht erst referenzieren — kein Chunk-Abruf, keine Chunk-Referenz + // im Dokument, keine Wirtschaftsdaten. Die Positivkontrolle dazu steht + // in Schritt 3b der Hauptreise: dort fordert der BERECHTIGTE dieselbe + // abgeleitete Chunkmenge nachweislich an. + const kostenChunks = kostenChunkDateien(); + + // Sammler VOR der Navigation — sonst fehlte genau die erste Anfrage. + const chunkAnfragenB: string[] = []; + seite.on("request", (anfrage) => { + const pfad = new URL(anfrage.url()).pathname; + if (pfad.startsWith("/_next/static/chunks/")) chunkAnfragenB.push(pfad); + }); + + for (const route of ["/kosten", "/kosten/stundensaetze"] as const) { + await seite.goto(route); + // Der vorgesehene Endzustand als Anker: erst wenn die Forbidden- + // Flaeche steht, misst die Inhaltspruefung den fertigen Zustand und + // nicht einen Ladezwischenstand. + await expect(seite.getByTestId("kosten-forbidden"), `${route}: Forbidden`).toBeVisible(); + + // Netzseite der Ladegrenze. Gegenmutation: das Gate aus + // `kosten/page.tsx` bzw. `stundensaetze/page.tsx` entfernen — die + // Route fordert die Kosten-Chunks dann wieder an. + const geladeneKostenChunks = chunkAnfragenB.filter((pfad) => + kostenChunks.some((datei) => pfad.includes(datei)), + ); + expect(geladeneKostenChunks, `${route}: angeforderte Kosten-Chunks`).toEqual([]); + + // Dokumentseite der Ladegrenze: schon die REFERENZ im HTML ist das + // Leck, nicht erst der erfolgreiche Abruf (ein Browser mit Cache + // fordert nichts an und truege die Referenz trotzdem). Gegenmutation: + // dieselbe wie oben. + const inhalt = await seite.content(); + for (const datei of kostenChunks) { + expect(inhalt, `${route}: ${datei} steht im Dokument`).not.toContain(datei); + } + + // Keine Wirtschaftsdaten im Dokument — Satzbetrag, Kostenanzeige, + // Minor Units, Mitarbeitername (dieselben Fixturkonstanten wie in + // 9g2). Gegenmutation: den Kosteninhalt serverseitig auch im + // Verweigerungszweig rendern und nur per CSS verbergen. + expect(inhalt, `${route}: Satzbetrag im Dokument`).not.toContain(ERWARTETER_BETRAG); + expect(inhalt, `${route}: Kostenanzeige im Dokument`).not.toContain( + ERWARTETE_KOSTEN_ANZEIGE, + ); + expect(inhalt, `${route}: Minor Units im Dokument`).not.toContain(ERWARTETE_KOSTEN_MINOR); + expect(inhalt, `${route}: Mitarbeitername im Dokument`).not.toContain(MITARBEITER_NAME); + } + + await seite.screenshot({ + path: test.info().outputPath("eyt113-kosten-forbidden.png"), + fullPage: true, + }); + bericht["eyt113_ladegrenze"] = { + kosten_chunks: kostenChunks, + chunk_anfragen_gesamt: chunkAnfragenB.length, + }; + }); } catch (fehler) { fehlerAusFall = [fehler]; } diff --git a/apps/web/test/kosten-page-gate.test.tsx b/apps/web/test/kosten-page-gate.test.tsx new file mode 100644 index 0000000..62fd7c4 --- /dev/null +++ b/apps/web/test/kosten-page-gate.test.tsx @@ -0,0 +1,228 @@ +/** + * Serverseitige Kosten-Ladegrenze der beiden Kostenseiten (EYT-113 Inkrement 2). + * + * ## Was hier gemessen wird + * + * `/kosten` und `/kosten/stundensaetze` fragen VOR jedem Kosteninhalt + * `leseKostenFreigabe()` (`lib/kosten-freigabe.ts`) und rendern in den vier + * Verweigerungszustaenden eine reine Server-Flaeche — OHNE die + * Kosten-Client-Komponenten. Der Client-Waechter `KostenZugang` ist dann kein + * zweiter Riegel, sondern gar nicht erst montiert: was nicht montiert ist, + * laedt keinen Chunk und traegt keine Props ins HTML + * (`rsc-flight-payload-contains-client-props`). + * + * ## RED-Beweis vor der Implementierung (TDD) + * + * Diese Datei entsteht BEVOR die Seiten die Freigabe rufen. Heute rendern + * beide Seiten `KostenZugang` bedingungslos; jede Verweigerungs-Zusicherung + * unten ist damit per Konstruktion rot (fehlende Server-Testid bzw. ein + * sichtbares `kosten-laedt`). Gruen wird sie erst durch die Ladegrenze selbst. + * + * ## Gegenmutationen (je Zusicherung im Test benannt) + * + * Nach der Implementierung macht z. B. „das Gate aus `stundensaetze/page.tsx` + * entfernen" die Stundensaetze-Haelfte rot, „`unbekannt` wie `abgemeldet` + * behandeln" den Unterscheidungsfall, „Any-Org-Pruefung statt ausgewaehlter + * Organisation" den `verboten`-Fall (die Flaeche naennte die falsche bzw. + * keine Organisation). + * + * ## Warum die Freigabe als GANZES Modul gemockt ist + * + * `leseKostenFreigabe` liest `next/headers` — das gibt es in jsdom nicht. + * Die Entscheidungsfunktion `kostenFreigabe` selbst ist in + * `kosten-freigabe.test.ts` erschoepfend gemessen; HIER geht es darum, was die + * Seiten aus jedem Ergebnis MACHEN. + */ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import KostenPage from "../app/(werkbank)/kosten/page"; +import StundensaetzePage from "../app/(werkbank)/kosten/stundensaetze/page"; +import { AuthGatewayProvider } from "../lib/auth-gateway-provider"; +import { leseKostenFreigabe, type KostenFreigabe } from "../lib/kosten-freigabe"; +import { SessionProvider } from "../lib/session-provider"; +import type { + AuthGateway, + GatewayResult, + SessionDto, + SessionOrganisation, +} from "@easytree/contracts"; + +vi.mock("next/navigation", async () => { + const modul = await import("./helpers/navigation-attrappe"); + return modul.nextNavigationModul(); +}); + +// Das GANZE Modul, nicht nur die Funktion: die Seiten importieren es als +// `../../../lib/kosten-freigabe` (bzw. eine Ebene tiefer) — vitest loest beide +// Pfade auf dieselbe Datei auf, der Mock gilt fuer alle Importeure. +vi.mock("../lib/kosten-freigabe", () => ({ leseKostenFreigabe: vi.fn() })); + +const ORG_OHNE_RECHT: SessionOrganisation = { + id: "00000000-0000-4000-8000-0000000000c1", + name: "Org Ohne Recht", + role: "member", + permissions: [], +}; + +const ORG_MIT_RECHT: SessionOrganisation = { + id: "00000000-0000-4000-8000-0000000000c2", + name: "Org Mit Recht", + role: "owner", + permissions: ["costs.read"], +}; + +/** + * Eine Sitzung, die NIE antwortet. + * + * In den Verweigerungsfaellen ist das die schaerfste Attrappe: bliebe der + * Client-Waechter faelschlich montiert, zeigt er deterministisch sein + * `kosten-laedt` — und genau das faellt unten auf. Eine AUFGELOESTE Sitzung + * koennte dagegen selbst eine der vier Testids rendern (`KostenZugang` traegt + * `kosten-unauthenticated` und `kosten-forbidden` woertlich) und wuerde die + * Server-Zusicherung gruen faerben, ohne dass ein Server-Gate existiert. + */ +const offeneSitzung = (): AuthGateway => + ({ + session: (): Promise> => new Promise(() => undefined), + login: vi.fn(), + logout: vi.fn(), + }) as unknown as AuthGateway; + +/** Angemeldet, eine Organisation, `costs.read` — der gewaehrte Client-Pfad. */ +const angemeldetMitKostenrecht = (): AuthGateway => + ({ + session: (): Promise> => + Promise.resolve({ + ok: true, + value: { + userId: "00000000-0000-4000-8000-00000000aaa2", + organisations: [ORG_MIT_RECHT], + }, + }), + login: vi.fn(), + logout: vi.fn(), + }) as unknown as AuthGateway; + +type Params = Record; + +async function seiteRendern( + route: "/kosten" | "/kosten/stundensaetze", + freigabe: KostenFreigabe, + optionen: { params?: Params; auth?: AuthGateway } = {}, +): Promise { + vi.mocked(leseKostenFreigabe).mockResolvedValue(freigabe); + // `await` vertraegt beide Formen: heute ist die Stundensaetze-Seite synchron, + // mit der Ladegrenze wird sie async — der Test bleibt dabei unveraendert. + const element = + route === "/kosten" + ? await KostenPage({ searchParams: Promise.resolve(optionen.params ?? {}) }) + : await StundensaetzePage(); + render( + + {element} + , + ); +} + +beforeEach(() => { + vi.mocked(leseKostenFreigabe).mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +const VERWEIGERUNGEN: readonly (readonly [string, KostenFreigabe])[] = [ + ["unbekannt", { art: "unbekannt" }], + ["abgemeldet", { art: "abgemeldet" }], + ["keine-auswahl", { art: "keine-auswahl" }], + ["verboten", { art: "verboten", organisation: ORG_OHNE_RECHT }], +]; + +describe.each(["/kosten", "/kosten/stundensaetze"] as const)( + "%s — serverseitige Ladegrenze (EYT-113 Inkrement 2)", + (route) => { + it("fragt die Freigabe genau einmal", async () => { + // Gegenmutation: den `leseKostenFreigabe()`-Aufruf aus der Seite + // entfernen — heute ist genau das der Zustand, der Zaehler steht auf 0. + await seiteRendern(route, { art: "gewaehrt", organisation: ORG_MIT_RECHT }); + expect(vi.mocked(leseKostenFreigabe)).toHaveBeenCalledTimes(1); + }); + + it("unbekannt: eigene Flaeche, NICHT der Abgemeldet-Banner", async () => { + await seiteRendern(route, { art: "unbekannt" }); + // Gegenmutation: `unbekannt` im Server-Gate wie `abgemeldet` behandeln — + // dann fehlt die erste Testid und die zweite erscheint. + expect(screen.getByTestId("kosten-sitzung-unbekannt")).toBeTruthy(); + expect(screen.queryByTestId("kosten-unauthenticated")).toBeNull(); + }); + + it("abgemeldet: Banner mit dem Weg zur Anmeldung", async () => { + await seiteRendern(route, { art: "abgemeldet" }); + const flaeche = screen.getByTestId("kosten-unauthenticated"); + // Gegenmutation: den Link entfernen oder auf `/` zeigen lassen. + const link = within(flaeche).getByRole("link"); + expect(link.getAttribute("href")).toBe("/anmelden"); + }); + + it("keine-auswahl: fordert die Organisationswahl", async () => { + // Gegenmutation: bei fehlender Auswahl still die erste Organisation + // nehmen — dann rendert der gewaehrte Zweig statt dieser Flaeche. + await seiteRendern(route, { art: "keine-auswahl" }); + expect(screen.getByTestId("kosten-org-auswahl")).toBeTruthy(); + }); + + it("verboten: nennt die AUSGEWAEHLTE Organisation beim Namen", async () => { + await seiteRendern(route, { art: "verboten", organisation: ORG_OHNE_RECHT }); + const flaeche = screen.getByTestId("kosten-forbidden"); + // Gegenmutation: die Organisation aus der Beschreibung streichen — die + // Planerin wuesste dann nicht, WO ihr das Recht fehlt. + expect(flaeche.textContent).toContain("Org Ohne Recht"); + }); + + it.each(VERWEIGERUNGEN)( + "%s: der Client-Waechter wird gar nicht erst montiert", + async (_name, freigabe) => { + await seiteRendern(route, freigabe); + // Der Kern der Ladegrenze — und der heutige rote Zustand: beide Seiten + // rendern `KostenZugang` bedingungslos, sein `kosten-laedt` steht im + // Baum. Gegenmutation nach der Implementierung: das Gate aus + // `stundensaetze/page.tsx` (bzw. `kosten/page.tsx`) entfernen. + expect(screen.queryByTestId("kosten-laedt")).toBeNull(); + expect(screen.queryByTestId("saetze-laedt")).toBeNull(); + expect(document.querySelector(".eyt-kosten-ansicht")).toBeNull(); + }, + ); + }, +); + +describe("/kosten — der gewaehrte Zweig bleibt der bestehende Client-Pfad", () => { + it("rendert keine Verweigerungsflaeche, sondern den Client-Waechter", async () => { + await seiteRendern("/kosten", { art: "gewaehrt", organisation: ORG_MIT_RECHT }); + for (const testid of [ + "kosten-forbidden", + "kosten-unauthenticated", + "kosten-org-auswahl", + "kosten-sitzung-unbekannt", + ]) { + expect(screen.queryByTestId(testid)).toBeNull(); + } + // Die Sitzung der Attrappe antwortet nie — der Client-Waechter zeigt also + // seinen Ladezustand. Gegenmutation: den gewaehrten Zweig ebenfalls auf + // eine Server-Flaeche umstellen und `KostenZugang` ganz entfernen. + expect(screen.getByTestId("kosten-laedt")).toBeTruthy(); + }); + + it("lehnt einen kaputten snapshot-Parameter AUCH im gewaehrten Zweig sichtbar ab", async () => { + // Gegenmutation: die Parameterpruefung beim Einbau der Ladegrenze aus dem + // gewaehrten Zweig fallen lassen — dann fehlt `kosten-parameterfehler`. + await seiteRendern( + "/kosten", + { art: "gewaehrt", organisation: ORG_MIT_RECHT }, + { params: { snapshot: "kaputt" }, auth: angemeldetMitKostenrecht() }, + ); + expect(await screen.findByTestId("kosten-parameterfehler")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Zur Stundensatzverwaltung" })).toBeTruthy(); + }); +}); From 8170bbbdebbdf524129e7f230b7f5f0924bb8ad6 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 22:50:12 +0200 Subject: [PATCH 05/11] feat(web): EYT-113 serverseitige costs.read-Ladegrenze an der ausgewaehlten Organisation Co-Authored-By: Claude Fable 5 --- apps/web/app/(werkbank)/kosten/page.tsx | 53 +++--- .../(werkbank)/kosten/stundensaetze/page.tsx | 24 ++- apps/web/app/providers.tsx | 33 +++- apps/web/components/kosten-grenze.tsx | 64 ++++++++ apps/web/lib/session-provider.tsx | 24 ++- .../test/session-provider-auswahl.test.tsx | 153 ++++++++++++++++++ 6 files changed, 321 insertions(+), 30 deletions(-) create mode 100644 apps/web/components/kosten-grenze.tsx create mode 100644 apps/web/test/session-provider-auswahl.test.tsx diff --git a/apps/web/app/(werkbank)/kosten/page.tsx b/apps/web/app/(werkbank)/kosten/page.tsx index 19d00db..07972d5 100644 --- a/apps/web/app/(werkbank)/kosten/page.tsx +++ b/apps/web/app/(werkbank)/kosten/page.tsx @@ -5,7 +5,9 @@ import { IdSchema } from "@easytree/contracts"; import { Card, PageHeader } from "@easytree/ui"; import { KostenAnsicht } from "../../../components/kosten-ansicht"; +import { KostenGrenze } from "../../../components/kosten-grenze"; import { KostenZugang } from "../../../components/kosten-zugang"; +import { leseKostenFreigabe } from "../../../lib/kosten-freigabe"; export const metadata: Metadata = { title: "Kosten — easyTree" }; @@ -23,12 +25,21 @@ 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 = @@ -53,25 +64,29 @@ export default async function KostenPage({ title="Kosten" description="Geplante Personalkosten je Baustelle und Tag — aus veröffentlichten Planversionen, bis zur Einzelposition." /> - - {geprueft !== null && !geprueft.success ? ( -

- Keine gültige Snapshot-Id in der Adresse. Erwartet wird - `?snapshot=<id-aus-der-Kostenansicht>`. -

- ) : ( - - )} - -

- Interne Netto-Stundensätze je Mitarbeiter, versioniert mit Gültigkeit — die Grundlage - jeder Kostenberechnung. -

-

- Zur Stundensatzverwaltung -

-
-
+ {freigabe.art !== "gewaehrt" ? ( + + ) : ( + + {geprueft !== null && !geprueft.success ? ( +

+ Keine gültige Snapshot-Id in der Adresse. Erwartet wird + `?snapshot=<id-aus-der-Kostenansicht>`. +

+ ) : ( + + )} + +

+ Interne Netto-Stundensätze je Mitarbeiter, versioniert mit Gültigkeit — die Grundlage + jeder Kostenberechnung. +

+

+ Zur Stundensatzverwaltung +

+
+
+ )} ); } diff --git a/apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx b/apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx index 02fb62c..99e3258 100644 --- a/apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx +++ b/apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx @@ -2,25 +2,41 @@ import type { Metadata } from "next"; import { PageHeader } from "@easytree/ui"; +import { KostenGrenze } from "../../../../components/kosten-grenze"; import { KostenZugang } from "../../../../components/kosten-zugang"; import { RateManagement } from "../../../../components/rate-management"; +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 `
`; 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(); + return ( <> - - - + {freigabe.art !== "gewaehrt" ? ( + + ) : ( + + + + )} ); } diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 3207902..cc6974e 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useMemo, useRef, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { createApiClient } from "../lib/api-client"; import { ApiClientProvider } from "../lib/api-client-provider"; @@ -8,6 +9,7 @@ import { AuthGatewayProvider } from "../lib/auth-gateway-provider"; import { createAuthGateway } from "../lib/auth-gateway-factory"; import { CostsGatewayProvider } from "../lib/costs-gateway-provider"; import { createCostsGateway } from "../lib/costs-gateway-factory"; +import { liesOrgAuswahlAusDokument, schreibeOrgAuswahl } from "../lib/organisations-auswahl-cookie"; import { createPlanningGateway } from "../lib/planning-gateway-factory"; import { PlanningGatewayProvider } from "../lib/planning-gateway-provider"; import { SessionProvider } from "../lib/session-provider"; @@ -32,6 +34,7 @@ const SAME_ORIGIN = ""; * stabil (kein Neubau bei jedem Orgwechsel). */ export function Providers({ children }: { children: ReactNode }) { + const router = useRouter(); const client = useMemo(() => createApiClient(SAME_ORIGIN), []); const planning = useMemo( () => createPlanningGateway(SAME_ORIGIN, (input, init) => fetch(input, init)), @@ -53,16 +56,36 @@ export function Providers({ children }: { children: ReactNode }) { [], ); - const setOrganisation = useCallback((id: string | null) => { - organisationRef.current = id; - }, []); + const setOrganisation = useCallback( + (id: string | null) => { + organisationRef.current = id; + // Cookie = Selector fuer das Server-Gate (EYT-113 Inkrement 2); der + // refresh laesst die Server-Flaechen der Auswahl folgen. Der + // Gleichheits-Guard verhindert Refresh-Schleifen und den Extra-RSC-Lauf + // beim unveraenderten Reload. + if (liesOrgAuswahlAusDokument() !== id) { + schreibeOrgAuswahl(id); + router.refresh(); + } + }, + [router], + ); + + // Der Initialwert laeuft nur clientseitig (useState-Initializer); beim SSR + // liefert er null — folgenlos, solange die Sitzung ohnehin "laedt" rendert. + const [initialeOrganisationId] = useState(liesOrgAuswahlAusDokument); return ( - {children} + + {children} + diff --git a/apps/web/components/kosten-grenze.tsx b/apps/web/components/kosten-grenze.tsx new file mode 100644 index 0000000..820bca8 --- /dev/null +++ b/apps/web/components/kosten-grenze.tsx @@ -0,0 +1,64 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +import { ErrorState, StateBanner } from "@easytree/ui"; + +import type { KostenFreigabe } from "../lib/kosten-freigabe"; + +/** + * Serverseitige Verweigerungsflaechen der Kosten-Ladegrenze (EYT-113 + * Inkrement 2). + * + * Server-Komponente OHNE "use client": in den vier Verweigerungszustaenden + * montieren die Kostenseiten NUR diese Flaeche — der Client-Waechter + * `KostenZugang` und die Kosten-Client-Komponenten kommen gar nicht in den + * Baum, laden also keinen Chunk und tragen keine Props ins HTML. Deshalb + * auch kein `onRetry`: eine Server-Flaeche traegt keine Handler. + * + * Die Wortlaute folgen dem Client-Waechter `kosten-zugang.tsx`, damit die + * Planerin server- wie clientseitig dieselbe Auskunft liest. Der Typ der + * Props schliesst `gewaehrt` aus: den gewaehrten Zweig rendert die Seite, + * nie diese Flaeche. + */ +export function KostenGrenze({ + freigabe, +}: { + freigabe: Exclude; +}): ReactNode { + switch (freigabe.art) { + case "unbekannt": + // Nichtwissen ist nicht "abgemeldet" — dieselbe Unterscheidung wie im + // Feld-Gate (`app/feld/layout.tsx`). + return ( + + ); + case "abgemeldet": + return ( + + Für den Kostenbereich ist eine Anmeldung erforderlich.{" "} + Zur Anmeldung + + ); + case "keine-auswahl": + return ( + + Du gehörst mehreren Organisationen an. Bitte wähle oben in der Kopfleiste eine aus — ohne + Auswahl lädt easyTree keine Kostendaten. + + ); + case "verboten": + // Nennt die AUSGEWAEHLTE Organisation: die Planerin muss lesen koennen, + // WO ihr das Recht fehlt — keine Any-Org-Aussage. + return ( + + ); + } +} diff --git a/apps/web/lib/session-provider.tsx b/apps/web/lib/session-provider.tsx index eff8ae7..7793145 100644 --- a/apps/web/lib/session-provider.tsx +++ b/apps/web/lib/session-provider.tsx @@ -13,6 +13,12 @@ import { useAuthGateway } from "./auth-gateway-provider"; * kein LocalStorage, kein Mock, keine abgeleitete Wahrheit. `laedt` ist das * Warten auf die erste Antwort; `fehler` heisst: der Zustand ist UNBEKANNT * (Netz/Server), nicht "abgemeldet" — die UI unterscheidet das sichtbar. + * + * Die Organisationsauswahl wird als Selector-Cookie an den Server gemeldet + * (die Kompositionswurzel schreibt es), aber NIE als Wahrheit gelesen — + * Wahrheit bleibt `GET /auth/session`: `initialeOrganisationId` ist nur der + * Startwert der Auswahl und zaehlt erst, wenn die Id in der verifizierten + * Session steht (EYT-113 Inkrement 2). */ export type SessionZustand = | { zustand: "laedt" } @@ -36,6 +42,7 @@ const SessionContext = createContext(null); export function SessionProvider({ children, onOrganisationChange, + initialeOrganisationId, }: { children: ReactNode; /** @@ -44,10 +51,18 @@ export function SessionProvider({ * je einen Header sehen. */ onOrganisationChange?: (id: string | null) => void; + /** + * Beim Mount gelesene Auswahl (Selector-Cookie). Nur ein Startwert: + * gueltig wird sie erst durch `orgs.find` gegen die verifizierte Session — + * eine fremde Id faellt dort ersatzlos. + */ + initialeOrganisationId?: string | null; }) { const gateway = useAuthGateway(); const [sitzung, setSitzung] = useState({ zustand: "laedt" }); - const [gewaehlteOrgId, setGewaehlteOrgId] = useState(null); + const [gewaehlteOrgId, setGewaehlteOrgId] = useState( + initialeOrganisationId ?? null, + ); const [ladelauf, setLadelauf] = useState(0); useEffect(() => { @@ -77,8 +92,13 @@ export function SessionProvider({ }, [sitzung, gewaehlteOrgId]); useEffect(() => { + // Solange die Sitzung ungeklaert ist ("laedt"/"fehler"), wird NICHT + // gemeldet: ein transienter null wuerde das Auswahl-Cookie bei jedem + // Seitenaufbau loeschen. "abgemeldet" ist dagegen eine klare Auskunft + // und meldet null. + if (sitzung.zustand === "laedt" || sitzung.zustand === "fehler") return; onOrganisationChange?.(organisation?.id ?? null); - }, [organisation, onOrganisationChange]); + }, [sitzung, organisation, onOrganisationChange]); const wert = useMemo( () => ({ diff --git a/apps/web/test/session-provider-auswahl.test.tsx b/apps/web/test/session-provider-auswahl.test.tsx new file mode 100644 index 0000000..b8f5985 --- /dev/null +++ b/apps/web/test/session-provider-auswahl.test.tsx @@ -0,0 +1,153 @@ +/** + * Auswahl-Meldung des SessionProvider an die Kompositionswurzel (EYT-113 + * Inkrement 2). + * + * Der Provider meldet die ausgewaehlte Organisation ueber + * `onOrganisationChange` nach oben; die Kompositionswurzel schreibt daraus + * das Selector-Cookie fuer das Server-Gate. Zwei Eigenschaften haengen daran: + * + * 1. Waehrend die Sitzung ungeklaert ist ("laedt"/"fehler"), wird NICHT + * gemeldet — ein transienter `null` wuerde das Auswahl-Cookie bei jedem + * Seitenaufbau loeschen. + * 2. `initialeOrganisationId` (das beim Mount gelesene Cookie) stellt die + * Auswahl wieder her — aber nur, wenn die Id in der real verifizierten + * Session steht; eine fremde Id faellt ersatzlos. + * + * Attrappen-Muster wie `planung-page.test.tsx`: das AuthGateway liefert ein + * steuerbares `session()`-Versprechen, damit der Schwebezustand messbar ist. + */ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AuthGatewayProvider } from "../lib/auth-gateway-provider"; +import { SessionProvider, useSession } from "../lib/session-provider"; +import type { + AuthGateway, + GatewayResult, + SessionDto, + SessionOrganisation, +} from "@easytree/contracts"; + +const ORG_ALPHA: SessionOrganisation = { + id: "00000000-0000-4000-8000-0000000000d1", + name: "Alpha", + role: "owner", + permissions: ["costs.read"], +}; + +const ORG_BETA: SessionOrganisation = { + id: "00000000-0000-4000-8000-0000000000d2", + name: "Beta", + role: "member", + permissions: [], +}; + +const FREMDE_ID = "00000000-0000-4000-8000-00000000ffff"; + +/** Steuerbares Gateway: `session()` schwebt, bis der Test aufloest. */ +function steuerbaresGateway(): { + gateway: AuthGateway; + aufloesen: (ergebnis: GatewayResult) => void; +} { + let aufloesen!: (ergebnis: GatewayResult) => void; + const versprechen = new Promise>((resolve) => { + aufloesen = resolve; + }); + const gateway = { + session: (): Promise> => versprechen, + login: vi.fn(), + logout: vi.fn().mockResolvedValue({ ok: true, value: undefined }), + } as unknown as AuthGateway; + return { gateway, aufloesen }; +} + +const sitzungMit = (...orgs: SessionOrganisation[]): GatewayResult => ({ + ok: true, + value: { userId: "00000000-0000-4000-8000-00000000aaa3", organisations: orgs }, +}); + +/** Sichtbarer Abmeldeknopf — der Test drueckt, was die Kopfleiste drueckt. */ +function AbmeldeKnopf() { + const { abmelden } = useSession(); + return ( + + ); +} + +function providerRendern(optionen: { + gateway: AuthGateway; + gemeldet: (id: string | null) => void; + initialeOrganisationId?: string | null; +}): void { + render( + + + + + , + ); +} + +afterEach(() => { + cleanup(); +}); + +describe("SessionProvider — Meldung der Organisationsauswahl (EYT-113 Inkrement 2)", () => { + it("meldet NICHTS, solange die Sitzung laedt", () => { + // Gegenmutation: den laedt/fehler-Guard aus dem Melde-Effekt entfernen — + // dann feuert der Effekt beim Mount mit `null` und dieser Fall wird rot. + const gemeldet = vi.fn(); + const { gateway } = steuerbaresGateway(); + providerRendern({ gateway, gemeldet }); + expect(gemeldet).not.toHaveBeenCalled(); + }); + + it("meldet die einzige Organisation genau einmal, sobald die Sitzung steht", async () => { + // Gegenmutation: die Meldung an `sitzung` statt an `organisation` haengen + // und doppelt feuern — der Einmal-Zaehler unten wird rot. + const gemeldet = vi.fn(); + const { gateway, aufloesen } = steuerbaresGateway(); + providerRendern({ gateway, gemeldet }); + aufloesen(sitzungMit(ORG_ALPHA)); + await waitFor(() => expect(gemeldet).toHaveBeenCalledWith(ORG_ALPHA.id)); + expect(gemeldet.mock.calls.filter(([id]) => id === ORG_ALPHA.id)).toHaveLength(1); + }); + + it("stellt eine gemerkte Auswahl aus initialeOrganisationId wieder her", async () => { + // Gegenmutation: `initialeOrganisationId` ignorieren (useState(null)) — + // bei zwei Organisationen bliebe die Auswahl leer, gemeldet wuerde null. + const gemeldet = vi.fn(); + const { gateway, aufloesen } = steuerbaresGateway(); + providerRendern({ gateway, gemeldet, initialeOrganisationId: ORG_BETA.id }); + aufloesen(sitzungMit(ORG_ALPHA, ORG_BETA)); + await waitFor(() => expect(gemeldet).toHaveBeenCalledWith(ORG_BETA.id)); + }); + + it("verwirft eine fremde initialeOrganisationId — gemeldet wird null", async () => { + // Gegenmutation: die gemerkte Id UNGEPRUEFT als Organisation melden — + // dann kaeme die fremde Id oben an statt null. + const gemeldet = vi.fn(); + const { gateway, aufloesen } = steuerbaresGateway(); + providerRendern({ gateway, gemeldet, initialeOrganisationId: FREMDE_ID }); + aufloesen(sitzungMit(ORG_ALPHA, ORG_BETA)); + await waitFor(() => expect(gemeldet).toHaveBeenCalledWith(null)); + expect(gemeldet).not.toHaveBeenCalledWith(FREMDE_ID); + }); + + it("meldet null nach dem Abmelden", async () => { + // Gegenmutation: den abgemeldet-Zustand mit in den Melde-Guard nehmen — + // dann bliebe nach dem Abmelden die alte Id als letzte Meldung stehen. + const gemeldet = vi.fn(); + const { gateway, aufloesen } = steuerbaresGateway(); + providerRendern({ gateway, gemeldet }); + aufloesen(sitzungMit(ORG_ALPHA)); + await waitFor(() => expect(gemeldet).toHaveBeenCalledWith(ORG_ALPHA.id)); + fireEvent.click(screen.getByRole("button", { name: "Abmelden" })); + await waitFor(() => expect(gemeldet).toHaveBeenLastCalledWith(null)); + }); +}); From 5f88862b46ab156e3682cb073639f78283fedbd2 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 23:21:03 +0200 Subject: [PATCH 06/11] test(web): EYT-113 Schritt 11 auf Selector-Cookie geschaerft, Logout-Loeschung bewiesen Co-Authored-By: Claude Fable 5 --- apps/web/e2e/auth-journey/journey.pwtest.ts | 54 +++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts index 318c996..f439b9d 100644 --- a/apps/web/e2e/auth-journey/journey.pwtest.ts +++ b/apps/web/e2e/auth-journey/journey.pwtest.ts @@ -341,6 +341,15 @@ async function pruefeBarrierefreiheit( * - Die Kosten-Navigation unabhaengig von `costs.read` rendern (Schritt 6 * bliebe gruen, aber Schritt 11 nach dem Abmelden wuerde rot). * - Das Logout ohne loeschende Cookies (Schritt 12). + * NACHTRAG EYT-113 Inkrement 2: `eyt_org` ist der bewusst SICHTBARE + * Selector der Organisationsauswahl (nie Autorisierung, nie Geheimnis — + * der Server prueft ihn gegen die echte Session). Schritt 11 prueft + * seither die NAMENSMENGE der sichtbaren Cookies (Teilmenge von + * { eyt_org }) und den Wert (exakt die Org-UUID, kein Punkt) statt Leere — + * ein `eyt_access` ohne `HttpOnly` macht ihn also WEITERHIN rot, jeder + * neue sichtbare Cookie ebenfalls. Und Schritt 12 sichert zusaetzlich zu, + * dass das Abmelden den Selector loescht: Clear-Zweig + * (`schreibeOrgAuswahl(null)`) in `app/providers.tsx` entfernen -> rot. * - `app.is_runtime_channel()` aus der Update-Policy von `plan_versions` * entfernen (Migration 0015): Schritt 9c2 wird rot, weil PostgREST die * Planversion dann tatsaechlich veroeffentlicht. Das ist der P1-Nachweis @@ -2998,12 +3007,40 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ const speicher = await page.evaluate(() => ({ local: Object.entries(localStorage).map(([k, v]) => `${k}=${String(v)}`), session: Object.entries(sessionStorage).map(([k, v]) => `${k}=${String(v)}`), - // Cookies, die JavaScript SEHEN kann — bei HttpOnly ist das keiner. + // Cookies, die JavaScript SEHEN kann — die Token-Cookies (HttpOnly) + // gehoeren NIE dazu. sichtbareCookies: document.cookie, })); expect(speicher.local).toEqual([]); expect(speicher.session).toEqual([]); - expect(speicher.sichtbareCookies).toBe(""); + + // Seit EYT-113 Inkrement 2 ist GENAU EIN sichtbares Cookie vorgesehen: + // `eyt_org`, der Selector der Organisationsauswahl (nie Autorisierung, + // nie Geheimnis — der Server prueft ihn gegen die real verifizierte + // Session, lib/kosten-freigabe.ts). Die Zusicherung prueft seither die + // NAMENSMENGE und den Wert statt Leere — jeder neue sichtbare Cookie + // macht sie rot: ein `eyt_access`/`eyt_refresh` ohne HttpOnly genauso + // wie jeder fremde Name, und ein Tokenwert im Selector faellt am + // Wertevergleich plus Punkt-Waechter. + const sichtbare = speicher.sichtbareCookies + .split(";") + .map((teil) => teil.trim()) + .filter((teil) => teil !== "") + .map((teil) => { + const gleich = teil.indexOf("="); + return gleich === -1 + ? { name: teil, wert: "" } + : { name: teil.slice(0, gleich), wert: teil.slice(gleich + 1) }; + }); + // Namensmenge: Teilmenge von { eyt_org } — strenger als die alte + // Leere-Zusicherung fuer alles, was nicht der Selector ist. + const fremdeNamen = sichtbare.map((c) => c.name).filter((name) => name !== "eyt_org"); + expect(fremdeNamen).toEqual([]); + // Wert: exakt die Fixtur-Organisation — eine UUID, strukturell kein JWT. + const orgCookie = sichtbare.find((c) => c.name === "eyt_org"); + expect(orgCookie?.wert).toBe(ORG_ID); + // Ein JWT traegt immer zwei Punkte, eine Org-UUID keinen einzigen. + expect(orgCookie?.wert).not.toContain("."); // `eyJ` ist der Anfang jedes base64url-kodierten JWT-Headers. Erscheint er // im gerenderten HTML, ist ein Token in den DOM geraten. @@ -3013,7 +3050,7 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ schritte["11_browserspeicher"] = { localStorage: 0, sessionStorage: 0, - sichtbare_cookies: "", + sichtbare_cookies: speicher.sichtbareCookies, token_im_dom: false, }; }); @@ -3026,6 +3063,16 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ expect(danach.find((k) => k.name === "eyt_access")).toBeUndefined(); expect(danach.find((k) => k.name === "eyt_refresh")).toBeUndefined(); + // EYT-113: das Abmelden loescht auch den Selector `eyt_org`. Das ist der + // Clear-Zweig der Kompositionswurzel — onOrganisationChange(null) -> + // schreibeOrgAuswahl(null) in app/providers.tsx —, den sonst kein + // Nachweis ausuebt. Er feuert in einem React-Effekt NACH dem + // Abmelde-Commit, deshalb poll statt Einmal-Blick. Gegenmutation: + // Clear-Zweig in providers.tsx entfernen -> diese Zusicherung wird rot. + await expect + .poll(async () => (await context.cookies()).map((k) => k.name)) + .not.toContain("eyt_org"); + // Nicht nur "das Cookie ist weg", sondern "der Server laesst nicht mehr // durch": ohne diese Zusicherung bewiese der Test nur, dass der Browser // vergessen hat. @@ -3041,6 +3088,7 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ schritte["12_abmeldung"] = { cookies_geloescht: true, + selector_geloescht: true, session_status: sitzung.status(), kosten_status: kosten.status(), }; From 8e8f04f161bd58a957a224a25c7205d827ec780c Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 29 Aug 2026 23:21:07 +0200 Subject: [PATCH 07/11] =?UTF-8?q?feat(web):=20EYT-113=20client-seitige=20I?= =?UTF-8?q?mportgrenze=20=E2=80=94=20Kosten-Chunks=20erst=20nach=20Freigab?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/web/app/(werkbank)/kosten/page.tsx | 57 ++++++++-------- .../(werkbank)/kosten/stundensaetze/page.tsx | 25 ++++--- apps/web/components/kosten-flaeche.tsx | 65 +++++++++++++++++++ apps/web/components/stundensaetze-flaeche.tsx | 39 +++++++++++ apps/web/test/kosten-page-gate.test.tsx | 20 ++++-- 5 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 apps/web/components/kosten-flaeche.tsx create mode 100644 apps/web/components/stundensaetze-flaeche.tsx diff --git a/apps/web/app/(werkbank)/kosten/page.tsx b/apps/web/app/(werkbank)/kosten/page.tsx index 07972d5..9691288 100644 --- a/apps/web/app/(werkbank)/kosten/page.tsx +++ b/apps/web/app/(werkbank)/kosten/page.tsx @@ -1,12 +1,11 @@ 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 { KostenFlaeche } from "../../../components/kosten-flaeche"; import { KostenGrenze } from "../../../components/kosten-grenze"; -import { KostenZugang } from "../../../components/kosten-zugang"; import { leseKostenFreigabe } from "../../../lib/kosten-freigabe"; export const metadata: Metadata = { title: "Kosten — easyTree" }; @@ -45,6 +44,32 @@ export default async function KostenPage({ 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 `