Assessor_2.0/tests/features/reference-recognition-inter...

134 lines
9.5 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { ReferenceRecognitionPanel } from '../../src/features/project-import/ReferenceRecognitionPanel';
import { FindingReferences } from '../../src/features/finding-reader/FindingReferences';
import { createReferenceRecognizer } from '../../src/domain/reference-recognition';
import { bundledReferenceCatalogs } from '../../server/reference-data/reference-catalogs';
import { referenceSnapshot } from '../../server/reference-data/reference-catalogs';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { ReferenceRecognitionService } from '../../server/reference-data/reference-recognition-service';
import { seedReferenceScan } from '../fixtures/reference-recognition';
it.each(['dark', 'light'])('updates a client by clicking after its catalog returns to a previous version (%s)', async theme => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const repository = new LocalAssessorRepository();
const { project } = await seedReferenceScan(repository);
let catalogs = bundledReferenceCatalogs;
const service = new ReferenceRecognitionService(repository, async () => catalogs);
await service.reprocess(project.id);
const { sha256: _, ...attack } = catalogs[0];
catalogs = [referenceSnapshot({ ...attack, version: '99.0' }), catalogs[1]];
await service.reprocess(project.id);
catalogs = bundledReferenceCatalogs;
vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockImplementation(id => service.status(id));
const process = vi.spyOn(AssessorApiClient.prototype, 'recognizeReferences').mockImplementation(id => service.reprocess(id));
const changed = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId={project.id} onChanged={changed}/>));
expect(host.querySelector('[role="status"]')?.textContent).toContain('Please update');
await act(async () => host.querySelector('button')!.click());
expect(process).toHaveBeenCalledWith(project.id);
expect(changed).toHaveBeenCalledOnce();
expect(host.querySelector('[role="status"]')?.textContent).toContain('Update successful');
expect(host.querySelector('[role="alert"]')).toBeNull();
expect(await service.status(project.id)).toMatchObject({ pendingScans: 0 });
expect(await repository.listReferenceRuns(project.id)).toHaveLength(3);
} finally { await act(async () => root.unmount()); delete document.documentElement.dataset.theme; }
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
it('recognizes the selected client on click and refreshes its workspace, retaining retry after failure', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const status = vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockResolvedValue({ totalScans: 1, indexedScans: 0, pendingScans: 1,
catalogs: bundledReferenceCatalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) });
const process = vi.spyOn(AssessorApiClient.prototype, 'recognizeReferences').mockRejectedValueOnce(new Error('Synthetic failure'))
.mockResolvedValue({ processedScans: 1, unchangedScans: 0, references: 2 });
const changed = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId="synthetic-client" onChanged={changed}/>));
expect(host.textContent).toContain('ATT&CK 19.2 · D3FEND 1.6.0');
expect(host.querySelector('[role="status"]')?.textContent).toContain('Please update');
await act(async () => host.querySelector('button')!.click());
expect(host.querySelector('[role="alert"]')?.textContent).toContain('Synthetic failure');
expect(host.querySelector('button')!.disabled).toBe(false);
expect(host.querySelector('[role="status"]')?.textContent).toContain('Update failed — please retry');
status.mockResolvedValue({ totalScans: 1, indexedScans: 1, pendingScans: 0,
catalogs: bundledReferenceCatalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) });
await act(async () => host.querySelector('button')!.click());
expect(process).toHaveBeenLastCalledWith('synthetic-client');
expect(changed).toHaveBeenCalledOnce();
expect(host.querySelector('[role="status"]')?.textContent).toContain('Update successful');
expect(host.querySelector('[role="status"]')?.textContent).toContain('No action needed.');
expect(host.textContent).toContain('ATT&CK 19.2 · D3FEND 1.6.0');
} finally { await act(async () => root.unmount()); }
});
it.each([0, 1])('does not claim update success for an empty client or remaining work (scans=%i)', async totalScans => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockResolvedValue({ totalScans, indexedScans: 0, pendingScans: totalScans,
catalogs: bundledReferenceCatalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) });
const process = vi.spyOn(AssessorApiClient.prototype, 'recognizeReferences').mockResolvedValue({ processedScans: 1, unchangedScans: 0, references: 2 });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId="synthetic-client"/>));
if (totalScans === 0) {
expect(host.querySelector('[role="status"]')?.textContent).toBe('No scans to updateImport a scan first.');
expect(host.querySelector('button')!.disabled).toBe(true);
expect(process).not.toHaveBeenCalled();
expect(host.querySelector('details')!.open).toBe(false);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('neutral');
} else {
await act(async () => host.querySelector('button')!.click());
expect(host.querySelector('[role="status"]')?.textContent).toContain('Update failed — please retry');
expect(host.querySelector('[role="alert"]')?.textContent).toContain('Update incomplete: 1 scan still needs a reference update.');
expect(host.textContent).not.toContain('Update successful');
expect(host.querySelector('button')!.dataset.attention).toBe('true');
expect(host.querySelector('details')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
}
expect(host.textContent).toContain('ATT&CK 19.2 · D3FEND 1.6.0');
} finally { await act(async () => root.unmount()); }
});
it('starts collapsed without a green signal while checking, then automatically opens when work is found', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
let resolveStatus!: (status: Awaited<ReturnType<AssessorApiClient['getReferenceRecognitionStatus']>>) => void;
vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockImplementation(() => new Promise(resolve => { resolveStatus = resolve; }));
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId="synthetic-client"/>));
expect(host.querySelector('details')!.open).toBe(false);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('neutral');
expect(host.querySelector('summary')?.getAttribute('aria-label')).toContain('Checking status');
await act(async () => resolveStatus({ totalScans: 1, indexedScans: 0, pendingScans: 1, catalogs: [] }));
expect(host.querySelector('details')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
expect(host.querySelector('[role="status"]')?.textContent).toContain('Please update');
} finally { await act(async () => root.unmount()); }
});
it('shows declared, mentioned and unknown references with inspectable source and frozen catalog version', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const references = createReferenceRecognizer(bundledReferenceCatalogs)([
{ key: 'attack', label: 'MITRE ATT&CK', value: 'T1059.001' }, { key: 'description', label: 'Description', value: 'T9999 D3-CH' }
]);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<FindingReferences record={{ id: 'synthetic', title: 'Synthetic', index: 0, fields: [], filled: [], meta: [],
referenceRecognition: { runId: 'synthetic-run', recognizerVersion: '1.0.0', createdAt: '2026-09-26T00:00:00Z', references } }}/>));
expect(host.textContent).toContain('Declared · Catalog match');
expect(host.textContent).toContain('Mentioned · Not found in this catalog');
await act(async () => host.querySelector('summary')!.click());
expect(host.querySelector('details')!.open).toBe(true);
expect(host.textContent).toContain('MITRE ATT&CK · attack');
expect(host.textContent).toContain('19.2');
expect(host.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer');
} finally { await act(async () => root.unmount()); }
});