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