Assessor_2.0/tests/comparison/timeslot-comparison-engine....

152 lines
7.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
CanonicalAssessmentModel,
CanonicalFindingStatus,
FindingOccurrence,
ImportFileRecord
} from '../../src/domain/canonical-model';
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import { compareProjectTimeslotEvidence } from '../../src/comparison/timeslot-comparison-engine';
const observedA = '2026-01-01T10:00:00.000Z';
const observedB = '2026-02-01T10:00:00.000Z';
const provenance = {
decisionSource: 'source-native' as const,
confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] },
mappingVersion: '1'
};
function occurrence(id: string, runId: string, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
return {
id, definitionId: `definition-${ruleId}`, scanRunId: runId, importFileId: `file-${runId}`,
sourceIdentity: { tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, canonicalRuleId: ruleId },
sourcePosition: { rowIndex: 1 }, originalFields: [], fieldMappings: [],
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
status: { originalValue: status, normalizedValue: status, provenance },
outcome: {
originalValue: status === 'passed' ? 'pass' : 'fail',
normalizedValue: status === 'passed' ? 'pass' : 'fail', provenance
}
};
}
function importFile(runId: string): ImportFileRecord {
return {
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML',
sizeBytes: 10, importedAt: observedA, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' },
detection: { level: 'high', score: 1, reasons: ['synthetic'] },
processedWith: {
parserId: 'html', parserVersion: '1', profileId: 'cis-cat', profileVersion: '1.4.0',
mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0',
compatibilityKey: 'cis-cat-canonical-v1'
},
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
};
}
function observation(runId: string, property: string, value: string): EnvironmentObservation {
return {
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`,
tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, subject: { type: 'host' },
property, label: property, originalValue: value, normalizedValue: value,
valueType: property === 'report.generated_at' ? 'datetime' : 'string', state: 'observed',
source: {}, mappingVersion: '1', attributes: {}
};
}
function model(runId: string, observedAt: string, findings: FindingOccurrence[]): CanonicalAssessmentModel {
return {
schemaVersion: 1,
project: { id: 'project', name: 'Synthetic', createdAt: observedA, updatedAt: observedB, attributes: {} },
assessment: { id: `assessment-${runId}`, projectId: 'project', name: runId, createdAt: observedAt, updatedAt: observedAt },
scanRuns: [{
id: runId, assessmentId: `assessment-${runId}`, sequence: 1, label: runId,
tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: observedAt
}],
importFiles: [importFile(runId)], sourceTables: [],
environmentObservations: [
observation(runId, 'host.name', 'server-a'),
observation(runId, 'benchmark.name', 'benchmark-a'),
observation(runId, 'report.generated_at', observedAt)
],
findingDefinitions: [...new Set(findings.map(item => item.definitionId))].map(id => ({
id, canonicalKey: id, title: id, frameworkReferences: [], createdAt: observedA, updatedAt: observedAt
})),
findingOccurrences: findings, evidence: [], reviews: [], diagnostics: []
};
}
describe('conservative project timeslot comparison', () => {
it('counts explicit fail-to-pass, keeps absence unresolved and separates new deviations', () => {
const baseline = model('run-a', observedA, [
occurrence('a-1', 'run-a', 'RULE-1', 'failed'),
occurrence('a-2', 'run-a', 'RULE-2', 'failed')
]);
const target = model('run-b', observedB, [
occurrence('b-1', 'run-b', 'RULE-1', 'passed'),
occurrence('b-3', 'run-b', 'RULE-3', 'failed')
]);
const result = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(result.summary).toEqual({
baselineDeviations: 2, verifiedRemediated: 1, confirmedOpen: 0,
unverifiedAbsent: 1, notComparable: 0, notApplicable: 0, newDeviations: 1, regressedDeviations: 0
});
expect(result.sources[0].decisions.map(item => item.state).sort())
.toEqual(['unverified-absent', 'verified-remediated']);
});
it('separates a regression after a single verified pass from a new deviation and ambiguous history', () => {
const baseline = model('run-a', observedA, [
occurrence('a-1', 'run-a', 'RULE-1', 'passed'),
occurrence('a-2', 'run-a', 'RULE-2', 'passed'),
occurrence('a-2b', 'run-a', 'RULE-2', 'passed')
]);
const target = model('run-b', observedB, [
occurrence('b-1', 'run-b', 'RULE-1', 'failed'),
occurrence('b-2', 'run-b', 'RULE-2', 'failed'),
occurrence('b-3', 'run-b', 'RULE-3', 'failed')
]);
const result = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(result.version).toBe('1.1.0');
expect(result.sources[0].regressedDeviationOccurrenceIds).toEqual(['b-1']);
expect(result.sources[0].newDeviationOccurrenceIds).toEqual(['b-3']);
expect(result.summary).toMatchObject({ newDeviations: 1, regressedDeviations: 1, baselineDeviations: 0 });
});
it('reports a source of only one timeslot as not comparable instead of failing', () => {
const target = model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'failed')]);
const result = compareProjectTimeslotEvidence([target], [], ['run-b']);
expect(result.sources).toMatchObject([{ state: 'not-comparable', reasons: ['baseline-source-missing'] }]);
expect(result.summary).toMatchObject({ newDeviations: 0, regressedDeviations: 0 });
});
it('does not treat a missing scanner source or a same-time reimport as remediation', () => {
const baseline = model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]);
const otherBaseline = model('run-c', observedA, []);
otherBaseline.scanRuns[0].tool = { id: 'pingcastle', name: 'PingCastle' };
otherBaseline.importFiles[0].detectedTool = { id: 'pingcastle', name: 'PingCastle' };
otherBaseline.importFiles[0].processedWith.profileId = 'pingcastle';
otherBaseline.environmentObservations = [];
const missing = compareProjectTimeslotEvidence([baseline, otherBaseline], ['run-a'], ['run-c']);
expect(missing.summary).toMatchObject({ verifiedRemediated: 0, notComparable: 1 });
const target = model('run-b', observedA, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]);
const sameTime = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(sameTime.summary).toMatchObject({ verifiedRemediated: 0, unverifiedAbsent: 1 });
});
it('blocks scope changes and incompatible processing before lifecycle claims', () => {
const baseline = model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]);
const changedScope = model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]);
const host = changedScope.environmentObservations?.find(item => item.property === 'host.name');
if (host) host.normalizedValue = 'server-b';
expect(compareProjectTimeslotEvidence([baseline, changedScope], ['run-a'], ['run-b']).summary)
.toMatchObject({ verifiedRemediated: 0, notComparable: 1 });
const incompatible = model('run-c', observedB, [occurrence('c-1', 'run-c', 'RULE-1', 'passed')]);
incompatible.importFiles[0].processedWith.compatibilityKey = 'different-contract';
expect(compareProjectTimeslotEvidence([baseline, incompatible], ['run-a'], ['run-c']).summary)
.toMatchObject({ verifiedRemediated: 0, notComparable: 1 });
});
});