Assessor_2.0/tests/compliance/bsi-review-ui.test.tsx

146 lines
11 KiB
TypeScript

// @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(<MappingReviewTile projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} backendStatus="connected"/>);
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<HTMLButtonElement>('[aria-label="Open ISO ↔ BSI review results"]')!.click());
const window = () => document.querySelector<HTMLElement>('.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<HTMLElement>('details[data-consistency="inconsistent"] > summary')!.click(); await flush(); });
const detail = () => window().querySelector<HTMLElement>('.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<HTMLButtonElement>('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<HTMLInputElement>('.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<HTMLButtonElement>('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<HTMLButtonElement>('.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(<MappingReviewTile projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} backendStatus="connected"/>);
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 <RegulatoryMappingBatchDialog controller={controller} filteredResultCount={3} onClose={() => 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(<BatchHarness/>); await flush(); });
expect(status.mock.calls.map(call => call[2] ?? 'iso27001')).toEqual(['iso27001', 'bsi-kernel']);
const buttons = () => [...document.querySelectorAll<HTMLButtonElement>('.regulatory-batch-dialog button')].map(button => button.textContent);
expect(buttons()).toEqual(['Start mapping batch', 'Start BSI mapping batch', 'Close']);
await act(async () => { [...document.querySelectorAll<HTMLButtonElement>('.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(<FindingBsiRequirements projectId="dummy-project" resultProjectionId="scanner-finding:dummy-a" backendStatus="connected"/>);
await flush();
});
const section = host.querySelector<HTMLDetailsElement>('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<HTMLButtonElement>('button')].find(button => button.textContent === 'Map BSI requirements (1)')!.click(); });
expect(document.querySelector('[role="dialog"].regulatory-mapping-dialog')?.textContent).toContain('BSI requirement mapping');
});
});