Assessor_2.0/tests/server/regulatory-mapping-service....

441 lines
24 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { RegulatoryMappingService } from '../../server/regulatory/regulatory-mapping-service';
import { RegulatoryFindingRetrievalService } from '../../server/regulatory/regulatory-finding-retrieval';
import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from '../../src/domain/regulatory-mapping';
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/domain/regulatory-rag';
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
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';
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, categories: [], frameworkReferences: [],
...overrides
};
}
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002'): RegulatoryChunk {
const reference = sourceStandard === 'ISO/IEC 27001';
return {
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? '27001-5.1' : '27002-5.1-purpose',
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId: '5.1', annexAControlId: 'A.5.1',
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-5.1' : 'semantic-5.1', 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)) };
}),
getUnifiedResultById: vi.fn(async (id: string) => byProjection.get(id) ?? 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 AssessorRepository;
}
function service(repository: AssessorRepository, complete: (system: string, input: string) => Promise<unknown>) {
return new RegulatoryMappingService(repository, {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002') }]) }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), searchSparse: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
now: () => new Date(stamp)
});
}
function assessed(
relevance: 'primary' | 'supporting' | 'irrelevant' = 'irrelevant',
overrides: Record<string, unknown> = {}
) {
return {
schemaVersion: 1,
outcome: 'assessed',
assessments: [{
controlId: '5.1', relevance, confidence: relevance === 'irrelevant' ? 'low' : 'high',
rationale: 'Synthetic assessment.', evidenceChunkIds: ['semantic-5.1'], ...overrides
}]
};
}
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: assessed('primary', {
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(complete).toHaveBeenCalledOnce();
expect(complete.mock.calls[0][0]).toContain('Assess every supplied ISO control candidate independently');
expect(complete.mock.calls[0][0]).toContain('zero or one primary and zero to three supporting controls');
// 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('root 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');
// A decision made in project-1 is visible to project-2 (global decisions).
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '5.1', decision: 'accepted' });
const afterDecision = await svc.get('project-2', 'scanner-finding:occurrence-2');
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');
expect(completed.run?.status).toBe('succeeded');
expect(completed.run?.result?.mappings[0].evidenceChunkIds).toEqual(['semantic-5.1']);
expect(completed.run?.errorCode).toBeUndefined();
});
it('accepts a grounded supporting-only mapping when no candidate is clearly primary', async () => {
const { svc } = await runWith(assessed('supporting', {
confidence: 'medium', rationale: 'Relevant, but not uniquely primary.'
}));
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');
expect(completed.run?.status).toBe('succeeded');
expect(completed.run?.result?.mappings).toEqual([expect.objectContaining({ controlId: '5.1', relationship: 'supporting' })]);
});
it('fails safely with a structural detail when the model maps to an unknown control', async () => {
const { svc } = await runWith({
schemaVersion: 1, outcome: 'assessed', assessments: [{ controlId: 'A.5.1', relevance: '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('retrieves a broad control pool and sends only the best twelve controls to the model', async () => {
const repository = mockRepository([finding()]);
const semanticCandidates = Array.from({ length: 13 }, (_, index) => {
const controlId = `8.${index + 1}`;
return {
score: 1 - index / 100,
chunk: {
...chunk('ISO/IEC 27002'),
controlId,
annexAControlId: `A.${controlId}`,
documentId: `27002-${controlId}`,
chunkId: `semantic-${controlId}`,
title: `Synthetic control ${controlId}`,
sectionKind: 'control' as const
}
};
});
const retrieve = vi.fn(async (_finding: UnifiedResultProjection, limit: number) => {
expect(limit).toBe(12);
return {
sourceResultId: 'occurrence-1', status: 'candidates' as const,
preparedQuery: {
schemaVersion: 3 as const,
query: 'Synthetic public mapping context.',
retrievalQuery: 'Synthetic concise retrieval query.',
specificity: 'rule' as const,
publicTool: 'Semperis Purple Knight',
publicRuleId: 'A-SmbSigning',
publicTopics: ['SMB signing requirements']
},
candidates: semanticCandidates
};
});
const list = vi.fn(async (filters: { controlId?: string }) => {
const controlId = filters.controlId ?? '8.1';
return [{
...chunk('ISO/IEC 27001'),
controlId,
annexAControlId: `A.${controlId}`,
documentId: `27001-${controlId}`,
chunkId: `reference-${controlId}`,
title: `Synthetic control ${controlId}`
}];
});
const complete = vi.fn(async (_system: string, input: string) => {
const payload = JSON.parse(input) as { candidates: Array<{ controlId: string }> };
expect(payload.candidates).toHaveLength(12);
expect(payload.candidates.map(candidate => candidate.controlId)).toEqual(Array.from({ length: 12 }, (_, index) => `8.${index + 1}`));
return { value: {
schemaVersion: 1,
outcome: 'assessed',
assessments: payload.candidates.map(candidate => ({
controlId: candidate.controlId, relevance: 'irrelevant', confidence: 'low', rationale: 'Synthetic assessment.',
evidenceChunkIds: [`semantic-${candidate.controlId}`]
}))
} };
});
const svc = new RegulatoryMappingService(repository, {
findingRetrieval: { retrieve } as never,
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), searchSparse: vi.fn(), list },
client: { model: 'work-model', complete: complete as never },
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
});
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
await svc.close();
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');
expect(completed.run?.candidates).toHaveLength(12);
expect(complete).toHaveBeenCalledOnce();
});
it('maps the complete filtered timeslot, excludes non-scanner results 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-evaluation:one', sourceResultId: 'policy-one', sourceKind: 'policy-evaluation', sourceRuleId: undefined }),
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: assessed('primary', {
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: 5,
excludedNonScannerCount: 1,
eligibleFindingCount: 3,
unsupportedFindingCount: 1,
distinctRuleCount: 2,
scheduledRuleCount: 2,
succeededRuleCount: 2,
failedRuleCount: 0
} });
expect(complete).toHaveBeenCalledTimes(2);
expect(complete.mock.calls.every(call => !String(call[1]).includes(resultTableAsset))).toBe(true);
});
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: assessed() };
});
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: assessed() }));
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: assessed() };
});
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: assessed() };
});
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 };
}