config-audit/tests/scanners/rollback-paths.test.mjs
Kjell Tore Guttormsen 8f149891c9 fix(rollback): restore the backup path contract the engine and the commands disagreed on
Pipeline step 4 dogfood. `/config-audit rollback` could not see a single one of
the four real backups on this machine, and reported "Backup not found" for one
that was sitting right there.

Four defects, one root: nothing agreed on where a backup lives or what its
manifest looks like.

- M-BUG-22 `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0)
  while every command, agent and doc uses `~/.claude/config-audit/backups`.
  The auto-backup hook and fix-cli wrote to the first, implement to the second,
  rollback read only the first. Canonical root now, with the legacy root kept
  readable so older backups stay listable and restorable (`legacy: true`).
- M-BUG-25 `parseManifest` understood only the engine's quoted `original_path:`
  spelling, but implement hand-builds its manifest with `- backup:`/`original:`/
  `sha256:`. Every implement-made backup parsed to zero files and restoreBackup
  returned `{restored: [], failed: []}` — a success-shaped no-op. Both formats
  parse now, and a manifest with unparseable entries throws instead of
  pretending to succeed.
- M-BUG-23 both session hooks watched `~/.config-audit/sessions`, which does not
  exist; sessions live under `~/.claude/`. "Check for active sessions" had never
  fired once. It fires now.
- M-BUG-24 the suite called createBackup() against the developer's real home —
  it had left nine stray backups there, and cleanupOldBackups() deletes past ten.
  Root is overridable via CONFIG_AUDIT_BACKUP_ROOT; both test files use it.

Rollback still cannot delete files implement CREATED — no backup can hold a file
that never existed. It no longer does so silently: manifests carry a `created:`
list, restoreBackup returns `createdNotRemoved`, and rollback.md requires the
report. Automatic deletion is a destructive action and needs its own design.

Verified against backup 20260717_032636 on a throwaway copy: all three files
restore byte-exact (sha256 match), zero writes outside the copy, backup dir
unmodified. Suite 1382 -> 1398/0; frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SejM9RQAa1Hfuq7Ek2WfFr
2026-07-31 17:23:30 +02:00

267 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);
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 hand-builds its manifest (commands/implement.md tells the
// agent to mkdir + cp), so real backups on disk 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.
// ========================================
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`,
);
});
}
});