// @vitest-environment jsdom import { act } from 'react'; import { readFileSync } from 'node:fs'; 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(), saveProject: vi.fn(), getProject: 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; saveProject = api.saveProject; getProject = api.getProject; } })); 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 readinessStyles = readFileSync('src/styles/datahub-redesign.css', 'utf8'); const referenceStatus = (pendingScans: number, indexedScans = 2) => ({ totalScans: 2, indexedScans, pendingScans, catalogs: [{ framework: 'mitre-attack' as const, 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([['dark', 0], ['dark', 2], ['light', 0], ['light', 2]] as const)('marks pending references red in %s (indexed=%i) and clears the warning after Manage recognition', async (theme, indexedScans) => { document.documentElement.dataset.theme = theme; 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('reference-update'); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('reference-update'); expect(host.querySelector('.datahub-tenant-show')?.getAttribute('title')).toContain('2 scans require reference recognition'); expect(readinessStyles).toMatch(/\.datahub-tenant-processing-dot\[data-status="reference-update"\][^{]*\{[^}]*background: var\(--state-offline\)/); expect(readinessStyles).toMatch(/\.datahub-tenant-show\[data-status="reference-update"\][^{]*\{[^}]*border-color: var\(--state-offline\); color: var\(--state-offline\)/); 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('reference-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('reference-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('prioritizes a red reference update over a yellow processing update and restores the remaining task after recognition', () => { const processing = { state: 'update' as const, label: 'Synthetic profile update.' }; const pending = withReferenceReadiness(processing, { state: 'ready', status: referenceStatus(2) }); expect(pending.state).toBe('reference-update'); expect(pending.label).toContain('Synthetic profile update.'); expect(pending.label).toContain('2 scans require reference recognition'); expect(withReferenceReadiness(processing, { state: 'ready', status: referenceStatus(0) })).toEqual(processing); }); 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()); } }); it.each(['Name', 'Consultant', 'Both'])('requires explicit confirmation of %s changes through the real client form, supports cancellation and retains unrelated data', async field => { fixture(); const original = { id: projectId, name: 'Synthetic client', description: 'Original notes', attributes: { consultant: 'Original consultant', retained: 'keep' }, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }; api.listProjects.mockResolvedValue([original]); api.saveProject.mockImplementation(async project => ({ project })); api.getProject.mockImplementation(async () => api.saveProject.mock.calls.at(-1)![0]); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); const setInput = async (index: number, value: string) => { await act(async () => { const input = host.querySelectorAll('#tenant-editor-form input')[index]; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })); }); }; try { await act(async () => root.render( undefined}/>)); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); await act(async () => host.querySelector('.tenant-edit-client')!.click()); if (field !== 'Consultant') await setInput(0, 'New client'); if (field !== 'Name') await setInput(1, 'New consultant'); await act(async () => host.querySelector('.tenant-save-client')!.click()); let dialog = document.querySelector('[role="dialog"][aria-label="Confirm client information changes"]')!; expect(dialog).not.toBeNull(); expect(api.saveProject).not.toHaveBeenCalled(); expect(dialog.textContent).toContain('Previous value'); expect(dialog.textContent).toContain('New value'); expect(dialog.querySelectorAll('tbody tr')).toHaveLength(field === 'Both' ? 2 : 1); if (field !== 'Consultant') { expect(dialog.textContent).toContain('Synthetic client'); expect(dialog.textContent).toContain('New client'); } if (field !== 'Name') { expect(dialog.textContent).toContain('Original consultant'); expect(dialog.textContent).toContain('New consultant'); } expect(document.activeElement?.textContent).toBe('Cancel'); await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); expect(document.querySelector('[aria-label="Confirm client information changes"]')).toBeNull(); expect(api.saveProject).not.toHaveBeenCalled(); expect(original.name).toBe('Synthetic client'); expect(original.attributes.consultant).toBe('Original consultant'); // Implicit Enter submission reaches this same native form submit path. await act(async () => host.querySelector('#tenant-editor-form')!.requestSubmit()); dialog = document.querySelector('[aria-label="Confirm client information changes"]')!; expect(dialog).not.toBeNull(); expect(api.saveProject).not.toHaveBeenCalled(); await act(async () => [...dialog.querySelectorAll('button')].find(button => button.textContent === 'Cancel')!.click()); expect(api.saveProject).not.toHaveBeenCalled(); await act(async () => host.querySelector('#tenant-editor-form')!.requestSubmit()); dialog = document.querySelector('[aria-label="Confirm client information changes"]')!; await act(async () => [...dialog.querySelectorAll('button')].find(button => button.textContent === 'Confirm changes')!.click()); expect(api.saveProject).toHaveBeenCalledTimes(1); expect(api.saveProject.mock.calls[0][0]).toMatchObject({ id: original.id, createdAt: original.createdAt, description: original.description, name: field === 'Consultant' ? original.name : 'New client', attributes: { retained: 'keep', consultant: field === 'Name' ? original.attributes.consultant : 'New consultant' } }); expect(api.getProject).toHaveBeenCalledWith(original.id); expect(host.textContent).toContain('Saved to the database.'); } finally { await act(async () => root.unmount()); } }); it('does not request identity confirmation for unchanged fields or remarks-only edits', async () => { fixture(); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); api.saveProject.mockImplementation(async project => ({ project })); api.getProject.mockImplementation(async () => api.saveProject.mock.calls.at(-1)![0]); try { await act(async () => root.render( undefined}/>)); await act(async () => host.querySelector('.datahub-tenant-show')!.click()); await act(async () => host.querySelector('.tenant-edit-client')!.click()); await act(async () => { const notes = host.querySelector('#tenant-editor-form textarea')!; Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(notes, 'Changed notes'); notes.dispatchEvent(new Event('input', { bubbles: true })); }); await act(async () => host.querySelector('.tenant-save-client')!.click()); expect(document.querySelector('[aria-label="Confirm client information changes"]')).toBeNull(); expect(api.saveProject).toHaveBeenCalledTimes(1); expect(api.saveProject.mock.calls[0][0].description).toBe('Changed notes'); } finally { await act(async () => root.unmount()); } });