Assessor_2.0/tests/compliance/regulatory-auto-mapping.tes...

117 lines
5.4 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { RegulatoryAutoMapping } from '../../server/compliance/regulatory-auto-mapping';
import type { ScanRun } from '../../src/domain/canonical-model';
import type { UnifiedResultQuery } from '../../src/domain/unified-result';
let root: string;
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'assessor-auto-mapping-')); });
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
function run(id: string, extra: Partial<ScanRun> = {}): ScanRun {
return { id, assessmentId: 'synthetic-assessment', sequence: 1, label: id, tool: { id: 'synthetic', name: 'Synthetic' } as ScanRun['tool'],
importedAt: '2026-10-08T00:00:00Z', ...extra };
}
function harness(initialRuns: ScanRun[], generationEnabled = true) {
const runs = [...initialRuns];
const gpoRows: string[] = [];
const read = {
listProjects: vi.fn(async () => [{ id: 'synthetic-project' }] as never),
listAssessments: vi.fn(async () => [{ id: 'synthetic-assessment' }] as never),
listProjectScanRuns: vi.fn(async () => runs),
queryUnifiedResults: vi.fn(async (query: UnifiedResultQuery) => {
const items = gpoRows.slice(query.offset ?? 0, (query.offset ?? 0) + (query.limit ?? gpoRows.length))
.map(resultProjectionId => ({ resultProjectionId }));
return { total: gpoRows.length, items } as never;
})
};
const mapping = { generationEnabled, startBatch: vi.fn(async (_projectId: string, _request: unknown) => ({}) as never) };
const auto = new RegulatoryAutoMapping({ settingsPath: join(root, 'auto-mapping.json'), read, mapping, pollIntervalMs: 3_600_000 });
return { runs, gpoRows, read, mapping, auto };
}
it('stays off by default and reads nothing from the Core', async () => {
const { auto, read, mapping } = harness([run('existing')]);
await auto.start();
await auto.poll();
expect(auto.isEnabled).toBe(false);
expect(read.listProjects).not.toHaveBeenCalled();
expect(mapping.startBatch).not.toHaveBeenCalled();
});
it('maps the deviations of a scan uploaded after switching on, but not the scans that existed before', async () => {
const { auto, runs, mapping } = harness([run('existing')]);
await auto.setEnabled(true);
expect(mapping.startBatch).not.toHaveBeenCalled();
runs.push(run('new-scan'));
await auto.poll();
expect(mapping.startBatch).toHaveBeenCalledTimes(1);
expect(mapping.startBatch).toHaveBeenCalledWith('synthetic-project', {
idempotencyKey: 'auto:new-scan', assessmentIds: ['synthetic-assessment'], scanRunIds: ['new-scan'], filters: { deviation: true }
});
await auto.poll();
expect(mapping.startBatch).toHaveBeenCalledTimes(1);
await auto.stop();
});
it('maps the GPO deviations of a timeslot when a GPO import adds new rows, reading only Result DB pages', async () => {
const h = harness([]);
h.gpoRows.push('policy-evaluation-comparison:existing');
await h.auto.setEnabled(true);
expect(h.mapping.startBatch).not.toHaveBeenCalled();
expect(h.read.queryUnifiedResults.mock.calls.every(([query]) => query.audience === 'result-db')).toBe(true);
h.gpoRows.push('policy-evaluation-comparison:new-a', 'policy-evaluation-comparison:new-b');
await h.auto.poll();
expect(h.mapping.startBatch).toHaveBeenCalledTimes(1);
expect(h.mapping.startBatch.mock.calls[0][1]).toMatchObject({
assessmentIds: ['synthetic-assessment'], scanRunIds: [], filters: { sourceKind: ['policy-evaluation'], deviation: true }
});
await h.auto.poll();
expect(h.mapping.startBatch).toHaveBeenCalledTimes(1);
// A later GPO import in the same timeslot gets its own batch, not the earlier one by idempotency key.
h.gpoRows.push('policy-evaluation-comparison:new-c');
await h.auto.poll();
expect(h.mapping.startBatch).toHaveBeenCalledTimes(2);
const keys = h.mapping.startBatch.mock.calls.map(call => (call[1] as { idempotencyKey: string }).idempotencyKey);
expect(new Set(keys).size).toBe(2);
await h.auto.stop();
});
it('ignores superseded runs and keeps a new scan pending until mapping is available', async () => {
const h = harness([], false);
await h.auto.setEnabled(true);
h.runs.push(run('replaced', { supersededByScanRunId: 'replacement' }), run('replacement'));
await h.auto.poll();
expect(h.mapping.startBatch).not.toHaveBeenCalled();
h.mapping.generationEnabled = true;
await h.auto.poll();
expect(h.mapping.startBatch).toHaveBeenCalledTimes(1);
expect(h.mapping.startBatch.mock.calls[0][1]).toMatchObject({ scanRunIds: ['replacement'] });
await h.auto.stop();
});
it('persists the switch in its own settings file and stops watching when switched off', async () => {
const first = harness([]);
await first.auto.setEnabled(true);
expect(JSON.parse(await readFile(join(root, 'auto-mapping.json'), 'utf8'))).toEqual({ version: 1, enabled: true });
await first.auto.stop();
const restarted = harness([]);
await restarted.auto.start();
expect(restarted.auto.isEnabled).toBe(true);
await restarted.auto.setEnabled(false);
restarted.runs.push(run('after-off'));
restarted.gpoRows.push('policy-evaluation-comparison:after-off');
await restarted.auto.poll();
expect(restarted.mapping.startBatch).not.toHaveBeenCalled();
expect(JSON.parse(await readFile(join(root, 'auto-mapping.json'), 'utf8'))).toEqual({ version: 1, enabled: false });
});