diff --git a/web/test/destination-preload.test.mjs b/web/test/destination-preload.test.mjs new file mode 100644 index 0000000..fac35a3 --- /dev/null +++ b/web/test/destination-preload.test.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/* + * destination-preload.test.mjs — DP1..DP11, the ONBD-02 webview gates (D-11, D-07). + * ----------------------------------------------------------------------------------- + * The webview half of the deep-link hand-off: the destination id arrives from TWO + * carriers — the URL's own query string (`?dest=`, the inline web_app-button + * path, PRIMARY) and the SIGNED initData's `start_param` field (the documented + * direct-link / home-screen path, SECONDARY) — with ONE shared validation, and the + * resolved destination lives in MEMORY ONLY (GEO-03/D-07). + * + * Styles, per the repo's conventions: + * - pure-module vector cases over resolve-destination.ts (session.test.mjs shape) + * - source-introspection gates with comments stripped (no-storage.test.mjs shape) + * + * Cases: + * DP1 the URL carrier resolves (?dest=suria-klcc → url) + * DP2 the start_param carrier resolves (initData → start-param) + * DP3 the URL carrier wins when both are present + * DP4 the dest_ prefix is stripped on BOTH carriers + * DP5 no id yields the honest none + * DP6 malformed ids are rejected (traversal, XSS, empty, 200 chars) + * DP7 a known id resolves to a valid entry (finite lat/lng, non-empty name) + * DP8 an unknown id returns null — never a fabricated coordinate + * DP9 the memory-only source gate (no storage surface in code) + * DP10 no crypto in the destination path (D-11) + * DP11 the id-set parity — every committed gazetteer id resolves + */ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const { readDestinationId, resolveDestination, setDestination, getDestination } = await import( + "../src/search/resolve-destination.ts" +); +const gazetteer = JSON.parse( + readFileSync( + path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "..", "search", "src", "data", "kl-gazetteer.json"), + "utf8", + ), +); + +/* ————— DP1..DP6 — the two-carrier reader ————— */ + +test("DP1 the URL carrier resolves (?dest=suria-klcc → url)", () => { + assert.deepEqual(readDestinationId("?dest=suria-klcc", ""), { id: "suria-klcc", source: "url" }); +}); + +test("DP2 the start_param carrier resolves (signed initData field → start-param)", () => { + assert.deepEqual( + readDestinationId("", "user=%7B%7D&auth_date=1&start_param=suria-klcc&hash=h"), + { id: "suria-klcc", source: "start-param" }, + ); +}); + +test("DP3 the URL carrier wins over start_param when both are present", () => { + assert.deepEqual( + readDestinationId("?dest=suria-klcc", "user=%7B%7D&auth_date=1&start_param=pavilion-kl&hash=h"), + { id: "suria-klcc", source: "url" }, + "the inline-button path (URL) is PRIMARY — a stale start_param must not override it", + ); +}); + +test("DP4 the dest_ prefix is stripped on BOTH carriers (D-11's literal dest_ form)", () => { + assert.deepEqual(readDestinationId("?dest=dest_suria-klcc", ""), { id: "suria-klcc", source: "url" }); + assert.deepEqual( + readDestinationId("", "user=%7B%7D&auth_date=1&start_param=dest_suria-klcc&hash=h"), + { id: "suria-klcc", source: "start-param" }, + ); +}); + +test("DP5 no id at all yields the honest none", () => { + assert.deepEqual(readDestinationId("", ""), { id: null, source: "none" }); + assert.deepEqual(readDestinationId("", null), { id: null, source: "none" }); + assert.deepEqual(readDestinationId("", undefined), { id: null, source: "none" }); + assert.deepEqual(readDestinationId("?mock=android&scheme=dark", "user=%7B%7D&auth_date=1&hash=h"), { + id: null, + source: "none", + }); +}); + +test("DP6 malformed ids are rejected — traversal, XSS, empty, 200 chars, bad charset", () => { + for (const hostile of ["../../etc/passwd", "