config-audit/tests/scanners/rollback-paths.test.mjs
Kjell Tore Guttormsen 44b222859e feat(scanners): the recovery path is code you can run, not prose you can read
R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the
restore path, and neither closes alone.

R1: rollback-engine.mjs verified every checksum before AND after each write,
resolved the legacy backup root and reported createdNotRemoved — and none of it
was reachable. Measured: 16 files under scanners/ carry a process.argv entry;
the engine was not one of them. commands/rollback.md drove the restore as model
prose: an ESM import block a template cannot execute, ad-hoc `cp` offered
underneath as the runnable path, and "(checksum verified)" pre-rendered three
times in the success output. `cp` establishes no checksum, so the verification
was a property of the template rather than of the run — on the one surface that
runs when the user is already in trouble.

R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a
manifest typed out in the template) while parseManifest knew one frozen sample
of that format, pinned by a HAND-WRITTEN fixture instead of by the template's
own text. Rename a key and parseManifest returns zero files while rollback
reports success.

Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves
a clean format with no runnable entry.

- scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the
  existing engine, on the shared requireValidArgs gate. Exit 0 done, 1
  outstanding (gate refusal with nothing written, or a backup that covered fewer
  targets than given), 2 a file failed, 3 could not do the job. A gated restore
  is 1, not 3: "this write leaves your project" is a verdict about a write that
  WAS examined, and it rides in the payload where a command under 2>/dev/null
  can act on it.
- createBackup gains `created` (recorded, never copied — no backup can hold a
  file that does not exist) and `skipped`, so a backup covering fewer files than
  asked is no longer indistinguishable from a clean one.
- implement.md Step 3 and rollback.md now call the CLI. parseManifest's
  implement-format branch stays: nothing writes that shape now, but every backup
  made before this chunk is on disk in it.
- backup-restore-contract.test.mjs checks every field rollback.md renders
  against a payload produced by RUNNING the CLI. That is what replaced
  "(checksum verified)".

20 guards seen red against the original state before any production code, then
each against its own defect. Two holes that surfaced there were mine: the
implement assertion matched `--create` as a substring of `--created` and stayed
green when the call was removed; and mutating the argv gate showed
requireValidArgs sets exit 3 by itself, so a CLI can report that it could not
parse its arguments and still run the restore underneath — that case is now
asserted on the bytes.

Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded
through the templates' own command lines against a sandboxed HOME, including the
machine-wide arm: refused with the file unchanged, then restored under
--approve-scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
2026-08-18 21:28:15 +02:00

276 lines
11 KiB
JavaScript

import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir, homedir } from 'node:os';
import { resolve } from 'node:path';
import {
createBackup,
getBackupDir,
getLegacyBackupDir,
parseManifest,
} from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup } from '../../scanners/rollback-engine.mjs';
// ========================================
// Backup root — canonical vs legacy (M-BUG-22)
//
// Dogfood step 4 found the rollback engine reading ~/.config-audit/backups
// while every command, agent and doc writes to ~/.claude/config-audit/backups.
// listBackups() saw 0 of the 4 real backups on the operator's machine and
// restoreBackup() threw "Backup not found" for a backup that was right there.
// ========================================
const CANONICAL = join(homedir(), '.claude', 'config-audit', 'backups');
const LEGACY = join(homedir(), '.config-audit', 'backups');
/** Unique temp dir per test, cleaned up by the caller. */
function tempRoot(tag) {
const d = join(tmpdir(), `ca-rbpaths-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(d, { recursive: true });
return d;
}
describe('backup root resolution', () => {
const saved = {};
beforeEach(() => {
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
});
afterEach(() => {
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
});
it('getBackupDir() defaults to the canonical ~/.claude/config-audit/backups', () => {
assert.strictEqual(getBackupDir(), CANONICAL);
});
it('getLegacyBackupDir() exposes the pre-v2.2.0 path', () => {
assert.strictEqual(getLegacyBackupDir(), LEGACY);
});
it('the two roots are distinct (guards against a copy-paste fix)', () => {
assert.notStrictEqual(getBackupDir(), getLegacyBackupDir());
});
it('getBackupDir() honours CONFIG_AUDIT_BACKUP_ROOT so tests stay hermetic', () => {
const root = tempRoot('env');
try {
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
assert.strictEqual(getBackupDir(), root);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('createBackup() writes under the overridden root, not the real home (M-BUG-24)', () => {
const root = tempRoot('write');
const work = tempRoot('work');
try {
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
const target = join(work, 'settings.json');
writeFileSync(target, '{"original": true}');
const { backupPath } = createBackup([target]);
assert.ok(backupPath.startsWith(root), `backup landed outside the override: ${backupPath}`);
assert.ok(!backupPath.startsWith(homedir() + '/.config-audit'), 'must not write to the real home');
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(work, { recursive: true, force: true });
}
});
});
describe('listBackups / restoreBackup across both roots', () => {
const saved = {};
let canonical, legacy, work;
beforeEach(() => {
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
canonical = tempRoot('canon');
legacy = tempRoot('leg');
work = tempRoot('work');
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = legacy;
});
afterEach(() => {
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
for (const d of [canonical, legacy, work]) rmSync(d, { recursive: true, force: true });
});
it('lists a backup that lives in the canonical root', async () => {
const target = join(work, 'settings.json');
writeFileSync(target, '{"original": true}');
const { backupId } = createBackup([target]);
const { backups } = await listBackups();
assert.ok(backups.some(b => b.id === backupId), 'canonical backup must be listed');
});
it('still lists backups left behind in the legacy root', async () => {
// Seed a legacy-root backup by pointing the writer at it temporarily.
const target = join(work, 'legacy.json');
writeFileSync(target, '{"legacy": true}');
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
const { backupId } = createBackup([target]);
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
const { backups } = await listBackups();
const found = backups.find(b => b.id === backupId);
assert.ok(found, 'a pre-v2.2.0 backup must not become invisible after the path fix');
assert.strictEqual(found.legacy, true, 'legacy backups should be flagged as such');
});
it('restores a backup that only exists in the legacy root', async () => {
const target = join(work, 'legacy-restore.json');
writeFileSync(target, '{"original": true}');
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
const { backupId } = createBackup([target]);
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
writeFileSync(target, '{"modified": true}');
const result = await restoreBackup(backupId, { repoRoot: work });
assert.strictEqual(result.failed.length, 0, 'no failures expected');
assert.strictEqual(result.restored.length, 1, 'the legacy backup should resolve');
assert.strictEqual(await readFile(target, 'utf-8'), '{"original": true}');
});
});
// ========================================
// Manifest compatibility (M-BUG-25)
//
// The implement flow used to hand-build its manifest (commands/implement.md told
// the agent to mkdir + cp), so backups written by that flow use `- backup:` /
// `original:` / `sha256:` while parseManifest only understood the engine's
// quoted `original_path:` / `backup_path:` / `checksum:`. Result: parseManifest
// returned files: [] and restoreBackup reported success having restored
// nothing — a success-shaped no-op, the worst failure mode in the file.
//
// R2 removed the prose format at the SOURCE: Step 3 now calls
// `rollback-cli.mjs --create`, so nothing writes this shape any more. The
// fixture below therefore changed meaning rather than becoming obsolete — it
// pins a format that still exists ON DISK in every backup implement made before
// this chunk, and those must stay restorable. That is also why it is legitimately
// hand-written now: it is a golden sample of historical bytes, not a stand-in for
// a template's own text (the #63 objection that applied while the template was
// still authoring it).
// ========================================
const ENGINE_MANIFEST = `created_at: "2026-07-17T03:26:36.000Z"
backup_id: "20260717_032636"
files:
- original_path: "/tmp/x/CLAUDE.md"
backup_path: "./files/_tmp_x_CLAUDE.md"
checksum: "6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12"
size_bytes: 42
`;
const IMPLEMENT_MANIFEST = `session: 20260717_step3impl
created: 20260717_032636
target_root: /tmp/x
files:
- backup: files/project-claude/settings.local.json
original: /tmp/x/.claude/settings.local.json
sha256: f03f45699568218df0dce447d82954eeece34264f47783a1a2ff6b10cdc43ef9
- backup: files/nested-claude/settings.local.json
original: /tmp/x/posts/2026-01-23-ralph-wiggum/.claude/settings.local.json
sha256: 39a3723441892ce1cf987508fb448b6a656b65c8d194d1640c1915cd9098d3c6
- backup: files/root/CLAUDE.md
original: /tmp/x/CLAUDE.md
sha256: 6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12
`;
describe('parseManifest format compatibility', () => {
it('still parses the engine format unchanged', () => {
const m = parseManifest(ENGINE_MANIFEST);
assert.strictEqual(m.backup_id, '20260717_032636');
assert.strictEqual(m.files.length, 1);
assert.strictEqual(m.files[0].originalPath, '/tmp/x/CLAUDE.md');
assert.strictEqual(m.files[0].backupPath, './files/_tmp_x_CLAUDE.md');
assert.strictEqual(m.files[0].sizeBytes, 42);
});
it('parses the implement-flow format written by the agent', () => {
const m = parseManifest(IMPLEMENT_MANIFEST);
assert.strictEqual(m.files.length, 3, 'all three entries must be recognised');
assert.strictEqual(m.files[0].originalPath, '/tmp/x/.claude/settings.local.json');
assert.strictEqual(m.files[0].backupPath, 'files/project-claude/settings.local.json');
assert.strictEqual(
m.files[2].checksum,
'6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12',
);
});
it('picks up the implement-flow backup id from `created:`', () => {
const m = parseManifest(IMPLEMENT_MANIFEST);
assert.strictEqual(m.backup_id, '20260717_032636');
});
it('never returns a silent empty file list for a manifest that has entries', () => {
for (const [label, content] of [['engine', ENGINE_MANIFEST], ['implement', IMPLEMENT_MANIFEST]]) {
assert.ok(parseManifest(content).files.length > 0, `${label} manifest parsed to zero files`);
}
});
});
// ========================================
// Files created by implement are not restorable (B1 / M-BUG-26)
//
// A backup records only files that already existed. Rollback therefore cannot
// remove what implement CREATED. That is acceptable; doing it silently is not.
// ========================================
describe('created-file reporting', () => {
it('surfaces manifest `created:` entries so rollback can tell the user what it cannot undo', () => {
const manifest = IMPLEMENT_MANIFEST + `created:
- /tmp/x/.claude/rules/post-quality.md
- /tmp/x/guidelines/posting-rhythm.md
`;
const m = parseManifest(manifest);
assert.deepStrictEqual(m.created, [
'/tmp/x/.claude/rules/post-quality.md',
'/tmp/x/guidelines/posting-rhythm.md',
]);
});
it('defaults `created` to an empty array when the manifest has no such section', () => {
assert.deepStrictEqual(parseManifest(ENGINE_MANIFEST).created, []);
});
});
// ========================================
// Session-dir hooks (M-BUG-23)
//
// Both hooks watched ~/.config-audit/sessions, which does not exist on the
// operator's machine — sessions live in ~/.claude/config-audit/sessions. The
// hooks exited 0 every time, so "check for active sessions" never fired.
// Shape test: same layer as tests/commands/implement-log-append.test.mjs.
// ========================================
describe('session hooks point at the canonical sessions dir', () => {
for (const hook of ['session-start.mjs', 'stop-session-reminder.mjs']) {
it(`${hook} resolves ~/.claude/config-audit/sessions`, () => {
const src = readFileSync(resolve(import.meta.dirname, '../../hooks/scripts', hook), 'utf-8');
assert.match(
src,
/join\(\s*homedir\(\)\s*,\s*'\.claude'\s*,\s*'config-audit'\s*,\s*'sessions'\s*\)/,
`${hook} must watch the canonical sessions dir`,
);
});
}
});