// proxy_test.ts — юнит-тесты на proxy.ts. Намеренно БЕЗ внешних зависимостей // (никаких jsr:/npm: импортов) — только простые ручные assert-функции ниже. // Так `deno test` работает даже без доступа в интернет и не тянет lock-файл. // // Запуск: // deno test proxy_test.ts import { ALLOWED_HOSTS, MAX_REQUEST_BODY_BYTES, PROXY_AUTH_HEADER, buildForwardHeaders, buildResponseHeaders, handleRequest, limitStreamBytes, resolveTarget, } from "./proxy.ts"; const TEST_SECRET = "isolated-test-secret-0123456789abcdef"; const AUTH_HEADERS = { [PROXY_AUTH_HEADER]: TEST_SECRET }; function assert(condition: boolean, message: string): void { if (!condition) throw new Error(message); } function assertEquals(actual: T, expected: T, message?: string): void { const ok = JSON.stringify(actual) === JSON.stringify(expected); if (!ok) { throw new Error( message ?? `Ожидалось ${JSON.stringify(expected)}, получено ${JSON.stringify(actual)}`, ); } } // ─────────────────────────── resolveTarget ─────────────────────────── Deno.test("resolveTarget строит корректный URL для разрешённого хоста", () => { const result = resolveTarget("/fetch/api.telegram.org/bot123/getMe", ""); assert(result.ok, "ожидался ok=true"); if (result.ok) { assertEquals(result.url, "https://api.telegram.org/bot123/getMe"); } }); Deno.test("resolveTarget сохраняет query-строку как есть", () => { const result = resolveTarget("/fetch/www.tikwm.com/api/", "?url=https://x&hd=1"); assert(result.ok, "ожидался ok=true"); if (result.ok) { assertEquals(result.url, "https://www.tikwm.com/api/?url=https://x&hd=1"); } }); Deno.test("resolveTarget не теряет завершающий слеш в пути (регрессия)", () => { // РЕГРЕССИЯ: первая версия использовала .filter(Boolean) при разборе пути, // который съедал пустой хвостовой сегмент после последнего "/" — путь вида // "/fetch/host/api/" превращался в "https://host/api" (без слеша). Именно // такой формат URL реально нужен TikWM (см. bot.py: ".../api/?url=..."). const withSlash = resolveTarget("/fetch/www.tikwm.com/api/", ""); assert(withSlash.ok, "ожидался ok=true"); if (withSlash.ok) assertEquals(withSlash.url, "https://www.tikwm.com/api/"); const withoutSlash = resolveTarget("/fetch/www.tikwm.com/api", ""); assert(withoutSlash.ok, "ожидался ok=true"); if (withoutSlash.ok) assertEquals(withoutSlash.url, "https://www.tikwm.com/api"); }); Deno.test("resolveTarget корректно строит вложенный путь (Telegram file API)", () => { // Регрессия на реальный формат вызова из bot.py (_download_telegram_file_bytes): // {TELEGRAM_API_BASE_URL}/file/bot{TOKEN}/{file_path} const result = resolveTarget("/fetch/api.telegram.org/file/bot123/photos/file_1.jpg", ""); assert(result.ok, "ожидался ok=true"); if (result.ok) { assertEquals(result.url, "https://api.telegram.org/file/bot123/photos/file_1.jpg"); } }); Deno.test("resolveTarget отклоняет неразрешённый хост с 403 и не строит URL на его основе", () => { const result = resolveTarget("/fetch/evil.example.com/steal", ""); assert(!result.ok, "ожидался ok=false"); if (!result.ok) { assertEquals(result.status, 403); } }); Deno.test("resolveTarget отклоняет некорректный формат пути с 404", () => { assertEquals(resolveTarget("/", "").ok, false); assertEquals(resolveTarget("/fetch", "").ok, false); assertEquals(resolveTarget("/fetch/", "").ok, false); assertEquals(resolveTarget("/wrong-prefix/api.telegram.org/getMe", "").ok, false); }); Deno.test("resolveTarget требует форму bot-пути для api.telegram.org", () => { // Без токена в пути — не релей: произвольные пути при наличии секрета закрыты. assertEquals(resolveTarget("/fetch/api.telegram.org/getMe", "").ok, false); assertEquals(resolveTarget("/fetch/api.telegram.org/evil", "").ok, false); assertEquals(resolveTarget("/fetch/api.telegram.org/bot", "").ok, false); // Легитимные формы бота проходят. assertEquals(resolveTarget("/fetch/api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getMe", "").ok, true); assertEquals(resolveTarget("/fetch/api.telegram.org/file/bot123/photos/f.jpg", "").ok, true); // Чужим хостам форма не нужна. assertEquals(resolveTarget("/fetch/www.tikwm.com/api/", "").ok, true); }); Deno.test("ALLOWED_HOSTS содержит ровно те хосты, что реально нужны боту", () => { assertEquals(ALLOWED_HOSTS.has("api.telegram.org"), true); assertEquals(ALLOWED_HOSTS.has("www.tikwm.com"), true); assertEquals(ALLOWED_HOSTS.has("tikwm.com"), true); assertEquals(ALLOWED_HOSTS.size, 3); }); // ─────────────────────────── buildForwardHeaders / buildResponseHeaders ─────────────────────────── Deno.test("buildForwardHeaders убирает hop-by-hop заголовки запроса, остальные сохраняет", () => { const original = new Headers({ "Host": "proxy.example.com", "Connection": "keep-alive", "User-Agent": "test-ua", }); const forwarded = buildForwardHeaders(original); assertEquals(forwarded.has("host"), false); assertEquals(forwarded.has("connection"), false); assertEquals(forwarded.get("user-agent"), "test-ua"); }); Deno.test("buildResponseHeaders убирает Content-Encoding/Content-Length (см. докстринг про распаковку)", () => { // РЕГРЕССИЯ: fetch() в Deno сам распаковывает gzip/br до того, как тело // становится доступно — пробросить исходный Content-Encoding было бы багом // (клиент попытался бы распаковать уже распакованные данные). const original = new Headers({ "Content-Encoding": "gzip", "Content-Length": "1234", "Content-Type": "application/json", }); const cleaned = buildResponseHeaders(original); assertEquals(cleaned.has("content-encoding"), false); assertEquals(cleaned.has("content-length"), false); assertEquals(cleaned.get("content-type"), "application/json"); }); // ─────────────────────────── handleRequest (с подменённым fetch) ─────────────────────────── Deno.test("handleRequest пробрасывает GET без тела и возвращает статус/тело апстрима", async () => { const fakeFetch: typeof fetch = (url, init) => { assertEquals(String(url), "https://www.tikwm.com/api/?url=x"); assertEquals(init?.method, "GET"); assertEquals(init?.body, undefined); return Promise.resolve(new Response(JSON.stringify({ code: 0 }), { status: 200, headers: { "Content-Type": "application/json" }, })); }; const req = new Request("https://proxy.example/fetch/www.tikwm.com/api/?url=x", { method: "GET", headers: AUTH_HEADERS }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 200); const body = await resp.json(); assertEquals(body, { code: 0 }); }); Deno.test("handleRequest возвращает 403 для неразрешённого хоста, не вызывая fetch вообще", async () => { let fetchCalled = false; const fakeFetch: typeof fetch = () => { fetchCalled = true; return Promise.resolve(new Response("не должно случиться")); }; const req = new Request("https://proxy.example/fetch/evil.example.com/x", { method: "GET", headers: AUTH_HEADERS }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 403); assertEquals(fetchCalled, false, "fetch не должен был вызываться для неразрешённого хоста"); }); Deno.test("handleRequest возвращает 404 для пути без /fetch/ префикса", async () => { const req = new Request("https://proxy.example/something-else", { method: "GET", headers: AUTH_HEADERS }); const unusedFetch: typeof fetch = () => Promise.resolve(new Response("unused")); const resp = await handleRequest(req, unusedFetch, TEST_SECRET); assertEquals(resp.status, 404); }); Deno.test("handleRequest пробрасывает метод и тело для POST (Telegram sendMessage)", async () => { let capturedMethod: string | undefined; let capturedBodyIsStream = false; const fakeFetch: typeof fetch = (_url, init) => { capturedMethod = init?.method; capturedBodyIsStream = init?.body instanceof ReadableStream; return Promise.resolve(new Response('{"ok":true}', { status: 200 })); }; const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/sendMessage", { method: "POST", body: JSON.stringify({ chat_id: 1, text: "hi" }), headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 200); assertEquals(capturedMethod, "POST"); assert(capturedBodyIsStream, "тело POST-запроса должно передаваться апстриму как поток (без буферизации целиком)"); }); Deno.test("handleRequest возвращает 502, если апстрим-fetch упал с исключением", async () => { const fakeFetch: typeof fetch = () => { throw new Error(`network unreachable ${TEST_SECRET}`); }; const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { method: "GET", headers: AUTH_HEADERS }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 502); assertEquals(await resp.text(), "Upstream fetch failed"); }); for (const suppliedSecret of [undefined, "", "incorrect-test-secret"]) { Deno.test(`handleRequest rejects missing or wrong secret: ${String(suppliedSecret)}`, async () => { let fetchCalled = false; const fakeFetch: typeof fetch = () => { fetchCalled = true; return Promise.resolve(new Response("unexpected")); }; const headers = new Headers(); if (suppliedSecret !== undefined) headers.set(PROXY_AUTH_HEADER, suppliedSecret); const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { headers }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 401); assertEquals(await resp.text(), "Unauthorized"); assertEquals(fetchCalled, false); }); } for (const configuredSecret of [undefined, "", " ", "invalid secret", "short"]) { Deno.test(`handleRequest fails closed for invalid configuration: ${String(configuredSecret)}`, async () => { let fetchCalled = false; const fakeFetch: typeof fetch = () => { fetchCalled = true; return Promise.resolve(new Response("unexpected")); }; const req = new Request("https://proxy.example/fetch/www.tikwm.com/api/", { headers: AUTH_HEADERS }); const resp = await handleRequest(req, fakeFetch, configuredSecret); assertEquals(resp.status, 503); assertEquals(await resp.text(), "Proxy authentication unavailable"); assertEquals(fetchCalled, false); }); } Deno.test("correct secret is stripped without changing Telegram authentication", async () => { let fetchCalled = false; const fakeFetch: typeof fetch = (url, init) => { fetchCalled = true; assertEquals(String(url), "https://api.telegram.org/bot123/getMe"); const headers = new Headers(init?.headers); assertEquals(headers.has(PROXY_AUTH_HEADER), false); assertEquals(headers.get("authorization"), "Bearer upstream-test-token"); assertEquals(init?.redirect, "error"); return Promise.resolve(new Response('{"ok":true}', { status: 200 })); }; const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { headers: { "x-lumen-proxy-secret": TEST_SECRET, "Authorization": "Bearer upstream-test-token", }, }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 200); assertEquals(fetchCalled, true); assertEquals(await resp.json(), { ok: true }); assertEquals(req.headers.get(PROXY_AUTH_HEADER), TEST_SECRET); }); Deno.test("buildForwardHeaders removes mixed-case proxy secret without mutating input", () => { const original = new Headers({ "X-LuMeN-PrOxY-SeCrEt": TEST_SECRET }); assertEquals(buildForwardHeaders(original).has(PROXY_AUTH_HEADER), false); assertEquals(original.get(PROXY_AUTH_HEADER), TEST_SECRET); }); Deno.test("handleRequest rejects oversized declared body before fetching", async () => { let fetchCalled = false; const fakeFetch: typeof fetch = () => { fetchCalled = true; return Promise.resolve(new Response("unexpected")); }; const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/sendDocument", { method: "POST", headers: { ...AUTH_HEADERS, "content-length": String(MAX_REQUEST_BODY_BYTES + 1), }, }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(resp.status, 413); assertEquals(fetchCalled, false); }); Deno.test("handleRequest allows body at the cap and unknown-length streams", async () => { const seen: number[] = []; const fakeFetch: typeof fetch = () => { seen.push(1); return Promise.resolve(new Response('{"ok":true}', { status: 200 })); }; const atCap = new Request("https://proxy.example/fetch/api.telegram.org/bot123/sendDocument", { method: "POST", headers: { ...AUTH_HEADERS, "content-length": String(MAX_REQUEST_BODY_BYTES) }, }); assertEquals((await handleRequest(atCap, fakeFetch, TEST_SECRET)).status, 200); const streamed = new Request("https://proxy.example/fetch/api.telegram.org/bot123/sendVideo", { method: "POST", headers: { ...AUTH_HEADERS }, body: new Uint8Array([1, 2, 3]), }); assertEquals((await handleRequest(streamed, fakeFetch, TEST_SECRET)).status, 200); assertEquals(seen.length, 2); }); Deno.test("limitStreamBytes пропускает тело в пределах капа без буферизации", async () => { const capped = limitStreamBytes(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2])); controller.enqueue(new Uint8Array([3])); controller.close(); }, }), 3); assert(capped !== null, "ожидался поток"); assertEquals(Array.from(new Uint8Array(await new Response(capped).arrayBuffer())), [1, 2, 3]); }); Deno.test("limitStreamBytes рвёт поток при превышении капа", async () => { const capped = limitStreamBytes(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2])); controller.enqueue(new Uint8Array([3, 4])); controller.close(); }, }), 3); assert(capped !== null, "ожидался поток"); let failed = false; try { await new Response(capped).arrayBuffer(); } catch { failed = true; } assert(failed, "поток сверх капа обязан оборваться, а не доставиться целиком"); }); Deno.test("handleRequest не пропускает chunked-тело запроса сверх капа", async () => { const fakeFetch: typeof fetch = async (_url, init) => { await new Response(init?.body as BodyInit | null | undefined).arrayBuffer(); return new Response('{"ok":true}', { status: 200 }); }; const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/sendVideo", { method: "POST", headers: { ...AUTH_HEADERS }, body: new Uint8Array([1, 2, 3, 4, 5]), }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET, 3); assertEquals(resp.status, 413); assertEquals(await resp.text(), "Request body too large"); }); Deno.test("handleRequest не отдаёт тело ответа сверх капа", async () => { const fakeFetch: typeof fetch = () => Promise.resolve(new Response(new Uint8Array([1, 2, 3, 4]), { status: 200 })); const req = new Request("https://proxy.example/fetch/api.telegram.org/file/bot123/x", { headers: { ...AUTH_HEADERS }, }); const resp = await handleRequest(req, fakeFetch, TEST_SECRET, 3); let failed = false; try { await resp.arrayBuffer(); } catch { failed = true; } assert(failed, "ответ сверх капа обязан оборваться, а не утечь целиком"); }); Deno.test("handleRequest проверяет секрет раньше маршрутизации (401 раньше 403/404)", async () => { const unusedFetch: typeof fetch = () => Promise.resolve(new Response("unused")); const noSecret404 = new Request("https://proxy.example/something-else"); assertEquals((await handleRequest(noSecret404, unusedFetch, TEST_SECRET)).status, 401); const noSecret403 = new Request("https://proxy.example/fetch/evil.example.com/x"); assertEquals((await handleRequest(noSecret403, unusedFetch, TEST_SECRET)).status, 401); }); Deno.test("resolveTarget отклоняет враждебные варианты хостов", () => { assertEquals(resolveTarget("/fetch/API.TELEGRAM.ORG/x", "").ok, false); assertEquals(resolveTarget("/fetch/api.telegram.org./x", "").ok, false); assertEquals(resolveTarget("/fetch/api.telegram.org:443/x", "").ok, false); assertEquals(resolveTarget("/fetch/api.telegram.org%2Fevil/x", "").ok, false); assertEquals(resolveTarget("/fetch/../x", "").ok, false); }); Deno.test("handleRequest требует от апстрима не следовать редиректам", async () => { let capturedRedirect: string | undefined; const fakeFetch: typeof fetch = (_url, init) => { capturedRedirect = init?.redirect; return Promise.resolve(new Response('{"ok":true}', { status: 200 })); }; const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { headers: AUTH_HEADERS }); await handleRequest(req, fakeFetch, TEST_SECRET); assertEquals(capturedRedirect, "error"); }); Deno.test("handleRequest терпит кривой content-length (NaN/отрицательный)", async () => { let fetchCalled = 0; const fakeFetch: typeof fetch = () => { fetchCalled++; return Promise.resolve(new Response('{"ok":true}', { status: 200 })); }; for (const declared of ["abc", "-5"]) { const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { headers: { ...AUTH_HEADERS, "content-length": declared }, }); assertEquals((await handleRequest(req, fakeFetch, TEST_SECRET)).status, 200); } assertEquals(fetchCalled, 2); }); Deno.test("limitStreamBytes пропускает null без изменений", () => { assertEquals(limitStreamBytes(null, 3), null); }); Deno.test("buildForwardHeaders убирает расширенный hop-by-hop список и content-length", () => { const original = new Headers({ "Transfer-Encoding": "chunked", "TE": "trailers", "Upgrade": "websocket", "Content-Length": "10", "X-Custom": "keep", }); const forwarded = buildForwardHeaders(original); for (const name of ["transfer-encoding", "te", "upgrade", "content-length"]) { assertEquals(forwarded.has(name), false); } assertEquals(forwarded.get("x-custom"), "keep"); });