#!/usr/bin/env node /* * cari.test.mjs — the /cari payload-shape gates (D-01, D-02, D-10, D-11, D-13, D-15). * ------------------------------------------------------------------------------------ * Two gate styles, per the repo's established conventions: * * Style 1 — fake-transport payload capture (webhook.test.mjs's seam): * createBot(env, { baseFetch }) is driven with synthetic Telegram * updates; the fake fetch records every outgoing Bot API request * (method, url, JSON body). Assertions run on the RECORDED request * bodies — exactly what Telegram would receive. The live API is never * touched (bot.init()'s getMe also lands in the fake). * * Style 2 — source-introspection structural gates (privacy.test.mjs / * commands.test.mjs precedent): read the shipped source, strip * comments, assert on code text only. * * Cases: * CR1 the venue payload is structurally correct (lat/lng finite, title = * canonical name, curated address, reply_markup.inline_keyboard) * CR2 the OPEN button's URL carries the plain id and the CONFIGURED * origin — /?dest=, never a query-derived origin * CR3 NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate) * CR4 the alternates ride on the SAME message — exactly one sendVenue, * 2-4 alternates as alt: callback buttons * CR5 the callback_data carries no user data — alt: only, <= 64 * bytes, no space/query/coordinates * CR6 a no-match query sends NO venue and NO keyboard — the honest * searchNoMatch refusal (D-13: never a fabricated pin) * CR7 an over-long query is rejected before any search (V5, T-04-21) * CR8 an empty query produces no venue card — the usage line * CR9 every callback path answers the callback query (30-second rule, * T-04-27) — the alt: matcher AND the catch-all * CR10 the query text never reaches a log line (D-15) — every console.log * in cari.ts is a category-only string literal * CR11 the copy exists in both locales (refusals differ; the OPEN label * and the OSM attribution are identical by design) * CR12 the helpMenu carries the /cari line in both locales * CR13 the registration script derives the /cari scope from helpMenu — * "cari" in the command list, still exactly two fetch calls * CR14 an alternate tap swaps to a NEW venue card for the tapped entry * (no in-place edits — T-04-28) * CR15 a hostile callback id is dropped, not echoed (T-04-23) */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; const { strings } = await import("../src/i18n/strings.ts"); const { createBot } = await import("../src/bot/bot.ts"); const { createGazetteerSource } = await import("../../search/src/index.ts"); const BOT_SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src"); /** The comment-strip helper (web/test/no-storage.test.mjs precedent) — * source-introspection gates must never assert on prose. */ function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, " ") .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); } /* ————— fake-transport harness (webhook.test.mjs's makeKV + baseFetch seam) ————— */ const TEST_TOKEN = "12345:TESTTOKEN"; const ORIGIN = "https://araharah-bot.example.workers.dev"; const BOT_USERNAME = "araharah_test_bot"; function makeKV() { const map = new Map(); return { async get(key) { return map.has(key) ? map.get(key) : null; }, async put(key, value) { map.set(key, value); }, async delete(key) { map.delete(key); }, map, }; } /** Records every outgoing Bot API call; answers getMe with a synthetic bot * user and everything else with ok:true. NEVER touches the network. */ function makeTransport() { const calls = []; const fetchFn = async (url, init) => { const u = String(url); const matched = u.match(/bot[^/]+\/([A-Za-z]+)$/); const method = matched ? matched[1] : u; let body = null; if (init?.body !== undefined) { try { body = JSON.parse(init.body); } catch { body = init.body; } } calls.push({ method, url: u, body }); const result = method === "getMe" ? { id: 12345, is_bot: true, first_name: "araharah", username: BOT_USERNAME } : true; return new Response(JSON.stringify({ ok: true, result }), { headers: { "content-type": "application/json" }, }); }; return { calls, fetchFn }; } async function makeBot() { const { calls, fetchFn } = makeTransport(); const bot = createBot( { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: makeKV(), MINI_APP_URL: ORIGIN, }, { baseFetch: fetchFn }, ); await bot.init(); // the getMe lands in the fake transport, not the network return { bot, calls }; } function messageUpdate(text) { const command = text.split(" ")[0] ?? text; return { update_id: Math.floor(Math.random() * 1e9), message: { message_id: 1, from: { id: 42, is_bot: false, first_name: "Zul", language_code: "en" }, chat: { id: 42, type: "private" }, date: Math.floor(Date.now() / 1000), text, // Real Telegram command messages always carry this entity — grammY's // command filter matches on it, so the synthetic update must too. entities: [{ offset: 0, length: command.length, type: "bot_command" }], }, }; } function callbackUpdate(data) { return { update_id: Math.floor(Math.random() * 1e9), callback_query: { id: `cq${Math.floor(Math.random() * 1e9)}`, from: { id: 42, is_bot: false, first_name: "Zul", language_code: "en" }, message: { message_id: 1, chat: { id: 42, type: "private" }, date: Math.floor(Date.now() / 1000), text: "/cari KLCC", }, data, }, }; } /* ————— recorded-call views (getMe is harness noise) ————— */ function apiCalls(calls) { return calls.filter((c) => c.method !== "getMe"); } function venueCalls(calls) { return apiCalls(calls).filter((c) => c.method === "sendVenue"); } function messageCalls(calls) { return apiCalls(calls).filter((c) => c.method === "sendMessage"); } const source = createGazetteerSource(); /* ————— CR1 — the venue payload is structurally correct ————— */ test("CR1 the venue payload is structurally correct", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(messageUpdate("/cari KLCC")); const venues = venueCalls(calls); assert.equal(venues.length, 1, "exactly one sendVenue (D-02: the card IS the confirmation)"); const body = venues[0].body; assert.equal(body.chat_id, 42); assert.ok(Number.isFinite(body.latitude), "latitude is a finite number"); assert.ok(Number.isFinite(body.longitude), "longitude is a finite number"); const entry = source.byId("suria-klcc"); assert.equal(body.latitude, entry.lat, "the pin is the entry's own coordinate"); assert.equal(body.longitude, entry.lng, ""); assert.ok(typeof body.title === "string" && body.title.length > 0, "title non-empty"); assert.equal(body.title, entry.name, "title is the entry's canonical name"); assert.ok(typeof body.address === "string" && body.address.length > 0, "curated address non-empty"); assert.equal(body.address, entry.address, "address is the CURATED string verbatim (never normalized)"); assert.ok(body.reply_markup?.inline_keyboard, "the card carries an inline keyboard (D-10)"); }); /* ————— CR2 — the OPEN button's URL carries the plain id and the configured origin ————— */ test("CR2 the OPEN button's URL carries the plain id and the configured origin", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(messageUpdate("/cari KLCC")); const keyboard = venueCalls(calls)[0].body.reply_markup.inline_keyboard; const open = keyboard[0].find((btn) => btn.web_app); assert.ok(open, "row 0 carries the OPEN web_app button"); assert.equal(open.text, strings.en.searchOpenButton); assert.equal( open.web_app.url, `${ORIGIN}/?dest=suria-klcc`, "the URL is exactly /?dest=", ); assert.ok( open.web_app.url.startsWith(`${ORIGIN}/`), "the origin part equals the configured MINI_APP_URL — never a query-derived value", ); const dest = new URL(open.web_app.url).searchParams.get("dest"); assert.match(dest, /^[A-Za-z0-9_-]{1,64}$/, "the dest value is a slug (start_param-safe charset)"); assert.ok(!open.web_app.url.includes("KLCC"), "the user's query text never reaches the URL"); }); /* ————— CR3 — NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate) ————— */ test("CR3 NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate)", () => { const banned = [/createHmac/, /crypto\.subtle/, /sign\(/, /hmac/i, /signature/i, /sha256/i, /secret/i]; for (const rel of ["bot/commands/cari.ts", "bot/handlers/search-alternate.ts"]) { const stripped = stripComments(readFileSync(path.join(BOT_SRC, ...rel.split("/")), "utf8")); for (const pattern of banned) { assert.doesNotMatch( stripped, pattern, `${rel} — a crypto-shaped token appeared in code: ${pattern} (the deep-link payload is a PUBLIC place id — D-11)`, ); } } }); /* ————— CR4 — the alternates ride on the SAME message ————— */ test("CR4 the alternates ride on the SAME message", async () => { const { bot, calls } = await makeBot(); // "kuala" resolves 5 hits — 1 best + 4 alternates, exercising the cap. await bot.handleUpdate(messageUpdate("/cari kuala")); assert.equal(venueCalls(calls).length, 1, "exactly ONE venue message for the whole search (D-02)"); assert.equal(apiCalls(calls).length, 1, "exactly ONE outgoing Bot API message in total"); const keyboard = venueCalls(calls)[0].body.reply_markup.inline_keyboard; const alternates = keyboard.flat().filter((btn) => btn.callback_data !== undefined); assert.ok(alternates.length >= 2 && alternates.length <= 4, "2-4 alternates ride on the card"); for (const btn of alternates) { assert.match(btn.callback_data, /^alt:[a-z0-9-]{1,64}$/, "every alternate is an alt: callback"); assert.ok(btn.text.length > 0, "each alternate's label is the entry's own name"); } }); /* ————— CR5 — the callback_data carries no user data ————— */ test("CR5 the callback_data carries no user data", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(messageUpdate("/cari KLCC")); const keyboard = venueCalls(calls)[0].body.reply_markup.inline_keyboard; const allData = keyboard.flat().map((btn) => btn.callback_data).filter((d) => d !== undefined); assert.ok(allData.length >= 1, "the keyboard carries callback buttons to inspect"); for (const data of allData) { assert.match(data, /^alt:[a-z0-9-]+$/, `callback_data is exactly alt:: ${data}`); assert.ok(!data.includes(" "), "no space in callback_data"); assert.ok(!/\d+\.\d+/.test(data.slice(4)), "no coordinate in callback_data"); assert.ok(Buffer.byteLength(data, "utf8") <= 64, `callback_data within Telegram's 64-byte limit: ${data}`); } }); /* ————— CR6 — a no-match query sends NO venue and NO keyboard ————— */ test("CR6 a no-match query sends NO venue and NO keyboard", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(messageUpdate("/cari Kuching")); assert.equal(venueCalls(calls).length, 0, "no sendVenue for an out-of-area query (D-13)"); const messages = messageCalls(calls); assert.equal(messages.length, 1, "the honest refusal is the only reply"); const body = messages[0].body; assert.ok(body.reply_markup === undefined, "no reply_markup of any kind on the refusal"); assert.equal(body.text, strings.en.searchNoMatch, "the refusal names the v1 area, not the query"); assert.ok(!body.text.includes("Kuching"), "the refusal copy must not echo the query text"); }); /* ————— CR7 — an over-long query is rejected before any search ————— */ test("CR7 an over-long query is rejected before any search", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(messageUpdate(`/cari ${"x".repeat(200)}`)); assert.equal(venueCalls(calls).length, 0, "no venue card for an over-cap query (V5)"); const messages = messageCalls(calls); assert.equal(messages.length, 1); assert.equal(messages[0].body.text, strings.en.searchQueryTooLong, "the over-cap refusal copy"); assert.ok(messages[0].body.reply_markup === undefined, "no keyboard on the over-cap refusal"); }); /* ————— CR8 — an empty query produces no venue card ————— */ test("CR8 an empty query produces no venue card", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(messageUpdate("/cari")); assert.equal(venueCalls(calls).length, 0, "no venue card without a query"); const messages = messageCalls(calls); assert.equal(messages.length, 1); assert.equal(messages[0].body.text, strings.en.searchUsage, "the usage copy"); assert.ok(messages[0].body.reply_markup === undefined, "no keyboard on the usage reply"); }); /* ————— CR9 — every callback path answers the callback query ————— */ test("CR9 every callback path answers the callback query", async () => { const src = readFileSync(path.join(BOT_SRC, "bot", "handlers", "search-alternate.ts"), "utf8"); const answers = src.match(/answerCallbackQuery/g) ?? []; assert.ok(answers.length >= 2, "the alt: matcher AND the catch-all both answer (30-second rule)"); assert.match( src, /bot\.on\("callback_query:data"/, "the catch-all exists — Telegram never shows the 60-second spinner", ); assert.match(src, /bot\.callbackQuery\(\s*\/\^alt:/, "the matcher is scoped to the alt: prefix"); // Behavioural half: an UNKNOWN callback data still gets answered. const { bot, calls } = await makeBot(); await bot.handleUpdate(callbackUpdate("something-else-entirely")); const answered = apiCalls(calls).some((c) => c.method === "answerCallbackQuery"); assert.ok(answered, "an unknown callback query is answered, not dropped on the floor"); assert.equal(venueCalls(calls).length, 0, "and it never fabricates a card"); }); /* ————— CR10 — the query text never reaches a log line (D-15) ————— */ test("CR10 the query text never reaches a log line (D-15)", () => { const stripped = stripComments(readFileSync(path.join(BOT_SRC, "bot", "commands", "cari.ts"), "utf8")); const logs = stripped.match(/console\.log\([^;]*\)/g) ?? []; assert.ok(logs.length >= 1, "cari.ts carries its rejection categories"); for (const log of logs) { assert.match( log, /^console\.log\("\[search\] rejected: [a-z-]+"\)$/, `log must be a category-only string literal — no variable, no payload: ${log}`, ); } }); /* ————— CR11 — the copy exists in both locales ————— */ test("CR11 the copy exists in both locales", () => { for (const field of ["searchNoMatch", "searchUsage", "searchQueryTooLong"]) { assert.ok(strings.en[field]?.length > 0, `en ${field} non-empty`); assert.ok(strings.ms[field]?.length > 0, `ms ${field} non-empty`); assert.notEqual(strings.en[field], strings.ms[field], `${field} is not an untranslated copy`); } assert.equal( strings.en.searchOpenButton, strings.ms.searchOpenButton, "the OPEN label is identical in both locales by design (a product affordance)", ); assert.equal(strings.en.searchOpenButton, "OPEN IN ARAHARAH"); assert.equal( strings.en.osmAttribution, strings.ms.osmAttribution, "the OSM attribution is identical in both locales (legal text)", ); assert.equal(strings.en.osmAttribution, "© OpenStreetMap contributors"); }); /* ————— CR12 — the helpMenu carries the /cari line in both locales ————— */ function menuEntries(helpMenu) { return helpMenu .split("\n") .filter((line) => line.startsWith("/")) .map((line) => { const em = line.indexOf(" — "); assert.ok(em !== -1, `helpMenu line lacks its " — " description marker: ${line}`); return { command: line.slice(1, em), description: line.slice(em + 3).trim() }; }); } test("CR12 the helpMenu carries the /cari line in both locales", () => { const en = menuEntries(strings.en.helpMenu).find((e) => e.command === "cari"); const ms = menuEntries(strings.ms.helpMenu).find((e) => e.command === "cari"); assert.ok(en, "en helpMenu has a /cari entry"); assert.ok(ms, "ms helpMenu has a /cari entry"); assert.ok(en.description.length > 0 && ms.description.length > 0, "both descriptions non-empty"); assert.notEqual(en.description, ms.description, "the ms description is in the ms locale's own language"); }); /* ————— CR13 — the registration script derives the /cari scope from helpMenu ————— */ test("CR13 the registration script derives the /cari scope from helpMenu", () => { const src = readFileSync( path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "scripts", "register-commands.mjs"), "utf8", ); assert.ok(src.includes('["help", "privacy", "cari"]'), "the hardcoded command list gained cari"); // Device-gate incident: all_private_chats SHADOWS default — /cari must be // armed in the shadowing scope too, not only default. const allPrivate = src.indexOf('"all_private_chats"'); assert.notEqual(allPrivate, -1, "all_private_chats scope must be armed explicitly (shadow law)"); assert.ok(src.includes("menuFor"), "every armed scope derives its set from helpMenu"); }); /* ————— CR14 — the alternates swap produces a new venue card ————— */ test("CR14 the alternates swap produces a new venue card for the tapped entry", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(callbackUpdate("alt:aquaria-klcc")); const venues = venueCalls(calls); assert.equal(venues.length, 1, "the tap produces exactly one new venue card"); const entry = source.byId("aquaria-klcc"); assert.ok(entry, "the tapped id resolves through the source"); assert.equal(venues[0].body.title, entry.name, "the swapped card is for the TAPPED entry"); assert.ok( apiCalls(calls).some((c) => c.method === "answerCallbackQuery"), "the swap path answers the callback first", ); const handlerSrc = readFileSync(path.join(BOT_SRC, "bot", "handlers", "search-alternate.ts"), "utf8"); assert.ok( !handlerSrc.includes("editMessageText"), "the swap sends a NEW card — in-place edits never touch a venue message (T-04-28)", ); }); /* ————— CR15 — a hostile callback id is dropped, not echoed ————— */ test("CR15 a hostile callback id is dropped, not echoed", async () => { const { bot, calls } = await makeBot(); await bot.handleUpdate(callbackUpdate("alt:../../etc/passwd")); assert.equal(venueCalls(calls).length, 0, "a traversal-shaped id produces NO venue card"); assert.equal(messageCalls(calls).length, 0, "nothing is echoed into the chat either"); const answered = apiCalls(calls).some((c) => c.method === "answerCallbackQuery"); assert.ok(answered, "even a hostile callback is answered (30-second rule) before being dropped"); });