feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
110
plugins/config-audit/tests/scanners/claude-md-linter.test.mjs
Normal file
110
plugins/config-audit/tests/scanners/claude-md-linter.test.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/claude-md-linter.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('CML scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('scans at least 1 file', () => {
|
||||
assert.ok(result.files_scanned >= 1);
|
||||
});
|
||||
|
||||
it('has scanner prefix CML', () => {
|
||||
assert.strictEqual(result.scanner, 'CML');
|
||||
});
|
||||
|
||||
it('has all severity count keys', () => {
|
||||
for (const key of ['critical', 'high', 'medium', 'low', 'info']) {
|
||||
assert.ok(key in result.counts, `Missing count key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds no critical or high issues in healthy project', () => {
|
||||
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
|
||||
assert.strictEqual(serious.length, 0, `Found serious issues: ${serious.map(f => f.title).join(', ')}`);
|
||||
});
|
||||
|
||||
it('all finding IDs match CA-CML-NNN pattern', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-CML-\d{3}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CML scanner — broken project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
||||
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
||||
});
|
||||
|
||||
it('detects long CLAUDE.md (>200 lines)', () => {
|
||||
const found = result.findings.some(f => f.title.includes('exceeds'));
|
||||
assert.ok(found, 'Should detect oversized CLAUDE.md');
|
||||
});
|
||||
|
||||
it('detects missing headings', () => {
|
||||
const found = result.findings.some(f => f.title.includes('no markdown headings'));
|
||||
assert.ok(found, 'Should detect lack of headings');
|
||||
});
|
||||
|
||||
it('detects TODO markers', () => {
|
||||
const found = result.findings.some(f => f.title.includes('TODO'));
|
||||
assert.ok(found, 'Should detect TODO markers');
|
||||
});
|
||||
|
||||
it('detects repeated content', () => {
|
||||
const found = result.findings.some(f => f.title.includes('Repeated content'));
|
||||
assert.ok(found, 'Should detect repeated lines');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CML scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('detects missing CLAUDE.md', () => {
|
||||
const found = result.findings.some(f => f.title.includes('No CLAUDE.md'));
|
||||
assert.ok(found, 'Should report missing CLAUDE.md');
|
||||
});
|
||||
|
||||
it('returns high severity for missing CLAUDE.md', () => {
|
||||
const f = result.findings.find(f => f.title.includes('No CLAUDE.md'));
|
||||
assert.strictEqual(f?.severity, 'high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CML scanner — minimal project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
||||
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
||||
});
|
||||
|
||||
it('detects nearly empty CLAUDE.md', () => {
|
||||
const found = result.findings.some(f => f.title.includes('nearly empty'));
|
||||
assert.ok(found, 'Should detect nearly empty CLAUDE.md');
|
||||
});
|
||||
});
|
||||
124
plugins/config-audit/tests/scanners/conflict-detector.test.mjs
Normal file
124
plugins/config-audit/tests/scanners/conflict-detector.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/conflict-detector.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('CNF scanner — conflict project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'conflict-project'));
|
||||
result = await scan(resolve(FIXTURES, 'conflict-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('reports scanner prefix CNF', () => {
|
||||
assert.equal(result.scanner, 'CNF');
|
||||
});
|
||||
|
||||
it('finding IDs match CA-CNF-NNN pattern', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-CNF-\d{3}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('detects model key conflict', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('model')));
|
||||
});
|
||||
|
||||
it('settings conflict is medium severity', () => {
|
||||
const model = result.findings.find(f => f.title.includes('model'));
|
||||
assert.equal(model.severity, 'medium');
|
||||
});
|
||||
|
||||
it('detects effortLevel key conflict', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('effortLevel')));
|
||||
});
|
||||
|
||||
it('detects permission allow/deny conflict', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Permission allow/deny')));
|
||||
});
|
||||
|
||||
it('permission conflict is high severity', () => {
|
||||
const perm = result.findings.find(f => f.title.includes('Permission allow/deny'));
|
||||
assert.equal(perm.severity, 'high');
|
||||
});
|
||||
|
||||
it('detects duplicate hook definition', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Duplicate hook')));
|
||||
});
|
||||
|
||||
it('duplicate hook is low severity', () => {
|
||||
const hook = result.findings.find(f => f.title.includes('Duplicate hook'));
|
||||
assert.equal(hook.severity, 'low');
|
||||
});
|
||||
|
||||
it('has exactly 4 findings', () => {
|
||||
assert.equal(result.findings.length, 4);
|
||||
});
|
||||
|
||||
it('includes evidence with scope info', () => {
|
||||
const perm = result.findings.find(f => f.title.includes('Permission'));
|
||||
assert.ok(perm.evidence);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CNF scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns ok with no conflicts', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CNF scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped when no config files', () => {
|
||||
assert.equal(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CNF scanner — minimal project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
||||
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped with no settings files', () => {
|
||||
assert.equal(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
100
plugins/config-audit/tests/scanners/drift-cli.test.mjs
Normal file
100
plugins/config-audit/tests/scanners/drift-cli.test.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { deleteBaseline } from '../../scanners/lib/baseline.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
const HEALTHY = resolve(FIXTURES, 'healthy-project');
|
||||
const DRIFT_CLI = resolve(__dirname, '../../scanners/drift-cli.mjs');
|
||||
|
||||
const TEST_BASELINE = `_drift_test_${Date.now()}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteBaseline(TEST_BASELINE);
|
||||
});
|
||||
|
||||
describe('drift-cli --save', () => {
|
||||
it('saves a baseline and confirms', () => {
|
||||
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE, '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
const output = JSON.parse(result);
|
||||
assert.equal(output.saved, true);
|
||||
assert.equal(output.name, TEST_BASELINE);
|
||||
assert.ok(output.path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drift-cli --list', () => {
|
||||
it('lists baselines including saved one', async () => {
|
||||
// Save first
|
||||
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const result = execFileSync('node', [DRIFT_CLI, '--list', '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
const output = JSON.parse(result);
|
||||
assert.ok(Array.isArray(output.baselines));
|
||||
const found = output.baselines.find(b => b.name === TEST_BASELINE);
|
||||
assert.ok(found, 'Should find test baseline in list');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drift-cli compare', () => {
|
||||
it('outputs valid JSON with --json flag', () => {
|
||||
// Save baseline first
|
||||
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Compare same fixture against itself
|
||||
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
const diff = JSON.parse(result);
|
||||
assert.ok('newFindings' in diff);
|
||||
assert.ok('resolvedFindings' in diff);
|
||||
assert.ok('unchangedFindings' in diff);
|
||||
assert.ok('movedFindings' in diff);
|
||||
assert.ok('scoreChange' in diff);
|
||||
assert.ok('summary' in diff);
|
||||
});
|
||||
|
||||
it('shows stable trend when comparing same fixture', () => {
|
||||
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
const diff = JSON.parse(result);
|
||||
assert.equal(diff.summary.trend, 'stable');
|
||||
assert.equal(diff.summary.newCount, 0);
|
||||
assert.equal(diff.summary.resolvedCount, 0);
|
||||
});
|
||||
|
||||
it('exits with code 1 when baseline not found', () => {
|
||||
assert.throws(() => {
|
||||
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', `nonexistent_${Date.now()}`, '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
}, (err) => {
|
||||
assert.equal(err.status, 1);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
199
plugins/config-audit/tests/scanners/feature-gap-scanner.test.mjs
Normal file
199
plugins/config-audit/tests/scanners/feature-gap-scanner.test.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { scan, opportunitySummary } from '../../scanners/feature-gap-scanner.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
// Pre-discover fixture files WITHOUT includeGlobal so tests are environment-independent.
|
||||
// The GAP scanner uses shared discovery when it has files, avoiding its own includeGlobal scan.
|
||||
async function fixtureDiscovery(name) {
|
||||
return discoverConfigFiles(resolve(FIXTURES, name));
|
||||
}
|
||||
|
||||
describe('GAP scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await fixtureDiscovery('healthy-project');
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('reports scanner prefix GAP', () => {
|
||||
assert.equal(result.scanner, 'GAP');
|
||||
});
|
||||
|
||||
it('scans multiple files', () => {
|
||||
assert.ok(result.files_scanned >= 1);
|
||||
});
|
||||
|
||||
it('finding IDs match CA-GAP-NNN pattern', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-GAP-\d{3}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT report missing CLAUDE.md', () => {
|
||||
assert.ok(!result.findings.some(f => f.title === 'No CLAUDE.md file'));
|
||||
});
|
||||
|
||||
it('does NOT report missing MCP', () => {
|
||||
assert.ok(!result.findings.some(f => f.title === 'No MCP servers configured'));
|
||||
});
|
||||
|
||||
it('does NOT report missing hooks', () => {
|
||||
assert.ok(!result.findings.some(f => f.title === 'No hooks configured'));
|
||||
});
|
||||
|
||||
it('has counts object with all severity levels', () => {
|
||||
assert.ok('critical' in result.counts);
|
||||
assert.ok('high' in result.counts);
|
||||
assert.ok('medium' in result.counts);
|
||||
assert.ok('low' in result.counts);
|
||||
assert.ok('info' in result.counts);
|
||||
});
|
||||
|
||||
it('has no critical or high findings', () => {
|
||||
assert.equal(result.counts.critical, 0);
|
||||
assert.equal(result.counts.high, 0);
|
||||
});
|
||||
|
||||
it('all findings have recommendations', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.ok(f.recommendation, `Finding ${f.id} missing recommendation`);
|
||||
}
|
||||
});
|
||||
|
||||
it('T3/T4 findings are info severity', () => {
|
||||
const infoFindings = result.findings.filter(f => f.category === 't3' || f.category === 't4');
|
||||
for (const f of infoFindings) {
|
||||
assert.equal(f.severity, 'info', `${f.id} (${f.category}) should be info, got ${f.severity}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GAP scanner — minimal project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await fixtureDiscovery('minimal-project');
|
||||
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('reports missing hooks', () => {
|
||||
assert.ok(result.findings.some(f => f.title === 'No hooks configured'));
|
||||
});
|
||||
|
||||
it('reports missing MCP', () => {
|
||||
assert.ok(result.findings.some(f => f.title === 'No MCP servers configured'));
|
||||
});
|
||||
|
||||
it('T1 gaps are medium severity', () => {
|
||||
const t1 = result.findings.filter(f => f.category === 't1');
|
||||
for (const f of t1) {
|
||||
assert.equal(f.severity, 'medium', `${f.id} should be medium, got ${f.severity}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('T2 gaps are low severity', () => {
|
||||
const t2 = result.findings.filter(f => f.category === 't2');
|
||||
for (const f of t2) {
|
||||
assert.equal(f.severity, 'low', `${f.id} should be low, got ${f.severity}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('has more findings than healthy project', async () => {
|
||||
resetCounter();
|
||||
const discovery = await fixtureDiscovery('healthy-project');
|
||||
const healthyResult = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
assert.ok(result.findings.length > healthyResult.findings.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GAP scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await fixtureDiscovery('empty-project');
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok (never skips)', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('has multiple medium findings (T1 gaps)', () => {
|
||||
const mediums = result.findings.filter(f => f.severity === 'medium');
|
||||
assert.ok(mediums.length >= 1);
|
||||
});
|
||||
|
||||
it('all findings have category field', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.ok(f.category, `Finding ${f.id} missing category`);
|
||||
assert.match(f.category, /^t[1-4]$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports T1 gaps including missing CLAUDE.md', () => {
|
||||
assert.ok(result.findings.some(f => f.title === 'No CLAUDE.md file'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('opportunitySummary', () => {
|
||||
it('returns empty arrays for no findings', () => {
|
||||
const result = opportunitySummary([]);
|
||||
assert.deepEqual(result.highImpact, []);
|
||||
assert.deepEqual(result.mediumImpact, []);
|
||||
assert.deepEqual(result.explore, []);
|
||||
});
|
||||
|
||||
it('routes T1 to highImpact', () => {
|
||||
const findings = [{ category: 't1', title: 'No CLAUDE.md' }];
|
||||
const result = opportunitySummary(findings);
|
||||
assert.equal(result.highImpact.length, 1);
|
||||
assert.equal(result.mediumImpact.length, 0);
|
||||
assert.equal(result.explore.length, 0);
|
||||
});
|
||||
|
||||
it('routes T2 to mediumImpact', () => {
|
||||
const findings = [{ category: 't2', title: 'Low hook diversity' }];
|
||||
const result = opportunitySummary(findings);
|
||||
assert.equal(result.highImpact.length, 0);
|
||||
assert.equal(result.mediumImpact.length, 1);
|
||||
});
|
||||
|
||||
it('routes T3 and T4 to explore', () => {
|
||||
const findings = [
|
||||
{ category: 't3', title: 'No status line' },
|
||||
{ category: 't4', title: 'No custom plugin' },
|
||||
];
|
||||
const result = opportunitySummary(findings);
|
||||
assert.equal(result.explore.length, 2);
|
||||
});
|
||||
|
||||
it('handles mixed tiers', () => {
|
||||
const findings = [
|
||||
{ category: 't1', title: 'A' },
|
||||
{ category: 't2', title: 'B' },
|
||||
{ category: 't2', title: 'C' },
|
||||
{ category: 't3', title: 'D' },
|
||||
{ category: 't4', title: 'E' },
|
||||
];
|
||||
const result = opportunitySummary(findings);
|
||||
assert.equal(result.highImpact.length, 1);
|
||||
assert.equal(result.mediumImpact.length, 2);
|
||||
assert.equal(result.explore.length, 2);
|
||||
});
|
||||
});
|
||||
91
plugins/config-audit/tests/scanners/fix-cli.test.mjs
Normal file
91
plugins/config-audit/tests/scanners/fix-cli.test.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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, homedir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
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,
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
it('outputs valid JSON with --json flag', () => {
|
||||
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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
|
||||
const backupDir = join(homedir(), '.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,
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
305
plugins/config-audit/tests/scanners/fix-engine.test.mjs
Normal file
305
plugins/config-audit/tests/scanners/fix-engine.test.mjs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
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 } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { runAllScanners } from '../../scanners/scan-orchestrator.mjs';
|
||||
import { planFixes, applyFixes, verifyFixes, FIX_TYPES } from '../../scanners/fix-engine.mjs';
|
||||
import { parseJson, parseFrontmatter } from '../../scanners/lib/yaml-parser.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
const FIXABLE = resolve(FIXTURES, 'fixable-project');
|
||||
|
||||
/** Create a temporary copy of the fixable-project fixture. */
|
||||
async function createTmpCopy() {
|
||||
const tmpDir = join(tmpdir(), `config-audit-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await cp(FIXABLE, tmpDir, { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
// --- planFixes tests ---
|
||||
|
||||
describe('planFixes', () => {
|
||||
let envelope;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
envelope = await runAllScanners(FIXABLE);
|
||||
});
|
||||
|
||||
it('returns fixes, skipped, and manual arrays', () => {
|
||||
const result = planFixes(envelope);
|
||||
assert.ok(Array.isArray(result.fixes));
|
||||
assert.ok(Array.isArray(result.skipped));
|
||||
assert.ok(Array.isArray(result.manual));
|
||||
});
|
||||
|
||||
it('identifies auto-fixable findings', () => {
|
||||
const result = planFixes(envelope);
|
||||
assert.ok(result.fixes.length > 0, 'Should have at least one fix');
|
||||
});
|
||||
|
||||
it('sorts fixes by severity (critical first)', () => {
|
||||
const result = planFixes(envelope);
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
for (let i = 1; i < result.fixes.length; i++) {
|
||||
const prev = severityOrder[result.fixes[i - 1].severity] || 4;
|
||||
const curr = severityOrder[result.fixes[i].severity] || 4;
|
||||
assert.ok(prev <= curr, `Fix ${i} should not have higher severity than fix ${i - 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes manual findings with recommendations', () => {
|
||||
const result = planFixes(envelope);
|
||||
for (const m of result.manual) {
|
||||
assert.ok(m.findingId, 'Manual finding should have findingId');
|
||||
assert.ok(m.title, 'Manual finding should have title');
|
||||
}
|
||||
});
|
||||
|
||||
it('each fix has required fields', () => {
|
||||
const result = planFixes(envelope);
|
||||
for (const fix of result.fixes) {
|
||||
assert.ok(fix.findingId, 'Fix must have findingId');
|
||||
assert.ok(fix.file, 'Fix must have file');
|
||||
assert.ok(fix.type, 'Fix must have type');
|
||||
assert.ok(fix.description, 'Fix must have description');
|
||||
}
|
||||
});
|
||||
|
||||
it('detects json-key-add for missing $schema', () => {
|
||||
const result = planFixes(envelope);
|
||||
const schemaFix = result.fixes.find(f => f.type === FIX_TYPES.JSON_KEY_ADD && f.key === '$schema');
|
||||
assert.ok(schemaFix, 'Should have a json-key-add fix for $schema');
|
||||
});
|
||||
|
||||
it('detects json-key-remove for deprecated apiProvider', () => {
|
||||
const result = planFixes(envelope);
|
||||
// apiProvider is unknown, not deprecated (includeCoAuthoredBy is deprecated)
|
||||
// But the fixture has apiProvider which triggers "unknown key" (not auto-fixable)
|
||||
// The deprecated key in settings-validator is includeCoAuthoredBy — fixture doesn't have it
|
||||
// Let's check for hooks-as-array instead (critical)
|
||||
const hooksFix = result.fixes.find(f => f.restructureType === 'hooks-array-to-object');
|
||||
assert.ok(hooksFix, 'Should have a json-restructure fix for hooks-as-array');
|
||||
});
|
||||
|
||||
it('detects json-key-type-fix for alwaysThinkingEnabled', () => {
|
||||
const result = planFixes(envelope);
|
||||
const typeFix = result.fixes.find(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
|
||||
assert.ok(typeFix, 'Should have a type fix for alwaysThinkingEnabled');
|
||||
});
|
||||
|
||||
it('detects json-key-type-fix for effortLevel', () => {
|
||||
const result = planFixes(envelope);
|
||||
const effortFix = result.fixes.find(f => f.key === 'effortLevel');
|
||||
assert.ok(effortFix, 'Should have a fix for invalid effortLevel');
|
||||
});
|
||||
|
||||
it('detects json-restructure for matcher-as-object', () => {
|
||||
const result = planFixes(envelope);
|
||||
const matcherFix = result.fixes.find(f => f.restructureType === 'matcher-object-to-string');
|
||||
assert.ok(matcherFix, 'Should have a restructure fix for matcher-as-object');
|
||||
});
|
||||
|
||||
it('detects json-key-type-fix for timeout-as-string', () => {
|
||||
const result = planFixes(envelope);
|
||||
const timeoutFix = result.fixes.find(f => f.key === 'timeout');
|
||||
assert.ok(timeoutFix, 'Should have a type fix for timeout');
|
||||
});
|
||||
|
||||
it('detects frontmatter-rename for globs→paths', () => {
|
||||
const result = planFixes(envelope);
|
||||
const globsFix = result.fixes.find(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
|
||||
assert.ok(globsFix, 'Should have a frontmatter-rename fix for globs');
|
||||
});
|
||||
|
||||
it('detects file-rename for non-.md rules file', () => {
|
||||
const result = planFixes(envelope);
|
||||
const renameFix = result.fixes.find(f => f.type === FIX_TYPES.FILE_RENAME);
|
||||
assert.ok(renameFix, 'Should have a file-rename fix');
|
||||
assert.ok(renameFix.newPath.endsWith('.md'), 'New path should end with .md');
|
||||
});
|
||||
});
|
||||
|
||||
// --- applyFixes dry-run tests ---
|
||||
|
||||
describe('applyFixes dry-run', () => {
|
||||
let envelope;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
envelope = await runAllScanners(FIXABLE);
|
||||
});
|
||||
|
||||
it('returns dry-run status without modifying files', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const result = await applyFixes(fixes, { dryRun: true });
|
||||
assert.ok(result.applied.length > 0, 'Should have dry-run results');
|
||||
for (const r of result.applied) {
|
||||
assert.strictEqual(r.status, 'dry-run');
|
||||
}
|
||||
assert.strictEqual(result.failed.length, 0, 'No failures in dry-run');
|
||||
});
|
||||
|
||||
it('throws if no backupDir and not dryRun', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
await assert.rejects(
|
||||
() => applyFixes(fixes, { dryRun: false }),
|
||||
{ message: /backupDir is required/ },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- applyFixes actual (on tmp copies) ---
|
||||
|
||||
describe('applyFixes on tmp copy', () => {
|
||||
let tmpDir;
|
||||
let envelope;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await createTmpCopy();
|
||||
resetCounter();
|
||||
envelope = await runAllScanners(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies json-key-add ($schema) successfully', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const schemaFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_ADD);
|
||||
const result = await applyFixes(schemaFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
assert.ok(result.applied.length > 0, 'Should apply at least one fix');
|
||||
assert.strictEqual(result.failed.length, 0, 'No failures');
|
||||
|
||||
// Verify file has $schema
|
||||
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
assert.ok(parsed.$schema, 'Should have $schema key');
|
||||
assert.ok(parsed.$schema.includes('schemastore'), '$schema should point to schemastore');
|
||||
});
|
||||
|
||||
it('applies json-key-type-fix successfully', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const typeFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
|
||||
const result = await applyFixes(typeFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
assert.ok(result.applied.length > 0);
|
||||
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
assert.strictEqual(typeof parsed.alwaysThinkingEnabled, 'boolean', 'Should be boolean now');
|
||||
});
|
||||
|
||||
it('applies json-restructure (hooks array→object) successfully', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const hooksFix = fixes.filter(f => f.restructureType === 'hooks-array-to-object');
|
||||
const result = await applyFixes(hooksFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
assert.ok(result.applied.length > 0);
|
||||
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
assert.ok(!Array.isArray(parsed.hooks), 'hooks should be object now');
|
||||
assert.strictEqual(typeof parsed.hooks, 'object');
|
||||
});
|
||||
|
||||
it('applies json-restructure (matcher object→string) successfully', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const matcherFix = fixes.filter(f => f.restructureType === 'matcher-object-to-string');
|
||||
const result = await applyFixes(matcherFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
assert.ok(result.applied.length > 0);
|
||||
const content = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
const handler = parsed.hooks.PreToolUse[0];
|
||||
assert.strictEqual(typeof handler.matcher, 'string', 'matcher should be string now');
|
||||
});
|
||||
|
||||
it('applies frontmatter-rename (globs→paths) successfully', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
|
||||
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
assert.ok(result.applied.length > 0);
|
||||
const content = await readFile(join(tmpDir, '.claude', 'rules', 'typescript.md'), 'utf-8');
|
||||
assert.ok(content.includes('paths:'), 'Should have paths: in frontmatter');
|
||||
assert.ok(!content.includes('globs:'), 'Should not have globs: in frontmatter');
|
||||
});
|
||||
|
||||
it('applies file-rename (non-.md → .md) successfully', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const renameFix = fixes.filter(f => f.type === FIX_TYPES.FILE_RENAME);
|
||||
const result = await applyFixes(renameFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
assert.ok(result.applied.length > 0);
|
||||
// Old file should be gone
|
||||
await assert.rejects(() => stat(join(tmpDir, '.claude', 'rules', 'readme.txt')));
|
||||
// New file should exist
|
||||
const newStat = await stat(join(tmpDir, '.claude', 'rules', 'readme.md'));
|
||||
assert.ok(newStat.isFile());
|
||||
});
|
||||
|
||||
it('validates JSON output after fix', async () => {
|
||||
const { fixes } = planFixes(envelope);
|
||||
const jsonFixes = fixes.filter(f => f.file.endsWith('.json'));
|
||||
await applyFixes(jsonFixes, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
// All JSON files should still parse
|
||||
const settingsContent = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
|
||||
const settingsParsed = parseJson(settingsContent);
|
||||
assert.ok(settingsParsed !== null, 'settings.json should be valid JSON after fixes');
|
||||
|
||||
const hooksContent = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
|
||||
const hooksParsed = parseJson(hooksContent);
|
||||
assert.ok(hooksParsed !== null, 'hooks.json should be valid JSON after fixes');
|
||||
});
|
||||
|
||||
it('fails gracefully for missing file', async () => {
|
||||
const fakeFix = [{
|
||||
findingId: 'CA-SET-999',
|
||||
file: join(tmpDir, 'nonexistent.json'),
|
||||
type: FIX_TYPES.JSON_KEY_ADD,
|
||||
severity: 'info',
|
||||
description: 'Add key to missing file',
|
||||
key: 'test',
|
||||
value: true,
|
||||
}];
|
||||
const result = await applyFixes(fakeFix, { dryRun: false, backupDir: tmpDir });
|
||||
assert.strictEqual(result.failed.length, 1, 'Should have one failure');
|
||||
assert.strictEqual(result.applied.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- verifyFixes tests ---
|
||||
|
||||
describe('verifyFixes', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await createTmpCopy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('confirms fixed findings are gone', async () => {
|
||||
resetCounter();
|
||||
const envelope = await runAllScanners(tmpDir);
|
||||
const { fixes } = planFixes(envelope);
|
||||
|
||||
// Apply a subset of fixes
|
||||
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
|
||||
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
|
||||
|
||||
const verification = await verifyFixes(envelope, result.applied);
|
||||
assert.ok(verification.verified.length > 0, 'Should verify at least one fix');
|
||||
});
|
||||
});
|
||||
86
plugins/config-audit/tests/scanners/hook-validator.test.mjs
Normal file
86
plugins/config-audit/tests/scanners/hook-validator.test.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/hook-validator.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('HKV scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('has scanner prefix HKV', () => {
|
||||
assert.strictEqual(result.scanner, 'HKV');
|
||||
});
|
||||
|
||||
it('finds no critical or high issues', () => {
|
||||
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
|
||||
assert.strictEqual(serious.length, 0, `Found: ${serious.map(f => f.title).join(', ')}`);
|
||||
});
|
||||
|
||||
it('all finding IDs match CA-HKV-NNN', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-HKV-\d{3}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('HKV scanner — broken project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
||||
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
||||
});
|
||||
|
||||
it('detects unknown hook event', () => {
|
||||
const found = result.findings.some(f => f.title === 'Unknown hook event');
|
||||
assert.ok(found, 'Should detect InvalidEvent');
|
||||
});
|
||||
|
||||
it('detects object matcher (should be string)', () => {
|
||||
const found = result.findings.some(f => f.title.includes('Matcher must be a string'));
|
||||
assert.ok(found, 'Should detect nested object matcher');
|
||||
});
|
||||
|
||||
it('detects invalid handler type', () => {
|
||||
const found = result.findings.some(f => f.title === 'Invalid hook handler type');
|
||||
assert.ok(found, 'Should detect invalid_type');
|
||||
});
|
||||
|
||||
it('detects timeout below minimum', () => {
|
||||
const found = result.findings.some(f => f.title.includes('timeout'));
|
||||
assert.ok(found, 'Should detect timeout of 500ms');
|
||||
});
|
||||
|
||||
it('marks unknown event as high severity', () => {
|
||||
const f = result.findings.find(f => f.title === 'Unknown hook event');
|
||||
assert.strictEqual(f?.severity, 'high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HKV scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok with 0 findings', () => {
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
assert.strictEqual(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
117
plugins/config-audit/tests/scanners/import-resolver.test.mjs
Normal file
117
plugins/config-audit/tests/scanners/import-resolver.test.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/import-resolver.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('IMP scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('reports scanner prefix IMP', () => {
|
||||
assert.equal(result.scanner, 'IMP');
|
||||
});
|
||||
|
||||
it('scans at least 1 file', () => {
|
||||
assert.ok(result.files_scanned >= 1);
|
||||
});
|
||||
|
||||
it('has no high or critical findings', () => {
|
||||
assert.equal(result.counts.critical, 0);
|
||||
assert.equal(result.counts.high, 0);
|
||||
});
|
||||
|
||||
it('finding IDs match CA-IMP-NNN pattern', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-IMP-\d{3}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMP scanner — broken project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
||||
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('detects broken @import link', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Broken @import')));
|
||||
});
|
||||
|
||||
it('broken link is high severity', () => {
|
||||
const broken = result.findings.find(f => f.title.includes('Broken @import'));
|
||||
assert.equal(broken.severity, 'high');
|
||||
});
|
||||
|
||||
it('detects circular @import reference', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Circular @import')));
|
||||
});
|
||||
|
||||
it('circular reference is medium severity', () => {
|
||||
const circular = result.findings.find(f => f.title.includes('Circular @import'));
|
||||
assert.equal(circular.severity, 'medium');
|
||||
});
|
||||
|
||||
it('has at least 2 findings', () => {
|
||||
assert.ok(result.findings.length >= 2);
|
||||
});
|
||||
|
||||
it('includes evidence with path info', () => {
|
||||
const broken = result.findings.find(f => f.title.includes('Broken @import'));
|
||||
assert.ok(broken.evidence);
|
||||
assert.ok(broken.evidence.includes('nonexistent'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMP scanner — minimal project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
||||
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('has 0 findings for file without imports', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMP scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped when no CLAUDE.md files', () => {
|
||||
assert.equal(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/mcp-config-validator.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('MCP scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('reports scanner prefix MCP', () => {
|
||||
assert.equal(result.scanner, 'MCP');
|
||||
});
|
||||
|
||||
it('scans at least 1 file', () => {
|
||||
assert.ok(result.files_scanned >= 1);
|
||||
});
|
||||
|
||||
it('has no critical or high findings', () => {
|
||||
assert.equal(result.counts.critical, 0);
|
||||
assert.equal(result.counts.high, 0);
|
||||
});
|
||||
|
||||
it('finding IDs match CA-MCP-NNN pattern', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-MCP-\d{3}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('has counts object with all severity levels', () => {
|
||||
assert.ok('critical' in result.counts);
|
||||
assert.ok('high' in result.counts);
|
||||
assert.ok('medium' in result.counts);
|
||||
assert.ok('low' in result.counts);
|
||||
assert.ok('info' in result.counts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP scanner — broken project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
||||
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('detects SSE server type', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('SSE')));
|
||||
});
|
||||
|
||||
it('SSE recommendation is info severity', () => {
|
||||
const sse = result.findings.find(f => f.title.includes('SSE'));
|
||||
assert.equal(sse.severity, 'info');
|
||||
});
|
||||
|
||||
it('detects unknown server type', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Unknown MCP server type')));
|
||||
});
|
||||
|
||||
it('unknown server type is high severity', () => {
|
||||
const unknown = result.findings.find(f => f.title.includes('Unknown MCP server type'));
|
||||
assert.equal(unknown.severity, 'high');
|
||||
});
|
||||
|
||||
it('detects missing trust level', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Missing trust level')));
|
||||
});
|
||||
|
||||
it('missing trust is medium severity', () => {
|
||||
const trust = result.findings.find(f => f.title.includes('Missing trust level'));
|
||||
assert.equal(trust.severity, 'medium');
|
||||
});
|
||||
|
||||
it('detects unreferenced env vars in args', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Unreferenced env var')));
|
||||
});
|
||||
|
||||
it('detects unknown server fields', () => {
|
||||
assert.ok(result.findings.some(f => f.title.includes('Unknown MCP server field')));
|
||||
});
|
||||
|
||||
it('has multiple findings', () => {
|
||||
assert.ok(result.findings.length >= 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped status', () => {
|
||||
assert.equal(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP scanner — minimal project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
|
||||
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped when no .mcp.json', () => {
|
||||
assert.equal(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.equal(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { scan, discoverPlugins } from '../../scanners/plugin-health-scanner.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
const TEST_PLUGIN = resolve(FIXTURES, 'test-plugin');
|
||||
const BROKEN_PLUGIN = resolve(FIXTURES, 'broken-plugin');
|
||||
|
||||
describe('discoverPlugins', () => {
|
||||
it('discovers a single plugin when pointed at plugin dir', async () => {
|
||||
const plugins = await discoverPlugins(TEST_PLUGIN);
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.ok(plugins[0].endsWith('test-plugin'));
|
||||
});
|
||||
|
||||
it('discovers multiple plugins in parent dir', async () => {
|
||||
const plugins = await discoverPlugins(FIXTURES);
|
||||
// Should find test-plugin and broken-plugin (both have .claude-plugin/plugin.json)
|
||||
assert.ok(plugins.length >= 2, `Expected >=2, got ${plugins.length}`);
|
||||
});
|
||||
|
||||
it('returns empty array for dir with no plugins', async () => {
|
||||
const plugins = await discoverPlugins(resolve(FIXTURES, 'empty-project'));
|
||||
assert.equal(plugins.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan on valid test-plugin', () => {
|
||||
it('returns ok status', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(TEST_PLUGIN);
|
||||
assert.equal(result.scanner, 'PLH');
|
||||
assert.equal(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('finds commands and agents', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(TEST_PLUGIN);
|
||||
assert.ok(result.files_scanned >= 1, 'Should scan at least 1 plugin');
|
||||
// Valid plugin should have few or no findings
|
||||
const criticals = result.findings.filter(f => f.severity === 'critical');
|
||||
assert.equal(criticals.length, 0, 'Valid plugin should have no critical findings');
|
||||
});
|
||||
|
||||
it('no findings for missing plugin.json fields', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(TEST_PLUGIN);
|
||||
const missingFields = result.findings.filter(f => f.title.includes('Missing required field'));
|
||||
assert.equal(missingFields.length, 0, 'All required fields present in test-plugin');
|
||||
});
|
||||
|
||||
it('no findings for missing CLAUDE.md sections', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(TEST_PLUGIN);
|
||||
const missingSections = result.findings.filter(f => f.title.includes('missing') && f.title.includes('section'));
|
||||
assert.equal(missingSections.length, 0, 'All sections present in test-plugin CLAUDE.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan on broken-plugin', () => {
|
||||
it('detects missing plugin.json fields', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(BROKEN_PLUGIN);
|
||||
const missingFields = result.findings.filter(f => f.title.includes('Missing required field'));
|
||||
assert.ok(missingFields.length >= 2, 'Should detect missing description and version');
|
||||
});
|
||||
|
||||
it('detects missing CLAUDE.md', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(BROKEN_PLUGIN);
|
||||
const missingMd = result.findings.filter(f => f.title === 'Missing CLAUDE.md');
|
||||
assert.equal(missingMd.length, 1, 'Should detect missing CLAUDE.md');
|
||||
});
|
||||
|
||||
it('detects command without frontmatter', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(BROKEN_PLUGIN);
|
||||
const noFrontmatter = result.findings.filter(f => f.title === 'Command missing frontmatter');
|
||||
assert.equal(noFrontmatter.length, 1, 'Should detect command without frontmatter');
|
||||
});
|
||||
|
||||
it('detects agent missing required frontmatter fields', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(BROKEN_PLUGIN);
|
||||
const missingAgent = result.findings.filter(f =>
|
||||
f.title.startsWith('Agent missing frontmatter field:')
|
||||
);
|
||||
// bad-agent.md has name+description but missing model and tools
|
||||
assert.ok(missingAgent.length >= 2, `Should detect missing model and tools, got ${missingAgent.length}: ${missingAgent.map(f => f.title).join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan with no plugins', () => {
|
||||
it('returns info finding for empty directory', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(resolve(FIXTURES, 'empty-project'));
|
||||
assert.equal(result.findings.length, 1);
|
||||
assert.equal(result.findings[0].title, 'No plugins found');
|
||||
assert.equal(result.findings[0].severity, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-plugin command conflict detection', () => {
|
||||
it('scans fixtures dir and reports findings for all plugins', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(FIXTURES);
|
||||
assert.equal(result.scanner, 'PLH');
|
||||
assert.ok(result.files_scanned >= 2, 'Should scan multiple plugins');
|
||||
});
|
||||
});
|
||||
|
||||
describe('finding format', () => {
|
||||
it('findings have standard fields', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(BROKEN_PLUGIN);
|
||||
assert.ok(result.findings.length > 0);
|
||||
const f = result.findings[0];
|
||||
assert.ok(f.id.startsWith('CA-PLH-'));
|
||||
assert.equal(f.scanner, 'PLH');
|
||||
assert.ok(['critical', 'high', 'medium', 'low', 'info'].includes(f.severity));
|
||||
assert.ok(f.title);
|
||||
assert.ok(f.description);
|
||||
});
|
||||
});
|
||||
123
plugins/config-audit/tests/scanners/posture.test.mjs
Normal file
123
plugins/config-audit/tests/scanners/posture.test.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
const POSTURE_BIN = resolve(__dirname, '../../scanners/posture.mjs');
|
||||
|
||||
async function runPosture(args) {
|
||||
const { stdout, stderr } = await exec('node', [POSTURE_BIN, ...args], {
|
||||
timeout: 30000,
|
||||
cwd: resolve(__dirname, '../..'),
|
||||
});
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
async function runPostureJson(fixturePath) {
|
||||
const { stdout } = await runPosture([fixturePath, '--json']);
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
describe('posture.mjs CLI — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
|
||||
});
|
||||
|
||||
it('returns utilization with score and overhang', () => {
|
||||
assert.ok(typeof result.utilization.score === 'number');
|
||||
assert.ok(typeof result.utilization.overhang === 'number');
|
||||
assert.equal(result.utilization.score + result.utilization.overhang, 100);
|
||||
});
|
||||
|
||||
it('returns maturity level >= 2', () => {
|
||||
assert.ok(result.maturity.level >= 2);
|
||||
assert.ok(typeof result.maturity.name === 'string');
|
||||
});
|
||||
|
||||
it('returns segment string', () => {
|
||||
assert.ok(typeof result.segment.segment === 'string');
|
||||
assert.ok(result.segment.segment.length > 0);
|
||||
});
|
||||
|
||||
it('returns 8 area scores', () => {
|
||||
assert.equal(result.areas.length, 8);
|
||||
for (const area of result.areas) {
|
||||
assert.ok('name' in area);
|
||||
assert.ok('grade' in area);
|
||||
assert.ok('score' in area);
|
||||
assert.ok('findingCount' in area);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns overallGrade', () => {
|
||||
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(result.overallGrade));
|
||||
});
|
||||
|
||||
it('includes topActions array', () => {
|
||||
assert.ok(Array.isArray(result.topActions));
|
||||
});
|
||||
|
||||
it('includes scannerEnvelope', () => {
|
||||
assert.ok(result.scannerEnvelope.meta);
|
||||
assert.ok(result.scannerEnvelope.scanners);
|
||||
assert.ok(result.scannerEnvelope.aggregate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('posture.mjs CLI — minimal project', () => {
|
||||
it('scores lower utilization than healthy', async () => {
|
||||
const healthy = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
|
||||
const minimal = await runPostureJson(resolve(FIXTURES, 'minimal-project'));
|
||||
assert.ok(minimal.utilization.score < healthy.utilization.score,
|
||||
`minimal (${minimal.utilization.score}) should be < healthy (${healthy.utilization.score})`);
|
||||
});
|
||||
|
||||
it('has lower maturity than healthy', async () => {
|
||||
const healthy = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
|
||||
const minimal = await runPostureJson(resolve(FIXTURES, 'minimal-project'));
|
||||
assert.ok(minimal.maturity.level <= healthy.maturity.level);
|
||||
});
|
||||
});
|
||||
|
||||
describe('posture.mjs CLI — terminal output (v3 health format)', () => {
|
||||
it('scorecard contains health sections', async () => {
|
||||
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
|
||||
assert.ok(stderr.includes('Config-Audit Health Score'));
|
||||
assert.ok(stderr.includes('Health:'));
|
||||
assert.ok(stderr.includes('Area Scores'));
|
||||
assert.ok(stderr.includes('areas scanned'));
|
||||
});
|
||||
|
||||
it('scorecard does NOT contain legacy metrics', async () => {
|
||||
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
|
||||
assert.ok(!stderr.includes('Maturity:'));
|
||||
assert.ok(!stderr.includes('Utilization:'));
|
||||
assert.ok(!stderr.includes('Segment:'));
|
||||
});
|
||||
|
||||
it('scorecard excludes Feature Coverage from area display', async () => {
|
||||
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
|
||||
assert.ok(!stderr.includes('Feature Coverage'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('posture.mjs CLI — JSON includes opportunityCount', () => {
|
||||
it('returns opportunityCount field', async () => {
|
||||
const result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
|
||||
assert.ok(typeof result.opportunityCount === 'number');
|
||||
assert.ok(result.opportunityCount >= 0);
|
||||
});
|
||||
|
||||
it('JSON still includes legacy fields for backward compat', async () => {
|
||||
const result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
|
||||
assert.ok(typeof result.utilization.score === 'number');
|
||||
assert.ok(typeof result.maturity.level === 'number');
|
||||
assert.ok(typeof result.segment.segment === 'string');
|
||||
});
|
||||
});
|
||||
128
plugins/config-audit/tests/scanners/rollback-engine.test.mjs
Normal file
128
plugins/config-audit/tests/scanners/rollback-engine.test.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { writeFile, readFile, mkdir, rm, stat } from 'node:fs/promises';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
|
||||
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
|
||||
|
||||
/** Create a temp file and back it up, returning paths and content. */
|
||||
async function setupTestBackup() {
|
||||
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const testFile = join(tmpDir, 'test-settings.json');
|
||||
const originalContent = '{"original": true, "key": "value"}';
|
||||
writeFileSync(testFile, originalContent);
|
||||
|
||||
const backup = createBackup([testFile]);
|
||||
|
||||
// Now modify the file to simulate a change
|
||||
writeFileSync(testFile, '{"modified": true}');
|
||||
|
||||
return { tmpDir, testFile, originalContent, backup };
|
||||
}
|
||||
|
||||
describe('listBackups', () => {
|
||||
it('returns an array of backups', async () => {
|
||||
const result = await listBackups();
|
||||
assert.ok(Array.isArray(result.backups), 'Should return backups array');
|
||||
});
|
||||
|
||||
it('backups are sorted newest first', async () => {
|
||||
const result = await listBackups();
|
||||
if (result.backups.length >= 2) {
|
||||
assert.ok(result.backups[0].id >= result.backups[1].id, 'First backup should be newer');
|
||||
}
|
||||
});
|
||||
|
||||
it('each backup has required fields', async () => {
|
||||
const { tmpDir, backup } = await setupTestBackup();
|
||||
try {
|
||||
const result = await listBackups();
|
||||
const found = result.backups.find(b => b.id === backup.backupId);
|
||||
assert.ok(found, 'Should find our test backup');
|
||||
assert.ok(found.id, 'Backup should have id');
|
||||
assert.ok(found.createdAt, 'Backup should have createdAt');
|
||||
assert.ok(Array.isArray(found.files), 'Backup should have files array');
|
||||
assert.ok(found.files.length > 0, 'Backup should have at least one file');
|
||||
assert.ok(found.files[0].originalPath, 'File entry should have originalPath');
|
||||
assert.ok(found.files[0].checksum, 'File entry should have checksum');
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreBackup', () => {
|
||||
let tmpDir, testFile, originalContent, backup;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ tmpDir, testFile, originalContent, backup } = await setupTestBackup());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
// Cleanup our test backup
|
||||
try { await deleteBackup(backup.backupId); } catch {}
|
||||
});
|
||||
|
||||
it('restores files to original content', async () => {
|
||||
const result = await restoreBackup(backup.backupId);
|
||||
assert.ok(result.restored.length > 0, 'Should restore at least one file');
|
||||
assert.strictEqual(result.failed.length, 0, 'No failures');
|
||||
|
||||
const restoredContent = await readFile(testFile, 'utf-8');
|
||||
assert.strictEqual(restoredContent, originalContent, 'Content should match original');
|
||||
});
|
||||
|
||||
it('verifies checksums after restore', async () => {
|
||||
const result = await restoreBackup(backup.backupId, { verify: true });
|
||||
for (const r of result.restored) {
|
||||
assert.strictEqual(r.status, 'restored');
|
||||
}
|
||||
});
|
||||
|
||||
it('dry-run returns plan without writing', async () => {
|
||||
const result = await restoreBackup(backup.backupId, { dryRun: true });
|
||||
assert.ok(result.restored.length > 0);
|
||||
for (const r of result.restored) {
|
||||
assert.strictEqual(r.status, 'dry-run');
|
||||
}
|
||||
|
||||
// File should still be modified
|
||||
const content = await readFile(testFile, 'utf-8');
|
||||
assert.strictEqual(content, '{"modified": true}', 'File should not be restored in dry-run');
|
||||
});
|
||||
|
||||
it('throws for invalid backup-id', async () => {
|
||||
await assert.rejects(
|
||||
() => restoreBackup('nonexistent_99999999_999999'),
|
||||
{ message: /Backup not found/ },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBackup', () => {
|
||||
it('deletes an existing backup', async () => {
|
||||
const { tmpDir, backup } = await setupTestBackup();
|
||||
try {
|
||||
const result = await deleteBackup(backup.backupId);
|
||||
assert.strictEqual(result.deleted, true);
|
||||
|
||||
// Verify it's gone from the list
|
||||
const list = await listBackups();
|
||||
const found = list.backups.find(b => b.id === backup.backupId);
|
||||
assert.ok(!found, 'Deleted backup should not appear in list');
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns error for nonexistent backup', async () => {
|
||||
const result = await deleteBackup('nonexistent_99999999_999999');
|
||||
assert.strictEqual(result.deleted, false);
|
||||
assert.ok(result.error, 'Should have error message');
|
||||
});
|
||||
});
|
||||
84
plugins/config-audit/tests/scanners/rules-validator.test.mjs
Normal file
84
plugins/config-audit/tests/scanners/rules-validator.test.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/rules-validator.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('RUL scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('has scanner prefix RUL', () => {
|
||||
assert.strictEqual(result.scanner, 'RUL');
|
||||
});
|
||||
|
||||
it('finds no high severity issues', () => {
|
||||
const high = result.findings.filter(f => f.severity === 'high' || f.severity === 'critical');
|
||||
assert.strictEqual(high.length, 0, `Found: ${high.map(f => f.title + ': ' + f.description).join('\n')}`);
|
||||
});
|
||||
|
||||
it('all finding IDs match CA-RUL-NNN', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-RUL-\d{3}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RUL scanner — broken project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
||||
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
||||
});
|
||||
|
||||
it('detects deprecated globs field', () => {
|
||||
const found = result.findings.some(f => f.title.includes('deprecated'));
|
||||
assert.ok(found, 'Should detect globs: instead of paths:');
|
||||
});
|
||||
|
||||
it('detects dead rule (matches no files)', () => {
|
||||
const found = result.findings.some(f => f.title.includes('matches no files'));
|
||||
assert.ok(found, 'Should detect dead glob pattern');
|
||||
});
|
||||
|
||||
it('detects large unscoped rule', () => {
|
||||
const found = result.findings.some(f => f.title.includes('unscoped'));
|
||||
assert.ok(found, 'Should detect big rule without paths: frontmatter');
|
||||
});
|
||||
|
||||
it('marks dead rule as high severity', () => {
|
||||
const f = result.findings.find(f => f.title.includes('matches no files'));
|
||||
assert.strictEqual(f?.severity, 'high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RUL scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped when no rule files', () => {
|
||||
assert.strictEqual(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.strictEqual(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
172
plugins/config-audit/tests/scanners/scan-orchestrator.test.mjs
Normal file
172
plugins/config-audit/tests/scanners/scan-orchestrator.test.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve, dirname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isFixturePath, FIXTURE_DIR_NAMES, runAllScanners } from '../../scanners/scan-orchestrator.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = resolve(__dirname, '../..');
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
// ========================================
|
||||
// isFixturePath
|
||||
// ========================================
|
||||
describe('isFixturePath', () => {
|
||||
const target = '/repo';
|
||||
|
||||
it('matches tests/ subdirectory relative to target', () => {
|
||||
assert.strictEqual(isFixturePath({ file: '/repo/tests/fixtures/CLAUDE.md' }, target), true);
|
||||
});
|
||||
|
||||
it('matches examples/ subdirectory relative to target', () => {
|
||||
assert.strictEqual(isFixturePath({ file: '/repo/examples/demo/settings.json' }, target), true);
|
||||
});
|
||||
|
||||
it('matches __tests__/ subdirectory', () => {
|
||||
assert.strictEqual(isFixturePath({ file: '/repo/__tests__/config/CLAUDE.md' }, target), true);
|
||||
});
|
||||
|
||||
it('does not match production paths', () => {
|
||||
assert.strictEqual(isFixturePath({ file: '/repo/CLAUDE.md' }, target), false);
|
||||
assert.strictEqual(isFixturePath({ file: '/repo/plugins/config-audit/CLAUDE.md' }, target), false);
|
||||
});
|
||||
|
||||
it('does not filter when target IS a fixture directory', () => {
|
||||
// If we're scanning tests/fixtures/broken-project directly, its files should NOT be filtered
|
||||
const fixtureTarget = '/repo/tests/fixtures/broken-project';
|
||||
assert.strictEqual(
|
||||
isFixturePath({ file: '/repo/tests/fixtures/broken-project/CLAUDE.md' }, fixtureTarget),
|
||||
false,
|
||||
'Files at the root of the scanned target should not be filtered'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to path field', () => {
|
||||
assert.strictEqual(isFixturePath({ path: '/repo/tests/broken/hooks.json' }, target), true);
|
||||
});
|
||||
|
||||
it('falls back to location field', () => {
|
||||
assert.strictEqual(isFixturePath({ location: '/repo/examples/bad.md' }, target), true);
|
||||
});
|
||||
|
||||
it('returns false when file is null (GAP findings)', () => {
|
||||
assert.strictEqual(isFixturePath({ file: null }, target), false);
|
||||
});
|
||||
|
||||
it('returns false for empty finding (no file/path/location)', () => {
|
||||
assert.strictEqual(isFixturePath({}, target), false);
|
||||
});
|
||||
|
||||
it('returns false when file is outside target path', () => {
|
||||
assert.strictEqual(isFixturePath({ file: '/other/tests/foo.md' }, target), false);
|
||||
});
|
||||
|
||||
it('uses platform-native separator (path.sep)', () => {
|
||||
// On macOS/Linux sep='/', on Windows sep='\\'
|
||||
// This test verifies the function works with the native separator
|
||||
const nativePath = `${target}${sep}tests${sep}fixtures${sep}CLAUDE.md`;
|
||||
assert.strictEqual(isFixturePath({ file: nativePath }, target), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// FIXTURE_DIR_NAMES
|
||||
// ========================================
|
||||
describe('FIXTURE_DIR_NAMES', () => {
|
||||
it('contains expected directory names', () => {
|
||||
assert.ok(FIXTURE_DIR_NAMES.includes('tests'));
|
||||
assert.ok(FIXTURE_DIR_NAMES.includes('examples'));
|
||||
assert.ok(FIXTURE_DIR_NAMES.includes('__tests__'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// runAllScanners — fixture filtering
|
||||
// ========================================
|
||||
describe('runAllScanners — fixture filtering', () => {
|
||||
it('excludes fixture findings by default when scanning plugin root', async () => {
|
||||
const env = await runAllScanners(PLUGIN_ROOT);
|
||||
// The plugin has test fixtures in tests/fixtures/ — those should be filtered out
|
||||
const allFindingFiles = env.scanners.flatMap(s => s.findings.map(f => f.file)).filter(Boolean);
|
||||
const fixtureInResults = allFindingFiles.filter(f => f.includes('/tests/'));
|
||||
assert.strictEqual(fixtureInResults.length, 0, 'No fixture findings should appear in scanner results');
|
||||
});
|
||||
|
||||
it('stores excluded findings in env.fixture_findings', async () => {
|
||||
const env = await runAllScanners(PLUGIN_ROOT);
|
||||
// Plugin has intentionally broken fixtures — at least some findings should be excluded
|
||||
if (env.fixture_findings) {
|
||||
assert.ok(Array.isArray(env.fixture_findings));
|
||||
assert.ok(env.fixture_findings.length > 0, 'Expected fixture findings to be captured');
|
||||
// All fixture findings should have test/example paths
|
||||
for (const f of env.fixture_findings) {
|
||||
const p = f.file || f.path || f.location || '';
|
||||
assert.ok(
|
||||
p.includes('/tests/') || p.includes('/examples/'),
|
||||
`Fixture finding path should contain /tests/ or /examples/: ${p}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Note: if no fixture findings exist (unlikely but possible), test still passes
|
||||
});
|
||||
|
||||
it('includes fixture findings when filterFixtures is false', async () => {
|
||||
const env = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false });
|
||||
assert.strictEqual(env.fixture_findings, undefined, 'No fixture_findings field when filtering disabled');
|
||||
// Some findings should have test fixture paths
|
||||
const allFindingFiles = env.scanners.flatMap(s => s.findings.map(f => f.file)).filter(Boolean);
|
||||
const fixtureInResults = allFindingFiles.filter(f => f.includes('/tests/'));
|
||||
assert.ok(fixtureInResults.length > 0, 'Fixture findings should be present when filtering disabled');
|
||||
});
|
||||
|
||||
it('does not filter GAP findings (file is null)', async () => {
|
||||
const env = await runAllScanners(PLUGIN_ROOT);
|
||||
const gapScanner = env.scanners.find(s => s.scanner === 'GAP');
|
||||
assert.ok(gapScanner, 'GAP scanner should be present');
|
||||
// GAP findings have file: null — they should never be filtered
|
||||
for (const f of gapScanner.findings) {
|
||||
assert.strictEqual(f.file, null, 'GAP findings have null file and should not be filtered');
|
||||
}
|
||||
});
|
||||
|
||||
it('recalculates scanner counts after fixture filtering', async () => {
|
||||
const env = await runAllScanners(PLUGIN_ROOT);
|
||||
for (const scanner of env.scanners) {
|
||||
// Verify counts match actual findings
|
||||
const expected = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
for (const f of scanner.findings) {
|
||||
if (expected[f.severity] !== undefined) expected[f.severity]++;
|
||||
}
|
||||
assert.deepStrictEqual(scanner.counts, expected,
|
||||
`Scanner ${scanner.scanner}: counts should match actual findings after filtering`);
|
||||
}
|
||||
});
|
||||
|
||||
it('total_findings in aggregate excludes fixtures', async () => {
|
||||
const withFilter = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true });
|
||||
const withoutFilter = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false });
|
||||
// With filter should have fewer or equal findings
|
||||
assert.ok(
|
||||
withFilter.aggregate.total_findings <= withoutFilter.aggregate.total_findings,
|
||||
`Filtered total (${withFilter.aggregate.total_findings}) should be <= unfiltered (${withoutFilter.aggregate.total_findings})`
|
||||
);
|
||||
});
|
||||
|
||||
it('fixture filtering and suppression are independent', async () => {
|
||||
// Both enabled (default)
|
||||
const both = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true, suppress: true });
|
||||
// Only fixtures
|
||||
const fixturesOnly = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true, suppress: false });
|
||||
// Only suppression
|
||||
const suppressOnly = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false, suppress: true });
|
||||
|
||||
// fixture_findings should be present in both fixture-filtered runs
|
||||
if (both.fixture_findings) {
|
||||
assert.ok(fixturesOnly.fixture_findings, 'fixture_findings should be present regardless of suppress flag');
|
||||
}
|
||||
// suppressed_findings should be present in both suppression-enabled runs (if any suppressions exist)
|
||||
if (both.suppressed_findings) {
|
||||
assert.ok(suppressOnly.suppressed_findings, 'suppressed_findings should be present regardless of filterFixtures flag');
|
||||
}
|
||||
});
|
||||
});
|
||||
90
plugins/config-audit/tests/scanners/self-audit.test.mjs
Normal file
90
plugins/config-audit/tests/scanners/self-audit.test.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runSelfAudit, formatSelfAudit } from '../../scanners/self-audit.mjs';
|
||||
|
||||
// ========================================
|
||||
// runSelfAudit
|
||||
// ========================================
|
||||
describe('runSelfAudit', () => {
|
||||
it('runs without crash', async () => {
|
||||
const result = await runSelfAudit();
|
||||
assert.ok(result);
|
||||
assert.ok(typeof result.configGrade === 'string');
|
||||
assert.ok(typeof result.pluginGrade === 'string');
|
||||
});
|
||||
|
||||
it('returns combined results (scanners + plugin health)', async () => {
|
||||
const result = await runSelfAudit();
|
||||
assert.ok(result.configEnvelope);
|
||||
assert.ok(result.pluginHealthResult);
|
||||
assert.ok(Array.isArray(result.allFindings));
|
||||
});
|
||||
|
||||
it('has valid exit code', async () => {
|
||||
const result = await runSelfAudit();
|
||||
assert.ok([0, 1, 2].includes(result.exitCode));
|
||||
});
|
||||
|
||||
it('includes verdict', async () => {
|
||||
const result = await runSelfAudit();
|
||||
assert.ok(['PASS', 'WARN', 'FAIL'].includes(result.verdict));
|
||||
});
|
||||
|
||||
it('has numeric scores', async () => {
|
||||
const result = await runSelfAudit();
|
||||
assert.ok(typeof result.configScore === 'number');
|
||||
assert.ok(typeof result.pluginScore === 'number');
|
||||
assert.ok(result.configScore >= 0 && result.configScore <= 100);
|
||||
assert.ok(result.pluginScore >= 0 && result.pluginScore <= 100);
|
||||
});
|
||||
|
||||
it('points to correct plugin directory', async () => {
|
||||
const result = await runSelfAudit();
|
||||
assert.ok(result.pluginDir.includes('config-audit'));
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// fixture filtering delegation
|
||||
// ========================================
|
||||
describe('runSelfAudit — fixture filtering', () => {
|
||||
it('does not include fixture findings in allFindings', async () => {
|
||||
const result = await runSelfAudit();
|
||||
for (const f of result.allFindings) {
|
||||
const p = f.file || f.path || f.location || '';
|
||||
assert.ok(
|
||||
!p.includes('/tests/fixtures/'),
|
||||
`allFindings should not contain fixture paths: ${p}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('configEnvelope has fixture_findings from orchestrator', async () => {
|
||||
const result = await runSelfAudit();
|
||||
// The orchestrator filters fixtures and attaches them to the envelope
|
||||
if (result.configEnvelope.fixture_findings) {
|
||||
assert.ok(Array.isArray(result.configEnvelope.fixture_findings));
|
||||
assert.ok(result.configEnvelope.fixture_findings.length > 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// formatSelfAudit
|
||||
// ========================================
|
||||
describe('formatSelfAudit', () => {
|
||||
it('produces terminal output with Self-Audit header', async () => {
|
||||
const result = await runSelfAudit();
|
||||
const output = formatSelfAudit(result);
|
||||
assert.ok(output.includes('Self-Audit'));
|
||||
assert.ok(output.includes('Plugin health:'));
|
||||
assert.ok(output.includes('Config quality:'));
|
||||
});
|
||||
|
||||
it('includes verdict', async () => {
|
||||
const result = await runSelfAudit();
|
||||
const output = formatSelfAudit(result);
|
||||
assert.ok(output.includes('Self-audit:'));
|
||||
assert.ok(output.includes('PASS') || output.includes('WARN') || output.includes('FAIL'));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/settings-validator.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURES = resolve(__dirname, '../fixtures');
|
||||
|
||||
describe('SET scanner — healthy project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
|
||||
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns status ok', () => {
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
});
|
||||
|
||||
it('has scanner prefix SET', () => {
|
||||
assert.strictEqual(result.scanner, 'SET');
|
||||
});
|
||||
|
||||
it('finds no critical issues', () => {
|
||||
const critical = result.findings.filter(f => f.severity === 'critical');
|
||||
assert.strictEqual(critical.length, 0);
|
||||
});
|
||||
|
||||
it('all finding IDs match CA-SET-NNN', () => {
|
||||
for (const f of result.findings) {
|
||||
assert.match(f.id, /^CA-SET-\d{3}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SET scanner — broken project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
|
||||
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
|
||||
});
|
||||
|
||||
it('detects unknown settings key', () => {
|
||||
const found = result.findings.some(f => f.title === 'Unknown settings key');
|
||||
assert.ok(found, 'Should detect unknownKey123');
|
||||
});
|
||||
|
||||
it('detects deprecated key (includeCoAuthoredBy)', () => {
|
||||
const found = result.findings.some(f => f.title === 'Deprecated settings key');
|
||||
assert.ok(found, 'Should detect includeCoAuthoredBy');
|
||||
});
|
||||
|
||||
it('detects type mismatch (alwaysThinkingEnabled as string)', () => {
|
||||
const found = result.findings.some(f => f.title === 'Type mismatch in settings');
|
||||
assert.ok(found, 'Should detect boolean/string mismatch');
|
||||
});
|
||||
|
||||
it('detects invalid effortLevel value', () => {
|
||||
const found = result.findings.some(f => f.title === 'Invalid effortLevel value');
|
||||
assert.ok(found, 'Should detect effortLevel "turbo"');
|
||||
});
|
||||
|
||||
it('detects hooks as array', () => {
|
||||
const found = result.findings.some(f => f.title.includes('array instead of object'));
|
||||
assert.ok(found, 'Should detect hooks array format');
|
||||
});
|
||||
|
||||
it('marks hooks-as-array as critical', () => {
|
||||
const f = result.findings.find(f => f.title.includes('array instead of object'));
|
||||
assert.strictEqual(f?.severity, 'critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SET scanner — empty project', () => {
|
||||
let result;
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
|
||||
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
|
||||
});
|
||||
|
||||
it('returns skipped when no settings files', () => {
|
||||
assert.strictEqual(result.status, 'skipped');
|
||||
});
|
||||
|
||||
it('has 0 findings', () => {
|
||||
assert.strictEqual(result.findings.length, 0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue