#!/usr/bin/env node /* * webhook.test.mjs — the Worker entry (/api/session + /telegram/webhook gate). * --------------------------------------------------------------------------- * Source-under-test: src/webhook.ts, exercised through its exported * `fetch(request, env)` with undici Request/Response (Node 20+ ships both; * the code paths here are platform-neutral, no workerd needed). * * Scope note (offline-only): grammY's cloudflare-mod adapter calls * `bot.init()` (a live getMe) BEFORE its own secret-token compare, so no * committed case may let an update REACH the framework. Do not add a case * that posts a well-formed update with an ARMED secret — it would call the * live Bot API. Armed-secret behavior is verified by the live smokes in * plan 02-03 instead. * * Cases (WR-01 body cap, WR-02 fail-closed gate, T-02-01 gate): * WK1 POST valid initData under the cap → 200 {token: uuid}; * exactly two TTL-carrying KV writes (900 s) * WK2 POST an over-cap (4 KB+) but VALIDLY SIGNED initData → 413 * {error:"malformed"}, zero KV writes, log is category-only * ("[initdata] rejected: malformed") and echoes no payload * WK2b lying Content-Length (declares 100, ships 5000+) → 413 via the * post-read length check (the header blind spot in WR-01) * WK3 declared-length 999999 + unreadable body stream → 413 resolved * WITHOUT pulling the stream (the pre-read check refuses to buffer) * WK4 GET /api/session → 405 (the cap logic never runs on GET) * WK5 unarmed WEBHOOK_SECRET → /telegram/webhook rejects CLOSED with a * constant 500 BEFORE grammY's compareSecretToken (which would * accept every request once the secret is undefined — WR-02) */ import { strict as assert } from "node:assert"; import { createHmac } from "node:crypto"; import { test } from "./harness.mjs"; const TEST_TOKEN = "12345:TESTTOKEN"; // same signer token as initdata.test.mjs /* ————— node:crypto signing helpers (test-side, independent path) ————— */ function dataCheckString(fields) { return fields .filter(([key]) => key !== "hash") .slice() .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join("\n"); } function telegramSecretKey(botToken) { return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest(); } function buildInitData(fields) { const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN)) .update(dataCheckString(fields), "utf8") .digest("hex"); const wire = fields .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join("&"); return `${wire}&hash=${hash}`; } function signedUser(authDateSeconds, extraFields = []) { return buildInitData([ ...extraFields, ["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })], ["auth_date", String(authDateSeconds)], ]); } /* ————— env / request harness ————— */ function makeKV() { const map = new Map(); const ttlLog = []; return { async get(key) { return map.has(key) ? map.get(key) : null; }, async put(key, value, opts) { ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null }); map.set(key, value); }, async delete(key) { map.delete(key); }, map, ttlLog, }; } function makeEnv(kv) { return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv }; } const handler = await import("../src/webhook.ts").then((m) => m.default.fetch); function postSession(body, headers = {}, kvOverride = null) { const kv = kvOverride ?? makeKV(); const req = new Request("https://host/api/session", { method: "POST", ...(body !== undefined ? { body, headers } : { headers }), }); return { req, kv, resP: handler(req, makeEnv(kv), {}) }; } /* ————— WK1 — the happy path still works under the cap ————— */ test("WK1 POST valid initData (under cap) → 200 {token: uuid}; exactly two 900s KV writes", async () => { const { kv, resP } = postSession(signedUser(Math.floor(Date.now() / 1000))); const res = await resP; assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.clone().text()}`); const body = await res.json(); assert.match(body.token, /^[0-9a-f-]{36}$/, "opaque UUID token (D-06)"); assert.equal(kv.ttlLog.length, 2, "mint writes exactly two keys"); for (const entry of kv.ttlLog) assert.strictEqual(entry.expirationTtl, 900, "TTL-only (PRIV-02)"); }); /* ————— WK2 — over-cap signed body → 413, category-only, no side effects ————— */ test("WK2 POST 4KB+ validly-signed initData → 413 malformed; zero KV writes; log carries ONLY the category", async () => { const kv = makeKV(); const seen = []; const orig = console.log; console.log = (...args) => seen.push(args.map((a) => String(a)).join(" ")); let res; try { // validly signed — proves the cap fires before/instead of validation, // and that a real signature cannot buy the body past the cap const overCapSigned = signedUser(Math.floor(Date.now() / 1000), [["start_param", "p".repeat(4900)]]); assert.ok(overCapSigned.length > 4096, "fixture must exceed the cap"); res = await handler( new Request("https://host/api/session", { method: "POST", body: overCapSigned }), makeEnv(kv), {}, ); } finally { console.log = orig; } assert.equal(res.status, 413, `expected 413, got ${res.status}`); assert.deepEqual(await res.json(), { error: "malformed" }, "category-only body (D-14)"); assert.equal(kv.map.size, 0, "no session minted"); assert.equal(kv.ttlLog.length, 0, "no KV writes at all"); assert.deepEqual(seen, ["[initdata] rejected: malformed"], "log is the category ONLY — no payload fragment, no ids"); }); /* ————— WK2b/WK3 — the two blind spots of a naive body cap ————— */ /* ————— WK2b/WK3 — the two blind spots of a naive body cap ————— */ test("WK2b lying Content-Length (declares 100, ships 5000+) → 413 via the post-read check", async () => { const { kv, resP } = postSession("a=".concat("b".repeat(5000)), { "content-length": "100" }); const res = await resP; assert.equal(res.status, 413, "the post-read length check must backstop a lying header"); assert.deepEqual(await res.json(), { error: "malformed" }); assert.equal(kv.map.size, 0); }); test("WK3 declared-length 999999 + unreadable body stream → handler RESOLVES 413 (must not await the body)", async () => { // The stream errors if any consumer pulls it. The assertion that matters: // handler() resolves with 413 — had the handler awaited req.text() on a // broken stream, the fetch call would REJECT instead. (Node/undici pulls // stream bodies opportunistically after construction, so pull-tracking is // implementation noise, not boundable here; the resolution outcome is.) let pulled = false; const unreadable = new ReadableStream({ pull(controller) { pulled = true; controller.error(new Error("body must never be read (WR-01 pre-read path)")); }, }); void pulled; const kv = makeKV(); const req = new Request("https://host/api/session", { method: "POST", headers: { "content-length": "999999" }, body: unreadable, duplex: "half", // required by undici for stream bodies }); const res = await handler(req, makeEnv(kv), {}); assert.equal(res.status, 413, "resolve 413 — the pre-read check must refuse to buffer the declared-oversize body"); assert.equal(kv.map.size, 0); }); /* ————— WK4 — method gate ————— */ test("WK4 GET /api/session → 405", async () => { const req = new Request("https://host/api/session", { method: "GET" }); const res = await handler(req, makeEnv(makeKV()), {}); assert.equal(res.status, 405); }); /* ————— WK5 — WR-02 fail-closed webhook gate ————— */ test("WK5 unarmed WEBHOOK_SECRET ('') → webhook rejects CLOSED with a constant 500, no bot run, no KV touch", async () => { const env = { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "", chat_sessions: undefined }; const req = () => new Request("https://host/telegram/webhook", { method: "POST", body: '{\"update_id\":1}' }); const res1 = await handler(req(), env, {}); const res2 = await handler(req(), env, {}); assert.equal(res1.status, 500, "missing binding must fail closed, not fall through to grammY's accept-everything compare"); assert.equal(res2.status, 500, "the rejection is constant across calls"); assert.equal(await res1.text(), "Server misconfigured"); assert.equal(await res2.text(), "Server misconfigured"); assert.ok(env.chat_sessions === undefined, "the guard fired before any storage or bot wiring was needed"); });