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.
This commit is contained in:
parent
841b5f9d02
commit
169fd6884d
|
|
@ -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:<technische Setting-ID>`, 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. |
|
||||
|
|
|
|||
|
|
@ -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<AiOverviewResponse>();
|
||||
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RegulatoryMappingResponse>();
|
||||
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<string, RegulatoryMappingDecisionValue>();
|
||||
|
|
|
|||
|
|
@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(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 <output>{controller.response?.projectId ?? 'empty'}</output>;
|
||||
}
|
||||
|
||||
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<AiOverviewResponse>();
|
||||
const second = deferred<AiOverviewResponse>();
|
||||
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(<HookProbe projectId="project-a" />));
|
||||
await act(async () => root.render(<HookProbe projectId="project-b" />));
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(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 <>
|
||||
<button type="button" onClick={() => controller.setOpen(true)}>Open mapping</button>
|
||||
<output>{controller.response?.resultProjectionId ?? 'empty'}</output>
|
||||
</>;
|
||||
}
|
||||
|
||||
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<RegulatoryMappingResponse>();
|
||||
const second = deferred<RegulatoryMappingResponse>();
|
||||
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(<HookProbe occurrenceId="occurrence-a" />));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||||
await act(async () => root.render(<HookProbe occurrenceId="occurrence-b" />));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue