diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts index 5b4b1c3eec27..73f8267b0e7e 100644 --- a/extensions/qa-lab/web/src/app.ts +++ b/extensions/qa-lab/web/src/app.ts @@ -2,6 +2,7 @@ import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js"; import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture-saved-view.js"; import { formatErrorMessage } from "./errors.js"; +import { getJson, getJsonNoStore, postJson } from "./http.js"; import { type Bootstrap, type EvidenceEnvelope, @@ -19,34 +20,6 @@ import { type UiState, renderQaLabUi, } from "./ui-render.js"; -async function getJson(path: string): Promise { - const response = await fetch(path); - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); - } - return (await response.json()) as T; -} - -async function getJsonNoStore(path: string): Promise { - const response = await fetch(path, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); - } - return (await response.json()) as T; -} - -async function postJson(path: string, body: unknown): Promise { - const response = await fetch(path, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const payload = (await response.json().catch(() => ({}))) as { error?: string }; - throw new Error(payload.error || `${response.status} ${response.statusText}`); - } - return (await response.json()) as T; -} function countCaptureDimension( events: UiState["captureEvents"], diff --git a/extensions/qa-lab/web/src/http.test.ts b/extensions/qa-lab/web/src/http.test.ts new file mode 100644 index 000000000000..90a9f4e3fc74 --- /dev/null +++ b/extensions/qa-lab/web/src/http.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getJson, getJsonNoStore, postJson } from "./http.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("QA Lab dashboard HTTP", () => { + it("gives every API request a fresh 30 second deadline", async () => { + const controllers = [new AbortController(), new AbortController(), new AbortController()]; + const timeout = vi.spyOn(AbortSignal, "timeout").mockImplementation((timeoutMs) => { + expect(timeoutMs).toBe(30_000); + const controller = controllers.shift(); + if (!controller) { + throw new Error("unexpected timeout signal request"); + } + return controller.signal; + }); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await getJson("/api/bootstrap"); + await getJsonNoStore("/api/snapshot"); + await postJson("/api/runner/start", { scenario: "baseline" }); + + expect(timeout).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/bootstrap", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/snapshot", + expect.objectContaining({ cache: "no-store", signal: expect.any(AbortSignal) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/api/runner/start", + expect.objectContaining({ method: "POST", signal: expect.any(AbortSignal) }), + ); + }); + + it("rejects a stalled request when its deadline aborts", async () => { + const timeoutController = new AbortController(); + vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + vi.stubGlobal( + "fetch", + vi.fn(async (_input, init) => { + const signal = init?.signal; + if (!signal) { + throw new Error("missing request signal"); + } + return await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + // Fetch rejects with the exact abort reason. DOM types define it as an Error, + // although jsdom does not preserve that prototype relationship at runtime. + reject(signal.reason as Error); + }, + { once: true }, + ); + }); + }), + ); + + const request = getJson("/api/stalled"); + timeoutController.abort(new DOMException("request deadline exceeded", "TimeoutError")); + + await expect(request).rejects.toMatchObject({ name: "TimeoutError" }); + }); +}); diff --git a/extensions/qa-lab/web/src/http.ts b/extensions/qa-lab/web/src/http.ts new file mode 100644 index 000000000000..7914fbfc9a73 --- /dev/null +++ b/extensions/qa-lab/web/src/http.ts @@ -0,0 +1,38 @@ +const QA_LAB_API_REQUEST_TIMEOUT_MS = 30_000; + +function createRequestSignal(): AbortSignal { + return AbortSignal.timeout(QA_LAB_API_REQUEST_TIMEOUT_MS); +} + +export async function getJson(path: string): Promise { + const response = await fetch(path, { signal: createRequestSignal() }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return (await response.json()) as T; +} + +export async function getJsonNoStore(path: string): Promise { + const response = await fetch(path, { + cache: "no-store", + signal: createRequestSignal(), + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return (await response.json()) as T; +} + +export async function postJson(path: string, body: unknown): Promise { + const response = await fetch(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: createRequestSignal(), + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(payload.error || `${response.status} ${response.statusText}`); + } + return (await response.json()) as T; +} diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index c4e2549e9500..96a9568f54a6 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -48,9 +48,10 @@ const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 489), bundledPluginCallsite("qa-lab", "src/suite.ts", 330), bundledPluginCallsite("qa-lab", "src/suite.ts", 341), - bundledPluginCallsite("qa-lab", "web/src/app.ts", 23), - bundledPluginCallsite("qa-lab", "web/src/app.ts", 31), - bundledPluginCallsite("qa-lab", "web/src/app.ts", 39), + // The QA dashboard calls its same-origin local API from the browser, where server SSRF helpers do not run. + bundledPluginCallsite("qa-lab", "web/src/http.ts", 8), + bundledPluginCallsite("qa-lab", "web/src/http.ts", 16), + bundledPluginCallsite("qa-lab", "web/src/http.ts", 27), bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124), bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 554), bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211),