diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a7dec4..fe6b8153 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -682,8 +682,8 @@ jobs: - name: Lint run: npm run lint - - name: Run aiSettings tests - run: node src/utils/aiApi.test.mjs + - name: Run API utility tests + run: node src/utils/api.test.mjs && node src/utils/scanPolling.test.mjs && node src/utils/aiApi.test.mjs - name: Run dashboard load-state tests run: node src/hooks/usePageData.test.mjs diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 53c41f05..97d9ed34 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -5,6 +5,7 @@ import { FiLoader, FiZap, FiCheckCircle, FiAlertCircle, FiClock, } from 'react-icons/fi'; import { api } from '../../utils/api'; +import { pollScan } from '../../utils/scanPolling'; import { useI18n } from '../../i18n/I18nState'; const PAGE_KEYS = { @@ -223,14 +224,12 @@ export default function Header({ onMenuToggle }) { return; } - // Poll until done (max 5 min, every 4s) + // Poll until done (max 5 min, every 4s). Individual transport failures + // are transient; only a backend terminal status ends the scan. const scanId = trigger.scan_id; - for (let i = 0; i < 75; i++) { - await new Promise((r) => setTimeout(r, 4000)); - const scan = await api.getScan(scanId); - if (scan?.status === 'completed') { setScanToast({ result: scan }); return; } - if (scan?.status === 'failed') { setScanToast({ error: `Scan ${scanId} failed.` }); return; } - } + const scan = await pollScan({ scanId, getScan: api.getScan }); + if (scan?.status === 'completed') { setScanToast({ result: scan }); return; } + if (scan?.status === 'failed') { setScanToast({ error: `Scan ${scanId} failed.` }); return; } setScanToast({ error: 'Scan timed out after 5 minutes. Check backend logs.' }); } catch (err) { setScanToast({ error: err?.message || 'Scan failed. Is the backend running?' }); diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index af52ad6b..13041efc 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -14,18 +14,111 @@ const getToken = () => localStorage.getItem('jwt_token'); const setToken = (tok) => localStorage.setItem('jwt_token', tok); // ── Core fetch ───────────────────────────────────────────────────────────── -async function apiFetch(path, options = {}) { - const token = getToken(); - const res = await fetch(`${API_BASE}/api${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(options.headers || {}), - }, - }); - if (!res.ok) throw new Error(`API ${res.status} ${res.statusText}`); - return res.json(); +export const DEFAULT_REQUEST_TIMEOUT_MS = 30000; + +export class ApiRequestError extends Error { + constructor(message, { code, cause } = {}) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'ApiRequestError'; + this.code = code; + } +} + +export class ApiTimeoutError extends ApiRequestError { + constructor(timeoutMs, cause) { + super(`API request timed out after ${timeoutMs}ms`, { code: 'TIMEOUT', cause }); + this.name = 'ApiTimeoutError'; + this.timeoutMs = timeoutMs; + } +} + +export class ApiCancellationError extends ApiRequestError { + constructor(cause) { + super('API request was cancelled', { code: 'CANCELLED', cause }); + this.name = 'ApiCancellationError'; + } +} + +export class ApiHttpError extends ApiRequestError { + constructor(response) { + super(`API ${response.status} ${response.statusText}`, { code: 'HTTP_ERROR' }); + this.name = 'ApiHttpError'; + this.status = response.status; + this.statusText = response.statusText; + } +} + +export class ApiNetworkError extends ApiRequestError { + constructor(cause) { + super('API request failed due to a network error', { code: 'NETWORK_ERROR', cause }); + this.name = 'ApiNetworkError'; + } +} + +function isOptionalDataError(err) { + return err instanceof ApiHttpError + || err instanceof ApiNetworkError + || err instanceof ApiTimeoutError; +} + +export async function apiFetch(path, options = {}) { + const { + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + signal: callerSignal, + headers, + ...fetchOptions + } = options; + const controller = new AbortController(); + let abortSource = null; + let responseReceived = false; + let timeoutId; + + const abortFromCaller = () => { + if (abortSource === null) abortSource = 'caller'; + if (!controller.signal.aborted) controller.abort(); + }; + + if (callerSignal?.aborted) { + abortFromCaller(); + } else { + callerSignal?.addEventListener('abort', abortFromCaller, { once: true }); + } + + if (timeoutMs != null) { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + callerSignal?.removeEventListener('abort', abortFromCaller); + throw new TypeError('timeoutMs must be a non-negative finite number or null'); + } + timeoutId = setTimeout(() => { + if (abortSource === null) abortSource = 'timeout'; + if (!controller.signal.aborted) controller.abort(); + }, timeoutMs); + } + + try { + const token = getToken(); + const res = await fetch(`${API_BASE}/api${path}`, { + ...fetchOptions, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(headers || {}), + }, + }); + responseReceived = true; + if (!res.ok) throw new ApiHttpError(res); + return await res.json(); + } catch (err) { + if (abortSource === 'timeout') throw new ApiTimeoutError(timeoutMs, err); + if (abortSource === 'caller') throw new ApiCancellationError(err); + if (err instanceof ApiRequestError) throw err; + if (!responseReceived) throw new ApiNetworkError(err); + throw err; + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + callerSignal?.removeEventListener('abort', abortFromCaller); + } } // ───────────────────────────────────────────────────────────────────────────── @@ -281,78 +374,88 @@ export const api = { }, // ── Score GET /api/score ────────────────────────────────────────────────── - getScore: async () => normalizeScore(await apiFetch('/score')), + getScore: async (options = {}) => normalizeScore(await apiFetch('/score', options)), // ── CVE Summary GET /api/score/cve-summary ─────────────────────────────── - getCVESummary: async () => { - try { return await apiFetch('/score/cve-summary'); } - catch { return null; } + getCVESummary: async (options = {}) => { + try { return await apiFetch('/score/cve-summary', options); } + catch (err) { + if (isOptionalDataError(err)) return null; + throw err; + } }, // ── Findings GET /api/findings ──────────────────────────────────────────── - getFindings: async (filters = {}) => { + getFindings: async (filters = {}, options = {}) => { const params = new URLSearchParams(Object.entries(filters).filter(([, v]) => v != null && v !== '')); - const data = await apiFetch(`/findings${params.toString() ? '?' + params : ''}`); + const data = await apiFetch(`/findings${params.toString() ? '?' + params : ''}`, options); return (data.findings || data).map(normalizeFinding); }, // ── Single finding GET /api/findings/:id ───────────────────────────────── - getFinding: async (id) => normalizeFinding(await apiFetch(`/findings/${id}`)), + getFinding: async (id, options = {}) => normalizeFinding(await apiFetch(`/findings/${id}`, options)), // ── Playbook GET /api/findings/:id/playbook ─────────────────────────────── - getPlaybook: async (id) => { - try { return normalizePlaybook(await apiFetch(`/findings/${id}/playbook`)); } - catch { return { portalSteps: [], cliCommands: [], validationSteps: [], references: [] }; } + getPlaybook: async (id, options = {}) => { + try { return normalizePlaybook(await apiFetch(`/findings/${id}/playbook`, options)); } + catch (err) { + if (isOptionalDataError(err)) { + return { portalSteps: [], cliCommands: [], validationSteps: [], references: [] }; + } + throw err; + } }, // ── Resources (Discovery) GET /api/resources ───────────────────────────── - getResources: async () => normalizeResourcesResponse(await apiFetch('/resources')), - getResourceSummary: async () => { const d = await api.getResources(); return d.summary; }, + getResources: async (options = {}) => normalizeResourcesResponse(await apiFetch('/resources', options)), + getResourceSummary: async (options = {}) => { const d = await api.getResources(options); return d.summary; }, // ── Prioritization GET /api/prioritization ──────────────────────────────── - getPrioritization: async () => normalizePrioritizationResponse(await apiFetch('/prioritization')), - getPriorityMatrix: async () => { const d = await api.getPrioritization(); return d.matrix; }, - getRiskRankings: async () => { const d = await api.getPrioritization(); return d.rankings; }, + getPrioritization: async (options = {}) => normalizePrioritizationResponse(await apiFetch('/prioritization', options)), + getPriorityMatrix: async (options = {}) => { const d = await api.getPrioritization(options); return d.matrix; }, + getRiskRankings: async (options = {}) => { const d = await api.getPrioritization(options); return d.rankings; }, // ── Drift GET /api/drift ────────────────────────────────────────────────── - getDrift: async () => normalizeDriftResponse(await apiFetch('/drift')), - getDriftEvents: async () => { const d = await api.getDrift(); return d.events; }, + getDrift: async (options = {}) => normalizeDriftResponse(await apiFetch('/drift', options)), + getDriftEvents: async (options = {}) => { const d = await api.getDrift(options); return d.events; }, // ── Scans GET /api/scans ────────────────────────────────────────────────── - getScans: async () => normalizeScans(await apiFetch('/scans')), + getScans: async (options = {}) => normalizeScans(await apiFetch('/scans', options)), // ── Trigger scan POST /api/scans/trigger ───────────────────────────────── - triggerScan: async (subscriptionId) => apiFetch('/scans/trigger', { + triggerScan: async (subscriptionId, options = {}) => apiFetch('/scans/trigger', { + ...options, method: 'POST', body: JSON.stringify(subscriptionId ? { subscription_id: subscriptionId } : {}), }), // ── Single scan GET /api/scans/:id (falls back to list) ────────────────── - getScan: async (scanId) => { - try { return await apiFetch(`/scans/${scanId}`); } - catch { - const data = await apiFetch('/scans'); + getScan: async (scanId, options = {}) => { + try { return await apiFetch(`/scans/${scanId}`, options); } + catch (err) { + if (!(err instanceof ApiHttpError)) throw err; + const data = await apiFetch('/scans', options); return normalizeScans(data).scans.find((s) => s.scan_id === scanId) ?? null; } }, // ── Compliance GET /api/compliance/ ──────────────────────────── - getComplianceCIS: async () => apiFetch('/compliance/cis'), - getComplianceNIST: async () => apiFetch('/compliance/nist'), - getComplianceISO27001: async () => apiFetch('/compliance/iso27001'), - getComplianceSOC2: async () => apiFetch('/compliance/soc2'), + getComplianceCIS: async (options = {}) => apiFetch('/compliance/cis', options), + getComplianceNIST: async (options = {}) => apiFetch('/compliance/nist', options), + getComplianceISO27001: async (options = {}) => apiFetch('/compliance/iso27001', options), + getComplianceSOC2: async (options = {}) => apiFetch('/compliance/soc2', options), - getCompliance: async () => { + getCompliance: async (options = {}) => { const [cis, nist, iso, soc2, scansRaw] = await Promise.all([ - apiFetch('/compliance/cis'), - apiFetch('/compliance/nist'), - apiFetch('/compliance/iso27001'), - apiFetch('/compliance/soc2'), - apiFetch('/scans'), + apiFetch('/compliance/cis', options), + apiFetch('/compliance/nist', options), + apiFetch('/compliance/iso27001', options), + apiFetch('/compliance/soc2', options), + apiFetch('/scans', options), ]); return buildComplianceFromFrameworks(cis, nist, iso, soc2, normalizeScans(scansRaw).scans); }, - getFrameworks: async () => { const d = await api.getCompliance(); return d.frameworks; }, + getFrameworks: async (options = {}) => { const d = await api.getCompliance(options); return d.frameworks; }, // ── JWT helpers ──────────────────────────────────────────────────────────── setToken, diff --git a/frontend/src/utils/api.test.mjs b/frontend/src/utils/api.test.mjs new file mode 100644 index 00000000..7d9083ec --- /dev/null +++ b/frontend/src/utils/api.test.mjs @@ -0,0 +1,462 @@ +// Dependency-free request lifecycle tests for api.js. +// Run with: node frontend/src/utils/api.test.mjs + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function loadApiModule({ fetchImpl, timers, token = null } = {}) { + let source = readFileSync(path.join(__dirname, 'api.js'), 'utf8'); + source = source.replace( + "import { normalizeRisk, normalizeSeverity } from './severity.js';", + 'const normalizeRisk = (value) => value; const normalizeSeverity = (value) => value;', + ); + source = source.replace( + /import\.meta\.env\.VITE_API_URL\s*\|\|\s*\(import\.meta\.env\.DEV \? '[^']*' : '[^']*'\)/, + "'http://localhost:5000'", + ); + assert.ok(!source.includes('import.meta'), 'failed to neutralize import.meta usage — test harness is stale'); + source = source.replace(/^export (const|class|async function) /gm, '$1 '); + source = source.replace(/^export default api;$/m, ''); + source += `\nreturn { + api, apiFetch, DEFAULT_REQUEST_TIMEOUT_MS, ApiRequestError, + ApiTimeoutError, ApiCancellationError, ApiHttpError, ApiNetworkError, + };`; + + const localStorageStub = { + getItem: (key) => key === 'jwt_token' ? token : null, + setItem: () => {}, + }; + const load = new Function( + 'localStorage', 'fetch', 'AbortController', 'setTimeout', 'clearTimeout', source, + ); + return load( + localStorageStub, + fetchImpl || (() => Promise.reject(new Error('unexpected fetch'))), + AbortController, + timers?.setTimeout || setTimeout, + timers?.clearTimeout || clearTimeout, + ); +} + +function createTimers() { + const pending = new Map(); + const delays = []; + let nextId = 1; + return { + pending, + delays, + setTimeout(fn, delay) { + const id = nextId++; + pending.set(id, fn); + delays.push(delay); + return id; + }, + clearTimeout(id) { pending.delete(id); }, + runNext() { + const entry = pending.entries().next().value; + assert.ok(entry, 'expected a pending timeout'); + const [id, fn] = entry; + pending.delete(id); + fn(); + }, + }; +} + +function jsonResponse(body, { ok = true, status = 200, statusText = 'OK' } = {}) { + return { ok, status, statusText, json: async () => body }; +} + +function rejectWhenAborted(signal) { + return new Promise((resolve, reject) => { + const rejectAbort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + if (signal.aborted) rejectAbort(); + else signal.addEventListener('abort', rejectAbort, { once: true }); + }); +} + +const tests = []; +function test(description, fn) { tests.push({ description, fn }); } + +test('successful requests return JSON and clear the default timeout', async () => { + const timers = createTimers(); + let requestSignal; + const { apiFetch, DEFAULT_REQUEST_TIMEOUT_MS } = loadApiModule({ + timers, + fetchImpl: async (_url, options) => { + requestSignal = options.signal; + return jsonResponse({ value: 42 }); + }, + }); + assert.deepEqual(await apiFetch('/score'), { value: 42 }); + assert.equal(timers.delays[0], DEFAULT_REQUEST_TIMEOUT_MS); + assert.equal(timers.pending.size, 0); + assert.equal(requestSignal.aborted, false); +}); + +test('non-2xx responses throw an HTTP error with status details', async () => { + const timers = createTimers(); + const { apiFetch, ApiHttpError } = loadApiModule({ + timers, + fetchImpl: async () => jsonResponse(null, { ok: false, status: 503, statusText: 'Unavailable' }), + }); + await assert.rejects(apiFetch('/score'), (err) => { + assert.ok(err instanceof ApiHttpError); + assert.equal(err.code, 'HTTP_ERROR'); + assert.equal(err.status, 503); + return true; + }); + assert.equal(timers.pending.size, 0); +}); + +test('network failures are distinct from HTTP failures', async () => { + const cause = new TypeError('Failed to fetch'); + const { apiFetch, ApiNetworkError } = loadApiModule({ + fetchImpl: async () => { throw cause; }, + }); + await assert.rejects(apiFetch('/score'), (err) => { + assert.ok(err instanceof ApiNetworkError); + assert.equal(err.code, 'NETWORK_ERROR'); + assert.equal(err.cause, cause); + return true; + }); +}); + +test('response parsing errors are not mislabeled as network failures', async () => { + const parseError = new SyntaxError('invalid JSON'); + const { apiFetch, ApiNetworkError } = loadApiModule({ + fetchImpl: async () => ({ + ...jsonResponse(null), + json: async () => { throw parseError; }, + }), + }); + await assert.rejects(apiFetch('/score'), (err) => { + assert.equal(err, parseError); + assert.equal(err instanceof ApiNetworkError, false); + return true; + }); +}); + +test('the timeout aborts fetch and throws a typed timeout error', async () => { + const timers = createTimers(); + const { apiFetch, ApiTimeoutError } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = apiFetch('/score', { timeoutMs: 250 }); + timers.runNext(); + await assert.rejects(request, (err) => { + assert.ok(err instanceof ApiTimeoutError); + assert.equal(err.code, 'TIMEOUT'); + assert.equal(err.timeoutMs, 250); + return true; + }); + assert.equal(timers.pending.size, 0); +}); + +test('caller cancellation is forwarded without being reported as a timeout', async () => { + const timers = createTimers(); + const caller = new AbortController(); + let internalSignal; + const { apiFetch, ApiCancellationError } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => { + internalSignal = signal; + return rejectWhenAborted(signal); + }, + }); + const request = apiFetch('/score', { signal: caller.signal }); + assert.notEqual(internalSignal, caller.signal); + caller.abort(); + await assert.rejects(request, (err) => { + assert.ok(err instanceof ApiCancellationError); + assert.equal(err.code, 'CANCELLED'); + return true; + }); + assert.equal(internalSignal.aborted, true); + assert.equal(timers.pending.size, 0); +}); + +test('an already-aborted caller signal cancels before fetch can proceed', async () => { + const caller = new AbortController(); + caller.abort(); + const { apiFetch, ApiCancellationError } = loadApiModule({ + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + await assert.rejects(apiFetch('/score', { signal: caller.signal }), ApiCancellationError); +}); + +test('public API methods accept operation-specific timeout overrides', async () => { + const timers = createTimers(); + const { api } = loadApiModule({ + timers, + fetchImpl: async () => jsonResponse({ score: 90 }), + }); + assert.deepEqual(await api.getScore({ timeoutMs: 1250 }), { score: 90, max_score: 100 }); + assert.equal(timers.delays[0], 1250); +}); + +test('a completed request cannot be aborted by a stale timer', async () => { + const timers = createTimers(); + let requestSignal; + const { apiFetch } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => { + requestSignal = signal; + return jsonResponse({ done: true }); + }, + }); + await apiFetch('/score', { timeoutMs: 10 }); + assert.equal(timers.pending.size, 0); + assert.equal(requestSignal.aborted, false); +}); + +test('a completed request removes its caller abort listener', async () => { + let activeListeners = 0; + let registeredListener; + const callerSignal = { + aborted: false, + addEventListener(_type, listener) { + registeredListener = listener; + activeListeners++; + }, + removeEventListener(_type, listener) { + if (listener === registeredListener) activeListeners--; + }, + }; + const { apiFetch } = loadApiModule({ + fetchImpl: async () => jsonResponse({ done: true }), + }); + await apiFetch('/score', { signal: callerSignal }); + assert.equal(activeListeners, 0); +}); + +test('timeouts can be disabled explicitly for a caller-managed request', async () => { + const timers = createTimers(); + const { apiFetch } = loadApiModule({ + timers, + fetchImpl: async () => jsonResponse({ done: true }), + }); + await apiFetch('/score', { timeoutMs: null }); + assert.equal(timers.delays.length, 0); +}); + +test('invalid timeout values fail before fetch and remove the caller listener', async () => { + let fetchCalls = 0; + let listeners = 0; + const callerSignal = { + aborted: false, + addEventListener() { listeners++; }, + removeEventListener() { listeners--; }, + }; + const { apiFetch } = loadApiModule({ + fetchImpl: async () => { fetchCalls++; return jsonResponse({}); }, + }); + + await assert.rejects(apiFetch('/score', { timeoutMs: -1, signal: callerSignal }), TypeError); + assert.equal(fetchCalls, 0); + assert.equal(listeners, 0); +}); + +test('the first abort source deterministically wins caller-timeout races', async () => { + { + const timers = createTimers(); + const caller = new AbortController(); + const { apiFetch, ApiCancellationError } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = apiFetch('/score', { signal: caller.signal, timeoutMs: 10 }); + caller.abort(); + timers.runNext(); + await assert.rejects(request, ApiCancellationError); + } + + { + const timers = createTimers(); + const caller = new AbortController(); + const { apiFetch, ApiTimeoutError } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = apiFetch('/score', { signal: caller.signal, timeoutMs: 10 }); + timers.runNext(); + caller.abort(); + await assert.rejects(request, ApiTimeoutError); + } +}); + +test('custom headers are preserved alongside authentication and JSON headers', async () => { + let requestOptions; + const { api } = loadApiModule({ + token: 'token-123', + fetchImpl: async (_url, options) => { + requestOptions = options; + return jsonResponse({ score: 88 }); + }, + }); + + await api.getScore({ headers: { 'X-Request-ID': 'request-1' } }); + assert.equal(requestOptions.headers.Authorization, 'Bearer token-123'); + assert.equal(requestOptions.headers['Content-Type'], 'application/json'); + assert.equal(requestOptions.headers['X-Request-ID'], 'request-1'); +}); + +test('getScan falls back to the scan list after an HTTP compatibility failure', async () => { + const urls = []; + const { api } = loadApiModule({ + fetchImpl: async (url) => { + urls.push(url); + if (urls.length === 1) return jsonResponse(null, { ok: false, status: 404, statusText: 'Not Found' }); + return jsonResponse({ scans: [{ scan_id: 'scan-1', status: 'running' }] }); + }, + }); + + assert.deepEqual(await api.getScan('scan-1'), { scan_id: 'scan-1', status: 'running' }); + assert.equal(urls.length, 2); + assert.match(urls[0], /\/scans\/scan-1$/); + assert.match(urls[1], /\/scans$/); +}); + +test('getScan propagates network failures without launching a second request', async () => { + let calls = 0; + const { api, ApiNetworkError } = loadApiModule({ + fetchImpl: async () => { calls++; throw new TypeError('offline'); }, + }); + + await assert.rejects(api.getScan('scan-1'), ApiNetworkError); + assert.equal(calls, 1); +}); + +test('getScan propagates timeouts without launching a second request', async () => { + const timers = createTimers(); + let calls = 0; + const { api, ApiTimeoutError } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => { calls++; return rejectWhenAborted(signal); }, + }); + const request = api.getScan('scan-1', { timeoutMs: 50 }); + timers.runNext(); + + await assert.rejects(request, ApiTimeoutError); + assert.equal(calls, 1); +}); + +test('getScan propagates explicit caller cancellation', async () => { + const caller = new AbortController(); + const { api, ApiCancellationError } = loadApiModule({ + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = api.getScan('scan-1', { signal: caller.signal }); + caller.abort(); + await assert.rejects(request, ApiCancellationError); +}); + +for (const [label, response] of [ + ['HTTP', jsonResponse(null, { ok: false, status: 503, statusText: 'Unavailable' })], + ['network', new TypeError('offline')], +]) { + test(`getPlaybook returns empty optional data after a ${label} failure`, async () => { + const { api } = loadApiModule({ + fetchImpl: async () => { + if (response instanceof Error) throw response; + return response; + }, + }); + assert.deepEqual(await api.getPlaybook('finding-1'), { + portalSteps: [], cliCommands: [], validationSteps: [], references: [], + }); + }); +} + +test('getPlaybook returns empty optional data after a timeout', async () => { + const timers = createTimers(); + const { api } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = api.getPlaybook('finding-1', { timeoutMs: 20 }); + timers.runNext(); + assert.deepEqual(await request, { + portalSteps: [], cliCommands: [], validationSteps: [], references: [], + }); +}); + +test('getPlaybook propagates explicit caller cancellation', async () => { + const caller = new AbortController(); + const { api, ApiCancellationError } = loadApiModule({ + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = api.getPlaybook('finding-1', { signal: caller.signal }); + caller.abort(); + await assert.rejects(request, ApiCancellationError); +}); + +test('getCVESummary falls back for HTTP, network, and timeout failures', async () => { + for (const failure of ['http', 'network', 'timeout']) { + const timers = createTimers(); + const { api } = loadApiModule({ + timers, + fetchImpl: async (_url, { signal }) => { + if (failure === 'http') return jsonResponse(null, { ok: false, status: 404, statusText: 'Not Found' }); + if (failure === 'network') throw new TypeError('offline'); + return rejectWhenAborted(signal); + }, + }); + const request = api.getCVESummary({ timeoutMs: 20 }); + if (failure === 'timeout') timers.runNext(); + assert.equal(await request, null); + } +}); + +test('getCVESummary propagates explicit caller cancellation', async () => { + const caller = new AbortController(); + const { api, ApiCancellationError } = loadApiModule({ + fetchImpl: async (_url, { signal }) => rejectWhenAborted(signal), + }); + const request = api.getCVESummary({ signal: caller.signal }); + caller.abort(); + await assert.rejects(request, ApiCancellationError); +}); + +test('triggerScan is attempted once and options cannot override its POST body', async () => { + let calls = 0; + let requestOptions; + const { api, ApiNetworkError } = loadApiModule({ + fetchImpl: async (_url, options) => { + calls++; + requestOptions = options; + throw new TypeError('offline'); + }, + }); + + await assert.rejects(api.triggerScan('sub-1', { + method: 'GET', + body: 'wrong', + headers: { 'X-Request-ID': 'request-1' }, + }), ApiNetworkError); + assert.equal(calls, 1); + assert.equal(requestOptions.method, 'POST'); + assert.equal(requestOptions.body, JSON.stringify({ subscription_id: 'sub-1' })); + assert.equal(requestOptions.headers['X-Request-ID'], 'request-1'); +}); + +let failures = 0; +for (const { description, fn } of tests) { + try { + await fn(); + console.log(`PASS: ${description}`); + } catch (err) { + failures++; + console.error(`FAIL: ${description}\n ${err.stack || err.message}`); + } +} + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log(`\nAll ${tests.length} API request tests passed`); diff --git a/frontend/src/utils/scanPolling.js b/frontend/src/utils/scanPolling.js new file mode 100644 index 00000000..ce53cda2 --- /dev/null +++ b/frontend/src/utils/scanPolling.js @@ -0,0 +1,25 @@ +const TRANSIENT_ERROR_CODES = new Set(['NETWORK_ERROR', 'TIMEOUT']); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function pollScan({ + scanId, + getScan, + requestOptions = {}, + wait = delay, + intervalMs = 4000, + maxAttempts = 75, +}) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await wait(intervalMs); + + try { + const scan = await getScan(scanId, requestOptions); + if (scan?.status === 'completed' || scan?.status === 'failed') return scan; + } catch (err) { + if (!TRANSIENT_ERROR_CODES.has(err?.code)) throw err; + } + } + + return null; +} diff --git a/frontend/src/utils/scanPolling.test.mjs b/frontend/src/utils/scanPolling.test.mjs new file mode 100644 index 00000000..caef3e1a --- /dev/null +++ b/frontend/src/utils/scanPolling.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { pollScan } from './scanPolling.js'; + +const noWait = async () => {}; + +const tests = []; +function test(description, fn) { tests.push({ description, fn }); } + +test('transient network and timeout errors do not terminate a healthy polling sequence', async () => { + const outcomes = [ + Object.assign(new Error('offline'), { code: 'NETWORK_ERROR' }), + { scan_id: 'scan-1', status: 'running' }, + Object.assign(new Error('slow'), { code: 'TIMEOUT' }), + { scan_id: 'scan-1', status: 'completed', total_findings: 3 }, + ]; + let calls = 0; + + const result = await pollScan({ + scanId: 'scan-1', + getScan: async () => { + const outcome = outcomes[calls++]; + if (outcome instanceof Error) throw outcome; + return outcome; + }, + wait: noWait, + maxAttempts: outcomes.length, + }); + + assert.equal(calls, 4); + assert.equal(result.status, 'completed'); + assert.equal(result.total_findings, 3); +}); + +test('explicit caller cancellation stops polling immediately', async () => { + const controller = new AbortController(); + let calls = 0; + let markStarted; + const started = new Promise((resolve) => { markStarted = resolve; }); + + const polling = pollScan({ + scanId: 'scan-1', + requestOptions: { signal: controller.signal }, + getScan: async (_scanId, { signal }) => { + calls++; + markStarted(); + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject(Object.assign(new Error('cancelled'), { code: 'CANCELLED' })); + }, { once: true }); + }); + }, + wait: noWait, + }); + + await started; + controller.abort(); + await assert.rejects(polling, (err) => err.code === 'CANCELLED'); + + assert.equal(calls, 1); +}); + +test('a real backend failed status is returned as terminal state', async () => { + const failed = { scan_id: 'scan-1', status: 'failed' }; + const result = await pollScan({ + scanId: 'scan-1', + getScan: async () => failed, + wait: noWait, + }); + + assert.equal(result, failed); +}); + +test('polling returns null after the configured attempt limit', async () => { + let calls = 0; + const result = await pollScan({ + scanId: 'scan-1', + getScan: async () => { calls++; return { scan_id: 'scan-1', status: 'running' }; }, + wait: noWait, + maxAttempts: 3, + }); + + assert.equal(calls, 3); + assert.equal(result, null); +}); + +let failures = 0; +for (const { description, fn } of tests) { + try { + await fn(); + console.log(`PASS: ${description}`); + } catch (err) { + failures++; + console.error(`FAIL: ${description}\n ${err.stack || err.message}`); + } +} + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log(`\nAll ${tests.length} scan polling tests passed`);