diff --git a/apps/api/test/architecture-red-case.test.ts b/apps/api/test/architecture-red-case.test.ts index 3dd9a9ff..eac50856 100644 --- a/apps/api/test/architecture-red-case.test.ts +++ b/apps/api/test/architecture-red-case.test.ts @@ -99,6 +99,31 @@ describe("Rot-Fall", () => { rmSync(join(root, "apps/api/src/modules/planning"), { recursive: true, force: true }); }); + it("faengt einen Werkbank-Import und ein fremdes Paket in der Feld-Shell", () => { + // Der realistischste Verstoss der Zwei-Client-Architektur (EYT-113): eine + // Feld-Datei importiert eine Kosten-Komponente der Werkbank — und ein + // Paket, das nicht auf der Feld-Allowlist steht. + write("apps/web/components/kosten-ansicht.tsx", "export const KostenAnsicht = 1;\n"); + write( + "apps/web/app/feld/bad.tsx", + [ + 'import { KostenAnsicht } from "../../components/kosten-ansicht";', + 'import { irgendwas } from "@easytree/domain";', + "export const bad = [KostenAnsicht, irgendwas] as const;", + ].join("\n") + "\n", + ); + const found = violationsFor().violations.filter((v) => v.rule === "feld-shell-boundary"); + expect( + found.some((v) => v.message.includes("apps/web/components/kosten-ansicht")), + "Werkbank-Import nicht erkannt", + ).toBe(true); + expect( + found.some((v) => v.message.includes('"@easytree/domain"')), + "fremdes Paket nicht erkannt", + ).toBe(true); + rmSync(join(root, "apps/web"), { recursive: true, force: true }); + }); + it("faengt einen Querimport an der oeffentlichen Modul-API vorbei", () => { write( "apps/api/src/modules/planning/application/bypass.ts", diff --git a/apps/api/test/architecture.test.ts b/apps/api/test/architecture.test.ts index b46b8d61..70943d06 100644 --- a/apps/api/test/architecture.test.ts +++ b/apps/api/test/architecture.test.ts @@ -12,7 +12,7 @@ * Dateien — er belohnt nicht das Anlegen leerer Dateien, um einen Zähler zu heben. */ import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -58,6 +58,9 @@ function istGewolltUnaufloesbar(von: string, spezifizierer: string): boolean { return /^apps\/[^/]+\/cloudflare\/[^/]+\.mjs$/.test(von) && spezifizierer.startsWith("../dist/"); } +/** Die drei Verzeichnisse der Feld-Shell (EYT-113) — eine Liste, zwei Faelle. */ +const FELD_PRAEFIXE = ["apps/web/app/feld/", "apps/web/components/feld/", "apps/web/lib/feld/"]; + const refs = extractImports(repoRoot, files); const { violations, scopeCounts } = evaluate(refs); const starReExports = findStarReExports(repoRoot, files); @@ -125,6 +128,54 @@ describe("Architekturgrenzen", () => { ); }); + it("Regel feld-shell-boundary ueberwacht JEDE Datei der Feld-Shell", () => { + // Dieselbe Lehre wie bei ui-dependency-allowlist: `>= 1` unterscheidet + // nicht zwischen "bewacht die Shell" und "bewacht eine Datei". Die linke + // Seite kommt aus den Praefixen ueber die eingesammelten Importe, die + // rechte aus `inScope` der Regel — beide Wege muessen dieselbe Menge + // ergeben. Importfreie Dateien tauchen auf keiner Seite auf; dass der + // SCANNER keine Feld-Datei verschluckt, sichert der naechste Fall. + const ausDemPraefix = new Set( + refs + .filter((ref) => FELD_PRAEFIXE.some((praefix) => ref.from.startsWith(praefix))) + .map((ref) => ref.from), + ); + expect(ausDemPraefix.size).toBeGreaterThan(3); + expect([...(scopeCounts.get("feld-shell-boundary") ?? [])].sort()).toEqual( + [...ausDemPraefix].sort(), + ); + }); + + it("der Collector sammelt JEDE Quelldatei der Feld-Shell von der Platte ein", () => { + // Sourcery-Befund an PR #97: der refs-basierte Mengenvergleich oben ist + // gegenueber Dateien blind, die der Scanner NIE einsammelt — die fehlen + // dann auf beiden Seiten, und der Vergleich bleibt gruen. Deshalb hier + // die unabhaengige Wahrheit: ein eigenes Verzeichnislisting der drei + // Feld-Verzeichnisse (readdirSync, nicht der Collector selbst) muss + // exakt der Menge entsprechen, die `collectSourceFiles` eingesammelt + // hat. Ein SKIP_DIRS-Fehler oder eine vergessene Endung wird damit rot, + // und eine neue importfreie Datei erscheint auf beiden Seiten — sie hat + // zwar nichts zu pruefen (keine Importe, keine moegliche Verletzung), + // aber ihr Einsammeln ist ab jetzt zugesichert. + const QUELL_ENDUNGEN = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/; + const vonDerPlatte: string[] = []; + for (const wurzel of FELD_PRAEFIXE) { + const absolut = join(repoRoot, wurzel); + if (!existsSync(absolut)) continue; + for (const eintrag of readdirSync(absolut, { recursive: true, withFileTypes: true })) { + if (!eintrag.isFile() || !QUELL_ENDUNGEN.test(eintrag.name)) continue; + vonDerPlatte.push( + relative(repoRoot, join(eintrag.parentPath, eintrag.name)).split("\\").join("/"), + ); + } + } + expect(vonDerPlatte.length).toBeGreaterThan(3); + const eingesammelt = files + .filter((datei) => FELD_PRAEFIXE.some((praefix) => datei.startsWith(praefix))) + .sort(); + expect(eingesammelt).toEqual([...vonDerPlatte].sort()); + }); + it.each([...SCAFFOLDED_MODULES])( "Modul %s besitzt echten Inhalt und eine oeffentliche API", (slug) => { diff --git a/apps/api/test/architecture/rules.ts b/apps/api/test/architecture/rules.ts index cb298ec5..4156b0a5 100644 --- a/apps/api/test/architecture/rules.ts +++ b/apps/api/test/architecture/rules.ts @@ -139,6 +139,47 @@ const UI_ALLOWED_PACKAGES: readonly RegExp[] = [/^react$/]; const UI_PACKAGE = "packages/ui/src/"; +/** + * Was die FELD-SHELL importieren darf (EYT-113). + * + * Die Mitarbeiter-Shell (`apps/web/app/feld/`, `apps/web/components/feld/`, + * `apps/web/lib/feld/`) teilt Auth-, API-, Vertrags- und Domainbasis mit der + * Werkbank — aber KEINE Werkbank-Oberflaeche: Admin- und Kosten-Komponenten + * duerfen das Mitarbeiter-Bundle nicht erreichen, auch nicht unbeabsichtigt. + * Wieder eine ALLOWLIST (dieselbe Lehre wie `domain-allowlist`): eine + * Sperrliste `components/kosten-*` liesse jede kuenftige Admin-Komponente + * durch, bis jemand die Liste pflegt. + * + * `@easytree/domain` steht bewusst NICHT hier: die Feld-Shell dieses + * Inkrements braucht es nicht, und eine Allowlist waechst erst, wenn ein + * realer Konsument es verlangt. + */ +const FELD_SHELL_SCOPES: readonly string[] = [ + "apps/web/app/feld/", + "apps/web/components/feld/", + "apps/web/lib/feld/", +]; + +const FELD_ALLOWED_PACKAGES: readonly RegExp[] = [ + /^react$/, + /^next$/, + /^next\/.+$/, + /^@easytree\/ui$/, + /^@easytree\/contracts$/, +]; + +/** + * Wohin relative Importe der Feld-Shell aufgeloest zeigen duerfen: in die + * Feld-Verzeichnisse selbst und in die geteilte Infrastruktur `lib/` + * (Session-Provider, Gateways, Proxyziel). `components/` ausserhalb von + * `components/feld/` ist Werkbank-Oberflaeche und bleibt draussen. + */ +const FELD_ALLOWED_RESOLVED: readonly string[] = [ + "apps/web/app/feld/", + "apps/web/components/feld/", + "apps/web/lib/", +]; + export const RULES: readonly Rule[] = [ { // ADR-001 Z. 74 — als Allowlist formuliert, siehe Dateikopf. @@ -356,6 +397,28 @@ export const RULES: readonly Rule[] = [ return null; }, }, + { + // EYT-113 — die Import-Grenze der Zwei-Client-Architektur, als Allowlist. + id: "feld-shell-boundary", + inScope: (file): boolean => FELD_SHELL_SCOPES.some((scope) => file.startsWith(scope)), + check: (ref): string | null => { + if (ref.isTypeReference) { + return `Die Feld-Shell darf keine /// fuehren — sie zieht eine ganze Ambient-Oberflaeche in den Scope.`; + } + if (!ref.specifier.startsWith(".")) { + if (FELD_ALLOWED_PACKAGES.some((allowed) => allowed.test(ref.specifier))) return null; + return `"${ref.specifier}" steht nicht auf der Importliste der Feld-Shell (EYT-113). Erlaubt sind react, next, @easytree/ui und @easytree/contracts. Der Ausweg ist NICHT, diese Liste blind zu erweitern: was die Feld-Shell braucht, kommt als geteiltes Primitive nach packages/ui oder als geteilte Infrastruktur nach apps/web/lib.`; + } + const resolved = ref.resolved; + if (resolved === null) { + return `Relativer Import "${ref.specifier}" ist nicht aufloesbar — der Scanner koennte ihn sonst stillschweigend uebergehen.`; + } + if (!FELD_ALLOWED_RESOLVED.some((prefix) => resolved.startsWith(prefix))) { + return `Die Feld-Shell darf nicht nach "${resolved}" greifen (EYT-113): Werkbank-Komponenten und -Seiten bleiben aus dem Mitarbeiter-Bundle draussen.`; + } + return null; + }, + }, ]; export function evaluate(refs: readonly ImportRef[]): { diff --git a/apps/web/app/anmelden/page.tsx b/apps/web/app/(werkbank)/anmelden/page.tsx similarity index 90% rename from apps/web/app/anmelden/page.tsx rename to apps/web/app/(werkbank)/anmelden/page.tsx index fd269beb..5eb194c0 100644 --- a/apps/web/app/anmelden/page.tsx +++ b/apps/web/app/(werkbank)/anmelden/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { PageHeader } from "@easytree/ui"; -import { LoginForm } from "../../components/login-form"; +import { LoginForm } from "../../../components/login-form"; export const metadata: Metadata = { title: "Anmelden — easyTree" }; diff --git a/apps/web/app/kosten/page.tsx b/apps/web/app/(werkbank)/kosten/page.tsx similarity index 95% rename from apps/web/app/kosten/page.tsx rename to apps/web/app/(werkbank)/kosten/page.tsx index 89ab8d63..19d00db3 100644 --- a/apps/web/app/kosten/page.tsx +++ b/apps/web/app/(werkbank)/kosten/page.tsx @@ -4,8 +4,8 @@ import Link from "next/link"; import { IdSchema } from "@easytree/contracts"; import { Card, PageHeader } from "@easytree/ui"; -import { KostenAnsicht } from "../../components/kosten-ansicht"; -import { KostenZugang } from "../../components/kosten-zugang"; +import { KostenAnsicht } from "../../../components/kosten-ansicht"; +import { KostenZugang } from "../../../components/kosten-zugang"; export const metadata: Metadata = { title: "Kosten — easyTree" }; diff --git a/apps/web/app/kosten/stundensaetze/page.tsx b/apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx similarity index 82% rename from apps/web/app/kosten/stundensaetze/page.tsx rename to apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx index d7b221da..02fb62cb 100644 --- a/apps/web/app/kosten/stundensaetze/page.tsx +++ b/apps/web/app/(werkbank)/kosten/stundensaetze/page.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import { PageHeader } from "@easytree/ui"; -import { KostenZugang } from "../../../components/kosten-zugang"; -import { RateManagement } from "../../../components/rate-management"; +import { KostenZugang } from "../../../../components/kosten-zugang"; +import { RateManagement } from "../../../../components/rate-management"; export const metadata: Metadata = { title: "Stundensätze — easyTree" }; diff --git a/apps/web/app/(werkbank)/layout.tsx b/apps/web/app/(werkbank)/layout.tsx new file mode 100644 index 00000000..8f4856d3 --- /dev/null +++ b/apps/web/app/(werkbank)/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +import { AppShell } from "../../components/app-shell"; + +/** + * Werkbank-Layout (EYT-113): die bestehende Admin-/Werkbank-Shell gilt nur + * noch für die Routen dieser Route Group. Die Route Group ändert keine URLs, + * sie zieht die Shell-Grenze — die Feld-Shell unter `app/feld/` hat ein + * eigenes Layout und eine eigene Navigation. + */ +export default function WerkbankLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/app/(werkbank)/page.tsx b/apps/web/app/(werkbank)/page.tsx new file mode 100644 index 00000000..f3b2ed3f --- /dev/null +++ b/apps/web/app/(werkbank)/page.tsx @@ -0,0 +1,26 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { StartInhalt } from "../../components/start-inhalt"; +import { leseServerSitzung } from "../../lib/feld/sitzung-server"; +import { startShellFuer } from "../../lib/feld/start-shell"; + +/** + * Werkbank-Startseite mit Start-Shell-Dispatch (EYT-113): die zulaessige + * Start-Shell folgt SERVERSEITIG aus der real verifizierten Session. Wer + * ausschliesslich als member arbeitet, gehoert ins Feld und wird dorthin + * geleitet — die Route selbst verleiht kein Recht, sie liest nur. + * + * Abgemeldete und NICHT PRUEFBARE Sitzungen sehen die Startseite unveraendert: + * sie ist oeffentlich, und Nichtwissen darf niemanden aussperren oder + * umleiten (fail-open ist hier richtig, weil kein Inhalt geschuetzt ist — + * geschuetzte Inhalte pruefen API und RLS). + */ +export default async function HomePage() { + const kopfzeilen = await headers(); + const sitzung = await leseServerSitzung(kopfzeilen.get("cookie")); + if (sitzung.zustand === "angemeldet" && startShellFuer(sitzung.session) === "feld") { + redirect("/feld"); + } + return ; +} diff --git a/apps/web/app/planung/page.tsx b/apps/web/app/(werkbank)/planung/page.tsx similarity index 96% rename from apps/web/app/planung/page.tsx rename to apps/web/app/(werkbank)/planung/page.tsx index 39dc3773..df9a5ec6 100644 --- a/apps/web/app/planung/page.tsx +++ b/apps/web/app/(werkbank)/planung/page.tsx @@ -1,8 +1,8 @@ import { EUROPE_BERLIN } from "@easytree/domain"; -import { PlanungAnsicht } from "../../components/planung-ansicht"; -import { PlanungsWerkbank } from "../../components/planungs-werkbank"; -import { wochenmodell, type Fehlergrund } from "../../lib/wochennavigation"; +import { PlanungAnsicht } from "../../../components/planung-ansicht"; +import { PlanungsWerkbank } from "../../../components/planungs-werkbank"; +import { wochenmodell, type Fehlergrund } from "../../../lib/wochennavigation"; /** * Einstieg in die Planungswerkbank (EYT-50, erweitert um EYT-140 M5). diff --git a/apps/web/app/feld/layout.tsx b/apps/web/app/feld/layout.tsx new file mode 100644 index 00000000..90106f14 --- /dev/null +++ b/apps/web/app/feld/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import type { ReactNode } from "react"; + +import { ErrorState } from "@easytree/ui"; + +import { FeldShell } from "../../components/feld/feld-shell"; +import { leseServerSitzung } from "../../lib/feld/sitzung-server"; + +export const metadata: Metadata = { title: "Feld — easyTree" }; + +/** + * Server-Gate der Feld-Shell (EYT-113): die zulaessige Shell folgt aus der + * REAL VERIFIZIERTEN Session — die API prueft die Cookies, nicht diese + * Datei, und nicht die Route. `headers()` macht den Teilbaum dynamisch; + * das Proxyziel wird damit nie zur Bauzeit gelesen (EYT-126). + * + * Fail-closed in beide Richtungen: Abgemeldete werden zur Anmeldung + * geleitet, und NICHTWISSEN (API nicht erreichbar, Antwort unlesbar) ist + * nicht abgemeldet — es zeigt eine ehrliche Fehlerflaeche statt Shell. + */ +export default async function FeldLayout({ children }: { children: ReactNode }) { + const kopfzeilen = await headers(); + const sitzung = await leseServerSitzung(kopfzeilen.get("cookie")); + + if (sitzung.zustand === "abgemeldet") { + redirect("/anmelden"); + } + + if (sitzung.zustand === "unbekannt") { + return ( +
+ +
+ ); + } + + return {children}; +} diff --git a/apps/web/app/feld/page.tsx b/apps/web/app/feld/page.tsx new file mode 100644 index 00000000..61099669 --- /dev/null +++ b/apps/web/app/feld/page.tsx @@ -0,0 +1,19 @@ +import { PageHeader } from "@easytree/ui"; + +import { FeldStart } from "../../components/feld/feld-start"; + +/** + * Startseite der Feld-App (EYT-113). Muster wie die Werkbank-Seiten: + * Fragment, KEIN eigenes `
` — die Landmark stellt die Feld-Shell. + */ +export default function FeldPage() { + return ( + <> + + + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7bc3a891..15c20dd0 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -648,3 +648,52 @@ body { color: var(--eyt-text-secondary); font-size: 0.875rem; } + +/* ============================================================ + * Basisdesign v2.0 — Feld-Shell (EYT-113) + * ============================================================ */ + +/* Die Feld-Shell teilt das Geruest (eyt-app-shell__*) mit der Werkbank; + eigen ist nur die Feld-Informationsarchitektur. Mobile-first: alles + einspaltig, keine Mindestbreiten — 320 px ist die Grundflaeche. */ + +.feld-marke { + color: var(--eyt-text-secondary); + font-weight: 400; +} + +.feld-organisationen { + margin: 0 0 var(--space); + padding: 0; + list-style: none; + display: grid; + gap: 0.5rem; +} + +.feld-organisation { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + padding: var(--space); + border: 1px solid var(--eyt-border-default); + border-radius: var(--radius); + background: var(--eyt-bg-surface); +} + +.feld-organisation__name { + font-weight: 700; +} + +.feld-organisation__rolle { + color: var(--eyt-text-secondary); + font-size: 0.875rem; +} + +/* Ehrliche Fehlerflaeche des Server-Gates: dieselbe main-Vermessung wie die + Shell, damit die Meldung nicht am Viewportrand klebt. */ +.feld-gate { + display: grid; + align-content: start; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 9436800b..7e36686e 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; -import { AppShell } from "../components/app-shell"; import { Providers } from "./providers"; import "./globals.css"; @@ -21,13 +20,16 @@ export const viewport: Viewport = { themeColor: "#166534", }; +/** + * Wurzel-Layout: nur Dokumentrahmen und Provider (EYT-113). Welche Shell ein + * Nutzer sieht, entscheiden die Layouts der Teilbäume — `(werkbank)` für die + * Admin-Werkbank, `feld` für die Mitarbeiter-Feld-Shell. + */ export default function RootLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); diff --git a/apps/web/components/feld/feld-shell.tsx b/apps/web/components/feld/feld-shell.tsx new file mode 100644 index 00000000..6ef21fb9 --- /dev/null +++ b/apps/web/components/feld/feld-shell.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import type { ReactNode } from "react"; + +import { AppShell as UiAppShell } from "@easytree/ui"; + +import { useSession } from "../../lib/session-provider"; + +/** + * Shell der Feld-App (EYT-113, Basisdesign v2.0): mobile-first Rahmen fuer + * Mitarbeitende im Einsatz. Dasselbe domaenenfreie Geruest wie die Werkbank + * (`@easytree/ui` AppShell), aber eine EIGENE Informationsarchitektur: + * + * - KEINE Werkbank-Navigation. Die Feld-App bekommt erst dann Navigations- + * punkte, wenn die Ziele real existieren (EYT-81) — ein Punkt ohne + * funktionierendes Ziel waere eine Attrappe. Der Slot bleibt deshalb leer, + * und `test/feld-shell.test.tsx` haelt fest, dass hier keine Links auf + * /planung oder /kosten stehen. + * - Sitzungsbereich: Organisation und Abmelden — echte Session, echter + * Logout, sonst nichts. + * + * Import-Grenze: diese Datei und alles unter `components/feld/` darf keine + * Werkbank-Komponenten importieren — Regel `feld-shell-boundary` in + * `apps/api/test/architecture/rules.ts` macht einen Verstoss rot. + */ +export function FeldShell({ children }: { children: ReactNode }) { + const router = useRouter(); + const { sitzung, organisation, abmelden } = useSession(); + const angemeldet = sitzung.zustand === "angemeldet"; + + const sitzungsbereich = angemeldet ? ( + <> + {organisation === null ? null : ( + + {organisation.name} + + )} + + + ) : ( + + Anmelden + + ); + + return ( + + easyTree Feld + + } + sessionArea={sitzungsbereich} + > + {children} + + ); +} diff --git a/apps/web/components/feld/feld-start.tsx b/apps/web/components/feld/feld-start.tsx new file mode 100644 index 00000000..187e37b6 --- /dev/null +++ b/apps/web/components/feld/feld-start.tsx @@ -0,0 +1,90 @@ +"use client"; + +import Link from "next/link"; + +import { EmptyState, ErrorState, LoadingState } from "@easytree/ui"; + +import { useSession } from "../../lib/session-provider"; + +/** + * Startflaeche der Feld-App (EYT-113): zeigt ausschliesslich REALE + * Sessiondaten — Organisation(en) und Rolle aus der serverseitig + * aufgeloesten Session. Fachliche Feld-Ansichten (Einsaetze, Zeiten) + * existieren noch nicht und werden deshalb weder verlinkt noch angedeutet; + * der Leerzustand benennt die Abwesenheit ehrlich (gemeinsamer + * Zustandsvertrag aus `@easytree/ui`, Basisdesign v2.0 §4). + */ +const ROLLENNAMEN: Readonly> = { + owner: "Inhaber", + manager: "Leitung", + member: "Mitarbeiter", +}; + +export function FeldStart() { + const { sitzung, neuLaden } = useSession(); + + if (sitzung.zustand === "laedt") { + return ; + } + + if (sitzung.zustand === "fehler") { + return ( + + ); + } + + if (sitzung.zustand === "abgemeldet") { + // Das Server-Gate in `app/feld/layout.tsx` leitet Abgemeldete bereits + // um; dieser Zweig faengt den Fall ab, dass die Sitzung NACH dem + // Seitenaufbau ablaeuft — fail-closed statt eingefrorener Ansicht. + return ( + + Anmelden + + } + /> + ); + } + + const organisationen = sitzung.session.organisations; + if (organisationen.length === 0) { + return ( + + ); + } + + return ( +
+
    + {organisationen.map((organisation) => ( +
  • + {organisation.name} + + {ROLLENNAMEN[organisation.role] ?? organisation.role} + +
  • + ))} +
+ +
+ ); +} diff --git a/apps/web/components/login-form.tsx b/apps/web/components/login-form.tsx index 3d076cdf..b2b3a49d 100644 --- a/apps/web/components/login-form.tsx +++ b/apps/web/components/login-form.tsx @@ -8,6 +8,7 @@ import { PrimaryAction, StateBanner } from "@easytree/ui"; import { useAuthGateway } from "../lib/auth-gateway-provider"; import { useSession } from "../lib/session-provider"; +import { startShellFuer } from "../lib/feld/start-shell"; /** Vollstaendig, ohne Default-Zweig — dieselbe Regel wie in der Planung. */ const FEHLERTEXT: Record = { @@ -41,7 +42,11 @@ export function LoginForm() { } // Kein Optimismus: die Sitzung wird NEU vom Server geladen, dann Weiterleitung. neuLaden(); - router.push("/kosten"); + // Start-Shell aus der SERVER-Antwort des Logins (EYT-113): das DTO ist die + // frisch verifizierte Session, kein Client-Zustand. Leitungsrollen landen + // wie bisher in der Werkbank, reine member in der Feld-App — und das + // Server-Gate unter /feld prueft die Session dort ERNEUT. + router.push(startShellFuer(ergebnis.value) === "feld" ? "/feld" : "/kosten"); } return ( diff --git a/apps/web/app/page.tsx b/apps/web/components/start-inhalt.tsx similarity index 69% rename from apps/web/app/page.tsx rename to apps/web/components/start-inhalt.tsx index b6db2e09..2a1e6031 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/components/start-inhalt.tsx @@ -1,8 +1,14 @@ import { Button, Card } from "@easytree/ui"; -import { ApiStatus } from "../components/api-status"; +import { ApiStatus } from "./api-status"; -export default function HomePage() { +/** + * Inhalt der Werkbank-Startseite — bewusst eine synchrone Komponente ohne + * Serverbezug: `app/(werkbank)/page.tsx` entscheidet davor serverseitig + * ueber die Start-Shell (EYT-113), und `test/a11y.test.tsx` rendert diesen + * Inhalt direkt in jsdom, wo ein async Server Component nicht laufen kann. + */ +export function StartInhalt() { return ( <>

Willkommen bei easyTree

diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts index b86d41b3..7199013f 100644 --- a/apps/web/e2e/auth-journey/journey.pwtest.ts +++ b/apps/web/e2e/auth-journey/journey.pwtest.ts @@ -70,6 +70,18 @@ const ABNAHME_BREITEN = [ { name: "1440 px bei 200-%-Zoom (720 px CSS)", width: 720, height: 450 }, ] as const; +/** + * EYT-113: Abnahmebreiten der FELD-Shell — mobile-first 320/375 px laut + * Akzeptanzkriterium. Bewusst eine eigene Liste: die Werkbank wird auf + * Desktopbreiten abgenommen, die Feld-Shell auf Telefonbreiten; eine + * gemeinsame Liste wuerde beiden Flaechen Breiten zusichern, fuer die sie + * nicht entworfen sind. + */ +const FELD_BREITEN = [ + { name: "320 px", width: 320, height: 640 }, + { name: "375 px", width: 375, height: 720 }, +] as const; + /** * Tastaturbedienbarkeit und SICHTBARER Fokus auf einer angemeldeten Flaeche * (EYT-141/EYT-137: „Tastatur, sichtbarer Fokus"). @@ -192,10 +204,14 @@ async function pruefeTastaturUndFokus(seite: Page, flaeche: string): Promise { +async function pruefeBarrierefreiheit( + seite: Page, + flaeche: string, + breiten: ReadonlyArray<{ name: string; width: number; height: number }> = ABNAHME_BREITEN, +): Promise { const urspruenglich = seite.viewportSize(); - for (const breite of ABNAHME_BREITEN) { + for (const breite of breiten) { await seite.setViewportSize({ width: breite.width, height: breite.height }); const proBreite = await new AxeBuilder({ page: seite }) @@ -3038,7 +3054,9 @@ test("Benutzer B ist angemeldet, aber ohne Mitgliedschaft ausgesperrt", async ({ // Schlaegt der Login wirklich fehl, zeigt das Formular einen Banner mit // role="alert". Auf beides zu warten macht aus einem stillen Timeout // eine benannte Ursache. - const angemeldet = seite.waitForURL("**/kosten"); + // Seit EYT-113 landet ein Konto ohne Leitungsrolle in der Feld-Shell — + // B hat gar keine Mitgliedschaft und gehoert damit erst recht dorthin. + const angemeldet = seite.waitForURL("**/feld"); const abgelehnt = seite .getByRole("alert") .filter({ hasText: "Anmeldung fehlgeschlagen" }) @@ -3053,6 +3071,10 @@ test("Benutzer B ist angemeldet, aber ohne Mitgliedschaft ausgesperrt", async ({ // berechtigt ist er nicht — das ist der Unterschied, um den es geht. const kekse = await kontext.cookies(); expect(kekse.find((k) => k.name === "eyt_access")?.httpOnly).toBe(true); + + // Und die Feld-Shell bleibt ehrlich: ohne Mitgliedschaft gibt es keine + // fachliche Flaeche, sondern den benannten Leerzustand (EYT-113). + await expect(seite.getByTestId("feld-ohne-organisation")).toBeVisible(); }); await test.step("die Sitzung nennt Bs eigene Id, nicht die von A", async () => { @@ -3214,3 +3236,156 @@ test("Benutzer B ist angemeldet, aber ohne Mitgliedschaft ausgesperrt", async ({ await kontext.close(); } }); + +/** + * EYT-113 — die Feld-Reise: ein realer member erreicht aufgrund seiner + * serverseitig verifizierten Session die Mitarbeiter-Feld-Shell; die Werkbank + * bleibt ihm verschlossen, und zwar am RECHT, nicht an der Route. + * + * Die member-Mitgliedschaft ist dieselbe Leihgabe wie in 9c5/9g2 + * (`eyt136-member-an.sql`/`-aus.sql`): kein neuer Benutzer, `auth.users` + * bleibt unberuehrt, und die Nachbedingung der Rueckgabe ist der primaere + * Waechter gegen eine ueberlebende Leihgabe. Der Aufruf steht im `try`, + * die Rueckgabe laeuft auf jedem Weg (Begruendung woertlich bei 9c5). + */ +test("EYT-113: ein member erreicht die Feld-Shell, die Werkbank bleibt zu", async ({ browser }) => { + const emailB = pflicht("EASYTREE_JOURNEY_EMAIL_B"); + const passwortB = pflicht("EASYTREE_JOURNEY_PASSWORT_B"); + const idA = pflicht("EASYTREE_JOURNEY_USER_A"); + const idB = pflicht("EASYTREE_JOURNEY_USER_B"); + const verwaltung = pflicht("EASYTREE_JOURNEY_ADMIN_DB_URL"); + + const kontext = await browser.newContext(); + const seite = await kontext.newPage(); + const bericht: Record = { ticket: "EYT-113", benutzer: "B als member" }; + ZUSAMMENFASSUNGEN.set(test.info().testId, { datei: "zusammenfassung-feld.json", bericht }); + + let fehlerAusFall: [unknown] | null = null; + try { + const an = psqlMitMarker( + verwaltung, + join(HIER, "eyt136-member-an.sql"), + ["-v", `benutzer_a=${idA}`, "-v", `benutzer_b=${idB}`], + "[eyt136-member-an]", + ); + console.log(` ${an}`); + + await test.step("Login fuehrt einen member in die Feld-Shell", async () => { + await seite.goto("/anmelden"); + await seite.getByLabel("E-Mail").fill(emailB); + await seite.getByLabel("Passwort").fill(passwortB); + await seite.getByRole("button", { name: "Anmelden" }).click(); + + const angekommen = seite.waitForURL("**/feld"); + const abgelehnt = seite + .getByRole("alert") + .filter({ hasText: "Anmeldung fehlgeschlagen" }) + .waitFor({ state: "visible" }); + await Promise.race([angekommen, abgelehnt]); + await expect( + seite.getByRole("alert").filter({ hasText: "Anmeldung fehlgeschlagen" }), + ).toHaveCount(0); + await angekommen; + + // Die Shell nennt die REALEN Sessiondaten — Organisation und Rolle aus + // der serverseitig aufgeloesten Sitzung, keine Attrappe. + await expect(seite.getByTestId("feld-shell")).toBeVisible(); + await expect(seite.getByTestId("feld-org")).toHaveText(ORG_NAME); + await expect(seite.getByTestId("feld-rolle")).toHaveText("Mitarbeiter"); + bericht["landung"] = { url: seite.url() }; + }); + + await test.step("die Feld-Shell zeigt keine Werkbank-Navigation", async () => { + await expect(seite.getByRole("link", { name: "Planung" })).toHaveCount(0); + await expect(seite.getByRole("link", { name: "Kosten" })).toHaveCount(0); + const werkbankLinks = await seite.evaluate( + () => document.querySelectorAll('a[href="/planung"], a[href="/kosten"]').length, + ); + expect(werkbankLinks).toBe(0); + bericht["keine_werkbank_navigation"] = true; + }); + + await test.step("auch die Startseite leitet den member serverseitig ins Feld", async () => { + await seite.goto("/"); + await seite.waitForURL("**/feld"); + await expect(seite.getByTestId("feld-shell")).toBeVisible(); + bericht["start_dispatch"] = true; + }); + + await test.step("Feld-Shell bei 320/375 px: axe, Reflow, Tastatur, Fokus", async () => { + await pruefeBarrierefreiheit(seite, "/feld", FELD_BREITEN); + + // Touch-Ziel nach Basisdesign v2.0 §2.3 (mindestens 40 px): das eine + // reale Bedienelement der Shell, gemessen auf der kleinsten Breite. + await seite.setViewportSize({ width: 320, height: 640 }); + const abmelden = await seite.getByTestId("feld-abmelden").boundingBox(); + expect(abmelden).not.toBeNull(); + expect(abmelden!.height).toBeGreaterThanOrEqual(40); + + mkdirSync(ARTEFAKTE, { recursive: true }); + await seite.screenshot({ path: join(ARTEFAKTE, "04-feld-shell-320.png") }); + await seite.setViewportSize({ width: 375, height: 720 }); + await seite.screenshot({ path: join(ARTEFAKTE, "05-feld-shell-375.png") }); + bericht["responsive"] = { breiten: [320, 375], touchziel_abmelden_px: abmelden!.height }; + }); + + await test.step("Cross-Shell: die Werkbank gibt dem member nichts preis", async () => { + // B ist jetzt member GENAU EINER Organisation: der Zustand auf /planung + // ist Forbidden — nicht "Organisation erforderlich" wie bei B ohne + // Mitgliedschaft (dort geprueft) und nicht der Kosteninhalt (9g2 prueft + // die Kostenflaeche desselben members). + await seite.goto(`/planung?weekKey=${PLANWOCHE}`); + await expect(seite.getByTestId("planung-forbidden")).toBeVisible(); + await expect(seite.getByTestId("planungsfenster-stand")).toHaveCount(0); + await expect(seite.getByTestId("planung-veroeffentlichen")).toHaveCount(0); + + // Der Server lehnt unabhaengig von der Oberflaeche ab — 403, nicht 400: + // mit genau einer aktiven Mitgliedschaft ist die Organisation eindeutig, + // es fehlt das RECHT. + const fenster = await seite.request.get(`/api/v1/planung/fenster?weekKey=${PLANWOCHE}`); + expect(fenster.status()).toBe(403); + expect(await fenster.text()).not.toContain(MITARBEITER_NAME); + const mitarbeiter = await seite.request.get("/api/v1/kosten/mitarbeiter"); + expect(mitarbeiter.status()).toBe(403); + expect(await mitarbeiter.text()).not.toContain(MITARBEITER_NAME); + bericht["cross_shell"] = { + planung_fenster: fenster.status(), + kosten_mitarbeiter: mitarbeiter.status(), + erwartet: 403, + }; + }); + } catch (fehler) { + fehlerAusFall = [fehler]; + } + + // Rueckgabe der Leihgabe auf JEDEM Weg — die Nachbedingung in + // `eyt136-member-aus.sql` (leihe/b_gesamt = 0) ist der primaere Waechter. + const aus = psqlMitMarker( + verwaltung, + join(HIER, "eyt136-member-aus.sql"), + ["-v", `benutzer_a=${idA}`, "-v", `benutzer_b=${idB}`], + "[eyt136-member-aus]", + ); + console.log(` ${aus}`); + await kontext.close(); + if (fehlerAusFall !== null) throw fehlerAusFall[0]; +}); + +/** + * EYT-113 — fail-closed ohne Sitzung: /feld ist keine oeffentliche Flaeche. + * Das Server-Gate (app/feld/layout.tsx) fragt die API und leitet Abgemeldete + * zur Anmeldung, BEVOR irgendein Shell-Inhalt ausgeliefert wird. + */ +test("EYT-113: ohne Sitzung fuehrt /feld zur Anmeldung", async ({ browser }) => { + const kontext = await browser.newContext(); + const seite = await kontext.newPage(); + try { + await seite.goto("/feld"); + await seite.waitForURL("**/anmelden"); + await expect(seite.getByRole("heading", { name: "Anmelden", level: 1 })).toBeVisible(); + await expect(seite.getByTestId("feld-shell")).toHaveCount(0); + await expect(seite.getByTestId("feld-org")).toHaveCount(0); + } finally { + await kontext.close(); + } +}); diff --git a/apps/web/e2e/shell-smoke.spec.ts b/apps/web/e2e/shell-smoke.spec.ts index fc6f7407..1236a63b 100644 --- a/apps/web/e2e/shell-smoke.spec.ts +++ b/apps/web/e2e/shell-smoke.spec.ts @@ -501,3 +501,24 @@ for (const pfad of ["/", "/planung", "/kosten"]) { } }); } + +/** + * EYT-113: /feld ohne API — das Server-Gate weiss NICHTS und handelt das + * fail-closed: ehrliche Fehlerflaeche statt Shell, und KEINE Umleitung zur + * Anmeldung. Nichtwissen ist nicht "abgemeldet" — eine Umleitung wuerde bei + * einem API-Ausfall angemeldete Nutzer aussperren. Der Prozess haelt die + * Route; genau dieses Verhalten unterscheidet den Ausfall vom 401. + */ +test("EYT-113: /feld ohne API zeigt die ehrliche Fehlerflaeche, keine Shell", async ({ page }) => { + await page.goto("/feld"); + await expect(page.getByTestId("feld-sitzung-unbekannt")).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/feld"); + await expect(page.getByTestId("feld-shell")).toHaveCount(0); + await expect(page.getByTestId("feld-abmelden")).toHaveCount(0); + // Die Fehlerflaeche ist selbst zugaenglich: eine main-Landmark, und die + // Meldung ist assertiv. Bewusst NICHT getByRole("alert") ohne Filter: + // Nexts Route-Announcer traegt ebenfalls role=alert (strict-mode-Treffer, + // gemessen 28.08.2026) — geprueft wird das Element der Fehlerflaeche. + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.getByTestId("feld-sitzung-unbekannt")).toHaveAttribute("role", "alert"); +}); diff --git a/apps/web/lib/feld/sitzung-server.ts b/apps/web/lib/feld/sitzung-server.ts new file mode 100644 index 00000000..bf54ab0d --- /dev/null +++ b/apps/web/lib/feld/sitzung-server.ts @@ -0,0 +1,74 @@ +import { API_BASE_PATH, SessionDtoSchema, type SessionDto } from "@easytree/contracts"; + +import { aktuellesProxyziel } from "../proxy-durchreichen"; + +/** + * Serverseitige Sitzungslesung (EYT-113). + * + * Server Components (Feld-Gate, Start-Dispatch) fragen die echte API nach der + * Session — mit den Cookies der eingehenden Anfrage, gegen dasselbe + * Laufzeit-Proxyziel, das auch `lib/proxy-durchreichen.ts` benutzt. Die + * Umgebung wird hier bewusst NICHT gelesen: `aktuellesProxyziel()` ist die + * eine erlaubte Lesestelle (secret-surface-Waechter, EYT-126). + * + * Drei Zustaende, fail-closed: + * angemeldet — 200 und der Body besteht das strikte SessionDto-Schema + * abgemeldet — die API antwortet 401 (verifiziertes Nichtangemeldetsein) + * unbekannt — alles andere. Nichtwissen ist NICHT abgemeldet: ein + * API-Ausfall darf angemeldete Nutzer nicht zur Anmeldung + * umleiten, er zeigt eine ehrliche Fehlerflaeche. + */ +export type ServerSitzung = + | { zustand: "angemeldet"; session: SessionDto } + | { zustand: "abgemeldet" } + | { zustand: "unbekannt" }; + +export async function leseServerSitzung( + cookieHeader: string | null, + fetchImpl: typeof fetch = fetch, + zielLeser: () => string = aktuellesProxyziel, +): Promise { + let ziel: string; + try { + ziel = zielLeser(); + } catch { + return { zustand: "unbekannt" }; + } + + const headers = new Headers({ accept: "application/json" }); + if (cookieHeader !== null && cookieHeader !== "") { + headers.set("cookie", cookieHeader); + } + + let antwort: Response; + try { + antwort = await fetchImpl(`${ziel}${API_BASE_PATH}/auth/session`, { + method: "GET", + headers, + cache: "no-store", + redirect: "manual", + }); + } catch { + return { zustand: "unbekannt" }; + } + + if (antwort.status === 401) { + return { zustand: "abgemeldet" }; + } + if (antwort.status !== 200) { + return { zustand: "unbekannt" }; + } + + let body: unknown; + try { + body = await antwort.json(); + } catch { + return { zustand: "unbekannt" }; + } + + const geparst = SessionDtoSchema.safeParse(body); + if (!geparst.success) { + return { zustand: "unbekannt" }; + } + return { zustand: "angemeldet", session: geparst.data }; +} diff --git a/apps/web/lib/feld/start-shell.ts b/apps/web/lib/feld/start-shell.ts new file mode 100644 index 00000000..54e52660 --- /dev/null +++ b/apps/web/lib/feld/start-shell.ts @@ -0,0 +1,26 @@ +import type { SessionDto } from "@easytree/contracts"; + +/** + * Start-Shell-Ableitung (EYT-113). + * + * Die zulaessige Start-Shell folgt aus der serverseitig verifizierten Session, + * konkret aus den Mitgliedschaftsrollen: Wer in KEINER Organisation eine + * Leitungsrolle (owner/manager) traegt, arbeitet im Feld. Eine einzige + * Leitungsrolle genuegt fuer die Werkbank, denn nur dort existieren die + * Flaechen, die diese Rolle bedienen kann. + * + * Bewusst Rollen statt Permissions: `role_permissions` ist globale + * Konfiguration und kann wachsen (z. B. kuenftige Mitarbeiter-Rechte fuer + * EYT-81); die Rollentrennung member/leitung bleibt davon unberuehrt. + * + * Ein Nutzer ohne jede Mitgliedschaft faellt ins Feld: die kleinste Flaeche, + * auf der jede Ansicht ohne Organisation ehrlich leer bleibt. + */ +export type StartShell = "feld" | "werkbank"; + +export function startShellFuer(session: SessionDto): StartShell { + const leitung = session.organisations.some( + (organisation) => organisation.role === "owner" || organisation.role === "manager", + ); + return leitung ? "werkbank" : "feld"; +} diff --git a/apps/web/test/a11y.test.tsx b/apps/web/test/a11y.test.tsx index 24f33d42..6858d8f2 100644 --- a/apps/web/test/a11y.test.tsx +++ b/apps/web/test/a11y.test.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { HttpAuthGateway, type CostSnapshot, type CostsGateway } from "@easytree/contracts"; import RootLayout from "../app/layout"; -import HomePage from "../app/page"; +import { StartInhalt } from "../components/start-inhalt"; import { AppShell } from "../components/app-shell"; import { KostenAnsicht } from "../components/kosten-ansicht"; import { createApiClient } from "../lib/api-client"; @@ -51,7 +51,7 @@ function renderShell() { - + diff --git a/apps/web/test/feld-shell.test.tsx b/apps/web/test/feld-shell.test.tsx new file mode 100644 index 00000000..35f861f0 --- /dev/null +++ b/apps/web/test/feld-shell.test.tsx @@ -0,0 +1,113 @@ +import { cleanup, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import axe from "axe-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { HttpAuthGateway } from "@easytree/contracts"; + +import { FeldShell } from "../components/feld/feld-shell"; +import { FeldStart } from "../components/feld/feld-start"; +import { AuthGatewayProvider } from "../lib/auth-gateway-provider"; +import { SessionProvider } from "../lib/session-provider"; + +// Die Feld-Shell ist eine Client-Komponente mit Router-Bezug; jsdom hat +// keinen App-Router, also liefern die Hooks feste Werte (Muster a11y.test). +vi.mock("next/navigation", () => ({ + usePathname: () => "/feld", + useRouter: () => ({ push: vi.fn() }), +})); + +afterEach(cleanup); + +const MEMBER_SITZUNG = { + userId: "00000000-0000-4000-8000-000000000001", + organisations: [ + { + id: "00000000-0000-4000-8000-000000000002", + name: "Baumpflege Nord", + role: "member", + permissions: [], + }, + ], +}; + +/** + * Feld-Shell samt Startseite mit realem SessionProvider gegen einen + * gestubbten Auth-Endpunkt — kein Mock der eigenen Logik, nur des Netzes. + */ +function renderFeld(sessionBody: unknown = MEMBER_SITZUNG, status = 200) { + const auth = new HttpAuthGateway("http://api.test/api/v1", { + fetchImpl: async () => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => sessionBody, + }) as unknown as Response, + }); + return render( + + + + + + + , + ); +} + +describe("Feld-Shell (EYT-113)", () => { + it("zeigt einem member Organisation, Rolle und Abmelden — und hat null axe-Verstoesse", async () => { + const ansicht = renderFeld(); + // findBy wartet auf den Sitzungs-Effekt (Client laedt die Session nach). + expect((await ansicht.findByTestId("feld-org")).textContent).toContain("Baumpflege Nord"); + expect(await ansicht.findByTestId("feld-abmelden")).toBeTruthy(); + expect((await ansicht.findByTestId("feld-rolle")).textContent).toContain("Mitarbeiter"); + + const results = await axe.run(ansicht.container, { + runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] }, + // Kontrast braucht echtes Rendering — Teil der manuellen Checkliste. + rules: { "color-contrast": { enabled: false } }, + }); + expect(results.violations).toEqual([]); + }); + + it("traegt die Pflicht-Landmarken und den Sprunganker als erstes Tab-Ziel", async () => { + const ansicht = renderFeld(); + await ansicht.findByTestId("feld-abmelden"); + + const main = ansicht.getByRole("main"); + expect(main.id).toBe("hauptinhalt"); + expect(ansicht.getByRole("banner")).toBeTruthy(); + + const skipLink = ansicht.getByRole("link", { name: /zum hauptinhalt springen/i }); + expect(skipLink.getAttribute("href")).toBe("#hauptinhalt"); + const user = userEvent.setup(); + await user.tab(); + expect(document.activeElement).toBe(skipLink); + }); + + it("zeigt KEINE Werkbank-Navigation — keine Links auf /planung oder /kosten", async () => { + // Gegenmutation, die diesen Fall rot macht: der Feld-Shell einen + // Navigationspunkt auf /planung oder /kosten geben (die Import-Grenze + // in architecture.test.ts faengt zusaetzlich den Komponenten-Import). + const ansicht = renderFeld(); + await ansicht.findByTestId("feld-abmelden"); + expect( + ansicht.container.querySelectorAll( + 'a[href="/planung"], a[href="/kosten"], a[href^="/kosten/"]', + ), + ).toHaveLength(0); + }); + + it("ohne Organisation bleibt die Flaeche ehrlich leer statt fachlich zu raten", async () => { + const ansicht = renderFeld({ userId: MEMBER_SITZUNG.userId, organisations: [] }); + expect(await ansicht.findByTestId("feld-ohne-organisation")).toBeTruthy(); + expect(ansicht.queryByTestId("feld-rolle")).toBeNull(); + }); + + it("meldet einen Sitzungsfehler assertiv, statt Inhalte zu zeigen", async () => { + const ansicht = renderFeld({}, 500); + expect(await ansicht.findByTestId("feld-start-fehler")).toBeTruthy(); + expect(ansicht.queryByTestId("feld-org")).toBeNull(); + }); +}); diff --git a/apps/web/test/helpers/werkbank.tsx b/apps/web/test/helpers/werkbank.tsx index 16d86e98..62c40dda 100644 --- a/apps/web/test/helpers/werkbank.tsx +++ b/apps/web/test/helpers/werkbank.tsx @@ -39,7 +39,7 @@ import type { import { render } from "@testing-library/react"; import { useEffect, useState, type ReactNode } from "react"; -import PlanungPage from "../../app/planung/page"; +import PlanungPage from "../../app/(werkbank)/planung/page"; import { Providers } from "../../app/providers"; import { AppShell } from "../../components/app-shell"; import { navigation, navigieren } from "./navigation-attrappe"; diff --git a/apps/web/test/planung-page.test.tsx b/apps/web/test/planung-page.test.tsx index 669c5f87..2f528a70 100644 --- a/apps/web/test/planung-page.test.tsx +++ b/apps/web/test/planung-page.test.tsx @@ -17,7 +17,7 @@ import { render, screen, waitFor, cleanup } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import PlanungPage from "../app/planung/page"; +import PlanungPage from "../app/(werkbank)/planung/page"; import { AuthGatewayProvider } from "../lib/auth-gateway-provider"; import { PlanningGatewayProvider } from "../lib/planning-gateway-provider"; import { SessionProvider } from "../lib/session-provider"; diff --git a/apps/web/test/sitzung-server.test.ts b/apps/web/test/sitzung-server.test.ts new file mode 100644 index 00000000..a3a21f99 --- /dev/null +++ b/apps/web/test/sitzung-server.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +import { leseServerSitzung } from "../lib/feld/sitzung-server"; + +/** + * Serverseitige Sitzungslesung (EYT-113): Server Components fragen die echte + * API (`GET /auth/session`) mit den Cookies der eingehenden Anfrage. Die + * Antwort entscheidet fail-closed in drei Zustaenden: + * + * angemeldet — 200 und der Body besteht das strikte SessionDto-Schema + * abgemeldet — die API sagt 401 (verifiziertes Nichtangemeldetsein) + * unbekannt — alles andere (Netzfehler, 5xx, kaputter Body, fehlendes + * Proxyziel): Nichtwissen ist NICHT abgemeldet, sonst wuerde + * ein API-Ausfall angemeldete Nutzer zur Anmeldung umleiten. + * + * Gegenmutationen, die diese Suite rot machen: 401 auf "unbekannt" mappen, + * 500 auf "abgemeldet" mappen, den Cookie-Header nicht weiterreichen oder + * die Schemapruefung durch `await res.json() as SessionDto` ersetzen. + */ +const ZIEL = "http://api.intern:3001"; + +const GUELTIGE_SITZUNG = { + userId: "00000000-0000-4000-8000-000000000001", + organisations: [ + { + id: "00000000-0000-4000-8000-000000000002", + name: "Baumpflege Nord", + role: "member", + permissions: [], + }, + ], +}; + +function antwort(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +describe("leseServerSitzung (EYT-113)", () => { + it("liefert 'angemeldet' samt geparster Session bei 200 und gueltigem Body", async () => { + const fetchImpl = vi.fn(async () => antwort(200, GUELTIGE_SITZUNG)); + const ergebnis = await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL); + expect(ergebnis).toEqual({ zustand: "angemeldet", session: GUELTIGE_SITZUNG }); + }); + + it("fragt das Proxyziel unter dem Vertragspfad und reicht die Cookies weiter", async () => { + const fetchImpl = vi.fn(async () => antwort(200, GUELTIGE_SITZUNG)); + await leseServerSitzung("eyt_access=abc; eyt_refresh=def", fetchImpl, () => ZIEL); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("http://api.intern:3001/api/v1/auth/session"); + expect(new Headers(init.headers).get("cookie")).toBe("eyt_access=abc; eyt_refresh=def"); + expect(init.cache).toBe("no-store"); + }); + + it("sendet ohne eingehende Cookies keinen Cookie-Header", async () => { + const fetchImpl = vi.fn(async () => antwort(401, {})); + await leseServerSitzung(null, fetchImpl, () => ZIEL); + const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(new Headers(init.headers).has("cookie")).toBe(false); + }); + + it("liefert 'abgemeldet' bei 401", async () => { + const fetchImpl = vi.fn(async () => antwort(401, {})); + expect(await leseServerSitzung(null, fetchImpl, () => ZIEL)).toEqual({ + zustand: "abgemeldet", + }); + }); + + it("liefert 'unbekannt' bei 500 — Nichtwissen ist nicht abgemeldet", async () => { + const fetchImpl = vi.fn(async () => antwort(500, {})); + expect(await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL)).toEqual({ + zustand: "unbekannt", + }); + }); + + it("liefert 'unbekannt', wenn der Body das strikte Schema nicht besteht", async () => { + const fetchImpl = vi.fn(async () => + antwort(200, { userId: "kein-uuid-noetig?", organisations: "kaputt" }), + ); + expect(await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL)).toEqual({ + zustand: "unbekannt", + }); + }); + + it("liefert 'unbekannt', wenn fetch selbst scheitert", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }); + expect(await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL)).toEqual({ + zustand: "unbekannt", + }); + }); + + it("liefert 'unbekannt', wenn das Proxyziel fehlt oder ungueltig ist", async () => { + const fetchImpl = vi.fn(async () => antwort(200, GUELTIGE_SITZUNG)); + const ergebnis = await leseServerSitzung("eyt_access=abc", fetchImpl, () => { + throw new Error("Proxyziel fehlt"); + }); + expect(ergebnis).toEqual({ zustand: "unbekannt" }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/start-shell.test.ts b/apps/web/test/start-shell.test.ts new file mode 100644 index 00000000..02c69381 --- /dev/null +++ b/apps/web/test/start-shell.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionDto } from "@easytree/contracts"; + +import { startShellFuer } from "../lib/feld/start-shell"; + +/** + * Start-Shell-Ableitung (EYT-113): Welche Shell einem Nutzer beim Einstieg + * gehoert, folgt aus der serverseitig verifizierten Session — konkret aus den + * Rollen seiner Mitgliedschaften. Die Funktion ist rein und damit die eine + * testbare Wahrheit; Route oder Client-State verleihen kein Recht. + * + * Gegenmutation, die diese Suite rot macht: `every` durch `some` ersetzen + * (dann schickte eine einzige member-Mitgliedschaft auch Leitungen ins Feld) + * oder die Rolle "manager" als Feld-Rolle einstufen. + */ +function sitzung(rollen: ReadonlyArray<"owner" | "manager" | "member">): SessionDto { + return { + userId: "00000000-0000-4000-8000-000000000001", + organisations: rollen.map((role, index) => ({ + id: `00000000-0000-4000-8000-00000000000${index + 2}`, + name: `Org ${index + 1}`, + role, + permissions: [], + })), + }; +} + +describe("startShellFuer (EYT-113)", () => { + it("schickt eine reine member-Mitgliedschaft in die Feld-Shell", () => { + expect(startShellFuer(sitzung(["member"]))).toBe("feld"); + }); + + it("schickt einen owner in die Werkbank", () => { + expect(startShellFuer(sitzung(["owner"]))).toBe("werkbank"); + }); + + it("schickt einen manager in die Werkbank", () => { + expect(startShellFuer(sitzung(["manager"]))).toBe("werkbank"); + }); + + it("gemischte Mitgliedschaften: eine Leitungsrolle genuegt fuer die Werkbank", () => { + expect(startShellFuer(sitzung(["member", "manager"]))).toBe("werkbank"); + }); + + it("mehrere reine member-Mitgliedschaften bleiben Feld", () => { + expect(startShellFuer(sitzung(["member", "member"]))).toBe("feld"); + }); + + it("ohne jede Mitgliedschaft gilt die Feld-Shell (kleinste Flaeche)", () => { + // Ein authentifizierter Nutzer ohne Organisation (Reisender-B-Fall) hat + // in der Werkbank nichts zu suchen: dort waere jede Flaeche verboten. + expect(startShellFuer(sitzung([]))).toBe("feld"); + }); +});