test: move telegram fetch coverage into extensions

This commit is contained in:
Peter Steinberger 2026-04-03 11:35:07 +01:00
parent e0c4458a2f
commit bb3ea2137b
No known key found for this signature in database
9 changed files with 132 additions and 369 deletions

View File

@ -320,7 +320,12 @@ describe("resolveMedia getFile retry", () => {
it("uses caller-provided fetch impl for file downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch };
const dispatcherAttempts = [{ dispatcherPolicy: { mode: "direct" as const } }];
const callerTransport = {
fetch: callerFetch,
sourceFetch: callerFetch,
dispatcherAttempts,
};
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
@ -339,6 +344,13 @@ describe("resolveMedia getFile retry", () => {
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
fetchImpl: callerFetch,
dispatcherAttempts,
shouldRetryFetchError: expect.any(Function),
readIdleTimeoutMs: 30_000,
ssrfPolicy: {
allowRfc2544BenchmarkRange: true,
hostnameAllowlist: ["api.telegram.org"],
},
}),
);
});
@ -369,6 +381,36 @@ describe("resolveMedia getFile retry", () => {
);
});
it("allows an explicit Telegram apiRoot host without broadening the default SSRF allowlist", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
await resolveMediaWithDefaults(makeCtx("document", getFile), {
apiRoot: "https://telegram.internal:8443/custom/",
dangerouslyAllowPrivateNetwork: true,
});
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
url: `https://telegram.internal:8443/custom/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
ssrfPolicy: {
hostnameAllowlist: ["api.telegram.org", "telegram.internal"],
allowedHostnames: ["telegram.internal"],
allowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
},
}),
);
});
it("uses local absolute file paths directly for media downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });

View File

@ -1,347 +0,0 @@
import { afterAll, afterEach, beforeAll, 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 fetchRemoteMedia: typeof import("../../../src/media/fetch.js").fetchRemoteMedia;
let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport;
let shouldRetryTelegramTransportFallback: typeof import("./fetch.js").shouldRetryTelegramTransportFallback;
let makeProxyFetch: typeof import("./proxy.js").makeProxyFetch;
let TEST_UNDICI_RUNTIME_DEPS_KEY: typeof import("../../../src/infra/net/undici-runtime.js").TEST_UNDICI_RUNTIME_DEPS_KEY;
describe("fetchRemoteMedia telegram network policy", () => {
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
beforeAll(async () => {
({ TEST_UNDICI_RUNTIME_DEPS_KEY } = await import("../../../src/infra/net/undici-runtime.js"));
({ fetchRemoteMedia } = await import("../../../src/media/fetch.js"));
({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } =
await import("./fetch.js"));
({ makeProxyFetch } = await import("./proxy.js"));
});
beforeEach(() => {
undiciMocks.fetch.mockReset();
undiciMocks.agentCtor.mockClear();
undiciMocks.envHttpProxyAgentCtor.mockClear();
undiciMocks.proxyAgentCtor.mockClear();
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: undiciMocks.agentCtor,
EnvHttpProxyAgent: undiciMocks.envHttpProxyAgentCtor,
ProxyAgent: undiciMocks.proxyAgentCtor,
};
});
function createTelegramFetchFailedError(code: string): Error {
return Object.assign(new TypeError("fetch failed"), {
cause: { code },
});
}
afterEach(() => {
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
vi.unstubAllEnvs();
});
afterAll(() => {
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
});
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 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;
requestTls?: Record<string, unknown>;
};
};
})
| undefined;
expect(init?.dispatcher?.options?.uri).toBe("http://127.0.0.1:7890");
expect(init?.dispatcher?.options?.requestTls).toEqual(
expect.objectContaining({
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
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,
}),
});
});
});

View File

@ -79,6 +79,10 @@ let resolveFetch: typeof import("../../../src/infra/fetch.js").resolveFetch;
let resolveTelegramFetch: typeof import("./fetch.js").resolveTelegramFetch;
let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport;
type TelegramDispatcherPolicy = NonNullable<
ReturnType<typeof resolveTelegramTransport>["dispatcherAttempts"]
>[number]["dispatcherPolicy"];
beforeAll(async () => {
({ resolveFetch } = await import("../../../src/infra/fetch.js"));
({ resolveTelegramFetch, resolveTelegramTransport } = await import("./fetch.js"));
@ -393,6 +397,58 @@ describe("resolveTelegramFetch", () => {
);
});
it("exports fallback dispatcher attempts for Telegram media downloads", () => {
const transport = resolveTelegramTransport(undefined, {
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
});
expect(transport.sourceFetch).toBeDefined();
expect(transport.fetch).not.toBe(transport.sourceFetch);
expect(transport.dispatcherAttempts).toHaveLength(3);
const [defaultAttempt, ipv4Attempt, pinnedAttempt] = transport.dispatcherAttempts as Array<{
dispatcherPolicy?: TelegramDispatcherPolicy;
}>;
expect(defaultAttempt.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
connect: expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
}),
);
expect(ipv4Attempt.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
connect: expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
}),
);
expect(pinnedAttempt.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
pinnedHostname: {
hostname: "api.telegram.org",
addresses: ["149.154.167.220"],
},
connect: expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
}),
);
});
it("does not blind-retry when sticky IPv4 fallback is disallowed for explicit proxy paths", async () => {
const { makeProxyFetch } = await import("./proxy.js");
const proxyFetch = makeProxyFetch("http://127.0.0.1:7890");

View File

@ -106,10 +106,6 @@
"file": "extensions/telegram/src/bot.test.ts",
"reason": "This Telegram bot runtime suite measured ~819.9 MiB RSS growth locally; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file."
},
{
"file": "extensions/telegram/src/fetch.test.ts",
"reason": "This Telegram transport suite measured ~759.3 MiB RSS growth locally; keep it in its own forked channel lane so the shared channels worker can recycle immediately after the hotspot file."
},
{
"file": "extensions/telegram/src/sendchataction-401-backoff.test.ts",
"reason": "This Telegram send-chat-action backoff suite hoists infra-runtime sleep mocks and remains a relatively heavy shared hotspot; keep it isolated so top-level concurrency can overlap it instead of extending the shared channels batch."
@ -210,6 +206,10 @@
"file": "extensions/line/src/send.test.ts",
"reason": "This LINE send suite hoists SDK and account/token mocks; keep it isolated so shared extension workers do not reuse cached send modules that bypass those mocks."
},
{
"file": "extensions/telegram/src/fetch.test.ts",
"reason": "This Telegram transport suite is extension-owned and still measures high RSS locally; keep it in its own forked extensions lane so the shared worker can recycle immediately after the hotspot."
},
{
"file": "extensions/tavily/src/tavily-extract-tool.test.ts",
"reason": "This extract-tool suite hoists the Tavily client mock and imports lazily; keep it isolated so shared extension workers do not reuse cached tool modules and hit real API-key resolution."

View File

@ -60,15 +60,9 @@
"extensions/discord/src/monitor/message-handler.queue.test.ts": {
"durationMs": 3900
},
"extensions/telegram/src/fetch.test.ts": {
"durationMs": 3500
},
"extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts": {
"durationMs": 3200
},
"extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts": {
"durationMs": 15830
},
"extensions/telegram/src/webhook.test.ts": {
"durationMs": 2900
},

View File

@ -1047,9 +1047,13 @@
"durationMs": 2.155517578125,
"testCount": 5
},
"extensions/telegram/src/fetch.network-policy.test.ts": {
"durationMs": 3260,
"testCount": 5
"extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts": {
"durationMs": 10360,
"testCount": 27
},
"extensions/telegram/src/fetch.test.ts": {
"durationMs": 750,
"testCount": 23
}
}
}

View File

@ -447,14 +447,14 @@ describe("scripts/test-parallel lane planning", () => {
expect(output).not.toContain("vitest.unit.config.ts");
});
it("routes telegram fetch network policy through the extensions config", () => {
it("routes telegram fetch transport coverage through the extensions config", () => {
const output = runPlannerPlan([
"--explain",
bundledPluginFile("telegram", "src/fetch.network-policy.test.ts"),
bundledPluginFile("telegram", "src/fetch.test.ts"),
]);
expect(output).toContain("surface=extensions");
expect(output).toContain("reasons=extensions-surface");
expect(output).toContain("extensions-surface");
expect(output).toContain("vitest.extensions.config.ts");
expect(output).not.toContain("vitest.channels.config.ts");
});

View File

@ -48,6 +48,15 @@ describe("createScopedVitestConfig", () => {
expect(config.test?.include).toEqual(["**/*.test.ts"]);
expect(config.test?.exclude).toEqual(expect.arrayContaining(["channel/**", "dist/**"]));
});
it("overrides setup files when a scoped config requests them", () => {
const config = createScopedVitestConfig(["src/example.test.ts"], {
env: {},
setupFiles: ["test/setup.extensions.ts"],
});
expect(config.test?.setupFiles).toEqual(["test/setup.extensions.ts"]);
});
});
describe("scoped vitest configs", () => {
@ -97,19 +106,23 @@ describe("scoped vitest configs", () => {
expect(defaultExtensionsConfig.test?.include).toEqual(["**/*.test.ts"]);
});
it("keeps telegram fetch network policy in extensions while excluding other telegram channel suites", () => {
it("keeps telegram fetch transport coverage in extensions while excluding other telegram channel suites", () => {
const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? [];
expect(
extensionExcludes.some((pattern) => path.matchesGlob("telegram/src/fetch.test.ts", pattern)),
).toBe(true);
).toBe(false);
expect(
extensionExcludes.some((pattern) =>
path.matchesGlob("telegram/src/fetch.network-policy.test.ts", pattern),
path.matchesGlob("telegram/src/bot/delivery.resolve-media-retry.test.ts", pattern),
),
).toBe(false);
expect(defaultChannelsConfig.test?.exclude).toContain(
bundledPluginFile("telegram", "src/fetch.network-policy.test.ts"),
bundledPluginFile("telegram", "src/fetch.test.ts"),
);
expect(defaultChannelsConfig.test?.exclude).toContain(
bundledPluginFile("telegram", "src/bot/delivery.resolve-media-retry.test.ts"),
);
expect(defaultExtensionsConfig.test?.setupFiles).toEqual(["test/setup.extensions.ts"]);
});
it("normalizes gateway include patterns relative to the scoped dir", () => {

View File

@ -8,7 +8,8 @@ import {
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
export const extensionRoutedChannelTestFiles = [
bundledPluginFile("telegram", "src/fetch.network-policy.test.ts"),
bundledPluginFile("telegram", "src/bot/delivery.resolve-media-retry.test.ts"),
bundledPluginFile("telegram", "src/fetch.test.ts"),
];
const extensionRoutedChannelTestFileSet = new Set(extensionRoutedChannelTestFiles);