Assessor_2.0/tests/compliance/compliance-activity-log.tes...

154 lines
9.3 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } 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 } from '../../src/app/engine-preferences';
import type { RegulatoryMappingBatchResponse } from '../../src/compliance/regulatory-mapping';
import { useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog';
import { useRegulatoryMapping } from '../../src/compliance/ui/use-regulatory-mapping';
import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping';
import { activitySnapshot, activityStatusSnapshot, clearFinishedActivities } from '../../src/platform/activity-store';
// The activity log names Compliance Engine work as such: its requests never read as Core client work,
// and backend work keeps the log busy until it really ends.
describe('Compliance Engine requests in the activity log', () => {
it.each([
['/projects/p/regulatory-mappings/scanner-finding%3Ao', 'GET', 'Loading ISO mapping'],
['/projects/p/regulatory-mapping-runs', 'POST', 'Generating ISO control proposals'],
['/projects/p/regulatory-mapping-decisions', 'POST', 'Saving ISO mapping decision'],
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading compliance mapping batch status'],
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting compliance mapping batch'],
['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'],
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus'],
['/reference-data/bsi-grundschutz', 'GET', 'Loading BSI catalog'],
['/reference-data/bsi-grundschutz/refresh', 'POST', 'Updating BSI catalog'],
['/reference-data/bsi-grundschutz/reset', 'POST', 'Restoring bundled BSI catalog'],
['/projects/p/ai-overview?assessmentIds=a&scanRunIds=r', 'GET', 'Loading AI overview status'],
['/projects/p/ai-overview', 'POST', 'Starting AI overview']
])('labels %s %s', (path, method, label) => {
expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label });
});
it('keeps unrelated client requests in the Core', () => {
expect(requestActivity('/projects/p/import-files')).toEqual({ area: 'Core Engine', label: 'Loading imported files' });
expect(requestActivity('/projects')).toEqual({ area: 'Core Engine', label: 'Loading clients' });
});
});
function batchResponse(state: RegulatoryMappingBatchResponse['state']): RegulatoryMappingBatchResponse {
return { version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },
generationEnabled: true, state, pollAfterMs: 5 } as RegulatoryMappingBatchResponse;
}
const scope = { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} };
function BatchProbe({ projectId = 'dummy-project' }: { projectId?: string }): ReactNode {
useRegulatoryMappingBatch(projectId, scope);
return null;
}
const batchRows = () => activitySnapshot().filter(row => row.area === 'Compliance Engine' && row.label.startsWith('Mapping all ISO controls'));
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
clearFinishedActivities();
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
clearFinishedActivities();
});
describe('Compliance Engine backend work in the activity log', () => {
it('keeps a running ISO batch busy until the backend completes it', async () => {
const states: RegulatoryMappingBatchResponse['state'][] = ['running', 'running', 'completed'];
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async () => batchResponse(states.shift() ?? 'completed'));
await act(async () => root.render(<BatchProbe/>));
await act(async () => { await Promise.resolve(); });
expect(batchRows().map(row => row.state)).toEqual(['running']);
expect(activityStatusSnapshot().ready).toBe(false);
const generation = activityStatusSnapshot().generation;
await settle(); await settle();
expect(batchRows().map(row => row.state)).toEqual(['completed']);
expect(activityStatusSnapshot().ready).toBe(true);
// Repeated batch status checks start no further entries.
expect(activityStatusSnapshot().generation).toBe(generation);
});
it('marks the entry failed when the backend batch fails', async () => {
const states: RegulatoryMappingBatchResponse['state'][] = ['running', 'failed'];
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async () => batchResponse(states.shift() ?? 'failed'));
await act(async () => root.render(<BatchProbe/>));
await settle(); await settle();
expect(batchRows().map(row => row.state)).toEqual(['failed']);
expect(activityStatusSnapshot().failed).toBe(1);
});
it('ends the entry with a note when the view stops observing a running batch', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue(batchResponse('running'));
await act(async () => root.render(<BatchProbe/>));
await act(async () => { await Promise.resolve(); });
expect(batchRows().map(row => row.state)).toEqual(['running']);
await act(async () => { root.unmount(); });
root = createRoot(host);
expect(batchRows().map(row => [row.state, row.label])).toEqual([['completed', 'Mapping all ISO controls continues in the backend']]);
expect(activityStatusSnapshot().ready).toBe(true);
});
});
function mappingResponse(state: RegulatoryMappingResponse['state'], id: string): RegulatoryMappingResponse {
return { version: 1, projectId: 'dummy-project', resultProjectionId: `scanner-finding:${id}`, generationEnabled: true, state, decisions: [], pollAfterMs: 5 };
}
function MappingProbe({ id }: { id: string }): ReactNode {
const mapping = useRegulatoryMapping('dummy-project', `scanner-finding:${id}`, 'connected');
return <button type="button" onClick={() => void mapping.start()}>Generate</button>;
}
const mappingRows = () => activitySnapshot().filter(row => row.area === 'Compliance Engine' && row.label.startsWith('Mapping ISO controls'));
describe('Compliance Engine single mapping in the activity log', () => {
it('keeps a generated mapping running after the window closes until the backend completes it', async () => {
const states: RegulatoryMappingResponse['state'][] = ['empty', 'running', 'running', 'succeeded'];
// Replaced below the activity logging, so every client logs exactly as in the application.
const transport = vi.spyOn(AssessorApiClient.prototype as unknown as { performRequest(path: string, init: RequestInit): Promise<unknown> }, 'performRequest')
.mockImplementation(async (path: string, init: RequestInit) => init.method === 'POST'
? mappingResponse('queued', 'closed-window') : mappingResponse(states.shift() ?? 'succeeded', 'closed-window'));
await act(async () => root.render(<MappingProbe id="closed-window"/>));
await act(async () => { await Promise.resolve(); });
await act(async () => host.querySelector('button')!.click());
expect(mappingRows().map(row => row.state)).toEqual(['queued']);
// Status checks while it runs start no entries of their own, so the activity panel does not reopen and jump.
const generation = activityStatusSnapshot().generation;
// Closing the window or leaving the finding does not end the observation.
await act(async () => { root.unmount(); });
root = createRoot(host);
expect(mappingRows().map(row => row.state)).toEqual(['queued']);
expect(activityStatusSnapshot().ready).toBe(false);
await settle(); await settle(); await settle();
expect(mappingRows().map(row => [row.state, row.label])).toEqual([['completed', 'Mapping ISO controls']]);
expect(activityStatusSnapshot().ready).toBe(true);
expect(transport.mock.calls.filter(([path]) => String(path).includes('/regulatory-mappings/')).length).toBeGreaterThanOrEqual(4);
expect(activityStatusSnapshot().generation).toBe(generation);
// Only the first load of the finding and the generate request are logged; the status checks are not.
expect(activitySnapshot().filter(row => row.area === 'Compliance Engine').map(row => row.label).sort())
.toEqual(['Generating ISO control proposals', 'Loading ISO mapping', 'Mapping ISO controls']);
});
it('marks the mapping failed when the backend run fails', async () => {
const states: RegulatoryMappingResponse['state'][] = ['running', 'failed'];
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async () => mappingResponse(states.shift() ?? 'failed', 'failing'));
await act(async () => root.render(<MappingProbe id="failing"/>));
await settle(); await settle();
expect(mappingRows().map(row => row.state)).toEqual(['failed']);
});
});