// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import { useApp } from '../../src/app/AppProvider'; import type { RegulatoryCorpusStatus } from '../../src/compliance/regulatory-corpus-management'; import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping'; import { IsoCorpusLibrary } from '../../src/compliance/ui/IsoCorpusLibrary'; import { useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog'; import { useAiOverview } from '../../src/compliance/ui/AiOverviewPanel'; import { FindingReader } from '../../src/features/finding-reader/FindingReader'; import { makeSheet } from '../../src/importers/sheet-factory'; vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); // Compliance Engine switch (AGENTS.md, "Core Engine and engines"): switched off, the Finding Reader, // Result DB, Client Info AI overview and Data Hub show none of its controls and send none of its requests; switched on again, // the Finding Reader ISO panel starts closed. let host: HTMLDivElement; let root: Root; function scannerRecord(occurrence = 'dummy-occurrence') { const record = makeSheet('Dummy', [['Title', 'Status', 'Source: Scan'], ['Dummy SMB signing check', 'Fail', 'Purple Knight']], '', 'dummy.csv', { isProjectSheet: true }).analysis.records[0]; return { ...record, findingOccurrenceId: occurrence }; } const mappingResponse: RegulatoryMappingResponse = { version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy-occurrence', generationEnabled: true, state: 'empty', decisions: [] }; async function renderReader(occurrence?: string) { await act(async () => { root.render(); await new Promise(resolve => setTimeout(resolve, 0)); }); } const isoButton = () => [...host.querySelectorAll('button')].find(button => button.textContent?.startsWith('Map ISO controls')); function BatchProbe(): ReactNode { const batch = useRegulatoryMappingBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} }); return {batch.available ? 'available' : 'off'}; } function AiOverviewProbe(): ReactNode { const overview = useAiOverview('dummy-project', ['dummy-assessment'], []); return {overview.available ? 'available' : 'off'}; } beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); // The Context Engine is not under test here. setEngineEnabled('context', false); vi.mocked(useApp).mockReturnValue({ targetProjectId: 'dummy-project', activeAssessmentIds: [], dataset: null, setFacetFilter: vi.fn() } as unknown as ReturnType); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => { root.unmount(); }); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); resetEnginePreferencesForTests(); }); describe('Compliance Engine switch', () => { it('removes the Finding Reader ISO window, section and requests while off and reopens closed', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue(mappingResponse); const isoWindow = () => document.querySelector('[role="dialog"].regulatory-mapping-dialog'); setEngineEnabled('compliance', false); await renderReader(); expect(isoButton()).toBeUndefined(); expect(request).not.toHaveBeenCalled(); expect(host.querySelector('[data-iso-controls]')).toBeNull(); // Switched on, the mapping loads with the finding; the button opens it as a window like Accepted Risk. await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(request).toHaveBeenCalledTimes(1); expect(isoWindow()).toBeNull(); await act(async () => { isoButton()!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(isoWindow()?.querySelector('.regulatory-mapping-panel')).not.toBeNull(); expect(host.querySelector('.regulatory-mapping-panel')).toBeNull(); await act(async () => isoWindow()!.querySelector('[aria-label="Close window"]')!.click()); expect(isoWindow()).toBeNull(); await act(async () => { isoButton()!.click(); }); await act(async () => { setEngineEnabled('compliance', false); }); expect(isoButton()).toBeUndefined(); expect(isoWindow()).toBeNull(); await act(async () => { setEngineEnabled('compliance', true); }); expect(isoButton()?.getAttribute('aria-expanded')).toBe('false'); expect(isoWindow()).toBeNull(); expect(request).toHaveBeenCalledTimes(1); }); it('lists the controls accepted for the finding as a reader section like Description', async () => { const decision = (id: string, controlId: string, value: 'accepted' | 'rejected', createdAt: string) => ({ version: 1 as const, id, ruleKey: 'purple-knight:dummy', sourceTool: 'purple-knight', mappingId: 'dummy-mapping', controlId, decision: value, createdAt }); vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded', run: { candidates: [{ controlId: '8.20', annexAControlId: 'A.8.20', title: 'Networks security', score: 0.9, evidence: [] }, { controlId: '5.1', annexAControlId: 'A.5.1', title: 'Policies for information security', score: 0.8, evidence: [] }] } as never, decisions: [decision('d3', '5.1', 'rejected', '2026-10-05T10:02:00Z'), decision('d2', '8.20', 'accepted', '2026-10-05T10:01:00Z'), decision('d1', '5.1', 'accepted', '2026-10-05T10:00:00Z'), decision('d0', '7.4', 'accepted', '2026-10-05T09:00:00Z')] }); await renderReader(); const section = host.querySelector('[data-iso-controls]')!; expect(section.querySelector('h4')?.textContent).toBe('ISO controls'); // A later rejection replaces an earlier acceptance; a control without a retrieved title keeps its Annex A id. expect([...section.querySelectorAll('li')].map(item => item.textContent)).toEqual(['A.7.4', 'A.8.20Networks security']); expect(isoButton()?.textContent).toBe('Map ISO controls (2)'); }); it('lists the derived BSI requirements collapsed after the ISO section, grouped by ISO control, and none while off', async () => { const accepted = (controlId: string) => ({ version: 1 as const, id: `d-${controlId}`, ruleKey: 'purple-knight:dummy', sourceTool: 'purple-knight', mappingId: 'dummy-mapping', controlId, decision: 'accepted' as const, createdAt: '2026-10-05T10:00:00Z' }); const requirement = (requirementId: string, title: string, isoControlIds: string[]) => ({ requirementId, title, practice: requirementId.split('.')[0], statement: `Synthetische Anforderung ${requirementId}.`, via: isoControlIds.map(isoControlId => ({ isoControlId, relationship: 'subset-of' as const })) }); const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded', run: { candidates: [{ controlId: '8.2', annexAControlId: 'A.8.2', score: 0.9, evidence: [] }, { controlId: '5.18', annexAControlId: 'A.5.18', score: 0.8, evidence: [] }] } as never, decisions: [accepted('8.2'), accepted('5.18')], // Synthetic derived requirements; the real ones come from the active BSI mapping on the server. bsi: { catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution (CC BY-SA 4.0).', practices: [{ id: 'BER', title: 'Berechtigung' }], missingTargets: [], requirements: [requirement('BER.3.5', 'Synthetisches Konto', ['8.2']), requirement('BER.5.1', 'Synthetische Ausgabe', ['5.18', '8.2'])] } }); await renderReader(); const section = () => host.querySelector('details[data-bsi-requirements]'); expect(host.querySelector('[data-iso-controls]')!.nextElementSibling).toBe(section()); // Collapsed like Context, with the count in its summary. expect(section()!.open).toBe(false); expect(section()!.querySelector(':scope > summary')?.textContent).toBe('BSI requirements2 requirements derived from the accepted ISO controls'); await act(async () => section()!.querySelector(':scope > summary')!.click()); expect(section()!.open).toBe(true); // One group per ISO control in numeric order; a requirement reached twice is listed under each of its controls. const groups = [...section()!.querySelectorAll('.finding-bsi-requirements__group')]; expect(groups.map(group => group.querySelector('.finding-bsi-requirements__route')?.textContent)).toEqual(['A.5.18 · subset-of', 'A.8.2 · subset-of']); expect(groups.map(group => [...group.querySelectorAll('li > details > summary')].map(row => row.textContent))) .toEqual([['BER.5.1Synthetische Ausgabe'], ['BER.3.5Synthetisches Konto', 'BER.5.1Synthetische Ausgabe']]); // A statement opens only with its requirement. const item = groups[1].querySelector('li[data-requirement="BER.3.5"] > details')!; expect(item.open).toBe(false); await act(async () => item.querySelector('summary')!.click()); expect(item.open).toBe(true); expect(item.querySelector('.finding-bsi-requirements__statement')?.textContent).toBe('Synthetische Anforderung BER.3.5.'); expect(section()!.querySelector('.finding-bsi-requirements__source')?.textContent?.trim()) .toBe('Via BSI mapping 0.1-synthetic · catalog 2026-01-01 · Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0'); // Another finding starts collapsed again. await renderReader('dummy-other-occurrence'); expect(section()?.open).toBe(false); expect(request).toHaveBeenCalledTimes(2); await act(async () => setEngineEnabled('compliance', false)); expect(section()).toBeNull(); }); it('requests no Result DB batch status while off and hides the batch action', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({ version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} }, generationEnabled: true, state: 'empty' } as never); setEngineEnabled('compliance', false); await act(async () => root.render()); expect(host.textContent).toBe('off'); expect(request).not.toHaveBeenCalled(); await act(async () => { setEngineEnabled('compliance', true); }); expect(host.textContent).toBe('available'); expect(request).toHaveBeenCalledTimes(1); const source = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8'); expect(source).toContain('{regulatoryBatch.available && { const request = vi.spyOn(AssessorApiClient.prototype, 'getAiOverview').mockResolvedValue({ version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, generationEnabled: true, state: 'empty', stale: false }); setEngineEnabled('compliance', false); await act(async () => { root.render(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.textContent).toBe('off'); expect(request).not.toHaveBeenCalled(); await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.textContent).toBe('available'); expect(request).toHaveBeenCalledTimes(1); const dashboard = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8'); expect(dashboard).toContain('const showAiOverview = aiOverview.available && '); const browser = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8'); const exportButton = browser.slice(browser.lastIndexOf('{regulatoryBatch.available &&