From fd899a378820057a073f6d96fa1d170661979823 Mon Sep 17 00:00:00 2001 From: philipp Date: Mon, 18 May 2026 13:45:01 +0200 Subject: [PATCH] Fix dashboard auth JSON handling Proof: npm test (212/212) and npm run operator-dashboard:build cover non-JSON auth failures and rebuilt the dashboard bundle. Assumptions: browser auth failures may return plain text before a session cookie is established; API callers should receive JSON errors. Still fake: dashboard quote outcomes still depend on inventory-delta attribution instead of venue-native terminal fill events. --- src/apps/operator-dashboard.mjs | 3 ++ src/operator-dashboard/static/lib/api.js | 24 +++++++++++++-- test/operator-dashboard-api-client.test.mjs | 33 +++++++++++++++++++++ test/operator-dashboard-app-static.test.mjs | 5 ++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 7c5866c..4f1ac8c 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -730,6 +730,9 @@ function authenticateHttpRequest(req, res) { res.setHeader('WWW-Authenticate', buildDashboardAuthChallengeHeader({ realm: config.operatorDashboardAuthRealm, })); + if ((req.url || '').startsWith('/api/')) { + return sendJson(res, 401, { error: 'authentication_required' }); + } res.end('authentication required\n'); return null; } diff --git a/src/operator-dashboard/static/lib/api.js b/src/operator-dashboard/static/lib/api.js index 8edca32..9580a9c 100644 --- a/src/operator-dashboard/static/lib/api.js +++ b/src/operator-dashboard/static/lib/api.js @@ -12,10 +12,13 @@ export async function fetchJson(url, options = {}) { try { const response = await fetch(url, fetchOptions); const text = await response.text(); - const data = text ? JSON.parse(text) : null; + const data = parseJsonResponse(text, response); if (!response.ok) { - throw new Error(data?.error || `HTTP ${response.status}`); + const message = data?.error || text.trim() || `HTTP ${response.status}`; + throw new Error(message.startsWith(`HTTP ${response.status}`) + ? message + : `HTTP ${response.status}: ${message}`); } return data; @@ -28,3 +31,20 @@ export async function fetchJson(url, options = {}) { if (timeout) globalThis.clearTimeout(timeout); } } + +function parseJsonResponse(text, response) { + if (!text) return null; + const trimmed = text.trim(); + if (!trimmed) return null; + + const contentType = response.headers.get('content-type') || ''; + const looksJson = contentType.includes('json') || /^[{[]/.test(trimmed); + if (!looksJson) return null; + + try { + return JSON.parse(text); + } catch (error) { + if (!response.ok) return null; + throw new Error(`Invalid JSON response from ${response.url || 'dashboard API'}: ${error.message}`); + } +} diff --git a/test/operator-dashboard-api-client.test.mjs b/test/operator-dashboard-api-client.test.mjs index fb3380f..08f83e4 100644 --- a/test/operator-dashboard-api-client.test.mjs +++ b/test/operator-dashboard-api-client.test.mjs @@ -17,6 +17,39 @@ test('dashboard fetch helper reports empty upstream 500 responses', async (t) => ); }); +test('dashboard fetch helper reports plain-text auth failures without leaking JSON parse errors', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = async () => new Response('authentication required\n', { status: 401 }); + + await assert.rejects( + fetchJson('/api/session', { timeoutMs: 0 }), + /HTTP 401: authentication required/, + ); +}); + +test('dashboard fetch helper reports invalid successful JSON responses explicitly', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = async () => new Response('', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + await assert.rejects( + fetchJson('/api/session', { timeoutMs: 0 }), + /Invalid JSON response/, + ); +}); + test('dashboard fetch helper times out stale port-forward requests', async (t) => { const originalFetch = globalThis.fetch; t.after(() => { diff --git a/test/operator-dashboard-app-static.test.mjs b/test/operator-dashboard-app-static.test.mjs index be5ce2c..b2ebe4d 100644 --- a/test/operator-dashboard-app-static.test.mjs +++ b/test/operator-dashboard-app-static.test.mjs @@ -28,3 +28,8 @@ test('operator dashboard exposes DB-backed pair activation and pause controls', assert.match(source, /edgeBps: body\.edge_bps/); assert.match(source, /maxNotional: body\.max_notional/); }); + +test('operator dashboard API auth failures are JSON for frontend fetches', () => { + assert.match(source, /req\.url \|\| ''\)\.startsWith\('\/api\/'\)/); + assert.match(source, /sendJson\(res, 401, \{ error: 'authentication_required' \}\)/); +});