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).
+
+
+
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).
-