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
This commit is contained in:
+413
@@ -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,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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user