import { describe, expect, it } from 'vitest'; import type { CanonicalAssessmentModel, CanonicalFindingStatus, CanonicalSeverity, FindingOccurrence, ImportFileRecord, MappingProvenance, ScanRun, ToolIdentity } from '../../src/domain/canonical-model'; import { assertCanonicalAssessmentModel, validateCanonicalAssessmentModel } from '../../src/domain/canonical-validation'; const timestamp = '2026-07-17T08:00:00.000Z'; const provenance = (mappingVersion = 'mapping-1'): MappingProvenance => ({ decisionSource: 'automatic', confidence: { level: 'high', score: 0.95, reasons: ['synthetic-test-signal'] }, mappingVersion }); const tools: Record = { purple: { id: 'purple-knight', name: 'Purple Knight', version: 'synthetic' }, ping: { id: 'pingcastle', name: 'PingCastle', version: 'synthetic' }, cis: { id: 'cis-cat', name: 'CIS-CAT', version: 'synthetic' }, maester: { id: 'maester', name: 'Maester', version: 'synthetic' } }; const run = (id: string, sequence: number, tool: ToolIdentity): ScanRun => ({ id, assessmentId: 'assessment-1', sequence, label: `${tool.name} run ${sequence}`, tool, importedAt: timestamp }); const importFile = (id: string, scanRunId: string, tool: ToolIdentity, format: string): ImportFileRecord => ({ id, scanRunId, fileName: `${id}.${format.toLocaleLowerCase()}`, format, sizeBytes: 100, importedAt: timestamp, detectedTool: tool, detection: { level: 'high', score: 0.98, reasons: ['fixture marker'] }, processedWith: { parserId: `${format.toLocaleLowerCase()}-parser`, parserVersion: '1', profileId: tool.id, profileVersion: '1', mappingVersion: '1' }, sourceArtifact: { kind: 'embedded', bytesBase64: 'AA==' } }); function occurrence(input: { id: string; definitionId: string; scanRunId: string; importFileId: string; sourceTableId: string; tool: ToolIdentity; ruleId: string; title: string; originalSeverity: string; severity: CanonicalSeverity; originalStatus: string; status: CanonicalFindingStatus; }): FindingOccurrence { return { id: input.id, definitionId: input.definitionId, scanRunId: input.scanRunId, importFileId: input.importFileId, sourceTableId: input.sourceTableId, sourceIdentity: { tool: input.tool, originalRuleId: input.ruleId, canonicalRuleId: input.definitionId, originalTitle: input.title }, sourcePosition: { rowIndex: 1 }, originalFields: [ { key: 'id', label: 'ID', value: input.ruleId, columnIndex: 0 }, { key: 'title', label: 'Title', value: input.title, columnIndex: 1 }, { key: 'severity', label: 'Severity', value: input.originalSeverity, columnIndex: 2 }, { key: 'status', label: 'Status', value: input.originalStatus, columnIndex: 3 } ], fieldMappings: [ { targetRole: 'rule-id', sourceFieldKeys: ['id'], normalizedValue: input.definitionId, provenance: provenance() }, { targetRole: 'title', sourceFieldKeys: ['title'], normalizedValue: input.title, provenance: provenance() } ], severity: { originalValue: input.originalSeverity, normalizedValue: input.severity, provenance: provenance() }, status: { originalValue: input.originalStatus, normalizedValue: input.status, provenance: provenance() } }; } function modelFixture(): CanonicalAssessmentModel { const scanRuns = [ run('run-purple', 1, tools.purple), run('run-ping-previous', 2, tools.ping), run('run-ping-current', 3, tools.ping), run('run-cis', 4, tools.cis), run('run-maester', 5, tools.maester) ]; const importFiles = [ importFile('file-purple', 'run-purple', tools.purple, 'JSON'), importFile('file-ping-previous', 'run-ping-previous', tools.ping, 'HTML'), importFile('file-ping-current', 'run-ping-current', tools.ping, 'HTML'), importFile('file-cis', 'run-cis', tools.cis, 'HTML'), importFile('file-maester', 'run-maester', tools.maester, 'CSV') ]; const sourceTables = importFiles.map((file, index) => ({ id: `table-${index + 1}`, importFileId: file.id, name: 'Synthetic results', index: 0, rowCount: 2, columnCount: 4, attributes: {} })); const findingDefinitions = [ { id: 'definition-purple', canonicalKey: 'purple:pk-001', title: 'Purple Knight synthetic finding', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp }, { id: 'definition-ping', canonicalKey: 'pingcastle:s-synthetic', title: 'PingCastle synthetic finding', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp }, { id: 'definition-cis', canonicalKey: 'cis:1.1.1', title: 'CIS-CAT synthetic finding', frameworkReferences: [{ framework: 'CIS', controlId: '1.1.1' }], createdAt: timestamp, updatedAt: timestamp }, { id: 'definition-maester', canonicalKey: 'maester:mt.1001', title: 'Maester synthetic finding', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp } ]; const findingOccurrences = [ occurrence({ id: 'occurrence-purple', definitionId: 'definition-purple', scanRunId: 'run-purple', importFileId: 'file-purple', sourceTableId: 'table-1', tool: tools.purple, ruleId: 'PK-001', title: 'Purple Knight synthetic finding', originalSeverity: 'Severe', severity: 'critical', originalStatus: 'Failed', status: 'failed' }), occurrence({ id: 'occurrence-ping-previous', definitionId: 'definition-ping', scanRunId: 'run-ping-previous', importFileId: 'file-ping-previous', sourceTableId: 'table-2', tool: tools.ping, ruleId: 'S-Synthetic', title: 'PingCastle synthetic finding', originalSeverity: '20 points', severity: 'high', originalStatus: 'Matched', status: 'open' }), occurrence({ id: 'occurrence-ping-current', definitionId: 'definition-ping', scanRunId: 'run-ping-current', importFileId: 'file-ping-current', sourceTableId: 'table-3', tool: tools.ping, ruleId: 'S-Synthetic', title: 'PingCastle synthetic finding', originalSeverity: '20 points', severity: 'high', originalStatus: 'Matched', status: 'open' }), occurrence({ id: 'occurrence-cis', definitionId: 'definition-cis', scanRunId: 'run-cis', importFileId: 'file-cis', sourceTableId: 'table-4', tool: tools.cis, ruleId: '1.1.1', title: 'CIS-CAT synthetic finding', originalSeverity: 'Level 1', severity: 'medium', originalStatus: 'Fail', status: 'failed' }), occurrence({ id: 'occurrence-maester', definitionId: 'definition-maester', scanRunId: 'run-maester', importFileId: 'file-maester', sourceTableId: 'table-5', tool: tools.maester, ruleId: 'MT.1001', title: 'Maester synthetic finding', originalSeverity: 'High', severity: 'high', originalStatus: 'Failed', status: 'failed' }) ]; return { schemaVersion: 1, project: { id: 'project-1', name: 'Synthetic project', createdAt: timestamp, updatedAt: timestamp, attributes: {} }, assessment: { id: 'assessment-1', projectId: 'project-1', name: 'Synthetic assessment', createdAt: timestamp, updatedAt: timestamp }, scanRuns, importFiles, sourceTables, findingDefinitions, findingOccurrences, evidence: [{ id: 'evidence-1', occurrenceId: 'occurrence-maester', kind: 'text', value: 'Synthetic result detail', sourceFieldKeys: ['resultDetail'] }], reviews: [{ id: 'review-1', target: { kind: 'occurrence', occurrenceId: 'occurrence-maester' }, workflowStatus: 'open', priority: 'high', includeInActionPlan: true, markers: [], createdAt: timestamp, updatedAt: timestamp, revision: 1 }], diagnostics: [] }; } describe('canonical assessment model', () => { it('represents all pilot tools and reuses one definition across scan runs', () => { const model = modelFixture(); expect(validateCanonicalAssessmentModel(model)).toEqual([]); expect(new Set(model.scanRuns.map(item => item.tool.id))).toEqual(new Set(['purple-knight', 'pingcastle', 'cis-cat', 'maester'])); expect(model.findingOccurrences.filter(item => item.definitionId === 'definition-ping')).toHaveLength(2); expect(model.findingDefinitions.filter(item => item.id === 'definition-ping')).toHaveLength(1); }); it('keeps original values and normalized values independently', () => { const model = modelFixture(); const purpleKnight = model.findingOccurrences.find(item => item.id === 'occurrence-purple'); expect(purpleKnight?.severity.originalValue).toBe('Severe'); expect(purpleKnight?.severity.normalizedValue).toBe('critical'); expect(purpleKnight?.originalFields).toContainEqual({ key: 'status', label: 'Status', value: 'Failed', columnIndex: 3 }); }); it('keeps review state outside imported finding occurrences', () => { const model = modelFixture(); const occurrenceBefore = JSON.stringify(model.findingOccurrences[4]); model.reviews[0].comment = 'Reviewed independently'; model.reviews[0].revision += 1; expect(JSON.stringify(model.findingOccurrences[4])).toBe(occurrenceBefore); expect(validateCanonicalAssessmentModel(model)).toEqual([]); }); it('rejects contradictory identity metadata and duplicate canonical keys', () => { const model = modelFixture(); model.findingDefinitions[0].identity = { version: 1, strategy: 'unresolved', durable: true, evidence: [] }; model.findingDefinitions[1].canonicalKey = model.findingDefinitions[0].canonicalKey; const issues = validateCanonicalAssessmentModel(model); expect(issues.map(issue => issue.code)).toEqual(expect.arrayContaining([ 'invalid-finding-identity', 'duplicate-canonical-key' ])); }); it('reports broken references, missing source data, and invalid confidence', () => { const model = modelFixture(); model.findingOccurrences[0].definitionId = 'missing-definition'; model.findingOccurrences[0].originalFields = []; model.findingOccurrences[0].severity.provenance.confidence.score = 1.5; model.reviews[0].target = { kind: 'occurrence', occurrenceId: 'missing-occurrence' }; model.importFiles.push({ ...model.importFiles[0] }); const issues = validateCanonicalAssessmentModel(model); expect(issues.map(issue => issue.code)).toEqual(expect.arrayContaining([ 'duplicate-id', 'missing-reference', 'missing-original-fields', 'invalid-confidence' ])); expect(() => assertCanonicalAssessmentModel(model)).toThrow('Ungültiges kanonisches Assessment-Modell'); }); });