// @vitest-environment jsdom 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 { resetEnginePreferencesForTests } from '../../src/app/engine-preferences'; import type { RegulatoryConsistencyResponse } from '../../src/compliance/bsi-consistency'; import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping'; import { MappingReviewTile } from '../../src/compliance/ui/MappingReviewTile'; import { RegulatoryMappingBatchDialog, useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog'; import { FindingBsiRequirements } from '../../src/compliance/ui/FindingBsiRequirements'; // Phase 13F, BSI stage 2 (user decision 2026-10-09): the ISO <-> BSI review in the Client Info Compliance tab, the // BSI batch beside the ISO batch in the Result DB and the direct BSI mapping in the Finding Reader. Synthetic data only. const review: RegulatoryConsistencyResponse = { version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, crosswalkVersion: '0.1-synthetic', catalogVersion: '2026-01-01T00:00:00+00:00', counts: { consistent: 1, inconsistent: 1, noReference: 0, notCompared: 2, reviewed: 0 }, items: [ { resultProjectionId: 'scanner-finding:dummy-a', title: 'Synthetic check A', sourceTool: 'purple-knight', sourceRuleId: 'SYN-A', findingCount: 2, state: 'inconsistent', reviewed: false, iso: [{ controlId: '8.9', title: 'Configuration management' }], derivedCount: 2, direct: [{ requirementId: 'BER.3.5', title: 'Synthetische Anforderung', practice: 'BER', derived: false, reachable: true }], reverse: [{ controlId: '5.15', title: 'Access control', requirementIds: ['BER.3.5'], decidable: true }] }, { resultProjectionId: 'scanner-finding:dummy-b', title: 'Synthetic check B', sourceTool: 'purple-knight', findingCount: 1, state: 'consistent', reviewed: false, iso: [{ controlId: '8.9' }], derivedCount: 2, direct: [{ requirementId: 'KONF.2.1', derived: true, reachable: true }], reverse: [] } ] }; const mapping = (framework: 'iso27001' | 'bsi-kernel'): RegulatoryMappingResponse => ({ version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy-a', framework, generationEnabled: true, state: 'succeeded', run: { candidates: framework === 'iso27001' ? [{ controlId: '5.15', annexAControlId: 'A.5.15', title: 'Access control', score: 0.8, evidence: [] }] : [] } as never, decisions: framework === 'bsi-kernel' ? [{ version: 1, id: 'dummy-bsi-decision', framework, ruleKey: 'dummy', sourceTool: 'purple-knight', mappingId: 'dummy-bsi-mapping', controlId: 'BER.3.5', decision: 'accepted', createdAt: '2026-10-09T10:00:00Z' }] : [], ...(framework === 'bsi-kernel' ? { consistency: { state: 'inconsistent' as const, reviewed: false } } : {}) }); let host: HTMLDivElement; let root: Root; const flush = () => new Promise(resolve => setTimeout(resolve, 0)); beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => { root.unmount(); }); host.remove(); document.body.innerHTML = ''; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('ISO <-> BSI review tile', () => { it('shows the counts, lists inconsistent rules first, accepts a suggested ISO control and marks a rule reviewed', async () => { const consistency = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingConsistency').mockResolvedValue(review); vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async (_project, _result, framework = 'iso27001') => mapping(framework)); const decide = vi.spyOn(AssessorApiClient.prototype, 'saveRegulatoryMappingDecision').mockResolvedValue(mapping('iso27001')); const reviewed = vi.spyOn(AssessorApiClient.prototype, 'markRegulatoryMappingConsistencyReviewed').mockResolvedValue({ reviewed: true }); await act(async () => { root.render(); await flush(); }); const facts = [...host.querySelectorAll('[data-framework="iso-bsi-review"] .client-env-facts > div')].map(cell => cell.textContent); expect(facts).toEqual(['Compared rules2', 'Inconsistent1', 'No ISO reference0', 'Consistent1', 'Not compared2', 'Reviewed0']); await act(async () => host.querySelector('[aria-label="Open ISO ↔ BSI review results"]')!.click()); const window = () => document.querySelector('.mapping-review')!; // The window starts on the rules that need a review; the other states are one click away. expect([...window().querySelectorAll('.mapping-review__title')].map(title => title.textContent)).toEqual(['Synthetic check A']); expect(window().querySelector('.mapping-review__note')?.textContent).toContain('2 rules have an accepted ISO control but no accepted BSI requirement yet.'); await act(async () => { window().querySelector('details[data-consistency="inconsistent"] > summary')!.click(); await flush(); }); const detail = () => window().querySelector('.mapping-review__detail')!; expect(detail().textContent).toContain('BER.3.5'); expect(detail().textContent).toContain('not derived from the accepted ISO controls'); // Accepting the ISO control the BSI mapping points to is an ordinary ISO decision. const accept = [...detail().querySelectorAll('button')].find(button => button.textContent === 'Accept')!; await act(async () => { accept.click(); await flush(); }); expect(decide).toHaveBeenCalledWith('dummy-project', expect.objectContaining({ resultProjectionId: 'scanner-finding:dummy-a', controlId: '5.15', decision: 'accepted' })); expect(decide.mock.calls[0][1]).not.toHaveProperty('framework'); // A kept disagreement is recorded with its comment, and the review reloads. const calls = consistency.mock.calls.length; await act(async () => { const input = detail().querySelector('.mapping-review__actions input')!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Kept on purpose.'); input.dispatchEvent(new Event('input', { bubbles: true })); }); await act(async () => { [...detail().querySelectorAll('button')].find(button => button.textContent === 'Mark reviewed')!.click(); await flush(); }); expect(reviewed).toHaveBeenCalledWith('dummy-project', { resultProjectionId: 'scanner-finding:dummy-a', comment: 'Kept on purpose.' }); expect(consistency.mock.calls.length).toBeGreaterThan(calls); await act(async () => [...window().querySelectorAll('.mapping-review__filters button')].find(button => button.textContent === 'Consistent (1)')!.click()); expect([...window().querySelectorAll('.mapping-review__title')].map(title => title.textContent)).toEqual(['Synthetic check B']); }); it('shows em dashes while loading, never zero', async () => { vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingConsistency').mockReturnValue(new Promise(() => undefined)); await act(async () => { root.render(); await flush(); }); expect([...host.querySelectorAll('.client-env-facts dd')].map(value => value.textContent)).toEqual(['—', '—', '—', '—', '—', '—']); }); }); function BatchHarness() { const controller = useRegulatoryMappingBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: { deviation: true } }); return undefined}/>; } describe('Result DB mapping batch dialog', () => { it('offers a BSI batch of the same filters beside the ISO batch', async () => { const status = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async (projectId, scope) => ({ version: 1, projectId, scope, generationEnabled: true, state: 'empty' })); const start = vi.spyOn(AssessorApiClient.prototype, 'startRegulatoryMappingBatch').mockImplementation(async (projectId, request) => ({ version: 1, projectId, scope: request, generationEnabled: true, state: 'queued' }) as never); await act(async () => { root.render(); await flush(); }); expect(status.mock.calls.map(call => call[2] ?? 'iso27001')).toEqual(['iso27001', 'bsi-kernel']); const buttons = () => [...document.querySelectorAll('.regulatory-batch-dialog button')].map(button => button.textContent); expect(buttons()).toEqual(['Start mapping batch', 'Start BSI mapping batch', 'Close']); await act(async () => { [...document.querySelectorAll('.regulatory-batch-dialog button')].find(button => button.textContent === 'Start BSI mapping batch')!.click(); await flush(); }); expect(start).toHaveBeenCalledTimes(1); expect(start.mock.calls[0][1]).toMatchObject({ framework: 'bsi-kernel', filters: { deviation: true }, assessmentIds: ['dummy-assessment'] }); }); }); describe('Finding Reader direct BSI mapping', () => { it('shows the directly accepted requirements with their consistency and opens the BSI mapping window', async () => { vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async (_project, _result, framework = 'iso27001') => mapping(framework)); await act(async () => { root.render(); await flush(); }); const section = host.querySelector('details[data-bsi-requirements]')!; expect(section.open).toBe(false); expect(section.querySelector(':scope > summary')?.textContent).toBe('BSI requirements1 mapped directly · Inconsistent with ISO'); expect([...section.querySelectorAll('[data-direct-requirement]')].map(item => item.getAttribute('data-direct-requirement'))).toEqual(['BER.3.5']); await act(async () => { [...section.querySelectorAll('button')].find(button => button.textContent === 'Map BSI requirements (1)')!.click(); }); expect(document.querySelector('[role="dialog"].regulatory-mapping-dialog')?.textContent).toContain('BSI requirement mapping'); }); });