// @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, resetClientComplianceCacheForTests } 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` tab: one ISO tile in the environment tile geometry with totals and all four themes; // its Results window lists every control by theme and opens Work from a control or a theme. `Last mapping` // names the newest mapping batch of the timeslot and its trigger. 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(); await new Promise(resolve => setTimeout(resolve, 0)); }); beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetClientComplianceCacheForTests(); 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('[data-framework="iso-27001-27002"] .client-environment-results-button'); const openResults = () => act(async () => resultsButton()!.click()); const windowGroups = () => [...document.querySelectorAll('.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-header > 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(`[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('.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('.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('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('is its own Client Info tab, not part of the Overview, and opens Work through the ISO control and theme filters', () => { const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8'); expect(source).toContain("...(showCompliance ? [['compliance', 'Compliance'] as const] : [])"); expect(source.indexOf("['compliance', 'Compliance']")).toBeLessThan(source.indexOf("['ai', 'AI Overview']")); const tab = source.slice(source.indexOf("if (clientInfoView === 'compliance' && showCompliance) return host.querySelector('.client-compliance-last-mapping'); it('shows the latest mapping of any client and lists batches and single finding mappings with where they came from', async () => { vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [ { id: 'dummy-batch-2', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', completedAt: '2026-10-08T09:14:00Z', counts }, { id: 'dummy-single', kind: 'single', scope: 'other-timeslot', trigger: 'manual', status: 'completed', createdAt: '2026-10-08T08:00:00Z', counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } }, { id: 'dummy-batch-1', kind: 'batch', scope: 'other-client', trigger: 'manual', status: 'failed', createdAt: '2026-10-07T15:00:00Z', counts: { ...counts, failed: 2, succeeded: 0 } }] }); await render(); const expectedTime = new Date('2026-10-08T09:12:00Z').toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); expect(lastMapping()?.tagName).toBe('BUTTON'); expect(lastMapping()?.textContent).toBe(`Last mapping: ${expectedTime} · Automatic`); // The framework Results button stays on the tile, unchanged. expect(resultsButton()?.textContent).toBe('Results'); await act(async () => lastMapping()!.click()); const rows = [...document.querySelectorAll('.client-information-table tbody tr')].map(row => [...row.children].slice(1).map(cell => cell.textContent)); expect(rows).toEqual([['Automatic', 'This timeslot', 'Completed', '7', '2', '3', '2', '0'], ['Single finding', 'Other timeslot', 'Completed', '—', '1', '0', '1', '0'], ['Manual', 'Other client', 'Failed', '7', '2', '3', '0', '2']]); expect(document.querySelector('.client-compliance-history')).not.toBeNull(); }); it('names the scope on the button when the latest mapping is not this timeslot', async () => { vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [ { id: 'dummy-single', kind: 'single', scope: 'other-client', trigger: 'manual', status: 'running', createdAt: '2026-10-08T08:00:00Z', counts: { sentToModel: 1, reused: 0, succeeded: 0, failed: 0 } }] }); await render(); expect(lastMapping()?.textContent).toMatch(/· Single finding · Other client · Running$/); }); it('says when the timeslot was never mapped, and refreshes while a mapping runs', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce({ ...overview, mappingHistory: [] }); await render(); expect(lastMapping()?.tagName).toBe('SPAN'); expect(lastMapping()?.textContent).toBe('No mapping yet'); vi.useFakeTimers(); try { request.mockResolvedValueOnce({ ...overview, mappingHistory: [{ id: 'dummy-batch', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'running', createdAt: '2026-10-08T09:12:00Z', counts }] }) .mockResolvedValue({ ...overview, mappingHistory: [{ id: 'dummy-batch', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', counts }] }); await act(async () => { markRegulatoryDecisionSaved(); }); expect(lastMapping()?.textContent).toMatch(/· Automatic · Running$/); await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); expect(lastMapping()?.textContent).toMatch(/· Automatic$/); // Values stay visible during the refresh; nothing falls back to an em dash. expect(facts()[0]).toEqual(['Controls', '3']); } finally { vi.useRealTimers(); } }); it('shows the last overview at once when returning to the tab, and refreshes it in the background', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce(overview); await render(); expect(facts()[0]).toEqual(['Controls', '3']); // Switching to another Client Info tab unmounts this one. await act(async () => root.render(null)); let refresh: (value: RegulatoryControlOverviewResponse) => void = () => undefined; request.mockReturnValueOnce(new Promise(resolve => { refresh = resolve; })); await render(); expect(request).toHaveBeenCalledTimes(2); expect(facts()[0]).toEqual(['Controls', '3']); expect(resultsButton()).not.toBeNull(); await act(async () => refresh({ ...overview, controls: overview.controls.slice(0, 1), findingCount: 2 })); expect(facts()[0]).toEqual(['Controls', '1']); }); });