test(04-03): the four CS landmines + the CR1-CR15 /cari gate suite
- commands.test.mjs: commandSources gains commands/cari.ts; CS4 documents the SECOND inline-keyboard carve (venue card allowed, reply keyboard still banned in commands/); CS5 menu becomes help/privacy/cari; CS5b's future-stub ban narrows to /pergi_ke (reserved for Phase 5, D-01); CS6 registers cari+search in the alias set - cari.test.mjs NEW: CR1-CR15 — payload shapes captured through the real createBot + fake baseFetch (getMe included), plus source-introspection gates with the stripComments helper: venue fields, OPEN URL = configured origin + plain slug id, NO-CRYPTO law (D-11), one-message alternates, callback_data discipline, honest refusal, 128-char cap, always-answer, category-only logging (D-15), bilingual copy, registration script, swap semantics, hostile-id drop - register-commands.mjs: menu list gains cari (two fetch calls unchanged) - [Rule 1] location-fallback.ts: the message handler now passes non-location messages through (next) — it matched EVERY message and silently starved all downstream message-stage handlers, so /cari never fired through the real factory; caught by the fake-transport suite - suites: bot 65/65 (15 CR + 9 CS + all prior), web 33/33, search 57/57, bot:typecheck green
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
#!/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 — <MINI_APP_URL>/?dest=<slug>, 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:<slug> callback buttons
|
||||
* CR5 the callback_data carries no user data — alt:<slug> 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 <configured origin>/?dest=<plain id>",
|
||||
);
|
||||
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:<slug> 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:<slug>: ${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");
|
||||
const fetchCalls = src.match(/\bawait fetch\(/g) ?? [];
|
||||
assert.equal(fetchCalls.length, 2, "still exactly two fetch calls (default + ms scopes)");
|
||||
});
|
||||
|
||||
/* ————— 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");
|
||||
});
|
||||
+48
-15
@@ -9,11 +9,24 @@
|
||||
* (location / routes / history) and the stored TTL wording
|
||||
* CS4 D-02 structural gate — command tree source contains no reply-menu
|
||||
* or inline-button surface (grep: zero "keyboard"-shaped tokens)
|
||||
* CS5 D-03 menu gap-freedom — helpMenu lists exactly /help and /privacy,
|
||||
* and the registration script derives both scopes from it (no
|
||||
* inline description literals, no future-command stubs)
|
||||
* CARVE (the SECOND documented exception, after the GEO-02 reply
|
||||
* keyboard in handlers/location-fallback.ts): commands/cari.ts ships
|
||||
* the D-02/D-10 venue card whose INLINE keyboard (InlineKeyboard +
|
||||
* reply_markup with inline_keyboard: the OPEN IN ARAHARAH web_app
|
||||
* button + alternates) is a command's output — hiding it from this
|
||||
* gate would be the more dangerous choice, so the gate scans cari.ts
|
||||
* for the REPLY-keyboard shape specifically and still bans it there.
|
||||
* A reply keyboard remains a handlers/ surface only.
|
||||
* CS4b link previews — any command source calling ctx.reply( disables
|
||||
* link previews (a query echoed into a refusal must not become a
|
||||
* link preview)
|
||||
* CS5 D-03 menu gap-freedom — helpMenu lists exactly /help, /privacy,
|
||||
* and /cari, and the registration script derives all three scopes
|
||||
* from it (no inline description literals; /pergi_ke stays a banned
|
||||
* future stub — reserved for Phase 5, D-01)
|
||||
* CS6 D-04 alias completeness — command stack registers exactly
|
||||
* /start /mula /help /privacy /privasi (help unprefixed-alias only)
|
||||
* /start /mula /help /privacy /privasi /cari /search (help
|
||||
* unprefixed-alias only)
|
||||
*
|
||||
* Source-introspection style (privacy.test.mjs precedent): structural
|
||||
* claims are pinned by reading the shipped source, not by behavior mocks.
|
||||
@@ -27,9 +40,13 @@ import { test } from "./harness.mjs";
|
||||
const { strings, pickLocale } = await import("../src/i18n/strings.ts");
|
||||
|
||||
const BOT_SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src", "bot");
|
||||
const commandSources = ["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"].map((rel) =>
|
||||
path.join(BOT_SRC, ...rel.split("/")),
|
||||
);
|
||||
const commandSources = [
|
||||
"bot.ts",
|
||||
"commands/start.ts",
|
||||
"commands/help.ts",
|
||||
"commands/privacy.ts",
|
||||
"commands/cari.ts",
|
||||
].map((rel) => path.join(BOT_SRC, ...rel.split("/")));
|
||||
|
||||
/* ————— CS1 — en/ms key-set identity (D-05) ————— */
|
||||
|
||||
@@ -75,9 +92,25 @@ test("CS3 PRIV-01: stored-facts copy carries the TTL-minutes wording in both loc
|
||||
|
||||
/* ————— CS4 — D-02 structural gate ————— */
|
||||
|
||||
test("CS4 D-02: the command tree source has no reply-menu or inline-button surface", () => {
|
||||
test("CS4 D-02: the command tree has no reply-menu surface — the /cari inline-keyboard carve", () => {
|
||||
for (const file of commandSources) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (file.endsWith("commands/cari.ts")) {
|
||||
// The documented carve (second exception after the GEO-02 reply
|
||||
// keyboard): the venue card's INLINE keyboard is allowed, but a
|
||||
// REPLY keyboard must never appear in a command source.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/reply_markup:\s*\{\s*keyboard/,
|
||||
"cari.ts — a reply-keyboard shape appeared (reply keyboards live in handlers/, not commands/)",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/ReplyKeyboardMarkup/,
|
||||
"cari.ts — ReplyKeyboardMarkup appeared (the venue card carries an INLINE keyboard only)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/keyboard/i,
|
||||
@@ -110,13 +143,13 @@ function menuEntries(helpMenu) {
|
||||
});
|
||||
}
|
||||
|
||||
test("CS5 D-03: helpMenu lists exactly /help and /privacy — no future-command stubs", () => {
|
||||
test("CS5 D-03: helpMenu lists exactly /help, /privacy, and /cari — no future-command stubs", () => {
|
||||
for (const locale of ["en", "ms"]) {
|
||||
const entries = menuEntries(strings[locale].helpMenu);
|
||||
assert.deepEqual(
|
||||
entries.map((e) => e.command),
|
||||
["help", "privacy"],
|
||||
`${locale} menu must contain exactly the two live commands (D-03)`,
|
||||
["help", "privacy", "cari"],
|
||||
`${locale} menu must contain exactly the live commands, in helpMenu order (D-03; /pergi_ke stays reserved for Phase 5, D-01)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -140,14 +173,14 @@ test("CS5 D-03: register-commands derives both scopes from helpMenu — two fetc
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/cari|pergi_ke/,
|
||||
"no future-command stubs may be registered before their phases exist (D-03)",
|
||||
/pergi_ke/,
|
||||
"no future-command stubs may be registered before their phases exist (/pergi_ke stays reserved for Phase 5, D-01; /cari is a live command since 04-03)",
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— CS6 — D-04 alias completeness on the command stack ————— */
|
||||
|
||||
test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi", () => {
|
||||
test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi /cari /search", () => {
|
||||
const registered = new Set();
|
||||
for (const file of commandSources) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
@@ -157,7 +190,7 @@ test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /pri
|
||||
}
|
||||
assert.deepEqual(
|
||||
[...registered].sort(),
|
||||
["help", "mula", "privasi", "privacy", "start"].sort(),
|
||||
["cari", "help", "mula", "privasi", "privacy", "search", "start"].sort(),
|
||||
"the command stack must be exactly the phase surface (D-04; no drift, no extras)",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user