diff --git a/.planning/quick/260921-iwr-listenschluessel-per-positionsnummer-und/260921-iwr-PLAN.md b/.planning/quick/260921-iwr-listenschluessel-per-positionsnummer-und/260921-iwr-PLAN.md new file mode 100644 index 0000000..24d2aa7 --- /dev/null +++ b/.planning/quick/260921-iwr-listenschluessel-per-positionsnummer-und/260921-iwr-PLAN.md @@ -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." +--- + + +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. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.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 + + + +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`. + + + + + + Task 1: Die 19 Positionsschluessel beurteilen und die zwei nicht offensichtlichen Faelle nachmessen + + 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 + + + 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 + + + - 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. + + + 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. + + + cd apps/web && npx vitest run src/app/\(portal\)/modules/cert-manager/components/MergeTab.test.tsx src/components/dashboard/widgets/stopwatch-widget.test.tsx + cd apps/web && npx vitest run 2>&1 | tail -4 # erwartet: mindestens 69 Dateien, mindestens 484 Tests, alle gruen + 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 + + + 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. + + + + + Task 2: Die sechs Zusicherungen in cert-manager.service.ts beurteilen, drei davon zu echten Waechtern machen + + apps/api/src/cert-manager/cert-manager.service.ts, + apps/api/src/cert-manager/cert-manager.service.spec.ts + + + apps/api/src/cert-manager/cert-manager.service.ts, + apps/api/src/cert-manager/cert-manager.service.spec.ts + + node_modules ist installiert und node-forge 1.4.0 aufloesbar (die Tests bauen die Pruefdatei mit forge.asn1 selbst). + + 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. + + + 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). + + + cd apps/api && npx vitest run src/cert-manager/cert-manager.service.spec.ts + cd apps/api && npx vitest run 2>&1 | tail -4 # erwartet: mindestens 72 Dateien, mindestens 1141 Tests, alle gruen + 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 + + + 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. + + + + + Task 3: Die restlichen fuenf Zusicherungen beurteilen, zwei ueberfluessige entfernen + apps/api/src/inbox/imap.provider.ts + + 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 + + + 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). + + + cd apps/api && npx tsc --noEmit; echo "tsc=$?" # erwartet: tsc=0 + cd apps/api && npx vitest run 2>&1 | tail -4 # erwartet: mindestens 72 Dateien, mindestens 1141 Tests, alle gruen + 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 + + + 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. + + + + + + +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. | + + + +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. + + + +- 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). + + + +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:]]'`. + diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx new file mode 100644 index 0000000..bdc4e35 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx @@ -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(); + + 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(); + + 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(); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx index 4a566be..91484ba 100644 --- a/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx @@ -158,6 +158,43 @@ describe('StopwatchWidget', () => { 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(); + + 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 () => { // Fix system time so Date.now() is deterministic const fixedNow = new Date('2026-07-01T12:00:00.000Z'); diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx index e928c37..f723593 100644 --- a/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx @@ -271,8 +271,10 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
    {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).