222 lines
8.8 KiB
JavaScript
222 lines
8.8 KiB
JavaScript
// reference-config.test.mjs — Tests for the reference configuration generator
|
|
// Tests against fixtures in tests/fixtures/posture-scan/ with:
|
|
// - grade-a-project: already Grade A → no recommendations
|
|
// - grade-f-project: dangerous flags, no hooks → full recommendations
|
|
|
|
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { generate } from '../../scanners/reference-config-generator.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const GRADE_A_FIXTURE = resolve(__dirname, '../fixtures/posture-scan/grade-a-project');
|
|
const GRADE_F_FIXTURE = resolve(__dirname, '../fixtures/posture-scan/grade-f-project');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grade A project — already well-configured, no changes needed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('reference-config-generator: grade-a-project', () => {
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
result = await generate(GRADE_A_FIXTURE);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('detects project type as standalone', () => {
|
|
assert.equal(result.projectType, 'standalone');
|
|
});
|
|
|
|
it('includes posture grade', () => {
|
|
assert.equal(result.posture.grade, 'A');
|
|
});
|
|
|
|
it('has zero or minimal recommendations', () => {
|
|
// Grade A should need very few changes (maybe none)
|
|
const actionable = result.recommendations.filter(r => r.action !== 'none');
|
|
assert.ok(actionable.length <= 2, `Expected <= 2 actionable, got ${actionable.length}`);
|
|
});
|
|
|
|
it('does not recommend settings.json changes', () => {
|
|
const settingsRec = result.recommendations.find(r => r.file === '.claude/settings.json' && r.action !== 'none');
|
|
assert.equal(settingsRec, undefined, 'Should not recommend settings changes for Grade A');
|
|
});
|
|
|
|
it('applied is false by default (dry-run)', () => {
|
|
assert.equal(result.applied, false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grade F project — needs everything
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('reference-config-generator: grade-f-project', () => {
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
result = await generate(GRADE_F_FIXTURE);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('detects project type', () => {
|
|
assert.ok(
|
|
['plugin', 'standalone', 'monorepo'].includes(result.projectType),
|
|
`Expected valid project type, got ${result.projectType}`,
|
|
);
|
|
});
|
|
|
|
it('includes posture grade F', () => {
|
|
assert.equal(result.posture.grade, 'F');
|
|
});
|
|
|
|
it('recommends settings.json with deny-first', () => {
|
|
const rec = result.recommendations.find(r => r.file === '.claude/settings.json');
|
|
assert.ok(rec, 'Expected settings.json recommendation');
|
|
assert.ok(rec.action === 'create' || rec.action === 'merge', `Expected create or merge, got ${rec.action}`);
|
|
assert.ok(rec.content.includes('deny'), 'Settings should include deny-first');
|
|
});
|
|
|
|
it('recommends CLAUDE.md security section', () => {
|
|
const rec = result.recommendations.find(r => r.file === 'CLAUDE.md');
|
|
assert.ok(rec, 'Expected CLAUDE.md recommendation');
|
|
assert.ok(rec.content.includes('Security Boundaries'), 'Should include security boundaries');
|
|
});
|
|
|
|
it('recommends .gitignore additions', () => {
|
|
const rec = result.recommendations.find(r => r.file === '.gitignore');
|
|
assert.ok(rec, 'Expected .gitignore recommendation');
|
|
assert.ok(rec.content.includes('.env'), 'Should include .env');
|
|
});
|
|
|
|
it('has multiple recommendations', () => {
|
|
const actionable = result.recommendations.filter(r => r.action !== 'none');
|
|
assert.ok(actionable.length >= 3, `Expected >= 3 actionable, got ${actionable.length}`);
|
|
});
|
|
|
|
it('each recommendation has required fields', () => {
|
|
for (const rec of result.recommendations) {
|
|
assert.ok(rec.category, `Missing category in recommendation`);
|
|
assert.ok(rec.file, `Missing file in recommendation`);
|
|
assert.ok(rec.action, `Missing action in recommendation`);
|
|
assert.ok(typeof rec.content === 'string', `Missing content in recommendation`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Apply mode — writes files to a temp directory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('reference-config-generator: --apply mode', () => {
|
|
const tmpDir = resolve(__dirname, '../fixtures/posture-scan/tmp-apply-test');
|
|
|
|
beforeEach(() => {
|
|
// Create a bare project to apply to
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
writeFileSync(join(tmpDir, 'CLAUDE.md'), '# Test Project\n\nA bare project.\n');
|
|
});
|
|
|
|
it('creates settings.json when applying', async () => {
|
|
resetCounter();
|
|
const result = await generate(tmpDir, { apply: true });
|
|
assert.equal(result.applied, true);
|
|
const settingsPath = join(tmpDir, '.claude', 'settings.json');
|
|
assert.ok(existsSync(settingsPath), 'settings.json should exist after apply');
|
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
assert.equal(settings.permissions.defaultPermissionLevel, 'deny');
|
|
});
|
|
|
|
it('appends security section to existing CLAUDE.md', async () => {
|
|
resetCounter();
|
|
await generate(tmpDir, { apply: true });
|
|
const content = readFileSync(join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
|
assert.ok(content.includes('Security Boundaries'), 'CLAUDE.md should have security section');
|
|
assert.ok(content.includes('# Test Project'), 'Original content should be preserved');
|
|
});
|
|
|
|
it('creates .gitignore with security patterns', async () => {
|
|
resetCounter();
|
|
await generate(tmpDir, { apply: true });
|
|
const content = readFileSync(join(tmpDir, '.gitignore'), 'utf-8');
|
|
assert.ok(content.includes('.env'), '.gitignore should include .env');
|
|
assert.ok(content.includes('*.key'), '.gitignore should include *.key');
|
|
});
|
|
|
|
it('does not overwrite existing settings.json', async () => {
|
|
// Create existing settings
|
|
mkdirSync(join(tmpDir, '.claude'), { recursive: true });
|
|
writeFileSync(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({
|
|
permissions: { defaultPermissionLevel: 'deny', allow: ['Read(*)'] },
|
|
customKey: 'preserved',
|
|
}, null, 2));
|
|
|
|
resetCounter();
|
|
const result = await generate(tmpDir, { apply: true });
|
|
const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
|
|
assert.equal(settings.customKey, 'preserved', 'Custom keys should be preserved');
|
|
});
|
|
|
|
it('reports backupPath when applying', async () => {
|
|
resetCounter();
|
|
const result = await generate(tmpDir, { apply: true });
|
|
// backupPath is set when there were existing files to back up
|
|
// For a bare project, it may or may not create backup
|
|
assert.ok(typeof result.backupPath === 'string' || result.backupPath === null);
|
|
});
|
|
|
|
// Cleanup
|
|
it('cleanup', () => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project type detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('reference-config-generator: project type detection', () => {
|
|
const tmpDir = resolve(__dirname, '../fixtures/posture-scan/tmp-type-test');
|
|
|
|
it('detects plugin project', async () => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
mkdirSync(join(tmpDir, '.claude-plugin'), { recursive: true });
|
|
writeFileSync(join(tmpDir, '.claude-plugin', 'plugin.json'), '{"name":"test"}');
|
|
resetCounter();
|
|
const result = await generate(tmpDir);
|
|
assert.equal(result.projectType, 'plugin');
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('detects monorepo', async () => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
writeFileSync(join(tmpDir, 'package.json'), '{"workspaces":["packages/*"]}');
|
|
resetCounter();
|
|
const result = await generate(tmpDir);
|
|
assert.equal(result.projectType, 'monorepo');
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('defaults to standalone', async () => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
resetCounter();
|
|
const result = await generate(tmpDir);
|
|
assert.equal(result.projectType, 'standalone');
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
});
|