Skip to content

Commit 1838cba

Browse files
test(googlechat): add changed-path bounded-read regression; preserve overflow error message
1 parent 0168100 commit 1838cba

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Googlechat tests cover api module behavior, including the bounded JSON
2+
// reader swap that prevents an oversized Google Chat response from buffering
3+
// the full payload before parsing.
4+
import { beforeAll, describe, expect, it, vi } from "vitest";
5+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
6+
7+
const mocks = vi.hoisted(() => ({
8+
fetchWithSsrFGuard: vi.fn(),
9+
getGoogleChatAccessToken: vi.fn(async () => "test-token"),
10+
}));
11+
12+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
13+
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
14+
}));
15+
16+
vi.mock("./auth.js", () => ({
17+
getGoogleChatAccessToken: mocks.getGoogleChatAccessToken,
18+
}));
19+
20+
let sendGoogleChatMessage: typeof import("./api.js").sendGoogleChatMessage;
21+
22+
beforeAll(async () => {
23+
({ sendGoogleChatMessage } = await import("./api.js"));
24+
});
25+
26+
function overflowingSuccessJsonResponse(): {
27+
response: Response;
28+
cancel: ReturnType<typeof vi.fn>;
29+
} {
30+
const cancel = vi.fn(async () => undefined);
31+
const chunk = new Uint8Array(1024 * 1024); // 1 MiB zero-filled chunk
32+
let chunkIndex = 0;
33+
return {
34+
response: {
35+
ok: true,
36+
status: 200,
37+
headers: new Headers({ "Content-Type": "application/json" }),
38+
json: vi.fn(async () => {
39+
throw new Error("response.json() must not be called on the bounded path");
40+
}),
41+
body: {
42+
getReader: () => ({
43+
read: async () => {
44+
// Emit enough chunks to exceed the 16 MiB cap, then close the stream.
45+
if (chunkIndex > 18) {
46+
return { done: true, value: undefined };
47+
}
48+
chunkIndex += 1;
49+
return { done: false, value: chunk };
50+
},
51+
cancel,
52+
releaseLock: vi.fn(),
53+
}),
54+
},
55+
} as unknown as Response,
56+
cancel,
57+
};
58+
}
59+
60+
function smallValidJsonResponse(): Response {
61+
return new Response(
62+
JSON.stringify({ name: "spaces/AAA/messages/BBB", thread: { name: "spaces/AAA/threads/1" } }),
63+
{ status: 200, headers: { "Content-Type": "application/json" } },
64+
);
65+
}
66+
67+
function buildAccount(): ResolvedGoogleChatAccount {
68+
return {
69+
accountId: "test-account",
70+
enabled: true,
71+
config: {} as ResolvedGoogleChatAccount["config"],
72+
credentialSource: "inline",
73+
credentials: {},
74+
};
75+
}
76+
77+
describe("googlechat api bounded reader (sendGoogleChatMessage path)", () => {
78+
it("rejects oversized Google Chat API success bodies via the bounded reader", async () => {
79+
const overflow = overflowingSuccessJsonResponse();
80+
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
81+
response: overflow.response,
82+
release: async () => undefined,
83+
});
84+
85+
let error: Error | null = null;
86+
try {
87+
await sendGoogleChatMessage({
88+
account: buildAccount(),
89+
space: "spaces/AAA",
90+
text: "hello world",
91+
});
92+
} catch (caught) {
93+
error = caught as Error;
94+
}
95+
96+
expect(error).toBeInstanceOf(Error);
97+
// The bounded reader's canonical overflow message must surface verbatim —
98+
// not get rewrapped as "Google Chat API request failed: malformed JSON response".
99+
expect(error?.message).toMatch(/Google Chat API request failed/);
100+
expect(error?.message).toMatch(/exceeds 16777216 bytes/);
101+
// The bounded reader cancelled the underlying stream before draining the body.
102+
expect(overflow.cancel).toHaveBeenCalled();
103+
});
104+
105+
it("parses small valid Google Chat API success bodies end-to-end", async () => {
106+
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
107+
response: smallValidJsonResponse(),
108+
release: async () => undefined,
109+
});
110+
111+
const result = await sendGoogleChatMessage({
112+
account: buildAccount(),
113+
space: "spaces/AAA",
114+
text: "hello world",
115+
});
116+
expect(result?.messageName).toBe("spaces/AAA/messages/BBB");
117+
expect(result?.threadName).toBe("spaces/AAA/threads/1");
118+
});
119+
});

extensions/googlechat/src/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ async function readGoogleChatJsonResponse<T>(response: Response, label: string):
2222
try {
2323
return await readProviderJsonResponse<T>(response, label);
2424
} catch (cause) {
25+
// Preserve the bounded-reader overflow message verbatim so callers
26+
// (and ClawSweeper reviewers) can see the canonical "<label>: JSON
27+
// response exceeds <N> bytes" surface. Only wrap genuine parse errors.
28+
const message = cause instanceof Error ? cause.message : String(cause);
29+
if (/exceeds \d+ bytes/.test(message)) {
30+
throw cause instanceof Error ? cause : new Error(message);
31+
}
2532
throw new Error(`${label}: malformed JSON response`, { cause });
2633
}
2734
}

0 commit comments

Comments
 (0)