From 9d21df251e50ca5fbf5c0e34783cc9f66114951c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 08:33:36 +0100 Subject: [PATCH] fix: clear changed gate regressions --- extensions/kilocode/provider-models.test.ts | 44 +++++++++++-- .../src/providers/twilio/api.test.ts | 61 +++++++++++++------ .../voice-call/src/providers/twilio/api.ts | 3 +- 3 files changed, 81 insertions(+), 27 deletions(-) diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index d7c3ca25680..0f3e91d88b0 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -1,6 +1,27 @@ import { describe, expect, it, vi } from "vitest"; import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js"; +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +type MockKilocodeFetchResponse = { + ok: boolean; + status?: number; + json?: () => Promise; +}; + +type MockKilocodeFetch = (( + url: string, + init?: RequestInit, +) => Promise) & { + mock: { calls: unknown[][] }; +}; + function makeGatewayModel(overrides: Record = {}) { return { id: "anthropic/claude-sonnet-4", @@ -51,20 +72,24 @@ function makeAutoModel(overrides: Record = {}) { }); } -async function withFetchPathTest( - mockFetch: ReturnType, - runAssertions: () => Promise, -) { +async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise) { const origNodeEnv = process.env.NODE_ENV; const origVitest = process.env.VITEST; + const release = vi.fn(async () => {}); delete process.env.NODE_ENV; delete process.env.VITEST; - vi.stubGlobal("fetch", mockFetch); + fetchWithSsrFGuardMock.mockImplementation( + async (params: { url: string; init?: RequestInit }) => ({ + response: await mockFetch(params.url, params.init), + release, + }), + ); try { await runAssertions(); } finally { + fetchWithSsrFGuardMock.mockReset(); if (origNodeEnv === undefined) { delete process.env.NODE_ENV; } else { @@ -75,7 +100,6 @@ async function withFetchPathTest( } else { process.env.VITEST = origVitest; } - vi.unstubAllGlobals(); } } @@ -111,6 +135,14 @@ describe("discoverKilocodeModels (fetch path)", () => { await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: KILOCODE_MODELS_URL, + init: expect.objectContaining({ + headers: { Accept: "application/json" }, + }), + }), + ); expect(mockFetch).toHaveBeenCalledWith( KILOCODE_MODELS_URL, expect.objectContaining({ diff --git a/extensions/voice-call/src/providers/twilio/api.test.ts b/extensions/voice-call/src/providers/twilio/api.test.ts index 33efc4a5cfb..28323227c72 100644 --- a/extensions/voice-call/src/providers/twilio/api.test.ts +++ b/extensions/voice-call/src/providers/twilio/api.test.ts @@ -1,17 +1,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { TwilioApiError, twilioApiRequest } from "./api.js"; -const originalFetch = globalThis.fetch; +const apiMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: apiMocks.fetchWithSsrFGuard, +})); describe("twilioApiRequest", () => { afterEach(() => { - globalThis.fetch = originalFetch; + apiMocks.fetchWithSsrFGuard.mockReset(); }); it("posts form bodies with basic auth and parses json", async () => { - globalThis.fetch = vi.fn(async () => { - return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }); - }) as unknown as typeof fetch; + const release = vi.fn(async () => {}); + apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }), + release, + }); await expect( twilioApiRequest({ @@ -26,8 +34,10 @@ describe("twilioApiRequest", () => { }), ).resolves.toEqual({ sid: "CA123" }); - const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] ?? []; + const [{ url, init, auditContext, policy }] = apiMocks.fetchWithSsrFGuard.mock.calls[0] ?? []; expect(url).toBe("https://api.twilio.com/Calls.json"); + expect(auditContext).toBe("voice-call.twilio.api"); + expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] }); expect(init).toEqual( expect.objectContaining({ method: "POST", @@ -44,6 +54,7 @@ describe("twilioApiRequest", () => { expect(requestBody.toString()).toBe( "To=%2B14155550123&StatusCallbackEvent=initiated&StatusCallbackEvent=completed", ); + expect(release).toHaveBeenCalledTimes(1); }); it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => { @@ -51,7 +62,11 @@ describe("twilioApiRequest", () => { new Response(null, { status: 204 }), new Response("missing", { status: 404 }), ]; - globalThis.fetch = vi.fn(async () => responses.shift()!) as unknown as typeof fetch; + const release = vi.fn(async () => {}); + apiMocks.fetchWithSsrFGuard.mockImplementation(async () => ({ + response: responses.shift()!, + release, + })); await expect( twilioApiRequest({ @@ -73,12 +88,15 @@ describe("twilioApiRequest", () => { allowNotFound: true, }), ).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledTimes(2); }); it("throws twilio api errors for non-ok responses", async () => { - globalThis.fetch = vi.fn( - async () => new Response("bad request", { status: 400 }), - ) as unknown as typeof fetch; + const release = vi.fn(async () => {}); + apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response("bad request", { status: 400 }), + release, + }); await expect( twilioApiRequest({ @@ -89,19 +107,21 @@ describe("twilioApiRequest", () => { body: {}, }), ).rejects.toThrow("Twilio API error: 400 bad request"); + expect(release).toHaveBeenCalledTimes(1); }); it("exposes structured Twilio error codes from json error bodies", async () => { - globalThis.fetch = vi.fn( - async () => - new Response( - JSON.stringify({ - code: 21220, - message: "Call is not in-progress. Cannot redirect.", - }), - { status: 400 }, - ), - ) as unknown as typeof fetch; + const release = vi.fn(async () => {}); + apiMocks.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response( + JSON.stringify({ + code: 21220, + message: "Call is not in-progress. Cannot redirect.", + }), + { status: 400 }, + ), + release, + }); await expect( twilioApiRequest({ @@ -117,5 +137,6 @@ describe("twilioApiRequest", () => { twilioCode: 21220, message: "Twilio API error: 400 Call is not in-progress. Cannot redirect.", } satisfies Partial); + expect(release).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/voice-call/src/providers/twilio/api.ts b/extensions/voice-call/src/providers/twilio/api.ts index e2a93fd938c..9ba9a3c98c9 100644 --- a/extensions/voice-call/src/providers/twilio/api.ts +++ b/extensions/voice-call/src/providers/twilio/api.ts @@ -71,8 +71,9 @@ export async function twilioApiRequest(params: { }, body: bodyParams, }, + policy: { allowedHostnames: ["api.twilio.com"] }, timeoutMs: TWILIO_API_TIMEOUT_MS, - auditContext: "voice-call.twilio_api", + auditContext: "voice-call.twilio.api", }); try { if (!response.ok) {