diff --git a/hailbytes-vuln-calculator.js b/hailbytes-vuln-calculator.js index 1d2c2aa..729c308 100644 --- a/hailbytes-vuln-calculator.js +++ b/hailbytes-vuln-calculator.js @@ -48,7 +48,36 @@ const TIME_MULT = { light: 0.5, medium: 1.0, aggressive: 2.0, continuous: 0 // ─── Calculation engine ──────────────────────────────────────────────────────── -function calculateResources(data) { +/** + * Normalize raw inputs so the pure calculate() API tolerates omitted optional + * fields the same way the DOM component does. `scan_window` and + * `compliance_needs` are declared optional in the public types, so callers may + * omit them; without this, calculate() throws on a missing `compliance_needs` + * and yields NaN window-utilization on a missing `scan_window`. + */ +function normalizeInputs(data) { + const raw = data ?? {}; + const hosts = Number(raw.target_hosts); + // An empty tools array is a valid, zero-cost state for the pure API (the DOM + // component injects its own UI fallback); only a missing/invalid value needs + // a default to avoid a crash on `.includes`. + const tools = Array.isArray(raw.scanning_tools) ? raw.scanning_tools.filter(Boolean) : []; + + const window = Number(raw.scan_window); + + return { + ...raw, + target_hosts: Number.isFinite(hosts) && hosts > 0 ? hosts : 1, + scan_intensity: raw.scan_intensity ?? 'medium', + scan_frequency: raw.scan_frequency ?? 'weekly', + scan_window: Number.isFinite(window) && window > 0 ? window : 8, + scanning_tools: tools, + compliance_needs: Array.isArray(raw.compliance_needs) ? raw.compliance_needs : [], + }; +} + +function calculateResources(rawData) { + const data = normalizeInputs(rawData); const hasAsm = data.scanning_tools.includes('hailbytes_asm'); const vmResources = hasAsm diff --git a/test/calculate.test.mjs b/test/calculate.test.mjs index 0bab1d1..4fe4831 100644 --- a/test/calculate.test.mjs +++ b/test/calculate.test.mjs @@ -780,6 +780,80 @@ describe('Edge Cases', () => { }); }); +// ═════════════════════════════════════════════════════════════════════════════ +// Optional input handling — fields the public types mark as optional +// (scan_window?, compliance_needs?) must not crash or produce NaN when omitted. +// ═════════════════════════════════════════════════════════════════════════════ + +describe('Optional input handling', () => { + test('omitting compliance_needs does not throw and defaults to no compliance', () => { + const r = calculate({ + target_hosts: 1000, + scan_intensity: 'medium', + scan_frequency: 'weekly', + scan_window: 8, + scanning_tools: ['hailbytes_asm'], + // compliance_needs intentionally omitted + }); + assert.ok(r.vm_resources.cpu_cores >= 2); + // complianceFactor should behave like an empty list (1.0 multiplier) + assert.deepEqual(r.inputs.compliance_needs, []); + assert.ok(!r.recommendations.some(rec => rec.includes('PCI DSS requires'))); + }); + + test('omitting scan_window yields a finite utilization, not NaN', () => { + const r = calculate({ + target_hosts: 1000, + scan_intensity: 'medium', + scan_frequency: 'weekly', + scanning_tools: ['hailbytes_asm'], + compliance_needs: [], + // scan_window intentionally omitted + }); + assert.equal(typeof r.timing.scan_window_utilization, 'number'); + assert.ok(Number.isFinite(r.timing.scan_window_utilization)); + assert.ok(!Number.isNaN(r.timing.scan_window_utilization)); + // efficiency rating should reflect the real number, not the NaN→'poor' fallthrough + assert.ok(['excellent', 'good', 'acceptable', 'poor'].includes( + r.timing.performance_metrics.efficiency_rating)); + }); + + test('omitting scanning_tools does not throw and produces zero tool cost', () => { + const r = calculate({ + target_hosts: 1000, + scan_intensity: 'medium', + scan_frequency: 'weekly', + // scanning_tools intentionally omitted + }); + assert.ok(r.vm_resources); + assert.equal(r.has_asm, false); + assert.equal(r.costs.tool_licensing_annual, 0); + assert.equal(r.costs.tool_management_monthly, 0); + assert.equal(r.costs.tool_setup_cost, 0); + }); + + test('zero / invalid scan_window falls back to a safe default instead of dividing by zero', () => { + const r = calculate({ + target_hosts: 1000, + scan_intensity: 'medium', + scan_frequency: 'weekly', + scan_window: 0, + scanning_tools: ['hailbytes_asm'], + compliance_needs: [], + }); + assert.ok(Number.isFinite(r.timing.scan_window_utilization)); + }); + + test('only the minimal required fields are needed to get a full result', () => { + const r = calculate({ target_hosts: 500 }); + assert.ok(r.vm_resources); + assert.ok(r.timing); + assert.ok(r.costs); + assert.ok(Array.isArray(r.recommendations)); + assert.equal(typeof r.timestamp, 'string'); + }); +}); + // ═════════════════════════════════════════════════════════════════════════════ // Additional Accuracy Tests - Cross-validate full scenario // ═════════════════════════════════════════════════════════════════════════════ diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 5573ccf..9463bd1 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -78,6 +78,19 @@ test('calculate() recognises HailBytes ASM tool', () => { assert.equal(r.has_asm, true); }); +test('calculate() tolerates omitted optional inputs (scan_window, compliance_needs)', () => { + // index.d.ts marks scan_window and compliance_needs as optional, so omitting + // them must not throw or yield NaN. + const r = mod.calculate({ + target_hosts: 1000, + scan_intensity: 'medium', + scan_frequency: 'weekly', + scanning_tools: ['hailbytes_asm'], + }); + assert.ok(r.vm_resources); + assert.ok(Number.isFinite(r.timing.scan_window_utilization)); +}); + test('class declares observedAttributes including theme and branding', () => { const observed = mod.default.observedAttributes; assert.ok(Array.isArray(observed));