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, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { execFileSync, spawnSync } 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'); }); }); // --- v5.14 hardening (økt #45, chunk `fix`) -------------------------------- // Every case below was measured against the pre-fix CLI and recorded in // docs/fix-fasit.local.md (F1-F8 + M-BUG-29) before a line of it was fixed. /** Run the CLI and return { status, stdout, stderr } without throwing on non-zero. */ function runCli(args, opts = {}) { const res = spawnSync('node', [FIX_CLI, ...args], { encoding: 'utf-8', timeout: 30000, env: hermeticEnv(), ...opts, }); return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' }; } describe('fix-cli argument validation (F1/F3)', () => { it('rejects an unknown flag instead of swallowing its value as the target', () => { const outFile = join(tmpdir(), `ca-fix-argtest-${Date.now()}.json`); const { status, stderr } = runCli([FIXABLE, '--bogus', outFile, '--json']); assert.strictEqual(status, 3, 'Unknown flag must fail loud with exit 3'); assert.match(stderr, /--bogus/, 'Error must name the offending flag'); assert.ok(!existsSync(outFile), 'No file may be written for a rejected run'); }); it('rejects --output-file with no value', () => { const { status, stderr } = runCli([FIXABLE, '--output-file']); assert.strictEqual(status, 3, 'Missing flag value must fail loud'); assert.match(stderr, /--output-file/, 'Error must name the flag missing its value'); }); it('accepts --dry-run, the flag commands/fix.md documents', () => { const { status, stdout } = runCli([FIXABLE, '--dry-run', '--json']); assert.strictEqual(status, 0, '--dry-run must be a supported flag'); const output = JSON.parse(stdout); assert.strictEqual(output.backupId, null, '--dry-run must not create a backup'); }); }); describe('fix-cli --output-file (F2)', () => { it('writes the JSON payload to the file and leaves stdout byte-stable', () => { const outFile = join(tmpdir(), `ca-fix-out-${Date.now()}.json`); const piped = runCli([FIXABLE, '--json']); const written = runCli([FIXABLE, '--json', '--output-file', outFile]); assert.strictEqual(written.status, 0); assert.ok(existsSync(outFile), '--output-file must produce a file'); const fromFile = JSON.parse(readFileSync(outFile, 'utf-8')); assert.ok(Array.isArray(fromFile.planned), 'File must carry the fix payload'); assert.strictEqual( written.stdout, piped.stdout, '--json stdout is frozen v5.0.0 — --output-file may not change it', ); rmSync(outFile, { force: true }); }); it('writes the payload in default mode too, where stdout carries nothing', () => { const outFile = join(tmpdir(), `ca-fix-out-default-${Date.now()}.json`); const { status } = runCli([FIXABLE, '--output-file', outFile]); assert.strictEqual(status, 0); const fromFile = JSON.parse(readFileSync(outFile, 'utf-8')); assert.ok(Array.isArray(fromFile.planned), 'Default mode must also write the payload'); rmSync(outFile, { force: true }); }); }); describe('fix-cli exit codes (F8)', () => { it('exits 2 when a planned fix failed', async () => { // Two fixes on one file: the rename and the frontmatter edit. Before the // ordering fix this pair guaranteed one ENOENT failure (M-BUG-29). const dir = await createTmpCopy(); const rulesDir = join(dir, '.claude', 'rules'); mkdirSync(rulesDir, { recursive: true }); writeFileSync(join(rulesDir, 'both.txt'), '---\nglobs: "**/*.ts"\n---\n\nBody.\n'); const { status, stdout } = runCli([dir, '--apply', '--json']); const output = JSON.parse(stdout); if (output.failed.length > 0) { assert.strictEqual(status, 2, 'A failed fix must not be reported as exit 0'); } else { assert.strictEqual(status, 0, 'A clean run stays exit 0'); } await rm(dir, { recursive: true, force: true }); }); }); describe('fix-cli backup completeness (F4)', () => { it('backs up a file whose only fix is a rename', async () => { const dir = await createTmpCopy(); const rulesDir = join(dir, '.claude', 'rules'); mkdirSync(rulesDir, { recursive: true }); // Valid `paths:` frontmatter — the extension is the only defect, so // file-rename is the only planned fix for this file. writeFileSync(join(rulesDir, 'renameonly.txt'), '---\npaths: ["**/*.ts"]\n---\n\nBody.\n'); const { stdout } = runCli([dir, '--apply', '--json']); const output = JSON.parse(stdout); const renames = output.applied.filter((a) => /renameonly\.txt$/.test(a.file)); assert.strictEqual(renames.length, 1, 'The rename must have been applied'); const backupDir = join(HERMETIC_HOME, '.claude', 'config-audit', 'backups', output.backupId); const backedUp = readdirSync(join(backupDir, 'files')); assert.ok( backedUp.some((f) => /renameonly\.txt$/.test(f)), 'commands/fix.md promises a mandatory backup — a renamed file must be recoverable', ); await rm(dir, { recursive: true, force: true }); }); });