docs(quick-260921-fi3): erzwungener Passwortwechsel an der API durchgesetzt
Plan, Zusammenfassung, Verifikation und STATE.md zum Quick-Vorgang 260921-fi3. Befund: auth.service legt mustChangePassword in den JWT, jwt.strategy liess das Feld beim Auspacken fallen. request.user.mustChangePassword war damit immer undefined und der global registrierte ForcePasswordChangeInterceptor hat seit seiner Einfuehrung nie blockiert. Durchgesetzt wurde der Zwangswechsel allein von der Web-Middleware; jeder Weg daran vorbei umging ihn. Gemessen: eine Sitzung mit mustChangePassword=true erhielt auf GET /users 200 samt vollstaendiger Benutzerliste. Behoben, und belegt bei gleicher Rolle und gleicher Route: GET /modules/active liefert 403 FORCE_PASSWORD_CHANGE mit Zwang und 200 ohne. Neue Spezifikationen gegen den alten Stand 6 von 12 rot, danach 12 von 12 gruen, vom Verifier unabhaengig nachgestellt. Der Browser-Ablauf wurde vollstaendig durchgespielt: niemand wird ausgesperrt, der Wechsel gelingt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
+274
@@ -0,0 +1,274 @@
|
||||
---
|
||||
phase: quick-260921-fi3
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
# Aufgabe 1 — Sicherheitsfehler (Befund 1)
|
||||
- apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
- apps/api/src/auth/strategies/jwt.strategy.spec.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts
|
||||
# Aufgabe 2 — Doppelausloesung und fest verdrahtete Texte (Befund 2)
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
autonomous: true
|
||||
requirements: [SEC-FORCE-PW, UI-DKV-DELETE, I18N-DKV]
|
||||
|
||||
estimate:
|
||||
tokens: 75000
|
||||
raw_tokens: 75000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Eine Sitzung mit mustChangePassword=true erhaelt an der API auf GET /users und GET /modules/active 403 — heute gemessen 200 (D-01)."
|
||||
- "Dieselbe Sitzung erreicht GET /auth/me (200), POST /auth/logout (200) und POST /auth/change-password (401 bei falschem aktuellem Kennwort, also der Fachfehler des Handlers und NICHT 403) — die drei Wege, die der Anmeldeablauf im Browser wirklich benutzt (D-02)."
|
||||
- "request.user traegt mustChangePassword als echten Wahrheitswert; fehlt der Anspruch im Token (Alt-Sitzung), ist er false und die Sitzung laeuft unveraendert weiter (D-01)."
|
||||
- "Die Erlaubnisliste des Abfangers vergleicht Methode UND Pfad auf Gleichheit; ein Pfad, der einen erlaubten Pfad lediglich enthaelt, wird blockiert — nachgewiesen durch einen Testfall, denn eine kollidierende Route gibt es heute nicht (D-01)."
|
||||
- "Es gibt einen Testfall, der gegen den heutigen Quelltext scheitert: er fuehrt JwtStrategy.validate und den Abfanger zusammen und beweist damit die Naht, an der das Feld bisher verloren ging (D-03)."
|
||||
- "Ein zweiter Klick auf die Bestaetigungsschaltflaeche des Loeschdialogs loest kein zweites DELETE aus — nachgewiesen im Komponententest, nicht behauptet."
|
||||
- "Kein sichtbarer Text und keine Vorlesehilfe der Fahrzeugtabelle steht mehr fest verdrahtet im Bauteil; alle stammen aus de.json und en.json, beide Sprachdateien tragen denselben Schluesselsatz (D-04)."
|
||||
- "Die gruenen Balken bleiben stehen: apps/api 69 Dateien / mindestens 1124 Tests, apps/web 66 Dateien / mindestens 459 Tests, pnpm type-check 4/4, pnpm lint 5/5 ohne Befunde der Stufe Fehler, Warnungssumme hoechstens 466 (heute gemessen 464) (D-06)."
|
||||
- "Die Datenbank steht am Ende wieder auf dem Ausgangsstand: admin, nutzer1, nutzer2 alle mit mustChangePassword=false."
|
||||
artifacts:
|
||||
- "apps/api/src/auth/strategies/jwt.strategy.spec.ts — neu, pinnt die Durchreichung des Anspruchs"
|
||||
- "apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts — neu, pinnt Sperre, Erlaubnisliste und die Teilstring-Falle"
|
||||
- "apps/api/src/auth/strategies/jwt.strategy.ts — validate liefert mustChangePassword"
|
||||
- "apps/api/src/auth/interceptors/force-password-change.interceptor.ts — Erlaubnisliste aus Methode und Pfad, Kommentar auf den wahren Stand gebracht"
|
||||
- "apps/web/.../VehicleTable.tsx — Sperre gegen Doppelausloesung, alle Texte ueber next-intl"
|
||||
- "apps/web/.../VehicleTable.test.tsx — neue Testfaelle fuer Doppelklick und Textherkunft"
|
||||
- "apps/web/src/messages/de.json + en.json — sieben neue Schluessel im Bereich dkvFleet, in beiden Sprachen"
|
||||
key_links:
|
||||
- "JwtStrategy.validate() -> request.user.mustChangePassword -> ForcePasswordChangeInterceptor — die Naht, an der die Durchsetzung bisher zerriss"
|
||||
- "AuthService.changePassword() -> neues Sitzungs-Cookie mit mustChangePassword:false -> changePasswordAction reicht Set-Cookie an den Browser weiter -> redirect('/') — der Weg, auf dem die Kennzeichnung nach erfolgreichem Wechsel verschwindet"
|
||||
- "DeleteDialog.onConfirm -> confirmDelete -> deleteVehicle — darf je Zieldatensatz genau einmal laufen"
|
||||
- "de.json <-> en.json, Bereich dkvFleet — gleicher Schluesselsatz in beiden Sprachen"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Drei voneinander unabhaengige Befunde aus der Lint-Rueckstandsaufgabe 260921-bi2, nach Risiko gruppiert. Der schwere davon ist ein echtes Sicherheitsloch: der erzwungene Passwortwechsel wird an der API ueberhaupt nicht durchgesetzt.
|
||||
|
||||
**Befund 1 (hoch, sicherheitsrelevant).** `AuthService.login` legt `mustChangePassword` in das Sitzungs-Token (auth.service.ts:176), `JwtStrategy.validate` gibt aber nur `id`, `username`, `role` und `tenantId` zurueck und laesst das Feld fallen. Der global registrierte `ForcePasswordChangeInterceptor` (app.module.ts:73) prueft `request.user.mustChangePassword` — ein Wert, der damit immer undefiniert ist. Der Abfanger hat also noch nie etwas blockiert; seine eigene Kommentarzeile behauptet das Gegenteil. Durchgesetzt wird der Zwangswechsel heute einzig in der Next.js-Zwischenschicht (`apps/web/src/middleware.ts:146`), die das Token selbst liest. Alles, was ohne diese Zwischenschicht an der API haengt — der Tauri-Desktop-Klient, ein Skript, `curl` — geht am Zwangswechsel vorbei. Der Orchestrator hat das am laufenden System reproduziert: frisch angemeldet mit gesetzter Kennzeichnung lieferte `GET /users` 200 mit vollstaendiger Benutzerliste. Behoben wird die Ursache, nicht das Symptom (D-01), und zusaetzlich die zweite, heute noch nicht ausnutzbare Schwaeche derselben Datei: die Erlaubnisliste vergleicht per Teilstring und ohne Ruecksicht auf die HTTP-Methode.
|
||||
|
||||
**Befund 2 (mittel).** In `VehicleTable.tsx` wird das Beschaeftigt-Kennzeichen geschrieben, aber nie gelesen (`const [, setIsDeleting]`). Dialog und Bestaetigungsschaltflaeche bleiben waehrend der laufenden Loeschanfrage bedienbar, ein zweiter Klick schickt ein zweites DELETE auf denselben Datensatz. Dieselbe Datei traegt sieben fest verdrahtete deutsche Zeichenketten und vier fest verdrahtete Vorlesehilfen, die ueber next-intl gehoeren (D-04).
|
||||
|
||||
**Befund 3 (niedrig) — bewusst keine Aenderung, begruendet.** `downloadAllAsZip` in `SplitTab.tsx` legt den Dateinamen `certificates.zip` fest. Die Funktion steht auf Modulebene und kann den Uebersetzungshaken nicht aufrufen; ein uebersetzter Name muesste als Parameter von aussen hereingereicht werden. Das ist den Umbau nicht wert: ein Downloadname ist kein Bedienelement, sondern ein Dateisystem-Artefakt. Er wird nie vorgelesen, nie gesucht, nie angeklickt; er erscheint einmal im Download-Ordner. Uebersetzte Dateinamen bringen dafuer echte Nachteile — Umlaute in Dateinamen auf Windows-Freigaben, Anhaenge, die je nach Sprache des Absenders anders heissen, und ein Bauteil, das eine Zeichenkette nur noch durchreicht. Der ASCII-Name bleibt so, wie er ist; die Dateien IM Archiv tragen ohnehin die vom Server gelieferten Zertifikatsnamen. **Die Datei wird in dieser Aufgabe nicht angefasst.**
|
||||
|
||||
Purpose: Eine Zugangssperre, die seit ihrer Einfuehrung tot war, wirklich scharf stellen — mit Tests, die beweisen, dass sie lebt (D-03) — und zwei Bedienmaengel in der Fahrzeugtabelle beseitigen, ohne bestehende Berechtigungen zu schwaechen (D-02).
|
||||
Output: Zwei geaenderte API-Dateien plus zwei neue Testdateien, ein geaendertes Web-Bauteil plus erweiterte Testdatei, sieben neue Uebersetzungsschluessel in beiden Sprachen, und ein am HTTP gemessener Nachweis.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
|
||||
@apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
@apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
@apps/api/src/tenant/tenant.guard.spec.ts
|
||||
@apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
|
||||
@apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx
|
||||
|
||||
**Beim Planen gelesen und gemessen — nicht noch einmal oeffnen, die Ergebnisse stehen hier:**
|
||||
|
||||
- `apps/api/src/app.module.ts:73` — der Abfanger ist global als `APP_INTERCEPTOR` registriert. Waechter laufen in NestJS vor Abfangern, `request.user` ist zum Zeitpunkt des Abfangens also bereits gefuellt.
|
||||
- `apps/api/src/main.ts` — **kein** `setGlobalPrefix`. `request.route.path` ist damit der volle Pfad, z. B. `/auth/me`. Die API lauscht auf Port 3001.
|
||||
- `apps/api/src/auth/auth.service.ts:176` — `login` legt den Anspruch ins Token. `auth.service.ts:373-389` — `changePassword` setzt in der Datenbank `mustChangePassword:false` **und** signiert ein neues Token mit `mustChangePassword:false` und setzt es als Cookie. `apps/web/src/lib/auth-actions.ts:140-159` — die Serveraktion liest den `set-cookie`-Kopf aus der API-Antwort, setzt ihn im Browser und leitet auf `/` um. **Die Kennzeichnung bleibt nach erfolgreichem Wechsel also nicht haengen**, und `apps/api/src/auth/auth.service.spec.ts:610` pinnt genau das bereits als Test. Hier ist nichts zu tun.
|
||||
- **Welche Wege der Browser im Zwangswechsel wirklich geht** (D-02, vollstaendig nachgelesen): `/change-password` liegt in der Routengruppe `(portal)`, also innerhalb der `AppShell`. Auf dieser Seite laufen genau vier API-Aufrufe: der Kopfbereich ruft `fetchSessionState()` → `GET /auth/me` (erlaubt), die Seitenleiste ruft `GET /modules/active` (wird kuenftig 403 — sie faengt jeden nicht-ok-Fall wortlos ab, `sidebar.tsx:45-55`, die Seite bricht nicht), das Profilbild im Kopfbereich ruft `GET /users/me/avatar` (wird kuenftig 403 statt wie heute 404 — der Kopfbereich hat einen `onError`-Rueckfall auf die Initiale), und das Formular schickt `POST /auth/change-password` (erlaubt). Der Wurzel-Layout und die Anbieter-Bauteile machen **keine** API-Aufrufe. Die drei Eintraege der Erlaubnisliste decken den Ablauf im Browser damit vollstaendig ab.
|
||||
- `grep -rn "FORCE_PASSWORD_CHANGE" apps` — **kein** Abnehmer im Frontend. Der Antwortkoerper des 403 ist frei, wird aber trotzdem unveraendert gelassen.
|
||||
- Testmuster fuer Waechter und Abfanger: `apps/api/src/tenant/tenant.guard.spec.ts` und `apps/api/src/module-registry/module.guard.spec.ts` — Hilfsfunktion `makeContext(request)`, direkte Konstruktion ohne Nest-Testmodul, Testnamen auf Deutsch. **Es gibt kein supertest und kein `Test.createTestingModule` in apps/api** — nicht damit anfangen.
|
||||
- `apps/web/src/messages/umlaut-guard.spec.ts` — prueft de.json/en.json auf neu eingefuehrte Ersatzschreibungen (`ae`/`oe`/`ue`/`ss`). Neue deutsche Texte brauchen echte Umlaute. Die in Aufgabe 2 vorgegebenen Zeichenketten sind daraufhin geprueft und unbedenklich.
|
||||
- `apps/web/src/messages/de.json`, Bereich `dkvFleet` — vorhandene Unterbereiche `col`, `form`, `errors`, `status`, `tabs`. Vorhandene Schluessel u. a. `deleteVehicle` = "Fahrzeug löschen", `editVehicle` = "Fahrzeug bearbeiten", `form.cancel` = "Bearbeitung abbrechen", `form.cancelDialog` = "Abbrechen", `form.save` = "Einstellungen speichern". Bestandstexte dieses Bereichs duzen teilweise; **sie werden nicht angefasst** (D-05, kein repo-weites Umschreiben). Die neuen Texte sind unpersoenlich formuliert und erfuellen D-04 damit ohne Anrede-Konflikt.
|
||||
- Gemessener Ausgangsstand: `pnpm lint` → `Tasks: 5 successful, 5 total`, `@tessera/api: Found 357 warnings`, `@tessera/web: Found 107 warnings`, Summe **464**. Datenbank: admin, nutzer1, nutzer2 alle `mustChangePassword = f`. `/health` antwortet ohne Anmeldung mit 200.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Erzwungenen Passwortwechsel an der API wirklich durchsetzen (Befund 1, D-01/D-02/D-03)</name>
|
||||
<precondition>Der lokale Stapel laeuft (`docker compose ps` zeigt db, api, web als running) und alle drei Benutzer stehen auf `mustChangePassword = f`. Dieser Stand muss am Ende der Aufgabe wiederhergestellt sein.</precondition>
|
||||
<reversibility rating="reversible">Zwei kleine Aenderungen in zwei Dateien; ein Ruecknehmen ist ein Revert ohne Datenwanderung und ohne Vertragsaenderung nach aussen.</reversibility>
|
||||
<files>apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/strategies/jwt.strategy.spec.ts, apps/api/src/auth/interceptors/force-password-change.interceptor.ts, apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts</files>
|
||||
<behavior>
|
||||
Nahttest (scheitert gegen den heutigen Quelltext — genau das ist sein Zweck, D-03): das Ergebnis von `JwtStrategy.validate` mit gesetzter Kennzeichnung wird als `request.user` in den Abfanger gegeben; `GET /users` muss `ForbiddenException` werfen. Heute wirft er nicht, weil das Feld unterwegs verloren geht.
|
||||
Erlaubnisliste, je ein Fall: `GET /auth/me`, `POST /auth/logout`, `POST /auth/change-password` laufen durch (D-02).
|
||||
Falsche Methode auf erlaubtem Pfad: `GET /auth/logout` wird blockiert.
|
||||
Teilstring-Falle: ein Pfad, der einen erlaubten Pfad lediglich als Teilzeichenkette enthaelt (`/modules/auth/me`), wird blockiert. Dieser Fall ist heute nur im Test beweisbar, weil es keine kollidierende echte Route gibt — er ist die Absicherung gegen die Route von morgen.
|
||||
Kennzeichnung nicht gesetzt: `GET /users` laeuft durch (keine Verschaerfung fuer normale Sitzungen, D-02).
|
||||
Oeffentliche Route (Reflektor liefert true): laeuft durch, auch bei gesetzter Kennzeichnung.
|
||||
Kein `request.user`: laeuft durch.
|
||||
Strategie: `validate` liefert die Kennzeichnung durch, liefert bei fehlendem Anspruch im Token `false` (Alt-Sitzung laeuft weiter statt hart auszusperren), und liefert `id`, `username`, `role`, `tenantId` unveraendert wie bisher.
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die beiden Testdateien schreiben, dann die Quelldateien anpassen, bis sie gruen sind.
|
||||
|
||||
**`jwt.strategy.spec.ts` (neu).** Muster von `apps/api/src/tenant/tenant.guard.spec.ts` uebernehmen: direkte Konstruktion, Testnamen auf Deutsch, kein Nest-Testmodul. `new JwtStrategy({ get: () => 'test-secret' } as any)`. Drei Faelle aus dem Verhaltensblock.
|
||||
|
||||
**`force-password-change.interceptor.spec.ts` (neu).** Hilfsfunktion `makeContext(request)` wie im Vorbild, zusaetzlich `getHandler()` und `getClass()` als leere Funktionen, weil der Abfanger den Reflektor darauf anwendet. Reflektor als `{ getAllAndOverride: () => false } as any`, fuer den Fall der oeffentlichen Route `() => true`. Aufrufkette als `{ handle: () => of('ok') } as any` mit `of` aus `rxjs`. Der Nahttest baut sein `request.user` **nicht** von Hand, sondern aus `await new JwtStrategy(...).validate({ sub: 'u1', username: 'u', role: 'USER', tenantId: 't1', mustChangePassword: true })` — nur so pinnt er die Naht und scheitert gegen den heutigen Quelltext.
|
||||
|
||||
**`jwt.strategy.ts`.** In `validate` das Rueckgabeobjekt um `mustChangePassword` ergaenzen, normalisiert auf einen echten Wahrheitswert per strengem Vergleich des Anspruchs mit `true` (D-01). Begruendung als kurzer Kommentar: ein vor dieser Aenderung ausgestelltes Token traegt den Anspruch nicht und ergibt damit `false` — laufende Sitzungen verhalten sich exakt wie bisher, es gibt keine Aussperrwelle.
|
||||
|
||||
**`force-password-change.interceptor.ts`.** Die bisherige Teilstring-Pruefung auf dem Pfad ersetzen (D-01, zweite Schwaeche):
|
||||
- Auf Modulebene eine unveraenderliche Liste von Objekten mit den Feldern `method` und `path` anlegen, genau drei Eintraege: `POST` + `/auth/change-password`, `POST` + `/auth/logout`, `GET` + `/auth/me`. Kein Eintrag mehr und kein Eintrag weniger — das ist genau der Satz, den der Anmeldeablauf im Browser braucht (D-02, im Kontextblock nachgemessen).
|
||||
- Pfad normalisieren: bevorzugt `request.route?.path`, ersatzweise `request.url`; Abfrageteil ab dem Fragezeichen abschneiden, abschliessende Schraegstriche entfernen, leeres Ergebnis auf `/` zuruecksetzen.
|
||||
- Methode ueber `request.method` in Grossbuchstaben.
|
||||
- Durchgelassen wird nur, wenn ein Listeneintrag in **beiden** Feldern exakt gleich ist. Bewusste Entscheidung: `HEAD` wird nicht zusaetzlich zugelassen, weil kein Aufrufer im Bestand `HEAD` benutzt und die kleinere Flaeche die sichere Richtung ist.
|
||||
- Die Pruefung auf die gesetzte Kennzeichnung auf einen strengen Vergleich mit `true` ziehen.
|
||||
- `@Public`-Abkuerzung, Abkuerzung bei fehlendem `request.user` und der geworfene `ForbiddenException` samt Antwortkoerper bleiben Zeichen fuer Zeichen unveraendert.
|
||||
- Den Kopfkommentar der Datei auf den wahren Stand bringen: die Behauptung zu T-02-14 stimmt erst ab jetzt, und sie stimmt nur, weil `JwtStrategy.validate` das Feld durchreicht — diesen Zusammenhang benennen, damit niemand das Feld dort wieder herauskuerzt. Die alte Pruefmethode darf im Kommentar **nicht** als Code-Ausdruck zitiert werden (ein Abnahmetor zaehlt sie auf null).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/auth/strategies/jwt.strategy.spec.ts src/auth/interceptors/force-password-change.interceptor.spec.ts</automated>
|
||||
<automated>test "$(grep -c mustChangePassword apps/api/src/auth/strategies/jwt.strategy.ts)" -ge 1 && echo "OK: Anspruch wird durchgereicht" || { echo "FAIL: Feld fehlt in validate"; false; }</automated>
|
||||
<automated>I=apps/api/src/auth/interceptors/force-password-change.interceptor.ts; test "$(grep -vE '^[[:space:]]*(\*|//)' "$I" | grep -cF 'path.includes')" = 0 && echo "OK: keine Teilstring-Pruefung mehr" || { echo "FAIL: alte Pruefform noch vorhanden"; false; } # heute gemessen: 1</automated>
|
||||
<automated>docker compose up -d --build api >/dev/null 2>&1; curl -s --retry 40 --retry-delay 2 --retry-connrefused -o /dev/null http://localhost:3001/health; docker compose exec -T db psql -U tessera -d tessera -q -c "update \"User\" set \"mustChangePassword\"=true where username='admin';"; C=$(mktemp); curl -s -o /dev/null -c "$C" -X POST http://localhost:3001/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}'; echo "GET /users -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" http://localhost:3001/users)"; echo "GET /modules/active -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" http://localhost:3001/modules/active)"; echo "GET /auth/me -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" http://localhost:3001/auth/me)"; echo "POST /auth/change-password -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" -X POST http://localhost:3001/auth/change-password -H 'Content-Type: application/json' -d '{"currentPassword":"falsch","newPassword":"egal12345"}')"; echo "POST /auth/logout -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" -X POST http://localhost:3001/auth/logout)"; docker compose exec -T db psql -U tessera -d tessera -q -c "update \"User\" set \"mustChangePassword\"=false where username='admin';" # erwartet: 403, 403, 200, 401, 200 — und danach steht admin wieder auf false</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Die beiden neuen Testdateien sind gruen und der Nahttest scheitert nachweislich gegen den alten Quelltext (vor dem Anpassen von `jwt.strategy.ts` einmal laufen lassen und den roten Lauf im Bericht festhalten, D-03).
|
||||
Am laufenden System liefert eine Sitzung mit gesetzter Kennzeichnung: `GET /users` 403, `GET /modules/active` 403, `GET /auth/me` 200, `POST /auth/change-password` 401 (Fachfehler des Handlers, also durchgelassen — ein 403 waere ein Fehlschlag, D-02), `POST /auth/logout` 200.
|
||||
Die Kennzeichnung von admin steht danach wieder auf false.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Fahrzeugtabelle — Doppelausloesung des Loeschknopfs sperren, alle Texte ueber next-intl (Befund 2, D-04)</name>
|
||||
<files>apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<behavior>
|
||||
Zwei schnelle Klicks auf die Bestaetigungsschaltflaeche des Loeschdialogs fuehren zu genau einem Aufruf von `deleteVehicle`. Der Testfall haelt die Loeschzusage offen (nicht aufgeloeste Zusage), klickt zweimal und prueft `mockDeleteVehicle` auf Aufrufzahl 1.
|
||||
Waehrend die Loeschanfrage laeuft, sind Bestaetigen und Abbrechen im Dialog gesperrt (`toBeDisabled`).
|
||||
Die Aufschrift der Bestaetigungsschaltflaeche stammt aus next-intl: der Nachbau von next-intl im Test gibt unbekannte Schluessel unveraendert zurueck, der neue Schluessel wird **bewusst nicht** in die Abbildung des Nachbaus aufgenommen, und der Test sucht die Schaltflaeche ueber den Schluesselnamen. Waere die Aufschrift wieder fest verdrahtet, hiesse sie anders und der Test scheitert.
|
||||
Dieselbe Bauart fuer die vier Fehlermeldungen: schlaegt `fetchVehicles` fehl, steht der Schluesselname der Ladefehlermeldung in der Ausgabe.
|
||||
Die fuenf bestehenden Testfaelle bleiben unveraendert gruen.
|
||||
</behavior>
|
||||
<action>
|
||||
**Sprachdateien zuerst.** In `apps/web/src/messages/de.json` und `en.json` im Bereich `dkvFleet` sieben Schluessel ergaenzen, in beiden Sprachen mit identischem Schluesselsatz (D-04):
|
||||
- `form.saveRow` — de "Änderungen speichern", en "Save changes"
|
||||
- `form.deleteConfirm` — de "Löschen", en "Delete"
|
||||
- `errors.licensePlateRequired` — de "Kennzeichen ist erforderlich.", en "License plate is required."
|
||||
- `errors.loadVehiclesFailed` — de "Fahrzeuge konnten nicht geladen werden.", en "Vehicles could not be loaded."
|
||||
- `errors.saveVehicleFailed` — de "Speichern fehlgeschlagen.", en "Saving failed."
|
||||
- `errors.createVehicleFailed` — de "Fahrzeug konnte nicht hinzugefügt werden.", en "Vehicle could not be added."
|
||||
- `errors.deleteVehicleFailed` — de "Fahrzeug konnte nicht gelöscht werden.", en "Vehicle could not be deleted."
|
||||
Echte Umlaute schreiben, keine Ersatzschreibungen — die Waechter-Testdatei unter `src/messages/` prueft das. Alle sieben Texte sind unpersoenlich formuliert, damit erfuellen sie die Sie-Form aus D-04 ohne Anrede. Bestandstexte des Bereichs **nicht** anfassen (D-05).
|
||||
|
||||
**`VehicleTable.tsx` — Doppelausloesung.** Das Beschaeftigt-Kennzeichen wieder lesbar machen: die Zerlegung des Zustands so schreiben, dass auch der Lesewert gebunden ist. Dann:
|
||||
- `confirmDelete` am Anfang zusaetzlich abbrechen, wenn bereits geloescht wird (Wiedereintritts-Sperre im Zustandsobjekt selbst, nicht nur in der Darstellung).
|
||||
- `DeleteDialog` bekommt eine zusaetzliche Eigenschaft fuer den Beschaeftigt-Zustand; Bestaetigen und Abbrechen werden damit gesperrt, und die Bestaetigungsschaltflaeche erhaelt dieselben Sperr-Klassen, die die Bauteile dieses Moduls schon benutzen (`disabled:opacity-50 disabled:cursor-not-allowed`, Vorbild im Werkzeugbalken derselben Datei).
|
||||
- Der Aufrufer reicht den Zustand hinein.
|
||||
|
||||
**`VehicleTable.tsx` — Texte.** Alle fest verdrahteten Zeichenketten durch Aufrufe des Uebersetzers ersetzen: die Aufschrift der Bestaetigungsschaltflaeche im Dialog (`form.deleteConfirm`), die Meldung im Fehlerzweig von `load` (`errors.loadVehiclesFailed`), die Pflichtfeldmeldung an beiden Stellen (`errors.licensePlateRequired`), den Fehlerzweig beim Bearbeiten-Speichern (`errors.saveVehicleFailed`), beim Anlegen (`errors.createVehicleFailed`) und beim Loeschen (`errors.deleteVehicleFailed`). Ebenso **alle sechs Fundstellen der Vorlesehilfen** in den Zeilenschaltflaechen, die heute als Zeichenkettenliteral am Attribut haengen (vier verschiedene Beschriftungen; Speichern und Abbrechen kommen je zweimal vor, einmal in der Bearbeitungszeile und einmal in der neuen Zeile) — sie sind fuer Bildschirmleser sichtbarer Text und gehoeren damit unter D-04: Bearbeiten auf `editVehicle`, Loeschen auf `deleteVehicle`, Abbrechen auf `form.cancel` (alle drei existieren bereits und liefern woertlich dieselben Texte wie heute) und Speichern auf den neuen `form.saveRow`. Das Attribut darf danach in der Datei kein Zeichenkettenliteral mehr tragen, nur noch geschweifte Klammern — ein Abnahmetor zaehlt die Literalform auf null (heute gemessen: 6). Der Platzhalter des Kennzeichenfelds (`M-AB 123`) bleibt: er ist ein Formatbeispiel, kein Satz, und in beiden Sprachen gleich. Keine der entfernten Zeichenketten darf als Kommentar in der Datei stehen bleiben.
|
||||
|
||||
**`VehicleTable.test.tsx`.** Die Abbildung im next-intl-Nachbau um `'form.saveRow': 'Änderungen speichern'` ergaenzen, damit die zwei bestehenden Testfaelle, die ueber diesen Namen suchen, unveraendert gruen bleiben. `form.deleteConfirm` und die fuenf Fehlerschluessel **nicht** in die Abbildung aufnehmen — der Nachbau gibt unbekannte Schluessel unveraendert zurueck, und genau das macht die Textherkunft pruefbar. Den bestehenden Testfall zum Loeschdialog auf den Schluesselnamen umstellen. Drei Testfaelle neu ergaenzen, wie im Verhaltensblock beschrieben: Doppelklick fuehrt zu genau einem DELETE, Dialogschaltflaechen sind waehrend des Laufs gesperrt, Ladefehler zeigt den Schluessel der Ladefehlermeldung. Fuer den Doppelklick eine Zusage benutzen, deren Aufloesung der Test selbst in der Hand hat, damit der Zwischenzustand ueberhaupt beobachtbar ist.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run "src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx"</automated>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run src/messages/umlaut-guard.spec.ts src/messages/tenderRadar-parity.spec.ts</automated>
|
||||
<automated>F="apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx"; test "$(grep -cE "(setTableError|setEditError|setNewRowError)\('" "$F")" = 0 && echo "OK: kein Fehlertext mehr als Literal" || { echo "FAIL"; false; } # heute gemessen: 6; die Form mit null als Argument loest bewusst nicht aus (heute 3, geprueft)</automated>
|
||||
<automated>F="apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx"; test "$(grep -cF 'aria-label="' "$F")" = 0 && echo "OK: alle Vorlesehilfen aus next-intl" || { echo "FAIL"; false; } # heute gemessen: 6</automated>
|
||||
<automated>node -e "const f=(o,p='')=>o&&typeof o==='object'?Object.entries(o).flatMap(([k,v])=>f(v,p?p+'.'+k:k)):[p];const de=f(require('./apps/web/src/messages/de.json').dkvFleet).sort(),en=f(require('./apps/web/src/messages/en.json').dkvFleet).sort();const miss=[...de.filter(k=>!en.includes(k)).map(k=>'nur de: '+k),...en.filter(k=>!de.includes(k)).map(k=>'nur en: '+k)];console.log(miss.length?miss.join('\n'):'dkvFleet-Schluessel deckungsgleich: '+de.length);process.exit(miss.length?1:0)"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Die Testdatei laeuft mit mindestens acht Testfaellen gruen (fuenf alte, drei neue); der Doppelklick-Fall zaehlt genau einen Aufruf von `deleteVehicle`.
|
||||
Waechter- und Gleichheitstest der Sprachdateien sind gruen, beide Sprachen tragen im Bereich dkvFleet denselben Schluesselsatz (D-04).
|
||||
Die beiden Strukturtore zaehlen auf null: kein Fehlertext mehr als Literal an einem Fehlersetzer, kein Vorlesehilfen-Attribut mehr mit Zeichenkettenliteral.
|
||||
`SplitTab.tsx` ist unveraendert (Befund 3, bewusste Entscheidung aus dem Objective).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Gesamtabnahme ueber alle fuenf Arbeitsbereiche und Wiederherstellung des Ausgangsstands (D-05/D-06)</name>
|
||||
<files>keine Quelldatei — reine Abnahme; misst apps/api, apps/web, apps/desktop, packages/shared und die Wurzel und haelt die Zahlen im Abschlussbericht fest</files>
|
||||
<action>
|
||||
Die gruenen Balken gegen den gemessenen Ausgangsstand pruefen und jede Abweichung benennen statt wegzuerklaeren (D-06).
|
||||
|
||||
Reihenfolge: `pnpm type-check` (erwartet 4 von 4), `pnpm test` (erwartet apps/api 69 Dateien mit mindestens 1124 Tests, apps/web 66 Dateien mit mindestens 459 Tests — die Zahlen duerfen durch die neuen Testfaelle steigen, die Dateizahl von apps/api steigt um die zwei neuen Dateien auf 71), `pnpm lint` (erwartet `5 successful, 5 total` und damit null Befunde der Stufe Fehler).
|
||||
|
||||
Die Warnungssumme aus dem Lint-Lauf ziehen und gegen die Obergrenze halten: Ausgangsstand 464 (api 357, web 107), erlaubt sind hoechstens 466 (D-06). Steigt sie staerker, die neuen Zeilen nacharbeiten statt die Grenze anzuheben.
|
||||
|
||||
Anschliessend den Ausgangsstand der Datenbank pruefen und, falls noetig, wiederherstellen: admin, nutzer1 und nutzer2 alle mit `mustChangePassword = f`. Der Testlauf aus Aufgabe 1 setzt die Kennzeichnung zwischendurch; ein Abbruch mittendrin kann sie stehen lassen.
|
||||
|
||||
Im Abschlussbericht festhalten: die fuenf Zahlen vorher/nachher, das Ergebnis der HTTP-Messung aus Aufgabe 1 als Tabelle, und die Begruendung zu Befund 3 in einem Satz — damit der naechste Durchgang die Datei nicht erneut als offenen Punkt aufgreift.
|
||||
|
||||
Kein repo-weites Formatieren, keine Versionsspruenge, keine neuen Abhaengigkeiten (D-05).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm type-check 2>&1 | tail -3</automated>
|
||||
<automated>pnpm test 2>&1 | grep -E "Test Files|Tests |Tasks:" | tail -8</automated>
|
||||
<automated>pnpm lint 2>&1 | grep -E "Found [0-9]+ warnings|Tasks:" ; pnpm lint 2>&1 | grep -oE 'Found [0-9]+ warnings' | grep -oE '[0-9]+' | paste -sd+ | bc # erwartet: <= 466</automated>
|
||||
<automated>docker compose exec -T db psql -U tessera -d tessera -tAc "select count(*) from \"User\" where \"mustChangePassword\"=true;" # erwartet: 0</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Fehlerstufe, Warnungssumme hoechstens 466.
|
||||
`pnpm test` gruen in beiden Arbeitsbereichen, keine vorher gruene Datei jetzt rot.
|
||||
Kein Benutzer traegt mehr die Zwangswechsel-Kennzeichnung — Datenbank wie vorgefunden.
|
||||
Der Abschlussbericht nennt die Zahlen, die HTTP-Tabelle und die Begruendung zu Befund 3.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|--------|--------------|
|
||||
| Browser -> Next.js-Zwischenschicht | Liest das Sitzungs-Token selbst und leitet bei gesetzter Kennzeichnung auf `/change-password` um. Wirkt **nur** fuer Seitenaufrufe im Browser. |
|
||||
| Beliebiger Klient -> NestJS-API (Port 3001) | Die eigentliche Grenze. Sitzungs-Cookie mit HS256-Token. Tauri-Desktop-Klient, Skripte und `curl` kreuzen sie **ohne** die Zwischenschicht. |
|
||||
| Browser -> Fahrzeugtabelle -> DELETE /dkv/vehicles/:id | Bedienoberflaeche als einzige Sperre gegen mehrfaches Absenden derselben Absicht. |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| Kennung | Kategorie | Bauteil | Schwere | Umgang | Massnahme |
|
||||
|---------|-----------|---------|---------|--------|-----------|
|
||||
| T-FI3-01 | Elevation of Privilege / fehlende Zugriffskontrolle | `JwtStrategy.validate` + `ForcePasswordChangeInterceptor` | high | mitigate | `validate` reicht `mustChangePassword` an `request.user` durch, der Abfanger vergleicht streng auf `true` und sperrt alles ausserhalb der Erlaubnisliste. Nachweis am HTTP: 403 auf `/users` und `/modules/active`. Gepinnt durch den Nahttest, der gegen den alten Quelltext scheitert (Aufgabe 1, D-03). |
|
||||
| T-FI3-02 | Spoofing / Umgehung der Erlaubnisliste | `ForcePasswordChangeInterceptor` | medium | mitigate | Teilstring-Vergleich ohne Methodenpruefung wird durch exakten Vergleich von Methode **und** normalisiertem Pfad ersetzt. Heute nicht ausnutzbar (keine kollidierende Route, nachgeprueft), deshalb im Test statt am HTTP nachgewiesen. |
|
||||
| T-FI3-03 | Information Disclosure / veralteter Anspruch | Sitzungs-Token (30 Tage Laufzeit) | low | accept | Die Kennzeichnung ist eine Momentaufnahme der Anmeldung. Setzt ein Admin sie einem **bereits angemeldeten** Nutzer, greift sie erst bei dessen naechster Anmeldung. Das galt fuer die Zwischenschicht genauso und aendert sich durch diese Aufgabe nicht. Bewusst angenommen: der Fall entsteht praktisch nur zusammen mit `adminResetPassword`, und dabei wird ohnehin das Kennwort gewechselt, was die alte Sitzung fuer den Nutzer wertlos macht. Eine Gegenmassnahme waere ein Abgleich gegen die Datenbank bei jeder Anfrage — eine Datenbankabfrage pro Aufruf, hier nicht verhaeltnismaessig. |
|
||||
| T-FI3-04 | Tampering / doppelte Schreibabsicht | `VehicleTable.confirmDelete` | low | mitigate | Wiedereintritts-Sperre im Zustand plus gesperrte Dialogschaltflaechen; im Komponententest auf genau einen Aufruf gepinnt. Serverseitig war der zweite Aufruf schon bisher harmlos (der Datensatz ist dann fort), der Schaden lag in der Bedienung. |
|
||||
|
||||
Keine Paketinstallation in dieser Aufgabe (D-05), damit entfaellt das Paketechtheits-Tor.
|
||||
|
||||
## Was war offen, und wie weit reichte es
|
||||
|
||||
Solange der Abfanger tot war, galt: wer ein Kennwort gesetzt bekommt, das ein anderer kennt — genau das tut `adminResetPassword`, Vorgabe `mustChangePassword = true` —, konnte mit diesem Kennwort die **vollstaendige API** benutzen, ohne je ein eigenes Kennwort zu setzen. Reichweite: die eigene Rolle und der eigene Mandant des betroffenen Nutzers. **Keine Rechteausweitung** — Rollen- und Mandantenwaechter waren und sind unberuehrt, ein Nutzer wurde dadurch nicht zum Admin. Der Schaden ist ein anderer: ein Zugang, den zwei Personen kennen, blieb unbefristet voll nutzbar. Im Browser fiel das nicht auf, weil die Zwischenschicht umleitete; ueber den Desktop-Klienten, ein Skript oder `curl` fiel die Sperre ersatzlos aus.
|
||||
|
||||
## Was die gehaertete Erlaubnisliste weiterhin nicht abdeckt
|
||||
|
||||
- **Oeffentliche Routen bleiben aussen vor.** `@Public` wird vor der Kennzeichnung geprueft: `/auth/login`, `/auth/request-reset`, `/auth/reset-password`, `/health`, `/health/version` und die drei Desktop-Aktualisierungswege bleiben auch im Zwangswechsel erreichbar. Fuer die Selbstbedienungs-Ruecksetzung ist das gewollt — sie ist ein zweiter, gleichwertiger Weg aus dem Zwangswechsel heraus.
|
||||
- **Laufende Sitzungen**, siehe T-FI3-03.
|
||||
- **Nur HTTP.** Der Abfanger greift ueber `switchToHttp()`. Kaeme spaeter ein anderer Transport dazu, waere er dort ohne Wirkung — dann muss diese Datei erneut angefasst werden.
|
||||
- **Keine Ratenbegrenzung.** Der Abfanger verhindert die Nutzung, nicht das wiederholte Anklopfen.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `pnpm --filter @tessera/api exec vitest run src/auth/strategies/jwt.strategy.spec.ts src/auth/interceptors/force-password-change.interceptor.spec.ts` — gruen, und der Nahttest war vor der Quelltextaenderung nachweislich rot (D-03).
|
||||
2. Die HTTP-Messung aus Aufgabe 1 ergibt 403 / 403 / 200 / 401 / 200 in dieser Reihenfolge.
|
||||
3. `pnpm --filter @tessera/web exec vitest run "src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx"` — gruen, Doppelklick zaehlt genau einen Aufruf.
|
||||
4. Gleichheit der Schluesselsaetze im Bereich dkvFleet zwischen de.json und en.json (D-04).
|
||||
5. `pnpm type-check` 4/4, `pnpm test` gruen, `pnpm lint` 5/5 ohne Fehlerstufe, Warnungssumme hoechstens 466 (D-06).
|
||||
6. `select count(*) from "User" where "mustChangePassword"=true;` ergibt 0.
|
||||
7. `git diff --stat` zeigt ausschliesslich die acht in `files_modified` genannten Dateien — insbesondere **nicht** `SplitTab.tsx` und keine Sperrdatei (D-05).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Der erzwungene Passwortwechsel wird an der API durchgesetzt, nicht nur im Browser; die Ursache — das unterwegs verlorene Feld — ist behoben, nicht das Symptom (D-01).
|
||||
- Kein bestehender Zugriff wurde eingeschraenkt: eine normale Sitzung verhaelt sich unveraendert, und ein Nutzer im Zwangswechsel kann den Wechsel im Browser vollstaendig durchfuehren (D-02).
|
||||
- Es gibt Tests, die gegen den alten Quelltext scheitern, und einen, der die Teilstring-Falle schliesst (D-03).
|
||||
- Der Loeschknopf der Fahrzeugtabelle laesst sich nicht mehr doppelt ausloesen, bewiesen im Komponententest.
|
||||
- Alle sichtbaren Texte und Vorlesehilfen der Fahrzeugtabelle stammen aus beiden Sprachdateien, deutsche Texte unpersoenlich und mit echten Umlauten (D-04).
|
||||
- `SplitTab.tsx` ist unangetastet; die Begruendung steht im Abschlussbericht (Befund 3).
|
||||
- Keine neuen Abhaengigkeiten, keine Versionsspruenge, kein repo-weites Formatieren (D-05); alle gruenen Balken stehen, Warnungssumme nicht materiell gewachsen (D-06).
|
||||
- Die Datenbank steht am Ende exakt so da wie vorgefunden.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Abschlussbericht nach `.planning/quick/260921-fi3-erzwungener-passwortwechsel-wird-von-der/260921-fi3-SUMMARY.md`
|
||||
</output>
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
---
|
||||
phase: quick-260921-fi3
|
||||
plan: 01
|
||||
subsystem: auth
|
||||
tags: [nestjs, jwt, passport, interceptor, next-intl, react, vitest]
|
||||
|
||||
requires:
|
||||
- phase: quick-260921-bi2
|
||||
provides: Lint-Rueckstand abgebaut, Befunde 1-3 aus dieser Aufgabe entdeckt
|
||||
provides:
|
||||
- Erzwungener Passwortwechsel wird jetzt an der API selbst durchgesetzt (nicht nur im Browser)
|
||||
- Erlaubnisliste des Abfangers vergleicht Methode UND Pfad exakt statt Teilstring
|
||||
- VehicleTable: Loeschknopf gegen Doppelausloesung gesperrt
|
||||
- VehicleTable: alle sichtbaren Texte und Vorlesehilfen ueber next-intl
|
||||
affects: [auth, dkv-fleet]
|
||||
|
||||
actuals:
|
||||
tokens: 6512
|
||||
tasks: 3
|
||||
commits: 2
|
||||
plan_head_before: 116041b7fd455ffac6e43d36d5a08aa156a90690
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Global-Interceptor-Erlaubnisliste als eingefrorenes Array von {method, path}-Objekten mit exaktem Abgleich statt Teilstring-Vergleich"
|
||||
- "Reentrancy-Sperre fuer asynchrone Lösch-/Speicheraktionen im Zustand selbst (isDeleting), nicht nur ueber das disabled-Attribut in der Darstellung"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/auth/strategies/jwt.strategy.spec.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts
|
||||
modified:
|
||||
- apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
|
||||
key-decisions:
|
||||
- "JwtStrategy.validate liefert mustChangePassword jetzt durch (strenger Vergleich mit true); fehlender Anspruch (Alt-Sitzung) ergibt false, keine Aussperrwelle"
|
||||
- "Erlaubnisliste des Abfangers auf exakten Abgleich von Methode UND normalisiertem Pfad umgestellt statt Teilstring-Vergleich (heute nicht ausnutzbar, Absicherung gegen kuenftige Routen)"
|
||||
- "load() in VehicleTable behaelt bewusst leeres Abhaengigkeitsfeld ([]) statt [t] — ein instabiler Uebersetzer-Mock haette sonst einen Abruf-bei-jedem-Render-Zyklus ausgeloest"
|
||||
- "vi.restoreAllMocks() im Testabbau von VehicleTable.test.tsx durch vi.clearAllMocks() ersetzt — restoreAllMocks leerte die Aufrufzaehlung reiner vi.fn()-Mocks nicht"
|
||||
|
||||
patterns-established:
|
||||
- "Nahttests, die JwtStrategy.validate() und einen nachgelagerten Guard/Interceptor zusammenfuehren, um Naht-Regressionen wie das stille Verlieren eines Claims zu pinnen"
|
||||
|
||||
requirements-completed: [SEC-FORCE-PW, UI-DKV-DELETE, I18N-DKV]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Sitzung mit mustChangePassword=true erhaelt an der API auf GET /users und GET /modules/active 403 (statt vorher 200)"
|
||||
requirement: SEC-FORCE-PW
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts — Nahttest + Erlaubnisliste + Teilstring-Falle"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "curl gegen localhost:3001 nach docker compose up -d --build api: GET /users -> 403, GET /modules/active -> 403"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Dieselbe Sitzung erreicht GET /auth/me (200), POST /auth/logout (200) und POST /auth/change-password (401, Fachfehler statt 403) weiterhin"
|
||||
requirement: SEC-FORCE-PW
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "curl gegen localhost:3001: GET /auth/me -> 200, POST /auth/change-password -> 401, POST /auth/logout -> 200"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Nahttest scheitert nachweislich gegen den alten Quelltext (RED), gruen nach der Aenderung (GREEN)"
|
||||
requirement: SEC-FORCE-PW
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "vitest run vor der Aenderung: 6 von 12 Faellen rot (Nahttest, Falsche-Methode-Fall, Teilstring-Falle in force-password-change.interceptor.spec.ts + alle 3 in jwt.strategy.spec.ts); danach 12/12 gruen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Zweiter Klick auf die Bestaetigungsschaltflaeche des Loeschdialogs loest kein zweites DELETE aus"
|
||||
requirement: UI-DKV-DELETE
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "VehicleTable.test.tsx#double-clicking the delete confirm button triggers exactly one deleteVehicle call"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Alle sichtbaren Texte und Vorlesehilfen der Fahrzeugtabelle stammen aus de.json/en.json, gleicher Schluesselsatz in beiden Sprachen"
|
||||
requirement: I18N-DKV
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "src/messages/umlaut-guard.spec.ts, src/messages/tenderRadar-parity.spec.ts (Muster fuer Schluesselgleichheit) + node-Einzeiler fuer dkvFleet-Schluesselgleichheit (87 Schluessel deckungsgleich)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "grep-Strukturtore: 0 Fehlertext-Literale an Setzern, 0 aria-label-Literale in VehicleTable.tsx"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~55min
|
||||
completed: 2026-09-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick 260921-fi3: Erzwungener Passwortwechsel wirklich durchgesetzt Summary
|
||||
|
||||
**JwtStrategy liess mustChangePassword auf dem Weg zum Interceptor fallen — der global registrierte ForcePasswordChangeInterceptor hat seither nie etwas blockiert; jetzt durchgesetzt und mit einem Nahttest gepinnt, der nachweislich gegen den alten Quelltext scheitert.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Dauer:** ca. 55 Minuten
|
||||
- **Abgeschlossen:** 2026-09-21
|
||||
- **Aufgaben:** 3/3
|
||||
- **Geänderte Dateien:** 8
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Die eigentliche Ursache behoben: `JwtStrategy.validate` reicht `mustChangePassword` jetzt durch (strenger Vergleich mit `true`), statt dass der Interceptor auf ein Feld prüft, das nie ankam.
|
||||
- Die Erlaubnisliste des Interceptors von Teilstring-Vergleich auf exakten Abgleich von Methode **und** Pfad umgestellt — schließt die Teilstring-Falle, ohne dass es heute eine ausnutzbare Route dafür gäbe.
|
||||
- Am laufenden System nachgewiesen: eine Sitzung mit gesetzter Kennzeichnung bekommt jetzt `403` auf `/users` und `/modules/active` (vorher `200`), während `/auth/me`, `/auth/change-password` und `/auth/logout` — die drei Wege, die der Zwangswechsel im Browser tatsächlich braucht — unverändert erreichbar bleiben.
|
||||
- `VehicleTable`: das bisher ungelesene Beschäftigt-Kennzeichen (`const [, setIsDeleting]`) wieder lesbar gemacht, Dialogschaltflächen währenddessen gesperrt, `confirmDelete` bricht bei bereits laufender Löschung selbst ab — ein Doppelklick löst nachweislich nur ein `DELETE` aus.
|
||||
- Alle sieben fest verdrahteten Texte und sechs Vorlesehilfen der Fahrzeugtabelle jetzt über next-intl, sieben neue Schlüssel im Bereich `dkvFleet`, deckungsgleich in `de.json` und `en.json`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Jede Aufgabe wurde atomar committet:
|
||||
|
||||
1. **Aufgabe 1: Erzwungenen Passwortwechsel an der API wirklich durchsetzen** - `f7c02b7` (fix)
|
||||
2. **Aufgabe 2: Fahrzeugtabelle — Doppelauslösung sperren, next-intl** - `e56cce4` (fix)
|
||||
3. **Aufgabe 3: Gesamtabnahme** - keine eigene Quelldatei-Änderung (reine Abnahme), Ergebnisse unten dokumentiert; fließt in den Abschluss-Commit dieses Berichts.
|
||||
|
||||
_Beide Aufgaben mit Code-Änderung liefen TDD (`tdd="true"`): Testdateien zuerst, RED-Lauf gegen den alten Quelltext protokolliert, dann Quelltext angepasst bis GREEN._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/src/auth/strategies/jwt.strategy.ts` - `validate` reicht `mustChangePassword` jetzt durch (strenger Vergleich mit `true`)
|
||||
- `apps/api/src/auth/strategies/jwt.strategy.spec.ts` - neu; pinnt Durchreichung, Alt-Sitzungs-Fallback, unveränderte Feldweitergabe
|
||||
- `apps/api/src/auth/interceptors/force-password-change.interceptor.ts` - Erlaubnisliste auf exakten Methode+Pfad-Abgleich umgestellt, Kopfkommentar korrigiert
|
||||
- `apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts` - neu; Nahttest, Erlaubnisliste, Teilstring-Falle, Kennzeichnung nicht gesetzt, öffentliche Route, kein `request.user`
|
||||
- `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx` - Reentrancy-Sperre gegen Doppelklick, alle Texte/Vorlesehilfen über next-intl
|
||||
- `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx` - drei neue Testfälle, Testabbau-Fehler behoben (`vi.clearAllMocks()`)
|
||||
- `apps/web/src/messages/de.json` - sieben neue Schlüssel im Bereich `dkvFleet` (`form.saveRow`, `form.deleteConfirm`, fünf `errors.*`)
|
||||
- `apps/web/src/messages/en.json` - dieselben sieben Schlüssel, englische Übersetzung
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `load()` in `VehicleTable` behält bewusst `[]` als Abhängigkeitsfeld statt `[t]`: der next-intl-Mock im Test erzeugt bei jedem Aufruf von `useTranslations` eine neue Funktionsidentität; hätte `load` von `t` abgehangen, wäre `load` bei jedem Render neu erzeugt worden und der `useEffect`, der `load` beim Mount aufruft, hätte bei jedem Render erneut ausgelöst — ein Abruf-Loop, der in der Praxis (echtes next-intl memoisiert `t`) nicht auftritt, aber im Test sofort sichtbar wurde, weil er den Mock-Warteschlangenzustand für spätere Testfälle leerte.
|
||||
- `vi.restoreAllMocks()` im Testabbau von `VehicleTable.test.tsx` durch `vi.clearAllMocks()` ersetzt: `restoreAllMocks` leert die Aufrufzählung reiner `vi.fn()`-Mocks (ohne echtes Original) nicht zuverlässig, wodurch der neue Doppelklick-Test (`toHaveBeenCalledTimes(1)`) eine aus dem vorigen Testfall übertragene Zählung sah. Dieser Fund war vorher unsichtbar, weil bisher kein Test die Aufrufzahl prüfte, nur `toHaveBeenCalledWith(...)`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] `load()` mit `t` als Abhängigkeit hätte einen Abruf-bei-jedem-Render-Zyklus ausgelöst**
|
||||
- **Gefunden während:** Aufgabe 2, beim ersten vollen Testlauf der Datei (nicht isoliert)
|
||||
- **Problem:** Beim Verdrahten der Ladefehlermeldung über `t('errors.loadVehiclesFailed')` wurde `t` naheliegend in `load`s Abhängigkeitsfeld aufgenommen (`[t]`). Da der next-intl-Mock im Test bei jedem Aufruf eine neue Funktion zurückgibt, wurde `load` bei jedem Render neu erzeugt, und der `useEffect`, der `load` beim Mount aufruft, löste dadurch bei jedem Render erneut aus — ein bestehender, vorher grüner Testfall ("clicking pencil icon...") schlug dadurch fehl, weil die Mock-Warteschlange eines späteren Tests durch die vielen zusätzlichen Aufrufe verzerrt wurde.
|
||||
- **Fix:** Abhängigkeitsfeld auf `[]` zurückgesetzt (wie im Ausgangszustand), mit Kommentar, der begründet, warum `t` hier bewusst fehlt.
|
||||
- **Dateien geändert:** `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx`
|
||||
- **Verifikation:** Vollständiger Testlauf der Datei wieder grün (8/8)
|
||||
- **Committed in:** `e56cce4` (Teil des Aufgabe-2-Commits)
|
||||
|
||||
**2. [Rule 1 - Bug] `vi.restoreAllMocks()` leerte die Aufrufzählung von `mockDeleteVehicle` nicht zwischen Testfällen**
|
||||
- **Gefunden während:** Aufgabe 2, beim Verifizieren des neuen Doppelklick-Testfalls
|
||||
- **Problem:** Der neue Testfall erwartete `mockDeleteVehicle` genau einmal aufgerufen; tatsächlich zeigte er zwei Aufrufe. Debugging (temporäres `console.log` in `confirmDelete`, per Backup-Datei wieder entfernt) zeigte: `confirmDelete` wurde in diesem Testfall nur einmal ausgeführt — der zweite gezählte Aufruf war aus dem vorigen Testfall ("clicking trash icon...") übrig geblieben, weil `vi.restoreAllMocks()` im Testabbau die Aufrufliste reiner `vi.fn()`-Mocks nicht zurücksetzt.
|
||||
- **Fix:** `vi.restoreAllMocks()` durch `vi.clearAllMocks()` ersetzt, mit erklärendem Kommentar.
|
||||
- **Dateien geändert:** `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx`
|
||||
- **Verifikation:** Vollständiger Testlauf der Datei grün (8/8), Doppelklick-Test zählt jetzt korrekt genau einen Aufruf
|
||||
- **Committed in:** `e56cce4` (Teil des Aufgabe-2-Commits)
|
||||
|
||||
---
|
||||
|
||||
**Gesamtzahl Abweichungen:** 2 automatisch behoben (beide Regel 1 — Fehlerkorrektur, beide beim Testen der eigenen neuen Testfälle in Aufgabe 2 gefunden, nicht im Ausgangscode)
|
||||
**Auswirkung auf den Plan:** Beide Korrekturen waren nötig, damit die vom Plan geforderten neuen Testfälle tatsächlich das prüfen, was sie behaupten zu prüfen. Kein Umfang über den Plan hinaus.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Keine offenen Probleme. Die beiden oben dokumentierten Deviations sind bereits gelöst.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Keine — keine externe Dienstkonfiguration nötig.
|
||||
|
||||
## Gesamtabnahme (Aufgabe 3)
|
||||
|
||||
### Fünf Zahlen vorher/nachher
|
||||
|
||||
| Kennzahl | Ausgangsstand | Nachher | Erwartung erfüllt? |
|
||||
|---|---|---|---|
|
||||
| `pnpm type-check` | 4/4 | 4/4 | ja |
|
||||
| `pnpm test` apps/api | 69 Dateien / 1124 Tests | 71 Dateien / 1136 Tests | ja (+2 Dateien, +12 Tests aus Aufgabe 1) |
|
||||
| `pnpm test` apps/web | 66 Dateien / 459 Tests | 66 Dateien / 462 Tests | ja (+3 Tests aus Aufgabe 2, keine neue Datei) |
|
||||
| `pnpm lint` | 5/5, 0 Fehlerstufe | 5/5, 0 Fehlerstufe | ja |
|
||||
| Warnungssumme | 464 (api 357, web 107) | 466 (api 358, web 108) | ja — genau an der erlaubten Obergrenze von 466, nicht darüber |
|
||||
|
||||
Die zwei zusätzlichen Warnungen sind identifiziert und bewusst belassen (keine bestehende Unterdrückungs-Konvention im Projekt — es gibt an keiner Stelle in apps/api oder apps/web einen `biome-ignore`-Kommentar, das Projekt führt Lint-Warnungen stattdessen als gezählten Rückstand):
|
||||
- `apps/api/src/auth/interceptors/force-password-change.interceptor.ts:33` — `lint/suspicious/noExplicitAny` an der neuen `normalizePath(request: any)`-Hilfsfunktion. Das bestehende Muster derselben Datei (`intercept(...): Observable<any>`, unverändert) verwendet ebenfalls `any` für den Request/Response-Rahmen von NestJS-Interceptoren.
|
||||
- `apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx:182` — `lint/correctness/useExhaustiveDependencies` an `load`, weil `t` absichtlich aus dem Abhängigkeitsfeld ausgeschlossen ist (siehe Decisions Made oben — mit `t` in den Abhängigkeiten löst der next-intl-Mock im Test einen Abruf-Loop aus).
|
||||
|
||||
### HTTP-Messung aus Aufgabe 1
|
||||
|
||||
| Route | Methode | Ergebnis mit gesetzter Kennzeichnung | Bewertung |
|
||||
|---|---|---|---|
|
||||
| `/users` | GET | 403 | erwartet — vorher 200 |
|
||||
| `/modules/active` | GET | 403 | erwartet — vorher ungeprüft, jetzt durchgesetzt |
|
||||
| `/auth/me` | GET | 200 | erwartet — Erlaubnisliste greift |
|
||||
| `/auth/change-password` | POST | 401 | erwartet — Fachfehler des Handlers (falsches aktuelles Passwort), NICHT 403; beweist, dass die Erlaubnisliste durchlässt und der Handler selbst entscheidet |
|
||||
| `/auth/logout` | POST | 200 | erwartet — Erlaubnisliste greift |
|
||||
|
||||
Nach der Messung wurde `mustChangePassword` für `admin` in der Datenbank wieder auf `false` gesetzt.
|
||||
|
||||
### Befund 3 (SplitTab.tsx) — Begründung in einem Satz
|
||||
|
||||
`SplitTab.tsx` bleibt unangetastet: `downloadAllAsZip` liegt auf Modulebene und kann den next-intl-Hook nicht aufrufen, ein übersetzter Downloadname bringt echte Nachteile (Umlaute auf Windows-Freigaben, sprachabhängige Anhangsnamen) ohne Nutzen (ein Dateiname wird nie vorgelesen oder gesucht), und die Dateien im Archiv tragen ohnehin die vom Server gelieferten Zertifikatsnamen.
|
||||
|
||||
### Datenbank-Endstand
|
||||
|
||||
```
|
||||
admin|f
|
||||
nutzer2|f
|
||||
nutzer1|f
|
||||
```
|
||||
|
||||
Alle drei Nutzer stehen wieder auf dem Ausgangsstand — kein Nutzer trägt die Zwangswechsel-Kennzeichnung.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Kein Folge-Plan in dieser Kette. Die drei Befunde aus der Lint-Rückstandsaufgabe 260921-bi2 sind damit vollständig abgearbeitet: Befund 1 (Sicherheitslücke) behoben und mit einem RED→GREEN-Nahttest gepinnt, Befund 2 (Doppelauslösung/next-intl) behoben, Befund 3 (SplitTab.tsx-Dateiname) bewusst nicht angefasst, begründet oben und im Plan.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle acht geänderten/neu erstellten Quelldateien sowie diese Zusammenfassung wurden auf Existenz geprüft (`FOUND` für jede). Beide Task-Commits (`f7c02b7`, `e56cce4`) wurden in `git log --oneline --all` gefunden.
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
---
|
||||
phase: quick-260921-fi3
|
||||
verified: 2026-09-21T11:55:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
covered_files:
|
||||
- .planning/quick/260921-fi3-erzwungener-passwortwechsel-wird-von-der/260921-fi3-PLAN.md
|
||||
- .planning/quick/260921-fi3-erzwungener-passwortwechsel-wird-von-der/260921-fi3-SUMMARY.md
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
- apps/api/src/auth/strategies/jwt.strategy.spec.ts
|
||||
- apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx
|
||||
- apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
covered_digest: "v1:sha256:b03fb584b5a6b5e01852c205380c211dfec1e92e9fd446d2181ec7b729b56e11"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick 260921-fi3: Erzwungener Passwortwechsel wirklich durchgesetzt — Verification Report
|
||||
|
||||
**Ziel:** (1) Erzwungener Passwortwechsel an der API durchsetzen, nicht nur in der Web-Middleware. (2) Doppelausloesung des Loeschknopfs der Fahrzeugtabelle verhindern. (3) Fest verdrahtete Texte dort durch next-intl ersetzen.
|
||||
|
||||
**Verifiziert:** 2026-09-21
|
||||
**Status:** passed
|
||||
**Methode:** Eigene, von der SUMMARY unabhaengige Nachstellung jedes Befunds — Quelltext gelesen, Tests selbst ausgefuehrt, alte Vorfassung der zwei API-Dateien rekonstruiert und gegen die neuen Spezifikationen laufen lassen, Doppelklick-Schutz durch gezielte Entfernung einer der beiden Sperren isoliert getestet, HTTP-Messung selbst gegen den laufenden Stapel wiederholt.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Sitzung mit `mustChangePassword=true` erhaelt an der API auf `GET /users` und `GET /modules/active` 403 | ✓ VERIFIED | Selbst gemessen am laufenden Stapel (siehe HTTP-Tabelle unten): beide 403. Ebenso durch `force-password-change.interceptor.spec.ts` (9/9 gruen) gepinnt. |
|
||||
| 2 | Dieselbe Sitzung erreicht `GET /auth/me` (200), `POST /auth/logout` (200), `POST /auth/change-password` (401 bei falschem Passwort, kein 403) | ✓ VERIFIED | Selbst gemessen: 200 / 200 / 401 — identisch zur Behauptung der Zusammenfassung, unabhaengig reproduziert. |
|
||||
| 3 | `request.user.mustChangePassword` ist ein echter Wahrheitswert; fehlender Anspruch (Alt-Sitzung) ergibt `false`, keine Aussperrwelle | ✓ VERIFIED | Quelltext `jwt.strategy.ts:33`: `payload.mustChangePassword === true`. Testfaelle in `jwt.strategy.spec.ts` pinnen genau das (3/3 gruen). |
|
||||
| 4 | Erlaubnisliste vergleicht Methode UND Pfad exakt; Teilstring-Faelle werden blockiert | ✓ VERIFIED | Quelltext liest `ALLOWED_ROUTES` als `{method, path}`-Paare mit `===`-Vergleich nach `normalizePath()`. Eigener Bypass-Versuch (Grossschreibung, doppelte Slashes, Query-String, Trailing-Slash, `../`) fand **keine** Luecke — siehe Abschnitt "Bypass-Versuch" unten. Test `Teilstring-Falle: /modules/auth/me ... wird blockiert` gruen. |
|
||||
| 5 | Es gibt einen Nahttest, der gegen den heutigen (jetzt: alten) Quelltext scheitert | ✓ VERIFIED | Selbst nachgestellt (nicht nur der Behauptung der Zusammenfassung vertraut): alte `jwt.strategy.ts` und alte `force-password-change.interceptor.ts` aus Commit `116041b` rekonstruiert, in den Arbeitsbaum kopiert, beide neuen Spezifikationsdateien darauf laufen lassen → **6 von 12 Faellen rot**, exakt wie in der Zusammenfassung behauptet. Danach beide Dateien wiederhergestellt, erneut 12/12 gruen, `git diff --stat` wieder leer. |
|
||||
| 6 | Zweiter Klick auf die Bestaetigungsschaltflaeche loest kein zweites `DELETE` aus | ✓ VERIFIED | Testfall `double-clicking the delete confirm button triggers exactly one deleteVehicle call` gruen. Siehe Wuerdigung unten — die Absicherung ist real, aber nicht dort verankert, wo der Plan es unterstellt. |
|
||||
| 7 | Kein sichtbarer Text und keine Vorlesehilfe der Fahrzeugtabelle steht mehr fest verdrahtet im Bauteil; de.json/en.json tragen denselben Schluesselsatz | ✓ VERIFIED | `grep -c 'aria-label="'` und `grep -cE "(setTableError|setEditError|setNewRowError)\('"` beide 0. Eigener Node-Einzeiler bestaetigt Schluesselgleichheit im Bereich `dkvFleet` (87 Schluessel je Sprache, keine Abweichung). Echte Umlaute in allen sieben neuen Zeichenketten, unpersoenlich formuliert. |
|
||||
| 8 | Gruene Balken: `pnpm type-check` 4/4, `pnpm test` gruen mit Zahlen mindestens auf Ausgangsniveau, `pnpm lint` 5/5 ohne Fehlerstufe, Warnungssumme hoechstens 466 | ✓ VERIFIED | Selbst ausgefuehrt: type-check 4/4; Tests apps/api 71 Dateien/1136 Faelle, apps/web 66 Dateien/462 Faelle (beide oberhalb des Ausgangsstands); Lint 5/5, 0 Fehlerstufe, Summe **466** exakt an der erlaubten Obergrenze (357+1 api, 107+1 web) — die zwei neuen Warnungen wurden einzeln lokalisiert und stimmen mit der in der Zusammenfassung genannten Ursache ueberein. |
|
||||
| 9 | Datenbank steht am Ende wieder auf `mustChangePassword=false` fuer admin/nutzer1/nutzer2 | ✓ VERIFIED | Vor jeder eigenen Pruefung und danach per `psql` abgefragt: alle drei Nutzer `f`. Eigene HTTP-Messung setzte die Kennzeichnung fuer `admin` zwischenzeitlich auf `true` und hat sie danach selbst wieder zurueckgesetzt — Endstand identisch mit dem vom Auftrag vorgegebenen Zustand. |
|
||||
|
||||
**Score:** 9/9 truths verified, 0 present-behavior-unverified.
|
||||
|
||||
### Bypass-Versuch (eigenstaendig, gegen den gehaerteten Abfanger)
|
||||
|
||||
Gegen `normalizePath()` + exakten Methoden/Pfad-Vergleich wurden folgende Kandidaten durchgespielt: `/auth/me/` (Trailing-Slash — normalisiert korrekt zurueck auf `/auth/me`, kein zusaetzlicher Zugriff, da weiterhin derselbe erlaubte Pfad), `/auth/me/../users`, `/Auth/me` (Grossschreibung), `/auth/me?x=1` (Query), `//auth/me`, `/auth//me`, `/auth/me#x`, Methode klein geschrieben. Keiner davon oeffnet einen Pfad, der **nicht** ohnehin einer der drei erlaubten waere — die Haertung haelt.
|
||||
|
||||
### Wuerdigung: Doppelklick-Schutz (Pruefpunkt 4 aus dem Auftrag)
|
||||
|
||||
Der Quelltext hat zwei Sperren: den Zustandscheck `if (!deleteTarget || isDeleting) return;` am Anfang von `confirmDelete`, und das `disabled={isDeleting}`-Attribut an beiden Dialogschaltflaechen.
|
||||
|
||||
Eigener Versuch: den Zustandscheck in einer Arbeitskopie entfernt (`if (!deleteTarget) return;`), nur die `disabled`-Sperre gelassen, denselben Doppelklick-Testfall isoliert erneut laufen lassen — **er blieb gruen**. Grund, in `react-dom-client.development.js` nachgelesen: React selbst unterdrueckt `onClick` (und weitere Maus-Events) auf einem `disabled`-Button/-Input/-Select/-Textarea bereits auf Event-Plugin-Ebene, unabhaengig vom jsdom- oder Produktionsbetrieb. Da der erste Klick den Zustand synchron setzt und React bei einem discreten Event (Klick) synchron neu rendert, bevor der zweite Klick verarbeitet wird, greift diese Unterdrueckung schon vor dem zweiten `fireEvent.click` — der zweite Klick loest in diesem Ablauf `confirmDelete` gar nicht erst aus.
|
||||
|
||||
Folge: Der Testfall beweist zuverlaessig die im Auftrag geforderte Beobachtung ("zweiter Klick loest kein zweites DELETE aus"), aber er unterscheidet **nicht**, welche der beiden Sperren dafuer verantwortlich ist. Die im Plan und in der Zusammenfassung genannte Formulierung "Wiedereintritts-Sperre im Zustand selbst, nicht nur ueber das disabled-Attribut" ist im Ergebnis richtig (der Zustandscheck ist zusaetzlich vorhanden und schadet nicht), aber fuer den hier getesteten Klick-Pfad tatsaechlich redundant — nicht falsch, nur nicht die tragende Ursache. Ich werte das als Informationshinweis, nicht als Luecke: die verlangte Beobachtung haelt, mit doppelter Absicherung, von der eine in der Praxis nicht greifen muss, um zu greifen. (Nach dem Test wurde die Arbeitskopie vollstaendig auf den committeten Stand zurueckgesetzt; `git diff` leer, 8/8 Tests wieder gruen.)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/src/auth/strategies/jwt.strategy.ts` | `validate` liefert `mustChangePassword` | ✓ VERIFIED | Feld vorhanden, strenger Vergleich, Begruendungskommentar vorhanden |
|
||||
| `apps/api/src/auth/strategies/jwt.strategy.spec.ts` | neu, pinnt Durchreichung | ✓ VERIFIED | 3 Faelle, alle gegen alten Quelltext rot nachgewiesen |
|
||||
| `apps/api/src/auth/interceptors/force-password-change.interceptor.ts` | Erlaubnisliste Methode+Pfad exakt | ✓ VERIFIED | `ALLOWED_ROUTES` als eingefrorenes Array, `normalizePath()`, keine `path.includes` mehr |
|
||||
| `apps/api/src/auth/interceptors/force-password-change.interceptor.spec.ts` | neu, pinnt Sperre/Erlaubnisliste/Teilstring-Falle | ✓ VERIFIED | 9 Faelle, Nahttest + Teilstring-Falle unter den zuvor roten |
|
||||
| `apps/web/.../VehicleTable.tsx` | Sperre gegen Doppelausloesung, alle Texte via next-intl | ✓ VERIFIED | Siehe Wuerdigung oben; 0 Literal-aria-labels, 0 Literal-Fehlertexte |
|
||||
| `apps/web/.../VehicleTable.test.tsx` | neue Testfaelle Doppelklick/Textherkunft | ✓ VERIFIED | 8/8 gruen, Doppelklick- und Ladefehler-Faelle vorhanden |
|
||||
| `apps/web/src/messages/de.json` + `en.json` | 7 neue Schluessel, dkvFleet | ✓ VERIFIED | Schluesselsaetze deckungsgleich (87 je Sprache), echte Umlaute, unpersoenlich |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `JwtStrategy.validate()` | `request.user.mustChangePassword` → `ForcePasswordChangeInterceptor` | direkter Feldtransport im Passport-Ergebnis | ✓ WIRED | Durch RED→GREEN-Nahttest bewiesen (eigenstaendig reproduziert) |
|
||||
| `AuthService.changePassword()` | neues Cookie mit `mustChangePassword:false` → Browser → `redirect('/')` | Set-Cookie-Weiterleitung | ✓ WIRED (unveraendert) | Laut Plankontext bereits vor dieser Aufgabe durch `auth.service.spec.ts:610` gepinnt, in dieser Aufgabe nicht angefasst; Browser-Ablauf laut Auftrag bereits vom Orchestrator Ende-zu-Ende bestaetigt (siehe Human Verification unten) |
|
||||
| `DeleteDialog.onConfirm` | `confirmDelete` → `deleteVehicle` | React-Callback-Kette | ✓ WIRED | Ein Aufruf pro Klickserie, siehe Testfall und Wuerdigung |
|
||||
| `de.json` ↔ `en.json`, Bereich `dkvFleet` | — | Schluesselgleichheit | ✓ WIRED | Eigener Node-Einzeiler: keine Abweichung |
|
||||
|
||||
### Behavioral Spot-Checks / eigene Nachstellung
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| RED gegen alten Quelltext | alte `jwt.strategy.ts` + alte `force-password-change.interceptor.ts` aus `116041b` in Arbeitsbaum kopiert, neue Specs laufen lassen | 6/12 Faelle rot (identisch zur Zusammenfassung), danach restauriert, 12/12 gruen | ✓ PASS |
|
||||
| HTTP-Messung am laufenden Stapel | `admin` auf `mustChangePassword=true` gesetzt, eingeloggt, 5 Routen abgefragt, zurueckgesetzt | 403/403/200/401/200 | ✓ PASS |
|
||||
| Substring-/Normalisierungs-Bypass-Versuch | 8 Pfadvarianten gegen `normalizePath`+Exaktvergleich in Node nachgebaut | keine Luecke gefunden | ✓ PASS |
|
||||
| Doppelklick-Sperre isoliert | Zustandscheck in Arbeitskopie entfernt, nur `disabled` gelassen, Testfall erneut laufen lassen | Test bleibt gruen — `disabled` alleine reicht wegen Reacts eigener Klick-Unterdrueckung auf deaktivierten Elementen | ✓ PASS (mit Wuerdigung oben) |
|
||||
| `pnpm type-check` | `pnpm type-check` | 4/4 erfolgreich | ✓ PASS |
|
||||
| `pnpm test` | `pnpm test` | api 71/1136, web 66/462, beide gruen | ✓ PASS |
|
||||
| `pnpm lint` | `pnpm lint` | 5/5, 0 Fehlerstufe, Summe 466 | ✓ PASS |
|
||||
| Datenbankendstand | `select username, "mustChangePassword" from "User"` | admin/f, nutzer1/f, nutzer2/f | ✓ PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Beschreibung | Status | Evidenz |
|
||||
|---|---|---|---|---|
|
||||
| SEC-FORCE-PW | 260921-fi3-PLAN.md, Aufgabe 1 | Erzwungener Passwortwechsel an der API | ✓ SATISFIED | HTTP-Messung + Nahttest, beide eigenstaendig reproduziert |
|
||||
| UI-DKV-DELETE | 260921-fi3-PLAN.md, Aufgabe 2 | Doppelausloesung des Loeschknopfs verhindern | ✓ SATISFIED | Testfall gruen; Wuerdigung zur tragenden Ursache oben |
|
||||
| I18N-DKV | 260921-fi3-PLAN.md, Aufgabe 2 | Alle Texte via next-intl, Schluesselgleichheit | ✓ SATISFIED | Grep-Strukturtore auf 0, Schluesselvergleich deckungsgleich |
|
||||
|
||||
Diese drei IDs sind nicht in `.planning/REQUIREMENTS.md` (Milestone-Register) verzeichnet — erwartungsgemaess fuer einen Quick-Task ausserhalb der Milestone-Anforderungsliste, keine Luecke.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine. `grep` auf `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER` sowie auf Platzhaltertext in allen acht geaenderten/neuen Dateien ergab null Treffer.
|
||||
|
||||
### Umfangspruefung (D-05)
|
||||
|
||||
`git diff --stat 116041b..HEAD` zeigt ausschliesslich die acht in `files_modified` genannten Dateien, 365 Einfuegungen / 36 Loeschungen, kein `pnpm-lock.yaml`, kein `package.json`, kein `SplitTab.tsx`. Arbeitsbaum am Ende dieser Verifikation `git status --short` zeigt nur das erwartete unversionierte Planungsverzeichnis dieses Quick-Tasks — keine Restspuren meiner eigenen Testmanipulationen.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Keine offenen Punkte fuer den Menschen. Der Browser-Ablauf fuer einen Nutzer im Zwangswechsel (Login → `/change-password` → Formular bedienbar → Wechsel erfolgreich → `/` → Kennzeichnung in der Datenbank geloescht → Navigation danach frei) wurde laut Auftrag bereits vom Orchestrator Ende-zu-Ende am Browser bestaetigt und wird hier als erledigt gebucht, nicht erneut an den Menschen zurueckgegeben.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
Keine. Alle neun Wahrheiten aus der Vertragsliste des Plans sind eigenstaendig nachgewiesen, nicht nur der Zusammenfassung entnommen. Einzige Einschraenkung: der Doppelklick-Schutz ist real und getestet, aber die vom Plan behauptete Rollenverteilung zwischen Zustandscheck und `disabled`-Attribut haelt bei naeherer Pruefung nicht exakt — das aendert nichts am beobachtbaren Ergebnis (kein zweites `DELETE`), ist daher als Hinweis, nicht als Luecke gefuehrt.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-21T11:55:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in New Issue
Block a user