Assessor_2.0/tests/features/datahub-reference-recogniti...

322 lines
25 KiB
TypeScript

// @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(<DataHub backendStatus="connected" onProjectSelected={() => 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<HTMLButtonElement>('.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<HTMLButtonElement>('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<HTMLButtonElement>('.tenant-edit-client')!.click());
expect(host.querySelector('.tenant-detail-navigation .reference-recognition')).toBe(panel);
await act(async () => host.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.datahub-tenant-select')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.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<HTMLDetailsElement>('.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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
expect(host.querySelector('.reference-recognition [role="alert"]')?.textContent).toContain('Synthetic recognition failure');
expect(host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
await act(async () => host.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.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<typeof referenceStatus>) => 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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('checking');
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-select')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('review');
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
const retry = [...host.querySelectorAll<HTMLButtonElement>('.reference-recognition button')].find(button => button.textContent === 'Retry status')!;
expect(host.querySelector<HTMLDetailsElement>('.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<HTMLButtonElement>('.reference-recognition button')!.disabled).toBe(false);
await act(async () => host.querySelector<HTMLButtonElement>('.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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
expect(host.querySelector('.datahub-tenant-show')).toBeNull();
await act(async () => host.querySelector<HTMLButtonElement>('.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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
const timeslot = host.querySelector<HTMLDetailsElement>('.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<HTMLInputElement>('#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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.tenant-save-client')!.click());
let dialog = document.querySelector<HTMLElement>('[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<HTMLFormElement>('#tenant-editor-form')!.requestSubmit());
dialog = document.querySelector<HTMLElement>('[aria-label="Confirm client information changes"]')!;
expect(dialog).not.toBeNull(); expect(api.saveProject).not.toHaveBeenCalled();
await act(async () => [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Cancel')!.click());
expect(api.saveProject).not.toHaveBeenCalled();
await act(async () => host.querySelector<HTMLFormElement>('#tenant-editor-form')!.requestSubmit());
dialog = document.querySelector<HTMLElement>('[aria-label="Confirm client information changes"]')!;
await act(async () => [...dialog.querySelectorAll<HTMLButtonElement>('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(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-edit-client')!.click());
await act(async () => {
const notes = host.querySelector<HTMLTextAreaElement>('#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<HTMLButtonElement>('.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()); }
});