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
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
import { writeFile, readFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir, homedir } from 'node:os';
|
|
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
|
|
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
|
|
|
|
// Keep every backup this file creates inside a temp root. Without this the
|
|
// suite writes into the operator's real ~/.claude/config-audit/backups, where
|
|
// cleanupOldBackups() would start deleting genuine backups past MAX_BACKUPS.
|
|
const TEST_BACKUP_ROOT = join(tmpdir(), `config-audit-rb-root-${process.pid}`);
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = TEST_BACKUP_ROOT;
|
|
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = join(TEST_BACKUP_ROOT, 'legacy');
|
|
|
|
/** Create a temp file and back it up, returning paths and content. */
|
|
async function setupTestBackup() {
|
|
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
|
|
const testFile = join(tmpDir, 'test-settings.json');
|
|
const originalContent = '{"original": true, "key": "value"}';
|
|
writeFileSync(testFile, originalContent);
|
|
|
|
const backup = createBackup([testFile]);
|
|
|
|
// Now modify the file to simulate a change
|
|
writeFileSync(testFile, '{"modified": true}');
|
|
|
|
return { tmpDir, testFile, originalContent, backup };
|
|
}
|
|
|
|
describe('listBackups', () => {
|
|
it('returns an array of backups', async () => {
|
|
const result = await listBackups();
|
|
assert.ok(Array.isArray(result.backups), 'Should return backups array');
|
|
});
|
|
|
|
it('backups are sorted newest first', async () => {
|
|
const result = await listBackups();
|
|
if (result.backups.length >= 2) {
|
|
assert.ok(result.backups[0].id >= result.backups[1].id, 'First backup should be newer');
|
|
}
|
|
});
|
|
|
|
it('each backup has required fields', async () => {
|
|
const { tmpDir, backup } = await setupTestBackup();
|
|
try {
|
|
const result = await listBackups();
|
|
const found = result.backups.find(b => b.id === backup.backupId);
|
|
assert.ok(found, 'Should find our test backup');
|
|
assert.ok(found.id, 'Backup should have id');
|
|
assert.ok(found.createdAt, 'Backup should have createdAt');
|
|
assert.ok(Array.isArray(found.files), 'Backup should have files array');
|
|
assert.ok(found.files.length > 0, 'Backup should have at least one file');
|
|
assert.ok(found.files[0].originalPath, 'File entry should have originalPath');
|
|
assert.ok(found.files[0].checksum, 'File entry should have checksum');
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('restoreBackup', () => {
|
|
let tmpDir, testFile, originalContent, backup;
|
|
|
|
beforeEach(async () => {
|
|
({ tmpDir, testFile, originalContent, backup } = await setupTestBackup());
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
// Cleanup our test backup
|
|
try { await deleteBackup(backup.backupId); } catch {}
|
|
});
|
|
|
|
it('restores files to original content', async () => {
|
|
const result = await restoreBackup(backup.backupId);
|
|
assert.ok(result.restored.length > 0, 'Should restore at least one file');
|
|
assert.strictEqual(result.failed.length, 0, 'No failures');
|
|
|
|
const restoredContent = await readFile(testFile, 'utf-8');
|
|
assert.strictEqual(restoredContent, originalContent, 'Content should match original');
|
|
});
|
|
|
|
it('verifies checksums after restore', async () => {
|
|
const result = await restoreBackup(backup.backupId, { verify: true });
|
|
for (const r of result.restored) {
|
|
assert.strictEqual(r.status, 'restored');
|
|
}
|
|
});
|
|
|
|
it('dry-run returns plan without writing', async () => {
|
|
const result = await restoreBackup(backup.backupId, { dryRun: true });
|
|
assert.ok(result.restored.length > 0);
|
|
for (const r of result.restored) {
|
|
assert.strictEqual(r.status, 'dry-run');
|
|
}
|
|
|
|
// File should still be modified
|
|
const content = await readFile(testFile, 'utf-8');
|
|
assert.strictEqual(content, '{"modified": true}', 'File should not be restored in dry-run');
|
|
});
|
|
|
|
it('throws for invalid backup-id', async () => {
|
|
await assert.rejects(
|
|
() => restoreBackup('nonexistent_99999999_999999'),
|
|
{ message: /Backup not found/ },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deleteBackup', () => {
|
|
it('deletes an existing backup', async () => {
|
|
const { tmpDir, backup } = await setupTestBackup();
|
|
try {
|
|
const result = await deleteBackup(backup.backupId);
|
|
assert.strictEqual(result.deleted, true);
|
|
|
|
// Verify it's gone from the list
|
|
const list = await listBackups();
|
|
const found = list.backups.find(b => b.id === backup.backupId);
|
|
assert.ok(!found, 'Deleted backup should not appear in list');
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns error for nonexistent backup', async () => {
|
|
const result = await deleteBackup('nonexistent_99999999_999999');
|
|
assert.strictEqual(result.deleted, false);
|
|
assert.ok(result.error, 'Should have error message');
|
|
});
|
|
});
|