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:
@@ -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"]) {
|
||||
if (!code.includes("/api/route")) continue;
|
||||
const wired = code.includes("worker-router") || code.includes("getClaimByToken");
|
||||
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`,
|
||||
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]);
|
||||
for (const spec of specifiers) {
|
||||
const hit = code.match(banned);
|
||||
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)`,
|
||||
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,
|
||||
`${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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, "<html>not json</html>");
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user