Two carriers, one validation, memory-only resolution against the webview's own build-time gazetteer copy. Suite load fails until web/src/search/resolve-destination.ts exists (RED verified: 33 passed, 1 failed suite-load).
166 lines
7.1 KiB
JavaScript
166 lines
7.1 KiB
JavaScript
#!/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=<id>`, 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_<id> 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", "<script>", "%3Cscript%3E", "", "a".repeat(200), "kl cc", "kl+cc", "dest_"]) {
|
|
const viaUrl = readDestinationId(`?dest=${encodeURIComponent(hostile)}`, "");
|
|
assert.equal(viaUrl.id, null, `URL carrier must reject ${JSON.stringify(hostile)}`);
|
|
const viaStart = readDestinationId(
|
|
"",
|
|
`user=%7B%7D&auth_date=1&start_param=${encodeURIComponent(hostile)}&hash=h`,
|
|
);
|
|
assert.equal(viaStart.id, null, `start_param carrier must reject ${JSON.stringify(hostile)}`);
|
|
}
|
|
});
|
|
|
|
/* ————— DP7..DP8 — the resolver ————— */
|
|
|
|
test("DP7 a known id resolves to a valid entry (finite lat/lng, non-empty name)", () => {
|
|
const entry = resolveDestination("suria-klcc");
|
|
assert.ok(entry !== null, "suria-klcc must resolve against the webview's own gazetteer copy");
|
|
assert.equal(entry.name, "Suria KLCC");
|
|
assert.ok(Number.isFinite(entry.lat) && Number.isFinite(entry.lng), "coordinates must be finite");
|
|
assert.ok(entry.name.length > 0);
|
|
});
|
|
|
|
test("DP8 an unknown id returns null — never a fabricated coordinate", () => {
|
|
assert.equal(resolveDestination("not-a-real-place"), null);
|
|
assert.equal(resolveDestination(null), null);
|
|
});
|
|
|
|
/* ————— DP9..DP11 — the structural gates ————— */
|
|
|
|
function codeText(file) {
|
|
return readFileSync(file, "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
|
}
|
|
|
|
test("DP9 the memory-only source gate — no storage surface in code (GEO-03/D-07)", () => {
|
|
const file = path.join(
|
|
path.dirname(fileURLToPath(new URL(import.meta.url))),
|
|
"..",
|
|
"src",
|
|
"search",
|
|
"resolve-destination.ts",
|
|
);
|
|
const code = codeText(file);
|
|
for (const banned of ["localStorage", "sessionStorage", "indexedDB", "document.cookie", "caches"]) {
|
|
assert.ok(!code.includes(banned), `resolve-destination.ts code references ${banned} — the destination must stay memory-only`);
|
|
}
|
|
});
|
|
|
|
test("DP10 no crypto in the destination path (D-11 — a plain public place id)", () => {
|
|
const file = path.join(
|
|
path.dirname(fileURLToPath(new URL(import.meta.url))),
|
|
"..",
|
|
"src",
|
|
"search",
|
|
"resolve-destination.ts",
|
|
);
|
|
const code = codeText(file);
|
|
for (const banned of ["createHmac", "crypto.subtle", "sha256", "signature"]) {
|
|
assert.ok(!code.includes(banned), `resolve-destination.ts code references ${banned} — the deep link carries no crypto (D-11)`);
|
|
}
|
|
});
|
|
|
|
test("DP11 the id-set parity — every committed gazetteer id resolves (bot/web read ONE dataset)", () => {
|
|
assert.ok(gazetteer.entries.length >= 30, `scanned a real dataset (found ${gazetteer.entries.length} entries)`);
|
|
for (const entry of gazetteer.entries) {
|
|
const resolved = resolveDestination(entry.id);
|
|
assert.ok(resolved !== null, `gazetteer id "${entry.id}" must resolve in the webview copy`);
|
|
assert.equal(resolved.lat, entry.lat, `id "${entry.id}" coordinates must match the committed dataset`);
|
|
assert.equal(resolved.lng, entry.lng, `id "${entry.id}" coordinates must match the committed dataset`);
|
|
}
|
|
});
|
|
|
|
/* ————— the memory-only holder ————— */
|
|
|
|
test("DP12 setDestination/getDestination hold the entry in module scope (and reset to null)", () => {
|
|
const entry = resolveDestination("suria-klcc");
|
|
assert.ok(entry !== null);
|
|
setDestination(entry);
|
|
assert.equal(getDestination(), entry);
|
|
setDestination(null);
|
|
assert.equal(getDestination(), null);
|
|
});
|