// @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()); 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()); 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()); 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>) => 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()); 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()); 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()); } });