import { describe, expect, it } from 'vitest'; import type { CanonicalAssessmentModel, CanonicalFindingStatus, CanonicalSeverity, EvidenceRecord, FindingDefinition, FindingOccurrence, ImportFileRecord, ScanRun, ToolIdentity } from '../../src/domain/canonical-model'; import { compareScanRuns } from '../../src/comparison/comparison-engine'; import { createLocalComparisonRule } from '../../src/comparison/local-mapping-rules'; const timestamp = '2026-07-17T08:00:00.000Z'; const tool = (id: string): ToolIdentity => ({ id, name: id }); const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] }; const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' }; function definition(id: string, title: string, controlId?: string): FindingDefinition { return { id, canonicalKey: `${id}-key`, title, frameworkReferences: controlId ? [{ framework: 'CIS', controlId }] : [], createdAt: timestamp, updatedAt: timestamp }; } function occurrence(options: { id: string; runId: string; definitionId: string; ruleId?: string; title: string; toolId?: string; severity?: CanonicalSeverity; status?: CanonicalFindingStatus; category?: string; asset?: string; }): FindingOccurrence { const severity = options.severity ?? 'high'; const status = options.status ?? 'open'; return { id: options.id, definitionId: options.definitionId, scanRunId: options.runId, importFileId: `file-${options.runId}`, sourceIdentity: { tool: tool(options.toolId ?? 'pingcastle'), originalRuleId: options.ruleId, canonicalRuleId: options.ruleId, originalTitle: options.title }, sourcePosition: { rowIndex: 1 }, originalFields: [{ key: 'title', label: 'Title', value: options.title, columnIndex: 0 }], fieldMappings: [ { targetRole: 'title', sourceFieldKeys: ['title'], normalizedValue: options.title, provenance }, ...(options.category ? [{ targetRole: 'category' as const, sourceFieldKeys: ['category'], normalizedValue: options.category, provenance }] : []) ], severity: { originalValue: severity, normalizedValue: severity, provenance }, status: { originalValue: status, normalizedValue: status, provenance }, ...(options.asset ? { affectedAsset: { originalValue: options.asset, normalizedValue: options.asset, provenance } } : {}) }; } function run(id: string, sequence: number, toolId = 'pingcastle', assessmentId = 'assessment'): ScanRun { return { id, assessmentId, sequence, label: id, tool: tool(toolId), importedAt: timestamp }; } function importFile(runId: string, profileVersion: string, mappingVersion: string): ImportFileRecord { return { id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.json`, format: 'JSON', sizeBytes: 1, importedAt: timestamp, detectedTool: tool('pingcastle'), detection: confidence, processedWith: { parserId: 'json', parserVersion: '1.0.0', profileId: 'pingcastle', profileVersion, mappingVersion }, sourceArtifact: { kind: 'not-retained', reason: 'synthetic' } }; } function model(options: { runs: ScanRun[]; definitions: FindingDefinition[]; occurrences: FindingOccurrence[]; evidence?: EvidenceRecord[]; importFiles?: ImportFileRecord[]; }): CanonicalAssessmentModel { return { schemaVersion: 1, project: { id: 'project', name: 'Project', createdAt: timestamp, updatedAt: timestamp, attributes: {} }, assessment: { id: 'assessment', projectId: 'project', name: 'Assessment', createdAt: timestamp, updatedAt: timestamp }, scanRuns: options.runs, importFiles: options.importFiles ?? [], sourceTables: [], findingDefinitions: options.definitions, findingOccurrences: options.occurrences, evidence: options.evidence ?? [], reviews: [], diagnostics: [] }; } describe('comparison engine', () => { it('fails closed for semantically incompatible persisted processing contracts', () => { const comparisonModel = model({ runs: [run('left', 1), run('right', 2)], definitions: [definition('def-a', 'Synthetic finding')], occurrences: [ occurrence({ id: 'left-a', runId: 'left', definitionId: 'def-a', ruleId: 'A-1', title: 'Synthetic finding' }), occurrence({ id: 'right-a', runId: 'right', definitionId: 'def-a', ruleId: 'A-1', title: 'Synthetic finding' }) ], importFiles: [importFile('left', '1.0.0', '1.0.0'), importFile('right', '1.2.0', '1.1.0')] }); const result = compareScanRuns(comparisonModel, 'left', 'right'); expect(result.summary).toMatchObject({ notComparable: 2, persistent: 0, new: 0, resolved: 0 }); expect(result.diagnostics.join(' ')).toContain('semantisch inkompatiblen'); }); it('reports persistent, new, resolved, reoccurred and suggested findings with field changes', () => { const definitions = [ definition('def-a', 'Weak password policy'), definition('def-b', 'Legacy protocol enabled'), definition('def-c', 'New risky delegation'), definition('def-r', 'Recurring stale account'), definition('def-old-name', 'Password policy permits weak settings'), definition('def-new-name', 'Password policy permits weak settings') ]; const occurrences = [ occurrence({ id: 'history-r', runId: 'run-1', definitionId: 'def-r', ruleId: 'R-1', title: 'Recurring stale account' }), occurrence({ id: 'baseline-a', runId: 'run-2', definitionId: 'def-a', ruleId: 'A-1', title: 'Weak password policy', severity: 'high', status: 'open' }), occurrence({ id: 'baseline-b', runId: 'run-2', definitionId: 'def-b', ruleId: 'B-1', title: 'Legacy protocol enabled' }), occurrence({ id: 'baseline-s', runId: 'run-2', definitionId: 'def-old-name', ruleId: 'LEGACY-1', title: 'Password policy permits weak settings' }), occurrence({ id: 'target-a', runId: 'run-3', definitionId: 'def-a', ruleId: 'A-1', title: 'Weak password policy', severity: 'critical', status: 'failed' }), occurrence({ id: 'target-c', runId: 'run-3', definitionId: 'def-c', ruleId: 'C-1', title: 'New risky delegation' }), occurrence({ id: 'target-r', runId: 'run-3', definitionId: 'def-r', ruleId: 'R-1', title: 'Recurring stale account' }), occurrence({ id: 'target-s', runId: 'run-3', definitionId: 'def-new-name', ruleId: 'MODERN-1', title: 'Password policy permits weak settings' }) ]; const evidence: EvidenceRecord[] = [ { id: 'ev-1', occurrenceId: 'baseline-a', kind: 'text', value: 'old evidence', sourceFieldKeys: ['evidence'] }, { id: 'ev-2', occurrenceId: 'target-a', kind: 'text', value: 'new evidence', sourceFieldKeys: ['evidence'] } ]; const result = compareScanRuns(model({ runs: [run('run-1', 1), run('run-2', 2), run('run-3', 3)], definitions, occurrences, evidence }), 'run-2', 'run-3'); expect(result.summary).toMatchObject({ persistent: 1, new: 1, resolved: 1, reoccurred: 1, possiblyIdentical: 1, notComparable: 0 }); const persistent = result.results.find(item => item.targetOccurrenceId === 'target-a'); expect(persistent?.match.method).toBe('canonical-definition'); expect(persistent?.changes.map(change => change.kind)).toEqual(['severity', 'status', 'evidence']); expect(result.results.find(item => item.targetOccurrenceId === 'target-r')).toMatchObject({ lifecycle: 'reoccurred', historicalOccurrenceId: 'history-r' }); expect(result.results.find(item => item.targetOccurrenceId === 'target-s')).toMatchObject({ lifecycle: 'possibly-identical', match: { decision: 'suggested' } }); }); it('is reproducible regardless of input order', () => { const definitions = [definition('def-a', 'A'), definition('def-b', 'B')]; const occurrences = [ occurrence({ id: 'left-a', runId: 'left', definitionId: 'def-a', ruleId: 'A-1', title: 'A' }), occurrence({ id: 'left-b', runId: 'left', definitionId: 'def-b', ruleId: 'B-1', title: 'B' }), occurrence({ id: 'right-b', runId: 'right', definitionId: 'def-b', ruleId: 'B-1', title: 'B' }), occurrence({ id: 'right-a', runId: 'right', definitionId: 'def-a', ruleId: 'A-1', title: 'A' }) ]; const forward = model({ runs: [run('left', 1), run('right', 2)], definitions, occurrences }); const reversed = model({ runs: [...forward.scanRuns].reverse(), definitions: [...definitions].reverse(), occurrences: [...occurrences].reverse() }); expect(compareScanRuns(reversed, 'left', 'right')).toEqual(compareScanRuns(forward, 'left', 'right')); }); it('matches cross-tool controls only when the framework identity is unique', () => { const definitions = [definition('left-def', 'Control from scanner A', '1.1.1'), definition('right-def', 'Control from scanner B', '1.1.1')]; const result = compareScanRuns(model({ runs: [run('left', 1, 'cis-cat'), run('right', 2, 'maester')], definitions, occurrences: [ occurrence({ id: 'left-control', runId: 'left', definitionId: 'left-def', title: 'Control from scanner A', toolId: 'cis-cat' }), occurrence({ id: 'right-control', runId: 'right', definitionId: 'right-def', title: 'Control from scanner B', toolId: 'maester' }) ] }), 'left', 'right'); expect(result.results[0]).toMatchObject({ lifecycle: 'persistent', match: { method: 'framework-control', decision: 'automatic' } }); }); it('never turns an ambiguous duplicate identity into an automatic match', () => { const definitions = [definition('left-1-def', 'Duplicate title'), definition('left-2-def', 'Duplicate title'), definition('right-def', 'Duplicate title')]; const result = compareScanRuns(model({ runs: [run('left', 1), run('right', 2)], definitions, occurrences: [ occurrence({ id: 'left-1', runId: 'left', definitionId: 'left-1-def', ruleId: 'DUP-1', title: 'Duplicate title' }), occurrence({ id: 'left-2', runId: 'left', definitionId: 'left-2-def', ruleId: 'DUP-1', title: 'Duplicate title' }), occurrence({ id: 'right-1', runId: 'right', definitionId: 'right-def', ruleId: 'DUP-1', title: 'Duplicate title' }) ] }), 'left', 'right'); expect(result.results.some(item => item.lifecycle === 'persistent')).toBe(false); expect(result.results.find(item => item.targetOccurrenceId === 'right-1')?.match.decision).toBe('suggested'); }); it('reuses an explicitly confirmed suggestion as a local mapping rule', () => { const definitions = [definition('old-def', 'Renamed security check'), definition('new-def', 'Renamed security check')]; const left = occurrence({ id: 'left-old', runId: 'left', definitionId: 'old-def', ruleId: 'OLD-42', title: 'Renamed security check' }); const right = occurrence({ id: 'right-new', runId: 'right', definitionId: 'new-def', ruleId: 'NEW-42', title: 'Renamed security check' }); const comparisonModel = model({ runs: [run('left', 1), run('right', 2)], definitions, occurrences: [left, right] }); expect(compareScanRuns(comparisonModel, 'left', 'right').results[0].match.decision).toBe('suggested'); const rule = createLocalComparisonRule('confirmed-rename-42', left, right, definitions, 'Vom Benutzer bestÃĪtigt.'); const confirmed = compareScanRuns(comparisonModel, 'left', 'right', { localRules: [rule] }); expect(confirmed.results[0]).toMatchObject({ lifecycle: 'persistent', match: { method: 'local-rule', decision: 'user-confirmed', confidence: 1 } }); }); it('keeps exact title and asset matches as suggestions until a local rule is confirmed', () => { const definitions = [definition('left-title', 'Same title'), definition('right-title', 'Same title')]; const left = occurrence({ id: 'left-title-only', runId: 'left', definitionId: 'left-title', title: 'Same title', asset: 'Synthetic host' }); const right = occurrence({ id: 'right-title-only', runId: 'right', definitionId: 'right-title', title: 'Same title', asset: 'Synthetic host' }); const comparisonModel = model({ runs: [run('left', 1), run('right', 2)], definitions, occurrences: [left, right] }); const suggested = compareScanRuns(comparisonModel, 'left', 'right'); expect(suggested.results[0]).toMatchObject({ lifecycle: 'possibly-identical', match: { method: 'similarity-suggestion', decision: 'suggested' } }); const localRule = createLocalComparisonRule('confirmed-title-and-asset', left, right, definitions); expect(localRule).toMatchObject({ source: 'user-confirmed', left: { title: 'same title', asset: 'synthetic host' } }); const confirmed = compareScanRuns(comparisonModel, 'left', 'right', { localRules: [localRule] }); expect(confirmed.results[0]).toMatchObject({ lifecycle: 'persistent', match: { method: 'local-rule', decision: 'user-confirmed' } }); const duplicate = occurrence({ id: 'left-title-duplicate', runId: 'left', definitionId: 'left-title', title: 'Same title', asset: 'Synthetic host' }); const ambiguousModel = model({ runs: [run('left', 1), run('right', 2)], definitions, occurrences: [left, duplicate, right] }); expect(compareScanRuns(ambiguousModel, 'left', 'right', { localRules: [localRule] }).results .some(item => item.match.method === 'local-rule')).toBe(false); }); it('marks unrelated assessment runs as not comparable', () => { const definitions = [definition('left-def', 'Left'), definition('right-def', 'Right')]; const result = compareScanRuns(model({ runs: [run('left', 1, 'pingcastle', 'assessment-a'), run('right', 2, 'pingcastle', 'assessment-b')], definitions, occurrences: [ occurrence({ id: 'left-item', runId: 'left', definitionId: 'left-def', ruleId: 'LEFT-1', title: 'Left' }), occurrence({ id: 'right-item', runId: 'right', definitionId: 'right-def', ruleId: 'RIGHT-1', title: 'Right' }) ] }), 'left', 'right'); expect(result.summary.notComparable).toBe(2); expect(result.diagnostics[0]).toContain('unterschiedlichen Assessments'); }); });