diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff8691..601cb13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Behoben +- **Die Zusage „jede Seite und jeder Route Handler prüft sein Recht selbst“ wird jetzt geprüft.** + Sie steht seit #007 in den Konventionen und hielt bis hierher nur durch Nachsehen von Hand — das + gilt für den Tag, an dem es lief. `tests/zugang.test.ts` liest jetzt jede `page.tsx` und jeden + `route.ts`; die Ausnahmen (Auth, Healthcheck) stehen dort namentlich samt Begründung (#038) +- Das Lesemodell der Anhänge liegt jetzt in `queries/` und wendet seine Mandantensicht **selbst** an. + Vorher hing es daran, dass beide Aufrufer den Lauf vorher prüften — kein Schaden, aber die falsche + Form: der dritte Aufrufer ist der, der es vergisst (#037) +- `/meine-geraete` wird jetzt bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto. Die Seite + ist mit „am Handy kein horizontales Scrollen“ abgenommen worden — von Hand. Dazu ein Test, dass + dort kein Link in die Verwaltung angeboten wird (#037) - **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ zeigte ins Leere.** Gebaut wurde `/verwaltung/verzeichnis`; `verwaltung` ist aber der Name des Rechts, die Seite liegt unter `/admin/verzeichnis`. Das ist die eine Meldung, die an alle Administratoren geht, und zwar im diff --git a/CLAUDE.md b/CLAUDE.md index a1aff5d..47186b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,10 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen. **Route Handler deckt die Hülle nicht ab**; alles unter `src/app/api/`, das mehr tut als Auth und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur auf Vorhandensein, nie inhaltlich. + Beides prüft `tests/zugang.test.ts` seit #036 **strukturell**: es liest jede `page.tsx` und jeden + `route.ts` und schlägt an, wenn eine Datei nichts prüft. Die Ausnahmen stehen dort namentlich, + samt Begründung — ein neuer Handler ohne Prüfung soll jemanden zwingen, sie aufzuschreiben. + Vorher hielt die Zusage nur durch Nachsehen von Hand, und das gilt für den Tag, an dem es lief. - **Server-Module gehören nicht ins Browser-Bündel.** Eine Client-Komponente importiert nichts aus `src/server/*`. Was beide Seiten brauchen (Beschriftungen, Formate), liegt in `src/lib` — `ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig @@ -189,6 +193,53 @@ Wer eine Oberfläche abnimmt, klickt sie im gebauten Stand durch — hinzufügen speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht. `pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand. +## Die Ende-zu-Ende-Abläufe laufen vor jedem Merge nach `main` + +```bash +pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start & +E2E_APP_URL=http://127.0.0.1:3300 pnpm test:e2e +``` + +**Nicht im Commit-Tor, und das ist eine Rechnung, keine Bequemlichkeit.** Gemessen am 2026-10-11: +Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen 4 min 37 s, zu den 4 min des Commit-Tors. Neun +Minuten vor jedem Commit ist ein Tor, das umgangen wird, und ein umgangenes Tor prüft nichts. + +**Vor jedem Merge ist die Regel, weil sie gereicht hätte.** In Phase 2 waren das acht Läufe. Beim +ersten vollständigen Durchgang nach Monaten waren **sieben Abläufe rot** (#027): ein Test, der die +Felder des Laufzustands aufzählt und `anzahlAnhaenge` nicht kannte (seit #033); ein Selektor, der +seit #020 zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (seit #030/#031); und +Entwicklungsdaten, die vom Seed abwichen. Keiner davon war ein Produktfehler — und jeder wäre am Tag +seiner Entstehung aufgefallen. + +**Diese Zusagen stehen ausschließlich dort**, und keine davon lässt sich nach Vitest holen: sie +brauchen einen echten Browser. + +| Zusage | Warum nur im Browser | +|---|---| +| Kein horizontales Scrollen bei 390 px | verlangt Layout, nicht Logik | +| Scannen über `BarcodeDetector` | Browser-API samt Polyfill | +| Sortieren per Finger | Touch-Ereignisse und `dnd-kit` | +| Zwei Sitzungen sehen einander live | zwei Browser-Kontexte gleichzeitig | +| Rückfall auf Polling ohne `EventSource` | das Client-Verhalten; der Handler-Teil steht in Vitest | +| „Alle Befehle kopieren“ zeichengenau | Zwischenablage | +| Druckansicht und Etiketten | Print-CSS | +| Eine Rolle sieht keine Schreib-Knöpfe | gerendertes DOM | +| Dass überhaupt hydriert wird | der Grund für „gebauter Stand“, siehe oben | + +**Was sich holen ließ, ist geholt.** Die Rechteprüfung der Route Handler steht seit #036 in +`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts` — die Handler +werden mit echten `Request`-Objekten gefahren. Dazu prüft `tests/zugang.test.ts` **strukturell**, +dass jede Seite und jeder Route Handler sein Recht selbst prüft; die Ausnahmen (Auth und +Healthcheck) stehen dort namentlich. Vorher hielt diese Zusage nur durch Nachsehen von Hand. + +Die 35 übersprungenen Abläufe sind die modusabhängigen: sie laufen im jeweils anderen +`Betriebsmodus` und sagen das beim Überspringen. + +**Die Abläufe räumen auf, was sie anlegen** (#027). Alles trägt `ZZ` am Anfang; +`tests/e2e/aufraeumen.ts` greift daran, **vor und nach** dem Lauf. Der Nachlauf fängt Testfehler und +Zeitabläufe, der Vorlauf fängt `Strg+C` — ein Vorlauf lässt sich nicht überspringen. Er sagt laut, +wenn er einen Rückstand findet. + ## Vor jedem Commit ```bash diff --git a/docs/architektur.md b/docs/architektur.md index 1bd2d31..028d48a 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -153,6 +153,17 @@ Zwei Folgen, beide verbindlich: klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu laden und nachsehen, ob es wirklich in der Datenbank steht. +Daraus folgt eine dritte, die erst #027 gelehrt hat: **diese Abläufe müssen regelmäßig laufen, weil +sie die einzigen Prüfer ihrer Zusagen sind.** Sie stehen nicht im Commit-Tor — gemessen 256 s für +die Abläufe, dazu Bau und Start, zu den vier Minuten des Tores; neun Minuten vor jedem Commit ist +ein Tor, das umgangen wird. Verbindlich ist deshalb **vor jedem Merge nach `main`**, und `CLAUDE.md` +führt auf, welche Zusagen ausschließlich dort hängen. + +Beim ersten vollständigen Durchgang nach Monaten waren sieben Abläufe rot, vier Ursachen, **keine +ein Produktfehler** — ein veralteter Feldvergleich, ein seit #020 mehrdeutiger Selektor, eine feste +Pixelzahl bei gewachsenem Layout und abweichende Entwicklungsdaten. Dass keiner ein Produktfehler +war, ist kein Trost: es heißt, dass die Abläufe in dieser Zeit **nichts** geprüft haben. + Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit `unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an `process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine diff --git a/docs/devlog.md b/docs/devlog.md index 536021e..69def3b 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -38,12 +38,14 @@ als Unterpunkte mit Aufgabe und Dauer. | #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min | | #036 | Reviewbefunde aus der Durchsicht von Phase 2 | 3 h 40 min | | #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min | -| | **Summe** | **70 h 30 min** | +| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min | +| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min | +| | **Summe** | **73 h 55 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | -| v0.2.0 | 35 h 10 min | in Arbeit | +| v0.2.0 | 38 h 35 min | in Arbeit | --- @@ -1611,3 +1613,63 @@ stehen in `docs/betrieb/scim.md`. - Danach ist v0.2.0 reif für einen Tag. Vorher gehört #038 entschieden: ein Release, dessen E2E-Suite nur gelegentlich läuft, ist ein Release mit ungeprüften Zusagen — und genau das hat diese Sitzung gezeigt. + + +--- + +## 2026-10-11 · Sitzung 24 · 02:00–06:10 + +**Issues:** #038 (E2E in ein Tor), #037 (zwei Nachzügler) +**Version:** 0.2.0 (unveröffentlicht) + +### Erledigt + +- **#038 entschieden und festgeschrieben.** E2E läuft **vor jedem Merge nach `main`**, nicht vor + jedem Commit. Die Regel steht in `CLAUDE.md` mit gemessenen Zahlen, `docs/architektur.md` §5b + trägt sie mit. +- **Die Liste der Zusagen, die nur in E2E hängen** — neun Stück, alle brauchen einen echten + Browser, keine lässt sich nach Vitest holen. Steht als Tabelle in `CLAUDE.md`. +- **Was sich holen ließ, ist geholt:** `tests/zugang.test.ts` prüft jetzt strukturell, dass jede + `page.tsx` und jeder `route.ts` sein Recht selbst prüft. +- **#037 beide Teile**: `anhaengeDesLaufs` liegt in `queries/` und engt selbst ein; `/meine-geraete` + wird bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto aus dem E2E-Vorlauf. +- 1457 Vitest-Tests (vorher 1454), 115 E2E-Abläufe (vorher 113). + +### Entscheidungen + +- **Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird.** Gemessen: Bau 14 s, Start 7 s, + Abläufe 256 s — zu den vier Minuten des bestehenden Tores. Vor jedem Merge hätte in Phase 2 acht + Läufe bedeutet und jeden der vier Befunde aus #027 am Tag seiner Entstehung gezeigt. Das ist die + Rechnung, die die Regel trägt. +- **Die Antwort auf #038 ist nicht „verlagern“.** Jeder der 16 Abläufe wurde gegen die Frage + geprüft, ob es ein Vitest-Gegenstück gibt. Was dort hängt, braucht einen Browser: Layout bei + 390 px, `BarcodeDetector`, Touch, zwei Kontexte, Zwischenablage, Print-CSS, gerendertes DOM, und + die Hydrierung selbst. +- **Das MITARBEITER-Konto liegt im E2E-Vorlauf, nicht im Seed.** Ein Konto mit bekanntem Passwort + gehört nicht in einen Seed, der auch produktiv läuft. Die Person wird gewählt (erste aktive mit + Gerät), nicht gesetzt — eine feste Kennung wäre eine Annahme über den Seed. + +### Probleme + +- **Der eigentliche Fund steckte in #038 und war keine Testlücke, sondern eine Konvention ohne + Prüfer.** „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md`; geprüft hat es bis hierher + niemand außer einem Menschen, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten + durchgesehen — ein Ergebnis, das für genau einen Tag gilt. Jetzt liest ein Test sie, und beide + Gegenproben (eine Seite, ein Handler ohne Prüfung) werden rot. +- **Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Für das zweite Konto brauchte + `anmelden` eine Variante, die nicht auf `/` wartet. Ich habe das Warten dabei für **alle** + gelockert — und damit zwei unverwandte Abläufe unzuverlässig gemacht, darunter einen in + `laeufe.spec.ts`, der mit dem Thema nichts zu tun hat. **Sichtbar nur im vollen Durchgang**, nicht + beim Einzellauf: einzeln waren beide grün. Behoben, indem `anmelden` wieder streng wartet und + `anmeldenMit` das Ziel als Parameter nimmt; dazu beide auf `load`, denn ein `waitForURL` endet an + der Adresse, nicht am geladenen Dokument. + Dass genau dieser Fehler in derselben Sitzung passiert ist, in der die Regel „E2E vor jedem Merge“ + entstand, ist die beste Begründung für sie: ohne vollen Durchgang hätte ich zwei rote Abläufe + gemergt. + +### Nächster Schritt + +- Offen bleibt nur noch **#024** (Proxmox-Einzeiler auf einem echten PVE-Host) — und das verlangt + einen Host, den es hier nicht gibt. +- **v0.2.0 ist damit reif für einen Tag**: Changelog-Abschnitt, Devlog-Phasenzusammenfassung, Tag, + Release-Notiz. Die Bedingung aus Sitzung 23 („vorher gehört #038 entschieden“) ist erfüllt. diff --git a/docs/issues/037-anhaenge-lesemodell.md b/docs/issues/037-anhaenge-lesemodell.md index bf66a31..05e5925 100644 --- a/docs/issues/037-anhaenge-lesemodell.md +++ b/docs/issues/037-anhaenge-lesemodell.md @@ -1,10 +1,10 @@ # #037 Zwei Nachzügler aus der Durchsicht von Phase 2 -**Status:** offen +**Status:** erledigt **Im Tracker:** – -**Meilenstein:** v0.3.0 +**Meilenstein:** v0.2.0 **Labels:** phase-2, backend, test -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 1 h 50 min ## Ziel @@ -23,11 +23,11 @@ Es steht aber gegen die Regel aus `CLAUDE.md` und `docs/architektur.md` §2: Les Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst — und er wird nicht auffallen, weil die zwei bestehenden korrekt sind. -- [ ] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter -- [ ] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer -- [ ] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne** +- [x] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter +- [x] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer +- [x] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne** dass der Aufrufer vorher geprüft hat -- [ ] Gegenprobe: die Sicht entfernen, der Test muss rot werden +- [x] Gegenprobe: die Sicht entfernen, der Test muss rot werden ## 2. `/meine-geraete` fehlt in der 390-px-Prüfung @@ -37,17 +37,51 @@ einfach ergänzt werden: sie verlangt das Recht `selbstauskunft`, und das hat nu Ablauf meldet sich als Admin an und würde die Verweigerungsseite messen — die scrollt garantiert nicht. -- [ ] Ein `MITARBEITER`-Konto für die Abläufe: lokales Passwort, verknüpft mit einer +- [x] Ein `MITARBEITER`-Konto für die Abläufe: lokales Passwort, verknüpft mit einer `Mitarbeiter`-Zeile (der CHECK `Benutzer_mitarbeiter_nur_bei_rolle` verlangt die Verknüpfung) -- [ ] Im Seed oder im E2E-Vorlauf? **Im Vorlauf**, denn ein Konto mit bekanntem Passwort gehört +- [x] Im Seed oder im E2E-Vorlauf? **Im Vorlauf**, denn ein Konto mit bekanntem Passwort gehört nicht in einen Seed, der auch produktiv läuft -- [ ] `/meine-geraete` in die Seitenliste von `mobil.spec.ts` -- [ ] Dabei prüfen, was ein `MITARBEITER` auf den **anderen** Seiten sieht: die Rollenmatrix ist in +- [x] `/meine-geraete` in die Seitenliste von `mobil.spec.ts` +- [x] Dabei prüfen, was ein `MITARBEITER` auf den **anderen** Seiten sieht: die Rollenmatrix ist in Vitest vollständig, aber kein Ablauf klickt sie durch ## Akzeptanzkriterien -- [ ] `anhaengeDesLaufs` liegt in `queries/`, und ein Test belegt die Sicht ohne Vorprüfung des +- [x] `anhaengeDesLaufs` liegt in `queries/`, und ein Test belegt die Sicht ohne Vorprüfung des Aufrufers -- [ ] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER` -- [ ] Beide Gegenproben werden rot +- [x] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER` +- [x] Beide Gegenproben werden rot + +## Beim Umsetzen dazugekommen + +- **Das Konto liegt im E2E-Vorlauf, nicht im Seed** — wie im Issue vorgezeichnet, und der Grund hat + sich beim Schreiben bestätigt: ein Konto mit bekanntem Passwort gehört nicht in einen Seed, der + auch produktiv läuft. Es trägt `ZZ` im Namen und fällt damit unter das Aufräumen aus + [#027](027-e2e-reste-vorlagen.md); der Nachlauf meldet es mit („1 Benutzer“). +- **Die Person wird gewählt, nicht gesetzt:** die erste aktive mit mindestens einem Gerät. Eine + feste Kennung wäre eine Annahme über den Seed, und ohne Gerät zeigte die Seite nur ihren + Leerzustand — der Test hätte dann nichts gemessen. Der Vorlauf bricht mit einer Erklärung ab, wenn + es keine solche Person gibt. +- **Ein zweiter Test dazu, den das Issue nicht verlangte:** dass ein `MITARBEITER` auf + `/meine-geraete` **keinen** Link in die Verwaltung angeboten bekommt. Die Rollenmatrix ist in + Vitest vollständig, und seit #035 nennt jeder Navigationseintrag sein Recht — dass das im + **gerenderten** DOM ankommt, prüfte nichts. Gegenprobe: ein Eintrag ohne `recht`, und der Test + wird rot. + +## Was dabei schiefging, und warum es hier steht + +Für das zweite Konto brauchte `anmelden` eine Variante, die nicht auf `/` wartet — ein +`MITARBEITER` landet auf `/meine-geraete`, weil `startseite(rolle)` das Ziel an der Rolle +entscheidet (#035). Dabei habe ich das Warten für **alle** gelockert: „irgendeine Adresse außer +`/anmelden`“. + +Das sah harmlos aus und machte **zwei unverwandte Abläufe** unzuverlässig — und zwar nur im vollen +Durchgang, nicht beim Einzellauf: der nächste Schritt begann, bevor die Übersicht stand. Einer davon +war `laeufe.spec.ts` („verlangt für nicht zutreffend eine Begründung“), der mit dem Thema nichts zu +tun hat. + +Behoben: `anmelden` wartet wieder streng auf `/`, `anmeldenMit` nimmt das Ziel als Parameter, und +beide warten zusätzlich auf `load` — ein `waitForURL` endet an der Adresse, nicht am geladenen +Dokument. **Die Lehre:** ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ihn für +einen neuen Fall aufzuweichen kostet die Zuverlässigkeit aller alten, und das zeigt sich erst im +vollen Lauf. diff --git a/docs/issues/038-e2e-im-tor.md b/docs/issues/038-e2e-im-tor.md index 73e700d..1a3b0e9 100644 --- a/docs/issues/038-e2e-im-tor.md +++ b/docs/issues/038-e2e-im-tor.md @@ -1,10 +1,10 @@ # #038 Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot -**Status:** offen +**Status:** erledigt **Im Tracker:** – -**Meilenstein:** v0.3.0 +**Meilenstein:** v0.2.0 **Labels:** phase-2, test, infra -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 1 h 35 min ## Ziel @@ -27,28 +27,66 @@ eine Notiz und keine Prüfung. ## Zu entscheiden -- [ ] **`pnpm test:e2e` ins Tor vor dem Commit?** Dagegen: es verlangt einen **gebauten, +- [x] **`pnpm test:e2e` ins Tor vor dem Commit?** → **Nein.** Dagegen: es verlangt einen **gebauten, laufenden** Stand (`pnpm build && pnpm start`), dauert über vier Minuten und braucht eine Datenbank mit Seed-Bestand. Vier Minuten vor jedem Commit ist ein Tor, das umgangen wird. -- [ ] **Oder eine kleinere, verbindliche Regel:** E2E läuft vor jedem Merge nach `main` — das wären +- [x] **Gewählt: eine kleinere, verbindliche Regel.** E2E läuft vor jedem Merge nach `main` — das wären acht Läufe in Phase 2 gewesen und hätte jeden der vier Befunde am Tag seiner Entstehung gezeigt. Das ist die Empfehlung. -- [ ] **Oder im CI** (`.gitea/workflows/ci.yml`). Dort ist kein Runner registriert; das zu ändern +- [x] **Nicht im CI** (`.gitea/workflows/ci.yml`). Dort ist kein Runner registriert; das zu ändern ist eine eigene Entscheidung und gehört nicht in dieses Issue. ## Aufgaben -- [ ] Die gewählte Regel in `CLAUDE.md` aufschreiben, mit **gemessener** Dauer und der Begründung, +- [x] Die gewählte Regel in `CLAUDE.md` aufschreiben, mit **gemessener** Dauer und der Begründung, warum sie nicht im Commit-Tor steht -- [ ] Die Voraussetzungen, die `tests/e2e/global-setup.ts` prüft, in derselben Reihenfolge +- [x] Die Voraussetzungen, die `tests/e2e/global-setup.ts` prüft, in derselben Reihenfolge dokumentieren — der Vorlauf ist inzwischen die ehrlichste Beschreibung dessen, was E2E braucht -- [ ] Prüfen, welche Zusagen **ausschließlich** in E2E stehen, und für jede entscheiden: gehört sie +- [x] Prüfen, welche Zusagen **ausschließlich** in E2E stehen, und für jede entscheiden: gehört sie zusätzlich in einen Vitest-Test, der den Handler direkt fährt? (Das Muster steht in `tests/scim.test.ts`, `tests/anhang-route.test.ts` und `tests/lauf-ereignisse.test.ts`.) -- [ ] Mindestens die Rechteprüfung der beiden Phase-2-Handler ist damit schon abgedeckt — bleiben +- [x] Mindestens die Rechteprüfung der beiden Phase-2-Handler ist damit schon abgedeckt — bleiben Rückfall auf Polling, Herzschlag am echten Browser und 390 px ## Akzeptanzkriterien -- [ ] In `CLAUDE.md` steht, wann E2E läuft, und die Angabe ist gemessen, nicht geschätzt -- [ ] Eine Liste der Zusagen, die nur in E2E stehen — und zu jeder eine Entscheidung +- [x] In `CLAUDE.md` steht, wann E2E läuft, und die Angabe ist gemessen, nicht geschätzt +- [x] Eine Liste der Zusagen, die nur in E2E stehen — und zu jeder eine Entscheidung + +## Die Entscheidung, und was sie gekostet hat + +**Nicht im Commit-Tor.** Gemessen am 2026-10-11: Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen +4 min 37 s, zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, +das umgangen wird, und ein umgangenes Tor prüft nichts. + +**Vor jedem Merge nach `main`.** In Phase 2 wären das acht Läufe gewesen, und jeder der vier +Befunde aus #027 wäre am Tag seiner Entstehung aufgefallen. Die Regel steht in `CLAUDE.md` mit den +gemessenen Zahlen und der Begründung; `docs/architektur.md` §5b trägt sie mit. + +**Nicht im CI**, weil dort kein Runner registriert ist. Das zu ändern ist eine eigene Entscheidung +und gehört nicht in dieses Issue. + +## Die Liste der Zusagen — und was sich davon holen ließ + +Geprüft wurde jeder der 16 Abläufe gegen die Frage „gibt es dafür ein Vitest-Gegenstück?“. Das +Ergebnis steht als Tabelle in `CLAUDE.md`. Die Kurzfassung: + +**Nichts davon lässt sich nach Vitest holen** — alle brauchen einen echten Browser: Layout bei +390 px, `BarcodeDetector`, Touch-Ereignisse, zwei Browser-Kontexte gleichzeitig, Zwischenablage, +Print-CSS, gerendertes DOM, und überhaupt die Hydrierung. Die Antwort auf #038 ist deshalb nicht +„verlagern“, sondern „regelmäßig laufen lassen“. + +**Was sich holen ließ, ist geholt** — und das war mehr als erwartet: + +- Die **Rechteprüfung der Route Handler** steht seit [#036](036-reviewbefunde-phase-2.md) in + `tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts`: die + Handler werden mit echten `Request`-Objekten gefahren, nicht über die Oberfläche. +- Neu in diesem Issue: `tests/zugang.test.ts` prüft **strukturell**, dass jede `page.tsx` unter + `(app)` und jeder `route.ts` sein Recht selbst prüft. Die Ausnahmen (Auth, Healthcheck) stehen + dort namentlich samt Begründung, und ein Test prüft auch die **Gegenrichtung**: eine Ausnahme für + einen Handler, den es nicht mehr gibt, verdeckt beim nächsten Mal einen echten Befund. + + Dass das nötig war, ist der eigentliche Fund: die Zusage „jede Seite prüft selbst“ steht seit #007 + in `CLAUDE.md`, und sie hielt bis hierher **nur durch Nachsehen von Hand**. Der Review von Phase 2 + hat alle 27 Seiten durchgesehen — das gilt für den Tag, an dem es lief. Beide Gegenproben (eine + Seite, ein Handler ohne Prüfung) werden jetzt rot. diff --git a/docs/issues/README.md b/docs/issues/README.md index 3304ec5..0cf4398 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -41,6 +41,6 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min | | #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min | -| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | offen | v0.3.0 | 2 h | – | -| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | offen | v0.3.0 | 3 h | – | -| | **Summe** | | | **238 h** | **70 h 30 min** | +| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min | +| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min | +| | **Summe** | | | **238 h** | **73 h 55 min** | diff --git a/src/app/(app)/laeufe/[id]/page.tsx b/src/app/(app)/laeufe/[id]/page.tsx index b4d2d0a..a0a4da0 100644 --- a/src/app/(app)/laeufe/[id]/page.tsx +++ b/src/app/(app)/laeufe/[id]/page.tsx @@ -20,7 +20,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert"; import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand"; import { darf } from "@/server/auth/rollen"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; -import { anhaengeDesLaufs } from "@/server/services/anhaenge"; +import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { protokollDesLaufs } from "@/server/queries/protokoll"; import { kommentareDesLaufs, @@ -322,7 +322,7 @@ export default async function LaufSeite({ const laufProtokoll = await protokollDesLaufs(id); // Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der // Anhaenge dieses Laufs (#032). - const anhaenge = await anhaengeDesLaufs(id); + const anhaenge = await anhaengeDesLaufs(id, benutzer); /* * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in * jedem Seitenaufbau eine Abfrage, die niemand braucht. diff --git a/src/server/queries/anhaenge.ts b/src/server/queries/anhaenge.ts new file mode 100644 index 0000000..4f237ea --- /dev/null +++ b/src/server/queries/anhaenge.ts @@ -0,0 +1,78 @@ +import { prisma } from "@/lib/db"; +import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; + +/** + * Lesemodell der Anhänge (#037). + * + * **Warum das hier liegt und nicht beim Dienst.** `anhaengeDesLaufs` stand in + * `src/server/services/anhaenge.ts` und nahm nur eine Lauf-Kennung — ohne Rollen- + * und ohne Mandantensicht. Beide Aufrufer prüften den Lauf vorher, es war also + * kein Schaden; es stand aber gegen die Regel aus `CLAUDE.md` und + * `docs/architektur.md` §2: Lesemodelle liegen in `queries/*` und **wenden ihre + * Sicht selbst an, damit kein Aufrufer sie vergessen kann**. + * + * Zwei Aufrufer in zwei Schichten gab es schon (die Abarbeitungsansicht und die + * Druckansicht). Der dritte ist der, der es vergisst — und er fällt nicht auf, + * weil die bestehenden korrekt sind. Gefunden bei der Durchsicht von Phase 2. + */ + +export type AnhangZeile = { + readonly id: string; + readonly schrittergebnisId: string; + readonly dateiname: string; + readonly medientyp: string; + readonly groesse: number; + readonly erstelltAm: Date; + readonly erstelltVon: string | null; +}; + +/** + * Die Anhänge eines Laufs, je Schrittergebnis. **Nur Metadaten** — der Inhalt + * geht über `GET /api/anhaenge/[id]`, und der prüft sein Recht selbst. + * + * Gibt eine leere Liste zurück, wenn der Lauf dem Benutzer nicht sichtbar ist. + * Leer und nicht geworfen, weil dieselbe Antwort für „keine Anhänge“ und „nicht + * sichtbar“ richtig ist: ob es den Lauf gibt, ist für jemanden, der ihn nicht + * sehen darf, keine Auskunft. + */ +export async function anhaengeDesLaufs( + laufId: string, + benutzer: HandelnderBenutzer, +): Promise { + /* + * `undefined` heißt „alle Kunden“ — im Modus INTERN, für ADMIN und REDAKTEUR, + * und ohne eingerichtete Kundengruppen. Die Einschränkung sitzt deshalb in + * einem `...(sichtbar ? … : {})` und nicht in einem `in: sichtbar ?? []`: das + * zweite ergäbe für jeden Administrator eine leere Liste. + */ + const sichtbar = await sichtbareKundenIds(benutzer); + + const zeilen = await prisma.anhang.findMany({ + where: { + laufId, + ...(sichtbar ? { kundeId: { in: [...sichtbar] } } : {}), + }, + orderBy: { erstelltAm: "asc" }, + select: { + id: true, + schrittergebnisId: true, + dateiname: true, + medientyp: true, + groesse: true, + erstelltAm: true, + erstelltVon: { select: { name: true, email: true } }, + }, + }); + + return zeilen.map((zeile) => ({ + id: zeile.id, + schrittergebnisId: zeile.schrittergebnisId, + dateiname: zeile.dateiname, + medientyp: zeile.medientyp, + groesse: zeile.groesse, + erstelltAm: zeile.erstelltAm, + // Der Name, sonst die Mailadresse: ein Systemlauf hat keinen von beidem, und + // `null` heißt dort „von der Anwendung“ (#029 schreibt so, der Zeitplan auch). + erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null, + })); +} diff --git a/src/server/queries/druck.ts b/src/server/queries/druck.ts index 916e8ef..ebe15e2 100644 --- a/src/server/queries/druck.ts +++ b/src/server/queries/druck.ts @@ -1,7 +1,7 @@ import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { nurGeltende } from "@/lib/geltende-abschnitte"; -import { anhaengeDesLaufs } from "@/server/services/anhaenge"; +import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe"; @@ -226,7 +226,7 @@ export async function laufZumDrucken( const alleKommentare = await kommentareDesLaufs(laufId, benutzer); // Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei // nicht in sich (#032). - const alleAnhaenge = await anhaengeDesLaufs(laufId); + const alleAnhaenge = await anhaengeDesLaufs(laufId, benutzer); const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis])); let nummer = 0; diff --git a/src/server/services/anhaenge.ts b/src/server/services/anhaenge.ts index b4f7ff0..b55154c 100644 --- a/src/server/services/anhaenge.ts +++ b/src/server/services/anhaenge.ts @@ -213,42 +213,6 @@ export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenu await rm(dateiPfad(anhang), { force: true }); } -/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */ -export async function anhaengeDesLaufs(laufId: string): Promise< - readonly { - readonly id: string; - readonly schrittergebnisId: string; - readonly dateiname: string; - readonly medientyp: string; - readonly groesse: number; - readonly erstelltAm: Date; - readonly erstelltVon: string | null; - }[] -> { - const zeilen = await prisma.anhang.findMany({ - where: { laufId }, - orderBy: { erstelltAm: "asc" }, - select: { - id: true, - schrittergebnisId: true, - dateiname: true, - medientyp: true, - groesse: true, - erstelltAm: true, - erstelltVon: { select: { name: true, email: true } }, - }, - }); - return zeilen.map((zeile) => ({ - id: zeile.id, - schrittergebnisId: zeile.schrittergebnisId, - dateiname: zeile.dateiname, - medientyp: zeile.medientyp, - groesse: zeile.groesse, - erstelltAm: zeile.erstelltAm, - erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null, - })); -} - /** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */ export function anhangPfad(anhang: { readonly id: string; diff --git a/tests/anhaenge.test.ts b/tests/anhaenge.test.ts index 86ec43a..bce2b95 100644 --- a/tests/anhaenge.test.ts +++ b/tests/anhaenge.test.ts @@ -12,12 +12,12 @@ import { } from "@/lib/anhaenge"; import { AnhangFehler, - anhaengeDesLaufs, anhangEntfernen, anhangLesen, anhangPfad, anhangSpeichern, } from "@/server/services/anhaenge"; +import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { MandantFehler } from "@/server/mandant"; import { LaufFehler } from "@/server/services/laeufe"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; @@ -393,7 +393,7 @@ describe.runIf(datenbankVerfuegbar)("Anhänge", () => { admin, ); - const liste = await anhaengeDesLaufs(laufId); + const liste = await anhaengeDesLaufs(laufId, admin); expect(liste).toHaveLength(1); expect(liste[0]).toMatchObject({ schrittergebnisId: ergebnisId, @@ -402,6 +402,54 @@ describe.runIf(datenbankVerfuegbar)("Anhänge", () => { }); }); + it("engt die Liste selbst auf die sichtbaren Kunden ein – ohne Vorprüfung des Aufrufers", async () => { + /* + * **Die Zusage von #037.** Vorher nahm `anhaengeDesLaufs` nur eine + * Lauf-Kennung und wendete keine Sicht an; dass niemand fremde Anhänge sah, + * hing daran, dass beide Aufrufer den Lauf vorher prüften. Das war kein + * Schaden und trotzdem die falsche Form: ein Lesemodell wendet seine Sicht + * selbst an, **damit kein Aufrufer sie vergessen kann** (CLAUDE.md). + * + * Dieser Test ruft die Funktion deshalb **direkt** und ohne jede + * Vorprüfung — so wie es der dritte Aufrufer täte, der die Prüfung vergisst. + */ + const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-"; + try { + const { admin, laufId, schrittId } = await aufbau(); + await testPrisma.einstellung.update({ + where: { id: "global" }, + data: { modus: Betriebsmodus.DIENSTLEISTUNG }, + }); + await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "fremd.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + + const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FRD" } }); + const techniker = await testPrisma.benutzer.create({ + data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" }, + }); + await testPrisma.benutzerKunde.create({ + data: { benutzerId: techniker.id, kundeId: fremd.id }, + }); + + // Der Administrator sieht den Anhang. + expect(await anhaengeDesLaufs(laufId, admin)).toHaveLength(1); + // Der auf einen anderen Kunden beschränkte Techniker nicht. + expect(await anhaengeDesLaufs(laufId, { id: techniker.id, rolle: techniker.rolle })).toHaveLength(0); + } finally { + if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher; + } + }); + it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => { const { laufId, schrittId, ergebnisId } = await aufbau(); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index abf308d..e3f4b7b 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -2,7 +2,16 @@ import { chromium } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; import { ABTEILUNGEN } from "@/../prisma/seed-daten/stammdaten"; -import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer"; +import { passwortHashen } from "@/server/auth/benutzer"; +import { + anmelden, + E2E_MITARBEITER_MAIL, + E2E_MITARBEITER_NAME, + E2E_MITARBEITER_PASSWORT, + SEED_GERAET, + SEED_TAG, + SEED_VORLAGE, +} from "./helfer"; import { e2eAufraeumenMitBericht } from "./aufraeumen"; /** @@ -114,6 +123,42 @@ export default async function vorbereiten(): Promise { }); process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN"; + /* + * 2d. Das Konto der Rolle `MITARBEITER` fuer `/meine-geraete` (#037). + * + * Es muss an einer `Mitarbeiter`-Zeile haengen — der CHECK + * `Benutzer_mitarbeiter_nur_bei_rolle` verlangt das in **beide** Richtungen + * (#035): ein MITARBEITER ohne Person waere ein Recht, das sich auf niemanden + * bezieht. Genommen wird die erste Person des Seed-Bestands, denn sie hat + * Geraete, und ohne Geraet zeigte die Seite nur ihren Leerzustand. + */ + const person = await prisma.mitarbeiter.findFirst({ + where: { aktiv: true, geraete: { some: {} } }, + select: { id: true, kundeId: true, anzeigename: true }, + orderBy: { anzeigename: "asc" }, + }); + if (!person) { + fehlen( + "Es gibt keine Person mit einem Gerät.", + "pnpm db:seed laufen lassen — die Selbstauskunft braucht eine Person, der ein Gerät gehört.", + ); + } + await prisma.benutzer.upsert({ + where: { email: E2E_MITARBEITER_MAIL }, + create: { + email: E2E_MITARBEITER_MAIL, + name: E2E_MITARBEITER_NAME, + rolle: "MITARBEITER", + passwortHash: await passwortHashen(E2E_MITARBEITER_PASSWORT), + mitarbeiterId: person.id, + aktiv: true, + }, + // Ein Durchgang, der vorher abgebrochen ist, laesst das Konto stehen. Die + // Person kann dabei eine andere sein als beim letzten Mal. + update: { rolle: "MITARBEITER", mitarbeiterId: person.id, aktiv: true }, + }); + process.env["E2E_MITARBEITER_PERSON"] = person.anzeigename; + /* * 3. Liest der Server **dieselbe** Datenbank? * diff --git a/tests/e2e/helfer.ts b/tests/e2e/helfer.ts index 86f3a0d..7608db4 100644 --- a/tests/e2e/helfer.ts +++ b/tests/e2e/helfer.ts @@ -20,6 +20,22 @@ export const SEED_VORLAGE = "PC Neuinstallation"; /** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */ export const SEED_GERAET = "PC-001"; + +/** + * Ein Konto der Rolle `MITARBEITER`, nur fuer die Ablaeufe (#037). + * + * **Nicht im Seed**, obwohl das kuerzer waere: ein Konto mit bekanntem Passwort + * gehoert nicht in einen Seed, der auch produktiv laeuft. Der E2E-Vorlauf legt + * es an, der Nachlauf nimmt es wieder mit — es traegt `ZZ` im Namen und faellt + * damit unter `tests/e2e/aufraeumen.ts`. + * + * Gebraucht wird es, weil `/meine-geraete` das Recht `selbstauskunft` verlangt + * und das **nur** `MITARBEITER` hat: ein Administrator saehe dort „kein + * Zugriff“, und ein Ablauf haette die Verweigerungsseite gemessen. + */ +export const E2E_MITARBEITER_MAIL = "zz.e2e.mitarbeiter@firma.example"; +export const E2E_MITARBEITER_PASSWORT = "zz-e2e-nur-fuer-ablaeufe"; +export const E2E_MITARBEITER_NAME = "ZZ E2E Mitarbeiterkonto"; export const SEED_TAG = "LZ-7K3F9Q"; export async function anmelden(seite: Page): Promise { @@ -31,10 +47,40 @@ export async function anmelden(seite: Page): Promise { "Administrator passen, den der Seed angelegt hat.", ); } + /* + * Wartet auf `/`, und zwar genau so streng wie vorher. Lockerer („irgendwas + * ausser /anmelden“) sah harmlos aus und machte zwei Ablaeufe im vollen + * Durchgang unzuverlaessig: der naechste Schritt begann, bevor die Uebersicht + * stand. Der Admin landet immer auf `/` — fuer ihn gibt es keinen Grund, das + * aufzuweichen. + */ + await anmeldenMit(seite, email, passwort, "**/"); +} + +/** + * Anmelden mit beliebigen Zugangsdaten. + * + * `ziel` ist das Muster, auf das gewartet wird. Ohne Angabe genuegt es, dass die + * Anmeldeseite verlassen ist: ein `MITARBEITER` landet auf `/meine-geraete`, + * weil `startseite(rolle)` das Ziel an der Rolle entscheidet (#035), und ein + * Warten auf `/` liefe dort in den Zeitablauf. + */ +export async function anmeldenMit( + seite: Page, + email: string, + passwort: string, + ziel?: string, +): Promise { await seite.goto("/anmelden"); await seite.fill('input[name="email"]', email); await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); + await Promise.all([ + seite.waitForURL(ziel ?? ((adresse) => !adresse.pathname.startsWith("/anmelden"))), + seite.click('button[type="submit"]'), + ]); + // Und bis die Seite wirklich steht: ein `waitForURL` endet an der Adresse, + // nicht am geladenen Dokument. + await seite.waitForLoadState("load"); } /** diff --git a/tests/e2e/mobil.spec.ts b/tests/e2e/mobil.spec.ts index f4e4cfb..ba78458 100644 --- a/tests/e2e/mobil.spec.ts +++ b/tests/e2e/mobil.spec.ts @@ -1,7 +1,15 @@ import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; -import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer"; +import { + E2E_MITARBEITER_MAIL, + E2E_MITARBEITER_PASSWORT, + SEED_TAG, + SEED_VORLAGE, + anmelden, + anmeldenMit, + betriebsmodus, +} from "./helfer"; /** * Am Handy scrollt nichts nach rechts (#018). @@ -191,10 +199,10 @@ const SEITEN: readonly Hauptseite[] = [ * Zustellungs-Protokoll sind Tabellen, und Tabellen sind die Stellen, an denen * horizontales Scrollen entsteht. * - * Nicht dabei: `/meine-geraete` (#035) — es verlangt das Recht - * `selbstauskunft`, und das hat nur `MITARBEITER`. Dieser Ablauf meldet sich - * als Admin an, der dort „kein Zugriff“ sähe; gemessen würde die - * Verweigerungsseite. Dafür braucht es ein eigenes Konto, siehe #037. + * `/meine-geraete` steht **nicht** in dieser Liste, sondern in einem eigenen + * Block darunter: die Seite verlangt `selbstauskunft`, und das hat nur + * `MITARBEITER`. Mit der Anmeldung dieses Ablaufs (Admin) würde die + * Verweigerungsseite gemessen — und die scrollt garantiert nicht (#037). */ { name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" }, { name: "Admin: Schnellstart", pfad: () => "/admin/schnellstart" }, @@ -283,6 +291,65 @@ test.describe("Am Handy bei 390 px", () => { } }); +/** + * `/meine-geraete` braucht eine andere Anmeldung (#037). + * + * Eigener Block statt eines Sonderfalls in der Schleife oben: die Seitenliste + * dort gilt für **eine** Rolle, und ein `if` darin hätte beim nächsten Eintrag + * geerbt werden müssen. Die Messung selbst ist dieselbe Funktion. + */ +test.describe("Am Handy bei 390 px: die Selbstauskunft", () => { + const PFAD = "/meine-geraete"; + + test(`scrollt „Meine Geräte“ nicht horizontal`, async ({ page }) => { + test.slow(); + await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT); + + await page.goto(PFAD); + /* + * Dieselbe Vorsicht wie oben, hier noch wichtiger: hätte das Konto die Rolle + * verloren, landete es auf `/kein-zugriff` — und der Ablauf hätte grün + * gemeldet, ohne die Seite gesehen zu haben. + */ + expect(new URL(page.url()).pathname, `${PFAD} hat weitergeleitet.`).toBe(PFAD); + await page.waitForLoadState("load"); + + // Und es ist wirklich die Selbstauskunft, nicht eine leere Hülle: der Name + // der verknüpften Person steht darauf. + const person = process.env["E2E_MITARBEITER_PERSON"]; + if (person) { + await expect(page.getByText(person, { exact: false }).first()).toBeVisible(); + } + + const masse = await messen(page); + expect( + masse.scroll, + `„Meine Geräte“ (${PFAD}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` + + `war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` + + (masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""), + ).toBeLessThanOrEqual(masse.sichtbar); + }); + + test("sieht dort keinen Weg in die Verwaltung", async ({ page }) => { + /* + * Die Gegenprobe zur Rolle, und sie gehört hierher: die Seitenleiste nennt + * seit #035 an jedem Eintrag ihr Recht, damit einem `MITARBEITER` keine + * sechs Links angeboten werden, die alle auf `/kein-zugriff` führen. Das + * ist in Vitest als Rollenmatrix geprüft — dass es im **gerenderten** DOM + * ankommt, nur hier. + */ + await anmeldenMit(page, E2E_MITARBEITER_MAIL, E2E_MITARBEITER_PASSWORT); + await page.goto("/meine-geraete"); + + for (const ziel of ["/admin", "/vorlagen", "/laeufe", "/mitarbeiter", "/geraete", "/archiv"]) { + await expect( + page.locator(`a[href^="${ziel}"]`), + `Ein MITARBEITER bekommt einen Link auf ${ziel} angeboten.`, + ).toHaveCount(0); + } + }); +}); + test.afterAll(async () => { if (laufId) { await prisma.kommentar.deleteMany({ where: { laufId } }); diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts index 25d6ad7..e25a6da 100644 --- a/tests/zugang.test.ts +++ b/tests/zugang.test.ts @@ -232,6 +232,111 @@ describe("Rechtepruefung an den Einstiegspunkten", () => { expect(befunde).toEqual([]); }); + /** + * Alle `page.tsx` unter `(app)`, rekursiv. + * + * Von Hand aufzuzaehlen waere dasselbe Problem wie bei der Proxy-Allowlist: + * eine Liste, die dem Code hinterherlaeuft. Eine neue Seite soll **ohne** + * Zutun in diesem Test landen. + */ + function seitenDateien(ordner: string): string[] { + const gefunden: string[] = []; + for (const eintrag of readdirSync(ordner, { withFileTypes: true })) { + const pfad = join(ordner, eintrag.name); + if (eintrag.isDirectory()) gefunden.push(...seitenDateien(pfad)); + else if (eintrag.name === "page.tsx") gefunden.push(pfad); + } + return gefunden; + } + + it("laesst jede Seite ihr Recht selbst pruefen", () => { + /* + * **Die Huelle ist das zweite Netz, nicht das erste** (CLAUDE.md). Next + * rendert Layouts bei einer Navigation innerhalb desselben Layouts nicht + * neu — eine Seite, die sich auf `src/app/(app)/layout.tsx` verlaesst, ist + * nach dem ersten Seitenwechsel ungeschuetzt. + * + * Dass das heute an allen 27 Seiten stimmt, hat der Review von Phase 2 **von + * Hand** nachgesehen (Reviewbefund zu #029). Eine Pruefung von Hand ist kein + * Regressionstest: sie gilt fuer den Tag, an dem sie lief. Dieser Test gilt + * fuer jede kuenftige Seite — und genau dort liegt sein Wert, nicht im + * heutigen gruenen Ergebnis. + */ + const befunde: string[] = []; + const seiten = seitenDateien("src/app/(app)"); + + for (const datei of seiten) { + const quelle = readFileSync(datei, "utf8"); + const prueft = quelle.includes("sitzungErforderlich(") || quelle.includes("rechtErforderlich("); + if (!prueft) befunde.push(`${datei} prueft weder Sitzung noch Recht`); + } + + expect(befunde).toEqual([]); + // Ohne diese Zeile waere eine leere Liste ein gruener Test. + expect(seiten.length).toBeGreaterThan(20); + }); + + it("laesst jeden Route Handler sein Recht selbst pruefen", () => { + /* + * **Die Huelle deckt Route Handler gar nicht ab**, und der Proxy prueft das + * Cookie absichtlich nur auf Vorhandensein (CLAUDE.md). Alles unter + * `src/app/api/`, das mehr tut als Auth und Healthcheck, prueft deshalb + * selbst. + * + * Die Ausnahmen stehen **namentlich** darunter, wie bei den oeffentlichen + * Aktionen: ein neuer Handler ohne Pruefung soll jemanden zwingen, ihn hier + * einzutragen — und damit zu begruenden. + * + * Dass das noetig ist, hat der Review gezeigt: beim Anhang-Handler liess + * sich die ganze Pruefung entfernen, ohne dass ein Test rot wurde. Jetzt + * faehrt `tests/anhang-route.test.ts` ihn echt; dieser Test hier faengt den + * **naechsten**. + */ + const OHNE_PRUEFUNG = new Map([ + [ + "src/app/api/gesundheit/route.ts", + "Healthcheck fuer Docker und den Reverse Proxy – muss ohne Anmeldung antworten", + ], + ["src/app/api/auth/abmelden/route.ts", "Abmelden kann keine gueltige Sitzung verlangen"], + ["src/app/api/auth/oidc/start/route.ts", "Beginn des Anmeldevorgangs"], + ["src/app/api/auth/oidc/rueckruf/route.ts", "Rueckruf des Anbieters, vor der Sitzung"], + ]); + + function handlerDateien(ordner: string): string[] { + const gefunden: string[] = []; + for (const eintrag of readdirSync(ordner, { withFileTypes: true })) { + const pfad = join(ordner, eintrag.name); + if (eintrag.isDirectory()) gefunden.push(...handlerDateien(pfad)); + else if (eintrag.name === "route.ts") gefunden.push(pfad); + } + return gefunden; + } + + const befunde: string[] = []; + const handler = handlerDateien("src/app/api"); + + for (const datei of handler) { + const quelle = readFileSync(datei, "utf8"); + const prueft = + quelle.includes("rechtErforderlich(") || + quelle.includes("sitzungErforderlich(") || + // Der SCIM-Weg: das Bearer-Token wird zur Quelle und damit zum Kunden. + quelle.includes("quelleAusAnfrage("); + if (!prueft && !OHNE_PRUEFUNG.has(datei)) { + befunde.push(`${datei} prueft nichts und steht nicht in der Ausnahmeliste`); + } + } + + expect(befunde).toEqual([]); + expect(handler.length).toBeGreaterThan(8); + + // Und die Gegenrichtung: eine Ausnahme fuer einen Handler, den es nicht mehr + // gibt, verdeckt beim naechsten Mal einen echten Befund. + for (const datei of OHNE_PRUEFUNG.keys()) { + expect(handler, `${datei} steht in der Ausnahmeliste, existiert aber nicht`).toContain(datei); + } + }); + it("erkennt eine Aktion ohne jede Pruefung", () => { // Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er // trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.