From 169fd6884d99d527c5a7df1b731d31297bd74892 Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 2 Oct 2026 11:31:25 +0200 Subject: [PATCH] fix: prevent stale AI overview and ISO mapping state Partial pick of 3f6259d: both hooks bind each response to its request scope and discard a late response for a previous selection. The Result window palette, MITRE path gradient removal and Defender styles of that commit are not taken. --- docs/IMPLEMENTATION_PLAN.md | 1 + src/features/dashboard/AiOverviewPanel.tsx | 39 ++++++++--- .../finding-reader/use-regulatory-mapping.ts | 52 +++++++++----- .../ai-overview-scope-interaction.test.tsx | 61 +++++++++++++++++ ...ulatory-mapping-scope-interaction.test.tsx | 67 +++++++++++++++++++ 5 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 tests/features/ai-overview-scope-interaction.test.tsx create mode 100644 tests/features/regulatory-mapping-scope-interaction.test.tsx diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 679e4f3..fda12ce 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4223,6 +4223,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client | 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. | | 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. | | 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. | +| 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. | | 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. | | 2026-10-02 | Phase 7: Privacy-Safe-`Export JSON` um GPO-Vergleiche ergaenzt. Handlungsrelevante `policy-evaluation`-Findings tragen einen `policy`-Block (Status, Baseline-Familie, Sollwert, Vergleich, Kundenzustand, Scope, Delivery, Diagnosecodes) ausschliesslich aus der bestehenden Result-DB-Disclosure-Projektion; der Kundenwert bleibt zurueckgehalten. Rohe `policy-observation`-Zeilen zaehlen nicht mehr als Findings, sondern separat als `policyObservationResults`; `policyStatuses` zaehlt die GPO-Urteile. Synthetischer Test in `tests/exports/result-db-copy.test.ts`. | | 2026-10-02 | Phase 7: AI-Integration auf den aktuellen Michael-Stand uebernommen (Branch `merge/michael-binh`). Der Privacy-Safe-`Export JSON` aus `result-db-copy` ergaenzt die Result DB als vierte Exportaktion; `Export JSONL`, `Export Excel` und `Export CSV` bleiben unveraendert. Die Einzelaktions-Festlegung vom 2026-09-14 gilt auf diesem Stand deshalb nicht als Ersatz des JSONL-Exports. Die Beispieldateien `test.json`, `test-output.json` und `artifacts/test-output-*` wurden nicht uebernommen. | diff --git a/src/features/dashboard/AiOverviewPanel.tsx b/src/features/dashboard/AiOverviewPanel.tsx index 34d630b..05dda5f 100644 --- a/src/features/dashboard/AiOverviewPanel.tsx +++ b/src/features/dashboard/AiOverviewPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import type { AiOverviewResponse, AiOverviewResult } from '../../domain/ai-overview'; import type { ReactNode } from 'react'; @@ -14,7 +14,7 @@ export interface AiOverviewController { } export function useAiOverview(projectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController { - const [response, setResponse] = useState(); + const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>(); const [loading, setLoading] = useState(false); const [generating, setGenerating] = useState(false); const [error, setError] = useState(); @@ -23,21 +23,35 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]); const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]); const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`; + const activeScope = useRef({ key: scopeKey }); + if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey }; + const requestScope = activeScope.current; + const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined; const refresh = useCallback(async () => { - if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) { setResponse(undefined); return; } + if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) { + if (activeScope.current === requestScope) setStoredResponse(undefined); + return; + } try { const next = await assessorApi.getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds); - setResponse(next); + if (activeScope.current !== requestScope) return; + setStoredResponse({ scope: requestScope, value: next }); setError(undefined); } catch (cause) { + if (activeScope.current !== requestScope) return; setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.'); - setResponse(current => current && ['queued', 'running'].includes(current.state) ? { ...current } : current); + setStoredResponse(current => current?.scope === requestScope && ['queued', 'running'].includes(current.value.state) + ? { ...current, value: { ...current.value } } + : current); } - }, [normalizedAssessmentIds, normalizedScanRunIds, projectId]); + }, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]); useEffect(() => { let active = true; + setStoredResponse(undefined); + setError(undefined); + setGenerating(false); setLoading(true); void refresh().finally(() => { if (active) setLoading(false); }); return () => { active = false; }; @@ -54,17 +68,20 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri setGenerating(true); setError(undefined); try { - setResponse(await assessorApi.generateAiOverview(projectId, { + const next = await assessorApi.generateAiOverview(projectId, { assessmentIds: normalizedAssessmentIds, scanRunIds: normalizedScanRunIds, idempotencyKey: createIdempotencyKey() - })); + }); + if (activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next }); } catch (cause) { - setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.'); + if (activeScope.current === requestScope) { + setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.'); + } } finally { - setGenerating(false); + if (activeScope.current === requestScope) setGenerating(false); } - }, [normalizedAssessmentIds, normalizedScanRunIds, projectId]); + }, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]); return { response, loading, generating, error, generate }; } diff --git a/src/features/finding-reader/use-regulatory-mapping.ts b/src/features/finding-reader/use-regulatory-mapping.ts index c74ffbc..0b03b4f 100644 --- a/src/features/finding-reader/use-regulatory-mapping.ts +++ b/src/features/finding-reader/use-regulatory-mapping.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { BackendStatus } from '../../app/app-state'; import type { FindingRecord } from '../../domain/finding'; import { AssessorApiClient } from '../../api/assessor-api-client'; @@ -17,29 +17,37 @@ export function useRegulatoryMapping( backendStatus: BackendStatus ) { const [open, setOpen] = useState(false); - const [response, setResponse] = useState(); + const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`; + const activeScope = useRef({ key: scopeKey }); + if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey }; + const requestScope = activeScope.current; + const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined; useEffect(() => { setOpen(false); - setResponse(undefined); + setStoredResponse(undefined); setLoading(false); setError(''); - }, [projectId, resultProjectionId]); + }, [scopeKey]); const load = useCallback(async () => { - if (!projectId || !resultProjectionId || backendStatus !== 'connected') return; + if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return; setLoading(true); try { - setResponse(await assessorApi.getRegulatoryMappings(projectId, resultProjectionId)); + const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId); + if (activeScope.current !== requestScope) return; + setStoredResponse({ scope: requestScope, value: next }); setError(''); } catch (reason) { + if (activeScope.current !== requestScope) return; setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.'); } finally { - setLoading(false); + if (activeScope.current === requestScope) setLoading(false); } - }, [backendStatus, projectId, resultProjectionId]); + }, [backendStatus, projectId, requestScope, resultProjectionId]); useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]); useEffect(() => { @@ -49,33 +57,43 @@ export function useRegulatoryMapping( }, [load, open, response]); const start = useCallback(async () => { - if (!projectId || !resultProjectionId) return; + if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return; setLoading(true); try { - setResponse(await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId })); + const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId }); + if (activeScope.current !== requestScope) return; + setStoredResponse({ scope: requestScope, value: next }); setError(''); } catch (reason) { + if (activeScope.current !== requestScope) return; setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.'); - } finally { setLoading(false); } - }, [projectId, resultProjectionId]); + } finally { + if (activeScope.current === requestScope) setLoading(false); + } + }, [projectId, requestScope, resultProjectionId]); const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => { - if (!projectId || !resultProjectionId || !response?.run) return; + if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return; const latest = response.decisions.find(item => item.controlId === candidate.controlId); setLoading(true); try { - setResponse(await assessorApi.saveRegulatoryMappingDecision(projectId, { + const next = await assessorApi.saveRegulatoryMappingDecision(projectId, { resultProjectionId, controlId: candidate.controlId, decision, ...(comment?.trim() ? { comment: comment.trim() } : {}), ...(latest ? { expectedLatestDecisionId: latest.id } : {}) - })); + }); + if (activeScope.current !== requestScope) return; + setStoredResponse({ scope: requestScope, value: next }); setError(''); } catch (reason) { + if (activeScope.current !== requestScope) return; setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.'); - } finally { setLoading(false); } - }, [projectId, response, resultProjectionId]); + } finally { + if (activeScope.current === requestScope) setLoading(false); + } + }, [projectId, requestScope, response, resultProjectionId]); const acceptedCount = useMemo(() => { const latest = new Map(); diff --git a/tests/features/ai-overview-scope-interaction.test.tsx b/tests/features/ai-overview-scope-interaction.test.tsx new file mode 100644 index 0000000..d3bf15d --- /dev/null +++ b/tests/features/ai-overview-scope-interaction.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import type { AiOverviewResponse } from '../../src/domain/ai-overview'; +import { useAiOverview } from '../../src/features/dashboard/AiOverviewPanel'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(next => { resolve = next; }); + return { promise, resolve }; +} + +function response(projectId: string): AiOverviewResponse { + return { + version: 1, + projectId, + scope: { assessmentIds: [`assessment-${projectId}`], scanRunIds: [] }, + generationEnabled: true, + state: 'empty', + stale: false + }; +} + +function HookProbe({ projectId }: { projectId: string }): ReactNode { + const controller = useAiOverview(projectId, [`assessment-${projectId}`]); + return {controller.response?.projectId ?? 'empty'}; +} + +it('does not show a previous project response when it resolves after the active project', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const first = deferred(); + const second = deferred(); + const request = vi.spyOn(AssessorApiClient.prototype, 'getAiOverview').mockImplementation(projectId => + projectId === 'project-a' ? first.promise : second.promise); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => root.render()); + await act(async () => root.render()); + expect(request).toHaveBeenCalledTimes(2); + + await act(async () => { second.resolve(response('project-b')); await Promise.resolve(); }); + expect(container.textContent).toBe('project-b'); + + await act(async () => { first.resolve(response('project-a')); await Promise.resolve(); }); + expect(container.textContent).toBe('project-b'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); diff --git a/tests/features/regulatory-mapping-scope-interaction.test.tsx b/tests/features/regulatory-mapping-scope-interaction.test.tsx new file mode 100644 index 0000000..49927c3 --- /dev/null +++ b/tests/features/regulatory-mapping-scope-interaction.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import type { RegulatoryMappingResponse } from '../../src/domain/regulatory-mapping'; +import { useRegulatoryMapping } from '../../src/features/finding-reader/use-regulatory-mapping'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(next => { resolve = next; }); + return { promise, resolve }; +} + +function response(projectId: string, occurrenceId: string): RegulatoryMappingResponse { + return { + version: 1, + projectId, + resultProjectionId: `scanner-finding:${occurrenceId}`, + generationEnabled: true, + state: 'empty', + decisions: [] + }; +} + +function HookProbe({ occurrenceId }: { occurrenceId: string }): ReactNode { + const controller = useRegulatoryMapping('project-1', `scanner-finding:${occurrenceId}`, 'connected'); + return <> + + {controller.response?.resultProjectionId ?? 'empty'} + ; +} + +it('does not show a previous finding mapping when it resolves after the active finding', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const first = deferred(); + const second = deferred(); + const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation((_projectId, resultProjectionId) => + resultProjectionId === 'scanner-finding:occurrence-a' ? first.promise : second.promise); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => root.render()); + await act(async () => container.querySelector('button')!.click()); + await act(async () => root.render()); + await act(async () => container.querySelector('button')!.click()); + expect(request).toHaveBeenCalledTimes(2); + + await act(async () => { second.resolve(response('project-1', 'occurrence-b')); await Promise.resolve(); }); + expect(container.textContent).toContain('scanner-finding:occurrence-b'); + + await act(async () => { first.resolve(response('project-1', 'occurrence-a')); await Promise.resolve(); }); + expect(container.textContent).toContain('scanner-finding:occurrence-b'); + expect(container.textContent).not.toContain('scanner-finding:occurrence-a'); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +});