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

533 lines
40 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, 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 onOpenBsiRequirement = vi.fn();
const onOpenBsiPractice = vi.fn();
const onOpenMappingState = vi.fn();
const render = () => act(async () => {
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}
context={<header className="client-overview-header" data-testid="dummy-context">Dummy context</header>}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
resetClientComplianceCacheForTests();
onOpenControl.mockReset();
onOpenTheme.mockReset();
onOpenBsiRequirement.mockReset();
onOpenBsiPractice.mockReset();
onOpenMappingState.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-heading > 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-compliance-grid .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('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 <ClientIsoControls"));
expect(tab.startsWith("if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls navigation={clientInfoTabs}")).toBe(true);
// The Overview page no longer renders the row.
expect(source.indexOf('<ClientIsoControls')).toBe(source.lastIndexOf('<ClientIsoControls'));
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('<ClientContextHeatmap'));
const drilldown = tab.slice(0, tab.indexOf('if (hasDefender'));
expect(drilldown).toContain("setStatusFilter('deviation')");
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
// The BSI tile opens Work the same way through the lower-case BSI facet values.
expect(drilldown).toContain('setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] })');
expect(drilldown).toContain('setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] })');
// `Mapping failed` in the coverage legend opens Work with the deviations whose latest ISO mapping failed.
expect(drilldown).toContain('setFacetFilter(ISO_MAPPING_FACET_ID, { values: [state] })');
// Client and timeslot like the Overview, without a Source control, because the tab covers the whole timeslot.
const context = drilldown.slice(drilldown.indexOf('context={'));
expect(context).toContain('<span>Client:</span><strong>{dataset.fileName}</strong>');
expect(context).toContain('<span>Assessment:</span><WorkTimeslotControl />');
expect(context).not.toContain('Source:');
expect(drilldown.match(/setStatusFilter\('deviation'\)/g)).toHaveLength(5);
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]);
});
const counts = { findings: 7, sentToModel: 2, reused: 3, succeeded: 2, failed: 0 };
const lastMapping = () => host.querySelector<HTMLElement>('.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']);
});
});
// Overview row (user decision 2026-10-08): a square Mapping coverage tile and the Annex A footprint above the
// framework tiles. Coverage counts are content-free; the footprint shows all 93 controls and opens Work only from a hit.
describe('Client Info Compliance overview', () => {
const coverage = { deviations: 8, accepted: 3, mappedNoneAccepted: 2, failed: 1, notMapped: 2 };
const legend = () => [...host.querySelectorAll('.compliance-coverage__legend > div')]
.map(row => [row.querySelector('dt')?.textContent, row.querySelector('dd')?.textContent]);
const cell = (controlId: string) => host.querySelector<HTMLElement>(`.compliance-footprint__cell[data-control="${controlId}"]`);
const themeHead = (theme: string) => host.querySelector<HTMLElement>(`[data-iso-theme="${theme}"] .compliance-footprint__theme-head`);
it('sits above the framework tiles with the coverage of the deviations by mapping state', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, coverage });
await render();
const section = host.querySelector('.client-compliance')!;
expect([...section.children].map(child => child.className)).toEqual(['client-compliance-header', 'compliance-overview', 'client-compliance-grid']);
expect([...host.querySelectorAll('.compliance-overview .client-env-group-title')].map(title => title.textContent)).toEqual(['Mapping coverage', 'Annex A footprint']);
expect(host.querySelector('.compliance-coverage .client-info-donut > span')?.textContent).toBe('338%');
expect(host.querySelector('.compliance-coverage .client-info-donut')?.getAttribute('aria-label')).toBe('Deviations with an ISO control: 3 of 8');
expect(legend()).toEqual([['Accepted ISO control', '3'], ['Mapped, none accepted', '2'], ['Not mapped yet', '2'], ['Mapping failed', '1'], ['Mappable deviations', '8']]);
// Not mapped yet is the neutral track; the other three states are arcs.
expect([...host.querySelectorAll<SVGGElement>('.compliance-coverage svg g')].map(arc => arc.dataset.state)).toEqual(['accepted', 'mappedNoneAccepted', 'failed']);
expect(host.querySelector('.compliance-coverage__note')?.textContent).toBe('Shows how far mapping has progressed, not whether controls are met.');
// Only `Mapping failed` opens Work, so its findings can be mapped again from the Finding Reader.
const legendButtons = [...host.querySelectorAll<HTMLButtonElement>('.compliance-coverage__legend button')];
expect(legendButtons.map(button => button.getAttribute('aria-label'))).toEqual(['Open Mapping failed findings in Work (1)']);
expect(host.querySelector('.compliance-coverage__legend [data-state="failed"]')?.classList.contains('is-actionable')).toBe(true);
await act(async () => legendButtons[0].click());
expect(onOpenMappingState).toHaveBeenCalledWith('failed');
// The Client Info context row (client and timeslot) follows the tab bar.
expect(host.querySelector('.client-compliance-page > [data-testid="dummy-context"] + .client-compliance')).not.toBeNull();
});
it('shows all 93 Annex A controls and opens Work from a control or theme with findings only', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, coverage,
annexATitles: { '7.4': 'Synthetischer Kontrolltitel', '8.9': 'Corpus title is not preferred over the mapping title' } });
await render();
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
expect([...host.querySelectorAll<HTMLElement>('[data-iso-theme]')].map(theme =>
[theme.dataset.isoTheme, theme.querySelectorAll('.compliance-footprint__cell').length])).toEqual(
[['Organizational', 37], ['People', 8], ['Physical', 14], ['Technological', 34]]);
expect(themeHead('Technological')?.textContent).toBe('Technological2 of 34 controls · 3 findings');
expect(themeHead('People')?.textContent).toBe('People0 of 8 controls · 0 findings');
// Only a control or theme with hits opens Work, like the ISO control and theme facets.
expect(cell('8.9')?.tagName).toBe('BUTTON');
expect(cell('8.9')?.dataset.step).toBe('2');
expect(cell('5.15')?.dataset.step).toBe('1');
expect(cell('8.10')?.tagName).toBe('SPAN');
expect(cell('8.10')?.dataset.step).toBeUndefined();
expect(themeHead('People')?.tagName).toBe('DIV');
await act(async () => cell('8.20')!.click());
expect(onOpenControl).toHaveBeenCalledWith('8.20');
await act(async () => themeHead('Organizational')!.click());
expect(onOpenTheme).toHaveBeenCalledWith('Organizational');
// Focus names the control, its title and findings; an empty control says so without implying compliance.
const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent;
await act(async () => cell('8.9')!.focus());
expect(detail()).toBe('A.8.9 · TechnologicalConfiguration management3 findings');
await act(async () => { cell('7.4')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(detail()).toBe('A.7.4 · PhysicalSynthetischer KontrolltitelNo mapped findings');
await act(async () => { cell('7.5')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(detail()).toBe('A.7.5 · PhysicalNo mapped findings');
});
it('keeps pending values as an em dash with nothing to open while loading', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
await render();
expect(legend().map(([, value]) => value)).toEqual(['—', '—', '—', '—', '—']);
expect(host.querySelector('.compliance-coverage .client-info-donut > span')?.textContent).toBe('——');
expect(host.querySelector('.compliance-overview button')).toBeNull();
expect(themeHead('Organizational')?.textContent).toBe('Organizational—');
});
// Synthetic footprint: ARCH.9.9 and KONF.3.1 are not reachable through the ISO mapping; DET has no findings.
const bsiFootprint = { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', practices: [
{ id: 'BER', title: 'Berechtigung', requirements: [{ id: 'BER.1.1', title: 'Katalogtitel BER.1.1', reachable: true }, { id: 'BER.1.2', title: 'Katalogtitel BER.1.2', reachable: true }] },
{ id: 'ARCH', title: 'Architektur', requirements: [{ id: 'ARCH.1.1', title: 'Katalogtitel ARCH.1.1', reachable: true }, { id: 'ARCH.9.9', title: 'Katalogtitel ARCH.9.9', reachable: false }] },
{ id: 'KONF', title: 'Konfiguration', requirements: [{ id: 'KONF.1.1', title: 'Katalogtitel KONF.1.1', reachable: true }, { id: 'KONF.2.1', title: 'Katalogtitel KONF.2.1', reachable: true }, { id: 'KONF.3.1', title: 'Katalogtitel KONF.3.1', reachable: false }] },
{ id: 'DET', title: 'Detektion', requirements: [{ id: 'DET.1.1', title: 'Katalogtitel DET.1.1', reachable: true }] }] };
const switchButton = (view: string) => host.querySelector<HTMLButtonElement>(`.compliance-framework-switch [data-framework-view="${view}"]`);
const requirement = (id: string) => host.querySelector<HTMLElement>(`.compliance-footprint__cell[data-requirement="${id}"]`);
const practiceHead = (id: string) => host.querySelector<HTMLElement>(`[data-bsi-practice="${id}"] .compliance-footprint__theme-head`);
it('switches the overview row to BSI with its coverage and Kernel footprint, and opens Work from a requirement or practice', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi, bsiFootprint,
coverage: { deviations: 9, accepted: 4, mappedNoneAccepted: 2, failed: 1, notMapped: 2 } });
await render();
expect(switchButton('iso')?.getAttribute('aria-pressed')).toBe('true');
expect(switchButton('bsi')?.getAttribute('aria-pressed')).toBe('false');
expect(switchButton('iso')?.textContent).toBe('ISO 27001/27002');
await act(async () => switchButton('bsi')!.click());
expect(switchButton('bsi')?.getAttribute('aria-pressed')).toBe('true');
expect(host.querySelector<HTMLElement>('.compliance-overview')?.dataset.frameworkView).toBe('bsi');
expect([...host.querySelectorAll('.compliance-overview .client-env-group-title')].map(title => title.textContent)).toEqual(['Mapping coverage', 'Kernel footprint']);
// All three findings reach a requirement; the fourth accepted finding keeps only its ISO control.
expect(host.querySelector('.compliance-coverage .client-info-donut > span')?.textContent).toBe('333%');
expect(host.querySelector('.compliance-coverage__caption')?.textContent).toBe('Deviations with a BSI requirement');
expect(legend()).toEqual([['With BSI requirement', '3'], ['ISO control, no BSI requirement', '1'], ['Mapped, none accepted', '2'],
['Not mapped yet', '2'], ['Mapping failed', '1'], ['Mappable deviations', '9']]);
// ISO control without a BSI requirement and Mapping failed open Work through the ISO mapping filter.
const isoOnly = host.querySelector<HTMLButtonElement>('.compliance-coverage__legend [data-state="isoOnly"] button')!;
expect(isoOnly.getAttribute('aria-label')).toBe('Open ISO control, no BSI requirement findings in Work (1)');
await act(async () => isoOnly.click());
expect(onOpenMappingState).toHaveBeenLastCalledWith('no-bsi');
expect(host.querySelectorAll('.compliance-coverage__legend button')).toHaveLength(2);
// The tile shows the practices with the most findings first, each with all of its squares labelled by number.
expect([...host.querySelectorAll('.compliance-footprint__requirements .compliance-footprint__cell')].map(cell => cell.textContent))
.toEqual(['1.1', '2.1', '3.1', '1.1', '9.9']);
expect(practiceHead('DET')).toBeNull();
expect(practiceHead('KONF')?.textContent).toBe('KONF · Konfiguration2 of 3 requirements · 3 findings');
// BER has the fewest findings and stays out of the tile; Show all lists it below.
expect(practiceHead('BER')).toBeNull();
expect(requirement('KONF.1.1')?.tagName).toBe('BUTTON');
expect(requirement('KONF.1.1')?.dataset.step).toBe('2');
expect(requirement('ARCH.9.9')?.classList.contains('is-unreachable')).toBe(true);
await act(async () => requirement('KONF.1.1')!.click());
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
await act(async () => practiceHead('ARCH')!.click());
expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH');
const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent;
await act(async () => requirement('KONF.1.1')!.focus());
expect(detail()).toBe('KONF.1.1 · KONF Konfiguration · via A.8.20, A.8.9Synthetische Konfiguration3 findings');
// Show all opens every practice in a window, including those without findings; unreachable squares never open Work.
const showAll = host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button');
expect(showAll?.textContent).toBe('Show all');
await act(async () => showAll!.click());
const inWindow = (id: string) => document.querySelector<HTMLElement>(`.compliance-footprint--window [data-requirement="${id}"]`);
expect(document.querySelectorAll('.compliance-footprint--window .compliance-footprint__cell[data-requirement]')).toHaveLength(8);
expect(inWindow('BER.1.2')?.tagName).toBe('SPAN');
expect(inWindow('ARCH.9.9')?.classList.contains('is-unreachable')).toBe(true);
expect(inWindow('BER.1.2')?.classList.contains('is-unreachable')).toBe(false);
const windowDetail = () => document.querySelector('.compliance-footprint--window .compliance-footprint__detail')?.textContent;
await act(async () => { inWindow('ARCH.9.9')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(windowDetail()).toBe('ARCH.9.9 · ARCH ArchitekturKatalogtitel ARCH.9.9Not reachable through the ISO mapping');
await act(async () => { inWindow('BER.1.2')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(windowDetail()).toBe('BER.1.2 · BER BerechtigungKatalogtitel BER.1.2No mapped findings');
// The detail line and legend sit in the footer the window pins below the scrolling grid.
expect(document.querySelector('.compliance-footprint--window .compliance-footprint__footer .compliance-footprint__detail')).not.toBeNull();
expect(document.querySelector('.compliance-footprint--window .compliance-footprint__footer .compliance-footprint__legend')).not.toBeNull();
// Opening Work from the window closes it.
await act(async () => inWindow('KONF.2.1')!.click());
expect(onOpenBsiRequirement).toHaveBeenLastCalledWith('KONF.2.1');
expect(document.querySelector('.compliance-footprint--window')).toBeNull();
// The framework tiles stay unchanged, and the switch returns to ISO.
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section')].map(section => section.dataset.framework))
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
await act(async () => switchButton('iso')!.click());
expect(host.querySelector<HTMLElement>('.compliance-overview')?.dataset.frameworkView).toBe('iso');
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
});
it('shows only the two BSI practices with the most findings in the tile, and every practice in Show all', async () => {
// Two more synthetic practices with hits: DET via 8.9 (3 findings) and NOT via 5.15 (1 finding).
const wider = { ...bsi, practices: [...bsi.practices, { id: 'NOT', title: 'Notfallplanung' }], requirements: [...bsi.requirements,
{ requirementId: 'DET.1.1', title: 'Synthetische Detektion', practice: 'DET', statement: 'Synthetisch.', via: [{ isoControlId: '8.9', relationship: 'subset-of' as const }] },
{ requirementId: 'NOT.1.1', title: 'Synthetischer Notfall', practice: 'NOT', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] };
const footprint = { ...bsiFootprint, practices: [...bsiFootprint.practices,
{ id: 'NOT', title: 'Notfallplanung', requirements: [{ id: 'NOT.1.1', title: 'Katalogtitel NOT.1.1', reachable: true }] }] };
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi: wider, bsiFootprint: footprint,
coverage: { deviations: 9, accepted: 4, mappedNoneAccepted: 2, failed: 1, notMapped: 2 } });
await render();
await act(async () => switchButton('bsi')!.click());
// KONF and DET have 3, ARCH 2, BER and NOT 1 each; the tie between KONF and DET keeps catalog order.
const shown = () => [...host.querySelectorAll<HTMLElement>('.compliance-overview [data-bsi-practice]')].map(block => block.dataset.bsiPractice);
expect(shown()).toEqual(['KONF', 'DET']);
await act(async () => host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button')!.click());
expect([...document.querySelectorAll<HTMLElement>('.compliance-footprint--window [data-bsi-practice]')].map(block => block.dataset.bsiPractice))
.toEqual(['BER', 'ARCH', 'KONF', 'DET', 'NOT']);
});
it('says so in the BSI tile when no requirement is derived, while Show all still lists the catalog', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsiFootprint,
coverage: { deviations: 9, accepted: 4, mappedNoneAccepted: 2, failed: 1, notMapped: 2 } });
await render();
await act(async () => switchButton('bsi')!.click());
expect(host.querySelector('.compliance-footprint .compliance-footprint__pending')?.textContent)
.toBe('No BSI requirements derived from accepted ISO controls in this timeslot.');
expect(legend()[0]).toEqual(['With BSI requirement', '0']);
await act(async () => host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button')!.click());
expect(document.querySelectorAll('.compliance-footprint--window .compliance-footprint__cell[data-requirement]')).toHaveLength(8);
});
it('keeps the BSI view pending with an em dash and nothing to open while loading', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
await render();
await act(async () => switchButton('bsi')!.click());
expect(legend().map(([, value]) => value)).toEqual(['—', '—', '—', '—', '—', '—']);
expect(host.querySelector('.compliance-footprint__pending')?.textContent).toBe('—');
expect(host.querySelector('.compliance-overview button')).toBeNull();
});
it('renders nothing while the Compliance Engine is off', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, coverage });
setEngineEnabled('compliance', false);
await render();
expect(host.querySelector('.compliance-overview')).toBeNull();
});
});
// BSI Grundschutz++ tile (Phase 13F, stage 1): requirements derived from accepted ISO controls through the BSI
// mapping. Synthetic requirement ids, titles and statements; the findings come from the ISO controls above.
const bsi: NonNullable<RegulatoryControlOverviewResponse['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' }, { id: 'ARCH', title: 'Architektur' }, { id: 'KONF', title: 'Konfiguration' }, { id: 'DET', title: 'Detektion' }],
requirements: [
{ requirementId: 'ARCH.1.1', title: 'Synthetische Architektur', practice: 'ARCH', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] },
{ requirementId: 'BER.1.1', title: 'Synthetische Berechtigung', practice: 'BER', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'equivalent-to' }] },
{ requirementId: 'KONF.1.1', title: 'Synthetische Konfiguration', practice: 'KONF', statement: 'Synthetisch.',
via: [{ isoControlId: '8.20', relationship: 'intersects-with' }, { isoControlId: '8.9', relationship: 'subset-of' }] },
{ requirementId: 'KONF.2.1', title: 'Zweite Konfiguration', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] }
],
missingTargets: [{ isoControlId: '8.9', requirementId: 'KONF.9.9' }]
};
describe('Client Info BSI Grundschutz++ tile', () => {
const tile = () => host.querySelector<HTMLElement>('[data-framework="bsi-grundschutz-plus-plus"]');
const facts = () => [...tile()!.querySelectorAll('.client-env-facts > div')].map(fact => [fact.querySelector('dt')?.textContent, fact.querySelector('dd')?.textContent]);
const openResults = () => act(async () => tile()!.querySelector<HTMLButtonElement>('.client-environment-results-button')!.click());
const groups = () => [...document.querySelectorAll<HTMLElement>('.bsi-requirements .iso-controls__group')];
it('sits beside the ISO tile with totals and the practices with the most findings, and opens Work from a practice', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
await render();
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section.client-env-group')].map(section => section.dataset.framework))
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
expect(tile()!.querySelector('.client-env-group-title')?.textContent).toBe('BSI Grundschutz++');
// KONF.1.1 reaches all three findings through 8.9; a finding counts once per practice and once in total.
expect(facts()).toEqual([['Requirements', '4'], ['Findings', '3'], ['Konfiguration', '3 findings'], ['Architektur', '2 findings'], ['Berechtigung', '1 finding']]);
const practice = tile()!.querySelector<HTMLButtonElement>('[data-practice="ARCH"] button')!;
expect(practice.getAttribute('aria-label')).toBe('Open BSI practice ARCH findings in Work (2)');
await act(async () => practice.click());
expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH');
});
it('lists every derived requirement by practice in the Results window and opens Work from a requirement or practice', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
await render();
await openResults();
// Catalog practice order, only practices with requirements; rows ranked by finding count with their ISO route.
expect(groups().map(group => group.dataset.practice)).toEqual(['BER', 'ARCH', 'KONF']);
expect(groups()[2].querySelector('.iso-controls__theme')?.textContent).toBe('KONF · Konfiguration2 requirements · 3 findings');
expect([...groups()[2].querySelectorAll('.iso-controls__row')].map(row => [...row.children].map(part => part.textContent))).toEqual([
['KONF.1.1', 'Synthetische Konfiguration · via A.8.20, A.8.9', '3'], ['KONF.2.1', 'Zweite Konfiguration · via A.8.20', '2']]);
const source = document.querySelector('.bsi-requirements__source')?.textContent ?? '';
expect(source).toContain('0.1-synthetic');
expect(source).toContain('Not in the catalog: KONF.9.9.');
expect(source).toContain('Synthetic attribution (CC BY-SA 4.0).');
await act(async () => groups()[2].querySelector<HTMLButtonElement>('.iso-controls__row')!.click());
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
expect(document.querySelector('.bsi-requirements')).toBeNull();
await openResults();
await act(async () => groups()[0].querySelector<HTMLButtonElement>('button.iso-controls__theme')!.click());
expect(onOpenBsiPractice).toHaveBeenCalledWith('BER');
expect(document.querySelector('.bsi-requirements')).toBeNull();
});
it('shows zero totals and an empty Results window when no requirement is derived', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render();
expect(facts()).toEqual([['Requirements', '0'], ['Findings', '0']]);
await openResults();
expect(document.querySelector('.iso-controls__empty')?.textContent).toBe('No BSI requirements derived from accepted ISO controls in this timeslot.');
});
it('keeps pending values as an em dash and renders nothing while the Compliance Engine is off', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
await render();
expect(facts()).toEqual([['Requirements', '—'], ['Findings', '—']]);
await act(async () => { root.unmount(); });
root = createRoot(host);
setEngineEnabled('compliance', false);
await render();
expect(tile()).toBeNull();
});
});