mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
229 lines
7.1 KiB
TypeScript
229 lines
7.1 KiB
TypeScript
// Discord tests cover gateway metadata plugin behavior.
|
|
import { createServer, type Server } from "node:http";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
fetchDiscordGatewayInfoWithTimeout,
|
|
fetchDiscordGatewayMetadataGuarded,
|
|
resolveDiscordGatewayInfoTimeoutMs,
|
|
resolveGatewayInfoWithFallback,
|
|
} from "./gateway-metadata.js";
|
|
|
|
const DISCORD_GATEWAY_METADATA_MAX_BYTES = 4 * 1024 * 1024;
|
|
|
|
const { mockFetchWithSsrFGuard } = vi.hoisted(() => ({
|
|
mockFetchWithSsrFGuard: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
|
fetchWithSsrFGuard: mockFetchWithSsrFGuard,
|
|
}));
|
|
|
|
async function listenLoopbackServer(server: Server): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("expected loopback TCP address"));
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function closeServer(server: Server): Promise<void> {
|
|
await new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
function stubGuardedFetch(response: Response) {
|
|
const release = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
mockFetchWithSsrFGuard.mockResolvedValue({
|
|
response,
|
|
release,
|
|
});
|
|
return release;
|
|
}
|
|
|
|
function createStalledLookup() {
|
|
let release: (() => void) | undefined;
|
|
const lookupFn = vi.fn(
|
|
async () =>
|
|
await new Promise<Array<{ address: string; family: 4 }>>((resolve) => {
|
|
release = () => resolve([{ address: "162.159.136.234", family: 4 }]);
|
|
}),
|
|
);
|
|
return {
|
|
lookupFn: lookupFn as unknown as NonNullable<
|
|
Parameters<
|
|
typeof import("openclaw/plugin-sdk/ssrf-runtime").fetchWithSsrFGuard
|
|
>[0]["lookupFn"]
|
|
>,
|
|
release: () => release?.(),
|
|
};
|
|
}
|
|
|
|
describe("Discord gateway metadata", () => {
|
|
it("resolves gateway info timeouts from strict integer env values", () => {
|
|
expect(
|
|
resolveDiscordGatewayInfoTimeoutMs({
|
|
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "90000" },
|
|
}),
|
|
).toBe(90_000);
|
|
expect(
|
|
resolveDiscordGatewayInfoTimeoutMs({
|
|
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "0x1000" },
|
|
}),
|
|
).toBe(30_000);
|
|
expect(
|
|
resolveDiscordGatewayInfoTimeoutMs({
|
|
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "1e3" },
|
|
}),
|
|
).toBe(30_000);
|
|
});
|
|
|
|
it("falls back on Cloudflare HTML rate limits without logging raw HTML", async () => {
|
|
const error = await fetchDiscordGatewayInfoWithTimeout({
|
|
token: "test",
|
|
fetchImpl: async () =>
|
|
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
|
|
status: 429,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
timeoutMs: 1_000,
|
|
}).catch((err: unknown) => err);
|
|
const runtime = {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
};
|
|
|
|
const resolved = resolveGatewayInfoWithFallback({ runtime, error });
|
|
|
|
expect(resolved.usedFallback).toBe(true);
|
|
expect(resolved.info.url).toBe("wss://gateway.discord.gg/");
|
|
const logs = runtime.log.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(logs).toBe(
|
|
"discord: gateway metadata lookup failed transiently; using default gateway url (Failed to get gateway information from Discord: fetch failed | Discord API /gateway/bot failed (429): Error 1015 rate limited)",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fetchDiscordGatewayMetadataGuarded bounded reads", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
mockFetchWithSsrFGuard.mockReset();
|
|
});
|
|
|
|
it("returns under-cap response bodies and releases the guarded fetch", async () => {
|
|
const payload = JSON.stringify({
|
|
url: "wss://gateway.discord.gg/",
|
|
shards: 1,
|
|
session_start_limit: {
|
|
total: 1000,
|
|
remaining: 999,
|
|
reset_after: 3600000,
|
|
max_concurrency: 1,
|
|
},
|
|
});
|
|
|
|
const release = stubGuardedFetch(
|
|
new Response(payload, { headers: { "content-type": "application/json" } }),
|
|
);
|
|
|
|
const response = await fetchDiscordGatewayMetadataGuarded(
|
|
"https://discord.com/api/v10/gateway/bot",
|
|
);
|
|
|
|
await expect(response.json()).resolves.toEqual(JSON.parse(payload));
|
|
expect(release).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("aborts stalled DNS preflight through the gateway metadata deadline", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
|
|
"openclaw/plugin-sdk/ssrf-runtime",
|
|
);
|
|
const stalledLookup = createStalledLookup();
|
|
const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
let guardSettled = false;
|
|
mockFetchWithSsrFGuard.mockImplementation(async (params) => {
|
|
try {
|
|
return await actual.fetchWithSsrFGuard({
|
|
...params,
|
|
fetchImpl,
|
|
lookupFn: stalledLookup.lookupFn,
|
|
});
|
|
} finally {
|
|
guardSettled = true;
|
|
}
|
|
});
|
|
|
|
const fetchPromise = fetchDiscordGatewayInfoWithTimeout({
|
|
token: "test",
|
|
timeoutMs: 250,
|
|
fetchImpl: fetchDiscordGatewayMetadataGuarded,
|
|
}).catch((error: unknown) => error);
|
|
|
|
try {
|
|
await vi.waitFor(() => expect(stalledLookup.lookupFn).toHaveBeenCalledOnce());
|
|
const guardedParams = mockFetchWithSsrFGuard.mock.calls[0]?.[0];
|
|
expect(guardedParams.signal).toBe(guardedParams.init.signal);
|
|
expect(guardedParams).not.toHaveProperty("timeoutMs");
|
|
|
|
await expect(fetchPromise).resolves.toBeInstanceOf(Error);
|
|
await vi.waitFor(() => expect(guardSettled).toBe(true));
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
} finally {
|
|
stalledLookup.release();
|
|
}
|
|
await Promise.resolve();
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects oversized response body from a real loopback HTTP server", async () => {
|
|
const oversizedPayloadBytes = DISCORD_GATEWAY_METADATA_MAX_BYTES + 256 * 1024;
|
|
let streamedBytes = 0;
|
|
|
|
const server = createServer((_request, res) => {
|
|
const chunk = Buffer.alloc(64 * 1024, 0x78);
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
|
|
const writeMore = () => {
|
|
while (streamedBytes < oversizedPayloadBytes) {
|
|
if (res.destroyed) {
|
|
return;
|
|
}
|
|
streamedBytes += chunk.byteLength;
|
|
if (!res.write(chunk)) {
|
|
res.once("drain", writeMore);
|
|
return;
|
|
}
|
|
}
|
|
res.end();
|
|
};
|
|
|
|
writeMore();
|
|
});
|
|
const port = await listenLoopbackServer(server);
|
|
|
|
try {
|
|
const rawResponse = await fetch(`http://127.0.0.1:${port}`);
|
|
const release = stubGuardedFetch(rawResponse);
|
|
|
|
await expect(
|
|
fetchDiscordGatewayMetadataGuarded("https://discord.com/api/v10/gateway/bot"),
|
|
).rejects.toThrow(
|
|
new RegExp(
|
|
`Discord gateway metadata response body too large: \\d+ bytes \\(limit: ${DISCORD_GATEWAY_METADATA_MAX_BYTES} bytes\\)`,
|
|
),
|
|
);
|
|
expect(release).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
});
|