222 lines
14 KiB
TypeScript
222 lines
14 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(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(<FindingReader record={scannerRecord(occurrence)} 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();
|
|
});
|
|
|
|
// The Finding Reader also loads the direct BSI mapping of the finding (Phase 13F, stage 2); these counts are ISO requests.
|
|
const isoCalls = (request: { mock: { calls: unknown[][] } }) => request.mock.calls.filter(call => (call[2] ?? 'iso27001') === 'iso27001').length;
|
|
|
|
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(isoCalls(request)).toBe(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(isoCalls(request)).toBe(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').mockImplementation(async (_project, _result, framework) => framework === 'bsi-kernel'
|
|
? { ...mappingResponse, framework } : { ...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'])] } } as never);
|
|
await renderReader();
|
|
const section = () => host.querySelector<HTMLDetailsElement>('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<HTMLElement>(':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<HTMLElement>('.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', 'Mapped directly']);
|
|
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<HTMLDetailsElement>('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(isoCalls(request)).toBe(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(<BatchProbe/>));
|
|
expect(host.textContent).toBe('off');
|
|
expect(request).not.toHaveBeenCalled();
|
|
await act(async () => { setEngineEnabled('compliance', true); });
|
|
expect(host.textContent).toBe('available');
|
|
expect(isoCalls(request)).toBe(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);
|
|
});
|
|
});
|