Spaces:
Running
Running
| // proxy_test.ts — юнит-тесты на proxy.ts. Намеренно БЕЗ внешних зависимостей | |
| // (никаких jsr:/npm: импортов) — только простые ручные assert-функции ниже. | |
| // Так `deno test` работает даже без доступа в интернет и не тянет lock-файл. | |
| // | |
| // Запуск: | |
| // deno test proxy_test.ts | |
| import { | |
| ALLOWED_HOSTS, | |
| PROXY_AUTH_HEADER, | |
| buildForwardHeaders, | |
| buildResponseHeaders, | |
| handleRequest, | |
| resolveTarget, | |
| } from "./proxy.ts"; | |
| const TEST_SECRET = "isolated-test-secret"; | |
| const AUTH_HEADERS = { [PROXY_AUTH_HEADER]: TEST_SECRET }; | |
| function assert(condition: boolean, message: string): void { | |
| if (!condition) throw new Error(message); | |
| } | |
| function assertEquals<T>(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.host, "api.telegram.org"); | |
| 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("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/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"]) { | |
| 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); | |
| }); | |