/** * R1 — the restore path gets a runnable entry. * * `rollback-engine.mjs` has always verified checksums before AND after each * write, resolved the legacy backup root, and reported `createdNotRemoved`. * None of it was reachable: measured at the head of this chunk, 16 files under * `scanners/` carry a `process.argv` entry and the engine was not one of them. * `commands/rollback.md` drove the restore as model prose — an ESM `import` * block a command template cannot execute, with ad-hoc `cp` offered as the * runnable alternative and a pre-rendered "(checksum verified)" line under it. * `cp` establishes no checksum, so the claim was rendered by the template * rather than produced by the run. * * Two properties of this file are load-bearing: * * 1. **The unknown-flag control comes first.** `command-cli-contract.test.mjs` * only trusts a CLI's silence about a flag once it has SEEN that CLI reject * a flag which cannot exist. The same control is asserted here, at the * source, so a future edit that moves argv parsing behind a required-arg * check fails in the CLI's own test rather than silently making every flag * pair downstream pass for the wrong reason. * * 2. **A refusal is asserted on the BYTES, never on the exit code alone.** * The gate's whole job is that nothing was written; a test that reads only * the verdict would pass against an engine that refused loudly and wrote * anyway. */ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawn } from 'node:child_process'; import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from 'node:fs/promises'; import { join, resolve, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { parseManifest } from '../../scanners/lib/backup.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI = resolve(__dirname, '..', '..', 'scanners', 'rollback-cli.mjs'); /** * A sandbox that is a HOME as well as a backup root, so `user-scope` * classification and backup resolution both stay inside the temp dir. The cwd * is a third, empty directory: probing a writer runs the writer (#67), and a * CLI that defaults a path to the working directory must be caught doing it. */ async function sandbox() { const root = await mkdtemp(join(tmpdir(), 'ca-rbcli-')); const home = join(root, 'home'); const work = join(root, 'work'); const cwd = join(root, 'cwd'); await mkdir(join(home, '.claude'), { recursive: true }); await mkdir(work, { recursive: true }); await mkdir(cwd, { recursive: true }); return { root, home, work, cwd, backupRoot: join(home, '.claude', 'config-audit', 'backups'), cleanup: () => rm(root, { recursive: true, force: true }), }; } async function run(sb, argv) { const { code, stdout, stderr } = await new Promise((res) => { const child = spawn(process.execPath, [CLI, ...argv], { cwd: sb.cwd, env: { ...process.env, HOME: sb.home, USERPROFILE: sb.home, CONFIG_AUDIT_BACKUP_ROOT: sb.backupRoot, CONFIG_AUDIT_LEGACY_BACKUP_ROOT: join(sb.home, '.config-audit', 'backups'), }, }); let out = ''; let err = ''; child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { err += d; }); child.on('close', (c) => res({ code: c, stdout: out, stderr: err })); }); return { code, stdout, stderr, cwdEntries: await readdir(sb.cwd) }; } /** Run and read the `--output-file` payload back. */ async function runJson(sb, argv) { const out = join(sb.root, `payload-${Math.random().toString(36).slice(2)}.json`); const r = await run(sb, [...argv, '--output-file', out]); return { ...r, payload: JSON.parse(await readFile(out, 'utf-8')), outFile: out }; } /** Make a backup of `files` through the CLI itself and return its id. */ async function seedBackup(sb, files, created = []) { const argv = ['--create']; for (const f of files) argv.push('--target', f); for (const c of created) argv.push('--created', c); const { code, payload } = await runJson(sb, [...argv, '--repo', sb.work]); assert.equal(code, 0, 'seeding a backup must succeed'); return payload.backupId; } test('rejects a flag that cannot exist (the control every downstream probe rests on)', async () => { const sb = await sandbox(); try { const { code, stderr } = await run(sb, ['--zzz-not-a-real-flag']); assert.equal(code, 3, 'a malformed argv is exit 3, never a verdict'); assert.match(stderr, /unknown flag "--zzz-not-a-real-flag"/i); } finally { await sb.cleanup(); } }); test('a malformed argv stops the run, it does not merely colour the exit code', async () => { // Found by mutating the gate: `requireValidArgs` sets exit 3 by itself, so a // caller that drops the `return` still LOOKS rejected while the mode runs to // completion underneath. A restore that happened is not undone by the exit // code that says it should not have. const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const id = await seedBackup(sb, [target]); await writeFile(target, '# current\n'); const { code } = await run(sb, ['--restore', id, '--repo', sb.work, '--zzz-not-a-real-flag']); assert.equal(code, 3); assert.equal( await readFile(target, 'utf-8'), '# current\n', 'the CLI restored a file while reporting that it could not parse its own arguments', ); } finally { await sb.cleanup(); } }); test('two modes in one argv is an argument error, not a silent winner', async () => { const sb = await sandbox(); try { const { code, stderr } = await run(sb, ['--list', '--delete', 'x']); assert.equal(code, 3); assert.match(stderr, /one mode/i); } finally { await sb.cleanup(); } }); test('list mode on an empty backup root answers zero, and writes nothing', async () => { const sb = await sandbox(); try { const { code, payload, cwdEntries } = await runJson(sb, ['--list']); assert.equal(code, 0); assert.equal(payload.meta.mode, 'list'); assert.deepEqual(payload.backups, []); assert.equal(payload.count, 0); assert.deepEqual(cwdEntries, [], 'the CLI must not default any path to the working directory'); } finally { await sb.cleanup(); } }); test('--output-file keeps the payload off stdout (ux-rules rule 1)', async () => { const sb = await sandbox(); try { const { stdout } = await runJson(sb, ['--list']); assert.equal(stdout, '', 'a payload written to a file must not also reach the transcript'); } finally { await sb.cleanup(); } }); test('create mode backs up the real bytes and round-trips through parseManifest', async () => { const sb = await sandbox(); try { const a = join(sb.work, 'CLAUDE.md'); const b = join(sb.work, '.claude', 'settings.json'); await mkdir(dirname(b), { recursive: true }); await writeFile(a, '# original A\n'); await writeFile(b, '{"a":1}\n'); const willCreate = join(sb.work, '.claude', 'rules', 'new-rule.md'); const { code, payload } = await runJson( sb, ['--create', '--target', a, '--target', b, '--created', willCreate, '--repo', sb.work], ); assert.equal(code, 0); assert.equal(payload.meta.mode, 'create'); assert.match(payload.backupId, /^\d{8}_\d{6}$/); assert.equal(payload.files.length, 2); assert.deepEqual(payload.created, [willCreate]); assert.deepEqual(payload.skipped, []); // R2: the data contract is owned by the code on BOTH sides. A manifest the // engine writes must be one the engine's own parser reads back whole — // including `created:`, which the hand-built template format carried and // `createBackup` did not. const manifest = parseManifest( await readFile(join(payload.backupPath, 'manifest.yaml'), 'utf-8'), ); assert.equal(manifest.backup_id, payload.backupId); assert.equal(manifest.files.length, 2); assert.deepEqual(manifest.created, [willCreate]); assert.equal(manifest.files[0].originalPath, a); } finally { await sb.cleanup(); } }); test('create mode reports a target it could not back up instead of counting it', async () => { const sb = await sandbox(); try { const real = join(sb.work, 'CLAUDE.md'); await writeFile(real, 'x\n'); const ghost = join(sb.work, 'not-there.md'); const { code, payload } = await runJson( sb, ['--create', '--target', real, '--target', ghost, '--repo', sb.work], ); assert.equal(code, 1, 'a backup that covers fewer files than asked is a warning, not a pass'); assert.deepEqual(payload.skipped, [ghost]); assert.equal(payload.files.length, 1); } finally { await sb.cleanup(); } }); test('restore writes the backed-up bytes back and verifies the checksum', async () => { const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const id = await seedBackup(sb, [target]); await writeFile(target, '# clobbered\n'); const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]); assert.equal(code, 0); assert.equal(payload.meta.mode, 'restore'); assert.equal(payload.backupId, id); assert.deepEqual(payload.failed, []); assert.equal(payload.restored.length, 1); assert.equal(payload.restored[0].status, 'restored'); assert.equal(await readFile(target, 'utf-8'), '# original\n'); } finally { await sb.cleanup(); } }); test('a corrupted backup fails loudly and leaves the original alone', async () => { const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const id = await seedBackup(sb, [target]); await writeFile(target, '# current\n'); // Corrupt the stored copy: the checksum in the manifest no longer matches. const files = join(sb.backupRoot, id, 'files'); const [stored] = await readdir(files); await writeFile(join(files, stored), '# tampered\n'); const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]); assert.equal(code, 2, 'a failed restore is a FAIL verdict, not a pass'); assert.deepEqual(payload.restored, []); assert.equal(payload.failed[0].status, 'checksum-mismatch'); assert.equal( await readFile(target, 'utf-8'), '# current\n', 'a checksum mismatch must stop the write, not land tampered bytes on the original', ); } finally { await sb.cleanup(); } }); test('a restore that leaves this project is refused until --approve-scope', async () => { const sb = await sandbox(); try { const target = join(sb.home, '.claude', 'CLAUDE.md'); await writeFile(target, '# machine-wide original\n'); const id = await seedBackup(sb, [target]); await writeFile(target, '# machine-wide current\n'); const refused = await runJson(sb, ['--restore', id, '--repo', sb.work]); assert.equal(refused.code, 1, 'approval owed is a warning verdict, not a tool error'); assert.equal(refused.payload.requiresApproval, true); assert.equal(refused.payload.gate, 'require-ok'); assert.ok(refused.payload.disclosures.length > 0, 'the refusal must carry words to render'); assert.deepEqual(refused.payload.restored, []); assert.equal(refused.payload.refused[0].reason, 'scope-gate'); assert.equal( await readFile(target, 'utf-8'), '# machine-wide current\n', 'the gate refused and must therefore have written nothing', ); const ok = await runJson(sb, ['--restore', id, '--repo', sb.work, '--approve-scope']); assert.equal(ok.code, 0); assert.equal(await readFile(target, 'utf-8'), '# machine-wide original\n'); } finally { await sb.cleanup(); } }); test('--dry-run reports what would happen and touches nothing', async () => { const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const id = await seedBackup(sb, [target]); await writeFile(target, '# current\n'); const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work, '--dry-run']); assert.equal(code, 0); assert.equal(payload.dryRun, true); assert.equal(payload.restored[0].status, 'dry-run'); assert.equal(await readFile(target, 'utf-8'), '# current\n', 'a dry run is not a write'); } finally { await sb.cleanup(); } }); test('restore reports the files it cannot undo', async () => { const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const madeByImplement = join(sb.work, '.claude', 'rules', 'post-quality.md'); const id = await seedBackup(sb, [target], [madeByImplement]); const { payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]); assert.deepEqual( payload.createdNotRemoved, [madeByImplement], 'a half-restored target is only dangerous when it is also silent', ); } finally { await sb.cleanup(); } }); test('an unknown backup id is a tool error, never an empty success', async () => { const sb = await sandbox(); try { const { code, stderr } = await run(sb, ['--restore', '19700101_000000', '--repo', sb.work]); assert.equal(code, 3); assert.match(stderr, /not found/i); } finally { await sb.cleanup(); } }); test('delete removes the backup directory, and refuses an id it cannot find', async () => { const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const id = await seedBackup(sb, [target]); const { code, payload } = await runJson(sb, ['--delete', id]); assert.equal(code, 0); assert.equal(payload.deleted, true); await assert.rejects(() => stat(join(sb.backupRoot, id))); const missing = await run(sb, ['--delete', '19700101_000000']); assert.equal(missing.code, 3); } finally { await sb.cleanup(); } }); test('list mode sees a backup the CLI itself created', async () => { const sb = await sandbox(); try { const target = join(sb.work, 'CLAUDE.md'); await writeFile(target, '# original\n'); const id = await seedBackup(sb, [target]); const { payload } = await runJson(sb, ['--list']); assert.equal(payload.count, 1); assert.equal(payload.backups[0].id, id); assert.equal(payload.backups[0].files[0].originalPath, target); } finally { await sb.cleanup(); } });