fix: preserve risk evidence, ISO mappings, and AI overviews

This commit is contained in:
Binh 2026-09-23 09:05:06 +02:00
parent 78ea9a616d
commit 95ec32250f
11 changed files with 148 additions and 18 deletions

View File

@ -2,6 +2,8 @@
Status: lebendes Projektdokument
Review remediation 2026-09-23 (Phase 7 and Phase 13D, verified): Defender device-risk projection now preserves the exact source-occurrence/device relationship when separate occurrences in one run share a CVE; a synthetic repository acceptance test rejects a device absent from the selected occurrence. Saved regulatory mappings remain readable when the retrieval index is unavailable. A failed AI Overview regeneration reports the failed attempt while displaying the last successful overview. Existing risk decisions, scanner data, scoring, mapping decisions and database schema are unchanged; no reimport or migration is required. The remaining historical risk is that a previously recorded device-finding acceptance from a same-CVE multi-occurrence run may contain incorrect captured provenance and requires review before reliance. Focused tests pass 22/22 plus repository acceptance 16/16; the full standard suite passes 1,576 tests with 16 opt-in tests skipped, both production builds and typechecks pass, and local-only/design checks pass. Phases 7 and 13D remain in progress.
Defender-device-scoped risk acceptance 2026-09-21 (Phases 7 and 9-11 extension with Phase 13 validation, explicit user request): Existing whole-finding acceptance remains unchanged. Work can record one CVE for one exact Defender device; Defender Device view can record one immutable snapshot of every detailed actionable CVE in the selected active scan-run scope. The shared platform-neutral projection uses the existing Defender identity model, rejects unresolved or ambiguous identities, retains canonical definition/CVE and exact occurrence provenance, and carries coverage forward only for the same project, exact device key and captured definition. Independent whole-finding, device-finding and device-snapshot decisions overlap without revoking each other. Migration 028, repository/API parity, project export/load, optimistic revision checks and the Accepted Risks register are implemented. The canonical PostgreSQL datastore was backed up and verified before migration; 028 is recorded and a synthetic PostgreSQL accept/conflict/revoke/export/cascade test passed with cleanup. Verification: 287 files and 1,567 tests passed with six files and 16 opt-in tests skipped; client/server TypeScript checks, local-only and design-language checks, both production builds and the dedicated canonical PostgreSQL integration passed. Phase 14 authentication, authorization, signing, evidence verification and retention remain explicitly unclaimed.
Wiederholbarer ISO-Mappingvergleich 2026-09-18 (Phase 13D, ausdruecklicher Nutzerauftrag): Ein read-only Evaluationsskript vergleicht abgeschlossene PostgreSQL-Mappinglaeufe fuer PingCastle und Purple Knight mit einer versionierten manuellen Baseline und erzeugt eine reviewfaehige Excel-Datei mit Summary, Findingvergleich, Beziehungsdetails und Methodik. Ohne Parameter erkennt es die aktuelle Retrievalversion aus dem Produktivcode und waehlt die neueste vorherige Version mit vollstaendiger Scannerabdeckung; unvollstaendige neue Laeufe stoppen explizit. Die Baseline enthaelt ausschliesslich oeffentliche Rule-IDs und manuell bewertete Controlrollen, erlaubt null Primaercontrol und ist bewusst Evaluationsmaterial statt CI-Sollvertrag oder Produktmapping. Dauerhafte Tests verwenden nur synthetische Kontrollkennungen und pruefen Validierung, Retrieval-/Auswahl-/Rollen-/Extra-Trennung sowie Aenderungsklassifikation. Der erzeugte Smoke-Workbook bestand Import, Strukturscan, Formelpruefung und visuelle Kontrolle aller vier Blaetter. Beide Produktionsbuilds erfolgreich; der fokussierte neue Test besteht 3/3. Der Gesamtlauf erreicht 1.331 bestandene Tests und zehn opt-in-Skips, wird aber reproduzierbar durch den fachfremden vorhandenen 5-Sekunden-Timeout in `sheet-selection-cost.test.tsx` beendet; derselbe Test besteht isoliert in 0,9 Sekunden. Keine PostgreSQL-/Qdrant-Mutation, keine Mappingannahme, kein Manifestbump und kein Reimport.
@ -2658,9 +2660,12 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
| 2026-08-04 | Phase-13B-Schritt-9/11 um den freigegebenen Policy-Evaluation-Regelkatalog nachgehaertet. Der implizite Equality-Fallback ist entfernt; nur eine exakte Policy-/Delivery-/Produkt-/Versions-/Rollen-/Scope-Regel darf Comparator und Risikoregel aktivieren. Ausdrueckliche Empfehlung, ausdrueckliche Nichtanwendbarkeit, keine Empfehlung und Katalogluecke werden getrennt gespeichert. Prerequisite-, Mutual-Exclusion- und Override-Regeln sind versioniert und konservativ an Snapshotvollstaendigkeit gebunden. Migration 016 persistiert Katalog-, Baselinezustands-, Regel-, Dependency- und Risikoprovenienz relational; die read-only Referenzdaten-API zeigt den aktiven Katalog. Der produktive Katalog 1.0.0 ist bis fachlicher Regelkuratierung bewusst leer und damit review-only. Synthetische Tests pruefen Minimum, Katalogluecke, Nichtanwendbarkeit, keine Empfehlung, Prerequisite, Mutual Exclusion und Override. `npm test`: 573 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Produktionsbuild erfolgreich. Die komplette PostgreSQL-Suite lief gegen eine frische PostgreSQL-16-Instanz mit 8 von 8 Tests erfolgreich; Migrationen 001 bis 016 und die relationalen Sicherheitsgrenzen wurden verifiziert, Container und synthetische Testdaten entfernt. Finding Factory, Unified-Result-Projektion, UI und reale Regelkuratierung bleiben offen; Phase 13B bleibt in Arbeit. |
| Ein neuer Profilstand könnte ohne Manifestregistrierung importiert werden, kompatible Darstellungsänderungen könnten fälschlich semantische Vergleiche sperren oder semantische Änderungen könnten alte und neue Findings als gleich behandeln. Fehlende Scanneroriginale könnten außerdem einen behaupteten Reimport unmöglich machen. | Neue Verträge fail closed registrieren, Änderungsklasse und betroffene Dimensionen explizit testen, Timeslot- und Vergleichsgrenzen auf den kanonischen Kompatibilitätsschlüssel stützen und Reprocessing ausschließlich aus hashgeprüften Originalbytes erlauben. Bei `not-retained` ist ein erneuter Upload erforderlich. |
| Werkzeugerkennung und gespeichertes Verarbeitungsprofil koennen unterschiedliche IDs tragen; wenn die Werkzeug-ID den Profilvertrag ueberschreibt, erscheint ein registrierter Defender-Import faelschlich als unbekannt. | Kompatibilitaet ausschliesslich gegen `processedWith.profileId` und dessen Version pruefen. Die erkannte Werkzeug-ID bleibt Anzeige- und Gruppierungsmetadatum. Eine Regression sichert den abweichenden-ID-Fall, waehrend unbekannte Profil-/Versionspaare weiterhin fail closed bleiben. |
| A device-finding risk acceptance recorded before the 2026-09-23 fix may cite an occurrence that did not itself contain that device when two occurrences in one scan run shared a CVE. | The new source-row attribution and repository-command regression prevent future writes. Review affected historical decisions against retained source observations before relying on their captured provenance; do not rewrite approval history automatically. |
## 12. Änderungsprotokoll
Verified 2026-09-23 (Phase 7 and Phase 13D review fixes): Scoped Defender acceptance now rejects a device not present in the selected same-CVE source occurrence while retaining cross-occurrence identity conflict detection. Saved ISO mappings remain available during Qdrant outages. AI Overview preserves and labels the last successful result after a failed regeneration. Synthetic domain, repository-command, service and rendered-panel regressions pass; standard suite 1,576 passed/16 opt-in skipped, builds/typechecks and local-only/design checks pass. No database or scanner-data mutation. Existing device-finding decisions from the affected source pattern need provenance review; no historical decision was silently rewritten.
Verifiziert 2026-09-15 (Phase 13D, llama.cpp/BGE-M3): Lokalen Server Health/Modellmetadaten geprueft, sechs rein synthetische EN/DE-Dokumente ueber den produktiv gebauten Embedding-/Ingestionpfad als sechs 1.024-dimensionale Chunks indexiert und drei Cross-Language-Cases ausgewertet. HitRate@3, Recall@3 und MRR jeweils 1,0; alle Ziele Rang 1. Ergebnis als Wiring-Smoke-Test, nicht als Produktionsqualitaet dokumentiert. Qdrant enthaelt sechs synthetische Punkte; PostgreSQL/Realdaten unberuehrt. 1.285 Standardtests bestanden, acht PostgreSQL- und ein Qdrant-Integrationstest uebersprungen; Local-only-/Designcheck und beide Builds erfolgreich.
Implementiert 2026-09-15 (Phase 13D, lokales Embedding): Autorisierte Legacy-Collection geloescht, alten leeren Qdrant-1.12.4-Container entfernt und dessen Volume bewahrt; authentifizierten 1.19.1-Dienst auf kanonischen Loopback-Port 6333 verschoben und Auth/Collectionstatus erneut bestaetigt. BGE-M3/1.024 als lokale llama.cpp-Dense-Baseline festgelegt. Embeddingadapter um strikt loopbackbegrenztes HTTP und optionalen `dimensions`-Request fuer spaeteres `text-embedding-3-large` via HTTPS-LiteLLM erweitert; llama.cpp bleibt auf nativer Dimension. 1.285 Standardtests bestanden, acht PostgreSQL- und ein Qdrant-Integrationstest uebersprungen; Local-only-/Designcheck und beide Builds erfolgreich. Kein PostgreSQL- oder Realdatenzugriff, kein Reimport. BGE-M3-GGUF/Serverstart und echte Modellmetriken bleiben offen.

View File

@ -96,16 +96,19 @@ export class AiOverviewService {
async get(projectId: string, requestedScope: AiOverviewScope): Promise<AiOverviewResponse> {
const scope = await this.validateScope(projectId, requestedScope);
const scopeKey = aiOverviewScopeKey(projectId, scope);
const run = (await this.repository.listAiOverviewRuns(projectId, scopeKey))[0];
const runs = await this.repository.listAiOverviewRuns(projectId, scopeKey);
const run = runs[0];
const previousSuccessfulRun = run?.status === 'succeeded' ? undefined : runs.find(item => item.status === 'succeeded');
let stale = false;
if (run?.status === 'succeeded') {
if (run?.status === 'succeeded' || previousSuccessfulRun) {
const current = await this.prepareInput(projectId, scope);
stale = current.hash !== run.inputHash
|| run.promptVersion !== AI_OVERVIEW_PROMPT_VERSION
|| run.outputSchemaVersion !== AI_OVERVIEW_OUTPUT_SCHEMA_VERSION
|| Boolean(this.client && run.model !== this.client.model);
const saved = previousSuccessfulRun ?? run!;
stale = current.hash !== saved.inputHash
|| saved.promptVersion !== AI_OVERVIEW_PROMPT_VERSION
|| saved.outputSchemaVersion !== AI_OVERVIEW_OUTPUT_SCHEMA_VERSION
|| Boolean(this.client && saved.model !== this.client.model);
}
return responseFor(projectId, scope, this.generationEnabled, run, stale);
return responseFor(projectId, scope, this.generationEnabled, run, stale, previousSuccessfulRun);
}
async start(projectId: string, request: AiOverviewCreateRequest): Promise<AiOverviewResponse> {
@ -138,7 +141,9 @@ export class AiOverviewService {
const task = this.process(run, prepared).finally(() => this.activeTasks.delete(task));
this.activeTasks.add(task);
}
return responseFor(projectId, scope, true, run, false);
const previousSuccessfulRun = run.status === 'succeeded' ? undefined
: (await this.repository.listAiOverviewRuns(projectId, run.scopeKey)).find(item => item.status === 'succeeded');
return responseFor(projectId, scope, true, run, false, previousSuccessfulRun);
}
async close(): Promise<void> {
@ -350,7 +355,7 @@ function stableResultKey(item: UnifiedResultProjection): string {
return [item.sourceKind, item.sourceTool ?? '', item.sourceRuleId ?? '', item.resultProjectionId].join('\u0000');
}
function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean, run?: AiOverviewRun, stale = false): AiOverviewResponse {
function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean, run?: AiOverviewRun, stale = false, previousSuccessfulRun?: AiOverviewRun): AiOverviewResponse {
return {
version: 1,
projectId,
@ -359,7 +364,8 @@ function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean
state: run?.status ?? 'empty',
stale,
pollAfterMs: run && ['queued', 'running'].includes(run.status) ? ACTIVE_POLL_MS : undefined,
run
run,
previousSuccessfulRun
};
}

View File

@ -107,7 +107,7 @@ export class RegulatoryMappingService {
const availability = supported ? await this.availability() : { enabled: false };
const enabled = availability.enabled && supported;
const ruleKey = supported ? this.ruleKey(finding) : undefined;
const run = enabled && ruleKey ? await this.repository.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null;
const run = ruleKey && this.options.client ? await this.repository.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null;
const decisions = ruleKey ? await this.repository.listRegulatoryMappingDecisions(ruleKey) : [];
return {
version: 1,

View File

@ -81,6 +81,7 @@ export interface AiOverviewResponse {
stale: boolean;
pollAfterMs?: number;
run?: AiOverviewRun;
previousSuccessfulRun?: AiOverviewRun;
}
export interface AiOverviewCreateRequest extends AiOverviewScope {

View File

@ -162,7 +162,13 @@ export function defenderRiskTargetsForOccurrences(occurrences: FindingOccurrence
const source = sourceObservationsForOccurrence(occurrence);
return /^CVE-\d{4}-\d{4,}$/.test(cve) && source !== undefined ? [{ occurrence, cve, rows: observationRows(source) }] : [];
});
const rows = eligible.flatMap(item => item.rows);
const rowIndexesByOccurrence = new Map(eligible.map(item => [item.occurrence.id, new Set<number>()]));
const rows: DefenderSourceRow[] = [];
for (const item of eligible) for (const row of item.rows) {
const rowIndex = rows.length;
rows.push({ ...row, tableIndex: 0, rowIndex });
rowIndexesByOccurrence.get(item.occurrence.id)!.add(rowIndex);
}
if (!rows.length) continue;
const model = buildDefenderVulnerabilityModel([{
tableIndex: 0,
@ -175,7 +181,10 @@ export function defenderRiskTargetsForOccurrences(occurrences: FindingOccurrence
.map(item => text(item.details?.deviceId)));
const devices = new Map(model.devices.map(device => [device.id, device]));
for (const item of eligible) {
const deviceIds = new Set(model.occurrences.filter(value => value.cve === item.cve).map(value => value.deviceId));
const sourceRows = rowIndexesByOccurrence.get(item.occurrence.id)!;
const deviceIds = new Set(model.observations
.filter(value => value.cve === item.cve && sourceRows.has(value.rowIndex))
.map(value => value.deviceId));
for (const deviceId of deviceIds) {
const device = devices.get(deviceId);
if (!device) continue;

View File

@ -89,20 +89,23 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri
export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) {
const { response, loading, generating, error, generate } = controller;
const run = response?.run;
const result = run?.result;
const displayedRun = run?.result ? run : response?.previousSuccessfulRun;
const result = displayedRun?.result;
const active = response && ['queued', 'running'].includes(response.state);
const staleState = response?.stale && run?.status === 'succeeded';
return <div className="dashboard-page client-command-center ai-overview-page">
{navigation}
<header className="ai-overview-header">
<div><span>Advisory analysis</span><h1>AI Overview</h1><p>Generated from the complete privacy-safe export for this assessment timeslot.</p></div>
<div className="ai-overview-actions">
{run && <span className={`ai-overview-state is-${response?.stale ? 'stale' : run.status}`}>{response?.stale ? 'Stale' : run.status}</span>}
{run && <span className={`ai-overview-state is-${staleState ? 'stale' : run.status}`}>{staleState ? 'Stale' : run.status}</span>}
{response?.generationEnabled && <button className="btn primary" type="button" disabled={Boolean(active) || generating}
onClick={() => void generate()}>{result || run ? 'Regenerate' : 'Generate'}</button>}
</div>
</header>
{response?.stale && result && <p className="ai-overview-notice">The assessment data, model, or analysis contract changed. This saved overview remains visible until you regenerate it.</p>}
{error && <p className="ai-overview-error" role="alert">{error}</p>}
{run?.status === 'failed' && result && <p className="ai-overview-error" role="alert">{run.errorMessage ?? 'Generation failed.'} Showing the previous saved overview.</p>}
{(loading || active || generating) && !result && <section className="ai-overview-empty"><h2>{active ? 'Generation in progress' : 'Loading AI Overview'}</h2><p>{active ? 'The server is analyzing the complete timeslot. You can leave this tab while it runs.' : 'Loading the latest saved analysis…'}</p></section>}
{!loading && !result && !active && <section className="ai-overview-empty">
<h2>{run?.status === 'failed' ? 'Generation failed' : 'No AI Overview yet'}</h2>
@ -110,7 +113,7 @@ export function AiOverviewPanel({ controller, navigation }: { controller: AiOver
? 'Generate an advisory summary from the privacy-safe assessment export.'
: 'Work LiteLLM is not configured on this server.')}</p>
</section>}
{result && <AiOverviewResultView result={result} generatedAt={run?.completedAt} model={run?.model} />}
{result && <AiOverviewResultView result={result} generatedAt={displayedRun?.completedAt} model={displayedRun?.model} />}
</div>;
}

View File

@ -93,6 +93,22 @@ describe('device-scoped Defender risk acceptance', () => {
expect(() => scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-y', crossCveConflict, now)).toThrow('conflicting identity');
});
it('keeps same-CVE devices attached to their own source occurrence', () => {
const first = occurrence('occurrence-a', 'definition-cve-1', 'CVE-2026-1001', [
observation('DEV-A', 'CVE-2026-1001', 'Device A')
]);
const second = occurrence('occurrence-b', 'definition-cve-1', 'CVE-2026-1001', [
observation('DEV-B', 'CVE-2026-1001', 'Device B')
]);
expect(defenderRiskTargetsForOccurrences([first, second]).map(item => [item.occurrenceId, item.device.key])).toEqual([
['occurrence-a', 'device-id:dev-a'],
['occurrence-b', 'device-id:dev-b']
]);
expect(() => scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-b', [first, second], now, new Set([first.id])))
.toThrow('not an actionable member');
});
it('derives decision state, preserves overlaps, and prevents target mutation', () => {
const cve1 = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
const cve2 = occurrence('occurrence-2', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002')]);

View File

@ -1,6 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { AiOverviewResultView } from '../../src/features/dashboard/AiOverviewPanel';
import { AiOverviewPanel, AiOverviewResultView } from '../../src/features/dashboard/AiOverviewPanel';
import type { AiOverviewResponse, AiOverviewRun } from '../../src/domain/ai-overview';
import { validAiOverviewResult } from '../fixtures/ai-overview';
import { readFileSync } from 'node:fs';
@ -17,6 +18,25 @@ describe('Client Info AI Overview', () => {
expect(html).toContain('Advisory AI output');
});
it('shows the failed regeneration and the previous saved overview together', () => {
const base: AiOverviewRun = {
version: 1, id: 'run-1', projectId: 'project-1', scopeKey: 'scope-1', assessmentIds: [], scanRunIds: [],
idempotencyKey: 'request-1', status: 'succeeded', inputHash: 'a'.repeat(64), promptVersion: '1',
outputSchemaVersion: 1, model: 'work-model', createdAt: '2026-09-14T08:00:00.000Z',
updatedAt: '2026-09-14T08:00:00.000Z', callCount: 1, result: validAiOverviewResult()
};
const response: AiOverviewResponse = {
version: 1, projectId: 'project-1', scope: { assessmentIds: [], scanRunIds: [] },
generationEnabled: true, state: 'failed', stale: false,
run: { ...base, id: 'run-2', status: 'failed', result: undefined, errorMessage: 'Generation failed safely.' },
previousSuccessfulRun: base
};
const html = renderToStaticMarkup(<AiOverviewPanel controller={{ response, loading: false, generating: false, generate: async () => {} }} />);
expect(html).toContain('Generation failed safely.');
expect(html).toContain('Synthetic executive summary.');
expect(html).toContain('previous saved overview');
});
it('adds the AI tab to standard, Defender-only, and mixed Client Info compositions', () => {
const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
expect(dashboard).toContain("...(pureDefender ? [] : [['overview', 'Overview'] as const])");

View File

@ -206,6 +206,27 @@ describe('LocalAssessorRepository contract', () => {
]));
});
it('rejects accepting a device absent from the selected same-CVE occurrence', async () => {
const repository = await repositoryWithProject();
const run = defenderRun(1);
const data = defenderImportData(run, [
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-a', cve: 'CVE-2026-1001', devices: ['DEV-A'] },
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-b', cve: 'CVE-2026-1001', devices: ['DEV-B'] }
]);
data.findingDefinitions = data.findingDefinitions.slice(0, 1);
await commitRun(repository, run, data);
await expect(repository.recordScopedRiskAcceptance(project.id, {
target: { kind: 'device-finding', occurrenceId: 'occurrence-a', deviceKey: 'device-id:dev-b' },
command: riskCommand('accept'), expectedRevision: 0
})).rejects.toBeInstanceOf(RepositoryValidationError);
const accepted = await repository.recordScopedRiskAcceptance(project.id, {
target: { kind: 'device-finding', occurrenceId: 'occurrence-b', deviceKey: 'device-id:dev-b' },
command: riskCommand('accept'), expectedRevision: 0
});
expect(accepted.target.members[0].capturedOccurrences.map(item => item.occurrenceId)).toEqual(['occurrence-b']);
});
it('stores risk decisions atomically with optimistic concurrency, export and reimport history', async () => {
const repository = await repositoryWithProject();
await commitRun(repository, scanRun(1));

View File

@ -61,10 +61,12 @@ describe('AI overview service', () => {
const complete = vi.fn()
.mockResolvedValueOnce({ value: serviceResult })
.mockResolvedValueOnce({ value: { schemaVersion: 1 } });
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(stamp) });
let currentTime = stamp;
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(currentTime) });
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
await service.close();
currentTime = '2026-09-14T09:00:00.000Z';
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' });
await service.close();
@ -72,5 +74,33 @@ describe('AI overview service', () => {
expect(runs.map(run => run.status).sort()).toEqual(['failed', 'succeeded']);
expect(runs.find(run => run.status === 'succeeded')?.result?.executiveSummary).toBe('Synthetic executive summary.');
expect(runs.find(run => run.status === 'failed')?.errorCode).toBe('ai_output_invalid');
const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] });
expect(response).toMatchObject({
state: 'failed', run: { errorCode: 'ai_output_invalid' },
previousSuccessfulRun: { result: { executiveSummary: 'Synthetic executive summary.' } }
});
});
it('marks the previous success stale while a regeneration is queued', async () => {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
const serviceResult = validAiOverviewResult();
serviceResult.coverageGaps = [];
serviceResult.environmentOverview.factsUsed = [];
const complete = vi.fn().mockResolvedValueOnce({ value: serviceResult });
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(stamp) });
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
await service.close();
const [saved] = await repository.listAiOverviewRuns('project-1');
await repository.createAiOverviewRun({
...saved, id: 'ai-overview-queued', idempotencyKey: 'request-queued', status: 'queued', inputHash: 'b'.repeat(64),
result: undefined, completedAt: undefined, createdAt: '2026-09-14T09:00:00.000Z', updatedAt: '2026-09-14T09:00:00.000Z', callCount: 0
});
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [{ ...syntheticResult(), title: 'Changed synthetic finding' }] });
const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] });
expect(response).toMatchObject({ state: 'queued', stale: true, previousSuccessfulRun: { id: saved.id } });
});
});

View File

@ -156,6 +156,25 @@ describe('regulatory mapping service', () => {
expect(afterDecision.decisions.map(d => ({ controlId: d.controlId, decision: d.decision }))).toEqual([{ controlId: '5.1', decision: 'accepted' }]);
});
it('keeps a saved mapping readable when the retrieval index is unavailable', async () => {
const repository = mockRepository([finding()]);
const complete = vi.fn(async () => ({ value: assessed('primary') }));
const online = service(repository, complete);
await online.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
await online.close();
const offline = new RegulatoryMappingService(repository, {
findingRetrieval: {} as never,
index: { list: vi.fn(async () => { throw new Error('Index unavailable'); }) } as never,
client: { model: 'work-model', complete } as never,
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3'
});
const response = await offline.get('project-1', 'scanner-finding:occurrence-1');
expect(response).toMatchObject({ generationEnabled: false, state: 'succeeded', run: { status: 'succeeded' } });
expect(response.run?.result?.mappings[0].controlId).toBe('5.1');
expect(response.run?.candidates.every(candidate => candidate.evidence.every(item => item.text === undefined))).toBe(true);
});
it('drops one ungrounded evidence id but keeps the otherwise-valid mapping', async () => {
const { svc } = await runWith(assessed('primary', { evidenceChunkIds: ['semantic-5.1', 'not-a-supplied-chunk'] }));
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');