// output.test.mjs — Tests for scanners/lib/output.mjs // Zero external dependencies: node:test + node:assert only. import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { resetCounter, finding, scannerResult, envelope, } from '../../scanners/lib/output.mjs'; // --------------------------------------------------------------------------- // finding + resetCounter // --------------------------------------------------------------------------- describe('finding', () => { beforeEach(() => { resetCounter(); }); it('returns an object with auto-incrementing ID in DS-SCANNER-NNN format', () => { const f = finding({ scanner: 'UNI', severity: 'high', title: 'Test', description: 'Desc' }); assert.equal(f.id, 'DS-UNI-001'); }); it('increments ID with each call', () => { const f1 = finding({ scanner: 'UNI', severity: 'high', title: 'A', description: 'Desc' }); const f2 = finding({ scanner: 'ENT', severity: 'medium', title: 'B', description: 'Desc' }); const f3 = finding({ scanner: 'PRM', severity: 'low', title: 'C', description: 'Desc' }); assert.equal(f1.id, 'DS-UNI-001'); assert.equal(f2.id, 'DS-ENT-002'); assert.equal(f3.id, 'DS-PRM-003'); }); it('zero-pads counter to 3 digits', () => { for (let i = 0; i < 9; i++) { finding({ scanner: 'UNI', severity: 'info', title: `F${i}`, description: 'x' }); } const f10 = finding({ scanner: 'UNI', severity: 'info', title: 'F10', description: 'x' }); assert.equal(f10.id, 'DS-UNI-010'); }); it('includes all required fields', () => { const f = finding({ scanner: 'ENT', severity: 'critical', title: 'High Entropy Secret', description: 'Found a high-entropy string that looks like an API key.', }); assert.equal(f.scanner, 'ENT'); assert.equal(f.severity, 'critical'); assert.equal(f.title, 'High Entropy Secret'); assert.equal(f.description, 'Found a high-entropy string that looks like an API key.'); }); it('sets optional fields to null when not provided', () => { const f = finding({ scanner: 'UNI', severity: 'low', title: 'T', description: 'D' }); assert.equal(f.file, null); assert.equal(f.line, null); assert.equal(f.evidence, null); assert.equal(f.owasp, null); assert.equal(f.recommendation, null); }); it('includes all provided optional fields', () => { const f = finding({ scanner: 'TNT', severity: 'high', title: 'Taint flow', description: 'Untrusted data flows into eval().', file: 'src/runner.mjs', line: 42, evidence: 'eval(userInput)', owasp: 'LLM01', recommendation: 'Sanitize user input before evaluation.', }); assert.equal(f.file, 'src/runner.mjs'); assert.equal(f.line, 42); assert.equal(f.evidence, 'eval(userInput)'); assert.equal(f.owasp, 'LLM01'); assert.equal(f.recommendation, 'Sanitize user input before evaluation.'); }); }); describe('resetCounter', () => { it('resets the counter so the next finding starts at 001', () => { // Advance counter to some arbitrary position finding({ scanner: 'UNI', severity: 'info', title: 'A', description: 'x' }); finding({ scanner: 'UNI', severity: 'info', title: 'B', description: 'x' }); finding({ scanner: 'UNI', severity: 'info', title: 'C', description: 'x' }); resetCounter(); const f = finding({ scanner: 'ENT', severity: 'low', title: 'After reset', description: 'x' }); assert.equal(f.id, 'DS-ENT-001'); }); it('can be called multiple times without error', () => { assert.doesNotThrow(() => { resetCounter(); resetCounter(); resetCounter(); }); }); }); // --------------------------------------------------------------------------- // scannerResult // --------------------------------------------------------------------------- describe('scannerResult', () => { beforeEach(() => { resetCounter(); }); it('returns an object with the expected top-level keys', () => { const result = scannerResult('unicode-scanner', 'ok', [], 10, 42); assert.ok('scanner' in result); assert.ok('status' in result); assert.ok('findings' in result); assert.ok('counts' in result); assert.ok('files_scanned' in result); assert.ok('duration_ms' in result); }); it('sets scanner name and status correctly', () => { const result = scannerResult('entropy-scanner', 'ok', [], 5, 100); assert.equal(result.scanner, 'entropy-scanner'); assert.equal(result.status, 'ok'); }); it('returns empty counts for no findings', () => { const result = scannerResult('dep-auditor', 'ok', [], 0, 0); assert.deepEqual(result.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 }); }); it('counts findings by severity correctly', () => { const f1 = finding({ scanner: 'ENT', severity: 'critical', title: 'A', description: 'x' }); const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' }); const f3 = finding({ scanner: 'ENT', severity: 'high', title: 'C', description: 'x' }); const f4 = finding({ scanner: 'ENT', severity: 'medium', title: 'D', description: 'x' }); const result = scannerResult('entropy-scanner', 'ok', [f1, f2, f3, f4], 20, 300); assert.equal(result.counts.critical, 1); assert.equal(result.counts.high, 2); assert.equal(result.counts.medium, 1); assert.equal(result.counts.low, 0); assert.equal(result.counts.info, 0); }); it('stores findings array as provided', () => { const f = finding({ scanner: 'UNI', severity: 'low', title: 'X', description: 'y' }); const result = scannerResult('unicode-scanner', 'ok', [f], 1, 10); assert.equal(result.findings.length, 1); assert.equal(result.findings[0].id, f.id); }); it('sets files_scanned and duration_ms', () => { const result = scannerResult('git-forensics', 'ok', [], 77, 1234); assert.equal(result.files_scanned, 77); assert.equal(result.duration_ms, 1234); }); it('does not include error field when errorMsg is not provided', () => { const result = scannerResult('taint-tracer', 'ok', [], 5, 50); assert.ok(!('error' in result)); }); it('includes error field when errorMsg is provided', () => { const result = scannerResult('dep-auditor', 'error', [], 0, 10, 'ENOENT: package.json not found'); assert.equal(result.error, 'ENOENT: package.json not found'); assert.equal(result.status, 'error'); }); it('handles skipped status', () => { const result = scannerResult('network-mapper', 'skipped', [], 0, 0); assert.equal(result.status, 'skipped'); }); }); // --------------------------------------------------------------------------- // envelope // --------------------------------------------------------------------------- describe('envelope', () => { beforeEach(() => { resetCounter(); }); it('returns an object with meta, scanners, and aggregate keys', () => { const result = envelope('/some/path', {}, 100); assert.ok('meta' in result); assert.ok('scanners' in result); assert.ok('aggregate' in result); }); it('meta contains target, timestamp, node_version, total_duration_ms', () => { const result = envelope('/my/project', {}, 999); assert.equal(result.meta.target, '/my/project'); assert.ok(typeof result.meta.timestamp === 'string'); assert.ok(result.meta.timestamp.length > 0); assert.ok(typeof result.meta.node_version === 'string'); assert.equal(result.meta.total_duration_ms, 999); }); it('aggregate contains risk_score and verdict', () => { const result = envelope('/project', {}, 0); assert.ok('risk_score' in result.aggregate); assert.ok('verdict' in result.aggregate); }); it('aggregate has zero counts and ALLOW verdict for empty scanner results', () => { const result = envelope('/project', {}, 0); assert.equal(result.aggregate.total_findings, 0); assert.equal(result.aggregate.risk_score, 0); assert.equal(result.aggregate.verdict, 'ALLOW'); assert.deepEqual(result.aggregate.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 }); }); it('aggregates counts from multiple scanner results', () => { const f1 = finding({ scanner: 'UNI', severity: 'critical', title: 'A', description: 'x' }); const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' }); const scanners = { unicode: scannerResult('unicode-scanner', 'ok', [f1], 10, 50), entropy: scannerResult('entropy-scanner', 'ok', [f2], 10, 75), }; const result = envelope('/project', scanners, 125); assert.equal(result.aggregate.total_findings, 2); assert.equal(result.aggregate.counts.critical, 1); assert.equal(result.aggregate.counts.high, 1); }); it('computes correct risk_score from aggregated counts', () => { // 1 critical = score 25 const f = finding({ scanner: 'ENT', severity: 'critical', title: 'C', description: 'x' }); const scanners = { entropy: scannerResult('entropy-scanner', 'ok', [f], 5, 30), }; const result = envelope('/project', scanners, 30); assert.equal(result.aggregate.risk_score, 25); }); it('returns BLOCK verdict when critical finding present', () => { const f = finding({ scanner: 'UNI', severity: 'critical', title: 'Critical', description: 'x' }); const scanners = { uni: scannerResult('unicode-scanner', 'ok', [f], 1, 10), }; const result = envelope('/project', scanners, 10); assert.equal(result.aggregate.verdict, 'BLOCK'); }); it('tracks scanner ok/error/skipped counts', () => { const scanners = { uni: scannerResult('unicode-scanner', 'ok', [], 5, 10), ent: scannerResult('entropy-scanner', 'error', [], 0, 5, 'failed'), net: scannerResult('network-mapper', 'skipped', [], 0, 0), }; const result = envelope('/project', scanners, 15); assert.equal(result.aggregate.scanners_ok, 1); assert.equal(result.aggregate.scanners_error, 1); assert.equal(result.aggregate.scanners_skipped, 1); }); it('includes owasp_breakdown in aggregate', () => { const result = envelope('/project', {}, 0); assert.ok('owasp_breakdown' in result.aggregate); }); it('passes through scanner results as-is in scanners field', () => { const sr = scannerResult('unicode-scanner', 'ok', [], 3, 20); const scanners = { uni: sr }; const result = envelope('/project', scanners, 20); assert.deepEqual(result.scanners, scanners); }); });