import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { readFileSync, mkdtempSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { loadLedger, validateLedger, rollUp, } from '../../scanners/lib/campaign-ledger.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const CLI = resolve(__dirname, '../../scanners/campaign-write-cli.mjs'); const NOW = '2026-06-22'; // Every test passes an explicit --ledger-file in a temp dir + an injected --reference-date, // so the write-CLI never touches the real default path under HOME and never reads the clock. // The suite is hermetic + deterministic by construction. function runWrite(args) { try { const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', timeout: 15000 }); return { status: 0, stdout }; } catch (err) { return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' }; } } function newDir() { return mkdtempSync(join(tmpdir(), 'camp-write-')); } // Read the persisted ledger back through the real lib so a test proves the on-disk file // is exactly what the command layer would later load. async function readLedger(file) { const ledger = await loadLedger(file); return { ledger, validation: validateLedger(ledger) }; } describe('campaign-write-cli — init', () => { it('creates a valid, empty, versioned ledger (exit 0)', async () => { const dir = newDir(); const file = join(dir, 'ledger.json'); const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]); assert.equal(status, 0); const out = JSON.parse(stdout); assert.equal(out.action, 'init'); assert.equal(out.written, true); assert.equal(out.alreadyInitialized, false); assert.equal(out.ledgerPath, resolve(file)); const { ledger, validation } = await readLedger(file); assert.ok(validation.valid, validation.errors.join('; ')); assert.equal(ledger.schemaVersion, 1); assert.equal(ledger.createdDate, NOW); assert.equal(ledger.updatedDate, NOW); assert.deepEqual(ledger.repos, []); }); it('refuses to clobber an existing ledger (exit 1, file untouched)', async () => { const dir = newDir(); const file = join(dir, 'ledger.json'); // First init, then add a repo so we can prove a second init does NOT wipe it. runWrite(['init', '--ledger-file', file, '--reference-date', NOW]); runWrite(['add', '/r/a', '--ledger-file', file, '--reference-date', NOW]); const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]); assert.equal(status, 1, 'advisory: already initialized'); const out = JSON.parse(stdout); assert.equal(out.written, false); assert.equal(out.alreadyInitialized, true); const { ledger } = await readLedger(file); assert.equal(ledger.repos.length, 1, 'existing repo preserved — not clobbered'); }); }); describe('campaign-write-cli — add', () => { it('auto-initializes when no ledger exists, then adds repos (exit 0)', async () => { const dir = newDir(); const file = join(dir, 'ledger.json'); assert.ok(!existsSync(file)); const { status, stdout } = runWrite([ 'add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW, ]); assert.equal(status, 0); const out = JSON.parse(stdout); assert.equal(out.action, 'add'); assert.equal(out.autoInitialized, true); assert.deepEqual(out.added.sort(), [resolve('/r/a'), resolve('/r/b')].sort()); const { ledger, validation } = await readLedger(file); assert.ok(validation.valid, validation.errors.join('; ')); assert.equal(ledger.repos.length, 2); assert.ok(ledger.repos.every((r) => r.status === 'pending')); }); it('appends to an existing ledger and is idempotent on re-add (added vs skipped)', async () => { const dir = newDir(); const file = join(dir, 'ledger.json'); runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]); const { status, stdout } = runWrite([ 'add', '/r/a', '/r/c', '--ledger-file', file, '--reference-date', NOW, ]); assert.equal(status, 0); const out = JSON.parse(stdout); assert.equal(out.autoInitialized, false); assert.deepEqual(out.added, [resolve('/r/c')]); assert.deepEqual(out.skipped, [resolve('/r/a')]); const { ledger } = await readLedger(file); assert.equal(ledger.repos.length, 3); }); it('uses --name for a single path; derives basenames for multiple', async () => { const dir = newDir(); const solo = join(dir, 'solo.json'); runWrite(['add', '/r/x', '--name', 'custom', '--ledger-file', solo, '--reference-date', NOW]); const { ledger: l1 } = await readLedger(solo); assert.equal(l1.repos[0].name, 'custom'); const multi = join(dir, 'multi.json'); runWrite(['add', '/r/alpha', '/r/beta', '--ledger-file', multi, '--reference-date', NOW]); const { ledger: l2 } = await readLedger(multi); assert.deepEqual(l2.repos.map((r) => r.name).sort(), ['alpha', 'beta']); }); }); describe('campaign-write-cli — set-status', () => { async function seeded() { const dir = newDir(); const file = join(dir, 'ledger.json'); runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]); return file; } it('transitions a tracked repo and attaches findings + session (exit 0)', async () => { const file = await seeded(); const findings = { critical: 1, high: 2, medium: 0, low: 4 }; const { status, stdout } = runWrite([ 'set-status', '/r/a', 'audited', '--findings', JSON.stringify(findings), '--session', '20260622_120000', '--ledger-file', file, '--reference-date', NOW, ]); assert.equal(status, 0); const out = JSON.parse(stdout); assert.equal(out.action, 'set-status'); assert.equal(out.repo.status, 'audited'); assert.deepEqual(out.repo.findingsBySeverity, findings); assert.equal(out.repo.sessionId, '20260622_120000'); const { ledger } = await readLedger(file); assert.deepEqual(rollUp(ledger).bySeverity, findings); assert.deepEqual(out.rollUp, rollUp(ledger)); }); it('exits 3 on an invalid status', async () => { const file = await seeded(); const { status } = runWrite([ 'set-status', '/r/a', 'nonsense', '--ledger-file', file, '--reference-date', NOW, ]); assert.equal(status, 3); }); it('exits 3 when the repo is not tracked', async () => { const file = await seeded(); const { status } = runWrite([ 'set-status', '/r/ghost', 'audited', '--ledger-file', file, '--reference-date', NOW, ]); assert.equal(status, 3); }); it('exits 3 when no ledger exists yet', () => { const dir = newDir(); const { status } = runWrite([ 'set-status', '/r/a', 'audited', '--ledger-file', join(dir, 'nope.json'), '--reference-date', NOW, ]); assert.equal(status, 3); }); }); describe('campaign-write-cli — determinism + --output-file', () => { it('stamps updatedDate from --reference-date and writes the payload to --output-file', async () => { const dir = newDir(); const file = join(dir, 'ledger.json'); runWrite(['init', '--ledger-file', file, '--reference-date', '2026-01-01']); const report = join(dir, 'report.json'); const { stdout } = runWrite([ 'add', '/r/a', '--ledger-file', file, '--reference-date', '2026-03-15', '--output-file', report, ]); assert.equal(stdout.trim(), '', 'stdout is silent when --output-file is given'); const written = JSON.parse(readFileSync(report, 'utf-8')); assert.equal(written.action, 'add'); const { ledger } = await readLedger(file); assert.equal(ledger.createdDate, '2026-01-01', 'created stamp preserved'); assert.equal(ledger.updatedDate, '2026-03-15', 'updated stamp from the later --reference-date'); }); it('exits 3 on an unknown subcommand', () => { const dir = newDir(); const { status } = runWrite(['frobnicate', '--ledger-file', join(dir, 'x.json')]); assert.equal(status, 3); }); });