Assessor_2.0/tests/domain/canonical-model.test.ts

227 lines
10 KiB
TypeScript

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<string, ToolIdentity> = {
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');
});
});