Files
arah2/bot/test/webhook.test.mjs
T
araharah-executor 212aa57c14 test(06-03): guidance endpoint suite GW1-GW11 — 401/413/400 classes, claim-owned chat (T-06-02 injection probe), own-vs-foreign GET/DELETE, 405 (RED)
- offline undici Request/Response pattern; claims minted through the real
  /api/session path (mintSession) against the Map-fake KV
- GW8 green-by-construction (the catch-all already 404s absent ids);
  ownership teeth live in GW7/GW10
2026-10-24 13:04:17 +08:00

438 lines
20 KiB
JavaScript

#!/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");
});
/* ————————————————————————————————————————————————————————————————————————
* /api/guidance — the Phase 6 envelope endpoints (06-03, GUID-05/06, D-14).
*
* GW1 POST without Bearer → 401 {error:"unauthorized"}; zero KV writes;
* log is the category ONLY
* GW2 POST with a non-conformant Bearer token → 401 (the charset gate)
* GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes;
* lying Content-Length backstop → 413 via the post-read check
* GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at
* guidance:<id>; a chat_id IN THE BODY is IGNORED (T-06-02 injection
* probe — the CLAIM's chat_id is stored); extra body fields dropped
* GW5 malformed snapshot classes → 400 {error:"malformed"} + category-
* only log; zero writes (non-numeric distanceM, negative durationS,
* non-integer startedAtSec, off-slug destinationId, empty/
* oversized-free destinationName, empty/undecodable geometry,
* broken JSON, null/array body)
* GW6 GET own id → 200 envelope JSON
* GW7 GET a foreign chat's id → 404 (no existence oracle) and the
* record SURVIVES (T-06-02b)
* GW8 GET absent / malformed id → 404
* GW9 DELETE own id → 200 {deleted:true}; the record is GONE (D-14)
* GW10 DELETE a foreign chat's id → 404 and the record SURVIVES
* (cross-chat deletion impossible, T-06-02b)
* GW11 PUT /api/guidance → 405
* ———————————————————————————————————————————————————————————————————————— */
const { mintSession } = await import("../src/kv/sessions.ts");
const { mintGuidanceSession } = await import("../src/kv/guidance-sessions.ts");
/** polyline6, two KL points — the routing workspace's storage format (05-01),
* generated with routing/src/polyline.ts encodePolyline6 (round-trip holds). */
const KL_TWO_POINTS = "oqv_Egt~~`Ewj@_q@";
const VALID_BODY = {
destinationId: "suria-klcc",
destinationName: "Suria KLCC",
distanceM: 8432,
durationS: 900,
geometry: KL_TWO_POINTS,
startedAtSec: 1792817000,
};
/** A memory-held claim the honest way: through the real mint (offline KV). */
async function bearerFor(kv, chatId = 101) {
return mintSession(kv, {
user_id: 42,
chat_id: chatId,
issued_at: Math.floor(Date.now() / 1000),
language_code: "en",
});
}
function guidanceReq(method, { body, token, id, headers } = {}) {
const url = id === undefined ? "https://host/api/guidance" : `https://host/api/guidance?id=${encodeURIComponent(id)}`;
return new Request(url, {
method,
headers: {
...(token ? { authorization: `Bearer ${token}` } : {}),
...(headers ?? {}),
},
...(method === "POST" && body !== undefined
? { body: typeof body === "string" ? body : JSON.stringify(body) }
: {}),
});
}
async function captureLogs(fn) {
const seen = [];
const orig = console.log;
console.log = (...args) => seen.push(args.map((a) => String(a)).join(" "));
try {
return { result: await fn(), seen };
} finally {
console.log = orig;
}
}
async function seedEnvelope(kv, chatId) {
const token = await bearerFor(kv, chatId);
const envelope = await mintGuidanceSession(kv, chatId, VALID_BODY, 1792817600);
return { token, id: envelope.id };
}
test("GW1 POST /api/guidance without Bearer → 401 {error:'unauthorized'}; zero KV writes; log is the category ONLY", async () => {
const kv = makeKV();
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: VALID_BODY }), makeEnv(kv), {}));
const res = await result;
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
assert.deepEqual(await res.json(), { error: "unauthorized" });
assert.equal(kv.ttlLog.length, 0, "an unauthorized caller writes nothing");
assert.deepEqual(seen, ["[guidance] rejected: unauthorized"], "category ONLY — no token, no body fragment");
});
test("GW2 POST with a non-conformant Bearer token (charset gate) → 401", async () => {
const kv = makeKV();
const res = await handler(guidanceReq("POST", { body: VALID_BODY, token: "bad_token!!" }), makeEnv(kv), {});
assert.equal(res.status, 401);
assert.deepEqual(await res.json(), { error: "unauthorized" });
assert.equal(kv.ttlLog.length, 0);
});
test("GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes; category-only log; lying Content-Length backstop → 413", async () => {
const kv = makeKV();
const token = await bearerFor(kv, 101);
const overCap = JSON.stringify({ ...VALID_BODY, destinationName: "x".repeat(9000) });
assert.ok(overCap.length > 8192, "fixture must exceed the cap");
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: overCap, token }), makeEnv(kv), {}));
const res = await result;
assert.equal(res.status, 413, "the pre-read cap refuses to buffer");
assert.deepEqual(await res.json(), { error: "malformed" });
assert.equal(kv.ttlLog.length, 0);
assert.deepEqual(seen, ["[guidance] rejected: malformed"], "category ONLY");
// lying header: declares 100, ships 9000+ → the post-read check closes the blind spot
const kv2 = makeKV();
const token2 = await bearerFor(kv2, 101);
const lying = await handler(
guidanceReq("POST", { body: overCap, token: token2, headers: { "content-length": "100" } }),
makeEnv(kv2),
{},
);
assert.equal(lying.status, 413, "the post-read length check must backstop a lying header");
assert.equal(kv2.ttlLog.length, 0);
});
test("GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at guidance:<id>; body chat_id 999 IGNORED (T-06-02); extra fields dropped", async () => {
const kv = makeKV();
const token = await bearerFor(kv, 101);
const res = await handler(
guidanceReq("POST", { body: { ...VALID_BODY, chat_id: 999, extra: "junk" }, token }),
makeEnv(kv),
{},
);
assert.equal(res.status, 201, `expected 201, got ${res.status}: ${await res.clone().text()}`);
const { id } = await res.json();
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id");
assert.equal(kv.ttlLog.length, 1, "mint is exactly one KV write");
assert.equal(kv.ttlLog[0].key, `guidance:${id}`);
assert.equal(kv.ttlLog[0].expirationTtl, 7200, "TTL-only (GE1)");
const stored = JSON.parse(kv.map.get(`guidance:${id}`));
assert.equal(stored.chat_id, 101, "identity comes from the CLAIM — the body's chat_id 999 is structurally ignored (T-06-02)");
assert.deepEqual(Object.keys(stored).sort(), ["chat_id", "created_at", "id", "route_snapshot"], "frozen 4-field envelope");
assert.deepEqual(
Object.keys(stored.route_snapshot).sort(),
["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"],
"exactly the six snapshot fields — extra body properties never reach storage",
);
assert.equal(stored.route_snapshot.destinationName, "Suria KLCC");
});
test("GW5 malformed snapshot classes → 400 {error:'malformed'} + category-only log; zero KV writes", async () => {
const badBodies = [
["broken JSON", "{not json"],
["null body", "null"],
["array body", "[1,2]"],
["non-numeric distanceM", { ...VALID_BODY, distanceM: "far" }],
["negative durationS", { ...VALID_BODY, durationS: -5 }],
["non-integer startedAtSec", { ...VALID_BODY, startedAtSec: 1.5 }],
["off-slug destinationId", { ...VALID_BODY, destinationId: "Suria KLCC" }],
["empty destinationName", { ...VALID_BODY, destinationName: "" }],
["empty geometry (decodes to 0 points)", { ...VALID_BODY, geometry: "" }],
["undecodable geometry (truncated)", { ...VALID_BODY, geometry: "x" }],
];
for (const [label, body] of badBodies) {
const kv = makeKV();
const token = await bearerFor(kv, 101);
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body, token }), makeEnv(kv), {}));
const res = await result;
assert.equal(res.status, 400, `${label} must be a 400, got ${res.status}`);
assert.deepEqual(await res.json(), { error: "malformed" }, `${label} carries the category only`);
assert.deepEqual(seen, ["[guidance] rejected: malformed"], `${label} logs the category ONLY`);
assert.equal(kv.ttlLog.length, 0, `${label} writes nothing`);
}
});
test("GW6 GET own id → 200 with the envelope JSON", async () => {
const kv = makeKV();
const { token, id } = await seedEnvelope(kv, 101);
const res = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
const envelope = await res.json();
assert.equal(envelope.id, id);
assert.equal(envelope.chat_id, 101);
assert.deepEqual(envelope.route_snapshot, VALID_BODY);
});
test("GW7 GET a foreign chat's id → 404 (no existence oracle) and the record SURVIVES (T-06-02b)", async () => {
const kv = makeKV();
const { id } = await seedEnvelope(kv, 101);
const mallory = await bearerFor(kv, 202);
const res = await handler(guidanceReq("GET", { token: mallory, id }), makeEnv(kv), {});
assert.equal(res.status, 404);
assert.deepEqual(await res.json(), { error: "not_found" }, "foreign and absent are indistinguishable");
assert.ok(kv.map.has(`guidance:${id}`), "a foreign GET never deletes");
});
test("GW8 GET absent / malformed id → 404", async () => {
const kv = makeKV();
const { token } = await seedEnvelope(kv, 101);
for (const id of ["3f2a2c3e-1b4d-4e5a-9c8d-0000000000ff", "garbage", ""]) {
const res = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
assert.equal(res.status, 404, `id ${JSON.stringify(id)} must 404`);
}
});
test("GW9 DELETE own id → 200 {deleted:true}; the KV record is GONE (D-14's server half)", async () => {
const kv = makeKV();
const { token, id } = await seedEnvelope(kv, 101);
const res = await handler(guidanceReq("DELETE", { token, id }), makeEnv(kv), {});
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
assert.deepEqual(await res.json(), { deleted: true });
assert.equal(kv.map.has(`guidance:${id}`), false, "actively removed, not merely expired");
const again = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
assert.equal(again.status, 404);
});
test("GW10 DELETE a foreign chat's id → 404 and the record SURVIVES (cross-chat deletion impossible, T-06-02b)", async () => {
const kv = makeKV();
const { id } = await seedEnvelope(kv, 101);
const mallory = await bearerFor(kv, 202);
const res = await handler(guidanceReq("DELETE", { token: mallory, id }), makeEnv(kv), {});
assert.equal(res.status, 404);
assert.deepEqual(await res.json(), { error: "not_found" });
assert.ok(kv.map.has(`guidance:${id}`), "a foreign DELETE never deletes");
});
test("GW11 PUT /api/guidance → 405", async () => {
const kv = makeKV();
const token = await bearerFor(kv, 101);
const res = await handler(guidanceReq("PUT", { token }), makeEnv(kv), {});
assert.equal(res.status, 405);
});