import { describe, expect, it, vi } from 'vitest'; import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service'; import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval'; import { bsiCorpusDocuments, bsiCorpusVersion, BsiCorpus } from '../../server/compliance/bsi-corpus'; import type { BsiCatalogs } from '../../server/compliance/bsi-snapshots'; import { acceptedControlIds, BSI_CORPUS_ID } from '../../src/compliance/regulatory-mapping'; import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag'; import { LocalComplianceStore } from '../../src/compliance/local-compliance-store'; import type { ComplianceReadPort } from '../../src/compliance/compliance-store'; import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result'; import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure'; const stamp = '2026-10-09T10:00:00.000Z'; // Synthetic BSI catalog and mapping: ISO 8.9 reaches KONF.1.1 and KONF.2.1, ISO 5.15 reaches BER.3.5, SYN.9.1 is unreachable. const requirement = (id: string, practice: string) => ({ id, title: `Synthetische Anforderung ${id}`, practice, statement: `Synthetische Aussage {{ insert: param, ${id}-p }} für ${id}.`, modalVerb: 'MUSS', params: { [`${id}-p`]: 'Wert' } }); const catalogs: BsiCatalogs = { catalog: { schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: 'BSI-Stand-der-Technik-Kernel-G0', title: 'Synthetic', version: '2026-01-01T00:00:00+00:00', sourceUrl: 'https://example.invalid/catalog', sourceSha256: 'c'.repeat(64), license: 'Synthetic', practices: [{ id: 'KONF', title: 'Konfiguration' }, { id: 'BER', title: 'Berechtigung' }, { id: 'SYN', title: 'Synthetisch' }], requirements: [requirement('KONF.1.1', 'KONF'), requirement('KONF.2.1', 'KONF'), requirement('BER.3.5', 'BER'), requirement('SYN.9.1', 'SYN')] }, crosswalk: { schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: 'Synthetic', version: '0.1-synthetic', sourceUrl: 'https://example.invalid/mapping', sourceSha256: 'd'.repeat(64), license: 'Synthetic', targetCatalog: 'synthetic', entries: [{ uuid: 'a', isoControlId: '8.9', relationship: 'subset-of', targets: ['KONF.1.1', 'KONF.2.1'] }, { uuid: 'b', isoControlId: '5.15', relationship: 'equivalent-to', targets: ['BER.3.5'] }] } }; const corpusVersion = bsiCorpusVersion(catalogs.catalog); function finding(id: string, ruleId: string): UnifiedResultProjection { return { resultProjectionId: `scanner-finding:${id}`, sourceKind: 'scanner-finding', sourceResultId: id, projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'run-1', scanRunId: 'run-1', sourceTool: 'purple-knight', sourceRuleId: ruleId, title: `Synthetic configuration check ${ruleId}`, description: 'A synthetic configuration setting is weaker than recommended.', 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: [] }; } function readPort(findings: UnifiedResultProjection[]): ComplianceReadPort { return { getProject: async projectId => projectId === 'project-1' ? { id: projectId, name: 'Synthetic project' } as never : null, listProjects: async () => [{ id: 'project-1', name: 'Synthetic project' }] as never, listAssessments: async () => [{ id: 'assessment-1', projectId: 'project-1', label: 'Synthetic', createdAt: stamp }] as never, listProjectScanRuns: async () => [{ id: 'run-1', assessmentId: 'assessment-1', sequence: 1, startedAt: stamp }] as never, queryUnifiedResults: 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)) } as never; }, getUnifiedResultById: async (id, projectId) => { const item = findings.find(entry => entry.resultProjectionId === id && entry.projectId === projectId); return item ? removeResultDbScopeIdentifiers(item) : null; } }; } function bsiChunk(controlId: string): RegulatoryChunk { return { schemaVersion: 1, corpusId: BSI_CORPUS_ID, corpusVersion, documentId: `bsi-kernel:${controlId}`, framework: 'BSI Grundschutz++', frameworkVersion: catalogs.catalog.version, controlId, chunkId: `bsi-kernel:${controlId}:0:synthetic`, chunkIndex: 0, language: 'de', title: `Synthetische Anforderung ${controlId}`, text: `Synthetische Aussage Wert für ${controlId}.`, textSha256: 'b'.repeat(64) }; } function isoChunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002', controlId: string): RegulatoryChunk { const reference = sourceStandard === 'ISO/IEC 27001'; return { schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: `${reference ? '27001' : '27002'}-${controlId}`, framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId, annexAControlId: `A.${controlId}`, controlTheme: 'technological', 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' : 'semantic'}-${controlId}`, chunkIndex: 0, language: 'de', title: 'Synthetic control', text: 'Synthetic evidence.', textSha256: 'a'.repeat(64) }; } function setup(findings = [finding('occurrence-1', 'SYN-CONFIG')]) { const store = new LocalComplianceStore(); // Retrieval answers each framework from its own corpus; ISO always retrieves 8.9. const retrieve = vi.fn(async (request: RegulatoryRetrievalRequest) => request.filters?.corpusId === BSI_CORPUS_ID ? ['KONF.2.1', 'BER.3.5', 'SYN.9.1'].map((id, index) => ({ score: 0.9 - index / 10, chunk: bsiChunk(id) })) : [{ score: 0.9, chunk: isoChunk('ISO/IEC 27002', '8.9') }]); const list = vi.fn(async (filters: { corpusId?: string; controlId?: string }) => filters.corpusId === BSI_CORPUS_ID ? [bsiChunk(filters.controlId!)] : [isoChunk('ISO/IEC 27001', filters.controlId ?? '8.9')]); const complete = vi.fn(async (system: string, input: string) => { const bsi = system.includes('BSI Grundschutz++'); const controlId = bsi ? 'KONF.2.1' : '8.9'; const evidence = JSON.parse(input).candidates.find((candidate: { controlId: string }) => candidate.controlId === controlId).evidence .map((item: { chunkId: string }) => item.chunkId); return { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId, relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: evidence }] } }; }); const ensure = vi.fn(async () => corpusVersion); const svc = new RegulatoryMappingService(readPort(findings), store, { findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve }), index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } as never, client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', bsiCatalogs: async () => catalogs, bsiCorpus: { configured: true, ensure } as unknown as BsiCorpus, now: () => new Date(stamp) }); return { store, svc, complete, ensure, retrieve }; } const scope = { assessmentIds: ['assessment-1'], scanRunIds: [] as string[] }; const id = 'scanner-finding:occurrence-1'; describe('direct BSI mapping and the ISO <-> BSI review', () => { it('maps a rule directly to BSI requirements beside its ISO mapping, keeps the decisions apart and accepts the first proposals', async () => { const { store, svc, complete, ensure } = setup(); await svc.start('project-1', { resultProjectionId: id }); await svc.start('project-1', { resultProjectionId: id, framework: 'bsi-kernel' }); await svc.close(); const iso = await svc.get('project-1', id); const bsi = await svc.get('project-1', id, 'bsi-kernel'); // Two runs of one rule; the ISO run keeps its earlier shape without a framework field. expect(iso.run).toMatchObject({ status: 'succeeded', corpusId: 'iso-test' }); expect(iso.run).not.toHaveProperty('framework'); expect(bsi.run).toMatchObject({ status: 'succeeded', framework: 'bsi-kernel', corpusId: BSI_CORPUS_ID, corpusVersion, retrievalVersion: 'bsi-hybrid-rrf-de-1.0.0' }); expect(bsi.run!.id).not.toBe(iso.run!.id); // The BSI candidates carry their requirement id, catalog text evidence without pages, and the model received BSI instructions. expect(bsi.run!.candidates.map(candidate => candidate.controlId)).toEqual(['KONF.2.1', 'BER.3.5', 'SYN.9.1']); expect(bsi.run!.candidates[0]).toMatchObject({ annexAControlId: 'KONF.2.1', evidence: [{ sourceStandard: 'BSI Grundschutz++', sectionKind: 'control' }] }); expect(bsi.run!.candidates[0].evidence[0]).not.toHaveProperty('pageStart'); expect(complete.mock.calls.filter(([system]) => system.includes('BSI Grundschutz++'))).toHaveLength(1); expect(ensure).toHaveBeenCalledTimes(1); // User decision 2026-10-09: the first proposals are accepted automatically in both frameworks, each in its own decision set. expect(acceptedControlIds(iso.decisions)).toEqual(['8.9']); expect(acceptedControlIds(bsi.decisions)).toEqual(['KONF.2.1']); expect(bsi.decisions[0]).toMatchObject({ framework: 'bsi-kernel', comment: AUTO_ACCEPT_COMMENT }); expect(await store.listRegulatoryMappingDecisions('purple-knight:SYN-CONFIG')).toHaveLength(1); // The derived stage 1 overview still follows the accepted ISO control only; the BSI response names the consistency. expect(iso.bsi?.requirements.map(item => item.requirementId)).toEqual(['KONF.1.1', 'KONF.2.1']); expect(bsi.consistency).toEqual({ state: 'consistent', reviewed: false }); }); it('lists the rule in the review, reports a disagreement after a decision, keeps a reviewed result until it changes, and changes no decision itself', async () => { const { store, svc } = setup(); await svc.start('project-1', { resultProjectionId: id }); await svc.start('project-1', { resultProjectionId: id, framework: 'bsi-kernel' }); await svc.close(); expect((await svc.consistency('project-1', scope)).counts).toEqual({ consistent: 1, inconsistent: 0, noReference: 0, notCompared: 0, reviewed: 0 }); // A person rejects the direct KONF.2.1 and accepts BER.3.5, which only ISO 5.15 reaches. const latest = async (controlId: string) => (await svc.get('project-1', id, 'bsi-kernel')).decisions.find(item => item.controlId === controlId)?.id; await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'KONF.2.1', decision: 'rejected', expectedLatestDecisionId: await latest('KONF.2.1') }); await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'BER.3.5', decision: 'accepted' }); const isoBefore = await store.listRegulatoryMappingDecisions('purple-knight:SYN-CONFIG'); const review = await svc.consistency('project-1', scope); expect(review.counts).toMatchObject({ consistent: 0, inconsistent: 1, reviewed: 0 }); expect(review.items).toHaveLength(1); expect(review.items[0]).toMatchObject({ resultProjectionId: id, sourceTool: 'purple-knight', sourceRuleId: 'SYN-CONFIG', findingCount: 1, state: 'inconsistent', reviewed: false, iso: [{ controlId: '8.9', title: 'Synthetic control' }], derivedCount: 2, direct: [{ requirementId: 'BER.3.5', title: 'Synthetische Anforderung BER.3.5', practice: 'BER', derived: false, reachable: true }], // 5.15 was not retrieved by the ISO mapping, so it cannot be accepted from the review. reverse: [{ controlId: '5.15', requirementIds: ['BER.3.5'], decidable: false }] }); // The review names rules only by a Result DB row of the timeslot, never by the internal rule key. expect(JSON.stringify(review)).not.toContain('purple-knight:SYN-CONFIG'); await svc.markConsistencyReviewed('project-1', { resultProjectionId: id, comment: 'Kept on purpose.' }); await svc.markConsistencyReviewed('project-1', { resultProjectionId: id }); expect((await store.listRegulatoryConsistencyReviews())).toHaveLength(1); expect((await svc.consistency('project-1', scope)).items[0]).toMatchObject({ state: 'inconsistent', reviewed: true }); expect((await svc.get('project-1', id, 'bsi-kernel')).consistency).toEqual({ state: 'inconsistent', reviewed: true }); // Neither the review nor the disagreement touched an ISO decision. expect(await store.listRegulatoryMappingDecisions('purple-knight:SYN-CONFIG')).toEqual(isoBefore); // A further decision changes the compared sets, so the rule needs a review again. await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'SYN.9.1', decision: 'accepted' }); expect((await svc.consistency('project-1', scope)).items[0]).toMatchObject({ state: 'inconsistent', reviewed: false }); }); it('counts a rule with an accepted ISO control but no BSI mapping as not compared, and reports no ISO reference for unreachable requirements', async () => { const { svc } = setup([finding('occurrence-1', 'SYN-CONFIG'), finding('occurrence-2', 'SYN-OTHER')]); await svc.start('project-1', { resultProjectionId: id }); await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-2' }); await svc.start('project-1', { resultProjectionId: id, framework: 'bsi-kernel' }); await svc.close(); const latest = (await svc.get('project-1', id, 'bsi-kernel')).decisions[0].id; await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'KONF.2.1', decision: 'rejected', expectedLatestDecisionId: latest }); await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'SYN.9.1', decision: 'accepted' }); const review = await svc.consistency('project-1', scope); expect(review.counts).toEqual({ consistent: 0, inconsistent: 0, noReference: 1, notCompared: 1, reviewed: 0 }); expect(review.items.map(item => [item.sourceRuleId, item.state])).toEqual([['SYN-CONFIG', 'no-reference']]); }); it('runs a BSI batch beside an ISO batch of the same scope and lists both in the mapping history', async () => { const { svc } = setup(); await svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'synthetic-iso-batch' }); await svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'synthetic-bsi-batch', framework: 'bsi-kernel' }); await svc.close(); const isoBatch = await svc.getBatch('project-1', { ...scope, filters: { deviation: true } }); const bsiBatch = await svc.getBatch('project-1', { ...scope, filters: { deviation: true } }, 'bsi-kernel'); expect(isoBatch.run).toMatchObject({ status: 'completed', idempotencyKey: 'synthetic-iso-batch', counts: { succeededRuleCount: 1 } }); expect(isoBatch.run).not.toHaveProperty('framework'); expect(bsiBatch.run).toMatchObject({ status: 'completed', framework: 'bsi-kernel', idempotencyKey: 'synthetic-bsi-batch', counts: { succeededRuleCount: 1 } }); expect(bsiBatch.run!.scopeKey).not.toBe(isoBatch.run!.scopeKey); expect(acceptedControlIds((await svc.get('project-1', id, 'bsi-kernel')).decisions)).toEqual(['KONF.2.1']); const overview = await svc.controlOverview('project-1', scope); // User decision 2026-10-09: the history names each mapping's framework. expect(overview.mappingHistory?.map(entry => [entry.id, entry.framework]).sort()) .toEqual([[bsiBatch.run!.id, 'bsi-kernel'], [isoBatch.run!.id, 'iso27001']].sort()); // The ISO overview counts only ISO controls; a BSI requirement never appears as an ISO control. expect(overview.controls.map(control => control.controlId)).toEqual(['8.9']); }); it('rejects an unknown framework', async () => { const { svc } = setup(); await expect(svc.get('project-1', id, 'dora' as never)).rejects.toThrow('framework must be iso27001 or bsi-kernel.'); }); }); describe('BSI Kernel corpus', () => { it('indexes every requirement once per catalog state with its parameters filled in, and reuses a complete index', async () => { const ingest = vi.fn(async () => ({ documentCount: 4, chunkCount: 4, embeddingModel: 'bge-m3' })); let indexed = 0; const prune = vi.fn(async () => undefined); const corpus = new BsiCorpus({ catalogs: async () => catalogs, ingestion: { ingest } as never, countIndexed: async () => indexed, pruneIndexed: prune }); const documents = bsiCorpusDocuments(catalogs.catalog); expect(documents).toHaveLength(4); expect(documents[0]).toMatchObject({ corpusId: BSI_CORPUS_ID, corpusVersion, controlId: 'KONF.1.1', language: 'de', text: 'Synthetische Aussage Wert für KONF.1.1.' }); await Promise.all([corpus.ensure(), corpus.ensure()]); expect(ingest).toHaveBeenCalledTimes(1); expect(prune).toHaveBeenCalledWith(BSI_CORPUS_ID, corpusVersion); indexed = 4; const fresh = new BsiCorpus({ catalogs: async () => catalogs, ingestion: { ingest } as never, countIndexed: async () => indexed }); expect(await fresh.ensure()).toBe(corpusVersion); expect(ingest).toHaveBeenCalledTimes(1); }); it('reports the index for the Data Hub: not built, building with progress, built, outdated, failed, and rebuilds on request', async () => { let indexed = 0; let otherVersions = 0; let release!: () => void; let fail = false; const ingest = vi.fn(async (_documents: unknown[], onProgress?: (completed: number, total: number) => void) => { onProgress?.(2, 4); await new Promise(resolve => { release = resolve; }); if (fail) throw new Error('Synthetic embedding failure.'); indexed = 4; return { documentCount: 4, chunkCount: 4, embeddingModel: 'bge-m3' }; }); const corpus = new BsiCorpus({ catalogs: async () => catalogs, ingestion: { ingest } as never, countIndexed: async () => indexed, countCorpus: async () => indexed + otherVersions }); expect(await corpus.status()).toMatchObject({ state: 'not-built', requirementCount: 4, corpusVersion, indexedChunks: 0 }); otherVersions = 3; expect((await corpus.status()).state).toBe('outdated'); expect(await corpus.startBuild(false)).toMatchObject({ state: 'building' }); await new Promise(resolve => setTimeout(resolve, 0)); expect(await corpus.status()).toMatchObject({ state: 'building', progress: { completed: 2, total: 4 } }); // A second click while it runs starts nothing new. await corpus.startBuild(false); expect(ingest).toHaveBeenCalledTimes(1); release(); await corpus.ensure(); expect(await corpus.status()).toMatchObject({ state: 'built', indexedChunks: 4 }); expect((await corpus.status()).builtAt).toBeDefined(); // A rebuild embeds again even though the index is complete; a failure is reported until the next build. fail = true; await corpus.startBuild(true); await new Promise(resolve => setTimeout(resolve, 0)); release(); await new Promise(resolve => setTimeout(resolve, 0)); expect(ingest).toHaveBeenCalledTimes(2); expect(await corpus.status()).toMatchObject({ state: 'failed', error: 'Synthetic embedding failure.' }); expect(await new BsiCorpus({ catalogs: async () => catalogs }).status()).toMatchObject({ state: 'unavailable' }); }); });