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
This commit is contained in:
araharah-executor
2026-10-22 23:33:01 +08:00
parent 4fbbb0b516
commit ecaf242d0e
5 changed files with 400 additions and 3 deletions
+213
View File
@@ -0,0 +1,213 @@
#!/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);
});