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
267 lines
11 KiB
JavaScript
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`,
|
|
);
|
|
});
|
|
}
|
|
});
|