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, '.config-audit', 'backups', output.backupId); assert.ok(existsSync(backupDir), 'Backup directory should exist'); }); 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'); }); });