diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index acac8b50e868..112439926ac3 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -1,12 +1,70 @@ // Byteplus tests cover video generation provider plugin behavior. -import { - getProviderHttpMocks, - installProviderHttpMockCleanup, -} from "openclaw/plugin-sdk/provider-http-test-mocks"; import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks(); +// Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON +// bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded +// reader actually streams and cancels oversized bodies under test instead of a stub. +const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi.hoisted( + () => ({ + postJsonRequestMock: vi.fn(), + fetchWithTimeoutMock: vi.fn(), + resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })), + }), +); + +vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ + resolveApiKeyForProvider: resolveApiKeyForProviderMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => { + const actual = await importActual(); + const resolveTimeoutMs = (timeoutMs: unknown): number => + typeof timeoutMs === "function" ? (timeoutMs() as number) : ((timeoutMs as number) ?? 60_000); + return { + // REAL byte-bounded JSON reader under test — not stubbed. + readProviderJsonResponse: actual.readProviderJsonResponse, + postJsonRequest: postJsonRequestMock, + fetchProviderOperationResponse: async (params: { + url: string; + init?: RequestInit; + timeoutMs?: unknown; + fetchFn: typeof fetch; + }) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)), + fetchProviderDownloadResponse: async (params: { + url: string; + init?: RequestInit; + timeoutMs?: unknown; + fetchFn: typeof fetch; + }) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)), + assertOkOrThrowHttpError: async () => {}, + createProviderOperationDeadline: ({ + label, + timeoutMs, + }: { + label: string; + timeoutMs?: number; + }) => ({ label, timeoutMs }), + createProviderOperationTimeoutResolver: + ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) => + () => + defaultTimeoutMs, + resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) => + defaultTimeoutMs, + resolveProviderHttpRequestConfig: (params: { + baseUrl?: string; + defaultBaseUrl: string; + allowPrivateNetwork?: boolean; + defaultHeaders?: Record; + }) => ({ + baseUrl: params.baseUrl ?? params.defaultBaseUrl, + allowPrivateNetwork: params.allowPrivateNetwork === true, + headers: new Headers(params.defaultHeaders), + dispatcherPolicy: undefined, + }), + waitProviderOperationPollInterval: async () => {}, + }; +}); let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider; @@ -14,20 +72,22 @@ beforeAll(async () => { ({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js")); }); -installProviderHttpMockCleanup(); +afterEach(() => { + postJsonRequestMock.mockReset(); + fetchWithTimeoutMock.mockReset(); + resolveApiKeyForProviderMock.mockClear(); +}); function mockSuccessfulBytePlusTask(params?: { model?: string }) { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - id: "task_123", - }), - }, + response: streamedJsonResponse({ + id: "task_123", + }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task_123", status: "succeeded", content: { @@ -35,7 +95,7 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) { }, model: params?.model ?? "seedance-1-0-lite-t2v-250428", }), - }) + ) .mockResolvedValueOnce({ headers: new Headers({ "content-type": "video/webm" }), arrayBuffer: async () => Buffer.from("webm-bytes"), @@ -77,6 +137,53 @@ function streamedVideoResponse(bytes: string): Response { ); } +// BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the +// mocked responses must expose a real readable body (not just a json() shortcut). +function streamedJsonResponse(payload: unknown): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify(payload))); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the +// bounded reader cancels the stream mid-flight; if the cap were removed the reader would +// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled +// and whether the stream was canceled so callers can assert the body was not fully read. +function makeOversizedJsonStream(): { + body: ReadableStream; + maxBytes: number; + totalBytes: number; + state: { bytesPulled: number; canceled: boolean }; +} { + const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES. + const ONE_MIB = 1024 * 1024; + const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap. + const chunk = new Uint8Array(ONE_MIB); + const state = { bytesPulled: 0, canceled: false }; + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= TOTAL_CHUNKS) { + controller.close(); + return; + } + pulled += 1; + state.bytesPulled += chunk.length; + controller.enqueue(chunk); + }, + cancel() { + state.canceled = true; + }, + }); + return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state }; +} + describe("byteplus video generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider()); @@ -110,21 +217,19 @@ describe("byteplus video generation provider", () => { it("rejects generated video downloads that exceed the configured media cap", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task_too_large" }), - }, + response: streamedJsonResponse({ id: "task_too_large" }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task_too_large", status: "succeeded", content: { video_url: "https://example.com/too-large.mp4", }, }), - }) + ) .mockResolvedValueOnce(streamedVideoResponse("too-large")); const provider = buildBytePlusVideoGenerationProvider(); @@ -222,16 +327,14 @@ describe("byteplus video generation provider", () => { it("drops malformed response duration metadata", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - id: "task_123", - }), - }, + response: streamedJsonResponse({ + id: "task_123", + }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task_123", status: "succeeded", content: { @@ -239,7 +342,7 @@ describe("byteplus video generation provider", () => { }, duration: 1.5, }), - }) + ) .mockResolvedValueOnce({ headers: new Headers({ "content-type": "video/mp4" }), arrayBuffer: async () => Buffer.from("mp4-bytes"), @@ -259,11 +362,15 @@ describe("byteplus video generation provider", () => { it("reports malformed create JSON with a provider-owned error", async () => { const release = vi.fn(async () => {}); postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => { - throw new SyntaxError("bad json"); - }, - }, + response: new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{ not valid json")); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), release, }); @@ -281,19 +388,17 @@ describe("byteplus video generation provider", () => { it("rejects status responses missing a task status", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task_missing_status" }), - }, + response: streamedJsonResponse({ id: "task_missing_status" }), release: vi.fn(async () => {}), }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - json: async () => ({ + fetchWithTimeoutMock.mockResolvedValueOnce( + streamedJsonResponse({ id: "task_missing_status", content: { video_url: "https://example.com/byteplus.mp4", }, }), - }); + ); const provider = buildBytePlusVideoGenerationProvider(); await expect( @@ -308,18 +413,16 @@ describe("byteplus video generation provider", () => { it("rejects malformed completed content", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task_malformed_content" }), - }, + response: streamedJsonResponse({ id: "task_malformed_content" }), release: vi.fn(async () => {}), }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - json: async () => ({ + fetchWithTimeoutMock.mockResolvedValueOnce( + streamedJsonResponse({ id: "task_malformed_content", status: "succeeded", content: ["https://example.com/byteplus.mp4"], }), - }); + ); const provider = buildBytePlusVideoGenerationProvider(); await expect( @@ -331,4 +434,61 @@ describe("byteplus video generation provider", () => { }), ).rejects.toThrow("BytePlus video generation completed with malformed content"); }); + + it("bounds the submit task JSON body and cancels an oversized stream", async () => { + const stream = makeOversizedJsonStream(); + const release = vi.fn(async () => {}); + postJsonRequestMock.mockResolvedValue({ + response: new Response(stream.body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + release, + }); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "oversized submit response", + cfg: {}, + }), + ).rejects.toThrow( + `BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`, + ); + expect(stream.state.canceled).toBe(true); + // Only the bounded prefix is pulled, never the full advertised stream. + expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes); + // The submit request must still be released even though the body overflowed. + expect(release).toHaveBeenCalledOnce(); + }); + + it("bounds the poll status JSON body and cancels an oversized stream", async () => { + postJsonRequestMock.mockResolvedValue({ + response: streamedJsonResponse({ id: "task_oversized_poll" }), + release: vi.fn(async () => {}), + }); + const stream = makeOversizedJsonStream(); + fetchWithTimeoutMock.mockResolvedValueOnce( + new Response(stream.body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "oversized poll response", + cfg: {}, + }), + ).rejects.toThrow( + `BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`, + ); + expect(stream.state.canceled).toBe(true); + expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes); + }); }); diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index 1c1734e4afee..b8a39e51c0d3 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -11,6 +11,7 @@ import { fetchProviderDownloadResponse, fetchProviderOperationResponse, postJsonRequest, + readProviderJsonResponse, resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, @@ -55,16 +56,13 @@ type BytePlusTaskResponse = { type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled"; -async function readBytePlusJsonResponse( - response: Pick, - label: string, -): Promise { - let payload: unknown; - try { - payload = await response.json(); - } catch (cause) { - throw new Error(`${label}: malformed JSON response`, { cause }); - } +async function readBytePlusJsonResponse(response: Response, label: string): Promise { + // BytePlus submit/poll task bodies are read through the shared byte-bounded reader + // (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint + // that streams an unbounded JSON body cannot force the runtime to buffer the whole + // payload before parsing. Overflow cancels the stream and throws a bounded error; + // malformed JSON keeps the existing `${label}: malformed JSON response` wrapping. + const payload = await readProviderJsonResponse(response, label); if (!isRecord(payload)) { throw new Error(`${label}: malformed JSON response`); }