117 lines
5.4 KiB
TypeScript
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 });
|
|
});
|