forked from c-fu/arah2
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:
+13
-2
@@ -10,17 +10,28 @@
|
|||||||
* text replies only — no reply menus, no inline button payloads, no
|
* text replies only — no reply menus, no inline button payloads, no
|
||||||
* callback routes, no menu augmentation per request. The persistent menu
|
* callback routes, no menu augmentation per request. The persistent menu
|
||||||
* is registered once by scripts/register-commands.mjs (D-03).
|
* is registered once by scripts/register-commands.mjs (D-03).
|
||||||
|
*
|
||||||
|
* GEO-02 (structural): createBot threads env.chat_sessions into the
|
||||||
|
* location-fallback handler (handlers/location-fallback.ts) — the ONE
|
||||||
|
* deliberate exception to the D-02 text-only rule (its Telegram send
|
||||||
|
* attaches a reply-markup location request). Also: requestLocationFallback
|
||||||
|
* has exactly ONE caller — handleLocationFallback in src/webhook.ts. If a
|
||||||
|
* second caller ever appears, that is a design change, not a refactor.
|
||||||
*/
|
*/
|
||||||
import { Bot } from "grammy";
|
import { Bot } from "grammy";
|
||||||
import type { Env } from "../config.ts";
|
import type { Env } from "../config.ts";
|
||||||
|
import { registerLocationFallback } from "./handlers/location-fallback.ts";
|
||||||
import { registerHelp } from "./commands/help.ts";
|
import { registerHelp } from "./commands/help.ts";
|
||||||
import { registerPrivacy } from "./commands/privacy.ts";
|
import { registerPrivacy } from "./commands/privacy.ts";
|
||||||
import { registerStart } from "./commands/start.ts";
|
import { registerStart } from "./commands/start.ts";
|
||||||
|
|
||||||
export function createBot(env: Env): Bot {
|
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
|
||||||
const bot = new Bot(env.BOT_TOKEN);
|
const bot = opts.baseFetch
|
||||||
|
? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } })
|
||||||
|
: new Bot(env.BOT_TOKEN);
|
||||||
registerStart(bot); // /start + /mula (D-04)
|
registerStart(bot); // /start + /mula (D-04)
|
||||||
registerHelp(bot); // /help
|
registerHelp(bot); // /help
|
||||||
registerPrivacy(bot); // /privacy + /privasi (D-04)
|
registerPrivacy(bot); // /privacy + /privasi (D-04)
|
||||||
|
registerLocationFallback(bot, env.chat_sessions); // GEO-02 location reply fallback
|
||||||
return bot;
|
return bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* location-fallback.ts — GEO-02 chat-keyboard location fallback.
|
||||||
|
* -------------------------------------------------------------------
|
||||||
|
* When in-app location fails (denied permission, or Desktop Telegram
|
||||||
|
* which has NO geolocation at all — Telegram-Mini-Apps issue #77), the
|
||||||
|
* bot sends a ReplyKeyboardMarkup with request_location: true. The
|
||||||
|
* user taps it, Telegram's native location picker opens, and the bot
|
||||||
|
* receives the shared pin as a normal Message with a `location` field.
|
||||||
|
*
|
||||||
|
* That pin is written to TTL-only KV (kv/location-pin.ts) keyed by
|
||||||
|
* chat_id. The open Mini App polls GET /api/location (webhook.ts) to
|
||||||
|
* pick it up — Telegram has NO push channel from chat to WebView, so
|
||||||
|
* the relay is server-mediated by necessity.
|
||||||
|
*
|
||||||
|
* D-02 structural note: this handler is the ONE deliberate exception
|
||||||
|
* to the Phase-2 "plain text only" command-surface rule. The GEO-02
|
||||||
|
* reply keyboard is required by the platform; it lives in handlers/
|
||||||
|
* and is NOT part of commandSources in commands.test.mjs's CS4 gate
|
||||||
|
* (which scans only bot.ts + commands/*). See the CS4 decision in the
|
||||||
|
* plan.
|
||||||
|
*
|
||||||
|
* All copy comes from src/i18n/strings.ts — nothing inline here.
|
||||||
|
*/
|
||||||
|
import type { Bot, Context } from "grammy";
|
||||||
|
import { pickLocale, strings } from "../../i18n/strings.ts";
|
||||||
|
import type { Locale } from "../../i18n/strings.ts";
|
||||||
|
import { writePin } from "../../kv/location-pin.ts";
|
||||||
|
import type { LocationPinKv } from "../../kv/location-pin.ts";
|
||||||
|
|
||||||
|
/** Send the awaiting-pin prompt + location keyboard (GEO-02).
|
||||||
|
*
|
||||||
|
* grammY note: the plan's original shape called `bot.chat.sendMessage`,
|
||||||
|
* but grammY 1.46 exposes no `bot.chat` proxy — `bot.api.sendMessage` is
|
||||||
|
* the same request over the same API surface. The Mini App's
|
||||||
|
* POST /api/location-fallback route (webhook.ts) is the ONLY caller. */
|
||||||
|
export async function requestLocationFallback(bot: Bot, chatId: number, locale: Locale): Promise<void> {
|
||||||
|
await bot.api.sendMessage(chatId, strings[locale].awaitingPin, {
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a user-shared pin to TTL-only KV (GEO-03, PRIV-02). */
|
||||||
|
export async function recordSharedPin(
|
||||||
|
kv: LocationPinKv,
|
||||||
|
chatId: number,
|
||||||
|
loc: { latitude: number; longitude: number; horizontal_accuracy?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await writePin(kv, chatId, {
|
||||||
|
latitude: loc.latitude,
|
||||||
|
longitude: loc.longitude,
|
||||||
|
horizontal_accuracy: loc.horizontal_accuracy ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the GEO-02 message handler. Filters on ctx.message?.location
|
||||||
|
* — Telegram-authenticated updates only (T-03-03), so a fabricated pin
|
||||||
|
* can never enter KV. */
|
||||||
|
export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void {
|
||||||
|
bot.on("message", async (ctx: Context) => {
|
||||||
|
const location = ctx.message?.location;
|
||||||
|
if (!location) return;
|
||||||
|
const chatId = ctx.chat?.id;
|
||||||
|
if (chatId === undefined) return;
|
||||||
|
await recordSharedPin(kv, chatId, location);
|
||||||
|
const locale = pickLocale(ctx.from?.language_code);
|
||||||
|
await ctx.reply(strings[locale].pinReceived, { link_preview_options: { is_disabled: true } });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ export interface BotStrings {
|
|||||||
/** D-03: lists only commands that exist in this phase — derived lines
|
/** D-03: lists only commands that exist in this phase — derived lines
|
||||||
* ("cmd — description") feed the setMyCommands registration script. */
|
* ("cmd — description") feed the setMyCommands registration script. */
|
||||||
helpMenu: string;
|
helpMenu: string;
|
||||||
|
/** GEO-02: chat-keyboard fallback prompt — bot tells the user to share a pin. */
|
||||||
|
awaitingPin: string;
|
||||||
|
/** GEO-02: the reply-keyboard button label (request_location: true). */
|
||||||
|
shareLocationButton: string;
|
||||||
|
/** GEO-02: confirmation after the user's shared pin has been recorded
|
||||||
|
* in the TTL-only location-pin KV (GEO-03). */
|
||||||
|
pinReceived: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Locale = "en" | "ms";
|
export type Locale = "en" | "ms";
|
||||||
@@ -42,6 +49,9 @@ export const strings: Record<Locale, BotStrings> = {
|
|||||||
"What araharah never keeps: your location, your routes, your trip history, any profile about you.",
|
"What araharah never keeps: your location, your routes, your trip history, any profile about you.",
|
||||||
helpMenu:
|
helpMenu:
|
||||||
"Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores",
|
"Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores",
|
||||||
|
awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.",
|
||||||
|
shareLocationButton: "Share my location",
|
||||||
|
pinReceived: "Got it — your starting point is set.",
|
||||||
},
|
},
|
||||||
ms: {
|
ms: {
|
||||||
startIntro:
|
startIntro:
|
||||||
@@ -52,6 +62,9 @@ export const strings: Record<Locale, BotStrings> = {
|
|||||||
"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.",
|
"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.",
|
||||||
helpMenu:
|
helpMenu:
|
||||||
"Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan",
|
"Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan",
|
||||||
|
awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.",
|
||||||
|
shareLocationButton: "Kongsi lokasi saya",
|
||||||
|
pinReceived: "Baik — titik mula anda sudah ditetapkan.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+89
-1
@@ -21,6 +21,18 @@
|
|||||||
* own invariant (D-07 — best-effort under KV eventual consistency, see
|
* own invariant (D-07 — best-effort under KV eventual consistency, see
|
||||||
* sessions.ts) — this endpoint adds no extra branch and no extra write
|
* sessions.ts) — this endpoint adds no extra branch and no extra write
|
||||||
* path (T-02-06).
|
* path (T-02-06).
|
||||||
|
* GET /api/location → GEO-02 relay (T-03-01): the Mini App polls
|
||||||
|
* here with its memory-held Bearer token; the token resolves through
|
||||||
|
* getClaimByToken and the KV key loc:<chat_id> derives from the
|
||||||
|
* CLAIM — never from a client-supplied id — so one user's token can
|
||||||
|
* never read another user's pin. No pin or expired token → honest
|
||||||
|
* nulls / 401, nothing fabricated.
|
||||||
|
* POST /api/location-fallback → GEO-02 keyboard trigger (T-03-26): the
|
||||||
|
* app's denied card asks the BOT to send the request_location reply
|
||||||
|
* keyboard into the user's own chat. The target chat_id AND the
|
||||||
|
* locale come from the resolved claim (T-03-27) — the request body
|
||||||
|
* is never read, and no chat id or language is accepted from the
|
||||||
|
* client. A rejected Telegram send is a category-only 502 (T-03-28).
|
||||||
* Anything else → 404.
|
* Anything else → 404.
|
||||||
*
|
*
|
||||||
* D-14: the ONLY log statements are category-only — malformed/tampered/stale
|
* D-14: the ONLY log statements are category-only — malformed/tampered/stale
|
||||||
@@ -33,8 +45,11 @@
|
|||||||
import { webhookCallback } from "grammy";
|
import { webhookCallback } from "grammy";
|
||||||
import type { Env } from "./config.ts";
|
import type { Env } from "./config.ts";
|
||||||
import { createBot } from "./bot/bot.ts";
|
import { createBot } from "./bot/bot.ts";
|
||||||
import { mintSession } from "./kv/sessions.ts";
|
import { requestLocationFallback } from "./bot/handlers/location-fallback.ts";
|
||||||
|
import { mintSession, getClaimByToken } from "./kv/sessions.ts";
|
||||||
|
import { getPin } from "./kv/location-pin.ts";
|
||||||
import { validateInitData } from "./security/initdata.ts";
|
import { validateInitData } from "./security/initdata.ts";
|
||||||
|
import { pickLocale } from "./i18n/strings.ts";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
||||||
@@ -58,6 +73,12 @@ export default {
|
|||||||
if (url.pathname === "/api/session") {
|
if (url.pathname === "/api/session") {
|
||||||
return handleSession(req, env);
|
return handleSession(req, env);
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/location") {
|
||||||
|
return handleLocation(req, env);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/location-fallback") {
|
||||||
|
return handleLocationFallback(req, env);
|
||||||
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
},
|
},
|
||||||
} satisfies ExportedHandler<Env>;
|
} satisfies ExportedHandler<Env>;
|
||||||
@@ -112,3 +133,70 @@ async function handleSession(req: Request, env: Env): Promise<Response> {
|
|||||||
});
|
});
|
||||||
return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
|
return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GEO-02 relay (T-03-01): the Mini App polls here with its memory-held
|
||||||
|
* Bearer token to pick up a pin the user shared in chat. V4 Access
|
||||||
|
* Control (RESEARCH §Security Domain): the bearer token is resolved via
|
||||||
|
* getClaimByToken; a missing/expired token is a 401. The KV key is
|
||||||
|
* derived from the CLAIM's chat_id — NEVER from a client-supplied chat
|
||||||
|
* id, so one user's token can never read another user's pin. */
|
||||||
|
async function handleLocation(req: Request, env: Env): Promise<Response> {
|
||||||
|
if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
||||||
|
const header = req.headers.get("authorization") ?? "";
|
||||||
|
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
||||||
|
const token = match !== null && match[1] ? match[1] : null;
|
||||||
|
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
||||||
|
if (claim === null) {
|
||||||
|
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
|
||||||
|
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pin = await getPin(env.chat_sessions, claim.chat_id);
|
||||||
|
return new Response(JSON.stringify({ pin }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GEO-02 trigger (T-03-26): the Mini App's denied card taps ASK VIA CHAT
|
||||||
|
* and POSTs here; the Worker asks the BOT to send the request_location
|
||||||
|
* reply keyboard in the user's own chat. Same V4 access control as
|
||||||
|
* handleLocation — the target chat_id comes from the resolved CLAIM,
|
||||||
|
* never from the request body, so one user's token can never push a
|
||||||
|
* keyboard into another user's chat.
|
||||||
|
*
|
||||||
|
* The locale comes from `claim.language_code`, which mintSession already
|
||||||
|
* recorded from the validated initData — the Worker does NOT re-derive it
|
||||||
|
* from client input, and no language is accepted from the request body
|
||||||
|
* (T-03-27). */
|
||||||
|
async function handleLocationFallback(req: Request, env: Env): Promise<Response> {
|
||||||
|
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
||||||
|
const header = req.headers.get("authorization") ?? "";
|
||||||
|
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
||||||
|
const token = match !== null && match[1] ? match[1] : null;
|
||||||
|
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
||||||
|
if (claim === null) {
|
||||||
|
console.log("[location-fallback] rejected: unauthorized"); // category ONLY (D-14)
|
||||||
|
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code));
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Telegram rejected the send (user blocked the bot, chat not found, rate
|
||||||
|
// limit). Category only — never the chat id, never the error text (D-14, T-03-28).
|
||||||
|
console.log("[location-fallback] failed: telegram-send");
|
||||||
|
return new Response(JSON.stringify({ error: "unavailable" }), {
|
||||||
|
status: 502,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user