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'] }); }); });