// @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, expect, it, vi } from 'vitest'; import { DataHub } from '../../src/features/project-import/DataHub'; import { withReferenceReadiness } from '../../src/features/project-import/datahub-processing-readiness'; const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(), listProjectImportFiles: vi.fn(), listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(), getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn(), getReferenceRecognitionStatus: vi.fn(), recognizeReferences: vi.fn(), refreshMitreCatalog: vi.fn(), listProjectAssessments: vi.fn(), loadAssessmentModel: vi.fn() })); vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class { health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects; listProjectImportFiles = api.listProjectImportFiles; listProjectAssessmentHistory = api.listProjectAssessmentHistory; listProjectGpoBackupStatus = api.listProjectGpoBackupStatus; getTimeslotResultMetrics = api.getTimeslotResultMetrics; queryUnifiedResultFacets = api.queryUnifiedResultFacets; getReferenceRecognitionStatus = api.getReferenceRecognitionStatus; recognizeReferences = api.recognizeReferences; refreshMitreCatalog = api.refreshMitreCatalog; listProjectAssessments = api.listProjectAssessments; loadAssessmentModel = api.loadAssessmentModel; } })); vi.mock('../../src/features/project-import/MicrosoftBaselineLibrary', () => ({ MicrosoftBaselineLibrary: () => null })); vi.mock('../../src/features/file-import/FileImport', () => ({ FileImport: () => null })); vi.mock('../../src/features/project-import/GpoFolderImport', () => ({ GpoFolderImport: () => null })); afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); const projectId = 'synthetic-reference-client'; const referenceStatus = (pendingScans: number, indexedScans = 2) => ({ totalScans: 2, indexedScans, pendingScans, catalogs: [{ framework: 'mitre-attack', version: '19.2', sha256: 'synthetic-hash' }] }); function fixture() { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const date = '2026-01-01T00:00:00Z'; api.health.mockResolvedValue({ status: 'ok', repositoryRevision: 801 }); api.getMitreCatalog.mockResolvedValue({ catalog: { version: '19.2', tacticOrder: ['Execution'], techniques: [{ id: 'T1059', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } }); api.listProjects.mockResolvedValue([{ id: projectId, name: 'Synthetic client', attributes: {}, createdAt: date, updatedAt: date }]); api.listProjectImportFiles.mockResolvedValue([]); api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] }); api.listProjectAssessmentHistory.mockResolvedValue({ version: 1, projectId, assessments: ['first', 'second'].map(id => ({ id, projectId, name: `Synthetic ${id}`, createdAt: date, updatedAt: date, effectiveAt: date })), scanRuns: ['first', 'second'].map(id => ({ id: `${id}-run`, assessmentId: id, sequence: 1, label: 'Synthetic scan', importedAt: date, tool: { id: 'synthetic', name: 'Synthetic scanner' } })) }); api.getTimeslotResultMetrics.mockResolvedValue({ all: 2, deviation: 1, passed: 1, notApplicable: 0, notRun: 0, error: 0 }); api.queryUnifiedResultFacets.mockResolvedValue({ total: 2, facets: [] }); api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2, 0)); api.recognizeReferences.mockImplementation(async () => { api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0)); return { processedScans: 2, unchangedScans: 0, references: 4 }; }); } it.each([0, 2])('warns before client selection for pending references (indexed=%i), and clears the warning after Manage recognition', async indexedScans => { fixture(); api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2, indexedScans)); const changed = vi.fn(); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render( undefined} onProjectDataChanged={changed}/>)); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('update'); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('update'); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('title')).toContain('2 scans require reference recognition'); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); const panel = host.querySelector('.tenant-detail-navigation .reference-recognition'); expect(panel?.nextElementSibling?.classList.contains('tenant-timeslots')).toBe(true); expect(host.querySelector('.tenant-information-sheet .reference-recognition')).toBeNull(); expect(panel?.textContent).toContain('Catalog references'); const disclosure = panel!.querySelector('details')!; expect(disclosure.open).toBe(true); expect(disclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention'); await act(async () => disclosure.querySelector('summary')!.click()); expect(disclosure.open).toBe(false); await act(async () => disclosure.querySelector('summary')!.click()); expect(disclosure.open).toBe(true); expect(panel?.querySelector('[role="status"]')?.textContent).toContain('Please update'); expect(panel?.textContent).not.toContain('Recognize ATT&CK and D3FEND references in'); const button = panel!.querySelector('button')!; expect(button.textContent).toBe('Update references'); expect(button.disabled).toBe(false); expect(button.dataset.attention).toBe('true'); expect(button.classList.contains('primary')).toBe(true); await act(async () => button.click()); expect(api.recognizeReferences).toHaveBeenCalledExactlyOnceWith(projectId); expect(changed).toHaveBeenCalledExactlyOnceWith(projectId); expect(panel?.querySelector('[role="status"]')?.textContent).toContain('Update successful'); expect(panel?.querySelector('[role="status"]')?.textContent).toContain('No action needed.'); expect(button.dataset.attention).toBe('false'); expect(disclosure.open).toBe(true); expect(disclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready'); expect(disclosure.querySelector('[role="status"] .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready'); await act(async () => host.querySelector('.tenant-edit-client')!.click()); expect(host.querySelector('.tenant-detail-navigation .reference-recognition')).toBe(panel); await act(async () => host.querySelector('.tenant-back')!.click()); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready'); expect(host.querySelector('.datahub-tenant-show')).toBeNull(); await act(async () => host.querySelector('.datahub-tenant-select')!.click()); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); expect(host.querySelector('.reference-recognition [role="status"]')?.textContent).toBe('Up to dateNo action needed.'); expect(host.querySelector('.reference-recognition-versions')?.textContent).toContain('ATT&CK 19.2'); const currentDisclosure = host.querySelector('.reference-recognition-disclosure')!; expect(currentDisclosure.open).toBe(false); expect(currentDisclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready'); expect(host.querySelector('.reference-recognition-versions')?.closest('details')).toBeNull(); await act(async () => currentDisclosure.querySelector('summary')!.click()); expect(currentDisclosure.open).toBe(true); expect(currentDisclosure.querySelector('[role="status"] .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready'); } finally { await act(async () => root.unmount()); } }); it('keeps a recognition failure red on return and allows a successful retry', async () => { fixture(); api.recognizeReferences.mockRejectedValueOnce(new Error('Synthetic recognition failure')); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render( undefined}/>)); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); await act(async () => host.querySelector('.reference-recognition button')!.click()); expect(host.querySelector('.reference-recognition [role="alert"]')?.textContent).toContain('Synthetic recognition failure'); expect(host.querySelector('.reference-recognition-disclosure')!.open).toBe(true); expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention'); await act(async () => host.querySelector('.tenant-back')!.click()); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('error'); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('error'); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); await act(async () => host.querySelector('.reference-recognition button')!.click()); await act(async () => host.querySelector('.tenant-back')!.click()); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready'); } finally { await act(async () => root.unmount()); } }); it('does not let a slow overview status overwrite successful recognition in the client panel', async () => { fixture(); let resolveOld!: (status: ReturnType) => void; api.getReferenceRecognitionStatus.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; })); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render( undefined}/>)); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('checking'); await act(async () => host.querySelector('.datahub-tenant-select')!.click()); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); await act(async () => host.querySelector('.reference-recognition button')!.click()); await act(async () => host.querySelector('.tenant-back')!.click()); await act(async () => resolveOld(referenceStatus(2, 0))); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready'); } finally { await act(async () => root.unmount()); } }); it('keeps unavailable status visible and offers a working status retry in Manage', async () => { fixture(); api.getReferenceRecognitionStatus.mockRejectedValueOnce(new Error('Synthetic unavailable')).mockRejectedValueOnce(new Error('Synthetic unavailable')); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render( undefined}/>)); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('review'); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); const retry = [...host.querySelectorAll('.reference-recognition button')].find(button => button.textContent === 'Retry status')!; expect(host.querySelector('.reference-recognition-disclosure')!.open).toBe(true); expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention'); await act(async () => retry.click()); expect(host.querySelector('.reference-recognition button')!.disabled).toBe(false); await act(async () => host.querySelector('.tenant-back')!.click()); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('update'); } finally { await act(async () => root.unmount()); } }); it('refreshes reference warnings after a catalog update and a changed import', async () => { fixture(); api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0)); api.refreshMitreCatalog.mockImplementation(async () => { api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2)); return { updated: true, catalog: { version: '19.3', tacticOrder: ['Execution'], techniques: [{ id: 'T1059', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } }; }); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render( undefined}/>)); expect(host.querySelector('.datahub-tenant-show')).toBeNull(); await act(async () => host.querySelector('.mitre-library .datahub-maintenance-action')!.click()); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('update'); api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0)); await act(async () => globalThis.dispatchEvent(new Event('assessor:datahub-changed'))); expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready'); } finally { await act(async () => root.unmount()); } }); it('preserves scanner and GPO error priority alongside a reference update', () => { const result = withReferenceReadiness({ state: 'error', label: 'Synthetic GPO error.' }, { state: 'ready', status: { ...referenceStatus(2), catalogs: [] } }); expect(result.state).toBe('error'); expect(result.label).toContain('Synthetic GPO error.'); expect(result.label).toContain('2 scans require reference recognition'); }); it.each(['run', 'file', 'observation'])('names AD and Entra scans explicitly after Manage and timeslot expansion, using retained %s scope', async source => { fixture(); const date = '2026-01-01T00:00:00Z'; const expected = ['Purple Knight for Active Directory', 'Purple Knight for Entra ID', 'Purple Knight for Active Directory and Entra ID', 'PurpleKnight']; const tool = { id: 'purple-knight', name: 'PurpleKnight' }; const assessment = { id: 'synthetic-scope-assessment', projectId, name: 'Synthetic timeslot', createdAt: date, updatedAt: date, effectiveAt: date }; const runs = expected.map((name, index) => ({ id: `scope-${index}`, assessmentId: assessment.id, sequence: index + 1, label: 'Untrusted title says Entra ID', importedAt: date, tool: { ...tool, name: source === 'run' ? name : tool.name } })); const files = runs.map((run, index) => ({ id: `file-${run.id}`, scanRunId: run.id, fileName: 'untrusted-entra-ad.xlsx', format: 'Excel', sizeBytes: 100, importedAt: date, detectedTool: { ...tool, name: source === 'file' ? expected[index] : tool.name }, detection: { level: 'high', score: 1, reasons: ['synthetic'] }, sourceArtifact: { kind: 'not-retained', reason: 'Synthetic' }, processedWith: { parserId: 'xlsx', parserVersion: '1.0.0', profileId: tool.id, profileVersion: '1.0.0', mappingVersion: '1.0.0' } })); const properties = ['purple_knight.ad.security_posture_score', 'purple_knight.entra.security_posture_score']; const observations = source === 'observation' ? [0, 1, 2].flatMap(index => (index === 2 ? properties : [properties[index]]) .map(property => ({ scanRunId: runs[index].id, importFileId: files[index].id, tool, property, originalValue: 70 }))) : []; // Unrelated tool and file facts must never assign Entra scope to the unknown fourth run. observations.push({ scanRunId: runs[3].id, importFileId: files[3].id, tool: { id: 'maester', name: 'Maester' }, property: properties[1], originalValue: 70 }); observations.push({ scanRunId: runs[3].id, importFileId: 'foreign-file', tool, property: properties[1], originalValue: 70 }); api.listProjectAssessmentHistory.mockResolvedValue({ version: 1, projectId, assessments: [assessment], scanRuns: runs }); api.listProjectImportFiles.mockResolvedValue(files); api.listProjectAssessments.mockResolvedValue([assessment]); api.loadAssessmentModel.mockResolvedValue({ scanRuns: runs, importFiles: files, environmentObservations: observations, findingOccurrences: runs.map(run => ({ id: `occurrence-${run.id}`, scanRunId: run.id })), findingDefinitions: [], evidence: [], diagnostics: [], reviews: [] }); const originalRuns = structuredClone(runs); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render( undefined}/>)); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); const timeslot = host.querySelector('.tenant-timeslot')!; if (!timeslot.open) await act(async () => timeslot.querySelector('summary')!.click()); expect(timeslot.open).toBe(true); expect([...timeslot.querySelectorAll('.tenant-report-copy b')].map(element => element.textContent)).toEqual(expected); for (const name of expected) { expect(timeslot.querySelector(`input[aria-label="Select ${name} scan report"]`)).not.toBeNull(); expect(timeslot.querySelector(`button[aria-label="Delete ${name} scan report"]`)).not.toBeNull(); } expect(runs).toEqual(originalRuns); } finally { await act(async () => root.unmount()); } });