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 { 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 }); });