forked from c-fu/arah2
- handlers/location-fallback.ts: requestLocationFallback (ReplyKeyboardMarkup request_location:true copy from strings.ts), recordSharedPin, registerLocationFallback (filters message.location, T-03-03) - strings.ts: awaitingPin/shareLocationButton/pinReceived in en+ms (CS1 parity) — all bot copy stays in strings.ts - bot.ts: createBot threads env.chat_sessions into the fallback handler; optional client fetch seam for tests; one-caller rule documented - webhook.ts: GET /api/location relay + POST /api/location-fallback trigger, both resolve chat_id & locale from getClaimByToken CLAIM, never client input (T-03-01/26/27); telegram-send failure is category-only 502 (T-03-28) - location-pin.test.mjs: LP1–LP14 (TTL contract, range guard, cross-user isolation both routes, locale split); LP12/LP13 patch node-fetch's cached default (grammy's node shim transport), then restore - CS4 scope untouched (handlers/ not scanned); CS1 parity automatic
214 lines
9.9 KiB
JavaScript
214 lines
9.9 KiB
JavaScript
#!/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:<chat_id>", 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);
|
||
});
|