Assessor_2.0/tests/compliance/client-iso-controls.test.tsx

165 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { act } 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 { requestActivity } from '../../src/api/request-activity';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
import { ClientIsoControls } from '../../src/compliance/ui/ClientIsoControls';
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
import { makeSheet } from '../../src/importers/sheet-factory';
// Client Info `Compliance` row: one ISO tile in the environment tile geometry with totals and the two themes
// with the most findings; its Results window lists every control by theme and opens Work from a control or
// a theme. Switched off, nothing renders or loads.
const overview: RegulatoryControlOverviewResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
controls: [
{ controlId: '8.20', annexAControlId: 'A.8.20', title: 'Networks security', theme: 'Technological', findings: [
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] },
{ controlId: '5.15', annexAControlId: 'A.5.15', theme: 'Organizational', findings: [
{ resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] },
{ controlId: '8.9', annexAControlId: 'A.8.9', title: 'Configuration management', theme: 'Technological', findings: [
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' },
{ resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] }
]
};
let host: HTMLDivElement;
let root: Root;
const onOpenControl = vi.fn();
const onOpenTheme = vi.fn();
const render = () => act(async () => {
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
onOpenControl.mockReset();
onOpenTheme.mockReset();
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('Client Info ISO controls', () => {
const facts = () => [...host.querySelectorAll('[data-framework="iso-27001-27002"] .client-env-facts > div')]
.map(fact => [fact.querySelector('dt')?.textContent, fact.querySelector('dd')?.textContent]);
const resultsButton = () => host.querySelector<HTMLButtonElement>('[data-framework="iso-27001-27002"] .client-environment-results-button');
const openResults = () => act(async () => resultsButton()!.click());
const windowGroups = () => [...document.querySelectorAll<HTMLElement>('.iso-controls__group')];
it('shows a Compliance tile with totals and all four themes, and opens Work from a theme with hits', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render();
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
expect(host.querySelector('.client-compliance > h2')?.textContent).toBe('Compliance');
// Same tile geometry as the environment row.
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002');
// All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'],
['Physical', '0 findings'], ['Technological', '3 findings']]);
expect(host.querySelector('.iso-controls__row')).toBeNull();
// Only a theme with hits is a Work drilldown; Work's ISO theme facet offers no zero-hit value.
const themeButton = (theme: string) => host.querySelector<HTMLButtonElement>(`[data-theme="${theme}"] button`);
expect(themeButton('People')).toBeNull();
expect(themeButton('Physical')).toBeNull();
expect(themeButton('Technological')?.getAttribute('aria-label')).toBe('Open Technological ISO theme findings in Work (3)');
await act(async () => themeButton('Technological')!.click());
expect(onOpenTheme).toHaveBeenCalledWith('Technological');
await act(async () => themeButton('Organizational')!.click());
expect(onOpenTheme).toHaveBeenLastCalledWith('Organizational');
});
it('keeps pending values as an em dash and offers no Results before the overview loads', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
await render();
expect(facts()).toEqual([['Controls', '—'], ['Findings', '—'], ['Organizational', '—'], ['People', '—'], ['Physical', '—'], ['Technological', '—']]);
expect(host.querySelector('[data-theme] button')).toBeNull();
expect(resultsButton()).toBeNull();
});
it('opens the complete list by theme in the Results window and opens Work from a control or theme', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render();
await openResults();
expect(document.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3');
// ISO 27002 theme order, only themes with hits, every control ranked by finding count.
expect(windowGroups().map(group => group.dataset.theme)).toEqual(['Organizational', 'Technological']);
expect(windowGroups().map(group => group.querySelector('.iso-controls__theme')?.textContent)).toEqual([
'Organizational1 control · 1 finding', 'Technological2 controls · 3 findings'
]);
const rows = (group: HTMLElement) => [...group.querySelectorAll<HTMLButtonElement>('.iso-controls__row')]
.map(row => [...row.children].map(part => part.textContent));
expect(rows(windowGroups()[0])).toEqual([['A.5.15', '', '1']]);
expect(rows(windowGroups()[1])).toEqual([['A.8.9', 'Configuration management', '3'], ['A.8.20', 'Networks security', '2']]);
await act(async () => windowGroups()[1].querySelectorAll<HTMLButtonElement>('.iso-controls__row')[1].click());
expect(onOpenControl).toHaveBeenCalledWith('8.20');
// Opening Work closes the window.
expect(document.querySelector('.iso-controls')).toBeNull();
await openResults();
await act(async () => windowGroups()[1].querySelector<HTMLButtonElement>('button.iso-controls__theme')!.click());
expect(onOpenTheme).toHaveBeenCalledWith('Technological');
expect(document.querySelector('.iso-controls')).toBeNull();
});
it('reloads after a control decision and says so when nothing is accepted', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview')
.mockResolvedValueOnce({ ...overview, findingCount: 0, controls: [] }).mockResolvedValue(overview);
await render();
expect(facts().slice(0, 3)).toEqual([['Controls', '0'], ['Findings', '0'], ['Organizational', '0 findings']]);
await openResults();
expect(document.querySelector('.iso-controls__empty')?.textContent).toBe('No accepted ISO controls in this timeslot.');
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(2);
expect(facts()[0]).toEqual(['Controls', '3']);
});
it('renders nothing and requests nothing while the Compliance Engine is off', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
setEngineEnabled('compliance', false);
await render();
expect(host.innerHTML).toBe('');
expect(request).not.toHaveBeenCalled();
});
it('names the overview request in the activity log as Compliance Engine work', () => {
expect(requestActivity('/projects/p/regulatory-control-overview?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control overview' });
expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' });
});
it('places the section below the Context heatmap and opens Work through the ISO control and theme filters', () => {
const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap'));
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid'));
const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid'));
expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)');
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
expect(drilldown).not.toContain('setPathFindingIds');
// A scanner finding resolves to its Work record through the occurrence id.
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });
const record = { ...sheet.analysis.records[0], findingOccurrenceId: 'dummy-a' };
const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never;
expect(workRecordIdsForResult(dataset, overview.controls[0].findings[0])).toEqual([record.id]);
});
});