webhook corridor/sparse builders + guidance-cron unnamed way compute tiny tables through the ONE preprocessing core (@araharah/routing farthestPointAnchors + landmarkTables) and pass the landmarks block into the same single encodeGraph call — call-site updates, not a second encode function. Graphs with no degree>=2 candidate (sparse, 2-node) pin their anchor explicitly; tables still come from the integer-Dijkstra core. FULL BATTERY at this commit: routing:test 74/74, bot 126/126, both typechecks clean; the regenerated fixture is deterministic (byte- identical re-run); mini-graph-arg1.bin is byte-stable vs its committed 08-01 bytes.
752 lines
35 KiB
JavaScript
752 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* webhook.test.mjs — the Worker entry (/api/session + /telegram/webhook gate).
|
|
* ---------------------------------------------------------------------------
|
|
* Source-under-test: src/webhook.ts, exercised through its exported
|
|
* `fetch(request, env)` with undici Request/Response (Node 20+ ships both;
|
|
* the code paths here are platform-neutral, no workerd needed).
|
|
*
|
|
* Scope note (offline-only): grammY's cloudflare-mod adapter calls
|
|
* `bot.init()` (a live getMe) BEFORE its own secret-token compare, so no
|
|
* committed case may let an update REACH the framework. Do not add a case
|
|
* that posts a well-formed update with an ARMED secret — it would call the
|
|
* live Bot API. Armed-secret behavior is verified by the live smokes in
|
|
* plan 02-03 instead.
|
|
*
|
|
* Cases (WR-01 body cap, WR-02 fail-closed gate, T-02-01 gate):
|
|
* WK1 POST valid initData under the cap → 200 {token: uuid};
|
|
* exactly two TTL-carrying KV writes (900 s)
|
|
* WK2 POST an over-cap (4 KB+) but VALIDLY SIGNED initData → 413
|
|
* {error:"malformed"}, zero KV writes, log is category-only
|
|
* ("[initdata] rejected: malformed") and echoes no payload
|
|
* WK2b lying Content-Length (declares 100, ships 5000+) → 413 via the
|
|
* post-read length check (the header blind spot in WR-01)
|
|
* WK3 declared-length 999999 + unreadable body stream → 413 resolved
|
|
* WITHOUT pulling the stream (the pre-read check refuses to buffer)
|
|
* WK4 GET /api/session → 405 (the cap logic never runs on GET)
|
|
* WK5 unarmed WEBHOOK_SECRET → /telegram/webhook rejects CLOSED with a
|
|
* constant 500 BEFORE grammY's compareSecretToken (which would
|
|
* accept every request once the secret is undefined — WR-02)
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import { createHmac } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { test } from "./harness.mjs";
|
|
|
|
const TEST_TOKEN = "12345:TESTTOKEN"; // same signer token as initdata.test.mjs
|
|
|
|
/* ————— node:crypto signing helpers (test-side, independent path) ————— */
|
|
|
|
function dataCheckString(fields) {
|
|
return fields
|
|
.filter(([key]) => key !== "hash")
|
|
.slice()
|
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function telegramSecretKey(botToken) {
|
|
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
|
|
}
|
|
|
|
function buildInitData(fields) {
|
|
const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
|
|
.update(dataCheckString(fields), "utf8")
|
|
.digest("hex");
|
|
const wire = fields
|
|
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
.join("&");
|
|
return `${wire}&hash=${hash}`;
|
|
}
|
|
|
|
function signedUser(authDateSeconds, extraFields = []) {
|
|
return buildInitData([
|
|
...extraFields,
|
|
["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })],
|
|
["auth_date", String(authDateSeconds)],
|
|
]);
|
|
}
|
|
|
|
/* ————— env / request harness ————— */
|
|
|
|
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 handler = await import("../src/webhook.ts").then((m) => m.default.fetch);
|
|
|
|
function postSession(body, headers = {}, kvOverride = null) {
|
|
const kv = kvOverride ?? makeKV();
|
|
const req = new Request("https://host/api/session", {
|
|
method: "POST",
|
|
...(body !== undefined ? { body, headers } : { headers }),
|
|
});
|
|
return { req, kv, resP: handler(req, makeEnv(kv), {}) };
|
|
}
|
|
|
|
/* ————— WK1 — the happy path still works under the cap ————— */
|
|
|
|
test("WK1 POST valid initData (under cap) → 200 {token: uuid}; exactly two 900s KV writes", async () => {
|
|
const { kv, resP } = postSession(signedUser(Math.floor(Date.now() / 1000)));
|
|
const res = await resP;
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.clone().text()}`);
|
|
const body = await res.json();
|
|
assert.match(body.token, /^[0-9a-f-]{36}$/, "opaque UUID token (D-06)");
|
|
assert.equal(kv.ttlLog.length, 2, "mint writes exactly two keys");
|
|
for (const entry of kv.ttlLog) assert.strictEqual(entry.expirationTtl, 900, "TTL-only (PRIV-02)");
|
|
});
|
|
|
|
/* ————— WK2 — over-cap signed body → 413, category-only, no side effects ————— */
|
|
|
|
test("WK2 POST 4KB+ validly-signed initData → 413 malformed; zero KV writes; log carries ONLY the category", async () => {
|
|
const kv = makeKV();
|
|
const seen = [];
|
|
const orig = console.log;
|
|
console.log = (...args) => seen.push(args.map((a) => String(a)).join(" "));
|
|
let res;
|
|
try {
|
|
// validly signed — proves the cap fires before/instead of validation,
|
|
// and that a real signature cannot buy the body past the cap
|
|
const overCapSigned = signedUser(Math.floor(Date.now() / 1000), [["start_param", "p".repeat(4900)]]);
|
|
assert.ok(overCapSigned.length > 4096, "fixture must exceed the cap");
|
|
res = await handler(
|
|
new Request("https://host/api/session", { method: "POST", body: overCapSigned }),
|
|
makeEnv(kv),
|
|
{},
|
|
);
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
assert.equal(res.status, 413, `expected 413, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "malformed" }, "category-only body (D-14)");
|
|
assert.equal(kv.map.size, 0, "no session minted");
|
|
assert.equal(kv.ttlLog.length, 0, "no KV writes at all");
|
|
assert.deepEqual(seen, ["[initdata] rejected: malformed"], "log is the category ONLY — no payload fragment, no ids");
|
|
});
|
|
|
|
/* ————— WK2b/WK3 — the two blind spots of a naive body cap ————— */
|
|
|
|
/* ————— WK2b/WK3 — the two blind spots of a naive body cap ————— */
|
|
|
|
test("WK2b lying Content-Length (declares 100, ships 5000+) → 413 via the post-read check", async () => {
|
|
const { kv, resP } = postSession("a=".concat("b".repeat(5000)), { "content-length": "100" });
|
|
const res = await resP;
|
|
assert.equal(res.status, 413, "the post-read length check must backstop a lying header");
|
|
assert.deepEqual(await res.json(), { error: "malformed" });
|
|
assert.equal(kv.map.size, 0);
|
|
});
|
|
|
|
test("WK3 declared-length 999999 + unreadable body stream → handler RESOLVES 413 (must not await the body)", async () => {
|
|
// The stream errors if any consumer pulls it. The assertion that matters:
|
|
// handler() resolves with 413 — had the handler awaited req.text() on a
|
|
// broken stream, the fetch call would REJECT instead. (Node/undici pulls
|
|
// stream bodies opportunistically after construction, so pull-tracking is
|
|
// implementation noise, not boundable here; the resolution outcome is.)
|
|
let pulled = false;
|
|
const unreadable = new ReadableStream({
|
|
pull(controller) {
|
|
pulled = true;
|
|
controller.error(new Error("body must never be read (WR-01 pre-read path)"));
|
|
},
|
|
});
|
|
void pulled;
|
|
const kv = makeKV();
|
|
const req = new Request("https://host/api/session", {
|
|
method: "POST",
|
|
headers: { "content-length": "999999" },
|
|
body: unreadable,
|
|
duplex: "half", // required by undici for stream bodies
|
|
});
|
|
const res = await handler(req, makeEnv(kv), {});
|
|
assert.equal(res.status, 413, "resolve 413 — the pre-read check must refuse to buffer the declared-oversize body");
|
|
assert.equal(kv.map.size, 0);
|
|
});
|
|
|
|
/* ————— WK4 — method gate ————— */
|
|
|
|
test("WK4 GET /api/session → 405", async () => {
|
|
const req = new Request("https://host/api/session", { method: "GET" });
|
|
const res = await handler(req, makeEnv(makeKV()), {});
|
|
assert.equal(res.status, 405);
|
|
});
|
|
|
|
/* ————— WK5 — WR-02 fail-closed webhook gate ————— */
|
|
|
|
test("WK5 unarmed WEBHOOK_SECRET ('') → webhook rejects CLOSED with a constant 500, no bot run, no KV touch", async () => {
|
|
const env = { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "", chat_sessions: undefined };
|
|
const req = () => new Request("https://host/telegram/webhook", { method: "POST", body: '{\"update_id\":1}' });
|
|
const res1 = await handler(req(), env, {});
|
|
const res2 = await handler(req(), env, {});
|
|
assert.equal(res1.status, 500, "missing binding must fail closed, not fall through to grammY's accept-everything compare");
|
|
assert.equal(res2.status, 500, "the rejection is constant across calls");
|
|
assert.equal(await res1.text(), "Server misconfigured");
|
|
assert.equal(await res2.text(), "Server misconfigured");
|
|
assert.ok(env.chat_sessions === undefined, "the guard fired before any storage or bot wiring was needed");
|
|
});
|
|
|
|
/* ————————————————————————————————————————————————————————————————————————
|
|
* /api/guidance — the Phase 6 envelope endpoints (06-03, GUID-05/06, D-14).
|
|
*
|
|
* GW1 POST without Bearer → 401 {error:"unauthorized"}; zero KV writes;
|
|
* log is the category ONLY
|
|
* GW2 POST with a non-conformant Bearer token → 401 (the charset gate)
|
|
* GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes;
|
|
* lying Content-Length backstop → 413 via the post-read check
|
|
* GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at
|
|
* guidance:<id>; a chat_id IN THE BODY is IGNORED (T-06-02 injection
|
|
* probe — the CLAIM's chat_id is stored); extra body fields dropped
|
|
* GW5 malformed snapshot classes → 400 {error:"malformed"} + category-
|
|
* only log; zero writes (non-numeric distanceM, negative durationS,
|
|
* non-integer startedAtSec, off-slug destinationId, empty/
|
|
* oversized-free destinationName, empty/undecodable geometry,
|
|
* broken JSON, null/array body)
|
|
* GW6 GET own id → 200 envelope JSON
|
|
* GW7 GET a foreign chat's id → 404 (no existence oracle) and the
|
|
* record SURVIVES (T-06-02b)
|
|
* GW8 GET absent / malformed id → 404
|
|
* GW9 DELETE own id → 200 {deleted:true}; the record is GONE (D-14)
|
|
* GW10 DELETE a foreign chat's id → 404 and the record SURVIVES
|
|
* (cross-chat deletion impossible, T-06-02b)
|
|
* GW11 PUT /api/guidance → 405
|
|
* ———————————————————————————————————————————————————————————————————————— */
|
|
|
|
const { mintSession } = await import("../src/kv/sessions.ts");
|
|
const { mintGuidanceSession } = await import("../src/kv/guidance-sessions.ts");
|
|
|
|
/** polyline6, two KL points — the routing workspace's storage format (05-01),
|
|
* generated with routing/src/polyline.ts encodePolyline6 (round-trip holds). */
|
|
const KL_TWO_POINTS = "oqv_Egt~~`Ewj@_q@";
|
|
|
|
const VALID_BODY = {
|
|
destinationId: "suria-klcc",
|
|
destinationName: "Suria KLCC",
|
|
distanceM: 8432,
|
|
durationS: 900,
|
|
geometry: KL_TWO_POINTS,
|
|
startedAtSec: 1792817000,
|
|
};
|
|
|
|
/** A memory-held claim the honest way: through the real mint (offline KV). */
|
|
async function bearerFor(kv, chatId = 101) {
|
|
return mintSession(kv, {
|
|
user_id: 42,
|
|
chat_id: chatId,
|
|
issued_at: Math.floor(Date.now() / 1000),
|
|
language_code: "en",
|
|
});
|
|
}
|
|
|
|
function guidanceReq(method, { body, token, id, headers } = {}) {
|
|
const url = id === undefined ? "https://host/api/guidance" : `https://host/api/guidance?id=${encodeURIComponent(id)}`;
|
|
return new Request(url, {
|
|
method,
|
|
headers: {
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
...(headers ?? {}),
|
|
},
|
|
...(method === "POST" && body !== undefined
|
|
? { body: typeof body === "string" ? body : JSON.stringify(body) }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
async function captureLogs(fn) {
|
|
const seen = [];
|
|
const orig = console.log;
|
|
console.log = (...args) => seen.push(args.map((a) => String(a)).join(" "));
|
|
try {
|
|
return { result: await fn(), seen };
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
}
|
|
|
|
async function seedEnvelope(kv, chatId) {
|
|
const token = await bearerFor(kv, chatId);
|
|
const envelope = await mintGuidanceSession(kv, chatId, VALID_BODY, 1792817600);
|
|
return { token, id: envelope.id };
|
|
}
|
|
|
|
test("GW1 POST /api/guidance without Bearer → 401 {error:'unauthorized'}; zero KV writes; log is the category ONLY", async () => {
|
|
const kv = makeKV();
|
|
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: VALID_BODY }), makeEnv(kv), {}));
|
|
const res = await result;
|
|
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
assert.equal(kv.ttlLog.length, 0, "an unauthorized caller writes nothing");
|
|
assert.deepEqual(seen, ["[guidance] rejected: unauthorized"], "category ONLY — no token, no body fragment");
|
|
});
|
|
|
|
test("GW2 POST with a non-conformant Bearer token (charset gate) → 401", async () => {
|
|
const kv = makeKV();
|
|
const res = await handler(guidanceReq("POST", { body: VALID_BODY, token: "bad_token!!" }), makeEnv(kv), {});
|
|
assert.equal(res.status, 401);
|
|
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
assert.equal(kv.ttlLog.length, 0);
|
|
});
|
|
|
|
test("GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes; category-only log; lying Content-Length backstop → 413", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101); // the claim mint writes its own 2 session keys
|
|
const writesBefore = kv.ttlLog.length;
|
|
const overCap = JSON.stringify({ ...VALID_BODY, destinationName: "x".repeat(9000) });
|
|
assert.ok(overCap.length > 8192, "fixture must exceed the cap");
|
|
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: overCap, token }), makeEnv(kv), {}));
|
|
const res = await result;
|
|
assert.equal(res.status, 413, "the pre-read cap refuses to buffer");
|
|
assert.deepEqual(await res.json(), { error: "malformed" });
|
|
assert.equal(kv.ttlLog.length, writesBefore, "an over-cap body writes nothing");
|
|
assert.deepEqual(seen, ["[guidance] rejected: malformed"], "category ONLY");
|
|
|
|
// lying header: declares 100, ships 9000+ → the post-read check closes the blind spot
|
|
const kv2 = makeKV();
|
|
const token2 = await bearerFor(kv2, 101);
|
|
const writesBefore2 = kv2.ttlLog.length;
|
|
const lying = await handler(
|
|
guidanceReq("POST", { body: overCap, token: token2, headers: { "content-length": "100" } }),
|
|
makeEnv(kv2),
|
|
{},
|
|
);
|
|
assert.equal(lying.status, 413, "the post-read length check must backstop a lying header");
|
|
assert.equal(kv2.ttlLog.length, writesBefore2);
|
|
});
|
|
|
|
test("GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at guidance:<id>; body chat_id 999 IGNORED (T-06-02); extra fields dropped", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101); // the claim mint writes its own 2 session keys
|
|
const writesBefore = kv.ttlLog.length;
|
|
const res = await handler(
|
|
guidanceReq("POST", { body: { ...VALID_BODY, chat_id: 999, extra: "junk" }, token }),
|
|
makeEnv(kv),
|
|
{},
|
|
);
|
|
assert.equal(res.status, 201, `expected 201, got ${res.status}: ${await res.clone().text()}`);
|
|
const { id } = await res.json();
|
|
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id");
|
|
assert.equal(kv.ttlLog.length, writesBefore + 1, "mint is exactly one KV write");
|
|
const guidanceWrite = kv.ttlLog[kv.ttlLog.length - 1];
|
|
assert.equal(guidanceWrite.key, `guidance:${id}`);
|
|
assert.equal(guidanceWrite.expirationTtl, 7200, "TTL-only (GE1)");
|
|
const stored = JSON.parse(kv.map.get(`guidance:${id}`));
|
|
assert.equal(stored.chat_id, 101, "identity comes from the CLAIM — the body's chat_id 999 is structurally ignored (T-06-02)");
|
|
assert.deepEqual(Object.keys(stored).sort(), ["chat_id", "created_at", "id", "route_snapshot"], "frozen 4-field envelope");
|
|
assert.deepEqual(
|
|
Object.keys(stored.route_snapshot).sort(),
|
|
["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"],
|
|
"exactly the six snapshot fields — extra body properties never reach storage",
|
|
);
|
|
assert.equal(stored.route_snapshot.destinationName, "Suria KLCC");
|
|
});
|
|
|
|
test("GW5 malformed snapshot classes → 400 {error:'malformed'} + category-only log; zero KV writes", async () => {
|
|
const badBodies = [
|
|
["broken JSON", "{not json"],
|
|
["null body", "null"],
|
|
["array body", "[1,2]"],
|
|
["non-numeric distanceM", { ...VALID_BODY, distanceM: "far" }],
|
|
["negative durationS", { ...VALID_BODY, durationS: -5 }],
|
|
["non-integer startedAtSec", { ...VALID_BODY, startedAtSec: 1.5 }],
|
|
["off-slug destinationId", { ...VALID_BODY, destinationId: "Suria KLCC" }],
|
|
["empty destinationName", { ...VALID_BODY, destinationName: "" }],
|
|
["empty geometry (decodes to 0 points)", { ...VALID_BODY, geometry: "" }],
|
|
["undecodable geometry (truncated)", { ...VALID_BODY, geometry: "x" }],
|
|
];
|
|
for (const [label, body] of badBodies) {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101); // the claim mint writes its own 2 session keys
|
|
const writesBefore = kv.ttlLog.length;
|
|
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body, token }), makeEnv(kv), {}));
|
|
const res = await result;
|
|
assert.equal(res.status, 400, `${label} must be a 400, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "malformed" }, `${label} carries the category only`);
|
|
assert.deepEqual(seen, ["[guidance] rejected: malformed"], `${label} logs the category ONLY`);
|
|
assert.equal(kv.ttlLog.length, writesBefore, `${label} writes nothing`);
|
|
}
|
|
});
|
|
|
|
test("GW6 GET own id → 200 with the envelope JSON", async () => {
|
|
const kv = makeKV();
|
|
const { token, id } = await seedEnvelope(kv, 101);
|
|
const res = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
|
const envelope = await res.json();
|
|
assert.equal(envelope.id, id);
|
|
assert.equal(envelope.chat_id, 101);
|
|
assert.deepEqual(envelope.route_snapshot, VALID_BODY);
|
|
});
|
|
|
|
test("GW7 GET a foreign chat's id → 404 (no existence oracle) and the record SURVIVES (T-06-02b)", async () => {
|
|
const kv = makeKV();
|
|
const { id } = await seedEnvelope(kv, 101);
|
|
const mallory = await bearerFor(kv, 202);
|
|
const res = await handler(guidanceReq("GET", { token: mallory, id }), makeEnv(kv), {});
|
|
assert.equal(res.status, 404);
|
|
assert.deepEqual(await res.json(), { error: "not_found" }, "foreign and absent are indistinguishable");
|
|
assert.ok(kv.map.has(`guidance:${id}`), "a foreign GET never deletes");
|
|
});
|
|
|
|
test("GW8 GET absent / malformed id → 404", async () => {
|
|
const kv = makeKV();
|
|
const { token } = await seedEnvelope(kv, 101);
|
|
for (const id of ["3f2a2c3e-1b4d-4e5a-9c8d-0000000000ff", "garbage", ""]) {
|
|
const res = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
|
|
assert.equal(res.status, 404, `id ${JSON.stringify(id)} must 404`);
|
|
}
|
|
});
|
|
|
|
test("GW9 DELETE own id → 200 {deleted:true}; the KV record is GONE (D-14's server half)", async () => {
|
|
const kv = makeKV();
|
|
const { token, id } = await seedEnvelope(kv, 101);
|
|
const res = await handler(guidanceReq("DELETE", { token, id }), makeEnv(kv), {});
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { deleted: true });
|
|
assert.equal(kv.map.has(`guidance:${id}`), false, "actively removed, not merely expired");
|
|
const again = await handler(guidanceReq("GET", { token, id }), makeEnv(kv), {});
|
|
assert.equal(again.status, 404);
|
|
});
|
|
|
|
test("GW10 DELETE a foreign chat's id → 404 and the record SURVIVES (cross-chat deletion impossible, T-06-02b)", async () => {
|
|
const kv = makeKV();
|
|
const { id } = await seedEnvelope(kv, 101);
|
|
const mallory = await bearerFor(kv, 202);
|
|
const res = await handler(guidanceReq("DELETE", { token: mallory, id }), makeEnv(kv), {});
|
|
assert.equal(res.status, 404);
|
|
assert.deepEqual(await res.json(), { error: "not_found" });
|
|
assert.ok(kv.map.has(`guidance:${id}`), "a foreign DELETE never deletes");
|
|
});
|
|
|
|
test("GW11 PUT /api/guidance → 405", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const res = await handler(guidanceReq("PUT", { token }), makeEnv(kv), {});
|
|
assert.equal(res.status, 405);
|
|
});
|
|
|
|
/* ————————————————————————————————————————————————————————————————————————
|
|
* /api/route — the engine-A arm (06-04, D-01/D-03, T-06-01/T-06-01b).
|
|
*
|
|
* The Worker routes for real: R2 graph binary (faked here with the COMMITTED
|
|
* mini-graph fixture via fs, plus purpose-built corridor/sparse/split graphs
|
|
* through the same encodeGraph contract) → decode → snap origin+destination
|
|
* → A* → RouteResult. Identity is the Bearer CLAIM (T-06-01); the origin is
|
|
* type+range-validated against graph.bbox BEFORE any graph work (T-06-01b,
|
|
* V5); every miss class degrades to 200 null — the null contract crosses
|
|
* HTTP (D-03 router-pending), never a 500.
|
|
*
|
|
* RT1 POST without Bearer → 401 {error:"unauthorized"}; category-only log
|
|
* RT2 over-cap body (4096+) with a valid claim → 413
|
|
* RT3 malformed body classes → 400 (broken JSON, null/array body, missing
|
|
* or non-numeric origin, off-slug destinationId)
|
|
* RT4 origin outside graph.bbox → 400 (V5 range validation)
|
|
* RT5 unknown destinationId → 200 null (JSON null body)
|
|
* RT6 R2 graph absent → 200 null (designed degradation, D-03)
|
|
* RT7 corrupt graph binary → 200 null (decodeGraph fail-honest, T-06-03)
|
|
* RT8 origin in-bbox but nodeless (snap miss) → 200 null
|
|
* RT9 origin/destination snap but graph disconnected (A* miss) → 200 null
|
|
* RT10 happy path → 200 RouteResult: backend "engine-a", ONE option (no
|
|
* alternatives on the guidance path), steps carry kind/name/distance/
|
|
* polyline6; the R2 fake is hit EXACTLY ONCE across two requests
|
|
* (module-scope memo — 1 GET per cold isolate)
|
|
* RT11 GET /api/route → 405
|
|
* ———————————————————————————————————————————————————————————————————————— */
|
|
|
|
const { encodeGraph } = await import("../../routing/src/graph.ts");
|
|
const { haversineM } = await import("../../routing/src/geo.ts");
|
|
const { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables } = await import(
|
|
"../../routing/src/landmark.ts"
|
|
);
|
|
|
|
/** Tiny REAL landmark block through the ONE preprocessing core (08-02):
|
|
* farthest-point anchor + integer ĉ tables — computed, never faked.
|
|
* `{ pin }` pins the anchor for graphs with no degree>=2 candidate (the
|
|
* selection filter's law — e.g. a single-segment graph); the tables still
|
|
* come from the ONE integer-Dijkstra core. */
|
|
function landmarkBlock(nodeCount, edges, { pin } = {}) {
|
|
const input = { nodeCount, edges, quantumS: LANDMARK_QUANTUM_S };
|
|
const anchors = pin !== undefined ? Uint32Array.of(pin) : farthestPointAnchors(input, 1);
|
|
if (anchors.length !== 1) throw new Error("graph has no degree-2 anchor candidate");
|
|
const { to, from } = landmarkTables(input, anchors);
|
|
return { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from };
|
|
}
|
|
|
|
/** The committed 06-01 ground truth (25 nodes around KL center) via fs. */
|
|
const MINI_GRAPH = readFileSync(new URL("../../routing/test/fixtures/mini-graph.bin", import.meta.url));
|
|
|
|
/** Public gazetteer landmarks only — no user coordinate enters a test (D-12). */
|
|
const KL_TOWER = { lat: 3.1528, lng: 101.7038 };
|
|
const KL_SENTRAL = { lat: 3.1343, lng: 101.6869 };
|
|
|
|
/** 8-node straight corridor kl-tower → suria-klcc (public coords), two-way. */
|
|
function corridorGraphBuffer({ gap = false } = {}) {
|
|
const n = 8;
|
|
const lat = [];
|
|
const lng = [];
|
|
for (let i = 0; i < n; i++) {
|
|
lat.push(3.1528 + ((3.1578 - 3.1528) * i) / (n - 1));
|
|
lng.push(101.7038 + ((101.7117 - 101.7038) * i) / (n - 1));
|
|
}
|
|
const edges = [];
|
|
for (let i = 0; i < n - 1; i++) {
|
|
if (gap && i === 3) continue; // split variant: no edge across the middle
|
|
const meters = haversineM({ lat: lat[i], lng: lng[i] }, { lat: lat[i + 1], lng: lng[i + 1] });
|
|
const seconds = meters / (60 / 3.6);
|
|
edges.push({ from: i, to: i + 1, seconds, nameId: 0 }, { from: i + 1, to: i, seconds, nameId: 0 });
|
|
}
|
|
return encodeGraph({
|
|
lat,
|
|
lng,
|
|
edges,
|
|
names: ["Jalan Koridor Ujian"],
|
|
maxSpeedKmh: 60,
|
|
landmarks: { ...landmarkBlock(n, edges) },
|
|
});
|
|
}
|
|
|
|
/** Nodes cluster at two far-apart cells with a nodeless middle: an in-bbox
|
|
* origin between them is the snap-miss carrier (3x3-neighborhood law). */
|
|
function sparseGraphBuffer() {
|
|
const lat = [3.15, 3.1501, 3.1515];
|
|
const lng = [101.69, 101.6905, 101.7];
|
|
const meters = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[1], lng: lng[1] });
|
|
const seconds = meters / (50 / 3.6);
|
|
const edges = [
|
|
{ from: 0, to: 1, seconds, nameId: 0 },
|
|
{ from: 1, to: 0, seconds, nameId: 0 },
|
|
];
|
|
return encodeGraph({
|
|
lat,
|
|
lng,
|
|
edges,
|
|
names: ["Jalan Sparse Ujian"],
|
|
maxSpeedKmh: 50,
|
|
landmarks: { ...landmarkBlock(lat.length, edges, { pin: 0 }) },
|
|
});
|
|
}
|
|
|
|
const CORRIDOR_BUF = corridorGraphBuffer();
|
|
const SPLIT_BUF = corridorGraphBuffer({ gap: true });
|
|
const SPARSE_BUF = sparseGraphBuffer();
|
|
|
|
/** An R2 fake over one fixed binary; counts + records its gets (the memo probe).
|
|
* Node Buffers are views over a pooled ArrayBuffer — the fake hands over an
|
|
* exact copy (the astar suite's toArrayBuffer law) so decodeGraph sees what
|
|
* a real R2 arrayBuffer() returns. */
|
|
function r2With(maybeBuf) {
|
|
let gets = 0;
|
|
let lastKey = null;
|
|
const exact = () => {
|
|
if (maybeBuf === null) return null;
|
|
if (maybeBuf instanceof ArrayBuffer) return maybeBuf; // encodeGraph's product
|
|
return maybeBuf.buffer.slice(maybeBuf.byteOffset, maybeBuf.byteOffset + maybeBuf.byteLength); // fs Buffer (view)
|
|
};
|
|
return {
|
|
gets: () => gets,
|
|
lastKey: () => lastKey,
|
|
async get(key) {
|
|
gets += 1;
|
|
lastKey = key;
|
|
if (maybeBuf === null) return null; // R2 object absent
|
|
return { arrayBuffer: async () => exact() };
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeRouteEnv(kv, r2) {
|
|
return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv, ROAD_GRAPH: r2 };
|
|
}
|
|
|
|
function routeReq({ body, token, method = "POST" } = {}) {
|
|
return new Request("https://host/api/route", {
|
|
method,
|
|
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
...(method === "POST" && body !== undefined
|
|
? { body: typeof body === "string" ? body : JSON.stringify(body) }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
const ROUTE_BODY = { origin: KL_TOWER, destinationId: "suria-klcc" };
|
|
|
|
test("RT1 POST /api/route without Bearer → 401 {error:'unauthorized'}; log is the category ONLY (T-06-01)", async () => {
|
|
const kv = makeKV();
|
|
const r2 = r2With(CORRIDOR_BUF);
|
|
const { result, seen } = await captureLogs(() => handler(routeReq({ body: ROUTE_BODY }), makeRouteEnv(kv, r2), {}));
|
|
const res = await result;
|
|
assert.equal(res.status, 401, `expected 401, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
assert.equal(r2.gets(), 0, "an unauthorized caller never touches the graph");
|
|
assert.deepEqual(seen, ["[route] rejected: unauthorized"], "category ONLY — no token, no coordinates");
|
|
});
|
|
|
|
test("RT2 over-cap body (4096+) with a valid claim → 413; the graph is never consulted", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(CORRIDOR_BUF);
|
|
const overCap = JSON.stringify(ROUTE_BODY).concat("x".repeat(4200));
|
|
assert.ok(overCap.length > 4096, "fixture must exceed the cap");
|
|
const { result, seen } = await captureLogs(() => handler(routeReq({ body: overCap, token }), makeRouteEnv(kv, r2), {}));
|
|
const res = await result;
|
|
assert.equal(res.status, 413, `expected 413, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "malformed" });
|
|
assert.equal(r2.gets(), 0, "an over-cap body never reaches the graph");
|
|
assert.deepEqual(seen, ["[route] rejected: malformed"], "category ONLY");
|
|
});
|
|
|
|
test("RT3 malformed body classes → 400 {error:'malformed'} + category-only log", async () => {
|
|
const badBodies = [
|
|
["broken JSON", "{not json"],
|
|
["null body", "null"],
|
|
["array body", "[1,2]"],
|
|
["missing origin", { destinationId: "suria-klcc" }],
|
|
["non-numeric origin.lat", { origin: { lat: "far", lng: 101.7 }, destinationId: "suria-klcc" }],
|
|
["missing origin.lng", { origin: { lat: 3.15 }, destinationId: "suria-klcc" }],
|
|
["missing destinationId", { origin: KL_TOWER }],
|
|
["off-slug destinationId", { origin: KL_TOWER, destinationId: "Suria KLCC" }],
|
|
];
|
|
for (const [label, body] of badBodies) {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(CORRIDOR_BUF);
|
|
const { result, seen } = await captureLogs(() => handler(routeReq({ body, token }), makeRouteEnv(kv, r2), {}));
|
|
const res = await result;
|
|
assert.equal(res.status, 400, `${label} must be a 400, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "malformed" }, `${label} carries the category only`);
|
|
assert.deepEqual(seen, ["[route] rejected: malformed"], `${label} logs the category ONLY`);
|
|
}
|
|
});
|
|
|
|
test("RT4 origin outside graph.bbox → 400 (V5 range validation BEFORE any snap/A* work)", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(MINI_GRAPH); // mini bbox: lat 3.1500..3.1520, lng 101.6900..101.6920
|
|
const { result, seen } = await captureLogs(
|
|
() => handler(routeReq({ body: { origin: KL_SENTRAL, destinationId: "suria-klcc" }, token }), makeRouteEnv(kv, r2), {}),
|
|
);
|
|
const res = await result;
|
|
assert.equal(res.status, 400, `out-of-bbox origin must be a 400, got ${res.status}`);
|
|
assert.deepEqual(await res.json(), { error: "malformed" });
|
|
assert.deepEqual(seen, ["[route] rejected: malformed"]);
|
|
assert.equal(r2.gets(), 1, "the bbox check needs the loaded graph (1 GET), then stops");
|
|
});
|
|
|
|
test("RT5 unknown destinationId → 200 null (the null contract crosses HTTP, D-03)", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(MINI_GRAPH);
|
|
const res = await handler(
|
|
routeReq({ body: { origin: { lat: 3.1501, lng: 101.6901 }, destinationId: "a-destination-nowhere" }, token }),
|
|
makeRouteEnv(kv, r2),
|
|
{},
|
|
);
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
|
assert.equal(await res.text(), "null", "JSON null — honest router-pending, never a 500");
|
|
});
|
|
|
|
test("RT6 R2 graph absent → 200 null (designed degradation, D-03 — never an error)", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(null);
|
|
const res = await handler(routeReq({ body: ROUTE_BODY, token }), makeRouteEnv(kv, r2), {});
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
|
assert.equal(await res.text(), "null");
|
|
assert.equal(r2.lastKey(), "kl-roadgraph.v1.bin", "the loader asks for the pinned key");
|
|
});
|
|
|
|
test("RT7 corrupt graph binary → 200 null (decodeGraph fail-honest, T-06-03)", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(new TextEncoder().encode("this is definitely not a graph binary, but it is long enough to pass the 64-byte floor...").buffer);
|
|
const res = await handler(routeReq({ body: ROUTE_BODY, token }), makeRouteEnv(kv, r2), {});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(await res.text(), "null");
|
|
});
|
|
|
|
test("RT8 origin in-bbox but nodeless (snap miss) → 200 null", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(SPARSE_BUF);
|
|
// (3.1507, 101.6950) sits inside the sparse bbox, 2+ grid cells from every node.
|
|
const res = await handler(
|
|
routeReq({ body: { origin: { lat: 3.1507, lng: 101.695 }, destinationId: "suria-klcc" }, token }),
|
|
makeRouteEnv(kv, r2),
|
|
{},
|
|
);
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
|
|
assert.equal(await res.text(), "null");
|
|
});
|
|
|
|
test("RT9 snapped but graph disconnected (A* miss) → 200 null", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(SPLIT_BUF);
|
|
const res = await handler(routeReq({ body: ROUTE_BODY, token }), makeRouteEnv(kv, r2), {});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(await res.text(), "null");
|
|
});
|
|
|
|
test("RT10 happy path → 200 RouteResult: engine-a, ONE option, real steps; ONE R2 GET across two requests (memo)", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(CORRIDOR_BUF);
|
|
const env = makeRouteEnv(kv, r2);
|
|
const res = await handler(routeReq({ body: ROUTE_BODY, token }), env, {});
|
|
assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.clone().text()}`);
|
|
const result = await res.json();
|
|
assert.equal(result.destinationId, "suria-klcc");
|
|
assert.deepEqual(result.origin, KL_TOWER, "the request origin is echoed");
|
|
assert.equal(result.backend, "engine-a");
|
|
assert.equal(result.options.length, 1, "ONE best route — alternatives never during the guidance path");
|
|
const option = result.options[0];
|
|
const { points, cumulativeDistanceM } = option.geometry;
|
|
assert.ok(points.length >= 2, "a real node path");
|
|
assert.equal(points.length, cumulativeDistanceM.length, "the day-one progress contract");
|
|
assert.equal(cumulativeDistanceM[0], 0);
|
|
assert.ok(option.summary.distanceM > 0 && Number.isFinite(option.summary.distanceM));
|
|
assert.ok(option.summary.durationS > 0 && Number.isFinite(option.summary.durationS));
|
|
assert.ok(Array.isArray(option.steps) && option.steps.length >= 2);
|
|
assert.equal(option.steps[0].kind, "depart");
|
|
assert.equal(option.steps[option.steps.length - 1].kind, "arrive");
|
|
assert.equal(option.steps[0].name, "Jalan Koridor Ujian", "steps carry the street name");
|
|
for (const step of option.steps) {
|
|
assert.ok(typeof step.kind === "string");
|
|
assert.ok(typeof step.geometry === "string" && step.geometry.length > 0, "polyline6 geometry");
|
|
if (step.kind !== "arrive") assert.ok(Number.isFinite(step.distanceM) && step.distanceM > 0);
|
|
}
|
|
assert.equal(r2.gets(), 1, "the load memo: exactly one R2 GET");
|
|
assert.equal(r2.lastKey(), "kl-roadgraph.v1.bin");
|
|
|
|
// A second request on the SAME env (same isolate) — still one GET.
|
|
const again = await handler(routeReq({ body: ROUTE_BODY, token }), env, {});
|
|
assert.equal(again.status, 200);
|
|
assert.equal(r2.gets(), 1, "module-scope memo — 1 R2 GET per cold isolate, never per request");
|
|
});
|
|
|
|
test("RT11 GET /api/route → 405", async () => {
|
|
const kv = makeKV();
|
|
const token = await bearerFor(kv, 101);
|
|
const r2 = r2With(CORRIDOR_BUF);
|
|
const res = await handler(routeReq({ method: "GET", token }), makeRouteEnv(kv, r2), {});
|
|
assert.equal(res.status, 405);
|
|
assert.equal(r2.gets(), 0);
|
|
});
|