test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor
jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier
Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das
umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und
jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen.

Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein
Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine
laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in
CLAUDE.md.

Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne
Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft
hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat
alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt.
tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen
(Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch
die Gegenrichtung.

#037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher
hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird
bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf,
dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird.

Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das
zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich
habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe
unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen.
Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen
Durchgang haette ich zwei rote Ablaeufe gemergt.

1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur
noch #024, und das verlangt einen echten Proxmox-Host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 06:05:29 +02:00
co-authored by Claude Opus 5
parent 80d97467da
commit 75c335df23
16 changed files with 639 additions and 80 deletions
+10
View File
@@ -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
+51
View File
@@ -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
+11
View File
@@ -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
+64 -2
View File
@@ -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.
+48 -14
View File
@@ -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.
+50 -12
View File
@@ -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.
+3 -3
View File
@@ -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** |
+2 -2
View File
@@ -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.
+78
View File
@@ -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<readonly AnhangZeile[]> {
/*
* `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,
}));
}
+2 -2
View File
@@ -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;
-36
View File
@@ -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;
+50 -2
View File
@@ -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" } });
+46 -1
View File
@@ -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<void> {
});
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?
*
+47 -1
View File
@@ -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<void> {
@@ -31,10 +47,40 @@ export async function anmelden(seite: Page): Promise<void> {
"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<void> {
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");
}
/**
+72 -5
View File
@@ -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 } });
+105
View File
@@ -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.