ktg-plugin-marketplace/plugins/llm-security/tests/scanners/reference-config.test.mjs

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 });
});
});