146 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|