175 lines
7.9 KiB
TypeScript
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']
|
|
});
|
|
});
|
|
});
|