150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
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';
|
|
|
|
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' }
|
|
};
|
|
}
|
|
|
|
function withDetectedTool(fileRecord: ImportFileRecord, id: string): ImportFileRecord {
|
|
return { ...fileRecord, detectedTool: { id, name: 'Microsoft Defender for IoT' } };
|
|
}
|
|
const currentPurpleKnight = (scanRunId: string) => file(scanRunId, 'purple-knight', '1.3.0', 'purple-knight-canonical-v1.1');
|
|
|
|
describe('Datahub client processing readiness', () => {
|
|
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.'
|
|
});
|
|
});
|
|
});
|