Assessor_2.0/tests/features/datahub-scan-analysis.test.ts

175 lines
7.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
CanonicalAssessmentModel,
CanonicalFindingStatus,
CanonicalSeverity,
FindingOccurrence,
FindingReview
} from '../../src/domain/canonical-model';
import { comparableTimeslotSources, compareProjectScanRuns, compareProjectTimeslots, mergeProjectModelsForComparison, summarizeTimeslotResults } from '../../src/features/project-import/datahub-scan-analysis';
import { canonicalModelForScanRun } from '../../src/features/project-import/load-canonical-project-workspace';
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1' };
const timestamp = '2026-07-01T08:00:00.000Z';
function occurrence(
id: string,
runId: string,
ruleId: string,
severity: CanonicalSeverity = 'high',
status: CanonicalFindingStatus = 'open'
): FindingOccurrence {
return {
id,
definitionId: `definition-${ruleId}`,
scanRunId: runId,
importFileId: `file-${runId}`,
sourceIdentity: { tool: { id: 'scanner', name: 'Scanner' }, canonicalRuleId: ruleId, originalTitle: ruleId },
sourcePosition: { rowIndex: 1 },
originalFields: [{ key: 'rule', label: 'Rule', value: ruleId }],
fieldMappings: [{ targetRole: 'rule-id', sourceFieldKeys: ['rule'], normalizedValue: ruleId, provenance }],
severity: { originalValue: severity, normalizedValue: severity, provenance },
status: { originalValue: status, normalizedValue: status, provenance }
};
}
function review(occurrenceId: string): FindingReview {
return {
id: `review-${occurrenceId}`,
target: { kind: 'occurrence', occurrenceId },
workflowStatus: 'in-progress',
priority: 'high',
includeInActionPlan: true,
markers: [],
createdAt: timestamp,
updatedAt: timestamp,
revision: 1
};
}
function model(assessmentId: string, runId: string, importedAt: string, occurrences: FindingOccurrence[], reviews: FindingReview[] = []): CanonicalAssessmentModel {
const definitionIds = [...new Set(occurrences.map(item => item.definitionId))];
return {
schemaVersion: 1,
project: { id: 'project', name: 'Synthetic client', createdAt: timestamp, updatedAt: timestamp, attributes: {} },
assessment: { id: assessmentId, projectId: 'project', name: assessmentId, createdAt: timestamp, updatedAt: importedAt },
scanRuns: [{ id: runId, assessmentId, sequence: 1, label: runId, tool: { id: 'scanner', name: 'Scanner' }, importedAt }],
importFiles: [],
sourceTables: [],
findingDefinitions: definitionIds.map(id => ({
id,
canonicalKey: id,
title: id,
frameworkReferences: [],
createdAt: timestamp,
updatedAt: importedAt
})),
findingOccurrences: occurrences,
evidence: [],
reviews,
diagnostics: []
};
}
describe('Datahub project scan analysis', () => {
it('compares runs from separate persisted assessments inside one client without mutating them', () => {
const baseline = model('assessment-a', 'run-a', '2026-04-18T08:00:00.000Z', [
occurrence('old-a', 'run-a', 'A'),
occurrence('old-b', 'run-a', 'OLD-LEGACY-PROTOCOL'),
occurrence('old-c', 'run-a', 'C')
]);
const target = model('assessment-b', 'run-b', '2026-07-27T08:00:00.000Z', [
occurrence('new-a', 'run-b', 'A', 'critical'),
occurrence('new-c', 'run-b', 'C'),
occurrence('new-d', 'run-b', 'NEW-RISKY-DELEGATION')
], [review('new-a')]);
const result = compareProjectScanRuns([baseline, target], 'run-b', 'run-a');
expect(result.comparison.summary).toMatchObject({ persistent: 2, resolved: 1, new: 1 });
expect(result).toMatchObject({ baselineOpen: 3, targetOpen: 3, resolved: 1, critical: 1, reviewed: 1 });
expect(result.resolvedPercent).toBe(33);
expect(result.criticalPercent).toBe(33);
expect(result.reviewedPercent).toBe(33);
expect(result.openChangePercent).toBe(0);
expect(baseline.scanRuns[0].assessmentId).toBe('assessment-a');
expect(target.scanRuns[0].assessmentId).toBe('assessment-b');
});
it('compares complete timeslots containing multiple scanner runs', () => {
const baselineA = model('baseline-a', 'baseline-run-a', '2026-07-22T08:00:00.000Z', [occurrence('baseline-a', 'baseline-run-a', 'A')]);
const baselineB = model('baseline-b', 'baseline-run-b', '2026-07-22T08:05:00.000Z', [occurrence('baseline-b', 'baseline-run-b', 'OLD-LEGACY-PROTOCOL')]);
const targetA = model('target-a', 'target-run-a', '2026-10-22T08:00:00.000Z', [occurrence('target-a', 'target-run-a', 'A')]);
const targetC = model('target-c', 'target-run-c', '2026-10-22T08:05:00.000Z', [occurrence('target-c', 'target-run-c', 'NEW-RISKY-DELEGATION')]);
const result = compareProjectTimeslots(
[baselineA, baselineB, targetA, targetC],
['baseline-run-a', 'baseline-run-b'],
['target-run-a', 'target-run-c'],
'2026-07-22T12:00:00.000Z',
'2026-10-22T12:00:00.000Z'
);
expect(result.comparison.summary).toMatchObject({ persistent: 1, resolved: 1, new: 1 });
expect(result).toMatchObject({ baselineOpen: 2, targetOpen: 2, resolved: 1 });
});
it('summarizes result status values from every scan run in the selected timeslot', () => {
const selected = model('selected', 'run-selected', timestamp, [
occurrence('failed', 'run-selected', 'FAILED', 'high', 'failed'),
occurrence('investigate', 'run-selected', 'INVESTIGATE', 'medium', 'investigate'),
occurrence('passed', 'run-selected', 'PASSED', 'info', 'passed'),
occurrence('not-applicable', 'run-selected', 'NOT-APPLICABLE', 'info', 'not-applicable'),
occurrence('not-run', 'run-selected', 'NOT-RUN', 'unknown', 'not-run'),
occurrence('error', 'run-selected', 'ERROR', 'unknown', 'error')
]);
const ignored = model('ignored', 'run-ignored', timestamp, [
occurrence('ignored', 'run-ignored', 'IGNORED', 'critical', 'failed')
]);
expect(summarizeTimeslotResults([selected, ignored], ['run-selected'])).toEqual({
all: 6,
deviation: 2,
passed: 1,
notApplicable: 1,
notRun: 1,
error: 1
});
});
it('filters the Work projection to the clicked scan date', () => {
const first = model('assessment-a', 'run-a', '2026-04-18T08:00:00.000Z', [occurrence('old-a', 'run-a', 'A')]);
const second = model('assessment-b', 'run-b', '2026-07-27T08:00:00.000Z', [occurrence('new-b', 'run-b', 'B')]);
const merged = mergeProjectModelsForComparison([first, second]);
const selected = canonicalModelForScanRun(merged, 'run-b');
expect(selected?.scanRuns.map(run => run.id)).toEqual(['run-b']);
expect(selected?.findingOccurrences.map(item => item.id)).toEqual(['new-b']);
expect(selected?.findingDefinitions.map(item => item.id)).toEqual(['definition-B']);
});
it('rejects models from different clients', () => {
const first = model('assessment-a', 'run-a', timestamp, []);
const second = model('assessment-b', 'run-b', timestamp, []);
second.project = { ...second.project, id: 'other-project' };
expect(() => mergeProjectModelsForComparison([first, second])).toThrow(/Mandanten/);
});
it('opens only source kinds present in every selected timeslot', () => {
const runs = [
{ id: 'old-ping', assessmentId: 'old', sequence: 1, label: 'Ping', tool: { id: 'pingcastle', name: 'PingCastle' }, importedAt: timestamp },
{ id: 'old-cis', assessmentId: 'old', sequence: 2, label: 'CIS', tool: { id: 'cis-cat', name: 'CIS-CAT' }, importedAt: timestamp },
{ id: 'new-ping', assessmentId: 'new', sequence: 1, label: 'Ping', tool: { id: 'pingcastle', name: 'PingCastle' }, importedAt: timestamp }
];
expect(comparableTimeslotSources([
{ id: 'old', runIds: ['old-ping', 'old-cis'], hasGpo: true },
{ id: 'new', runIds: ['new-ping'], hasGpo: false }
], runs)).toEqual({
runIds: ['old-ping', 'new-ping'],
toolIds: ['pingcastle'],
includesGpo: false,
excludedSourceIds: ['cis-cat', 'gpo-backup']
});
});
});