import { describe, expect, it } from 'vitest'; import type { ImportFileRecord } from '../../src/domain/canonical-model'; import { summarizeClientProcessingReadiness, summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness'; import { importSource } from '../../src/importers/engine'; import { importProfiles } from '../../src/profiles/profile-catalog'; import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters'; import { createCanonicalImportBatch } from '../../src/importers/canonical-import'; import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow'; function file(scanRunId: string, profileId: string, profileVersion: string, compatibilityKey: string): ImportFileRecord { return { id: `${scanRunId}-${profileVersion}`, scanRunId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 10, importedAt: '2026-08-22T08:00:00.000Z', detectedTool: { id: profileId, name: profileId }, detection: { level: 'high', score: 1, reasons: [] }, processedWith: { parserId: 'synthetic', parserVersion: '1.0.0', profileId, profileVersion, mappingVersion: profileVersion, normalizationVersion: profileVersion, presentationVersion: profileVersion, compatibilityKey }, sourceArtifact: { kind: 'not-retained', reason: 'synthetic' } }; } const currentPurpleKnight = (scanRunId: string) => file(scanRunId, 'purple-knight', '1.3.0', 'purple-knight-canonical-v1.1'); function withDetectedTool(fileRecord: ImportFileRecord, id: string): ImportFileRecord { return { ...fileRecord, detectedTool: { id, name: 'Microsoft Defender for IoT' } }; } describe('Datahub client processing readiness', () => { it('keeps a complete 22-file PurpleKnight import ready after serialization and repeated import', async () => { const sources = [ { name: 'Assessment_summary.csv', text: 'Metric;Value\nAD: Security posture score;65\nAD: Number of domains;1' }, { name: 'Security_Assessment_Report_synthetic_(Indicators_results).csv', text: 'Short Name;Name;SI Version;Status;Weight;Number of results\nSI900001;Synthetic check;1;Passed;1;0' }, ...Array.from({ length: 20 }, (_, index) => ({ name: `support-${index}.csv`, text: 'Object;Value\nSynthetic object;Synthetic value' })) ]; const entries = await Promise.all(sources.map(async (source, index) => { const bytes = new TextEncoder().encode(source.text); const dataset = await importSource({ metadata: { name: source.name, sizeBytes: bytes.length, lastModified: 0 }, bytes }, { profiles: importProfiles }, nodeImportRuntimeAdapters); return confirmEntryMappings(previewEntryFromDataset(dataset, { fileName: source.name, sizeBytes: bytes.length, lastModified: 0, sha256: index.toString(16).padStart(64, '0') }), importProfiles); })); for (const batchId of ['initial', 'reimported']) { const model = createCanonicalImportBatch(batchId, entries).model; const files: ImportFileRecord[] = JSON.parse(JSON.stringify(model.importFiles)); const before = structuredClone(files); expect(model.scanRuns[0].tool.id).toBe('purple-knight'); expect(files).toHaveLength(22); expect(files.filter(file => file.processedWith.profileId === 'generic')).toHaveLength(20); expect(summarizeScanProcessingReadiness(files).state).toBe('ready'); expect(summarizeClientProcessingReadiness(files, model.scanRuns.map(run => run.id)).state).toBe('ready'); expect(files).toEqual(before); } }); it('does not borrow recognition from another run, even in the same timeslot', () => { const files = [currentPurpleKnight('recognized'), file('separate', 'generic', '1.0.0', 'generic-canonical-v1')]; expect(summarizeScanProcessingReadiness(files).state).toBe('review'); expect(summarizeClientProcessingReadiness(files, ['recognized', 'separate']).state).toBe('review'); expect(summarizeClientProcessingReadiness(files, ['recognized']).state).toBe('ready'); }); it('checks each companion contract and keeps actual updates and unresolved reviews visible', () => { const companion = file('run', 'generic', '1.0.0', 'generic-canonical-v1'); expect(summarizeScanProcessingReadiness([currentPurpleKnight('run'), companion]).state).toBe('ready'); expect(summarizeScanProcessingReadiness([file('run', 'purple-knight', '1.2.0', 'purple-knight-canonical-v1.1'), companion]).state).toBe('update'); expect(summarizeScanProcessingReadiness([currentPurpleKnight('run'), { ...companion, processedWith: { ...companion.processedWith, profileVersion: 'unknown' } }]).state).toBe('error'); expect(summarizeScanProcessingReadiness([currentPurpleKnight('run'), { ...companion, processedWith: { ...companion.processedWith, compatibilityKey: 'unsupported' } }]).state).toBe('error'); expect(summarizeScanProcessingReadiness([currentPurpleKnight('run'), { ...companion, reimportReviewTransfer: { replacesScanRunId: 'old', transferred: 0, retainedByStableIdentity: 0, unresolved: 1 } }]).state).toBe('review'); // A display tool label cannot turn a generic processor into a native one. expect(summarizeScanProcessingReadiness([{ ...companion, detectedTool: { id: 'purple-knight', name: 'PurpleKnight' } }]).state).toBe('review'); expect(summarizeScanProcessingReadiness([currentPurpleKnight('run'), companion, file('run', 'pingcastle', '1.5.0', 'pingcastle-canonical-v1.1')]).state).toBe('review'); }); it('marks GPO failure and recovery red, including clients with healthy scanner reports', () => { for (const state of ['dead-letter', 'recovery-required'] as const) { expect(summarizeClientProcessingReadiness([], [], [{ state }]).state).toBe('error'); expect(summarizeClientProcessingReadiness( [currentPurpleKnight('current')], ['current'], [{ state }] ).state).toBe('error'); } }); it('distinguishes informational GPO states from actionable scanner updates and errors', () => { expect(summarizeClientProcessingReadiness([], [], [{ state: 'succeeded' }]).state).toBe('ready'); for (const state of ['queued', 'running'] as const) { expect(summarizeClientProcessingReadiness([], [], [{ state }]).state).toBe('processing'); } expect(summarizeClientProcessingReadiness([], [], [{ state: 'partial' }])).toEqual({ state: 'ready', label: 'Processing ready across all client timeslots.' }); for (const state of ['queued', 'running', 'partial'] as const) { expect(summarizeClientProcessingReadiness( [file('old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')], ['old'], [{ state }] ).state).toBe('update'); } expect(summarizeClientProcessingReadiness( [file('bad', 'unknown', '1.0.0', 'unknown')], ['bad'], [{ state: 'succeeded' }] ).state).toBe('error'); }); it('marks current processing contracts green', () => { expect(summarizeClientProcessingReadiness( [currentPurpleKnight('run-current')], ['run-current'] )).toEqual({ state: 'ready', label: 'Processing ready across all client timeslots.' }); }); it('assesses the stored profile contract independently from the detected tool identity', () => { const defender = withDetectedTool( file('run-defender', 'defender-for-iot-vulnerabilities', '1.2.0', 'defender-for-iot-vulnerabilities-v2'), 'microsoft-defender-for-iot' ); defender.processedWith.mappingVersion = '1.0.0'; defender.processedWith.presentationVersion = '1.0.0'; expect(summarizeScanProcessingReadiness([defender])).toEqual({ state: 'ready', statuses: ['current'], explanation: 'The scan uses a current or compatible ASSESSOR processing profile.' }); }); it('marks reimport recommendations as a yellow update notice', () => { expect(summarizeClientProcessingReadiness( [file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')], ['run-old'] )).toEqual({ state: 'update', label: 'ASSESSOR processing profile update available in a client timeslot.' }); expect(summarizeScanProcessingReadiness([ file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1') ])).toMatchObject({ state: 'update', explanation: 'ASSESSOR profile update: 1.1.0 → 1.6.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.' }); }); it('marks contextless PingCastle 1.2 imports for reprocessing', () => { expect(summarizeScanProcessingReadiness([ file('run-contextless', 'pingcastle', '1.2.0', 'pingcastle-canonical-v1.1') ])).toMatchObject({ state: 'update', explanation: 'ASSESSOR profile update: 1.2.0 → 1.6.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.' }); }); it('reserves the error state for incompatible or unknown processing contracts', () => { expect(summarizeClientProcessingReadiness( [file('run-incompatible', 'pingcastle', '1.0.0', 'pingcastle-canonical-v1')], ['run-incompatible'] )).toEqual({ state: 'error', label: 'Processing error requires attention in a client timeslot.' }); expect(summarizeScanProcessingReadiness([ file('run-unknown', 'unknown-scanner', '1.0.0', 'unknown-v1') ])).toMatchObject({ state: 'error' }); }); it('keeps the client yellow while any active older timeslot requires reimport', () => { expect(summarizeClientProcessingReadiness( [ file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1'), currentPurpleKnight('run-current') ], ['run-current', 'run-old'] )).toEqual({ state: 'update', label: 'ASSESSOR processing profile update available in a client timeslot.' }); }); it('shows no point when the client has no scanner-backed timeslot', () => { expect(summarizeClientProcessingReadiness([], [])).toEqual({ state: 'no-data', label: 'No processing status available.' }); }); it('requires review when only generic tabular processing was possible', () => { expect(summarizeScanProcessingReadiness([ file('generic-run', 'generic', '1.0.0', 'generic-canonical-v1') ])).toMatchObject({ state: 'review' }); expect(summarizeClientProcessingReadiness( [file('generic-run', 'generic', '1.0.0', 'generic-canonical-v1')], ['generic-run'] )).toEqual({ state: 'review', label: 'A client timeslot requires review or a future reimport.' }); }); it('keeps unmatched client data visible as a persistent review state', () => { const imported = currentPurpleKnight('reimported'); imported.reimportReviewTransfer = { replacesScanRunId: 'previous', transferred: 2, retainedByStableIdentity: 1, unresolved: 1 }; expect(summarizeScanProcessingReadiness([imported])).toMatchObject({ state: 'review', explanation: '1 client-owned finding record could not be matched uniquely during reimport. The original data is retained and requires review.' }); }); });