diff --git a/extensions/browser/src/browser/cdp.internal.test.ts b/extensions/browser/src/browser/cdp.internal.test.ts index 4eb3bb3c2fd..1d21e8f07bd 100644 --- a/extensions/browser/src/browser/cdp.internal.test.ts +++ b/extensions/browser/src/browser/cdp.internal.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { type WebSocket, WebSocketServer } from "ws"; import { rawDataToString } from "../infra/ws.js"; +import "../test-support/browser-security.mock.js"; import { type AriaSnapshotNode, captureScreenshot, diff --git a/extensions/browser/src/browser/routes/agent.shared.test.ts b/extensions/browser/src/browser/routes/agent.shared.test.ts index 8c8a84d5844..a101aaf30e5 100644 --- a/extensions/browser/src/browser/routes/agent.shared.test.ts +++ b/extensions/browser/src/browser/routes/agent.shared.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import "../../test-support/browser-security.mock.js"; import { readBody, resolveSafeRouteTabUrl, diff --git a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts index b00e329e33e..e0c3ffca357 100644 --- a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts +++ b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import "../../../test-support.js"; import "../server-context.chrome-test-harness.js"; +import "../../test-support/browser-security.mock.js"; import * as chromeModule from "../chrome.js"; import { createBrowserRouteContext } from "../server-context.js"; import { makeBrowserServerState } from "../server-context.test-harness.js"; diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index 2ea550fa203..304a79141c5 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withBrowserFetchPreconnect } from "../../test-fetch.js"; +import "../test-support/browser-security.mock.js"; vi.hoisted(() => { vi.resetModules(); diff --git a/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts b/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts index 67d6fd759da..56ffb6fb440 100644 --- a/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts +++ b/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getFreePort } from "./test-port.js"; import { getBrowserTestFetch } from "./test-support/fetch.js"; +import "../test-support/browser-security.mock.js"; let testPort = 0; let prevGatewayPort: string | undefined; diff --git a/extensions/browser/src/test-support/browser-security.mock.ts b/extensions/browser/src/test-support/browser-security.mock.ts index e62e58e1fb4..cb458ea6391 100644 --- a/extensions/browser/src/test-support/browser-security.mock.ts +++ b/extensions/browser/src/test-support/browser-security.mock.ts @@ -1,13 +1,24 @@ import { vi } from "vitest"; -vi.mock("../sdk-security-runtime.js", async () => { - const actual = await vi.importActual( - "../sdk-security-runtime.js", - ); - const lookupFn = async (_hostname: string, options?: { all?: boolean }) => { - const result = { address: "93.184.216.34", family: 4 }; - return options?.all === true ? [result] : result; - }; +const lookupFn = vi.hoisted(() => async (_hostname: string, options?: { all?: boolean }) => { + const result = { address: "93.184.216.34", family: 4 }; + return options?.all === true ? [result] : result; +}); + +vi.mock("../infra/net/ssrf.js", async () => { + const actual = + await vi.importActual("../infra/net/ssrf.js"); + return { + ...actual, + resolvePinnedHostnameWithPolicy: (hostname: string, params: object = {}) => + actual.resolvePinnedHostnameWithPolicy(hostname, { ...params, lookupFn: lookupFn as never }), + }; +}); + +vi.mock("../sdk-security-runtime.js", async () => { + const actual = await vi.importActual( + "../sdk-security-runtime.js", + ); return { ...actual, resolvePinnedHostnameWithPolicy: (hostname: string, params: object = {}) => diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 5c08a39a15a..aaed51099f4 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1728,7 +1728,7 @@ describe("processDiscordMessage draft streaming", () => { mode: "progress", progress: { label: "Clawing...", - maxLines: 3, + maxLines: 4, }, }, }, @@ -1736,8 +1736,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenNthCalledWith(1, "Clawing...\n🧩 First\n🧩 Second"); - expect(draftStream.update).toHaveBeenNthCalledWith(2, "🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { diff --git a/extensions/elevenlabs/media-understanding-provider.test.ts b/extensions/elevenlabs/media-understanding-provider.test.ts index f33b2a7671e..4897bea084e 100644 --- a/extensions/elevenlabs/media-understanding-provider.test.ts +++ b/extensions/elevenlabs/media-understanding-provider.test.ts @@ -1,10 +1,22 @@ -import { describe, expect, it, vi } from "vitest"; +import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { elevenLabsMediaUnderstandingProvider, transcribeElevenLabsAudio, } from "./media-understanding-provider.js"; describe("elevenLabsMediaUnderstandingProvider", () => { + let ssrfMock: { mockRestore: () => void } | undefined; + + beforeEach(() => { + ssrfMock = mockPinnedHostnameResolution(); + }); + + afterEach(() => { + ssrfMock?.mockRestore(); + ssrfMock = undefined; + }); + it("has expected provider metadata", () => { expect(elevenLabsMediaUnderstandingProvider.id).toBe("elevenlabs"); expect(elevenLabsMediaUnderstandingProvider.capabilities).toEqual(["audio"]); diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index fc0232079c8..e4a340bfbd9 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -1,9 +1,12 @@ import * as providerAuthRuntime from "openclaw/plugin-sdk/provider-auth-runtime"; import * as providerHttp from "openclaw/plugin-sdk/provider-http"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js"; import { __testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js"; +let ssrfMock: { mockRestore: () => void } | undefined; + function mockGoogleApiKeyAuth() { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-test-key", @@ -75,7 +78,13 @@ function postJsonRequestOptions(spy: unknown): { } describe("Google image-generation provider", () => { + beforeEach(() => { + ssrfMock = mockPinnedHostnameResolution(); + }); + afterEach(() => { + ssrfMock?.mockRestore(); + ssrfMock = undefined; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index ceb5b2b7d22..a20baa933f7 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -1,6 +1,7 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } = vi.hoisted(() => { @@ -54,8 +55,16 @@ function firstGoogleClientHttpOptions(): Record { return recordField(firstObjectArg(createGoogleGenAIMock).httpOptions, "httpOptions"); } +let ssrfMock: { mockRestore: () => void } | undefined; + describe("google video generation provider", () => { + beforeEach(() => { + ssrfMock = mockPinnedHostnameResolution(); + }); + afterEach(() => { + ssrfMock?.mockRestore(); + ssrfMock = undefined; vi.restoreAllMocks(); vi.unstubAllGlobals(); downloadMock.mockReset(); diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts index 0b678e7b236..471d33ea2d5 100644 --- a/extensions/line/src/channel.sendPayload.test.ts +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -2,7 +2,7 @@ import { verifyChannelMessageAdapterCapabilityProofs, verifyChannelMessageReceiveAckPolicyAdapterProofs, } from "openclaw/plugin-sdk/channel-message"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime } from "../api.js"; import { linePlugin } from "./channel.js"; import { lineConfigAdapter } from "./config-adapter.js"; @@ -11,6 +11,19 @@ import { lineOutboundAdapter } from "./outbound.js"; import { setLineRuntime } from "./runtime.js"; import { createLineSendReceipt } from "./send-receipt.js"; +const ssrfMocks = vi.hoisted(() => ({ + resolvePinnedHostnameWithPolicy: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy, +})); + +afterAll(() => { + vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); + vi.resetModules(); +}); + type LineRuntimeMocks = { pushMessageLine: ReturnType; pushMessagesLine: ReturnType; @@ -26,6 +39,14 @@ type LineRuntimeMocks = { resolveTextChunkLimit: ReturnType; }; +beforeEach(() => { + ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset(); + ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({ + hostname: "example.com", + addresses: ["93.184.216.34"], + }); +}); + function lineResult(messageId: string, chatId = "c1") { return { messageId, diff --git a/extensions/msteams/src/attachments.graph.test.ts b/extensions/msteams/src/attachments.graph.test.ts index 743a4fe815c..19ca0b5b434 100644 --- a/extensions/msteams/src/attachments.graph.test.ts +++ b/extensions/msteams/src/attachments.graph.test.ts @@ -83,6 +83,7 @@ const createTokenProvider = ( typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver, ), }); +const resolvePublicHost = async (): Promise<{ address: string }> => ({ address: "93.184.216.34" }); const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => { const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); return new Response(new Uint8Array(raw), { @@ -197,6 +198,7 @@ const downloadGraphMediaWithMockOptions = async ( tokenProvider: createTokenProvider(), maxBytes: DEFAULT_MAX_BYTES, fetchFn: asFetchFn(fetchMock), + resolveFn: resolvePublicHost, ...overrides, }); return { fetchMock, media }; @@ -282,6 +284,7 @@ describe("msteams graph attachments", () => { allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"], authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS, fetchFn: asFetchFn(fetchMock), + resolveFn: resolvePublicHost, }); expectAttachmentMediaLength(media.media, 1); diff --git a/extensions/msteams/src/attachments/bot-framework.test.ts b/extensions/msteams/src/attachments/bot-framework.test.ts index bbd29ef09cf..95b0bd03182 100644 --- a/extensions/msteams/src/attachments/bot-framework.test.ts +++ b/extensions/msteams/src/attachments/bot-framework.test.ts @@ -80,6 +80,10 @@ function buildTokenProvider(): MSTeamsAccessTokenProvider { }; } +async function resolvePublicHost(): Promise<{ address: string }> { + return { address: "93.184.216.34" }; +} + describe("isBotFrameworkPersonalChatId", () => { it("detects a: prefix personal chat IDs", () => { expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true); @@ -140,6 +144,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(media?.path).toBe(runtime.savePath); @@ -162,6 +167,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(media).toBeUndefined(); @@ -187,6 +193,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(media).toBeUndefined(); @@ -208,6 +215,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(media).toBeUndefined(); @@ -266,6 +274,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(media?.path).toBe(runtime.savePath); @@ -296,6 +305,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, logger, }); @@ -332,6 +342,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, logger, }); @@ -358,6 +369,7 @@ describe("downloadMSTeamsBotFrameworkAttachment", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000_000, fetchFn, + resolveFn: resolvePublicHost, logger: { warn }, }); @@ -407,6 +419,7 @@ describe("downloadMSTeamsBotFrameworkAttachments", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(result.media).toHaveLength(2); @@ -453,6 +466,7 @@ describe("downloadMSTeamsBotFrameworkAttachments", () => { tokenProvider: buildTokenProvider(), maxBytes: 10_000, fetchFn, + resolveFn: resolvePublicHost, }); expect(result.media).toHaveLength(1); diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 3d4ebe9b4fb..311647c7439 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -309,7 +309,7 @@ describe("createTeamsReplyStreamController", () => { mode: "progress", progress: { label: "Working", - maxLines: 1, + maxLines: 3, }, }, } as never, @@ -320,7 +320,9 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( + "Working\n- tool: search\n- tool: exec", + ); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 79e53914b69..32a41ea1588 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -1,19 +1,36 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); +const ssrfRuntimeMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn(), +})); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchWithSsrFGuardMock, + fetchWithSsrFGuard: ssrfRuntimeMocks.fetchWithSsrFGuard, })); +afterAll(() => { + vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); + vi.resetModules(); +}); + import { resolveSTTConfig, transcribeAudio } from "./stt.js"; describe("engine/utils/stt", () => { + beforeEach(() => { + ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset(); + ssrfRuntimeMocks.fetchWithSsrFGuard.mockImplementation( + async ({ url, init }: { url: string; init?: RequestInit }) => ({ + response: await fetch(url, init), + release: vi.fn(async () => {}), + }), + ); + }); + afterEach(() => { - fetchWithSsrFGuardMock.mockReset(); + ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset(); vi.unstubAllGlobals(); }); @@ -81,7 +98,7 @@ describe("engine/utils/stt", () => { fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ + ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({ response: Response.json({ text: "hello from audio", }), @@ -101,14 +118,17 @@ describe("engine/utils/stt", () => { }); expect(transcript).toBe("hello from audio"); - const request = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as - | { url?: string; auditContext?: string; init?: RequestInit } - | undefined; - expect(request?.url).toBe("https://api.example.test/v1/audio/transcriptions"); - expect(request?.auditContext).toBe("qqbot-stt"); - expect(request?.init?.method).toBe("POST"); - expect(request?.init?.headers).toEqual({ Authorization: "Bearer secret" }); - expect(request?.init?.body).toBeInstanceOf(FormData); + expect(ssrfRuntimeMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.test/v1/audio/transcriptions", + auditContext: "qqbot-stt", + init: expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer secret" }, + body: expect.any(FormData), + }), + }), + ); expect(release).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/tts-local-cli/speech-provider.test.ts b/extensions/tts-local-cli/speech-provider.test.ts index da80ecdfb80..a960af0b76d 100644 --- a/extensions/tts-local-cli/speech-provider.test.ts +++ b/extensions/tts-local-cli/speech-provider.test.ts @@ -102,6 +102,9 @@ describe("buildCliSpeechProvider", () => { if (typeof outputPath !== "string") { throw new Error("missing ffmpeg output path"); } + const stagedTarget = outputPath.endsWith(".part") + ? outputPath.slice(0, -".part".length) + : outputPath; const forcedFormatIndex = args.lastIndexOf("-f"); const forcedFormat = forcedFormatIndex >= 0 && typeof args[forcedFormatIndex + 1] === "string" @@ -112,7 +115,7 @@ describe("buildCliSpeechProvider", () => { ? ".pcm" : forcedFormat ? `.${forcedFormat}` - : path.extname(outputPath.replace(/\.part$/, "")); + : path.extname(stagedTarget); writeFileSync(outputPath, Buffer.from(`converted:${extension}`)); }); }); diff --git a/extensions/xai/tts.test.ts b/extensions/xai/tts.test.ts index a3a994af89b..59768bb1bab 100644 --- a/extensions/xai/tts.test.ts +++ b/extensions/xai/tts.test.ts @@ -1,10 +1,18 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { isValidXaiTtsVoice, XAI_BASE_URL, XAI_TTS_VOICES, xaiTTS } from "./tts.js"; describe("xai tts", () => { const originalFetch = globalThis.fetch; + let ssrfMock: { mockRestore: () => void } | undefined; + + beforeEach(() => { + ssrfMock = mockPinnedHostnameResolution(); + }); afterEach(() => { + ssrfMock?.mockRestore(); + ssrfMock = undefined; globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 0291fd141d1..7cd202065ac 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -491,7 +491,7 @@ describe("runCliAgent spawn path", () => { }, }), ); - await expectPathMissing(pluginDir); + await expect(fs.access(pluginDir)).rejects.toMatchObject({ code: "ENOENT" }); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); }