Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions frontend/src/components/layout/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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?' });
Expand Down
195 changes: 149 additions & 46 deletions frontend/src/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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: [] };
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same narrowing as getScan below. DetailedScan.jsx's selectFinding calls await api.getPlaybook(f.id) with no try/catch, on both mount and click, so a timeout/network error here becomes an unhandled promise rejection with no fallback UI - actually worse than the pre-PR behavior (which returned the empty-arrays fallback) for exactly the failure modes this PR targets.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be a bare catch { ... } that fell back to listing scans on any failure. Now it only falls back on ApiHttpError and rethrows everything else - including the new ApiTimeoutError/ApiNetworkError.

Header.jsx's executeScan polls this in a loop (for (let i = 0; i < 75; i++) { ...; const scan = await api.getScan(scanId); ... }, ~5 minutes at 4s intervals) with a single try/catch around the whole loop. A single transient network blip or timeout on any one poll now throws straight out of the loop and ends polling entirely - the user gets a "Scan failed" toast even though the backend scan is still running to completion. Before this PR there was no timeout and any transient error fell back to /scans and let the loop continue.

Given getScan doesn't override timeoutMs, it also now inherits the new default 30s timeout per call, so this isn't just a network-blip edge case - a single slow response during the 5-minute poll is enough to trigger it.

const data = await apiFetch('/scans', options);
return normalizeScans(data).scans.find((s) => s.scan_id === scanId) ?? null;
}
},

// ── Compliance GET /api/compliance/<framework> ────────────────────────────
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,
Expand Down
Loading
Loading