From a694272b40ac8f393f2412e4e9f9db2e8d579ed3 Mon Sep 17 00:00:00 2001 From: Robert Wojciechowski Date: Sun, 1 Mar 2026 22:35:30 +0000 Subject: [PATCH] Harden worker probe response validation and add tests --- web/apiClient.js | 25 +++++++++- web/tests/apiClient.test.js | 95 +++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 web/tests/apiClient.test.js diff --git a/web/apiClient.js b/web/apiClient.js index a731ff7..bda5895 100644 --- a/web/apiClient.js +++ b/web/apiClient.js @@ -178,11 +178,32 @@ export function createApiClient(baseUrl) { return { ok: false, status: response.status, queueRemaining: null }; } - const data = await response.json().catch(() => ({})); + let data; + try { + data = await response.json(); + } catch { + return { ok: false, status: response.status, queueRemaining: null }; + } + + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { ok: false, status: response.status, queueRemaining: null }; + } + + const execInfo = data.exec_info; + if (!execInfo || typeof execInfo !== "object" || Array.isArray(execInfo)) { + return { ok: false, status: response.status, queueRemaining: null }; + } + + const rawQueueRemaining = execInfo.queue_remaining; + const queueRemaining = Number(rawQueueRemaining); + if (!Number.isFinite(queueRemaining)) { + return { ok: false, status: response.status, queueRemaining: null }; + } + return { ok: true, status: response.status, - queueRemaining: data.exec_info?.queue_remaining || 0, + queueRemaining: Math.max(0, queueRemaining), }; } finally { clearTimeout(timeoutId); diff --git a/web/tests/apiClient.test.js b/web/tests/apiClient.test.js new file mode 100644 index 0000000..ad202ef --- /dev/null +++ b/web/tests/apiClient.test.js @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createApiClient } from "../apiClient.js"; + +describe("apiClient probeWorker", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns ok=true when /prompt returns valid exec_info payload", async () => { + globalThis.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ exec_info: { queue_remaining: 2 } }), + }); + + const client = createApiClient("http://127.0.0.1:8188"); + const result = await client.probeWorker("http://worker.local:8190", 1000); + + expect(result).toEqual({ ok: true, status: 200, queueRemaining: 2 }); + }); + + it("returns ok=false on non-200 responses", async () => { + globalThis.fetch.mockResolvedValue({ + ok: false, + status: 503, + json: vi.fn(), + }); + + const client = createApiClient("http://127.0.0.1:8188"); + const result = await client.probeWorker("http://worker.local:8190", 1000); + + expect(result).toEqual({ ok: false, status: 503, queueRemaining: null }); + }); + + it("returns ok=false when response JSON is invalid", async () => { + globalThis.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockRejectedValue(new Error("invalid json")), + }); + + const client = createApiClient("http://127.0.0.1:8188"); + const result = await client.probeWorker("http://worker.local:8190", 1000); + + expect(result).toEqual({ ok: false, status: 200, queueRemaining: null }); + }); + + it("returns ok=false when exec_info is missing", async () => { + globalThis.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({}), + }); + + const client = createApiClient("http://127.0.0.1:8188"); + const result = await client.probeWorker("http://worker.local:8190", 1000); + + expect(result).toEqual({ ok: false, status: 200, queueRemaining: null }); + }); + + it("returns ok=false when queue_remaining is not numeric", async () => { + globalThis.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ exec_info: { queue_remaining: "n/a" } }), + }); + + const client = createApiClient("http://127.0.0.1:8188"); + const result = await client.probeWorker("http://worker.local:8190", 1000); + + expect(result).toEqual({ ok: false, status: 200, queueRemaining: null }); + }); + + it("clamps negative queue_remaining to zero", async () => { + globalThis.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ exec_info: { queue_remaining: -5 } }), + }); + + const client = createApiClient("http://127.0.0.1:8188"); + const result = await client.probeWorker("http://worker.local:8190", 1000); + + expect(result).toEqual({ ok: true, status: 200, queueRemaining: 0 }); + }); +});