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
113 lines
4.4 KiB
JavaScript
113 lines
4.4 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { cp, rm, readFile, stat } from 'node:fs/promises';
|
|
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { hermeticEnv, HERMETIC_HOME } from '../helpers/hermetic-home.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
const FIXABLE = resolve(FIXTURES, 'fixable-project');
|
|
const FIX_CLI = resolve(__dirname, '../../scanners/fix-cli.mjs');
|
|
|
|
/** Create a temporary copy of the fixable-project fixture. */
|
|
async function createTmpCopy() {
|
|
const tmpDir = join(tmpdir(), `config-audit-cli-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
await cp(FIXABLE, tmpDir, { recursive: true });
|
|
return tmpDir;
|
|
}
|
|
|
|
describe('fix-cli dry-run', () => {
|
|
it('shows planned fixes without --apply', () => {
|
|
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
const output = JSON.parse(result);
|
|
assert.ok(Array.isArray(output.planned), 'Should have planned array');
|
|
assert.ok(output.planned.length > 0, 'Should have planned fixes');
|
|
assert.strictEqual(output.backupId, null, 'No backup in dry-run');
|
|
assert.ok(Array.isArray(output.manual), 'Should have manual array');
|
|
|
|
// Regression lock (HOME-leak class): a project-scoped fix run must surface only
|
|
// project-local findings, never HOME-scoped SKL/COL findings read from the
|
|
// developer's real ~/.claude. Without a hermetic HOME the spawned CLI picks up
|
|
// CA-SKL-001 from installed skills, making this test machine-dependent.
|
|
const homeScoped = output.manual.filter((m) => /^CA-(SKL|COL)-/.test(m.findingId));
|
|
assert.deepStrictEqual(
|
|
homeScoped.map((m) => m.findingId),
|
|
[],
|
|
'fix-cli leaked HOME-scoped findings — spawn is not HOME-isolated',
|
|
);
|
|
});
|
|
|
|
it('outputs valid JSON with --json flag', () => {
|
|
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
assert.doesNotThrow(() => JSON.parse(result), 'Output should be valid JSON');
|
|
});
|
|
});
|
|
|
|
describe('fix-cli --apply', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTmpCopy();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies fixes and creates backup', () => {
|
|
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
const output = JSON.parse(result);
|
|
assert.ok(output.applied.length > 0, 'Should have applied fixes');
|
|
assert.ok(output.backupId, 'Should have a backup ID');
|
|
|
|
// Verify backup exists. The CLI runs with a hermetic HOME (see execFileSync env
|
|
// below), so its backups land under HERMETIC_HOME, not the developer's real home.
|
|
const backupDir = join(HERMETIC_HOME, '.claude', 'config-audit', 'backups', output.backupId);
|
|
assert.ok(existsSync(backupDir), 'Backup directory should exist');
|
|
|
|
// …and nothing may land in the pre-v2.2.0 root, which is read-only now.
|
|
const legacyDir = join(HERMETIC_HOME, '.config-audit', 'backups', output.backupId);
|
|
assert.ok(!existsSync(legacyDir), 'Nothing may be written to the legacy backup root');
|
|
});
|
|
|
|
it('actually modifies files after --apply', async () => {
|
|
execFileSync('node', [FIX_CLI, tmpDir, '--apply'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
|
|
// Check that settings.json was fixed
|
|
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
|
|
const parsed = JSON.parse(content);
|
|
assert.ok(parsed.$schema, 'Should have $schema after fix');
|
|
});
|
|
|
|
it('reports verified fixes', () => {
|
|
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
const output = JSON.parse(result);
|
|
assert.ok(Array.isArray(output.verified), 'Should have verified array');
|
|
assert.ok(output.verified.length > 0, 'Should have verified fixes');
|
|
});
|
|
});
|