--- 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)_