#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:
co-authored by
Claude Opus 5
parent
80d97467da
commit
75c335df23
@@ -54,6 +54,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Behoben
|
### 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
|
- **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
|
`/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
|
`/admin/verzeichnis`. Das ist die eine Meldung, die an alle Administratoren geht, und zwar im
|
||||||
|
|||||||
@@ -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
|
**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
|
und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur
|
||||||
auf Vorhandensein, nie inhaltlich.
|
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
|
- **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` —
|
`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
|
`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.
|
speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
|
||||||
`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand.
|
`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
|
## Vor jedem Commit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ Zwei Folgen, beide verbindlich:
|
|||||||
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
|
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.
|
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
|
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
|
`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
|
`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
|
||||||
|
|||||||
+64
-2
@@ -38,12 +38,14 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min |
|
| #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min |
|
||||||
| #036 | Reviewbefunde aus der Durchsicht von Phase 2 | 3 h 40 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 |
|
| #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 |
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
| 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
|
- 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
|
E2E-Suite nur gelegentlich läuft, ist ein Release mit ungeprüften Zusagen — und genau das hat
|
||||||
diese Sitzung gezeigt.
|
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.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #037 Zwei Nachzügler aus der Durchsicht von Phase 2
|
# #037 Zwei Nachzügler aus der Durchsicht von Phase 2
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** –
|
**Im Tracker:** –
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend, test
|
**Labels:** phase-2, backend, test
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 1 h 50 min
|
||||||
|
|
||||||
## Ziel
|
## 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
|
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.
|
auffallen, weil die zwei bestehenden korrekt sind.
|
||||||
|
|
||||||
- [ ] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter
|
- [x] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter
|
||||||
- [ ] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer
|
- [x] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer
|
||||||
- [ ] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne**
|
- [x] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne**
|
||||||
dass der Aufrufer vorher geprüft hat
|
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
|
## 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
|
Ablauf meldet sich als Admin an und würde die Verweigerungsseite messen — die scrollt garantiert
|
||||||
nicht.
|
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)
|
`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
|
nicht in einen Seed, der auch produktiv läuft
|
||||||
- [ ] `/meine-geraete` in die Seitenliste von `mobil.spec.ts`
|
- [x] `/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] Dabei prüfen, was ein `MITARBEITER` auf den **anderen** Seiten sieht: die Rollenmatrix ist in
|
||||||
Vitest vollständig, aber kein Ablauf klickt sie durch
|
Vitest vollständig, aber kein Ablauf klickt sie durch
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## 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
|
Aufrufers
|
||||||
- [ ] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER`
|
- [x] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER`
|
||||||
- [ ] Beide Gegenproben werden rot
|
- [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.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #038 Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot
|
# #038 Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** –
|
**Im Tracker:** –
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, test, infra
|
**Labels:** phase-2, test, infra
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 1 h 35 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -27,28 +27,66 @@ eine Notiz und keine Prüfung.
|
|||||||
|
|
||||||
## Zu entscheiden
|
## 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
|
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.
|
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
|
acht Läufe in Phase 2 gewesen und hätte jeden der vier Befunde am Tag seiner Entstehung
|
||||||
gezeigt. Das ist die Empfehlung.
|
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.
|
ist eine eigene Entscheidung und gehört nicht in dieses Issue.
|
||||||
|
|
||||||
## Aufgaben
|
## 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
|
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
|
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
|
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`.)
|
`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
|
Rückfall auf Polling, Herzschlag am echten Browser und 390 px
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] In `CLAUDE.md` steht, wann E2E läuft, und die Angabe ist gemessen, nicht geschätzt
|
- [x] 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] 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.
|
||||||
|
|||||||
@@ -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 |
|
| #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 |
|
| #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 |
|
| #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 | – |
|
| #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) | offen | v0.3.0 | 3 h | – |
|
| #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** | **70 h 30 min** |
|
| | **Summe** | | | **238 h** | **73 h 55 min** |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
|||||||
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
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 { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||||
import {
|
import {
|
||||||
kommentareDesLaufs,
|
kommentareDesLaufs,
|
||||||
@@ -322,7 +322,7 @@ export default async function LaufSeite({
|
|||||||
const laufProtokoll = await protokollDesLaufs(id);
|
const laufProtokoll = await protokollDesLaufs(id);
|
||||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
||||||
// Anhaenge dieses Laufs (#032).
|
// 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
|
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
||||||
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
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 { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
||||||
@@ -226,7 +226,7 @@ export async function laufZumDrucken(
|
|||||||
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
||||||
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
|
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
|
||||||
// nicht in sich (#032).
|
// 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]));
|
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
|
||||||
|
|
||||||
let nummer = 0;
|
let nummer = 0;
|
||||||
|
|||||||
@@ -213,42 +213,6 @@ export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenu
|
|||||||
await rm(dateiPfad(anhang), { force: true });
|
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. */
|
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
|
||||||
export function anhangPfad(anhang: {
|
export function anhangPfad(anhang: {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|||||||
+50
-2
@@ -12,12 +12,12 @@ import {
|
|||||||
} from "@/lib/anhaenge";
|
} from "@/lib/anhaenge";
|
||||||
import {
|
import {
|
||||||
AnhangFehler,
|
AnhangFehler,
|
||||||
anhaengeDesLaufs,
|
|
||||||
anhangEntfernen,
|
anhangEntfernen,
|
||||||
anhangLesen,
|
anhangLesen,
|
||||||
anhangPfad,
|
anhangPfad,
|
||||||
anhangSpeichern,
|
anhangSpeichern,
|
||||||
} from "@/server/services/anhaenge";
|
} from "@/server/services/anhaenge";
|
||||||
|
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||||||
import { MandantFehler } from "@/server/mandant";
|
import { MandantFehler } from "@/server/mandant";
|
||||||
import { LaufFehler } from "@/server/services/laeufe";
|
import { LaufFehler } from "@/server/services/laeufe";
|
||||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
@@ -393,7 +393,7 @@ describe.runIf(datenbankVerfuegbar)("Anhänge", () => {
|
|||||||
admin,
|
admin,
|
||||||
);
|
);
|
||||||
|
|
||||||
const liste = await anhaengeDesLaufs(laufId);
|
const liste = await anhaengeDesLaufs(laufId, admin);
|
||||||
expect(liste).toHaveLength(1);
|
expect(liste).toHaveLength(1);
|
||||||
expect(liste[0]).toMatchObject({
|
expect(liste[0]).toMatchObject({
|
||||||
schrittergebnisId: ergebnisId,
|
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 () => {
|
it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => {
|
||||||
const { laufId, schrittId, ergebnisId } = await aufbau();
|
const { laufId, schrittId, ergebnisId } = await aufbau();
|
||||||
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import { chromium } from "@playwright/test";
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { ABTEILUNGEN } from "@/../prisma/seed-daten/stammdaten";
|
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";
|
import { e2eAufraeumenMitBericht } from "./aufraeumen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +123,42 @@ export default async function vorbereiten(): Promise<void> {
|
|||||||
});
|
});
|
||||||
process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN";
|
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?
|
* 3. Liest der Server **dieselbe** Datenbank?
|
||||||
*
|
*
|
||||||
|
|||||||
+47
-1
@@ -20,6 +20,22 @@ export const SEED_VORLAGE = "PC Neuinstallation";
|
|||||||
|
|
||||||
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
|
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
|
||||||
export const SEED_GERAET = "PC-001";
|
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 const SEED_TAG = "LZ-7K3F9Q";
|
||||||
|
|
||||||
export async function anmelden(seite: Page): Promise<void> {
|
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.",
|
"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.goto("/anmelden");
|
||||||
await seite.fill('input[name="email"]', email);
|
await seite.fill('input[name="email"]', email);
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
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
@@ -1,7 +1,15 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
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).
|
* 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
|
* Zustellungs-Protokoll sind Tabellen, und Tabellen sind die Stellen, an denen
|
||||||
* horizontales Scrollen entsteht.
|
* horizontales Scrollen entsteht.
|
||||||
*
|
*
|
||||||
* Nicht dabei: `/meine-geraete` (#035) — es verlangt das Recht
|
* `/meine-geraete` steht **nicht** in dieser Liste, sondern in einem eigenen
|
||||||
* `selbstauskunft`, und das hat nur `MITARBEITER`. Dieser Ablauf meldet sich
|
* Block darunter: die Seite verlangt `selbstauskunft`, und das hat nur
|
||||||
* als Admin an, der dort „kein Zugriff“ sähe; gemessen würde die
|
* `MITARBEITER`. Mit der Anmeldung dieses Ablaufs (Admin) würde die
|
||||||
* Verweigerungsseite. Dafür braucht es ein eigenes Konto, siehe #037.
|
* Verweigerungsseite gemessen — und die scrollt garantiert nicht (#037).
|
||||||
*/
|
*/
|
||||||
{ name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" },
|
{ name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" },
|
||||||
{ name: "Admin: Schnellstart", pfad: () => "/admin/schnellstart" },
|
{ 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 () => {
|
test.afterAll(async () => {
|
||||||
if (laufId) {
|
if (laufId) {
|
||||||
await prisma.kommentar.deleteMany({ where: { laufId } });
|
await prisma.kommentar.deleteMany({ where: { laufId } });
|
||||||
|
|||||||
@@ -232,6 +232,111 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
|
|||||||
expect(befunde).toEqual([]);
|
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", () => {
|
it("erkennt eine Aktion ohne jede Pruefung", () => {
|
||||||
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
|
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
|
||||||
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
|
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
|
||||||
|
|||||||
Reference in New Issue
Block a user