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

233 lines
18 KiB
TypeScript

// @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(<DataHub backendStatus="connected" onProjectSelected={() => 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<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('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('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(<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()); }
});