diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs index b314987..a60ca5c 100644 --- a/bot/test/webhook.test.mjs +++ b/bot/test/webhook.test.mjs @@ -202,3 +202,236 @@ test("WK5 unarmed WEBHOOK_SECRET ('') → webhook rejects CLOSED with a constant 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:; 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); + 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, 0); + 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 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, 0); +}); + +test("GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at guidance:; body chat_id 999 IGNORED (T-06-02); extra fields dropped", async () => { + const kv = makeKV(); + const token = await bearerFor(kv, 101); + 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, 1, "mint is exactly one KV write"); + assert.equal(kv.ttlLog[0].key, `guidance:${id}`); + assert.equal(kv.ttlLog[0].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); + 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, 0, `${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); +});