Assessor_2.0/tests/compliance/compliance-engine-switch.te...

176 lines
9.9 KiB
TypeScript

// @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() {
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: 'dummy-occurrence' };
}
const mappingResponse: RegulatoryMappingResponse = {
version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy-occurrence',
generationEnabled: true, state: 'empty', decisions: []
};
async function renderReader() {
await act(async () => {
root.render(<FindingReader record={scannerRecord()} title="Dummy SMB signing check" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}
projectId="dummy-project" backendStatus="connected"/>);
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 <output>{batch.available ? 'available' : 'off'}</output>;
}
function AiOverviewProbe(): ReactNode {
const overview = useAiOverview('dummy-project', ['dummy-assessment'], []);
return <output>{overview.available ? 'available' : 'off'}</output>;
}
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<typeof useApp>);
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<HTMLButtonElement>('[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('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(<BatchProbe/>));
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 && <button');
expect(source).toContain('{batchDialogOpen && regulatoryBatch.available && <RegulatoryMappingBatchDialog');
});
it('requests no AI overview while off and hides its Client Info tab and the Result DB Export JSON', async () => {
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(<AiOverviewProbe/>); 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 && <button', browser.indexOf("'Export JSON'")),
browser.indexOf("'Export JSON'"));
expect(exportButton).toContain('title="Export exactly what the AI overview and the ISO mapping receive');
});
it('renders no Data Hub ISO corpus tile and requests no corpus status while off', async () => {
const status: RegulatoryCorpusStatus = { version: 1, configured: true, active: null, job: null };
const request = vi.spyOn(AssessorApiClient.prototype, 'getIsoCorpusStatus').mockResolvedValue(status);
setEngineEnabled('compliance', false);
await act(async () => root.render(<IsoCorpusLibrary/>));
expect(host.innerHTML).toBe('');
expect(request).not.toHaveBeenCalled();
await act(async () => { setEngineEnabled('compliance', true); });
expect(host.innerHTML).not.toBe('');
expect(request).toHaveBeenCalledTimes(1);
});
});