mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 06:42:52 +00:00
fix(github): bound proof comment API bodies
This commit is contained in:
@@ -1,6 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchProofComments } from "../../scripts/github/real-behavior-proof-check.mjs";
|
||||
|
||||
function stalledResponse() {
|
||||
let keepAlive: ReturnType<typeof setTimeout> | undefined;
|
||||
const reader = {
|
||||
read: () =>
|
||||
new Promise<ReadableStreamReadResult<Uint8Array>>(() => {
|
||||
keepAlive = setTimeout(() => {}, 10_000);
|
||||
}),
|
||||
cancel: vi.fn(() => {
|
||||
if (keepAlive) {
|
||||
clearTimeout(keepAlive);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: {
|
||||
getReader: () => reader,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function contentLengthResponse(contentLength: number) {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-length": String(contentLength) }),
|
||||
body: { cancel },
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
function chunkedResponse(chunks: Uint8Array[]) {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
const read = vi.fn();
|
||||
for (const chunk of chunks) {
|
||||
read.mockResolvedValueOnce({ done: false, value: chunk });
|
||||
}
|
||||
read.mockResolvedValueOnce({ done: true, value: undefined });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read,
|
||||
cancel,
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("real-behavior-proof-check GitHub lookups", () => {
|
||||
it("aborts stalled proof comment fetches", async () => {
|
||||
const fetch = vi.fn((_url: URL, init: RequestInit) => {
|
||||
@@ -22,11 +79,7 @@ describe("real-behavior-proof-check GitHub lookups", () => {
|
||||
});
|
||||
|
||||
it("times out stalled proof comment response bodies", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => new Promise(() => {}),
|
||||
});
|
||||
const fetch = vi.fn().mockResolvedValue(stalledResponse());
|
||||
|
||||
await expect(
|
||||
fetchProofComments({
|
||||
@@ -39,4 +92,88 @@ describe("real-behavior-proof-check GitHub lookups", () => {
|
||||
}),
|
||||
).rejects.toThrow(/proof comment response page 1 timed out after 5ms/);
|
||||
});
|
||||
|
||||
it("skips oversized proof comment bodies by content length after narrow fallback", async () => {
|
||||
const response = contentLengthResponse(1024 * 1024 + 1);
|
||||
const fetch = vi.fn((url: URL) => {
|
||||
const perPage = url.searchParams.get("per_page");
|
||||
const page = url.searchParams.get("page");
|
||||
if ((perPage === "100" && page === "1") || (perPage === "1" && page === "1")) {
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
return Promise.resolve(new Response("[]", { status: 200 }));
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchProofComments({
|
||||
fetchImpl: fetch as typeof globalThis.fetch,
|
||||
issueNumber: 123,
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
tokens: ["tok"],
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(response.cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips oversized streamed proof comment bodies after narrow fallback", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const oversizedResponse = () =>
|
||||
chunkedResponse([
|
||||
encoder.encode("["),
|
||||
encoder.encode(" ".repeat(1024 * 1024)),
|
||||
encoder.encode("]"),
|
||||
]);
|
||||
const fetch = vi.fn((url: URL) => {
|
||||
const perPage = url.searchParams.get("per_page");
|
||||
const page = url.searchParams.get("page");
|
||||
if ((perPage === "100" && page === "1") || (perPage === "1" && page === "1")) {
|
||||
return Promise.resolve(oversizedResponse());
|
||||
}
|
||||
return Promise.resolve(new Response("[]", { status: 200 }));
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchProofComments({
|
||||
fetchImpl: fetch as typeof globalThis.fetch,
|
||||
issueNumber: 123,
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
tokens: ["tok"],
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to one-comment pages when a bulk comment page is oversized", async () => {
|
||||
const fetch = vi.fn((url: URL) => {
|
||||
const perPage = url.searchParams.get("per_page");
|
||||
const page = url.searchParams.get("page");
|
||||
if (perPage === "100" && page === "1") {
|
||||
return Promise.resolve(contentLengthResponse(1024 * 1024 + 1));
|
||||
}
|
||||
if (perPage === "1" && page === "1") {
|
||||
return Promise.resolve(contentLengthResponse(1024 * 1024 + 1));
|
||||
}
|
||||
if (perPage === "1" && page === "2") {
|
||||
return Promise.resolve(new Response('[{"id":2,"body":"trusted proof"}]', { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response("[]", { status: 200 }));
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchProofComments({
|
||||
fetchImpl: fetch as typeof globalThis.fetch,
|
||||
issueNumber: 123,
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
tokens: ["tok"],
|
||||
}),
|
||||
).resolves.toEqual([{ id: 2, body: "trusted proof" }]);
|
||||
|
||||
expect(
|
||||
fetch.mock.calls.map(
|
||||
([url]) => `${url.searchParams.get("per_page")}:${url.searchParams.get("page")}`,
|
||||
),
|
||||
).toEqual(["100:1", "1:1", "1:2", "1:3"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
hasClawSweeperExactHeadProof,
|
||||
isMaintainerTeamMember,
|
||||
labelsForRealBehaviorProof,
|
||||
readBoundedGitHubApiJson,
|
||||
} from "../../scripts/github/real-behavior-proof-policy.mjs";
|
||||
|
||||
function externalPr(body: string, overrides: Record<string, unknown> = {}) {
|
||||
@@ -46,6 +47,59 @@ function proofBody(evidence: string, overrides: Record<string, string> = {}) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stalledResponse() {
|
||||
let keepAlive: ReturnType<typeof setTimeout> | undefined;
|
||||
const reader = {
|
||||
read: () =>
|
||||
new Promise<ReadableStreamReadResult<Uint8Array>>(() => {
|
||||
keepAlive = setTimeout(() => {}, 10_000);
|
||||
}),
|
||||
cancel: vi.fn(() => {
|
||||
if (keepAlive) {
|
||||
clearTimeout(keepAlive);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: {
|
||||
getReader: () => reader,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function contentLengthResponse(contentLength: number) {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
return {
|
||||
headers: new Headers({ "content-length": String(contentLength) }),
|
||||
body: { cancel },
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
function chunkedResponse(chunks: Uint8Array[]) {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
const read = vi.fn();
|
||||
for (const chunk of chunks) {
|
||||
read.mockResolvedValueOnce({ done: false, value: chunk });
|
||||
}
|
||||
read.mockResolvedValueOnce({ done: true, value: undefined });
|
||||
return {
|
||||
headers: new Headers(),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read,
|
||||
cancel,
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("real-behavior-proof-policy", () => {
|
||||
it.each([
|
||||
"",
|
||||
@@ -406,11 +460,7 @@ describe("real-behavior-proof-policy", () => {
|
||||
|
||||
describe("isMaintainerTeamMember", () => {
|
||||
function jsonResponse(status: number, body: unknown = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
};
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
it("returns true for active members", async () => {
|
||||
@@ -478,11 +528,7 @@ describe("isMaintainerTeamMember", () => {
|
||||
});
|
||||
|
||||
it("times out stalled membership response bodies", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => new Promise(() => {}),
|
||||
});
|
||||
const fetch = vi.fn().mockResolvedValue(stalledResponse());
|
||||
|
||||
await expect(
|
||||
isMaintainerTeamMember({
|
||||
@@ -495,3 +541,39 @@ describe("isMaintainerTeamMember", () => {
|
||||
).rejects.toThrow(/maintainer membership response for u timed out after 5ms/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBoundedGitHubApiJson", () => {
|
||||
it("reads bounded JSON response bodies", async () => {
|
||||
await expect(
|
||||
readBoundedGitHubApiJson(new Response('{"state":"active"}'), "GitHub API", 1024),
|
||||
).resolves.toEqual({ state: "active" });
|
||||
});
|
||||
|
||||
it("rejects oversized JSON bodies by content length", async () => {
|
||||
const response = contentLengthResponse(1025);
|
||||
|
||||
await expect(
|
||||
readBoundedGitHubApiJson(response as unknown as Response, "GitHub API", 1024),
|
||||
).rejects.toMatchObject({
|
||||
code: "ETOOBIG",
|
||||
message: "GitHub API response body exceeded 1024 bytes",
|
||||
});
|
||||
expect(response.cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversized streamed JSON bodies", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const response = chunkedResponse([
|
||||
encoder.encode('{"body":"'),
|
||||
encoder.encode("x".repeat(1024)),
|
||||
encoder.encode('"}'),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readBoundedGitHubApiJson(response as unknown as Response, "GitHub API", 1024),
|
||||
).rejects.toMatchObject({
|
||||
code: "ETOOBIG",
|
||||
message: "GitHub API response body exceeded 1024 bytes",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user