openclaw/src/media/fetch.telegram-network.test.ts
Ayaan Zaidi e4825a0f93
fix(telegram): unify transport fallback chain (#49148)
* fix(telegram): unify transport fallback chain

* fix: address telegram fallback review comments

* fix: validate pinned SSRF overrides

* fix: unify telegram fallback retries (#49148)
2026-03-17 22:44:15 +05:30

326 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const undiciMocks = vi.hoisted(() => {
const createDispatcherCtor = <T extends Record<string, unknown> | string>() =>
vi.fn(function MockDispatcher(this: { options?: T }, options?: T) {
this.options = options;
});
return {
fetch: vi.fn(),
agentCtor: createDispatcherCtor<Record<string, unknown>>(),
envHttpProxyAgentCtor: createDispatcherCtor<Record<string, unknown>>(),
proxyAgentCtor: createDispatcherCtor<Record<string, unknown> | string>(),
};
});
vi.mock("undici", () => ({
Agent: undiciMocks.agentCtor,
EnvHttpProxyAgent: undiciMocks.envHttpProxyAgentCtor,
ProxyAgent: undiciMocks.proxyAgentCtor,
fetch: undiciMocks.fetch,
}));
let resolveTelegramTransport: typeof import("../../extensions/telegram/src/fetch.js").resolveTelegramTransport;
let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramTransportFallback;
let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia;
describe("fetchRemoteMedia telegram network policy", () => {
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
beforeEach(async () => {
vi.resetModules();
({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } =
await import("../../extensions/telegram/src/fetch.js"));
({ fetchRemoteMedia } = await import("./fetch.js"));
});
function createTelegramFetchFailedError(code: string): Error {
return Object.assign(new TypeError("fetch failed"), {
cause: { code },
});
}
afterEach(() => {
undiciMocks.fetch.mockReset();
undiciMocks.agentCtor.mockClear();
undiciMocks.envHttpProxyAgentCtor.mockClear();
undiciMocks.proxyAgentCtor.mockClear();
vi.unstubAllEnvs();
});
it("preserves Telegram resolver transport policy for file downloads", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
]) as unknown as LookupFn;
undiciMocks.fetch.mockResolvedValueOnce(
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "verbatim",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/1.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const init = undiciMocks.fetch.mock.calls[0]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
expect(init?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
);
});
it("keeps explicit proxy routing for file downloads", async () => {
const { makeProxyFetch } = await import("../../extensions/telegram/src/proxy.js");
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
]) as unknown as LookupFn;
undiciMocks.fetch.mockResolvedValueOnce(
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]), {
status: 200,
headers: { "content-type": "application/pdf" },
}),
);
const telegramTransport = resolveTelegramTransport(makeProxyFetch("http://127.0.0.1:7890"), {
network: {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/files/1.pdf",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const init = undiciMocks.fetch.mock.calls[0]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
uri?: string;
};
};
})
| undefined;
expect(init?.dispatcher?.options?.uri).toBe("http://127.0.0.1:7890");
expect(undiciMocks.proxyAgentCtor).toHaveBeenCalled();
});
it("retries Telegram file downloads with IPv4 fallback when the first fetch fails", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
{ address: "2001:67c:4e8:f004::9", family: 6 },
]) as unknown as LookupFn;
undiciMocks.fetch
.mockRejectedValueOnce(createTelegramFetchFailedError("EHOSTUNREACH"))
.mockResolvedValueOnce(
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/2.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const firstInit = undiciMocks.fetch.mock.calls[0]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
const secondInit = undiciMocks.fetch.mock.calls[1]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
expect(undiciMocks.fetch).toHaveBeenCalledTimes(2);
expect(firstInit?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
);
expect(secondInit?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
});
it("retries Telegram file downloads with pinned Telegram IP after IPv4 fallback fails", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.221", family: 4 },
{ address: "2001:67c:4e8:f004::9", family: 6 },
]) as unknown as LookupFn;
undiciMocks.fetch
.mockRejectedValueOnce(createTelegramFetchFailedError("EHOSTUNREACH"))
.mockRejectedValueOnce(createTelegramFetchFailedError("ETIMEDOUT"))
.mockResolvedValueOnce(
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/3.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
});
const thirdInit = undiciMocks.fetch.mock.calls[2]?.[1] as
| (RequestInit & {
dispatcher?: {
options?: {
connect?: Record<string, unknown>;
};
};
})
| undefined;
const callback = vi.fn();
(
thirdInit?.dispatcher?.options?.connect?.lookup as
| ((
hostname: string,
callback: (err: null, address: string, family: number) => void,
) => void)
| undefined
)?.("api.telegram.org", callback);
expect(undiciMocks.fetch).toHaveBeenCalledTimes(3);
expect(thirdInit?.dispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4);
});
it("preserves both primary and final fallback errors when Telegram media retry chain fails", async () => {
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
{ address: "2001:67c:4e8:f004::9", family: 6 },
]) as unknown as LookupFn;
const primaryError = createTelegramFetchFailedError("EHOSTUNREACH");
const ipv4Error = createTelegramFetchFailedError("ETIMEDOUT");
const fallbackError = createTelegramFetchFailedError("ETIMEDOUT");
undiciMocks.fetch
.mockRejectedValueOnce(primaryError)
.mockRejectedValueOnce(ipv4Error)
.mockRejectedValueOnce(fallbackError);
const telegramTransport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
await expect(
fetchRemoteMedia({
url: "https://api.telegram.org/file/bottok/photos/4.jpg",
fetchImpl: telegramTransport.sourceFetch,
dispatcherAttempts: telegramTransport.dispatcherAttempts,
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
lookupFn,
maxBytes: 1024,
ssrfPolicy: {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
},
}),
).rejects.toMatchObject({
name: "MediaFetchError",
code: "fetch_failed",
cause: expect.objectContaining({
name: "Error",
cause: fallbackError,
attemptErrors: [primaryError, ipv4Error, fallbackError],
primaryError,
}),
});
});
});