155 lines
8.8 KiB
TypeScript
155 lines
8.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 type { PolicyWorkResult, PolicyWorkStatus } from '../../src/evaluation/policy-work-results';
|
|
import { compareTimeslots } from '../../src/comparison/timeslot-comparison-report';
|
|
|
|
// Synthetic two-timeslot client: CIS-CAT in both, Maester only in B, GPO settings in both.
|
|
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'
|
|
};
|
|
const tools = { 'cis-cat': { id: 'cis-cat', name: 'CIS-CAT Pro' }, maester: { id: 'maester', name: 'Maester' } };
|
|
|
|
function occurrence(id: string, runId: string, toolId: keyof typeof tools, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
|
|
const outcome = status === 'passed' ? 'pass' : 'fail';
|
|
return {
|
|
id, definitionId: `definition-${ruleId}`, scanRunId: runId, importFileId: `file-${runId}`,
|
|
sourceIdentity: { tool: tools[toolId], canonicalRuleId: ruleId }, sourcePosition: { rowIndex: 1 },
|
|
originalFields: [], fieldMappings: [],
|
|
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
|
|
status: { originalValue: status, normalizedValue: status, provenance },
|
|
outcome: { originalValue: outcome, normalizedValue: outcome, provenance }
|
|
};
|
|
}
|
|
|
|
function importFile(runId: string, toolId: keyof typeof tools): ImportFileRecord {
|
|
return {
|
|
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML', sizeBytes: 10, importedAt: observedA,
|
|
detectedTool: tools[toolId], detection: { level: 'high', score: 1, reasons: ['synthetic'] },
|
|
processedWith: {
|
|
parserId: 'html', parserVersion: '1', profileId: toolId, profileVersion: '1.4.0', mappingVersion: '1.3.0',
|
|
normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: `${toolId}-canonical-v1`
|
|
},
|
|
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
|
|
};
|
|
}
|
|
|
|
function observation(runId: string, toolId: keyof typeof tools, property: string, value: string): EnvironmentObservation {
|
|
return {
|
|
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`, tool: tools[toolId],
|
|
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(slot: 'a' | 'b', runs: Array<{ runId: string; toolId: keyof typeof tools; findings: FindingOccurrence[] }>): CanonicalAssessmentModel {
|
|
const observedAt = slot === 'a' ? observedA : observedB;
|
|
const assessmentId = `assessment-${slot}`;
|
|
const findings = runs.flatMap(run => run.findings);
|
|
return {
|
|
schemaVersion: 1,
|
|
project: { id: 'dummy-project', name: 'Dummy Customer', createdAt: observedA, updatedAt: observedB, attributes: {} },
|
|
assessment: { id: assessmentId, projectId: 'dummy-project', name: slot, createdAt: observedAt, updatedAt: observedAt },
|
|
scanRuns: runs.map((run, index) => ({ id: run.runId, assessmentId, sequence: index + 1, label: run.runId, tool: tools[run.toolId], importedAt: observedAt })),
|
|
importFiles: runs.map(run => importFile(run.runId, run.toolId)), sourceTables: [],
|
|
environmentObservations: runs.flatMap(run => [
|
|
observation(run.runId, run.toolId, 'host.name', 'server-a'),
|
|
observation(run.runId, run.toolId, 'benchmark.name', 'benchmark-a'),
|
|
observation(run.runId, run.toolId, 'report.generated_at', observedAt),
|
|
observation(run.runId, run.toolId, 'report.engine_version', '4.0.0')
|
|
]),
|
|
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: []
|
|
};
|
|
}
|
|
|
|
function setting(id: string, identity: string, status: PolicyWorkStatus, actual?: number): PolicyWorkResult {
|
|
return {
|
|
id, status, title: identity, scope: 'computer', identity, importedAt: observedA, sources: [],
|
|
settingKey: JSON.stringify(['dummy-project', 'aggregation', 'computer', identity]),
|
|
comparison: { baselineId: 'baseline-server', comparator: { actual: actual === undefined ? undefined : { kind: 'number', value: actual } } } as unknown as PolicyWorkResult['comparison']
|
|
};
|
|
}
|
|
|
|
function reportInput(reversed = false) {
|
|
const order = <T,>(items: T[]) => reversed ? [...items].reverse() : items;
|
|
const a = model('a', [{ runId: 'cis-a', toolId: 'cis-cat', findings: [
|
|
occurrence('a-1', 'cis-a', 'cis-cat', '1.1.1', 'failed'),
|
|
occurrence('a-2', 'cis-a', 'cis-cat', '1.1.2', 'failed'),
|
|
occurrence('a-3', 'cis-a', 'cis-cat', '5.1', 'passed')
|
|
] }]);
|
|
const b = model('b', [
|
|
{ runId: 'cis-b', toolId: 'cis-cat', findings: [
|
|
occurrence('b-1', 'cis-b', 'cis-cat', '1.1.1', 'passed'),
|
|
occurrence('b-2', 'cis-b', 'cis-cat', '1.1.2', 'failed'),
|
|
occurrence('b-3', 'cis-b', 'cis-cat', '5.1', 'failed')
|
|
] },
|
|
{ runId: 'maester-b', toolId: 'maester', findings: [occurrence('m-1', 'maester-b', 'maester', 'MT.1001', 'failed')] }
|
|
]);
|
|
return {
|
|
projectId: 'dummy-project', models: order([a, b]), baselineRunIds: ['cis-a'], targetRunIds: order(['cis-b', 'maester-b']),
|
|
gpo: {
|
|
baseline: { observedAt: observedA, results: order([setting('ga-1', 'MinimumPasswordLength', 'Fail', 7), setting('ga-2', 'NoLMHash', 'Fail', 0)]) },
|
|
target: { observedAt: observedB, results: order([setting('gb-1', 'MinimumPasswordLength', 'Fail', 10), setting('gb-2', 'NoLMHash', 'Pass', 1)]) }
|
|
}
|
|
};
|
|
}
|
|
const report = (reversed = false) => compareTimeslots(reportInput(reversed));
|
|
|
|
describe('Timeslot comparison report', () => {
|
|
it('assigns every change category from scanner evidence and rolled-up GPO verdicts', () => {
|
|
const result = report();
|
|
expect(result.summary).toEqual({
|
|
resolved: 2, unchanged: 1, 'value-changed': 1, unverified: 0, 'not-comparable': 0, 'not-applicable': 0,
|
|
new: 0, regressed: 1, targetNotComparable: 1
|
|
});
|
|
const find = (change: string) => result.changes.filter(item => item.change === change).map(item => item.recordIds).flat().sort();
|
|
expect(find('resolved')).toEqual(['a-1', 'ga-2']);
|
|
expect(find('value-changed')).toEqual(['ga-1']);
|
|
expect(find('regressed')).toEqual(['b-3']);
|
|
// A source only in B is neither new nor worse.
|
|
expect(result.changes.find(item => item.change === 'not-comparable')).toMatchObject({ sourceId: 'maester', side: 'target', recordIds: ['m-1'] });
|
|
});
|
|
|
|
it('limits the score delta to sources comparable in both timeslots and lists every excluded source', () => {
|
|
const result = report();
|
|
const gpo = result.score.sources.find(item => item.sourceId === 'group-policy');
|
|
expect(gpo).toMatchObject({ method: 'shared-gpo-pass-rate', baselineScore: 0, targetScore: 50, change: 50 });
|
|
expect(result.score.excludedSources).toContainEqual({ sourceId: 'maester', reasons: ['baseline-source-missing'] });
|
|
const scored = result.score.sources.filter(item => item.change !== undefined);
|
|
expect(scored.map(item => item.sourceId)).toEqual(['cis-cat', 'group-policy']);
|
|
// Same three evaluated rules in A and B: one pass each, so the paired CIS-CAT pass rate is unchanged.
|
|
expect(result.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ method: 'paired-scans', change: 0 });
|
|
const weight = scored.reduce((sum, item) => sum + item.weight, 0);
|
|
expect(result.score.targetScore).toBeCloseTo(scored.reduce((sum, item) => sum + item.targetScore! * item.weight, 0) / weight);
|
|
});
|
|
|
|
it('keeps the score delta for report times without an ISO clock time, as CIS-CAT writes them', () => {
|
|
const result = compareTimeslots({ ...reportInput(), models: reportInput().models.map(model => ({ ...model,
|
|
environmentObservations: model.environmentObservations?.map(item => item.property === 'report.generated_at'
|
|
? { ...item, normalizedValue: model.assessment.id.endsWith('a') ? 'Thursday, January 1 2026 10:00:00' : 'Sunday, February 1 2026 10:00:00' } : item) })) });
|
|
expect(result.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ change: 0 });
|
|
expect(result.summary.resolved).toBe(2);
|
|
});
|
|
|
|
it('is deterministic and independent of the input order', () => {
|
|
const first = report();
|
|
const reversed = report(true);
|
|
expect(reversed.summary).toEqual(first.summary);
|
|
expect(reversed.changes).toEqual(first.changes);
|
|
expect(reversed.score).toEqual(first.score);
|
|
});
|
|
});
|