config-audit/tests/scanners/campaign-write-cli.test.mjs
Kjell Tore Guttormsen ee0c762151 feat(campaign): /config-audit campaign — human-approved write-CLI + command (v5.7 Fase 2 Block 3c)
Completes the THIN machine-wide campaign surface (ledger + roll-up + status).
The write half of the campaign motor, mirroring how knowledge-refresh gates
register writes:

- scanners/campaign-write-cli.mjs (-cli → NOT a scanner; 11 tests): init/add/
  set-status, each a thin wrapper over the invariant-enforcing lib transforms
  (createLedger/addRepo/setRepoStatus) + saveLedger — path-normalization/dedup,
  idempotent add, status-lifecycle guard and updatedDate bump never hand-rolled.
  init refuses to clobber an existing/corrupt ledger (exit 1, file untouched);
  add auto-inits + reports added vs skipped; set-status takes --findings/--session.
  Deterministic: --reference-date is the only clock read, injected as `now`.
  Exit 0=write, 1=advisory no-op, 3=error.
- commands/campaign.md (opus, no Web): thin orchestrator — always reports first
  (read-only campaign-cli), then proposes init/add/set-status and invokes ONE
  write-CLI subcommand only on explicit human approval (Verifiseringsplikt; never
  hand-edits the JSON). add --discover finds git repos under a root to pick from.

Not byte-stable (own command, outside snapshot suite) like /config-audit optimize
+ knowledge-refresh. Both CLIs are -cli → scanner count stays 15, snapshot suite
untouched. commands 20→21, suite 1127→1138 (+11), test files 64→65.

Docs: CLAUDE.md §campaign-write-cli + command table + Testing badge; README
commands badge 20→21 + table row; help.md + router wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:25:15 +02:00

211 lines
8.1 KiB
JavaScript

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