From 5079356b2b15ac0aebe5e11edfb9ebdc9cd60acf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 09:09:26 +0000 Subject: [PATCH] fix: make pure calculate() tolerate omitted optional inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public calculate() API crashed or produced NaN when called with the fields that index.d.ts documents as optional: - omitting `compliance_needs` threw "Cannot read properties of undefined (reading 'length')" in calcAsmResources - omitting `scan_window` produced NaN scan_window_utilization and a misleading "poor" efficiency rating (NaN <= 60 is false, so the rating chain fell through to the worst bucket) - omitting `scanning_tools` threw on `.includes` The DOM component guards these in _collectInputs()/_run(), but the pure calculate() — the headline programmatic API — did not, so TypeScript consumers got green typechecking followed by a runtime crash. Add normalizeInputs() at the calculation entry point to default the optional fields (scan_window -> 8, compliance_needs -> [], a missing/invalid scanning_tools -> [], a non-positive scan_window -> 8, non-finite target_hosts -> 1). An empty scanning_tools array stays a valid zero-cost state, matching the existing edge-case test. Add regression coverage: 5 tests in calculate.test.mjs plus one smoke-level test so the case is guarded by the CI `npm test` script today (which currently runs only the smoke suite). https://claude.ai/code/session_01Aed4GvoDBzgz6fuPXSHHif --- hailbytes-vuln-calculator.js | 31 ++++++++++++++- test/calculate.test.mjs | 74 ++++++++++++++++++++++++++++++++++++ test/smoke.test.mjs | 13 +++++++ 3 files changed, 117 insertions(+), 1 deletion(-) 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));