From 0abe64e734b34c454409ffcaf94ae08d75483162 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 13:22:51 +0800 Subject: [PATCH] test(06-04): failing http-source suite H1-H8 + /api/route block RT1-RT11 + RP1/RP2 re-pin (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routing/test/http-source.test.mjs: backend #2 client seam — slug-guard-first, fail-honest on throw/non-200/malformed/null-body, defensive RouteResult shape - webhook.test.mjs RT block: 401/413/400 classes, out-of-bbox origin (V5), 200-null on unknown dest / absent graph / corrupt binary / snap miss / A* miss, happy path (engine-a, ONE option, steps) with the 1-GET isolate-memo probe - route-privacy re-pin (06-04's own gate law): RP1 = claim-bound local routing (getClaimByToken, no fetch/proxy, arm wired to worker-router, no routing hosts), RP2 = workspace allowlist @araharah/{search,routing}/* subpaths only --- bot/test/route-privacy.test.mjs | 117 +++++++++---- bot/test/webhook.test.mjs | 276 ++++++++++++++++++++++++++++++ routing/test/http-source.test.mjs | 179 +++++++++++++++++++ 3 files changed, 539 insertions(+), 33 deletions(-) create mode 100644 routing/test/http-source.test.mjs diff --git a/bot/test/route-privacy.test.mjs b/bot/test/route-privacy.test.mjs index 6cc9bb4..8fb17f5 100644 --- a/bot/test/route-privacy.test.mjs +++ b/bot/test/route-privacy.test.mjs @@ -1,25 +1,31 @@ #!/usr/bin/env node /* - * route-privacy.test.mjs — the D-05 Worker boundary, gated (05-05, T-05-16; - * re-pinned by 06-03 per the gate re-pin law). + * route-privacy.test.mjs — the Worker routing boundary, gated (05-05 T-05-16; + * re-pinned by 06-03, then re-pinned AGAIN by 06-04 per the gate re-pin law). * ------------------------------------------------------------------------------ - * D-05: "The Worker gains no /api/route this phase. Fixtures ride in the web - * bundle." The routing STACK lives in the webview — the Worker must never - * grow a route proxy or import the routing machinery (router, graph, A*, - * fixtures). Phase 6 plan 06-03 legitimately broke ONE pane of that glass: - * the frozen guidance envelope's geometry field IS polyline6, and the - * Worker must validate it server-side (a corrupt geometry can never reach - * the cron). So RP2 re-pins from a total ban to an explicit allowlist — - * exactly ONE routing module may enter the Worker, the pure polyline6 - * codec; everything else routing-shaped stays out. RP1 stands unchanged. + * PHASE-5 SHAPE (historical): D-05 banned any /api/route outright — the + * routing stack lived web-side and the Worker grew no route proxy. * - * RP1 no route proxy — zero `/api/route` tokens AND zero - * `registerRoute` / `routeProxy` tokens anywhere in bot/src - * (comment-stripped): the Worker gains no route endpoint, not even - * an accidentally-named one - * RP2 routing allowlist — every `@araharah/routing` specifier in bot/src - * must be EXACTLY `@araharah/routing/src/polyline.ts` (codec - * validation only); any other subpath fails here at test time + * 06-03's re-pin: the frozen guidance envelope's geometry IS polyline6, so + * the polyline codec entered the Worker and RP2 became an explicit allowlist. + * RP1 stood unchanged. + * + * 06-04's re-pin (THIS FILE — D-01 made the Worker the router, so the + * negative-space ban is superseded by a positive-space law): + * + * RP1 claim-bound routing — the Worker MAY route, but only under the + * session identity: bot/src/routing/worker-router.ts must derive + * identity from the Bearer CLAIM (getClaimByToken — T-06-01), must + * NEVER forward to an external routing host (no fetch, no proxy — + * the routing computation is local A* over the R2 graph), and every + * file referencing the /api/route arm must be wired to that + * claim-deriving module. An unauthenticated or proxy-shaped routing + * arm fails here at test time. + * RP2 workspace allowlist — every @araharah/* specifier in bot/src must + * be a SUBPATH-WITH-EXTENSION import of the two allowed workspaces: + * @araharah/search/* and @araharah/routing/*. Any OTHER workspace + * package, or a bare package specifier (no subpath), fails. (GE5 + * still owns the KV-namespace count; this gate owns imports.) * * The scans cover EVERY TypeScript file under bot/src and grow with the * tree automatically. @@ -31,6 +37,7 @@ import path from "node:path"; import { test } from "./harness.mjs"; const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src"); +const ROUTER = path.join(SRC, "routing", "worker-router.ts"); function listFiles(dir, exts, acc = []) { for (const entry of readdirSync(dir)) { @@ -51,35 +58,79 @@ function codeText(file) { return stripComments(readFileSync(file, "utf8")); } -/* ————— RP1 — the Worker gains no route endpoint (D-05, T-05-16) ————— */ +/* ————— RP1 — the routing arm is claim-bound and never a proxy (T-06-01) ————— */ -test("RP1 no route proxy — zero /api/route + registerRoute/routeProxy tokens in bot/src (comment-stripped, D-05)", () => { +test("RP1a the router derives identity from the Bearer CLAIM and never forwards (worker-router.ts, T-06-01)", () => { + assert.ok( + existsIn(ROUTER), + "bot/src/routing/worker-router.ts must exist — 06-04's engine-A arm is the one lawful routing surface", + ); + const code = codeText(ROUTER); + assert.ok( + code.includes("getClaimByToken"), + "worker-router.ts must resolve the Bearer token through getClaimByToken — an unauthenticated routing arm fails RP1 (T-06-01)", + ); + assert.ok( + !/\bfetch\s*\(/.test(code), + "worker-router.ts must NEVER fetch — the routing computation is local A* over the R2 graph; a forwarding/proxy-shaped router fails RP1", + ); +}); + +test("RP1b every /api/route reference is wired to the claim-deriving router module", () => { const files = listFiles(SRC, [".ts"]); assert.ok(files.length >= 8, `scanned a real tree (found ${files.length} files)`); for (const file of files) { const code = codeText(file); - for (const banned of ["/api/route", "registerRoute", "routeProxy"]) { - assert.ok( - !code.includes(banned), - `${path.relative(process.cwd(), file)} references "${banned}" — D-05 forbids a Worker route proxy (T-05-16); fixtures ride the web bundle`, - ); - } + if (!code.includes("/api/route")) continue; + const wired = code.includes("worker-router") || code.includes("getClaimByToken"); + assert.ok( + wired, + `${path.relative(process.cwd(), file)} references "/api/route" but neither delegates to ` + + `worker-router.ts nor derives the claim itself — a detached/unauthenticated route arm fails RP1`, + ); } }); -/* ————— RP2 — the routing allowlist (re-pinned 06-03: codec only) ————— */ - -test("RP2 routing allowlist — every @araharah/routing specifier in bot/src is EXACTLY the polyline6 codec (D-05 + 06-03)", () => { - const ALLOWED = ["@araharah/routing/src/polyline.ts"]; +test("RP1c no external routing hostnames anywhere in bot/src (the routing oracle is OUR Worker)", () => { + const banned = /(overpass|project-osrm|osrm|valhalla|graphhopper|openrouteservice|routing\.openstreetmap)/i; const files = listFiles(SRC, [".ts"]); for (const file of files) { const code = codeText(file); - const specifiers = [...code.matchAll(/["'](@araharah\/routing[^"']*)["']/g)].map((m) => m[1]); + const hit = code.match(banned); + assert.ok( + hit === null, + `${path.relative(process.cwd(), file)} references routing-host "${hit?.[0]}" — the Worker routes locally (D-01); external routing hosts are maintainer-time build tools, never runtime dependencies`, + ); + } +}); + +/* ————— RP2 — the workspace import allowlist (re-pinned 06-04: search + routing) ————— */ + +test("RP2 workspace allowlist — @araharah/* imports in bot/src are search/routing SUBPATHS only (D-01 + 06-04)", () => { + const ALLOWED_PREFIXES = ["@araharah/search/", "@araharah/routing/"]; + const files = listFiles(SRC, [".ts"]); + for (const file of files) { + const code = codeText(file); + const specifiers = [...code.matchAll(/["'](@araharah\/[^"']*)["']/g)].map((m) => m[1]); for (const spec of specifiers) { + const allowed = + ALLOWED_PREFIXES.some((prefix) => spec.startsWith(prefix)) && + !ALLOWED_PREFIXES.some((prefix) => spec === prefix.slice(0, -1)); // a bare prefix with no subpath fails too assert.ok( - ALLOWED.includes(spec), - `${path.relative(process.cwd(), file)} imports "${spec}" — only the polyline6 codec may enter the Worker (06-03 geometry validation); the routing stack (router/graph/astar/fixtures) stays web-side (D-05)`, + allowed, + `${path.relative(process.cwd(), file)} imports "${spec}" — bot may import ONLY @araharah/search/* and ` + + `@araharah/routing/* as subpath-with-extension specifiers (the subpath law); any other workspace ` + + `package (or a bare specifier) fails RP2`, ); } } }); + +function existsIn(file) { + try { + statSync(file); + return true; + } catch { + return false; + } +} diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs index b4419c9..2bbaa31 100644 --- a/bot/test/webhook.test.mjs +++ b/bot/test/webhook.test.mjs @@ -30,6 +30,7 @@ */ 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 @@ -440,3 +441,278 @@ test("GW11 PUT /api/guidance → 405", async () => { 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"); + +/** 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 }); +} + +/** 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); + return encodeGraph({ + lat, + lng, + edges: [ + { from: 0, to: 1, seconds, nameId: 0 }, + { from: 1, to: 0, seconds, nameId: 0 }, + ], + names: ["Jalan Sparse Ujian"], + maxSpeedKmh: 50, + }); +} + +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). */ +function r2With(maybeBuf) { + let gets = 0; + let lastKey = null; + return { + gets: () => gets, + lastKey: () => lastKey, + async get(key) { + gets += 1; + lastKey = key; + if (maybeBuf === null) return null; // R2 object absent + return { arrayBuffer: async () => maybeBuf }; + }, + }; +} + +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); +}); diff --git a/routing/test/http-source.test.mjs b/routing/test/http-source.test.mjs new file mode 100644 index 0000000..dd5dfde --- /dev/null +++ b/routing/test/http-source.test.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/* + * http-source.test.mjs — RoutingSource backend #2's client seam (06-04, D-01). + * ------------------------------------------------------------------------------ + * Source-under-test: src/http-source.ts — createHttpRoutingSource(opts) turns + * the Worker's Bearer-gated POST /api/route into a RoutingSource. The fixture + * backend's laws carry over (fixture-source.ts is the posture reference): + * + * - slug guard FIRST: a hostile destinationId never reaches the network + * - fail-honest (T-05-04's .catch(() => null) law, network edition): a + * thrown fetch, a non-200, a malformed body, or a shape violation is an + * honest null — the designed router-pending state (D-03), never a throw + * in the webview's render path + * - a 200 with JSON null body IS the null contract crossing HTTP — also + * resolves null, not an error + * - the Authorization header rides ONLY when the injected bearer getter + * returns a token (a logged-out caller still sends the request; the + * server's 401 then degrades to null like any other miss) + * + * The fetcher is INJECTED (fetchImpl) — this suite is fully offline. + * + * Cases: + * H1 happy path → RouteResult; POST + JSON body {origin, destinationId}; + * Bearer header attached from the injected getter + * H2 slug guard first — off-slug destinationId → null, fetch NEVER called + * H3 non-200 (401/404/500) → null + * H4 thrown fetch → null (fail-honest) + * H5 malformed JSON body → null + * H6 200 with JSON null body (router-pending over HTTP) → null + * H7 shape violations → null (destination mismatch, options 0/4, non-finite + * summary, cumulativeDistanceM length ≠ points length, non-finite + * point, missing backend, steps not an array) + * H8 bearer() → null ⇒ request goes WITHOUT an Authorization header + * + * Run: npm run routing:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { test } from "./harness.mjs"; + +const ENDPOINT = "https://worker.example/api/route"; + +const OK_RESULT = { + destinationId: "suria-klcc", + origin: { lat: 3.1528, lng: 101.7038 }, + options: [ + { + geometry: { + points: [ + { lat: 3.1528, lng: 101.7038 }, + { lat: 3.1553, lng: 101.7078 }, + { lat: 3.1578, lng: 101.7117 }, + ], + cumulativeDistanceM: [0, 521.4, 1042.8], + }, + summary: { distanceM: 1042.8, durationS: 62.6 }, + steps: [ + { kind: "depart", name: "Jalan Koridor Ujian", distanceM: 1042.8, geometry: "oqv_Egt~~`Ewj@_q@" }, + { kind: "arrive", geometry: "{wv_Aeb~y`E" }, + ], + }, + ], + backend: "engine-a", +}; + +const REQUEST = { origin: { lat: 3.1528, lng: 101.7038 }, destinationId: "suria-klcc" }; + +/** A fetchImpl returning one fixed response; records the calls it saw. */ +function fetching(status, body, { throws = false } = {}) { + const calls = []; + const impl = async (url, init) => { + calls.push({ url, init }); + if (throws) throw new Error("network down"); + return { + ok: status >= 200 && status < 300, + status, + json: async () => JSON.parse(body), + }; + }; + return { impl, calls }; +} + +async function load() { + return import("../src/http-source.ts"); +} + +test("H1 happy path → RouteResult passthrough; POST JSON {origin, destinationId}; Bearer attached from the getter", async () => { + const { createHttpRoutingSource } = await load(); + const { impl, calls } = fetching(200, JSON.stringify(OK_RESULT)); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok-123", fetchImpl: impl }); + assert.equal(source.name, "engine-a"); + const result = await source.route(REQUEST); + assert.deepEqual(result, OK_RESULT, "the validated payload crosses as the RouteResult"); + assert.equal(calls.length, 1); + const { url, init } = calls[0]; + assert.equal(url, ENDPOINT); + assert.equal(init.method, "POST"); + assert.deepEqual(JSON.parse(init.body), { origin: REQUEST.origin, destinationId: REQUEST.destinationId }); + assert.equal(init.headers.authorization, "Bearer tok-123"); +}); + +test("H2 slug guard first — off-slug destinationId → null and the fetch is NEVER called", async () => { + const { createHttpRoutingSource } = await load(); + const { impl, calls } = fetching(200, JSON.stringify(OK_RESULT)); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + for (const bad of ["Suria KLCC", "", `${"x".repeat(65)}`, "klcc/../etc"]) { + const result = await source.route({ origin: REQUEST.origin, destinationId: bad }); + assert.equal(result, null, `off-slug id ${JSON.stringify(bad)} is an honest null`); + } + assert.equal(calls.length, 0, "a hostile id never reaches the network (T-05-03 posture)"); +}); + +test("H3 non-200 (401/404/500) → null", async () => { + const { createHttpRoutingSource } = await load(); + for (const status of [401, 404, 500]) { + const { impl } = fetching(status, '{"error":"x"}'); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + assert.equal(await source.route(REQUEST), null, `HTTP ${status} degrades to null`); + } +}); + +test("H4 thrown fetch → null (fail-honest, T-05-04 network edition)", async () => { + const { createHttpRoutingSource } = await load(); + const { impl } = fetching(200, "{}", { throws: true }); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + assert.equal(await source.route(REQUEST), null); +}); + +test("H5 malformed JSON body → null", async () => { + const { createHttpRoutingSource } = await load(); + const { impl } = fetching(200, "not json"); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + assert.equal(await source.route(REQUEST), null); +}); + +test("H6 200 with JSON null body (router-pending over HTTP) → null, not an error", async () => { + const { createHttpRoutingSource } = await load(); + const { impl } = fetching(200, "null"); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + assert.equal(await source.route(REQUEST), null); +}); + +test("H7 shape violations → null — every field defensive, the fixture-source posture", async () => { + const { createHttpRoutingSource } = await load(); + const mutations = [ + ["destinationId mismatch", (r) => { r.destinationId = "kl-sentral"; }], + ["options not an array", (r) => { r.options = "one"; }], + ["zero options", (r) => { r.options = []; }], + ["four options", (r) => { r.options = [OK_RESULT.options[0], OK_RESULT.options[0], OK_RESULT.options[0], OK_RESULT.options[0]]; }], + ["non-finite distanceM", (r) => { r.options[0].summary.distanceM = Number.NaN; }], + ["negative durationS", (r) => { r.options[0].summary.durationS = -5; }], + ["cumulativeDistanceM length ≠ points length", (r) => { r.options[0].geometry.cumulativeDistanceM = [0]; }], + ["non-finite point", (r) => { r.options[0].geometry.points[1].lat = Number.NaN; }], + ["missing backend", (r) => { delete r.backend; }], + ["steps not an array", (r) => { r.options[0].steps = "depart"; }], + ["origin not a point", (r) => { r.origin = { lat: "far", lng: 101.7 }; }], + ]; + for (const [label, mutate] of mutations) { + const payload = structuredClone(OK_RESULT); + mutate(payload); + const { impl } = fetching(200, JSON.stringify(payload)); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + const result = await source.route(REQUEST); + assert.equal(result, null, `${label} must degrade to null`); + } + for (const [label, raw] of [["array payload", "[]"], ["null payload", "null"], ["string payload", '"ok"']]) { + const { impl } = fetching(200, raw); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => "tok", fetchImpl: impl }); + assert.equal(await source.route(REQUEST), null, `${label} must degrade to null`); + } +}); + +test("H8 bearer() → null ⇒ the request goes out WITHOUT an Authorization header (server 401 degrades like any miss)", async () => { + const { createHttpRoutingSource } = await load(); + const { impl, calls } = fetching(401, '{"error":"unauthorized"}'); + const source = createHttpRoutingSource({ endpoint: ENDPOINT, bearer: () => null, fetchImpl: impl }); + assert.equal(await source.route(REQUEST), null); + assert.equal(calls.length, 1, "the request still went out"); + assert.equal(calls[0].init.headers.authorization, undefined, "no Authorization header without a token"); +});