test(06-04): failing http-source suite H1-H8 + /api/route block RT1-RT11 + RP1/RP2 re-pin (RED)

- 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
This commit is contained in:
araharah-executor
2026-10-24 13:22:51 +08:00
parent d51fd8b47b
commit 0abe64e734
3 changed files with 539 additions and 33 deletions
+84 -33
View File
@@ -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;
}
}
+276
View File
@@ -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);
});