294 lines
9.8 KiB
JavaScript
294 lines
9.8 KiB
JavaScript
// dashboard.test.mjs — Tests for the cross-project dashboard aggregator
|
|
// Tests discovery, aggregation, caching, and grade calculation.
|
|
// Uses posture-scan fixtures as known projects.
|
|
|
|
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 { writeFile, mkdir, rm, readFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
|
|
// Import functions under test
|
|
import { discoverProjects, aggregate } from '../../scanners/dashboard-aggregator.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures/posture-scan');
|
|
const GRADE_A_FIXTURE = resolve(FIXTURES, 'grade-a-project');
|
|
const GRADE_F_FIXTURE = resolve(FIXTURES, 'grade-f-project');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discovery tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('dashboard-aggregator: discoverProjects', () => {
|
|
it('finds projects with CLAUDE.md marker', async () => {
|
|
// The fixtures themselves have CLAUDE.md — use parent as search root with depth 2
|
|
const projects = await discoverProjects({
|
|
maxDepth: 2,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
});
|
|
assert.ok(projects.includes(GRADE_A_FIXTURE), 'Should find grade-a fixture via extraPaths');
|
|
});
|
|
|
|
it('finds projects with .claude-plugin marker', async () => {
|
|
const projects = await discoverProjects({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
});
|
|
assert.ok(projects.includes(GRADE_A_FIXTURE));
|
|
});
|
|
|
|
it('deduplicates project paths', async () => {
|
|
const projects = await discoverProjects({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE, GRADE_A_FIXTURE, GRADE_A_FIXTURE],
|
|
});
|
|
const count = projects.filter(p => p === GRADE_A_FIXTURE).length;
|
|
assert.equal(count, 1, 'Should deduplicate');
|
|
});
|
|
|
|
it('returns sorted paths', async () => {
|
|
const projects = await discoverProjects({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_F_FIXTURE, GRADE_A_FIXTURE],
|
|
});
|
|
const filtered = projects.filter(p => p.includes('posture-scan'));
|
|
for (let i = 1; i < filtered.length; i++) {
|
|
assert.ok(filtered[i] >= filtered[i - 1], 'Should be sorted');
|
|
}
|
|
});
|
|
|
|
it('handles non-existent extra paths gracefully', async () => {
|
|
const projects = await discoverProjects({
|
|
maxDepth: 0,
|
|
extraPaths: ['/nonexistent/path/that/does/not/exist'],
|
|
});
|
|
assert.ok(Array.isArray(projects));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aggregation tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('dashboard-aggregator: aggregate', () => {
|
|
let tmpCacheDir;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
});
|
|
|
|
it('scans known fixtures and returns structured result', async () => {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE, GRADE_F_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
// Meta
|
|
assert.equal(result.meta.scanner, 'dashboard-aggregator');
|
|
assert.ok(result.meta.version);
|
|
assert.ok(result.meta.timestamp);
|
|
assert.equal(result.meta.from_cache, false);
|
|
|
|
// Machine
|
|
assert.ok(result.machine.grade, 'Should have machine grade');
|
|
assert.ok(result.machine.projects_scanned >= 2, `Expected >=2 projects, got ${result.machine.projects_scanned}`);
|
|
|
|
// Projects array
|
|
assert.ok(Array.isArray(result.projects));
|
|
assert.ok(result.projects.length >= 2);
|
|
|
|
// Each project has required fields
|
|
for (const p of result.projects) {
|
|
assert.ok(p.path, 'Project should have path');
|
|
assert.ok(p.display_name, 'Project should have display_name');
|
|
assert.ok(p.grade, 'Project should have grade');
|
|
assert.ok(typeof p.risk_score === 'number', 'risk_score should be number');
|
|
assert.ok(typeof p.findings_count === 'number', 'findings_count should be number');
|
|
assert.ok(p.counts, 'Project should have counts');
|
|
}
|
|
});
|
|
|
|
it('machine grade is weakest link', async () => {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE, GRADE_F_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
// grade-f-project should drag machine grade down
|
|
const fProject = result.projects.find(p => p.path === GRADE_F_FIXTURE);
|
|
assert.ok(fProject, 'Should find grade-f fixture in results');
|
|
assert.equal(fProject.grade, 'F', 'Grade F fixture should get F');
|
|
|
|
// Machine grade should be F (weakest link)
|
|
assert.equal(result.machine.grade, 'F', 'Machine grade should be F (weakest link)');
|
|
});
|
|
|
|
it('identifies worst category per project', async () => {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_F_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
const fProject = result.projects.find(p => p.path === GRADE_F_FIXTURE);
|
|
assert.ok(fProject);
|
|
assert.ok(fProject.worst_category, 'Should identify worst category');
|
|
assert.equal(fProject.worst_status, 'FAIL', 'Worst status should be FAIL for grade-f');
|
|
});
|
|
|
|
it('aggregates finding counts across projects', async () => {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE, GRADE_F_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
assert.ok(typeof result.machine.total_findings === 'number');
|
|
assert.ok(typeof result.machine.counts.critical === 'number');
|
|
assert.ok(typeof result.machine.counts.high === 'number');
|
|
|
|
// Total should match sum of per-project
|
|
const sumFindings = result.projects.reduce((s, p) => s + p.findings_count, 0);
|
|
assert.equal(result.machine.total_findings, sumFindings);
|
|
});
|
|
|
|
it('includes duration_ms per project', async () => {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
for (const p of result.projects) {
|
|
assert.ok(typeof p.duration_ms === 'number', 'Should have duration_ms');
|
|
}
|
|
});
|
|
|
|
it('records errors for invalid projects', async () => {
|
|
// Create a fake project that will fail to scan properly
|
|
const tmpDir = join(tmpdir(), `dashboard-test-err-${Date.now()}`);
|
|
await mkdir(tmpDir, { recursive: true });
|
|
await writeFile(join(tmpDir, 'CLAUDE.md'), '# Test\n');
|
|
// Create a .claude dir with malformed settings.json to trigger issues
|
|
// (posture-scanner should still succeed but with low grade)
|
|
|
|
try {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [tmpDir],
|
|
useCache: false,
|
|
});
|
|
assert.ok(Array.isArray(result.errors));
|
|
// The project should either be in projects or errors
|
|
const found = result.projects.some(p => p.path === tmpDir) ||
|
|
result.errors.some(e => e.path === tmpDir);
|
|
assert.ok(found, 'Tmp project should appear in results or errors');
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Caching tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('dashboard-aggregator: caching', () => {
|
|
it('returns from_cache: true when cache is fresh', async () => {
|
|
// First run: force fresh to populate cache
|
|
const result1 = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
assert.equal(result1.meta.from_cache, false);
|
|
|
|
// Second run: should use cache (freshly written)
|
|
const result2 = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: true,
|
|
stalenessMs: 60000,
|
|
});
|
|
assert.equal(result2.meta.from_cache, true);
|
|
});
|
|
|
|
it('bypasses cache with useCache: false', async () => {
|
|
// Populate cache
|
|
await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: true,
|
|
stalenessMs: 60000,
|
|
});
|
|
|
|
// Force fresh scan
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
assert.equal(result.meta.from_cache, false);
|
|
});
|
|
|
|
it('rescans when cache is stale', async () => {
|
|
// Populate cache
|
|
await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: true,
|
|
});
|
|
|
|
// Use 0ms staleness threshold — everything is stale
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: true,
|
|
stalenessMs: 0,
|
|
});
|
|
assert.equal(result.meta.from_cache, false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grade comparison tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('dashboard-aggregator: grade logic', () => {
|
|
it('grade-a only yields machine grade A', async () => {
|
|
resetCounter();
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
const aProject = result.projects.find(p => p.path === GRADE_A_FIXTURE);
|
|
if (aProject && aProject.grade === 'A') {
|
|
// If only Grade A projects, machine grade should be A
|
|
const allA = result.projects.every(p => p.grade === 'A');
|
|
if (allA) {
|
|
assert.equal(result.machine.grade, 'A');
|
|
}
|
|
}
|
|
});
|
|
|
|
it('display_name uses tilde for home-relative paths', async () => {
|
|
const result = await aggregate({
|
|
maxDepth: 0,
|
|
extraPaths: [GRADE_A_FIXTURE],
|
|
useCache: false,
|
|
});
|
|
|
|
for (const p of result.projects) {
|
|
if (p.path.startsWith(process.env.HOME || '/Users')) {
|
|
assert.ok(p.display_name.startsWith('~/'), `Expected ~/... got ${p.display_name}`);
|
|
}
|
|
}
|
|
});
|
|
});
|