782 lines
56 KiB
TypeScript
782 lines
56 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService, type RegulatoryMappingServiceOptions } from '../../server/compliance/regulatory-mapping-service';
|
|
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
|
|
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
|
|
import { acceptedControlIds, REGULATORY_GPO_QUERY_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, type RegulatoryMappingBatchRun, type RegulatoryMappingDecision, type RegulatoryMappingRun } from '../../src/compliance/regulatory-mapping';
|
|
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
|
|
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
|
|
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
|
|
|
|
type EngineRepository = ComplianceReadPort & ComplianceStore;
|
|
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
|
|
import { complianceWorkFacetIndex } from '../../src/compliance/ui/compliance-work-facets';
|
|
import type { FindingRecord } from '../../src/domain/finding';
|
|
|
|
const stamp = '2026-09-15T10:00:00.000Z';
|
|
// Result-table / occurrence data that must never reach the model or the query.
|
|
const resultTableAsset = 'DC01.customer.local';
|
|
const customerPolicyValue = 'CUSTOMER\synthetic.account';
|
|
|
|
function finding(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResultProjection {
|
|
return {
|
|
resultProjectionId: 'scanner-finding:occurrence-1', sourceKind: 'scanner-finding', sourceResultId: 'occurrence-1',
|
|
projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'run-1', scanRunId: 'run-1',
|
|
sourceTool: 'purple-knight', sourceRuleId: 'A-SmbSigning', title: 'SMB signing is not required on domain controllers',
|
|
description: 'The SMB server permits unsigned sessions, allowing relay attacks.', affectedAsset: resultTableAsset,
|
|
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
|
technicalExecutionState: 'completed', resultType: 'compliance_check', isIoe: false, isDeviation: true,
|
|
lifecycleState: 'new', mappingConfidence: 1, reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, frameworkReferences: [], categories: [],
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002', controlId = '5.1'): RegulatoryChunk {
|
|
const reference = sourceStandard === 'ISO/IEC 27001';
|
|
return {
|
|
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? `27001-${controlId}` : `27002-${controlId}-purpose`,
|
|
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId, annexAControlId: `A.${controlId}`,
|
|
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
|
|
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
|
|
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
|
|
chunkId: reference ? `reference-${controlId}` : `semantic-${controlId}`, chunkIndex: 0, language: 'de', title: 'Synthetic control',
|
|
text: reference ? 'Synthetic normative statement.' : 'Synthetic semantic evidence.', textSha256: 'a'.repeat(64)
|
|
};
|
|
}
|
|
|
|
/** In-memory repository keyed like the global (rule-keyed) schema. */
|
|
function mockRepository(findings: UnifiedResultProjection[]) {
|
|
const byProjection = new Map(findings.map(item => [item.resultProjectionId, item]));
|
|
const runs = new Map<string, RegulatoryMappingRun>();
|
|
const batches: RegulatoryMappingBatchRun[] = [];
|
|
const decisions: RegulatoryMappingDecision[] = [];
|
|
return {
|
|
getProject: vi.fn(async (projectId: string) => findings.some(item => item.projectId === projectId) ? { id: projectId, name: 'Synthetic project' } : null),
|
|
listProjects: vi.fn(async () => [...new Set(findings.map(item => item.projectId))].map(id => ({ id, name: 'Synthetic project' }))),
|
|
listAssessments: vi.fn(async (projectId: string) => [...new Set(findings.filter(item => item.projectId === projectId).map(item => item.assessmentId).filter(Boolean))]
|
|
.map(id => ({ id: id!, projectId, label: 'Synthetic assessment', createdAt: stamp }))),
|
|
listProjectScanRuns: vi.fn(async (projectId: string) => [...new Map(findings.filter(item => item.projectId === projectId && item.scanRunId)
|
|
.map(item => [item.scanRunId!, { id: item.scanRunId!, assessmentId: item.assessmentId!, sequence: 1, startedAt: stamp }])).values()]),
|
|
queryUnifiedResults: vi.fn(async (query = {}) => {
|
|
const filtered = filterUnifiedResults(findings, query);
|
|
const offset = query.offset ?? 0;
|
|
return { total: filtered.length, items: filtered.slice(offset, offset + (query.limit ?? filtered.length)) };
|
|
}),
|
|
// Mirrors the real Result DB read: scoped to the project, returned without real scope ids.
|
|
getUnifiedResultById: vi.fn(async (id: string, projectId?: string) => {
|
|
const item = byProjection.get(id);
|
|
return item && (projectId === undefined || item.projectId === projectId) ? removeResultDbScopeIdentifiers(item) : null;
|
|
}),
|
|
getRegulatoryMappingRun: vi.fn(async (id: string) => runs.get(id) ?? null),
|
|
claimRegulatoryMappingRun: vi.fn(async (run: RegulatoryMappingRun, force: boolean) => {
|
|
const existing = runs.get(run.id);
|
|
if (existing && (['queued', 'running'].includes(existing.status) || existing.status === 'succeeded' && !force)) {
|
|
return { run: existing, claimed: false };
|
|
}
|
|
runs.set(run.id, run);
|
|
return { run, claimed: true };
|
|
}),
|
|
saveRegulatoryMappingRun: vi.fn(async (run: RegulatoryMappingRun) => { runs.set(run.id, run); return run; }),
|
|
listRegulatoryMappingRuns: vi.fn(async () => [...runs.values()]),
|
|
createRegulatoryMappingBatchRun: vi.fn(async (run: RegulatoryMappingBatchRun) => {
|
|
const existing = batches.find(item => item.projectId === run.projectId && (item.idempotencyKey === run.idempotencyKey
|
|
|| item.scopeKey === run.scopeKey && ['queued', 'running'].includes(item.status)));
|
|
if (existing) return existing;
|
|
batches.push(run);
|
|
return run;
|
|
}),
|
|
updateRegulatoryMappingBatchRun: vi.fn(async (run: RegulatoryMappingBatchRun) => {
|
|
const index = batches.findIndex(item => item.id === run.id);
|
|
if (index < 0) throw new Error('missing batch');
|
|
batches[index] = run;
|
|
return run;
|
|
}),
|
|
listRegulatoryMappingBatchRuns: vi.fn(async (projectId: string, scopeKey?: string) => batches
|
|
.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
|
|
.slice().reverse()),
|
|
createRegulatoryMappingDecision: vi.fn(async (decision: RegulatoryMappingDecision, expected?: string) => {
|
|
const run = runs.get(decision.mappingId);
|
|
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') throw new Error('missing mapping');
|
|
if (!run.candidates.some(c => c.controlId === decision.controlId)) throw new Error('ungrounded control');
|
|
const latest = decisions.filter(d => d.ruleKey === decision.ruleKey && d.controlId === decision.controlId)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))[0];
|
|
if ((expected ?? undefined) !== (latest?.id ?? undefined)) throw new Error('conflict');
|
|
const stored = { ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) };
|
|
decisions.push(stored);
|
|
return stored;
|
|
}),
|
|
listRegulatoryMappingDecisions: vi.fn(async (ruleKey: string) => decisions.filter(d => d.ruleKey === ruleKey)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)))
|
|
} as unknown as EngineRepository;
|
|
}
|
|
|
|
function service(repository: EngineRepository, complete: (system: string, input: string) => Promise<unknown>, controlId = '5.1',
|
|
extra: Partial<RegulatoryMappingServiceOptions> = {}) {
|
|
return new RegulatoryMappingService(repository, repository, {
|
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', controlId) }]) }),
|
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', controlId)]) },
|
|
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
|
|
now: () => new Date(stamp),
|
|
...extra
|
|
});
|
|
}
|
|
|
|
describe('regulatory mapping service', () => {
|
|
it('shares one global mapping and decision across projects and sends no result-table data', async () => {
|
|
const findingB = finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2', projectId: 'project-2', affectedAsset: 'DC02.other.local' });
|
|
const repository = mockRepository([finding(), findingB]);
|
|
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
}, usage: { totalTokens: 12 } }));
|
|
const svc = service(repository, complete);
|
|
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
|
|
// The same rule mapped once in project-1 is visible to project-2 (global sharing).
|
|
const shared = await svc.get('project-2', 'scanner-finding:occurrence-2');
|
|
expect(shared).toMatchObject({ state: 'succeeded', run: { ruleKey: 'purple-knight:A-SmbSigning', sourceTool: 'purple-knight', embeddingModel: 'bge-m3', tokenUsage: { totalTokens: 12 } } });
|
|
expect(shared.run?.result?.mappings[0].controlId).toBe('5.1');
|
|
expect(shared.run?.promptVersion).toBe(REGULATORY_MAPPING_PROMPT_VERSION);
|
|
expect(complete).toHaveBeenCalledOnce();
|
|
|
|
// The copyrighted ISO clause text is stripped from the response but still reaches the model.
|
|
expect(shared.run?.candidates.every(c => c.evidence.every(item => item.text === undefined))).toBe(true);
|
|
expect(complete.mock.calls[0][0]).toContain('governs the same security subject');
|
|
expect(complete.mock.calls[0][1]).toContain('Synthetic semantic evidence.');
|
|
expect(complete.mock.calls[0][1]).toContain('SMB signing is not required');
|
|
expect(complete.mock.calls[0][1]).not.toContain(resultTableAsset);
|
|
expect(complete.mock.calls[0][1]).not.toContain('DC02.other.local');
|
|
expect(complete.mock.calls[0][1]).not.toContain('A.5.1');
|
|
|
|
// The automatic acceptance in project-1 is visible to project-2 (global decisions), and so is a later manual decision.
|
|
expect(shared.decisions.map(d => ({ controlId: d.controlId, decision: d.decision }))).toEqual([{ controlId: '5.1', decision: 'accepted' }]);
|
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: shared.decisions[0].id });
|
|
const afterDecision = await svc.get('project-2', 'scanner-finding:occurrence-2');
|
|
const rejected = afterDecision.decisions.find(d => d.decision === 'rejected');
|
|
expect(rejected).toMatchObject({ controlId: '5.1', previousDecisionId: shared.decisions[0].id });
|
|
expect(acceptedControlIds(afterDecision.decisions)).toEqual([]);
|
|
});
|
|
|
|
it('maps a finding although the Result DB row carries no real project id, but never across projects', async () => {
|
|
const repository = mockRepository([finding()]);
|
|
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
} }));
|
|
const svc = service(repository, complete);
|
|
expect((await repository.getUnifiedResultById('scanner-finding:occurrence-1'))?.projectId).toBe('scoped-project');
|
|
|
|
await expect(svc.get('project-1', 'scanner-finding:occurrence-1')).resolves.toMatchObject({ generationEnabled: true });
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
const automatic = (await svc.get('project-1', 'scanner-finding:occurrence-1')).decisions[0];
|
|
await expect(svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: automatic.id }))
|
|
.resolves.toMatchObject({ state: 'succeeded' });
|
|
|
|
await expect(svc.get('project-2', 'scanner-finding:occurrence-1')).rejects.toThrow('does not exist in this project');
|
|
await expect(svc.start('project-2', { resultProjectionId: 'scanner-finding:occurrence-1' })).rejects.toThrow('does not exist in this project');
|
|
await expect(svc.decide('project-2', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '5.1', decision: 'rejected' }))
|
|
.rejects.toThrow('does not exist in this project');
|
|
});
|
|
|
|
it('counts accepted controls once per finding for the timeslot deviations of a client', async () => {
|
|
const gpo = (id: string) => finding({ resultProjectionId: `policy-evaluation-comparison:${id}`, sourceResultId: `comparison-${id}`,
|
|
sourceKind: 'policy-evaluation', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\policies\synthetic:mode',
|
|
policyScope: 'computer', title: 'Synthetic policy mode' });
|
|
const findings = [
|
|
finding(),
|
|
finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
|
|
finding({ resultProjectionId: 'scanner-finding:not-deviation', sourceResultId: 'not-deviation', isDeviation: false }),
|
|
finding({ resultProjectionId: 'scanner-finding:rejected', sourceResultId: 'rejected', sourceRuleId: 'A-Rejected', title: 'SMB signing is not enforced' }),
|
|
// Two baseline packages of one GPO setting are one Work record and count once.
|
|
gpo('one'), gpo('two')
|
|
];
|
|
const repository = mockRepository(findings);
|
|
const svc = service(repository, vi.fn(async () => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
} })));
|
|
for (const resultProjectionId of ['scanner-finding:occurrence-1', 'scanner-finding:rejected', 'policy-evaluation-comparison:one']) {
|
|
await svc.start('project-1', { resultProjectionId });
|
|
await svc.close();
|
|
}
|
|
const accepted = (await svc.get('project-1', 'scanner-finding:rejected')).decisions[0];
|
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:rejected', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
|
|
|
const overview = await svc.controlOverview('project-1', { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] });
|
|
expect(overview).toMatchObject({ version: 1, projectId: 'project-1', findingCount: 3,
|
|
controls: [{ controlId: '5.1', annexAControlId: 'A.5.1', title: 'Synthetic control', theme: 'Organizational' }] });
|
|
expect(overview.controls[0].findings.map(item => item.resultProjectionId))
|
|
.toEqual(['scanner-finding:occurrence-1', 'scanner-finding:occurrence-2', 'policy-evaluation-comparison:one']);
|
|
// Only the timeslot deviations are read, and only through the read-only port.
|
|
expect(vi.mocked(repository.queryUnifiedResults).mock.calls.at(-1)![0]).toMatchObject({ audience: 'result-db', deviation: true, projectId: 'project-1' });
|
|
// The Work filters read every mappable finding, so the status filter in Work decides what is shown.
|
|
const assignments = await svc.controlAssignments('project-1', { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] });
|
|
expect(assignments.findingCount).toBe(4);
|
|
expect(assignments.controls[0].findings.map(item => item.resultProjectionId)).toContain('scanner-finding:not-deviation');
|
|
expect(vi.mocked(repository.queryUnifiedResults).mock.calls.at(-1)?.[0]?.deviation).toBeUndefined();
|
|
await expect(svc.controlOverview('project-1', { assessmentIds: [], scanRunIds: [] })).rejects.toThrow('timeslot scope is required');
|
|
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
|
|
});
|
|
|
|
it('counts every mappable deviation of the timeslot once in one mapping state for the coverage overview', async () => {
|
|
const rule = (id: string, sourceRuleId: string, title: string) =>
|
|
finding({ resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceRuleId, title });
|
|
const findings = [
|
|
finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
|
|
rule('rejected', 'A-Rejected', 'SMB signing is not enforced'),
|
|
rule('failed', 'A-Failed', 'LDAP signing is not required'),
|
|
rule('unmapped', 'A-Unmapped', 'Print spooler runs on domain controllers'),
|
|
finding({ resultProjectionId: 'scanner-finding:passed', sourceResultId: 'passed', sourceRuleId: 'A-Passed', isDeviation: false })
|
|
];
|
|
const repository = mockRepository(findings);
|
|
const mapped = { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } };
|
|
const svc = service(repository, vi.fn(async () => mapped));
|
|
for (const resultProjectionId of ['scanner-finding:occurrence-1', 'scanner-finding:rejected']) {
|
|
await svc.start('project-1', { resultProjectionId });
|
|
await svc.close();
|
|
}
|
|
const accepted = (await svc.get('project-1', 'scanner-finding:rejected')).decisions[0];
|
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:rejected', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
|
const failing = service(repository, vi.fn(async () => { throw new Error('Synthetic model outage.'); }));
|
|
await failing.start('project-1', { resultProjectionId: 'scanner-finding:failed' });
|
|
await failing.close();
|
|
expect((await svc.get('project-1', 'scanner-finding:failed')).state).toBe('failed');
|
|
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
|
const overview = await svc.controlOverview('project-1', scope);
|
|
// Accepted equals the ISO tile's finding count; the passed result is no deviation and stays out.
|
|
expect(overview.coverage).toEqual({ deviations: 5, accepted: 2, mappedNoneAccepted: 1, failed: 1, notMapped: 1 });
|
|
expect(overview.findingCount).toBe(overview.coverage?.accepted);
|
|
// The Work facet assignments carry no coverage and no footprint, but the failed findings for the `ISO mapping` filter;
|
|
// the not-mapped passed result is no failure.
|
|
const assignments = await svc.controlAssignments('project-1', scope);
|
|
expect(assignments.coverage).toBeUndefined();
|
|
expect(assignments.bsiFootprint).toBeUndefined();
|
|
expect(assignments.failedFindings?.map(item => item.resultProjectionId)).toEqual(['scanner-finding:failed']);
|
|
expect(overview.failedFindings).toBeUndefined();
|
|
// The BSI footprint lists every Kernel requirement of the active catalog, whatever is derived for this client.
|
|
const footprint = overview.bsiFootprint!;
|
|
expect(footprint.practices).toHaveLength(14);
|
|
expect(footprint.practices.flatMap(practice => practice.requirements)).toHaveLength(901);
|
|
expect(footprint.practices.flatMap(practice => practice.requirements).filter(requirement => requirement.reachable)).toHaveLength(272);
|
|
expect(footprint.practices.find(practice => practice.id === 'KONF')?.requirements.find(item => item.id === 'KONF.2.1')?.reachable).toBe(true);
|
|
// Every requirement carries its published title, including unreachable ones.
|
|
expect(footprint.practices.flatMap(practice => practice.requirements).every(item => item.title.trim().length > 0)).toBe(true);
|
|
expect(footprint.practices.flatMap(practice => practice.requirements).find(item => !item.reachable)?.title).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a control accepted by an earlier run after a remap of the same version no longer proposes it (regression)', async () => {
|
|
// Real engine store: a remap with the same pipeline versions overwrites the rule's only run.
|
|
let proposed = '5.1';
|
|
const svc = new RegulatoryMappingService(mockRepository([finding()]), new LocalComplianceStore(), {
|
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async () => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', proposed) }]) }),
|
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', proposed)]) },
|
|
client: { model: 'work-model', complete: vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: proposed,
|
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: [`semantic-${proposed}`, `reference-${proposed}`] }] } })) as never },
|
|
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
|
|
});
|
|
const id = 'scanner-finding:occurrence-1';
|
|
await svc.start('project-1', { resultProjectionId: id });
|
|
await svc.close();
|
|
const accepted = (await svc.get('project-1', id)).decisions.find(item => item.controlId === '5.1')!;
|
|
expect(accepted.decision).toBe('accepted');
|
|
// Remap: the model now proposes only 8.9; 5.1 remains an earlier decision.
|
|
proposed = '8.9';
|
|
await svc.start('project-1', { resultProjectionId: id });
|
|
await svc.close();
|
|
const remapped = await svc.get('project-1', id);
|
|
expect(remapped.run?.candidates.map(candidate => candidate.controlId)).toEqual(['8.9']);
|
|
expect(remapped.earlierControls?.map(control => control.controlId)).toEqual(['5.1']);
|
|
|
|
const rejected = await svc.decide('project-1', { resultProjectionId: id, controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
|
expect(rejected.decisions.find(item => item.controlId === '5.1' && item.decision === 'rejected')).toMatchObject({ previousDecisionId: accepted.id });
|
|
expect(acceptedControlIds(rejected.decisions)).toEqual(['8.9']);
|
|
// A control no run ever proposed for this rule stays undecidable.
|
|
await expect(svc.decide('project-1', { resultProjectionId: id, controlId: '7.4', decision: 'accepted' })).rejects.toThrow('not a grounded candidate');
|
|
});
|
|
|
|
it('names every Annex A control from the reference documents of the active corpus, cached per corpus version', async () => {
|
|
const list = vi.fn(async (filters: { retrievalRole?: string }, _limit: number) => filters.retrievalRole === 'reference'
|
|
? [chunk('ISO/IEC 27001', '5.1'), chunk('ISO/IEC 27001', '8.9'), { ...chunk('ISO/IEC 27001', '8.9'), title: 'Second chunk of 8.9' }] : []);
|
|
const svc = service(mockRepository([finding()]), vi.fn(), '5.1',
|
|
{ index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } });
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
|
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
|
|
// Only titles of the active corpus version, read once for repeated overviews.
|
|
const referenceReads = () => list.mock.calls.filter(([filters]) => filters.retrievalRole === 'reference');
|
|
expect(referenceReads()[0]).toEqual([{ corpusId: 'iso-test', corpusVersion: 'v2', sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100]);
|
|
await svc.controlOverview('project-1', scope);
|
|
expect(referenceReads()).toHaveLength(1);
|
|
// The Work facet assignments carry no titles.
|
|
expect((await svc.controlAssignments('project-1', scope)).annexATitles).toBeUndefined();
|
|
// A new corpus version is read again; an unavailable index yields no titles and is retried next time.
|
|
await svc.activateCorpus('iso-test', 'v3');
|
|
list.mockRejectedValueOnce(new Error('Synthetic index outage.'));
|
|
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({});
|
|
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
|
|
});
|
|
|
|
it('derives BSI Kernel requirements from accepted ISO controls in the finding, overview and Work responses', async () => {
|
|
const repository = mockRepository([finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' })]);
|
|
const svc = service(repository, vi.fn(async () => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.9', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-8.9', 'reference-8.9'] }]
|
|
} })), '8.9');
|
|
// Nothing accepted yet: no BSI block, so ISO-only responses stay unchanged.
|
|
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi).toBeUndefined();
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
// The pinned BSI mapping assigns ISO 8.9 to five Kernel requirements of the practice KONF.
|
|
const expected = ['KONF.2.1', 'KONF.2.1.1', 'KONF.2.5', 'KONF.10.1', 'KONF.10.5'];
|
|
const single = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
|
expect(single.bsi?.requirements.map(item => item.requirementId)).toEqual(expected);
|
|
expect(single.bsi?.requirements[0]).toMatchObject({ practice: 'KONF', via: [{ isoControlId: '8.9', relationship: 'subset-of' }] });
|
|
expect(single.bsi?.requirements.every(item => item.statement && !item.statement.includes('{{'))).toBe(true);
|
|
expect(single.bsi).toMatchObject({ crosswalkVersion: '1.0.1-qa', missingTargets: [] });
|
|
expect(single.bsi?.license).toContain('CC BY-SA 4.0');
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
|
for (const response of [await svc.controlOverview('project-1', scope), await svc.controlAssignments('project-1', scope)]) {
|
|
expect(response.bsi?.requirements.map(item => item.requirementId)).toEqual(expected);
|
|
}
|
|
// Rejecting the only ISO control removes every derived requirement.
|
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '8.9', decision: 'rejected', expectedLatestDecisionId: single.decisions[0].id });
|
|
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi).toBeUndefined();
|
|
expect((await svc.controlOverview('project-1', scope)).bsi).toBeUndefined();
|
|
});
|
|
|
|
it('derives BSI requirements from the active catalog after Update from BSI, not only from the pinned one', async () => {
|
|
const active = structuredClone(bundledBsiCatalogs);
|
|
// Synthetic newer state: the BSI mapping now also assigns ISO 8.9 to KONF.1.1.
|
|
active.crosswalk.entries = active.crosswalk.entries.map(entry => entry.isoControlId === '8.9' ? { ...entry, targets: ['KONF.1.1'] } : entry);
|
|
const svc = service(mockRepository([finding()]), vi.fn(async () => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.9', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-8.9', 'reference-8.9'] }]
|
|
} })), '8.9', { bsiCatalogs: async () => active });
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi?.requirements.map(item => item.requirementId)).toEqual(['KONF.1.1']);
|
|
});
|
|
|
|
it('reports every batch and single finding mapping globally, marked by where it came from, on the Client Info overview only', async () => {
|
|
const repository = mockRepository([finding(),
|
|
finding({ assessmentId: 'assessment-3', scanRunId: 'run-3', resultProjectionId: 'scanner-finding:earlier', sourceResultId: 'earlier',
|
|
sourceRuleId: 'C-Earlier', title: 'Earlier synthetic check' }),
|
|
finding({ projectId: 'project-2', assessmentId: 'assessment-2', scanRunId: 'run-2',
|
|
resultProjectionId: 'scanner-finding:other', sourceResultId: 'other', sourceRuleId: 'B-Other', title: 'Other synthetic check' })]);
|
|
const svc = service(repository, vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1',
|
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })));
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
|
const run = async (action: Promise<unknown>) => { await action; await svc.close(); };
|
|
await run(svc.startBatch('project-1', { ...scope, filters: {}, idempotencyKey: 'regulatory-batch-dummy-manual' }));
|
|
await run(svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'auto:run-1' }));
|
|
await run(svc.startBatch('project-1', { assessmentIds: ['assessment-3'], scanRunIds: ['run-3'], filters: {}, idempotencyKey: 'regulatory-batch-dummy-earlier' }));
|
|
await run(svc.startBatch('project-2', { assessmentIds: ['assessment-2'], scanRunIds: ['run-2'], filters: {}, idempotencyKey: 'regulatory-batch-dummy-other' }));
|
|
// Single mappings from the Finding Reader carry the timeslot they were started in.
|
|
await run(svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', origin: scope }));
|
|
await run(svc.start('project-1', { resultProjectionId: 'scanner-finding:earlier', origin: { assessmentIds: ['assessment-3'], scanRunIds: ['run-3'] } }));
|
|
await run(svc.start('project-2', { resultProjectionId: 'scanner-finding:other', origin: { assessmentIds: ['assessment-2'], scanRunIds: ['run-2'] } }));
|
|
|
|
const overview = await svc.controlOverview('project-1', scope);
|
|
expect(overview.mappingHistory?.map(entry => [entry.kind, entry.scope, entry.trigger]).sort()).toEqual([
|
|
['batch', 'other-client', 'manual'], ['batch', 'other-timeslot', 'manual'], ['batch', 'timeslot', 'automatic'], ['batch', 'timeslot', 'manual'],
|
|
['single', 'other-client', 'manual'], ['single', 'other-timeslot', 'manual'], ['single', 'timeslot', 'manual']]);
|
|
// The automatic batch reused the rule the first one mapped.
|
|
const automatic = overview.mappingHistory!.find(entry => entry.trigger === 'automatic')!;
|
|
expect(automatic.counts).toEqual({ findings: 1, sentToModel: 0, reused: 1, succeeded: 0, failed: 0 });
|
|
const single = overview.mappingHistory!.find(entry => entry.kind === 'single' && entry.scope === 'timeslot')!;
|
|
expect(single).toMatchObject({ status: 'completed', counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } });
|
|
expect(single.counts.findings).toBeUndefined();
|
|
// Content-free: no client, rule, scope id, filter or key leaves the engine with an entry.
|
|
expect(Object.keys(single).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
|
|
expect(Object.keys(automatic).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
|
|
// The stored origin is hashed: runs are global and exported with every client, so no id is kept.
|
|
const stored = JSON.stringify(await repository.listRegulatoryMappingRuns());
|
|
for (const id of ['project-1', 'project-2', 'assessment-1', 'assessment-2', 'assessment-3', 'run-1', 'run-2', 'run-3']) {
|
|
expect(stored).not.toContain(`"${id}"`);
|
|
}
|
|
expect((await svc.controlAssignments('project-1', scope)).mappingHistory).toBeUndefined();
|
|
});
|
|
|
|
it('marks a single mapping without a usable origin as unknown, or as the client only', async () => {
|
|
const repository = mockRepository([finding()]);
|
|
const svc = service(repository, vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1',
|
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })));
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
|
// A foreign timeslot is not recorded; the client still is.
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', origin: { assessmentIds: ['foreign-assessment'], scanRunIds: [] } });
|
|
await svc.close();
|
|
expect((await svc.controlOverview('project-1', scope)).mappingHistory?.map(entry => entry.scope)).toEqual(['other-timeslot']);
|
|
// A run without origin, as recorded before this change, is unknown.
|
|
const [stored] = await repository.listRegulatoryMappingRuns();
|
|
const { origin: _origin, ...withoutOrigin } = stored;
|
|
await repository.saveRegulatoryMappingRun(withoutOrigin);
|
|
expect((await svc.controlOverview('project-1', scope)).mappingHistory?.map(entry => entry.scope)).toEqual(['unknown']);
|
|
});
|
|
|
|
it('counts a GPO setting once and gives Work its setting ID although the Result DB withholds it', async () => {
|
|
// Internal comparisons carry the setting ID; the Result DB rows of the same comparisons do not.
|
|
const comparison = (id: string, settingId: string, title: string, policyScope: 'computer' | 'user') => finding({
|
|
resultProjectionId: `policy-evaluation-comparison:${id}`, sourceResultId: `comparison-${id}`, sourceKind: 'policy-evaluation',
|
|
sourceTool: 'GPOs', sourceRuleId: settingId, policyScope, title });
|
|
const findings = [
|
|
// Three baseline packages of one setting, a second setting, the same title in the other scope and
|
|
// a different setting that shares the first setting's title.
|
|
comparison('a1', 'gpo-policy-identity-aaaa', 'Synthetic · Mode', 'computer'),
|
|
comparison('a2', 'gpo-policy-identity-aaaa', 'Synthetic · Mode', 'computer'),
|
|
comparison('a3', 'gpo-policy-identity-aaaa', 'Synthetic · Mode', 'computer'),
|
|
comparison('b1', 'gpo-policy-identity-bbbb', 'Security Options · SyntheticKey', 'computer'),
|
|
comparison('b2', 'gpo-policy-identity-bbbb', 'Security Options · SyntheticKey', 'computer'),
|
|
comparison('c1', 'gpo-policy-identity-cccc', 'Synthetic · Mode', 'user'),
|
|
comparison('d1', 'gpo-policy-identity-dddd', 'Synthetic · Mode', 'computer')
|
|
];
|
|
const repository = mockRepository(findings);
|
|
const internal = vi.mocked(repository.queryUnifiedResults).getMockImplementation()!;
|
|
vi.mocked(repository.queryUnifiedResults).mockImplementation(async (query = {}) => {
|
|
const page = await internal(query);
|
|
return query.audience === 'internal-analysis' ? page
|
|
: { ...page, items: page.items.map(item => ({ ...item, sourceRuleId: undefined })) };
|
|
});
|
|
const svc = service(repository, vi.fn(async () => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
} })));
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true } };
|
|
await svc.startBatch('project-1', { ...scope, idempotencyKey: 'batch-gpo-settings' });
|
|
await svc.close();
|
|
|
|
// Mapping stays one global rule per disclosed title; counting follows the Work settings.
|
|
expect((await svc.getBatch('project-1', scope)).run?.counts)
|
|
.toMatchObject({ filteredResultCount: 7, eligibleFindingCount: 4, distinctRuleCount: 2 });
|
|
const overview = await svc.controlOverview('project-1', { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] });
|
|
expect(overview.findingCount).toBe(4);
|
|
expect(overview.controls[0].findings.map(item => `${item.policyScope}:${item.sourceRuleId}`).sort()).toEqual([
|
|
'computer:gpo-policy-identity-aaaa', 'computer:gpo-policy-identity-bbbb',
|
|
'computer:gpo-policy-identity-dddd', 'user:gpo-policy-identity-cccc']);
|
|
// The internal read is limited to GPO comparisons of the same timeslot.
|
|
const internalQueries = vi.mocked(repository.queryUnifiedResults).mock.calls.map(call => call[0] ?? {})
|
|
.filter(query => query.audience === 'internal-analysis');
|
|
expect(internalQueries.length).toBeGreaterThan(0);
|
|
expect(internalQueries.every(query => query.projectId === 'project-1' && query.sourceKind?.join() === 'policy-evaluation'
|
|
&& query.assessmentIds?.join() === 'assessment-1' && query.scanRunIds?.join() === 'run-1')).toBe(true);
|
|
|
|
// The Work ISO filter finds the rolled-up GPO setting by scope and setting ID.
|
|
const workSetting = { id: `gpo-work-setting:${encodeURIComponent(JSON.stringify(['project-1', 'aggregation-1', 'computer', 'gpo-policy-identity-aaaa']))}` } as FindingRecord;
|
|
expect(complianceWorkFacetIndex(overview).facets[0].valuesFor!(workSetting)).toEqual(['5.1']);
|
|
});
|
|
|
|
it('accepts the first two proposals automatically, keeps existing decisions and accepts nothing without a proposal', async () => {
|
|
const semantic = (controlId: string): RegulatoryChunk => ({ ...chunk('ISO/IEC 27002'), controlId, annexAControlId: `A.${controlId}`,
|
|
documentId: `27002-${controlId}`, chunkId: `semantic-${controlId}` });
|
|
const proposal = (controlId: string) => ({ controlId, relationship: 'supporting', confidence: 'medium', rationale: 'Synthetic rationale.',
|
|
evidenceChunkIds: [`semantic-${controlId}`] });
|
|
const build = (repository: EngineRepository, value: unknown) => new RegulatoryMappingService(repository, repository, {
|
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) =>
|
|
['8.20', '5.15', '5.1'].map((controlId, index) => ({ score: 0.9 - index / 10, chunk: semantic(controlId) }))) }),
|
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
|
|
client: { model: 'work-model', complete: vi.fn(async () => ({ value })) as never }, corpusId: 'iso-test', corpusVersion: 'v2',
|
|
embeddingModel: 'bge-m3', now: () => new Date(stamp)
|
|
});
|
|
const accepted = async (svc: RegulatoryMappingService) => acceptedControlIds((await svc.get('project-1', 'scanner-finding:occurrence-1')).decisions).sort();
|
|
const mapped = (...controls: string[]) => ({ schemaVersion: 1, outcome: 'mapped', mappings: controls.map(proposal) });
|
|
|
|
// Three proposals: the first two in the model's order are accepted, the third stays undecided.
|
|
const three = build(mockRepository([finding()]), mapped('5.15', '8.20', '5.1'));
|
|
await three.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await three.close();
|
|
expect(await accepted(three)).toEqual(['5.15', '8.20']);
|
|
expect((await three.get('project-1', 'scanner-finding:occurrence-1')).decisions.every(item => item.comment === AUTO_ACCEPT_COMMENT)).toBe(true);
|
|
|
|
// A single proposal is accepted alone.
|
|
const one = build(mockRepository([finding()]), mapped('8.20'));
|
|
await one.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await one.close();
|
|
expect(await accepted(one)).toEqual(['8.20']);
|
|
|
|
// Regenerating never overrides a manual reject; a still undecided first proposal is accepted.
|
|
const automatic = (await three.get('project-1', 'scanner-finding:occurrence-1')).decisions.find(item => item.controlId === '5.15')!;
|
|
await three.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '5.15', decision: 'rejected', expectedLatestDecisionId: automatic.id });
|
|
await three.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await three.close();
|
|
expect(await accepted(three)).toEqual(['8.20']);
|
|
|
|
// Without a proposal nothing is accepted.
|
|
const none = build(mockRepository([finding()]), { schemaVersion: 1, outcome: 'no_relevant_control', mappings: [], reason: 'Synthetic refusal.' });
|
|
await none.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await none.close();
|
|
expect((await none.get('project-1', 'scanner-finding:occurrence-1')).decisions).toEqual([]);
|
|
});
|
|
|
|
it('keeps a control decided by an earlier run reviewable after a new pipeline version stops retrieving it', async () => {
|
|
const semantic = (controlId: string): RegulatoryChunk => ({ ...chunk('ISO/IEC 27002'), controlId, annexAControlId: `A.${controlId}`,
|
|
documentId: `27002-${controlId}`, chunkId: `semantic-${controlId}` });
|
|
const repository = mockRepository([finding()]);
|
|
const build = (corpusVersion: string, controlId: string) => new RegulatoryMappingService(repository, repository, {
|
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: semantic(controlId) }]) }),
|
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
|
|
client: { model: 'work-model', complete: vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId,
|
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: [`semantic-${controlId}`] }] } })) as never },
|
|
corpusId: 'iso-test', corpusVersion, embeddingModel: 'bge-m3', now: () => new Date(stamp)
|
|
});
|
|
const older = build('v1', '8.3');
|
|
await older.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await older.close();
|
|
const current = build('v2', '8.2');
|
|
await current.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await current.close();
|
|
|
|
const response = await current.get('project-1', 'scanner-finding:occurrence-1');
|
|
expect(acceptedControlIds(response.decisions).sort()).toEqual(['8.2', '8.3']);
|
|
expect(response.run?.candidates.map(item => item.controlId)).toEqual(['8.2']);
|
|
expect(response.earlierControls).toEqual([{ controlId: '8.3', annexAControlId: 'A.8.3', title: semantic('8.3').title }]);
|
|
|
|
// Rejecting the earlier control is grounded by the older run that retrieved it.
|
|
const earlier = response.decisions.find(item => item.controlId === '8.3')!;
|
|
const rejected = await current.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '8.3', decision: 'rejected', expectedLatestDecisionId: earlier.id });
|
|
expect(acceptedControlIds(rejected.decisions)).toEqual(['8.2']);
|
|
expect(rejected.decisions.find(item => item.controlId === '8.3' && item.previousDecisionId === earlier.id)).toMatchObject({ decision: 'rejected', mappingId: earlier.mappingId });
|
|
});
|
|
|
|
it('keeps a saved mapping readable when the retrieval index is unavailable', async () => {
|
|
const repository = mockRepository([finding()]);
|
|
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
} }));
|
|
const online = service(repository, complete);
|
|
await online.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await online.close();
|
|
|
|
const offline = new RegulatoryMappingService(repository, 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({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'not-a-supplied-chunk'] }]
|
|
});
|
|
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
|
expect(completed.run?.status).toBe('succeeded');
|
|
expect(completed.run?.result?.mappings[0].evidenceChunkIds).toEqual(['semantic-5.1']);
|
|
expect(completed.run?.errorCode).toBeUndefined();
|
|
});
|
|
|
|
it('fails safely with a structural detail when the model maps to an unknown control', async () => {
|
|
const { svc } = await runWith({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: 'A.5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1'] }]
|
|
});
|
|
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
|
expect(completed.run?.status).toBe('failed');
|
|
expect(completed.run?.errorCode).toBe('regulatory_mapping_invalid_output');
|
|
expect(completed.run?.errorDetail).toContain('unknown candidate control');
|
|
expect(completed.run?.errorDetail).not.toContain(resultTableAsset);
|
|
});
|
|
|
|
it('maps the complete filtered timeslot including GPO comparisons, excludes context rows and deduplicates global rules', async () => {
|
|
const findings = [
|
|
finding(),
|
|
finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
|
|
finding({ resultProjectionId: 'scanner-finding:occurrence-3', sourceResultId: 'occurrence-3', sourceRuleId: 'A-Second', title: 'Second synthetic check' }),
|
|
finding({ resultProjectionId: 'policy-observation:one', sourceResultId: 'policy-one', sourceKind: 'policy-observation', sourceRuleId: undefined }),
|
|
// Two baseline packages of one GPO setting share one global rule.
|
|
...['one', 'two'].map(id => finding({ resultProjectionId: `policy-evaluation-comparison:${id}`, sourceResultId: `comparison-${id}`,
|
|
sourceKind: 'policy-evaluation', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\policies\synthetic:mode',
|
|
title: 'Synthetic policy mode', policyActualValue: { principals: [customerPolicyValue] } })),
|
|
finding({ resultProjectionId: 'scanner-finding:unsupported', sourceResultId: 'unsupported', sourceRuleId: 'A-Unsupported', title: '' }),
|
|
finding({ resultProjectionId: 'scanner-finding:not-deviation', sourceResultId: 'not-deviation', sourceRuleId: 'A-Ignored', isDeviation: false })
|
|
];
|
|
const repository = mockRepository(findings);
|
|
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
} }));
|
|
const svc = service(repository, complete);
|
|
|
|
await svc.startBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true, affectedAsset: resultTableAsset },
|
|
idempotencyKey: 'batch-filtered-1'
|
|
});
|
|
await svc.close();
|
|
|
|
const response = await svc.getBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true, affectedAsset: resultTableAsset }
|
|
});
|
|
expect(response.run).toMatchObject({ status: 'completed', counts: {
|
|
filteredResultCount: 7,
|
|
excludedNonScannerCount: 1,
|
|
// The two baseline comparisons of one GPO setting are one eligible finding, like its Work record.
|
|
eligibleFindingCount: 4,
|
|
unsupportedFindingCount: 1,
|
|
distinctRuleCount: 3,
|
|
scheduledRuleCount: 3,
|
|
succeededRuleCount: 3,
|
|
failedRuleCount: 0
|
|
} });
|
|
expect(complete).toHaveBeenCalledTimes(3);
|
|
expect(complete.mock.calls.every(call => !String(call[1]).includes(resultTableAsset) && !String(call[1]).includes('synthetic.account'))).toBe(true);
|
|
});
|
|
|
|
it('maps a single GPO baseline comparison under its technical setting and refuses a raw observation', async () => {
|
|
const gpo = finding({ resultProjectionId: 'policy-evaluation-comparison:one', sourceResultId: 'comparison-one',
|
|
sourceKind: 'policy-evaluation', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\policies\synthetic:mode',
|
|
title: 'Synthetic policy mode', policyActualValue: { principals: [customerPolicyValue] } });
|
|
const observation = finding({ resultProjectionId: 'policy-observation:one', sourceResultId: 'observation-one', sourceKind: 'policy-observation' });
|
|
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
} }));
|
|
const svc = service(mockRepository([gpo, observation]), complete);
|
|
|
|
await svc.start('project-1', { resultProjectionId: gpo.resultProjectionId });
|
|
await svc.close();
|
|
const mapped = await svc.get('project-1', gpo.resultProjectionId);
|
|
expect(mapped).toMatchObject({ generationEnabled: true, state: 'succeeded', run: { ruleKey: 'GPOs:registry:machine:software\policies\synthetic:mode' } });
|
|
// The stored identity tuple must differ per GPO query version, or a version bump collides with the older row.
|
|
expect(mapped.run?.promptVersion).toBe(`${REGULATORY_MAPPING_PROMPT_VERSION}+${REGULATORY_GPO_QUERY_VERSION}`);
|
|
expect(complete).toHaveBeenCalledTimes(1);
|
|
expect(String(complete.mock.calls[0][1])).not.toContain('synthetic.account');
|
|
|
|
expect(await svc.get('project-1', observation.resultProjectionId)).toMatchObject({ generationEnabled: false, state: 'unsupported' });
|
|
await expect(svc.start('project-1', { resultProjectionId: observation.resultProjectionId })).rejects.toThrow('GPO baseline comparisons');
|
|
});
|
|
|
|
it('bounds concurrent mapping completions across a batch', async () => {
|
|
const findings = Array.from({ length: 5 }, (_, index) => finding({
|
|
resultProjectionId: `scanner-finding:occurrence-${index}`,
|
|
sourceResultId: `occurrence-${index}`,
|
|
sourceRuleId: `A-Rule-${index}`,
|
|
title: `Synthetic check ${index}`
|
|
}));
|
|
let active = 0;
|
|
let maximum = 0;
|
|
const complete = vi.fn(async () => {
|
|
active += 1;
|
|
maximum = Math.max(maximum, active);
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
active -= 1;
|
|
return { value: { schemaVersion: 1, outcome: 'no_relevant_control', reason: 'Synthetic.', mappings: [] } };
|
|
});
|
|
const svc = service(mockRepository(findings), complete);
|
|
await svc.startBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true }, idempotencyKey: 'batch-concurrency-1'
|
|
});
|
|
await svc.close();
|
|
expect(complete).toHaveBeenCalledTimes(5);
|
|
expect(maximum).toBe(2);
|
|
});
|
|
|
|
it('reads every server page independently of loaded rows', async () => {
|
|
const findings = Array.from({ length: 501 }, (_, index) => finding({
|
|
resultProjectionId: `scanner-finding:occurrence-${index}`,
|
|
sourceResultId: `occurrence-${index}`
|
|
}));
|
|
const repository = mockRepository(findings);
|
|
const complete = vi.fn(async () => ({ value: {
|
|
schemaVersion: 1, outcome: 'no_relevant_control', reason: 'Synthetic.', mappings: []
|
|
} }));
|
|
const svc = service(repository, complete);
|
|
await svc.startBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true }, idempotencyKey: 'batch-many-pages-1'
|
|
});
|
|
await svc.close();
|
|
const response = await svc.getBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true }
|
|
});
|
|
expect(response.run?.counts).toMatchObject({ filteredResultCount: 501, eligibleFindingCount: 501, distinctRuleCount: 1 });
|
|
expect(repository.queryUnifiedResults).toHaveBeenCalledTimes(2);
|
|
expect(complete).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('fails the batch safely when the filtered total changes between pages', async () => {
|
|
const findings = Array.from({ length: 501 }, (_, index) => finding({
|
|
resultProjectionId: `scanner-finding:occurrence-${index}`,
|
|
sourceResultId: `occurrence-${index}`
|
|
}));
|
|
const repository = mockRepository(findings);
|
|
let page = 0;
|
|
vi.mocked(repository.queryUnifiedResults).mockImplementation(async (query = {}) => {
|
|
page += 1;
|
|
const offset = query.offset ?? 0;
|
|
return { total: page === 1 ? 501 : 500, items: findings.slice(offset, offset + (query.limit ?? findings.length)) };
|
|
});
|
|
const complete = vi.fn();
|
|
const svc = service(repository, complete);
|
|
await svc.startBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true }, idempotencyKey: 'batch-changing-total-1'
|
|
});
|
|
await svc.close();
|
|
const response = await svc.getBatch('project-1', {
|
|
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true }
|
|
});
|
|
expect(response.run).toMatchObject({ status: 'failed', errorCode: 'regulatory_mapping_batch_scope_changed' });
|
|
expect(complete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('completes with partial failures, then reuses success and retries only the failed rule', async () => {
|
|
const findings = [finding(), finding({
|
|
resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2', sourceRuleId: 'A-Second', title: 'Second synthetic check'
|
|
})];
|
|
let secondAttempts = 0;
|
|
const complete = vi.fn(async (_system: string, input: string) => {
|
|
if (input.includes('Second synthetic check') && ++secondAttempts === 1) throw new Error('synthetic provider failure');
|
|
return { value: { schemaVersion: 1, outcome: 'no_relevant_control', reason: 'Synthetic.', mappings: [] } };
|
|
});
|
|
const svc = service(mockRepository(findings), complete);
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true } };
|
|
await svc.startBatch('project-1', { ...scope, idempotencyKey: 'batch-partial-1' });
|
|
await svc.close();
|
|
expect((await svc.getBatch('project-1', scope)).run).toMatchObject({
|
|
status: 'completed', counts: { scheduledRuleCount: 2, succeededRuleCount: 1, failedRuleCount: 1 }
|
|
});
|
|
|
|
await svc.startBatch('project-1', { ...scope, idempotencyKey: 'batch-partial-2' });
|
|
await svc.close();
|
|
expect((await svc.getBatch('project-1', scope)).run).toMatchObject({
|
|
status: 'completed', counts: {
|
|
distinctRuleCount: 2, reusedSucceededRuleCount: 1, scheduledRuleCount: 1, succeededRuleCount: 1, failedRuleCount: 0
|
|
}
|
|
});
|
|
expect(complete).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('reuses an active single-finding mapping without a duplicate model call', async () => {
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const complete = vi.fn(async () => {
|
|
await gate;
|
|
return { value: { schemaVersion: 1, outcome: 'no_relevant_control', reason: 'Synthetic.', mappings: [] } };
|
|
});
|
|
const svc = service(mockRepository([finding()]), complete);
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true } };
|
|
await svc.startBatch('project-1', { ...scope, idempotencyKey: 'batch-active-reuse-1' });
|
|
release();
|
|
await svc.close();
|
|
expect((await svc.getBatch('project-1', scope)).run?.counts).toMatchObject({
|
|
distinctRuleCount: 1, reusedActiveRuleCount: 1, scheduledRuleCount: 0
|
|
});
|
|
expect(complete).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
async function runWith(value: unknown): Promise<{ svc: RegulatoryMappingService }> {
|
|
const repository = mockRepository([finding()]);
|
|
const svc = service(repository, vi.fn(async () => ({ value })));
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
return { svc };
|
|
}
|