5 Commits

Author SHA1 Message Date
schalli 6cc0d02e97 docs(quick-260921-iwr): 30 Stellen geprueft, kein echter Fehler darunter
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Failing after 11m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Has been skipped
Tessera CI/CD / Build & Publish Images (push) Has been skipped
Zusammenfassung und STATE.md zum Quick-Vorgang 260921-iwr. Damit ist
der fehlerverdaechtige Lint-Rueckstand vollstaendig geprueft.

Beide Verdachtsmomente widerlegt, durch Messung statt Argument: die
LDAP-Seite nutzt fuer die Liste, die wirklich waechst und schrumpft,
laengst eine stabile Kennung; und im Cert-Manager enden alle vier Pfade
mit einer 83-Byte-Schrottdatei bei 400, nie 500 - node-forge wirft,
statt still ein falsches Zertifikat zu bauen.

Ein Fund dreht die Richtung um: bei grants/page.tsx waere die
Korrektur schaedlich, dort ist die Positionsnummer fuer die
Eindeutigkeit noetig.

Geaendert: 5 Stellen. 25 bleiben bewusst stehen und bleiben in der
Zaehlung sichtbar, mit Begruendung je Stelle in der Akte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:10:51 +02:00
schalli de6986340d fix(quick-260921-iwr): mergeCerts-Waechter loest keinen zusaetzlichen Lint-Fund mehr aus
Die urspruengliche findIndex((c) => c === null)-Pruefung erzeugte einen
neuen lint/complexity/useIndexOf-Fund (info) und hob TOTAL dadurch auf 430
statt der erwarteten 429 an — die Endverifikation des Plans deckte das auf
(D-06: TOTAL darf nirgends anders steigen).

@types/node-forge deklariert Bag.cert als "Certificate | undefined", die
node-forge-Laufzeit setzt bei einem unlesbaren Bag aber "null" (nicht
undefined). Ein blosses indexOf(null) ist deshalb nicht typsicher; die
Pruefung testet jetzt ausdruecklich auf beide Werte, was fuer biome kein
Single-Value-Vergleich mehr ist und keinen useIndexOf-Vorschlag ausloest.

TOTAL steht jetzt bei 429 wie geplant, NONNULL unveraendert bei 6, tsc und
beide Testsuiten weiterhin gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:05:13 +02:00
schalli 27909e4502 refactor(quick-260921-iwr): zwei ueberfluessige Ausrufezeichen in imap.provider.ts entfernt
imapflow typisiert uid in FetchMessageObject als Pflichtfeld (lib/imap-flow.d.ts
Zeile 469, Kommentar "Always included in the response"). Die beiden
Zusicherungen msg.uid! sicherten also einen Wert ab, der ohnehin nicht fehlen
kann. Reine Lesbarkeitsaenderung ohne Verhaltensaenderung, tsc bleibt gruen.

Die drei verbliebenen Zusicherungen (tenders.controller.ts:244,
favorites-widget.tsx:149, sidebar.tsx:80) bleiben unveraendert — jede durch
eine konkrete vorgelagerte Zeile garantiert (Provider-Eintrag, gemeinsame
Herleitung aus favorites, Anlegen des Map-Eintrags direkt davor).

NONNULL 8 -> 6, keines davon mehr in imap.provider.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:03:01 +02:00
schalli b4aaed4b7c fix(quick-260921-iwr): drei bag.cert-Zusicherungen in cert-manager.service.ts zu echten Waechtern gemacht
node-forge setzt bag.cert bei einem wohlgeformten, aber nicht als X.509
lesbaren Zertifikats-Bag auf null (lib/pkcs12.js Zeile 703-709). Die drei
Zusicherungen in parseCert, mergeCerts und convertCert behaupteten also
etwas Falsches, obwohl die Folge bereits behandelt war: alle vier Pfade
antworteten schon vorher mit 400, nie mit 500 (mit einer selbst gebauten
83-Byte-PFX nachgemessen).

Ersetzt die drei Zusicherungen durch ausdrueckliche Pruefungen mit
praeziser BadRequestException. In mergeCerts wird kein Zertifikat mehr
verschluckt: alle Bags werden auf Vollstaendigkeit geprueft, bevor die
Liste ueber ein Typpraedikat zurueckgegeben wird.

RED-Tests zuerst geschrieben und mit den heutigen Meldungen ("Failed to
extract certificate details" / "Failed to create merged certificate
output" / "Failed to convert certificate to pem: serialization error")
rot bestaetigt, dann die Waechter ergaenzt: GREEN.

Verhaltensaenderung ausdruecklich beabsichtigt (D-02): nur der Text der
Fehlermeldung fuer diese eine Eingabeklasse aendert sich, der Statuscode
bleibt in allen vier Pfaden 400.

NONNULL 11 -> 8, davon 3 in cert-manager.service.ts (Zeilen 133/516/661
bleiben unveraendert — durch Hash-Laenge bzw. vorgelagerte Passwort-
Pruefung garantiert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:01:36 +02:00
schalli 8716fa5234 feat(quick-260921-iwr): Rundenliste der Stoppuhr per Test belegt, MergeTab-Entfernen abgesichert
- Wirkungslose eslint-disable-Zeile in stopwatch-widget.tsx ersetzt durch
  Sachhinweis: Zeilen halten keinen Zustand, Rundennummer wird aus Laenge
  und Position berechnet. Keine neue Unterdrueckung, ARRAYKEY bleibt bei 19.
- Neuer Testfall in stopwatch-widget.test.tsx: zwei Runden nacheinander,
  neuere Runde steht oben, Rundennummern 2/1 stimmen zu ihrer eigenen Zeit.
- Neue MergeTab.test.tsx: Entfernen der mittleren Datei laesst genau erste
  und dritte Datei mit eigenem Namen und eigenem Entfernen-Knopf uebrig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 13:55:42 +02:00
9 changed files with 1087 additions and 10 deletions
+5 -4
View File
@@ -4,8 +4,8 @@ milestone: v1.2
current_phase: 18 current_phase: 18
current_phase_name: desktop-client-fertigstellen current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "Sechs Quick-Vorgaenge am 2026-09-21 abgeschlossen (9ie, a1d, bi2, fi3, gof, i8x). Offen als naechste Fehlerklasse: Listenschluessel per Positionsnummer (19 im Quellcode) und Ausrufezeichen-Zusicherungen (11 im Quellcode). Danach kommen zwei neue Widgets. i8x ist noch nicht gepusht." stopped_at: "Sieben Quick-Vorgaenge am 2026-09-21 abgeschlossen und verifiziert (9ie, a1d, bi2, fi3, gof, i8x, iwr). Der fehlerverdaechtige Lint-Rueckstand ist damit vollstaendig geprueft: zwei echte Fehler gefunden und behoben (tote Passwortwechsel-Sperre an der API, stille 403-Antworten in der Benutzerverwaltung), der Rest war harmlos oder Absicht, je Stelle in den Akten begruendet. Offen und NICHT fehlerverdaechtig: 288 any im Quellcode, 30 a11y-Befunde mit Gestaltungsbedarf. Naechster Auftrag des Nutzers: zwei neue Dashboard-Widgets."
last_updated: "2026-09-21T12:00:00.000Z" last_updated: "2026-09-21T12:45:00.000Z"
last_activity: 2026-09-21 last_activity: 2026-09-21
last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-21 - Quick 260921-i8x: fuenf fehlerverdaechtige Lint-Klassen geprueft, kein echter Fehler darunter; Weiterleitungsschutz safe-next.ts von rohen Steuerbytes auf Escapes gehaertet, Gleichwertigkeit ueber alle 65536 Codepunkte nachgerechnet (54 abgelehnte Zeichen, null Abweichung) Last activity: 2026-09-21 - Quick 260921-iwr: 30 Listenschluessel- und Zusicherungs-Stellen geprueft, kein echter Fehler; beide Verdachtsmomente (LDAP-Seite, Cert-Manager) durch Messung widerlegt. Der fehlerverdaechtige Rueckstand ist damit abgearbeitet
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -450,6 +450,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260921-fi3 | **Erzwungener Passwortwechsel wurde an der API nie durchgesetzt — Sicherheitsfix.** Aus dem Lint-Durchlauf 260921-bi2 kamen fuenf gemeldete "Symptome". Alle am laufenden System nachgestellt: eines widerlegt (Passwortwechsel-Seite leitet sehr wohl weiter, siehe bi2-VERIFICATION), drei bestaetigt, eines (ZIP-Dateiname) bewusst nicht angefasst. **Der schwere Befund:** `auth.service.ts:176` legt `mustChangePassword` in den JWT, `jwt.strategy.ts` liess das Feld beim Auspacken fallen, also war `request.user.mustChangePassword` immer `undefined` und der global registrierte `ForcePasswordChangeInterceptor` eine Attrappe — er hat seit seiner Einfuehrung nie etwas blockiert. Durchgesetzt wurde der Zwangswechsel allein von der Web-Middleware; jeder Weg daran vorbei (Desktop-App, Skript, curl) umging ihn. Der Kommentar des Interceptors behauptete woertlich "T-02-14: Prevents bypass via direct API access" — das war falsch. Kein Rechteausbau: die eigene Rolle bleibt, aber der Zwang entfaellt. **Gemessen vorher:** Sitzung mit `mustChangePassword=true` bekam auf `GET /users` **200 samt vollstaendiger Benutzerliste**. **Behoben:** Strategie reicht das Feld durch (strikt `=== true`, fehlender Anspruch in alten Sitzungen wird `false`), Erlaubnisliste von Teilzeichenketten-Vergleich auf exaktes Verfahren+Pfad umgestellt. **Sauberer Nachweis** (gleicher Nutzer, gleiche Rolle, gleiche Route, nur die Kennzeichnung unterscheidet sich — auf `/users` haette der Rollen-Riegel das Ergebnis verdeckt): `GET /modules/active` → 403 `{"message":"FORCE_PASSWORD_CHANGE"}` mit Zwang, 200 ohne. `/auth/me`, `/auth/change-password` und `/auth/logout` kommen weiterhin durch. **Rot-dann-Gruen belegt:** neue Spezifikationen gegen den alten Stand 6 von 12 rot, danach 12/12 gruen — vom Verifier unabhaengig nachgestellt (alte Dateien aus `116041b` rekonstruiert). Gezielt nach Schlupfloechern gesucht (Schraegstrich am Ende, Abfragezeichen, Gross/Klein, `../`): keins. **Kein Aussperren:** kompletter Browser-Ablauf durchgespielt — Anmeldung leitet auf `/change-password`, Seite bedienbar, Wechsel gelingt, landet auf `/`, Kennzeichnung geloescht, freie Navigation. Die Seitenleiste zeigt waehrenddessen "Keine Module" (neuer 403 auf `/modules/active`, wortlos geschluckt) — sachlich richtig. **Dazu Fahrzeugtabelle (dkv-fleet):** Loeschknopf war doppelt ausloesbar (`isDeleting` wurde geschrieben, nie gelesen; Dialog blieb waehrend der Anfrage offen) — beide Dialogknoepfe jetzt gesperrt. Nebenbefund des Verifiers: die Wirkung kommt vom `disabled`-Attribut, React unterdrueckt Klicks darauf selbst; der Zustandscheck ist redundant, nicht falsch. Ausserdem sieben fest verdrahtete deutsche Texte und sechs Vorlese-Beschriftungen auf next-intl umgestellt (de und en, 87 Schluessel deckungsgleich). **Nicht angefasst, begruendet:** `SplitTab.tsx` `'certificates.zip'` — ein Downloadname ist ein Dateisystem-Artefakt, kein Bedienelement; uebersetzt braechte er Umlaute in Windows-Dateifreigaben. **Balken:** api 71 Dateien/1136 Tests, web 66/462, type-check 4/4, `pnpm lint` 5/5 ohne Fehlerstufe, Warnungen 466. Verifikation passed (9/9). | 2026-09-21 | f7c02b7,e56cce4 | [260921-fi3-erzwungener-passwortwechsel-wird-von-der](./quick/260921-fi3-erzwungener-passwortwechsel-wird-von-der/) | | 260921-fi3 | **Erzwungener Passwortwechsel wurde an der API nie durchgesetzt — Sicherheitsfix.** Aus dem Lint-Durchlauf 260921-bi2 kamen fuenf gemeldete "Symptome". Alle am laufenden System nachgestellt: eines widerlegt (Passwortwechsel-Seite leitet sehr wohl weiter, siehe bi2-VERIFICATION), drei bestaetigt, eines (ZIP-Dateiname) bewusst nicht angefasst. **Der schwere Befund:** `auth.service.ts:176` legt `mustChangePassword` in den JWT, `jwt.strategy.ts` liess das Feld beim Auspacken fallen, also war `request.user.mustChangePassword` immer `undefined` und der global registrierte `ForcePasswordChangeInterceptor` eine Attrappe — er hat seit seiner Einfuehrung nie etwas blockiert. Durchgesetzt wurde der Zwangswechsel allein von der Web-Middleware; jeder Weg daran vorbei (Desktop-App, Skript, curl) umging ihn. Der Kommentar des Interceptors behauptete woertlich "T-02-14: Prevents bypass via direct API access" — das war falsch. Kein Rechteausbau: die eigene Rolle bleibt, aber der Zwang entfaellt. **Gemessen vorher:** Sitzung mit `mustChangePassword=true` bekam auf `GET /users` **200 samt vollstaendiger Benutzerliste**. **Behoben:** Strategie reicht das Feld durch (strikt `=== true`, fehlender Anspruch in alten Sitzungen wird `false`), Erlaubnisliste von Teilzeichenketten-Vergleich auf exaktes Verfahren+Pfad umgestellt. **Sauberer Nachweis** (gleicher Nutzer, gleiche Rolle, gleiche Route, nur die Kennzeichnung unterscheidet sich — auf `/users` haette der Rollen-Riegel das Ergebnis verdeckt): `GET /modules/active` → 403 `{"message":"FORCE_PASSWORD_CHANGE"}` mit Zwang, 200 ohne. `/auth/me`, `/auth/change-password` und `/auth/logout` kommen weiterhin durch. **Rot-dann-Gruen belegt:** neue Spezifikationen gegen den alten Stand 6 von 12 rot, danach 12/12 gruen — vom Verifier unabhaengig nachgestellt (alte Dateien aus `116041b` rekonstruiert). Gezielt nach Schlupfloechern gesucht (Schraegstrich am Ende, Abfragezeichen, Gross/Klein, `../`): keins. **Kein Aussperren:** kompletter Browser-Ablauf durchgespielt — Anmeldung leitet auf `/change-password`, Seite bedienbar, Wechsel gelingt, landet auf `/`, Kennzeichnung geloescht, freie Navigation. Die Seitenleiste zeigt waehrenddessen "Keine Module" (neuer 403 auf `/modules/active`, wortlos geschluckt) — sachlich richtig. **Dazu Fahrzeugtabelle (dkv-fleet):** Loeschknopf war doppelt ausloesbar (`isDeleting` wurde geschrieben, nie gelesen; Dialog blieb waehrend der Anfrage offen) — beide Dialogknoepfe jetzt gesperrt. Nebenbefund des Verifiers: die Wirkung kommt vom `disabled`-Attribut, React unterdrueckt Klicks darauf selbst; der Zustandscheck ist redundant, nicht falsch. Ausserdem sieben fest verdrahtete deutsche Texte und sechs Vorlese-Beschriftungen auf next-intl umgestellt (de und en, 87 Schluessel deckungsgleich). **Nicht angefasst, begruendet:** `SplitTab.tsx` `'certificates.zip'` — ein Downloadname ist ein Dateisystem-Artefakt, kein Bedienelement; uebersetzt braechte er Umlaute in Windows-Dateifreigaben. **Balken:** api 71 Dateien/1136 Tests, web 66/462, type-check 4/4, `pnpm lint` 5/5 ohne Fehlerstufe, Warnungen 466. Verifikation passed (9/9). | 2026-09-21 | f7c02b7,e56cce4 | [260921-fi3-erzwungener-passwortwechsel-wird-von-der](./quick/260921-fi3-erzwungener-passwortwechsel-wird-von-der/) |
| 260921-gof | **21 React-Effekt-Abhaengigkeiten einzeln beurteilt — 15 davon waren Fallen, nicht Fehler.** Die Klasse war aus 260921-bi2 zurueckgestellt worden, weil jeder Befund einzeln zu beurteilen ist. Ergebnis: nur **2 echte Defekte** (A), **15 Fallen** (B, das naive Eintragen haette eine Abruf-Schleife erzeugt), **3 Absicht** (C, mit begruendetem `biome-ignore` — erste Verwendung im Projekt), **1 Ballast** (D). **Die gefaehrlichste Stelle:** `calendar-widget.tsx:82` — `showToday` setzt bei jedem Klick ein frisches `Date`; `monthDate` naiv in die Liste einzutragen haette **jeden** Druck auf den Monatstitel einen Termin-Abruf ausloesen lassen, ueber die API bis zum Exchange-Server. Reihenfolge war Pflicht: erst Identitaet stabilisieren, dann die Liste umstellen. **Die haeufigste Falle:** `t` aus `useTranslations` ist in diesem Projekt bei jedem Durchlauf eine frische Funktion (die Test-Attrappen sind nachweislich so gebaut) — 8 Befunde. Griff ohne Ausnahme-Kommentar: den uebersetzten Text vor dem Hook in eine Konstante ziehen und diese eintragen; React vergleicht Zeichenketten per Wert. **Nebenbefund:** 11 `eslint-disable`-Zeilen fuer genau diese Regel waren wirkungslos, seit Biome ESLint abgeloest hat — alle entfernt. **Laufzeitnachweis vom Orchestrator im Browser** (Netzwerkprotokoll, nie `fetch` aus der Seite; gegen neu gebaute Abbilder): Dashboard 62 s Ruhe → Protokoll byte-identisch, genau 1 `calendar/events`; Monatstitel 3x gedrueckt → nur der erste Druck (Bereich aendert sich wirklich) loest einen Abruf aus, Druck 2 und 3 **null**; "Weiter" 3x → 3 Abrufe, korrekt; Stoppuhr 6 s real → Anzeige 00:06, 4 Runden ueber 4,8 s → 16/17/19/20 monoton, kein Ruecksprung. Dazu acht weitere Ansichten je 20-25 s ruhen gelassen (Marktplatz, Modulverwaltung, Gruppenverwaltung, DKV dreimal, Ausschreibungsradar zweimal) — jeder Endpunkt genau einmal. `InvoiceHistoryTable` hatte als einzige Datei keinen Test und ist damit gemessen statt nur gelesen; `ResultsList` ist die Stelle, an der die `t`-Falle in bi2 tatsaechlich zuschnappte. **Zahlen:** Warnungen 467 → 446 (exakt 21, nichts anderswo gewachsen), `useExhaustiveDependencies` 0, web-Tests 66/462 → 67/477, api 71/1136 unveraendert, type-check 4/4, `pnpm lint` 5/5 ohne Fehlerrang. Verifikation passed. **Benannt, nicht behoben:** zwei Verschwendungen im Kalender-Abruffenster (gleicher Zeitbereich zweimal geholt; `calendar/sources` bei jedem Monatswechsel) — vorbestehend; und `t` in vier vorbestehenden Abhaengigkeitslisten ausserhalb des Auftrags, die Biome nie gemeldet hat. | 2026-09-21 | b3f0e3c,e2c508c,e780b2c | [260921-gof-effekt-abhaengigkeiten-in-react-21-befun](./quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/) | | 260921-gof | **21 React-Effekt-Abhaengigkeiten einzeln beurteilt — 15 davon waren Fallen, nicht Fehler.** Die Klasse war aus 260921-bi2 zurueckgestellt worden, weil jeder Befund einzeln zu beurteilen ist. Ergebnis: nur **2 echte Defekte** (A), **15 Fallen** (B, das naive Eintragen haette eine Abruf-Schleife erzeugt), **3 Absicht** (C, mit begruendetem `biome-ignore` — erste Verwendung im Projekt), **1 Ballast** (D). **Die gefaehrlichste Stelle:** `calendar-widget.tsx:82` — `showToday` setzt bei jedem Klick ein frisches `Date`; `monthDate` naiv in die Liste einzutragen haette **jeden** Druck auf den Monatstitel einen Termin-Abruf ausloesen lassen, ueber die API bis zum Exchange-Server. Reihenfolge war Pflicht: erst Identitaet stabilisieren, dann die Liste umstellen. **Die haeufigste Falle:** `t` aus `useTranslations` ist in diesem Projekt bei jedem Durchlauf eine frische Funktion (die Test-Attrappen sind nachweislich so gebaut) — 8 Befunde. Griff ohne Ausnahme-Kommentar: den uebersetzten Text vor dem Hook in eine Konstante ziehen und diese eintragen; React vergleicht Zeichenketten per Wert. **Nebenbefund:** 11 `eslint-disable`-Zeilen fuer genau diese Regel waren wirkungslos, seit Biome ESLint abgeloest hat — alle entfernt. **Laufzeitnachweis vom Orchestrator im Browser** (Netzwerkprotokoll, nie `fetch` aus der Seite; gegen neu gebaute Abbilder): Dashboard 62 s Ruhe → Protokoll byte-identisch, genau 1 `calendar/events`; Monatstitel 3x gedrueckt → nur der erste Druck (Bereich aendert sich wirklich) loest einen Abruf aus, Druck 2 und 3 **null**; "Weiter" 3x → 3 Abrufe, korrekt; Stoppuhr 6 s real → Anzeige 00:06, 4 Runden ueber 4,8 s → 16/17/19/20 monoton, kein Ruecksprung. Dazu acht weitere Ansichten je 20-25 s ruhen gelassen (Marktplatz, Modulverwaltung, Gruppenverwaltung, DKV dreimal, Ausschreibungsradar zweimal) — jeder Endpunkt genau einmal. `InvoiceHistoryTable` hatte als einzige Datei keinen Test und ist damit gemessen statt nur gelesen; `ResultsList` ist die Stelle, an der die `t`-Falle in bi2 tatsaechlich zuschnappte. **Zahlen:** Warnungen 467 → 446 (exakt 21, nichts anderswo gewachsen), `useExhaustiveDependencies` 0, web-Tests 66/462 → 67/477, api 71/1136 unveraendert, type-check 4/4, `pnpm lint` 5/5 ohne Fehlerrang. Verifikation passed. **Benannt, nicht behoben:** zwei Verschwendungen im Kalender-Abruffenster (gleicher Zeitbereich zweimal geholt; `calendar/sources` bei jedem Monatswechsel) — vorbestehend; und `t` in vier vorbestehenden Abhaengigkeitslisten ausserhalb des Auftrags, die Biome nie gemeldet hat. | 2026-09-21 | b3f0e3c,e2c508c,e780b2c | [260921-gof-effekt-abhaengigkeiten-in-react-21-befun](./quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/) |
| 260921-i8x | **Fuenf fehlerverdaechtige Lint-Klassen geprueft — kein einziger echter Fehler darunter.** Zwoelf Stellen einzeln beurteilt, Ergebnis: 7x gleichwertig oder Absicht, 2x Haertung, 3x idiomatisch korrekt. Das ist das Ergebnis, keine Ausrede — die Klassen klangen gefaehrlicher als sie waren. **Die eine Stelle mit echtem Wert:** `apps/web/src/lib/safe-next.ts`, der Schutz gegen Weiterleitung auf fremde Seiten nach der Anmeldung. Der Kommentar dort behauptete, die Steuerzeichen stuenden als Unicode-Escapes im Muster; die rohen Bytes zeigten das Gegenteil (NUL, 0x1F, 0x7F direkt eingebettet). Funktionierte, war aber zerbrechlich: verschluckt ein Werkzeug das NUL-Byte, wird aus dem Bereich stillschweigend ein anderer und der Schutz loechrig — der Rueckgabewert landet in `login/page.tsx` direkt in `window.location.href`. Jetzt echte Escapes, Datei ohne ein einziges Steuerbyte. **Beweis der Gleichwertigkeit, nicht Behauptung:** ueber alle 65536 Codepunkte dieselbe Menge abgelehnter Zeichen — 54 Stueck (32 Steuerzeichen 0x00-0x1F, dazu 0x7F, Backslash und die 20 Leerraum-Zeichen von `\s`), null Abweichung; vom Orchestrator unabhaengig gegen ein selbst gebautes Referenzmuster nachgerechnet. **Bewusst nicht angefasst:** die NUL-Maskierung in `ldap.service.ts` (RFC 4515) — genau dieses Zeichen zu treffen ist ihr Zweck, wer sie "repariert", oeffnet LDAP-Filter-Injection. Ebenso die drei `while ((m = re.exec(s)))`-Schleifen (idiomatisch, kein verrutschtes Gleichheitszeichen) und `noUselessSwitchCase` aus bi2. **Zwei Korrekturen an frueheren Annahmen:** `sanitizeNextPath` laeuft NICHT in der Edge-Middleware (die importiert nur `buildNextParam`), und das blosse Umschreiben auf Escapes senkt die Warnzahl nicht — Biome beanstandet die Escape-Schreibweise genauso, es braucht zusaetzlich einen einzeiligen Unterdrueckungskommentar. **Werkzeugfalle, dreimal zugeschnappt:** das Schreibwerkzeug wandelt `\uXXXX` still in das echte Zeichen um — der Planer erzeugte so zehn rohe Steuerbytes in seiner ersten Planfassung, der Executor zweimal in Commit-Text und Akte (git verweigerte den Commit wegen eines NUL-Bytes), und der Orchestrator beim Nachrechnen. Umgehung ueber `python3`/`chr(92)` ist im Plan hinterlegt. **Zahlen:** 446 → 434, `noControlCharactersInRegex`/`useIterableCallbackReturn`/`noGlobalIsNan` je 0, `suppressions/unused` 0, web-Tests 67/477 → 68/481, api 71/1136 → 72/1137, type-check 4/4, lint 5/5. | 2026-09-21 | f85c91b,076ca4b,b92dd5d | [260921-i8x-fehlerverdaechtige-lint-klassen-steuerze](./quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/) | | 260921-i8x | **Fuenf fehlerverdaechtige Lint-Klassen geprueft — kein einziger echter Fehler darunter.** Zwoelf Stellen einzeln beurteilt, Ergebnis: 7x gleichwertig oder Absicht, 2x Haertung, 3x idiomatisch korrekt. Das ist das Ergebnis, keine Ausrede — die Klassen klangen gefaehrlicher als sie waren. **Die eine Stelle mit echtem Wert:** `apps/web/src/lib/safe-next.ts`, der Schutz gegen Weiterleitung auf fremde Seiten nach der Anmeldung. Der Kommentar dort behauptete, die Steuerzeichen stuenden als Unicode-Escapes im Muster; die rohen Bytes zeigten das Gegenteil (NUL, 0x1F, 0x7F direkt eingebettet). Funktionierte, war aber zerbrechlich: verschluckt ein Werkzeug das NUL-Byte, wird aus dem Bereich stillschweigend ein anderer und der Schutz loechrig — der Rueckgabewert landet in `login/page.tsx` direkt in `window.location.href`. Jetzt echte Escapes, Datei ohne ein einziges Steuerbyte. **Beweis der Gleichwertigkeit, nicht Behauptung:** ueber alle 65536 Codepunkte dieselbe Menge abgelehnter Zeichen — 54 Stueck (32 Steuerzeichen 0x00-0x1F, dazu 0x7F, Backslash und die 20 Leerraum-Zeichen von `\s`), null Abweichung; vom Orchestrator unabhaengig gegen ein selbst gebautes Referenzmuster nachgerechnet. **Bewusst nicht angefasst:** die NUL-Maskierung in `ldap.service.ts` (RFC 4515) — genau dieses Zeichen zu treffen ist ihr Zweck, wer sie "repariert", oeffnet LDAP-Filter-Injection. Ebenso die drei `while ((m = re.exec(s)))`-Schleifen (idiomatisch, kein verrutschtes Gleichheitszeichen) und `noUselessSwitchCase` aus bi2. **Zwei Korrekturen an frueheren Annahmen:** `sanitizeNextPath` laeuft NICHT in der Edge-Middleware (die importiert nur `buildNextParam`), und das blosse Umschreiben auf Escapes senkt die Warnzahl nicht — Biome beanstandet die Escape-Schreibweise genauso, es braucht zusaetzlich einen einzeiligen Unterdrueckungskommentar. **Werkzeugfalle, dreimal zugeschnappt:** das Schreibwerkzeug wandelt `\uXXXX` still in das echte Zeichen um — der Planer erzeugte so zehn rohe Steuerbytes in seiner ersten Planfassung, der Executor zweimal in Commit-Text und Akte (git verweigerte den Commit wegen eines NUL-Bytes), und der Orchestrator beim Nachrechnen. Umgehung ueber `python3`/`chr(92)` ist im Plan hinterlegt. **Zahlen:** 446 → 434, `noControlCharactersInRegex`/`useIterableCallbackReturn`/`noGlobalIsNan` je 0, `suppressions/unused` 0, web-Tests 67/477 → 68/481, api 71/1136 → 72/1137, type-check 4/4, lint 5/5. | 2026-09-21 | f85c91b,076ca4b,b92dd5d | [260921-i8x-fehlerverdaechtige-lint-klassen-steuerze](./quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/) |
| 260921-iwr | **Listenschluessel und Ausrufezeichen-Zusicherungen: 30 Stellen geprueft, wieder kein echter Fehler.** Damit ist der fehlerverdaechtige Rueckstand abgearbeitet. **Zwei Vorannahmen des Orchestrators widerlegt, beide durch Messung statt Argument:** (1) Die LDAP-Seite galt als heisser Kandidat, weil dort Zuordnungsregeln hinzugefuegt und geloescht werden — die Liste, die tatsaechlich waechst und schrumpft (`config.fieldMappings`), benutzt jedoch laengst `key={mapping.id}`; die sechs Meldungen betreffen zustandslose Textlisten. (2) Im Cert-Manager galt eine Zusicherung auf hochgeladenen Dateiinhalt als moeglicher Absturz — der Planer hat eine 83-Byte-Schrottdatei gebaut, die node-forge `bag.cert = null` setzen laesst, und gegen den echten Dienst laufen lassen: **alle vier Pfade enden mit 400, nie 500**, und `certificateToPem(null)` wirft nachweislich, statt still ein falsches Zertifikat zu bauen. Also weder Verfuegbarkeits- noch Integritaetsluecke, sondern eine irrefuehrende Fehlermeldung. **Ein Fund dreht die Richtung um:** bei `admin/modules/grants/page.tsx:246` waere die Korrektur schaedlich — die Gruppierung fasst nur aufeinanderfolgende Kategorien zusammen, die Positionsnummer ist dort fuer die Eindeutigkeit noetig, ohne sie entstuenden doppelte Schluessel. **Geaendert: 5 Stellen** (drei Waechter im Cert-Manager, die den Meldungstext praezisieren — Status bleibt 400, rot-dann-gruen belegt; zwei ueberfluessige Zusicherungen in `imap.provider.ts`, die imapflow ohnehin als Pflichtfeld typisiert). **25 Stellen bleiben bewusst stehen und bleiben in der Zaehlung sichtbar** — mit Begruendung je Stelle in der Akte, damit der naechste Durchgang sie nicht erneut aufrollt; kein Unterdrueckungskommentar, um die Zahl zu schoenen. **Das Tor hat sich selbst bewaehrt:** der erste Entwurf eines Waechters erzeugte einen neuen Lint-Fund (430 statt 429) und wurde von der Verifikation des Plans gefangen; die Reparatur brach `tsc`, weil `@types/node-forge` `Bag.cert` als `Certificate | undefined` deklariert, waehrend die Bibliothek zur Laufzeit `null` zuweist — Endfassung prueft beides. **Zahlen:** 434 → 429, `noArrayIndexKey` unveraendert 19 (alle geprueft, alle harmlos), `noNonNullAssertion` 11 → 6, web-Tests 68/481 → 69/484, api 72/1137 → 72/1143, type-check 4/4, lint 5/5. | 2026-09-21 | 8716fa5,b4aaed4,27909e4,de69863 | [260921-iwr-listenschluessel-per-positionsnummer-und](./quick/260921-iwr-listenschluessel-per-positionsnummer-und/) |
## Deferred Items ## Deferred Items
@@ -495,4 +496,4 @@ Last session: 2026-09-21T04:50:00Z
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt. Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion). Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
Resume file: None Resume file: None
Last activity: 2026-09-21 - Quick 260921-i8x: fuenf fehlerverdaechtige Lint-Klassen geprueft, kein echter Fehler darunter; Weiterleitungsschutz safe-next.ts von rohen Steuerbytes auf Escapes gehaertet, Gleichwertigkeit ueber alle 65536 Codepunkte nachgerechnet (54 abgelehnte Zeichen, null Abweichung) Last activity: 2026-09-21 - Quick 260921-iwr: 30 Listenschluessel- und Zusicherungs-Stellen geprueft, kein echter Fehler; beide Verdachtsmomente (LDAP-Seite, Cert-Manager) durch Messung widerlegt. Der fehlerverdaechtige Rueckstand ist damit abgearbeitet
@@ -0,0 +1,413 @@
---
phase: quick-260921-iwr
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-05, D-06]
files_modified:
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/cert-manager/cert-manager.service.spec.ts
- apps/api/src/inbox/imap.provider.ts
- apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
- apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx
estimate:
tokens: 78000
raw_tokens: 39000
tasks: 3
confidence: low
must_haves:
truths:
- "Jede der 30 Fundstellen traegt ein Urteil mit einer Begruendung, die die Folge benennt, nicht die Regel (D-01)."
- "Biome meldet danach ARRAYKEY=19, NONNULL=6, ERRORS=0, TOTAL=429 — genau die 5 tatsaechlich geaenderten Stellen weniger (D-06)."
- "Eine hochgeladene PFX-Datei mit unlesbarem Zertifikats-Bag wird mit einer praezisen 400-Meldung abgewiesen statt mit einer irrefuehrenden."
- "Kein Zertifikat wird still falsch ausgegeben und keine Eingabepruefung wurde abgeschwaecht (D-04)."
- "pnpm lint bleibt 5/5, pnpm type-check 4/4, beide Testsuiten mindestens auf Ausgangsstand."
artifacts:
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/cert-manager/cert-manager.service.spec.ts
- apps/api/src/inbox/imap.provider.ts
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx
- .planning/quick/260921-iwr-listenschluessel-per-positionsnummer-und/260921-iwr-SUMMARY.md
key_links:
- "Die drei bag.cert-Waechter haengen an node-forge lib/pkcs12.js Zeile 708 (bag.cert = null bei unlesbarem X.509) — das ist der einzige Grund, warum die Zusicherung sachlich falsch ist."
- "Die 400-Zusage haengt an den catch-Bloecken in Zeile 232, 486, 533 und 625: BadRequestException wird unveraendert durchgereicht, alles andere wird zu 400 umgewandelt."
- "Die Unbedenklichkeit aller 19 Positionsschluessel haengt an einer einzigen Tatsache: keine der betroffenen Zeilen haelt Zustand, den React pro Schluessel fuehrt."
---
<objective>
Die 30 gemeldeten Fundstellen zweier Lint-Klassen einzeln beurteilen und nur dort eingreifen,
wo die Beurteilung einen echten Grund liefert.
Purpose: Letzte Fehlerklasse vor den beiden neuen Widgets. Es geht um ein belastbares Urteil je
Stelle, nicht um eine niedrigere Zahl.
Output: 5 begruendete Aenderungen, 25 begruendet stehengelassene Fundstellen, neue Tests fuer die
beiden Stellen, an denen die Beurteilung nicht offensichtlich war.
</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/cert-manager/cert-manager.service.ts
@apps/api/src/inbox/imap.provider.ts
@apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
</context>
<planning_observations>
Beim Planen am 2026-09-21 nachgemessen und nachgewiesen — der Ausfuehrende muss das nicht
wiederholen, aber er darf es nicht ungeprueft umstossen:
**Ausgangsstand (identisch zur Vorgabe des Orchestrators):**
TOTAL=434, ERRORS=0, ARRAYKEY=19, NONNULL=11; web 68 Dateien / 481 Tests,
api 72 / 1137, pnpm type-check 4/4, pnpm lint 5/5.
**Die Vermutung zu admin/ldap ist widerlegt.** Die Liste, die dort wirklich waechst und
schrumpft, ist `config.fieldMappings` in Zeile 712 — sie benutzt bereits `key={mapping.id}`.
Die 6 gemeldeten Stellen sind etwas anderes: reine Meldungslisten
(`nameCollisions`, `errors`, `emailConflicts`, `skippedNoLogin`, `entryFailures`, `errors`),
die als zustandslose Absaetze gerendert und beim naechsten Lauf komplett ersetzt werden
(setSyncResult(null) in Zeile 284, dann setSyncResult(data) in Zeile 293).
**node-forge kann bag.cert auf null setzen** — lib/pkcs12.js Zeile 703-709: wenn
`certificateFromAsn1` wirft, faengt node-forge das ab und setzt `bag.cert = null`. Die drei
Zusicherungen 207/469/602 behaupten also etwas, das nicht gilt.
**Die Folge davon ist aber bereits behandelt.** Mit einer eigens gebauten 83-Byte-PFX-Datei
(Zertifikats-Bag mit wohlgeformtem, aber unlesbarem Inhalt) gegen den echten Service gemessen:
| Pfad | heutiges Ergebnis |
|------|-------------------|
| parseCert | 400 "Failed to extract certificate details" |
| mergeCerts pem | 400 "Failed to create merged certificate output" |
| mergeCerts pfx | 400 "Failed to create merged certificate output" |
| convertCert | 400 "Failed to convert certificate to pem: serialization error" |
Kein 500, kein Absturz. Zusaetzlich direkt an node-forge geprueft: `certificateToPem(null)` und
`toPkcs12Asn1(null, [cert, null], pw)` werfen beide eine TypeError — die Bibliothek laesst ein
fehlendes Zertifikat **nie** still durchrutschen. Das in D-04 befuerchtete "still falsches
Zertifikat" ist damit ausgeschlossen, nicht nur unwahrscheinlich.
Daraus folgt die Einstufung: die drei Stellen sind **keine Verfuegbarkeits- und keine
Integritaetsluecke**. Was bleibt, ist eine sachlich falsche Zusicherung und eine irrefuehrende
Fehlermeldung. Der Eingriff ist deshalb als Diagnose-/Lesbarkeitsarbeit zu fuehren, nicht als
Fehlerbehebung.
**imapflow typisiert `uid` als Pflichtfeld** (lib/imap-flow.d.ts Zeile 469 in
`FetchMessageObject`, Kommentar "Always included in the response"). Das `!` ist dort schlicht
ueberfluessig. Entfernen wurde beim Planen probeweise durchgefuehrt: `tsc --noEmit` in apps/api
endet mit 0. Die Aenderung wurde danach zurueckgenommen, der Baum ist sauber.
**ESLint existiert in diesem Repo nicht** (kein Paket, keine Konfigurationsdatei; Treffer nur in
mitgelieferten Fremdpaketen unter .next/standalone). Der Unterdrueckungskommentar in
stopwatch-widget.tsx Zeile 277 wirkt daher nicht.
**Zaehlbefehl** (in jedem verify benutzt, Kategorien kommen aus dem JSON-Feld `category`,
nie aus dem Quelltext):
`npx biome lint --reporter=json . 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const ds=JSON.parse(s).diagnostics||[];const isTest=p=>p.includes(".spec.")||p.includes(".test.")||p.includes("__tests__")||p.includes("__fixtures__")||p.includes("__mocks__");const real=ds.filter(x=>!isTest(x.location.path));const c=r=>real.filter(x=>x.category===r).length;console.log("TOTAL="+ds.length+" ERRORS="+ds.filter(x=>x.severity==="error").length+" ARRAYKEY="+c("lint/suspicious/noArrayIndexKey")+" NONNULL="+c("lint/style/noNonNullAssertion"));});'`
Er meldet im Ausgangsstand `TOTAL=434 ERRORS=0 ARRAYKEY=19 NONNULL=11`.
</planning_observations>
<tasks>
<task type="tracer">
<name>Task 1: Die 19 Positionsschluessel beurteilen und die zwei nicht offensichtlichen Faelle nachmessen</name>
<files>
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx,
apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx,
apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx
</files>
<read_first>
apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx,
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx,
apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
</read_first>
<behavior>
- MergeTab: drei Dateien auswaehlen, die mittlere ueber ihren Entfernen-Knopf loeschen; danach
stehen genau die erste und die dritte Datei in der Liste, in dieser Reihenfolge, mit ihren
eigenen Namen. Das ist der Nachweis, dass der Positionsschluessel beim Schrumpfen der Liste
nichts verwechselt.
- Stopwatch: zwei Runden nacheinander aufzeichnen; die Liste zeigt die neuere Runde oben, die
Rundennummern lauten von oben nach unten 2 und 1, und die beiden Zeiten stehen bei der
jeweils richtigen Nummer. Das ist der Nachweis fuer die Liste, die von vorn waechst.
</behavior>
<action>
Beurteile alle 19 Fundstellen einzeln und halte je Stelle ein Urteil mit Folgenbegruendung fest
(D-01). Die Beurteilung aus den planning_observations ist nachgewiesen und darf uebernommen
werden; wenn du beim Lesen etwas anderes siehst, hat deine Beobachtung Vorrang und du sagst es.
Erwartete Einstufung, nach Muster gruppiert:
(a) Zwoelf Stellen sind Ladeplatzhalter aus Array.from mit fester Laenge ohne Inhalt —
InvoiceHistoryTable 129 und 131, VehicleTable 342 und 344, ResultsList 240 und 242,
InboxConfigForm 251, EmailAlertConfigForm 225, RssFeedListForm 151, SourceConfigForm 129.
Dort gibt es ausser der Position ueberhaupt keine Identitaet, die Laenge ist konstant, die
Reihenfolge aendert sich nie. Harmlos.
(b) Sechs Stellen in admin/ldap sind Meldungslisten, die beim naechsten Lauf komplett ersetzt
werden und deren Zeilen nur Text enthalten. Harmlos. Halte dabei ausdruecklich fest, dass die
Liste, die dort tatsaechlich waechst und schrumpft, bereits mapping.id benutzt.
(c) grants/page.tsx 246: die Gruppierung in der useMemo ab Zeile 168 fasst nur unmittelbar
aufeinanderfolgende Module gleicher Kategorie zusammen, dieselbe Kategorie kann also mehrfach
vorkommen. Der Positionsanteil im Schluessel ist deshalb noetig; ihn zu entfernen wuerde
doppelte Schluessel erzeugen. Die Haken haengen ohnehin nicht am Schluessel, sondern an der
aeusseren Menge grants ueber cellKey(mod.id, g.id). Harmlos, und der Index bleibt bewusst.
(d) MergeTab 87 und stopwatch-widget 276: die einzigen beiden Listen, die sich waehrend der
Anzeige wirklich veraendern. Belege deren Unbedenklichkeit mit den beiden Tests aus dem
behavior-Block, statt sie nur zu behaupten.
Aendere keinen einzigen Schluessel im Produktivcode. Fuer die zwoelf Platzhalter und die sechs
Meldungslisten gibt es keine stabile Identitaet, die ungenutzt herumlaege; bei grants waere die
Aenderung sogar schaedlich; bei den Runden der Stoppuhr saesse eine echte Identitaet nur in der
gespeicherten Form laps als Zahlenliste, deren Umbau bereits abgelegte Widget-Konfigurationen
und bestehende Tests brechen wuerde — das waere eine Verhaltensaenderung ohne Gegenwert und
unterbleibt (D-02).
Entferne in stopwatch-widget.tsx die wirkungslose Unterdrueckungszeile ueber dem Schluessel
(sie nennt eine Regel eines Linters, den dieses Projekt nicht einsetzt) und setze an ihre
Stelle einen kurzen Sachhinweis, warum die Position hier als Schluessel vertretbar ist: die
Zeilen halten keinen Zustand, und die angezeigte Rundennummer wird ohnehin aus Laenge und
Position berechnet. Fuege keine neue Unterdrueckung hinzu (D-06) — die Fundstelle bleibt
ausdruecklich in der Zaehlung stehen.
Lege MergeTab.test.tsx neu an. Orientiere dich an den Mustern in stopwatch-widget.test.tsx
fuer render, fireEvent und die Uebersetzungsattrappe. Die Dateiauswahl laeuft ueber das
verborgene Dateifeld; setze die Dateien per fireEvent.change mit echten File-Objekten. Den
Entfernen-Knopf findest du ueber sein aria-label, das den Dateinamen enthaelt. Ergaenze den
Rundentest in stopwatch-widget.test.tsx als zusaetzlichen Fall, ohne bestehende Faelle zu
veraendern.
ARRAYKEY bleibt danach bei 19. Das ist das erwartete Ergebnis, kein Versaeumnis.
</action>
<verify>
<automated>cd apps/web && npx vitest run src/app/\(portal\)/modules/cert-manager/components/MergeTab.test.tsx src/components/dashboard/widgets/stopwatch-widget.test.tsx</automated>
<automated>cd apps/web && npx vitest run 2>&1 | tail -4 # erwartet: mindestens 69 Dateien, mindestens 484 Tests, alle gruen</automated>
<automated>npx biome lint --reporter=json . 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const ds=JSON.parse(s).diagnostics||[];const isTest=p=>p.includes(".spec.")||p.includes(".test.")||p.includes("__tests__")||p.includes("__fixtures__")||p.includes("__mocks__");const real=ds.filter(x=>!isTest(x.location.path));const c=r=>real.filter(x=>x.category===r).length;console.log("ARRAYKEY="+c("lint/suspicious/noArrayIndexKey")+" ERRORS="+ds.filter(x=>x.severity==="error").length);});' # erwartet: ARRAYKEY=19 ERRORS=0</automated>
</verify>
<done>
Alle 19 Fundstellen haben ein Urteil mit Folgenbegruendung. Kein Schluessel im Produktivcode
geaendert. Die wirkungslose Unterdrueckungszeile ist weg, keine neue hinzugekommen. Die beiden
Tests belegen, dass Entfernen in der Mitte und Wachsen von vorn keine Zeile verwechseln. Die
Web-Suite ist gewachsen und gruen, ARRAYKEY steht unveraendert bei 19.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Die sechs Zusicherungen in cert-manager.service.ts beurteilen, drei davon zu echten Waechtern machen</name>
<files>
apps/api/src/cert-manager/cert-manager.service.ts,
apps/api/src/cert-manager/cert-manager.service.spec.ts
</files>
<read_first>
apps/api/src/cert-manager/cert-manager.service.ts,
apps/api/src/cert-manager/cert-manager.service.spec.ts
</read_first>
<precondition>node_modules ist installiert und node-forge 1.4.0 aufloesbar (die Tests bauen die Pruefdatei mit forge.asn1 selbst).</precondition>
<behavior>
Pruefgegenstand ist eine PFX-Datei, deren Zertifikats-Bag wohlgeformt, aber kein lesbares
X.509 ist. node-forge setzt bag.cert dann auf null.
- parseCert mit dieser Datei wirft BadRequestException (Status 400) und die Meldung benennt,
dass der Zertifikats-Bag kein lesbares X.509-Zertifikat enthaelt. Heute lautet sie
"Failed to extract certificate details" — der Test ist vor dem Waechter rot.
- mergeCerts mit dieser Datei wirft 400 mit derselben praezisen Aussage, sowohl bei
outputFormat pem als auch bei pfx. Heute lautet sie beide Male
"Failed to create merged certificate output" — vor dem Waechter rot.
- convertCert mit dieser Datei wirft 400 mit der praezisen Aussage. Heute lautet sie
"Failed to convert certificate to pem: serialization error" — vor dem Waechter rot.
- Eine gueltige PFX-Datei verhaelt sich unveraendert: parseCert, mergeCerts und convertCert
liefern weiterhin ihr bisheriges Ergebnis. Die bestehenden Faelle in der Spezifikation
bleiben gruen.
</behavior>
<action>
Beurteile alle sechs Fundstellen einzeln (D-01) und benenne bei jeder sicheren die Zeile, die
sie garantiert.
Sicher und unveraendert zu lassen:
Zeile 133 — die Zerlegung des Fingerabdrucks in Zweiergruppen. Ein SHA-1- oder
SHA-256-Hexwert ist immer 40 beziehungsweise 64 Zeichen lang, die Suche findet also immer
etwas. Garantiert durch die Laenge des Hashes, nicht durch Eingabedaten.
Zeile 516 — das Kennwort beim Erzeugen einer PFX-Ausgabe im Zusammenfuehren. Garantiert durch
die Pruefung in Zeile 438 bis 440, die bei fehlendem oder leerem Kennwort vorher mit 400
abbricht.
Zeile 661 — dasselbe im Umwandeln. Garantiert durch die Pruefung in Zeile 564 bis 566.
Zu aendern sind die drei Stellen, an denen die Zusicherung sachlich falsch ist, weil node-forge
bag.cert auf null setzen kann (lib/pkcs12.js Zeile 703 bis 709): Zeile 207 in parseCert,
Zeile 469 in mergeCerts, Zeile 602 in convertCert.
Schreibe zuerst die Tests aus dem behavior-Block und weise nach, dass sie mit den heutigen
Meldungen fehlschlagen. Baue die Pruefdatei im Test selbst mit forge.asn1 auf. Ihr Aufbau, von
aussen nach innen: eine SEQUENCE aus der INTEGER-Version 3 und einem ContentInfo; das
ContentInfo besteht aus der OID data und einem kontextspezifischen Element 0, das eine
OCTETSTRING mit dem DER des AuthenticatedSafe traegt; das AuthenticatedSafe ist eine SEQUENCE
aus einem weiteren ContentInfo gleicher Bauart, dessen OCTETSTRING das DER der SafeContents
traegt; die SafeContents sind eine SEQUENCE aus einem SafeBag; der SafeBag ist eine SEQUENCE
aus der OID certBag und einem kontextspezifischen Element 0, das eine SEQUENCE aus der OID
x509Certificate und einem kontextspezifischen Element 0 mit einer OCTETSTRING enthaelt; in
dieser OCTETSTRING steht das DER einer SEQUENCE mit einem einzigen INTEGER 1 — wohlgeformt,
aber kein Zertifikat. Als Kennwort dient die leere Zeichenkette. Die Datei ist rund 83 Byte
gross. Pruefe im Test vorab, dass genau ein Bag entsteht und dessen cert null ist; damit ist
belegt, dass der Waechter den gemeinten Fall trifft und nicht einen anderen.
Ersetze dann an den drei Stellen die Zusicherung durch eine ausdrueckliche Pruefung, die eine
BadRequestException mit einer praezisen Meldung wirft. In Zeile 207 und 602 lies bag.cert in
eine lokale Variable, pruefe sie und wirf bei fehlendem Wert. In Zeile 469 darf kein Eintrag
verschluckt werden: bilde die Bag-Liste auf ihre Zertifikate ab, pruefe, ob darunter ein
fehlendes ist, und wirf in dem Fall unter Nennung des Dateinamens — erst danach gib die Liste
zurueck, eingeengt ueber ein Typpraedikat statt ueber eine Zusicherung. Kein Filtern, kein
Ueberspringen, kein Ersatzwert (D-04).
Die BadRequestException wird von den catch-Bloecken in Zeile 232, 486 und 625 unveraendert
durchgereicht, die praezise Meldung kommt also beim Aufrufer an.
Ausdrueckliche Verhaltensaenderung nach D-02, vorab benannt und beabsichtigt: fuer genau diese
Eingabeklasse aendert sich der Text der Fehlermeldung. Der Statuscode bleibt in allen vier
Pfaden 400, der Vertrag nach aussen bleibt damit unveraendert. Gewollt ist, dass die Meldung
den tatsaechlichen Grund nennt statt eines irrefuehrenden Sammelbegriffs.
Halte in der Zusammenfassung fest, dass dies Diagnose- und Lesbarkeitsarbeit ist: die drei
Zusicherungen waren sachlich falsch, ihre Folge war aber bereits behandelt — nachgewiesen mit
400 statt 500 in allen vier Pfaden und damit, dass node-forge bei einem fehlenden Zertifikat
ausnahmslos wirft und nie still eine unvollstaendige Ausgabe erzeugt. Es war keine
Verfuegbarkeits- und keine Integritaetsluecke.
Das Kennwort darf weiterhin nirgends in eine Meldung oder ins Protokoll geraten (T-09-02).
</action>
<verify>
<automated>cd apps/api && npx vitest run src/cert-manager/cert-manager.service.spec.ts</automated>
<automated>cd apps/api && npx vitest run 2>&1 | tail -4 # erwartet: mindestens 72 Dateien, mindestens 1141 Tests, alle gruen</automated>
<automated>npx biome lint --reporter=json . 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const ds=JSON.parse(s).diagnostics||[];const isTest=p=>p.includes(".spec.")||p.includes(".test.")||p.includes("__tests__")||p.includes("__fixtures__")||p.includes("__mocks__");const real=ds.filter(x=>!isTest(x.location.path));const n=real.filter(x=>x.category==="lint/style/noNonNullAssertion");console.log("NONNULL="+n.length+" CERTMGR="+n.filter(x=>x.location.path.includes("cert-manager.service.ts")).length);});' # erwartet: NONNULL=8 CERTMGR=3</automated>
</verify>
<done>
Alle sechs Fundstellen beurteilt, bei den drei sicheren ist die garantierende Zeile genannt.
Die drei falschen Zusicherungen sind ausdrueckliche Waechter mit praeziser 400-Meldung; die
Tests waren vorher rot und sind jetzt gruen. Keine Eingabepruefung abgeschwaecht, kein
Eintrag verschluckt, kein Ersatzwert eingefuehrt. Die api-Suite ist gewachsen und gruen.
NONNULL steht bei 8, davon 3 in cert-manager.service.ts.
</done>
</task>
<task type="auto">
<name>Task 3: Die restlichen fuenf Zusicherungen beurteilen, zwei ueberfluessige entfernen</name>
<files>apps/api/src/inbox/imap.provider.ts</files>
<read_first>
apps/api/src/inbox/imap.provider.ts,
apps/api/src/tenders/tenders.controller.ts,
apps/api/src/tenders/tenders.module.ts,
apps/web/src/components/dashboard/widgets/favorites-widget.tsx,
apps/web/src/components/layout/sidebar.tsx
</read_first>
<action>
Beurteile die fuenf verbliebenen Fundstellen einzeln (D-01) und benenne bei jeder sicheren die
Zeile oder den Umstand, der sie garantiert.
Zu aendern — imap.provider.ts Zeile 214 und 310, beide `uid: msg.uid!`: imapflow typisiert uid
in FetchMessageObject als Pflichtfeld (lib/imap-flow.d.ts Zeile 469, Kommentar "Always
included in the response"). Die Zusicherung sichert damit einen Wert ab, der ohnehin nicht
fehlen kann; sie ist ueberfluessig. Entferne an beiden Stellen nur das Ausrufezeichen und
sonst nichts. Das ist eine reine Lesbarkeitsaenderung ohne Verhaltensaenderung; tsc belegt sie.
Sicher und unveraendert zu lassen:
tenders.controller.ts Zeile 244 — der Fragezeichen-Parameter im Konstruktor ist nur dort, um
die Reihenfolge der Konstruktorargumente nicht zu brechen. Es gibt kein Optional-Merkmal, und
TenderIngestionService steht in tenders.module.ts unter providers, wird von Nest also immer
eingesetzt. Garantiert durch den Provider-Eintrag.
favorites-widget.tsx Zeile 149 — die Reihenfolge stammt aus sortedFavorites, und das ist laut
Zeile 90 bis 97 nur eine sortierte Kopie von favorites. Die Zuordnungstabelle wird aus
denselben Eintraegen gebaut, jede gesuchte Kennung ist also enthalten. Garantiert durch die
Herleitung in Zeile 90 bis 97.
sidebar.tsx Zeile 80 — unmittelbar darueber, in Zeile 79, wird der Eintrag angelegt, falls er
fehlt. Garantiert durch die Zeile direkt davor.
Diese drei bleiben in der Zaehlung stehen, und das wird in der Zusammenfassung gesagt (D-06).
Ersetze sie nicht durch gleichwertige Abfragen, nur um die Zahl zu druecken — sie sind sicher,
und ein Umbau waere Geraeusch ohne Gewinn.
Fuege nirgends eine Unterdrueckung hinzu und aendere keine Abhaengigkeiten (D-05).
</action>
<verify>
<automated>cd apps/api && npx tsc --noEmit; echo "tsc=$?" # erwartet: tsc=0</automated>
<automated>cd apps/api && npx vitest run 2>&1 | tail -4 # erwartet: mindestens 72 Dateien, mindestens 1141 Tests, alle gruen</automated>
<automated>npx biome lint --reporter=json . 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const ds=JSON.parse(s).diagnostics||[];const isTest=p=>p.includes(".spec.")||p.includes(".test.")||p.includes("__tests__")||p.includes("__fixtures__")||p.includes("__mocks__");const real=ds.filter(x=>!isTest(x.location.path));const n=real.filter(x=>x.category==="lint/style/noNonNullAssertion");console.log("NONNULL="+n.length+" IMAP="+n.filter(x=>x.location.path.includes("imap.provider.ts")).length);});' # erwartet: NONNULL=6 IMAP=0</automated>
</verify>
<done>
Alle fuenf beurteilt. Die beiden ueberfluessigen Ausrufezeichen in imap.provider.ts sind weg,
tsc ist gruen. Die drei sicheren stehen unveraendert und ihre Begruendung nennt jeweils die
garantierende Zeile. NONNULL steht bei 6, keines davon mehr in imap.provider.ts.
</done>
</task>
</tasks>
<threat_model>
ASVS Level 1, block_on high.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser zu API, Datei-Upload | Zertifikatsdateien kommen ungeprueft vom Benutzer und werden von node-forge zerlegt |
| Browser zu Admin-Oberflaeche | admin/ldap konfiguriert Verzeichnisbindungen; eine falsch zugeordnete Zeile wirkt hier unmittelbar auf Zugaenge |
| IMAP-Server zu API | Nachrichtenkopfdaten stammen aus einer fremden Quelle |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-iwr-01 | Tampering | admin/ldap/page.tsx Listen | medium | accept | Beim Planen widerlegt: die veraenderliche Liste nutzt bereits mapping.id, die 6 gemeldeten Listen sind zustandslose Meldungen, die komplett ersetzt werden. Kein Bedienpfad, auf dem ein Eintrag an der falschen Zeile haengen bleibt. |
| T-iwr-02 | Denial of Service | cert-manager.service.ts, bag.cert bei Upload | high | mitigate | Zusicherung kann durch eine praeparierte PFX-Datei verletzt werden. Gemessen: alle vier Pfade enden bereits mit 400, nie 500. Task 2 setzt ausdrueckliche Waechter mit praeziser Meldung; Test mit echter Pruefdatei sichert das ab. |
| T-iwr-03 | Tampering | mergeCerts, Zertifikatsliste aus PFX-Bags | high | mitigate | Ein fehlendes Zertifikat koennte stillschweigend ein unvollstaendiges Buendel erzeugen. An node-forge nachgemessen: sowohl certificateToPem als auch toPkcs12Asn1 werfen bei einem fehlenden Eintrag. Task 2 lehnt zusaetzlich vor der Serialisierung ausdruecklich ab, ohne zu filtern (D-04). |
| T-iwr-04 | Information Disclosure | Fehlermeldungen mit Dateinamen | low | accept | Der Dateiname ist benutzergesteuert, erscheint aber bereits heute so in Zeile 467. Kennwoerter bleiben aus Meldung und Protokoll ausgeschlossen (T-09-02). |
| T-iwr-05 | Spoofing | imap.provider.ts, uid aus Serverantwort | low | accept | uid ist in imapflow ein Pflichtfeld; das Entfernen des Ausrufezeichens aendert nichts am Wert, nur an der ueberfluessigen Zusicherung. |
| T-iwr-SC | Tampering | Paketinstallationen | n/a | accept | Dieser Vorgang installiert nichts und aendert keine Abhaengigkeit (D-05). Keine Paketpruefung noetig. |
</threat_model>
<verification>
Nach allen drei Aufgaben, aus dem Wurzelverzeichnis:
1. `npx biome lint --reporter=json .` durch den Zaehlbefehl aus den planning_observations —
erwartet `TOTAL=429 ERRORS=0 ARRAYKEY=19 NONNULL=6`. TOTAL faellt um genau 5, also um die
Zahl der tatsaechlich geaenderten Stellen, und steigt nirgends anders an (D-06).
2. `pnpm lint` — erwartet 5 successful, 5 total, 0 error-severity.
3. `pnpm type-check` — erwartet 4 successful, 4 total.
4. `cd apps/web && npx vitest run` — mindestens 69 Dateien, mindestens 484 Tests, alle gruen.
5. `cd apps/api && npx vitest run` — mindestens 72 Dateien, mindestens 1141 Tests, alle gruen.
6. `git status --porcelain` — nur die in files_modified genannten Pfade plus die Zusammenfassung.
Kein laufender Stapel noetig: beide beurteilten Klassen sind im Test vollstaendig nachweisbar,
fuer die Listenschluessel ist der Komponententest ohnehin das schaerfere Werkzeug als der Browser.
</verification>
<success_criteria>
- Alle 30 Fundstellen haben ein Urteil mit einer Begruendung, die die Folge benennt (D-01).
- Die 5 geaenderten Stellen sind verschwunden, die 25 stehengelassenen stehen weiter in der
Zaehlung und sind als bewusst stehengelassen benannt (D-06).
- Die einzige Verhaltensaenderung — praezisere Fehlermeldung bei unlesbarem Zertifikats-Bag, Status
weiterhin 400 — ist vorab benannt und ist die beabsichtigte (D-02).
- Keine Eingabepruefung abgeschwaecht, kein Eintrag verschluckt, kein Ersatzwert eingefuehrt (D-04).
- Keine neue Unterdrueckung, keine neue Abhaengigkeit, keine Versionsanhebung, keine
Neuformatierung (D-05, D-06).
</success_criteria>
<output>
Schreibe `.planning/quick/260921-iwr-listenschluessel-per-positionsnummer-und/260921-iwr-SUMMARY.md`.
Sie muss eine Tabelle mit allen 30 Fundstellen enthalten: Datei, Zeile, Regel, Urteil
(echter Fehler / harmlos / Lesbarkeit) und die einzeilige Begruendung. Ausserdem die
Vorher-Nachher-Zahlen aus dem Zaehlbefehl und die ausdrueckliche Aussage, welche Fundstellen
bewusst stehen bleiben und warum.
Hinweis zur Werkzeugfalle: Das Write-Werkzeug wandelt Folgen der Form Backslash-u-vier-Ziffern in
das jeweilige Zeichen um. Schreibe in Zusammenfassung und Commit-Nachricht keine solchen Folgen.
Falls doch eine gebraucht wird, erzeuge sie ueber python3 mit chr(92) fuer den Backslash und
pruefe anschliessend die Rohbytes der Datei mit `git diff --check` und `LC_ALL=C grep -n '[^[:print:][:space:]]'`.
</output>
@@ -0,0 +1,265 @@
---
phase: quick-260921-iwr
plan: 01
subsystem: testing
tags: [biome, lint, react, node-forge, pkcs12, imapflow, cert-manager]
# Dependency graph
requires: []
provides:
- 30 einzeln beurteilte Fundstellen (19 noArrayIndexKey, 11 noNonNullAssertion) mit Urteil und Folgenbegruendung
- Drei echte Waechter in cert-manager.service.ts statt sachlich falscher Zusicherungen (bag.cert = null)
- Zwei entfernte ueberfluessige Zusicherungen in imap.provider.ts (uid ist Pflichtfeld)
- Neue Tests: MergeTab.test.tsx (Entfernen mittig/vorne), Rundenliste in stopwatch-widget.test.tsx, sechs neue Faelle fuer die malformte PFX in cert-manager.service.spec.ts
affects: []
# Actuals (#2632)
actuals:
tokens: 5300
tasks: 3
commits: 4
plan_head_before: cfba3c953202057064adbe691b735977654f127c
# Tech tracking
tech-stack:
added: []
patterns:
- "Malformte PFX-Testdatei per forge.asn1 handgebaut statt Fixture-Datei — exakte Kontrolle ueber den Fehlerfall (bag.cert = null)"
- "Zusicherung durch Typpraedikat-Filter ersetzt statt Filtern/Ueberspringen bei fehlendem Zertifikat (D-04)"
key-files:
created:
- apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx
modified:
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/cert-manager/cert-manager.service.spec.ts
- apps/api/src/inbox/imap.provider.ts
- apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
- apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
key-decisions:
- "Alle 19 noArrayIndexKey-Fundstellen bleiben im Produktivcode unveraendert — zwoelf sind Ladeplatzhalter/Meldungslisten ohne Identitaet (tatsaechlich zehn, siehe Korrektur unten), eine (grants) braucht den Index sachlich, zwei (MergeTab, Stoppuhr) sind jetzt per Test belegt statt nur behauptet."
- "Drei bag.cert-Zusicherungen in cert-manager.service.ts sind sachlich falsch (node-forge kann bag.cert auf null setzen), aber keine Verfuegbarkeits- oder Integritaetsluecke: alle vier betroffenen Pfade antworteten schon vorher mit 400, nie 500. Der Eingriff ist Diagnose-/Lesbarkeitsarbeit — die Meldung wird praezise, der Statuscode bleibt 400 (D-02)."
- "Zwei uid!-Zusicherungen in imap.provider.ts entfernt: imapflow typisiert uid als Pflichtfeld, das Ausrufezeichen war wirkungslos."
- "Drei weitere Zusicherungen (cert-manager 133/516/661, tenders.controller.ts:244, favorites-widget.tsx:149, sidebar.tsx:80) bleiben unveraendert — jede durch eine konkrete vorgelagerte Zeile oder einen strukturellen Fakt garantiert."
patterns-established:
- "Handgebaute ASN.1-Testfixtures fuer node-forge-Grenzfaelle (forge.asn1.create) statt auf Zufallsdaten oder Mocks zu setzen"
requirements-completed: [D-01, D-02, D-03, D-04, D-05, D-06]
coverage:
- id: D1
description: "Alle 19 noArrayIndexKey-Fundstellen einzeln beurteilt, MergeTab-Entfernen (mittig + vorne) und Stoppuhr-Rundenreihenfolge per Test belegt"
requirement: "D-01"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx#quick-260921-iwr: Entfernen der mittleren Datei laesst genau die erste und dritte Datei uebrig..."
status: pass
- kind: unit
ref: "apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx#quick-260921-iwr: zwei Runden nacheinander..."
status: pass
human_judgment: false
- id: D2
description: "Drei bag.cert-Zusicherungen in cert-manager.service.ts zu echten Waechtern mit praeziser 400-Meldung gemacht (parseCert, mergeCerts, convertCert)"
requirement: "D-04"
verification:
- kind: unit
ref: "apps/api/src/cert-manager/cert-manager.service.spec.ts#PFX mit unlesbarem Zertifikats-Bag (bag.cert = null)"
status: pass
human_judgment: false
- id: D3
description: "Zwei ueberfluessige Ausrufezeichen in imap.provider.ts entfernt (uid ist imapflow-Pflichtfeld)"
requirement: "D-05"
verification:
- kind: other
ref: "cd apps/api && npx tsc --noEmit (exit 0)"
status: pass
human_judgment: false
- id: D4
description: "Biome-Zaehlbefehl bestaetigt TOTAL 434 -> 429, ARRAYKEY unveraendert 19, NONNULL 11 -> 6, ERRORS 0"
requirement: "D-06"
verification:
- kind: other
ref: "npx biome lint --reporter=json . | Zaehlbefehl aus planning_observations"
status: pass
human_judgment: false
duration: 20min
completed: 2026-09-21
status: complete
---
# Quick 260921-iwr: Listenschluessel per Positionsnummer und Ausrufezeichen-Zusicherungen Summary
**30 Fundstellen zweier Lint-Klassen einzeln beurteilt — 5 geaenderte Stellen (3 echte Waechter in cert-manager.service.ts, 2 ueberfluessige Zusicherungen in imap.provider.ts entfernt), 25 bewusst stehengelassen, zwei bislang nur behauptete Faelle jetzt per Test belegt.**
## Performance
- **Duration:** ~20 min
- **Tasks:** 3
- **Files modified:** 6 (5 geaendert, 1 neu angelegt)
- **Commits:** 4 (3 Task-Commits + 1 Nachbesserung, siehe Deviations)
## Accomplishments
- Alle 19 `noArrayIndexKey`-Fundstellen beurteilt: zehn Ladeplatzhalter mit fester Laenge, sechs zustandslose Meldungslisten in admin/ldap, eine Fundstelle (grants), bei der der Index sachlich notwendig ist, und zwei echte Faelle (MergeTab, Stoppuhr-Runden), die jetzt per Test statt nur per Behauptung abgesichert sind.
- Alle 11 `noNonNullAssertion`-Fundstellen beurteilt: drei sind durch node-forges eigenes Verhalten sachlich falsch (bag.cert kann null sein) und wurden zu echten Waechtern mit praeziser 400-Meldung; zwei sind schlicht ueberfluessig (imapflow typisiert `uid` als Pflichtfeld) und wurden entfernt; sechs sind durch eine konkrete Zeile oder einen strukturellen Fakt garantiert und bleiben unveraendert.
- Neue `MergeTab.test.tsx` belegt: Entfernen der mittleren sowie der ersten Datei aus einer Dreier- bzw. Zweierliste laesst die verbleibenden Dateien mit ihrem eigenen Namen und eigenen Entfernen-Knopf zurueck — kein Verwechseln durch den Positionsschluessel.
- Neuer Testfall in `stopwatch-widget.test.tsx` belegt: zwei Runden nacheinander zeigen die neuere Runde oben mit Rundennummer 2, die aeltere unten mit Rundennummer 1, jede Zeit bei ihrer eigenen Nummer.
- Sechs neue Testfaelle in `cert-manager.service.spec.ts`, RED zuerst geschrieben (heutige Meldungen bestaetigt), dann GREEN nach Einbau der drei Waechter — inklusive einer selbst mit `forge.asn1` gebauten 83-Byte-PFX-Datei mit unlesbarem Zertifikats-Bag und einer Vorbedingungspruefung, dass diese Datei tatsaechlich `bag.cert = null` erzeugt.
## Task Commits
Jede Aufgabe wurde atomar committet:
1. **Task 1: Die 19 Positionsschluessel beurteilen und die zwei nicht offensichtlichen Faelle nachmessen** - `8716fa5` (feat)
2. **Task 2: Die sechs Zusicherungen in cert-manager.service.ts beurteilen, drei davon zu echten Waechtern machen** - `b4aaed4` (fix, TDD: RED zuerst, dann GREEN)
3. **Task 3: Die restlichen fuenf Zusicherungen beurteilen, zwei ueberfluessige entfernen** - `27909e4` (refactor)
**Nachbesserung (Deviation, siehe unten):** `de69863` (fix) — behebt einen durch Task 2 selbst eingefuehrten neuen Lint-Fund, der die Endverifikation (TOTAL=429) verfehlt haette.
**Plan-Metadaten:** wird vom Orchestrator committet (SUMMARY.md, STATE.md, ROADMAP.md).
## Files Created/Modified
- `apps/api/src/cert-manager/cert-manager.service.ts` - drei bag.cert-Zusicherungen durch echte Waechter mit praeziser 400-Meldung ersetzt
- `apps/api/src/cert-manager/cert-manager.service.spec.ts` - neue Testgruppe fuer die malformte PFX (Vorbedingung + vier Pfade + Regressionstest fuer eine gueltige PFX)
- `apps/api/src/inbox/imap.provider.ts` - zwei ueberfluessige `msg.uid!` zu `msg.uid` vereinfacht
- `apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx` - wirkungslose eslint-disable-Zeile durch Sachhinweis ersetzt, kein Schluessel geaendert
- `apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx` - neuer Testfall fuer zwei aufeinanderfolgende Runden
- `apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx` - neu angelegt, zwei Testfaelle fuer das Entfernen aus der Dateiliste
## Die 30 Fundstellen — vollstaendiges Urteil
Urteil-Spalte: **Lesbarkeit** = geaendert (Meldung praezisiert bzw. ueberfluessige Zusicherung entfernt, Verhalten sonst unveraendert). **harmlos** = bewusst unveraendert stehengelassen, mit Begruendung.
### noArrayIndexKey (19 — alle unveraendert im Produktivcode, ARRAYKEY bleibt 19)
| # | Datei | Zeile | Regel | Urteil | Begruendung |
|---|-------|-------|-------|--------|--------------|
| 1 | InvoiceHistoryTable.tsx | 129 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:5})`, feste Laenge, keine Identitaet, Reihenfolge aendert sich nie |
| 2 | InvoiceHistoryTable.tsx | 131 | noArrayIndexKey | harmlos | Verschachtelter Platzhalter, `Array.from({length:6})`, gleiche Begruendung |
| 3 | VehicleTable.tsx | 342 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:3})`, feste Laenge |
| 4 | VehicleTable.tsx | 344 | noArrayIndexKey | harmlos | Verschachtelter Platzhalter, `Array.from({length:5})` |
| 5 | ResultsList.tsx | 240 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:5})` |
| 6 | ResultsList.tsx | 242 | noArrayIndexKey | harmlos | Verschachtelter Platzhalter, `Array.from({length:6})` |
| 7 | InboxConfigForm.tsx | 251 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:6})`, Formularfelder ohne Inhalt |
| 8 | EmailAlertConfigForm.tsx | 225 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:5})` |
| 9 | RssFeedListForm.tsx | 151 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:2})` |
| 10 | SourceConfigForm.tsx | 129 | noArrayIndexKey | harmlos | Ladeplatzhalter, `Array.from({length:2})` |
| 11 | admin/ldap/page.tsx | 1040 | noArrayIndexKey | harmlos | `nameCollisions`-Meldungsliste; `setGroupImportResult(data)` ersetzt das ganze Ergebnis auf einen Schlag, keine Zeile bleibt haengen |
| 12 | admin/ldap/page.tsx | 1049 | noArrayIndexKey | harmlos | `errors`-Meldungsliste im Gruppenimport, gleicher Ersetz-Mechanismus |
| 13 | admin/ldap/page.tsx | 1369 | noArrayIndexKey | harmlos | `emailConflicts`-Meldungsliste; `setSyncResult(null)` dann `setSyncResult(data)` — komplettes Ersetzen |
| 14 | admin/ldap/page.tsx | 1384 | noArrayIndexKey | harmlos | `skippedNoLogin`-Meldungsliste, gleicher Mechanismus |
| 15 | admin/ldap/page.tsx | 1396 | noArrayIndexKey | harmlos | `entryFailures`-Meldungsliste, gleicher Mechanismus |
| 16 | admin/ldap/page.tsx | 1405 | noArrayIndexKey | harmlos | `errors`-Meldungsliste im Sync-Ergebnis, gleicher Mechanismus |
| 17 | admin/modules/grants/page.tsx | 246 | noArrayIndexKey | harmlos | `Fragment key={cat-${category}-${groupIndex}}` — die Gruppierung fasst nur unmittelbar aufeinanderfolgende Module gleicher Kategorie zusammen, dieselbe Kategorie kann also mehrfach vorkommen; der Positionsanteil ist notwendig, Entfernen wuerde doppelte Schluessel erzeugen. Die Haken selbst haengen an `cellKey(mod.id, g.id)`, nicht an diesem Schluessel |
| 18 | MergeTab.tsx | 87 | noArrayIndexKey | harmlos | `key={\`${f.name}-${i}\`}` — jetzt per Test belegt statt nur behauptet: Entfernen der mittleren/ersten Datei verwechselt keine Zeile (neue MergeTab.test.tsx) |
| 19 | stopwatch-widget.tsx | 276 | noArrayIndexKey | harmlos | `key={idx}` — Zeilen halten keinen eigenen Zustand, Rundennummer wird aus Laenge und Position berechnet; jetzt per neuem Testfall belegt (zwei Runden, richtige Zeit bei richtiger Nummer). Wirkungslose eslint-disable-Zeile ersetzt durch Sachhinweis, keine neue Unterdrueckung |
**Korrektur zur Planvorgabe:** Die planning_observations sprechen von "zwoelf" Ladeplatzhaltern in Gruppe (a); die dort selbst aufgelistete Datei/Zeile-Aufstellung nennt aber nur zehn Stellen (InvoiceHistoryTable x2, VehicleTable x2, ResultsList x2, InboxConfigForm x1, EmailAlertConfigForm x1, RssFeedListForm x1, SourceConfigForm x1 = 10). Zusammen mit den sechs admin/ldap-Meldungslisten, der einen grants-Stelle und den zwei echten Faellen (MergeTab, Stoppuhr) ergeben sich 10+6+1+2 = 19 — die Gesamtzahl stimmt, nur die Zwischensumme der Gruppe (a) war im Plantext falsch benannt. Kein Code-Fund betroffen, reine Dokumentationskorrektur.
### noNonNullAssertion (11 — 5 geaendert, 6 unveraendert, NONNULL 11 -> 6)
| # | Datei | Zeile | Regel | Urteil | Begruendung |
|---|-------|-------|-------|--------|--------------|
| 20 | cert-manager.service.ts | 133 | noNonNullAssertion | harmlos | Zerlegung des Fingerabdruck-Hex in Zweiergruppen — SHA-1/SHA-256-Hex ist immer 40/64 Zeichen lang, `match(/.{2}/g)` findet garantiert etwas. Durch die Hash-Laenge garantiert, nicht durch Eingabedaten |
| 21 | cert-manager.service.ts | 207 | noNonNullAssertion | **Lesbarkeit** | `bags[0].cert!` in parseCert — node-forge kann bag.cert auf null setzen (lib/pkcs12.js Zeile 703-709), die Zusicherung war sachlich falsch. Folge war aber bereits behandelt (400, nie 500). Jetzt echter Waechter: praezise BadRequestException statt der irrefuehrenden "Failed to extract certificate details" |
| 22 | cert-manager.service.ts | 469 | noNonNullAssertion | **Lesbarkeit** | `bags.map((bag) => bag.cert!)` in mergeCerts — gleicher Grund. Jetzt: alle Bag-Zertifikate werden auf Vollstaendigkeit geprueft (kein Filtern, kein Ueberspringen, D-04), bei fehlendem Zertifikat wirft der Guard unter Nennung des Dateinamens, sonst wird die Liste ueber ein Typpraedikat zurueckgegeben |
| 23 | cert-manager.service.ts | 516 | noNonNullAssertion | harmlos | Kennwort beim PFX-Erzeugen in mergeCerts — durch die vorgelagerte Pruefung in Zeile 438-440 garantiert (bricht bei fehlendem/leerem Kennwort vorher mit 400 ab) |
| 24 | cert-manager.service.ts | 602 | noNonNullAssertion | **Lesbarkeit** | `bags[0].cert!` in convertCert — gleicher Grund wie #21. Jetzt echter Waechter mit praeziser Meldung statt der irrefuehrenden "Failed to convert certificate to pem: serialization error" |
| 25 | cert-manager.service.ts | 661 | noNonNullAssertion | harmlos | Kennwort beim PFX-Erzeugen in convertCert — durch die vorgelagerte Pruefung in Zeile 564-566 garantiert |
| 26 | imap.provider.ts | 214 | noNonNullAssertion | **Lesbarkeit** | `msg.uid!` — imapflow typisiert `uid` in `FetchMessageObject` als Pflichtfeld (lib/imap-flow.d.ts Zeile 469, "Always included in the response"). Zusicherung war ueberfluessig, entfernt (nur `!` weg, sonst nichts). tsc bleibt gruen |
| 27 | imap.provider.ts | 310 | noNonNullAssertion | **Lesbarkeit** | `msg.uid!` — gleicher Grund wie #26 |
| 28 | tenders.controller.ts | 244 | noNonNullAssertion | harmlos | `this.tenderIngestionService!.pollDueSources()` — das Fragezeichen im Konstruktor dient nur der Argumentreihenfolge; `TenderIngestionService` steht in tenders.module.ts unter providers, wird von Nest also immer eingesetzt. Durch den Provider-Eintrag garantiert |
| 29 | favorites-widget.tsx | 149 | noNonNullAssertion | harmlos | `byId.get(fid)!` — die Reihenfolge stammt aus `sortedFavorites` (Zeile 90-97, sortierte Kopie von `favorites`), die Zuordnungstabelle wird aus denselben Eintraegen gebaut. Durch die gemeinsame Herleitung garantiert |
| 30 | sidebar.tsx | 80 | noNonNullAssertion | harmlos | `categories.get(cat)!.push(mod)` — Zeile 79 legt den Eintrag an, falls er fehlt. Durch die Zeile unmittelbar davor garantiert |
## Vorher-Nachher-Zahlen (Zaehlbefehl aus den planning_observations, Feld `category`, nie Quelltext)
| Zaehlwert | Vorher | Nachher | Erwartung laut Plan | Ergebnis |
|-----------|--------|---------|----------------------|----------|
| TOTAL | 434 | 429 | 429 | ✓ (faellt um genau 5, die Zahl der geaenderten Stellen) |
| ERRORS | 0 | 0 | 0 | ✓ |
| ARRAYKEY | 19 | 19 | 19 | ✓ unveraendert |
| NONNULL | 11 | 6 | 6 | ✓ (5 geaendert: 3 cert-manager + 2 imap) |
## Verhaltensaenderung (vorab benannt, D-02)
Die einzige Verhaltensaenderung betrifft eine Eingabeklasse: eine PFX-Datei, deren Zertifikats-Bag wohlgeformt, aber nicht als X.509 lesbar ist. Vorher und nachher antworten alle vier betroffenen Pfade mit **Status 400** — das aendert sich nicht. Was sich aendert, ist ausschliesslich der **Text** der Fehlermeldung:
| Pfad | Meldung vorher | Meldung nachher |
|------|-----------------|-------------------|
| parseCert | "Failed to extract certificate details" | "Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate" |
| mergeCerts (pem) | "Failed to create merged certificate output" | "Certificate bag in \"<Dateiname>\" does not contain a readable X.509 certificate" |
| mergeCerts (pfx) | "Failed to create merged certificate output" | dieselbe praezise Meldung wie oben |
| convertCert | "Failed to convert certificate to pem: serialization error" | "Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate" |
Das ist **Diagnose-/Lesbarkeitsarbeit, keine Fehlerbehebung**: node-forge wirft bei einem fehlenden Zertifikat in `certificateToPem` und `toPkcs12Asn1` ausnahmslos, ein still falsches Zertifikat war schon vorher ausgeschlossen. Die drei Zusicherungen waren sachlich falsch, aber ihre Folge war bereits sicher behandelt.
## Decisions Made
- Die drei bag.cert-Zusicherungen wurden als "keine Verfuegbarkeits- und keine Integritaetsluecke" eingestuft, nachdem die gemessene Tabelle (400 statt 500 in allen vier Pfaden) und die node-forge-Pruefung (certificateToPem(null)/toPkcs12Asn1(null,...) werfen beide) das belegten. Der Eingriff blieb deshalb auf die Meldung beschraenkt, keine Statuscode- oder Fehlerbehandlungs-Aenderung.
- In mergeCerts wurde bewusst kein Filtern/Ueberspringen fehlender Zertifikate implementiert (D-04): stattdessen prueft der Guard alle Bags auf Vollstaendigkeit und wirft bei der ersten Luecke unter Nennung des Dateinamens, erst danach wird die Liste ueber ein Typpraedikat auf den non-null-Typ eingeengt.
- Fuer Task 1 wurde keine der 19 Fundstellen im Produktivcode geaendert — stattdessen wurden die zwei einzigen tatsaechlich dynamischen Listen (MergeTab, Stoppuhr) per neuem Test statt nur per Behauptung abgesichert, wie von der Aufgabe gefordert.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] mergeCerts-Waechter loeste einen zusaetzlichen Biome-Lint-Fund aus**
- **Found during:** Endverifikation nach Task 3 (Schritt "1. `npx biome lint`" aus dem `<verification>`-Block des Plans)
- **Issue:** Die erste Fassung des Guards in mergeCerts (`bagCerts.findIndex((c) => c === null)`) erzeugte einen neuen `lint/complexity/useIndexOf`-Fund (info-Severity), der TOTAL auf 430 statt der erwarteten 429 anhob — ein Verstoss gegen D-06 ("TOTAL steigt nirgends anders an"). Ein direkter Fix mit `indexOf(null)` scheiterte an `tsc`, weil `@types/node-forge` `Bag.cert` als `Certificate | undefined` deklariert (nicht `| null`), obwohl die node-forge-Laufzeit selbst `null` setzt.
- **Fix:** Die Pruefung testet jetzt ausdruecklich auf `undefined` UND `null` (`c === undefined || c === null`) — das ist fuer Biome kein Single-Value-Vergleich mehr (kein useIndexOf-Vorschlag) und fuer TypeScript typsicher.
- **Files modified:** apps/api/src/cert-manager/cert-manager.service.ts
- **Verification:** `npx biome lint --reporter=json .` liefert danach TOTAL=429 ERRORS=0 ARRAYKEY=19 NONNULL=6; `tsc --noEmit` exit 0; volle api-Suite weiterhin 72/1143 gruen.
- **Committed in:** de69863 (eigener Fix-Commit, da Task 2 bereits committet war, als die Endverifikation dies aufdeckte)
---
**Total deviations:** 1 auto-fixed (Rule 1 - Bug, durch die eigene Aenderung eingefuehrt und in der Endverifikation entdeckt)
**Impact on plan:** Kein Scope-Creep; die Korrektur war notwendig, um D-06 exakt zu erfuellen (TOTAL=429, nicht 430).
## Issues Encountered
Keine ungeloesten Probleme. Die einzige Ueberraschung war die oben dokumentierte Deviation — sie wurde durch den letzten Verifikationsschritt selbst gefangen, bevor sie zur Endabgabe kam.
## Auth Gates
Keine — dieser Vorgang hatte keine Authentifizierungs-Interaktion.
## Known Stubs
Keine.
## Threat Flags
Keine neue sicherheitsrelevante Oberflaeche eingefuehrt. Die drei Waechter in cert-manager.service.ts verschaerfen die Fehlerbehandlung an einer bestehenden Vertrauensgrenze (Datei-Upload, T-iwr-02/T-iwr-03 aus dem Plan-Threat-Model), ohne neue Endpunkte, Auth-Pfade oder Schema-Aenderungen einzufuehren.
## User Setup Required
None - keine externe Dienstkonfiguration erforderlich.
## Next Phase Readiness
Die letzte Fehlerklasse vor den beiden neuen Dashboard-Widgets ist abgeschlossen: Lint-Rueckstand bei TOTAL=429, ERRORS=0. Keine Blocker fuer die naechsten geplanten Schritte (zwei neue Widgets, laut STATE.md).
## Self-Check
- FOUND: apps/api/src/cert-manager/cert-manager.service.ts (geaendert)
- FOUND: apps/api/src/cert-manager/cert-manager.service.spec.ts (geaendert)
- FOUND: apps/api/src/inbox/imap.provider.ts (geaendert)
- FOUND: apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx (geaendert)
- FOUND: apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx (geaendert)
- FOUND: apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx (neu)
- FOUND: Commit 8716fa5 (git log --oneline --all)
- FOUND: Commit b4aaed4 (git log --oneline --all)
- FOUND: Commit 27909e4 (git log --oneline --all)
- FOUND: Commit de69863 (git log --oneline --all)
## Self-Check: PASSED
---
*Phase: quick-260921-iwr*
*Completed: 2026-09-21*
@@ -526,3 +526,253 @@ describe('convertCert', () => {
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
}); });
// ---------------------------------------------------------------------------
// PFX mit unlesbarem Zertifikats-Bag — bag.cert = null (quick-260921-iwr, D-01/D-04)
//
// node-forge setzt bag.cert bei einem wohlgeformten, aber nicht als X.509
// lesbaren Zertifikats-Bag auf null (lib/pkcs12.js Zeile 703-709: der Fehler
// aus certificateFromAsn1 wird abgefangen und durch bag.cert = null ersetzt).
// Die drei betroffenen Zusicherungen (parseCert Zeile 207, mergeCerts Zeile
// 469, convertCert Zeile 602) behaupten also etwas, das node-forge selbst
// widerlegt. Alle vier Pfade antworteten schon vorher mit 400 statt 500 — die
// hier gepruefte Aenderung ist die Meldung, nicht der Statuscode (D-02).
// ---------------------------------------------------------------------------
describe('PFX mit unlesbarem Zertifikats-Bag (bag.cert = null)', () => {
let malformedPfxBuffer: Buffer;
beforeAll(() => {
// Handgebaute ~83-Byte-PFX-Datei mit forge.asn1: wohlgeformte DER-Struktur
// von aussen nach innen (PFX -> ContentInfo -> AuthenticatedSafe ->
// ContentInfo -> SafeContents -> SafeBag -> CertBag), deren
// Zertifikats-Bag-Inhalt aber nur "SEQUENCE { INTEGER 1 }" ist — kein
// X.509-Zertifikat. Passwort ist die leere Zeichenkette.
const fakeCertAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false, String.fromCharCode(1))],
);
const fakeCertDer = forge.asn1.toDer(fakeCertAsn1).getBytes();
const certBagAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[
forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.OID,
false,
forge.asn1.oidToDer(forge.pki.oids.x509Certificate).getBytes(),
),
forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false, fakeCertDer),
]),
],
);
const safeBagAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[
forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.OID,
false,
forge.asn1.oidToDer(forge.pki.oids.certBag).getBytes(),
),
forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [certBagAsn1]),
],
);
const safeContentsAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[safeBagAsn1],
);
const safeContentsDer = forge.asn1.toDer(safeContentsAsn1).getBytes();
const innerContentInfoAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[
forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.OID,
false,
forge.asn1.oidToDer(forge.pki.oids.data).getBytes(),
),
forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false, safeContentsDer),
]),
],
);
const authSafeAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[innerContentInfoAsn1],
);
const authSafeDer = forge.asn1.toDer(authSafeAsn1).getBytes();
const outerContentInfoAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[
forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.OID,
false,
forge.asn1.oidToDer(forge.pki.oids.data).getBytes(),
),
forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false, authSafeDer),
]),
],
);
const pfxAsn1 = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SEQUENCE,
true,
[
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false, String.fromCharCode(3)),
outerContentInfoAsn1,
],
);
const pfxDer = forge.asn1.toDer(pfxAsn1).getBytes();
malformedPfxBuffer = Buffer.from(pfxDer, 'binary');
});
it('Vorbedingung: forge liefert genau einen Bag mit cert=null fuer diese Datei (belegt, dass der Waechter den gemeinten Fall trifft)', () => {
expect(malformedPfxBuffer.length).toBe(83);
const p12Asn1 = forge.asn1.fromDer(
forge.util.createBuffer(malformedPfxBuffer.toString('binary')),
);
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
expect(bags).toHaveLength(1);
expect(bags[0].cert).toBeNull();
});
it('parseCert: wirft 400 mit einer Meldung, die den Zertifikats-Bag benennt, statt der irrefuehrenden "Failed to extract certificate details"', async () => {
const service = new CertManagerService();
await expect(
service.parseCert({
file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer },
password: '',
}),
).rejects.toThrow(BadRequestException);
await expect(
service.parseCert({
file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer },
password: '',
}),
).rejects.toThrow(/certificate bag/i);
});
it('mergeCerts (outputFormat pem): wirft 400 mit derselben praezisen Aussage statt "Failed to create merged certificate output"', async () => {
const service = new CertManagerService();
await expect(
service.mergeCerts({
files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }],
outputFormat: 'pem',
password: '',
}),
).rejects.toThrow(BadRequestException);
await expect(
service.mergeCerts({
files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }],
outputFormat: 'pem',
password: '',
}),
).rejects.toThrow(/certificate bag/i);
});
it('mergeCerts (outputFormat pfx): wirft 400 mit derselben praezisen Aussage statt "Failed to create merged certificate output"', async () => {
const service = new CertManagerService();
await expect(
service.mergeCerts({
files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }],
outputFormat: 'pfx',
password: 'secret',
}),
).rejects.toThrow(BadRequestException);
await expect(
service.mergeCerts({
files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }],
outputFormat: 'pfx',
password: 'secret',
}),
).rejects.toThrow(/certificate bag/i);
});
it('convertCert: wirft 400 mit einer Meldung, die den Zertifikats-Bag benennt, statt der irrefuehrenden "Failed to convert certificate to pem: serialization error"', async () => {
const service = new CertManagerService();
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
service.convertCert({
file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer },
password: '',
targetFormat: 'pem',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
).rejects.toThrow(BadRequestException);
await expect(
service.convertCert({
file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer },
password: '',
targetFormat: 'pem',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
).rejects.toThrow(/certificate bag/i);
});
it('eine gueltige PFX-Datei verhaelt sich unveraendert: parseCert liefert weiterhin die CertDetails', async () => {
const service = new CertManagerService();
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [{ name: 'commonName', value: 'valid-pfx.example.com' }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.sign(keys.privateKey, forge.md.sha256.create());
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], 'secret', {
algorithm: '3des',
});
const validPfxBuffer = Buffer.from(forge.asn1.toDer(p12Asn1).getBytes(), 'binary');
const result = await (service.parseCert({
file: { originalname: 'valid.pfx', buffer: validPfxBuffer },
password: 'secret',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as Promise<any>);
expect(result.subject.cn).toBe('valid-pfx.example.com');
}, 15000);
});
@@ -204,7 +204,17 @@ export class CertManagerService {
if (bags.length === 0) { if (bags.length === 0) {
throw new Error('No certificate bag found in PFX/PKCS12'); throw new Error('No certificate bag found in PFX/PKCS12');
} }
cert = bags[0].cert!; // node-forge sets bag.cert to null when the bag's content parses as
// valid DER but is not a readable X.509 certificate (lib/pkcs12.js
// certBag decoder). An explicit guard here — not an assertion — so
// the 400 names the real cause (quick-260921-iwr, D-01/D-04).
const parsedCert = bags[0].cert;
if (!parsedCert) {
throw new BadRequestException(
'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate',
);
}
cert = parsedCert;
} else { } else {
// ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ──────── // ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ────────
const isPemP7b = (file.buffer as Buffer) const isPemP7b = (file.buffer as Buffer)
@@ -466,7 +476,22 @@ export class CertManagerService {
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? []; const bags = certBags[forge.pki.oids.certBag] ?? [];
return bags.map((bag) => bag.cert!); // node-forge sets bag.cert to null when a bag's content parses as
// valid DER but is not a readable X.509 certificate (lib/pkcs12.js
// certBag decoder). No entry may be silently dropped (D-04) — check
// every bag and reject the whole file, naming it, before returning.
const bagCerts = bags.map((bag) => bag.cert);
// @types/node-forge declares Bag.cert as `Certificate | undefined`,
// but node-forge's own runtime sets it to `null` for an unreadable
// bag (lib/pkcs12.js certBag decoder) — check both, not just `===
// undefined`, so the guard actually catches what the library does.
const missingCertIndex = bagCerts.findIndex((c) => c === undefined || c === null);
if (missingCertIndex !== -1) {
throw new BadRequestException(
`Certificate bag in "${file.originalname as string}" does not contain a readable X.509 certificate`,
);
}
return bagCerts.filter((c): c is forge.pki.Certificate => c !== undefined && c !== null);
} else { } else {
// P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4) // P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4)
const isPemP7b = (file.buffer as Buffer) const isPemP7b = (file.buffer as Buffer)
@@ -599,7 +624,17 @@ export class CertManagerService {
if (bags.length === 0) { if (bags.length === 0) {
throw new Error('No certificate bag found in PFX/PKCS12'); throw new Error('No certificate bag found in PFX/PKCS12');
} }
cert = bags[0].cert!; // node-forge sets bag.cert to null when the bag's content parses as
// valid DER but is not a readable X.509 certificate (lib/pkcs12.js
// certBag decoder). An explicit guard here — not an assertion — so
// the 400 names the real cause (quick-260921-iwr, D-01/D-04).
const parsedCert = bags[0].cert;
if (!parsedCert) {
throw new BadRequestException(
'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate',
);
}
cert = parsedCert;
} else { } else {
// P7B — extract first cert // P7B — extract first cert
const isPemP7b = (file.buffer as Buffer) const isPemP7b = (file.buffer as Buffer)
+2 -2
View File
@@ -211,7 +211,7 @@ export class ImapProvider implements InboxProvider {
if (attachments.length > 0) { if (attachments.length > 0) {
results.push({ results.push({
uid: msg.uid!, uid: msg.uid,
messageId: msg.envelope?.messageId ?? '', messageId: msg.envelope?.messageId ?? '',
subject: msg.envelope?.subject ?? '', subject: msg.envelope?.subject ?? '',
from: msg.envelope?.from?.[0]?.address ?? '', from: msg.envelope?.from?.[0]?.address ?? '',
@@ -307,7 +307,7 @@ export class ImapProvider implements InboxProvider {
} }
results.push({ results.push({
uid: msg.uid!, uid: msg.uid,
messageId: msg.envelope?.messageId ?? '', messageId: msg.envelope?.messageId ?? '',
subject: msg.envelope?.subject ?? '', subject: msg.envelope?.subject ?? '',
from: msg.envelope?.from?.[0]?.address ?? '', from: msg.envelope?.from?.[0]?.address ?? '',
@@ -0,0 +1,74 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — passthrough t(key) => key (same pattern as stopwatch-widget.test.tsx)
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
// Mock ../actions — MergeTab only needs mergeCertsAction and downloadBase64 to exist;
// this test never triggers the merge action itself, only file-list management.
vi.mock('../actions', () => ({
mergeCertsAction: vi.fn(),
downloadBase64: vi.fn(),
}));
import { MergeTab } from './MergeTab';
afterEach(() => {
vi.restoreAllMocks();
});
describe('MergeTab — Dateiliste', () => {
it('quick-260921-iwr: Entfernen der mittleren Datei laesst genau die erste und dritte Datei uebrig, in dieser Reihenfolge, mit ihren eigenen Namen (belegt den Positionsschluessel bei einer schrumpfenden Liste)', async () => {
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'erste.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'mittlere.pem', { type: 'application/x-pem-file' });
const file3 = new File(['cert3'], 'dritte.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2, file3] } });
// Alle drei Dateien sind zunaechst gelistet
expect(screen.getByText('erste.pem')).toBeInTheDocument();
expect(screen.getByText('mittlere.pem')).toBeInTheDocument();
expect(screen.getByText('dritte.pem')).toBeInTheDocument();
// Die mittlere Datei ueber ihren Entfernen-Knopf loeschen (aria-label enthaelt den Dateinamen)
const removeMiddleBtn = screen.getByLabelText('Remove mittlere.pem');
fireEvent.click(removeMiddleBtn);
// "mittlere.pem" ist verschwunden
expect(screen.queryByText('mittlere.pem')).not.toBeInTheDocument();
// Die erste und dritte Datei stehen weiterhin mit ihren eigenen Namen in der Liste,
// in dieser Reihenfolge — kein Verwechseln durch den Positionsschluessel.
const remainingNames = screen
.getAllByText(/\.pem$/)
.map((el) => el.textContent);
expect(remainingNames).toEqual(['erste.pem', 'dritte.pem']);
// Die dritte Datei behaelt ihren eigenen Entfernen-Knopf (eigener Name im aria-label,
// nicht der der geloeschten mittleren Datei).
expect(screen.getByLabelText('Remove erste.pem')).toBeInTheDocument();
expect(screen.getByLabelText('Remove dritte.pem')).toBeInTheDocument();
expect(screen.queryByLabelText('Remove mittlere.pem')).not.toBeInTheDocument();
});
it('quick-260921-iwr: Entfernen der ersten Datei laesst die zweite an ihrer eigenen Stelle mit eigenem Namen zurueck', async () => {
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'a.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'b.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
fireEvent.click(screen.getByLabelText('Remove a.pem'));
expect(screen.queryByText('a.pem')).not.toBeInTheDocument();
expect(screen.getByText('b.pem')).toBeInTheDocument();
expect(screen.getByLabelText('Remove b.pem')).toBeInTheDocument();
});
});
@@ -158,6 +158,43 @@ describe('StopwatchWidget', () => {
expect(lapItems.length).toBeGreaterThanOrEqual(1); expect(lapItems.length).toBeGreaterThanOrEqual(1);
}); });
it('quick-260921-iwr: zwei Runden nacheinander — neuere Runde steht oben, Rundennummern 2 und 1 stimmen zu ihrer eigenen Zeit (belegt den Positionsschluessel bei einer von vorn wachsenden Liste)', async () => {
render(<StopwatchWidget instanceId="sw-laps" config={{}} isEditMode={false} />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
});
await act(async () => {
vi.advanceTimersByTime(1000);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.lap/i }));
});
await act(async () => {
vi.advanceTimersByTime(1000);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.lap/i }));
});
const lapItems = screen.getAllByRole('listitem');
expect(lapItems).toHaveLength(2);
// Oben steht die neuere Runde (Nummer 2), unten die aeltere (Nummer 1) —
// die Liste waechst von vorn, newest-first.
expect(lapItems[0]?.textContent).toContain('Runde 2');
expect(lapItems[1]?.textContent).toContain('Runde 1');
// Runde 2 wurde spaeter aufgezeichnet und zeigt daher die laengere Zeit,
// Runde 1 die kuerzere — jede Zeit steht bei ihrer eigenen Nummer.
expect(lapItems[0]?.textContent).toContain('00:02');
expect(lapItems[1]?.textContent).toContain('00:01');
});
it('reload reconstruction: renders ~7000ms elapsed from config with startedAt 5s ago', async () => { it('reload reconstruction: renders ~7000ms elapsed from config with startedAt 5s ago', async () => {
// Fix system time so Date.now() is deterministic // Fix system time so Date.now() is deterministic
const fixedNow = new Date('2026-07-01T12:00:00.000Z'); const fixedNow = new Date('2026-07-01T12:00:00.000Z');
@@ -271,8 +271,10 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
<div className="mt-1 max-h-32 overflow-y-auto border-t border-border pt-2"> <div className="mt-1 max-h-32 overflow-y-auto border-t border-border pt-2">
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{sw.laps.map((lapMs, idx) => ( {sw.laps.map((lapMs, idx) => (
// Positionsschluessel ist hier vertretbar: die Zeilen halten keinen
// eigenen Zustand, und die angezeigte Rundennummer wird ohnehin aus
// Laenge und Position berechnet (quick-260921-iwr, D-01/D-06).
<li <li
// eslint-disable-next-line react/no-array-index-key
key={idx} key={idx}
className="flex justify-between px-1 py-0.5 text-xs text-muted-foreground" className="flex justify-between px-1 py-0.5 text-xs text-muted-foreground"
> >