#!/usr/bin/env node /* * location-pin.test.mjs — the TTL-only location-pin KV layer + both * Worker routes that relay a chat-shared pin to the Mini App (GEO-02/03). * ---------------------------------------------------------------------- * Cases: * LP1–LP4 kv/location-pin.ts: single TTL-carrying chat-scoped write, * round-trip, range guard (null-island), malformed-JSON * defensive read * LP5–LP9 GET /api/location: 401s (missing/unknown token), honest * {pin:null}, CLAIM-scoped pin read (V4 cross-user * isolation), 405 * LP10–LP14 POST /api/location-fallback: 401s (missing/unknown * token), request_location keyboard to the CLAIM's chat, * locale from claim.language_code, 405 * * Transport note: grammY's node shim sends through node-fetch@2 (its own * require cache), NOT globalThis.fetch — LP12/LP13 patch node-fetch's * cached `default` export (the object grammy's shim getter reads at every * Bot construction) for the duration of the case, then restore it. */ import { strict as assert } from "node:assert"; import { createRequire } from "node:module"; import { test } from "./harness.mjs"; const { writePin, getPin } = await import("../src/kv/location-pin.ts"); const { mintSession, getTokenForChat } = await import("../src/kv/sessions.ts"); const { strings } = await import("../src/i18n/strings.ts"); const handler = await import("../src/webhook.ts").then((m) => m.default.fetch); const TEST_TOKEN = "12345:TESTTOKEN"; /* ————— env / KV fake (verbatim from webhook.test.mjs) and transport stub ————— */ 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 require_ = createRequire(import.meta.url); /** node-fetch's cached module.exports — grammy's node shim reads * `default` off this same object at every Bot construction, so patching * it suspends the outbound Telegram call for the duration of a case. */ const nodeFetchExports = require_("node-fetch"); /** Swap in a capturing stand-in for the Telegram sender; returns a * restore function (run in `finally`). */ function stubTelegramSender(calls) { const previous = nodeFetchExports.default; nodeFetchExports.default = async (_url, init) => { calls.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" }, }); }; return () => { nodeFetchExports.default = previous; }; } /* ————— LP1–LP4 — kv/location-pin.ts ————— */ test("LP1 writePin writes exactly one TTL-carrying key loc:", async () => { const kv = makeKV(); await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 }); assert.equal(kv.ttlLog.length, 1); assert.equal(kv.ttlLog[0].key, "loc:777"); assert.strictEqual(kv.ttlLog[0].expirationTtl, 300); assert.ok(!kv.ttlLog[0].key.includes("user"), "pin key is chat-scoped, not user-id scoped"); }); test("LP2 getPin round-trips a valid record and returns null for absent", async () => { const kv = makeKV(); assert.equal(await getPin(kv, 777), null); await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 }); const pin = await getPin(kv, 777); assert.equal(pin.latitude, 3.1513); assert.equal(pin.longitude, 101.6958); assert.strictEqual(pin.horizontal_accuracy, 12); }); test("LP3 getPin rejects out-of-range coordinates (null island guard)", async () => { const kv = makeKV(); await kv.put("loc:1", JSON.stringify({ latitude: 999, longitude: 101, horizontal_accuracy: null }), { expirationTtl: 300 }); assert.equal(await getPin(kv, 1), null, "absurd latitude must be rejected on read"); await kv.put("loc:2", JSON.stringify({ latitude: 3, longitude: -999, horizontal_accuracy: null }), { expirationTtl: 300 }); assert.equal(await getPin(kv, 2), null, "absurd longitude must be rejected on read"); }); test("LP4 getPin returns null on malformed JSON (defensive read)", async () => { const kv = makeKV(); await kv.put("loc:3", "{not json", { expirationTtl: 300 }); assert.equal(await getPin(kv, 3), null); }); /* ————— LP5–LP9 — GET /api/location (the poll relay) ————— */ test("LP5 GET /api/location rejects a missing bearer token with 401", async () => { const kv = makeKV(); const req = new Request("https://host/api/location", { method: "GET" }); const res = await handler(req, makeEnv(kv), {}); assert.strictEqual(res.status, 401); const body = await res.json(); assert.strictEqual(body.error, "unauthorized"); }); test("LP6 GET /api/location rejects an unknown token with 401", async () => { const kv = makeKV(); const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: "Bearer deadbeef-1234" } }); const res = await handler(req, makeEnv(kv), {}); assert.strictEqual(res.status, 401); }); test("LP7 GET /api/location returns {pin:null} for a valid token with no pin yet", async () => { const kv = makeKV(); await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" }); const token = await getTokenForChat(kv, 777); const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token}` } }); const res = await handler(req, makeEnv(kv), {}); assert.strictEqual(res.status, 200); const body = await res.json(); assert.strictEqual(body.pin, null); }); test("LP8 GET /api/location returns the pin for the token's OWN chat only", async () => { const kv = makeKV(); await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" }); await mintSession(kv, { user_id: 99, chat_id: 888, issued_at: 1700000000, language_code: "en" }); const token777 = await getTokenForChat(kv, 777); const token888 = await getTokenForChat(kv, 888); await writePin(kv, 888, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 }); const reqA = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token777}` } }); const bodyA = await (await handler(reqA, makeEnv(kv), {})).json(); assert.strictEqual(bodyA.pin, null, "user 42's token must NOT read user 99's pin (V4 access control)"); const reqB = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token888}` } }); const bodyB = await (await handler(reqB, makeEnv(kv), {})).json(); assert.strictEqual(bodyB.pin.latitude, 3.1513); }); test("LP9 GET /api/location with a non-GET method returns 405", async () => { const res = await handler(new Request("https://host/api/location", { method: "POST" }), makeEnv(makeKV()), {}); assert.strictEqual(res.status, 405); }); /* ————— LP10–LP14 — POST /api/location-fallback (the keyboard trigger) ————— */ test("LP10 POST /api/location-fallback with a missing bearer token returns 401", async () => { const res = await handler(new Request("https://host/api/location-fallback", { method: "POST" }), makeEnv(makeKV()), {}); assert.strictEqual(res.status, 401); assert.strictEqual((await res.json()).error, "unauthorized"); }); test("LP11 POST /api/location-fallback with an unknown token returns 401", async () => { const req = new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: "Bearer deadbeef-1234" } }); assert.strictEqual((await handler(req, makeEnv(makeKV()), {})).status, 401); }); test("LP12 POST /api/location-fallback sends the location keyboard to the token's OWN chat", async () => { const kv = makeKV(); await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" }); const token = await getTokenForChat(kv, 777); const calls = []; const restore = stubTelegramSender(calls); try { const res = await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${token}` } }), makeEnv(kv), {}); assert.strictEqual(res.status, 200); assert.strictEqual(calls.length, 1); assert.strictEqual(calls[0].chat_id, 777, "keyboard must go to the CLAIM's chat, never a client-supplied id (V4)"); assert.strictEqual(calls[0].reply_markup.keyboard[0][0].request_location, true); } finally { restore(); } }); test("LP13 POST /api/location-fallback honours claim.language_code", async () => { const kv = makeKV(); await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" }); await mintSession(kv, { user_id: 7, chat_id: 555, issued_at: 1700000000, language_code: "en" }); const calls = []; const restore = stubTelegramSender(calls); try { const tokMs = await getTokenForChat(kv, 777); await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokMs}` } }), makeEnv(kv), {}); const tokEn = await getTokenForChat(kv, 555); await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokEn}` } }), makeEnv(kv), {}); } finally { restore(); } assert.strictEqual(calls[0].text, strings.ms.awaitingPin, "ms claim must receive ms copy"); assert.strictEqual(calls[1].text, strings.en.awaitingPin, "en claim must receive en copy"); }); test("LP14 POST /api/location-fallback with a non-POST method returns 405", async () => { assert.strictEqual((await handler(new Request("https://host/api/location-fallback", { method: "GET" }), makeEnv(makeKV()), {})).status, 405); });