Files
Arah2/bot/test/location-pin.test.mjs
T
araharah-executor ecaf242d0e feat(03-01): GEO-02 server half both directions — chat-keyboard fallback + GET /api/location + POST /api/location-fallback (claim-scoped)
- 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
2026-10-22 23:33:01 +08:00

214 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});