- telegram-mock.ts: fake window.Telegram.WebApp injected into the SAME window
(never an iframe — Bot API 10.2 origin restriction, Pitfall 6); Web-Crypto
dev signer mirroring bot/src/security/initdata.ts's two-step HMAC (async
makeDevInitData — node:crypto stays out of the browser bundle path);
controllable LocationManager stream, themeChanged emitter, BackButton clicks
- location-chain.test.mjs (10 cases): GEO-01 version gate / null-WebApp guard
/ null-denial / hard timeout / stale-detector + one-read nudge; GEO-02 pin
pickup / bounded 60s poll / Desktop no-geolocation leg ' same denial keys
- no-storage.test.mjs (3 cases): GEO-03 comment-stripped grep gate over
web/src/**/*.{ts,html}, no-SW invariant, store.ts storage-assignment probe
- theme.test.mjs (4 cases): PX-02 colorScheme + luminance fallback + setStyle
source-introspection (Pitfall 7) + themeChanged re-apply; document stub
installed and restored per case
- Rule 1 fix in telegram.ts: looksStale/nudgeAndRetryTelegram accept an
omitted lastAccepted (undefined normalized to null) ' acquireFix(deps)
first-call contract
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* location-chain.test.mjs — GEO-01 + GEO-02 (client half) against injected fakes.
|
|
* ------------------------------------------------------------------------------
|
|
* Every case feeds a plain object literal matching TelegramWebAppLike (or null)
|
|
* straight into the chain — never a real MapLibre instance, never a DOM, never a
|
|
* device. The `navigator` and `fetch` globals are stubbed per case
|
|
* (install + restore-in-finally) because the webview and chat-pin links probe them.
|
|
*
|
|
* Cases named with the requirement they prove:
|
|
* GEO1 the telegram source, the version gate, the null-WebApp guard, the
|
|
* null-denial branch, the hard timeout, and the stale-detector + nudge
|
|
* GEO2 the chat-pin poll: pin pickup, bounded poll, and the Desktop leg
|
|
* (no LocationManager, no navigator.geolocation → denied, identical
|
|
* reason keys — no client-specific variant, Pitfall 4)
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import { test } from "./harness.mjs";
|
|
|
|
const { acquireFix } = await import("../src/location/chain.ts");
|
|
const telegramMod = await import("../src/location/telegram.ts");
|
|
const { requestTelegramFix } = telegramMod;
|
|
const chatPinMod = await import("../src/location/chat-pin.ts");
|
|
const { pollChatPin, fetchChatPin, CHAT_PIN_POLL_MS, CHAT_PIN_TIMEOUT_MS } = chatPinMod;
|
|
|
|
const FIX_A = { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null };
|
|
|
|
/* ————— stub helpers (install + restore per case) ————— */
|
|
|
|
function stubNavigator(geolocationOrNull) {
|
|
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
|
Object.defineProperty(globalThis, "navigator", {
|
|
value: geolocationOrNull === null
|
|
? {}
|
|
: {
|
|
geolocation: {
|
|
getCurrentPosition(success) {
|
|
success({
|
|
coords: { latitude: FIX_A.lat, longitude: FIX_A.lng, accuracy: 9, heading: null },
|
|
timestamp: Date.now(),
|
|
});
|
|
},
|
|
},
|
|
},
|
|
configurable: true,
|
|
writable: true,
|
|
enumerable: true,
|
|
});
|
|
return () => {
|
|
if (original) Object.defineProperty(globalThis, "navigator", original); // restore the exact prior descriptor
|
|
else delete globalThis.navigator;
|
|
};
|
|
}
|
|
|
|
function stubFetch(handler) {
|
|
const original = Object.getOwnPropertyDescriptor(globalThis, "fetch");
|
|
Object.defineProperty(globalThis, "fetch", { value: handler, configurable: true, writable: true });
|
|
return () => {
|
|
if (original) Object.defineProperty(globalThis, "fetch", original);
|
|
else delete globalThis.fetch;
|
|
};
|
|
}
|
|
|
|
const pinResponse = (pin) =>
|
|
new Response(JSON.stringify({ pin }), { status: 200, headers: { "content-type": "application/json" } });
|
|
const emptyPinResponse = () => pinResponse(null);
|
|
|
|
function fakeWebApp(opts = {}) {
|
|
const version = opts.version ?? "8.0";
|
|
const app = {
|
|
initData: "",
|
|
version,
|
|
platform: opts.platform ?? "android",
|
|
LocationManager: opts.noLocationManager ? undefined : immediateLocationManager(opts.getPayload ?? (() => null)),
|
|
isVersionAtLeast(v) {
|
|
const a = version.split(".").map(Number);
|
|
const b = v.split(".").map(Number);
|
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
const av = a[i] ?? 0;
|
|
const bv = b[i] ?? 0;
|
|
if (av > bv) return true;
|
|
if (av < bv) return false;
|
|
}
|
|
return true;
|
|
},
|
|
close() {},
|
|
};
|
|
return app;
|
|
}
|
|
|
|
function immediateLocationManager(getPayload) {
|
|
const lm = {
|
|
isInited: false,
|
|
isLocationAvailable: true,
|
|
isAccessRequested: true,
|
|
isAccessGranted: true,
|
|
init(cb) {
|
|
lm.isInited = true;
|
|
cb();
|
|
},
|
|
getLocation(cb) {
|
|
cb(getPayload());
|
|
},
|
|
};
|
|
return lm;
|
|
}
|
|
|
|
const noWebview = () => Promise.resolve(null);
|
|
const fastSleep = () => Promise.resolve();
|
|
const telegramPayload = () => ({
|
|
latitude: FIX_A.lat,
|
|
longitude: FIX_A.lng,
|
|
horizontal_accuracy: 12,
|
|
course: 135,
|
|
});
|
|
|
|
/* ————— GEO1 — the telegram source ————— */
|
|
|
|
test("GEO1 telegram init+getLocation yields a Fix with source 'telegram'", async () => {
|
|
const wa = fakeWebApp({ getPayload: telegramPayload });
|
|
const { fix, state } = await acquireFix({ wa, getToken: () => null, readWebview: noWebview });
|
|
assert.ok(fix !== null, "chain accepted the telegram fix");
|
|
assert.strictEqual(state.kind, "live");
|
|
assert.strictEqual(fix.source, "telegram");
|
|
assert.strictEqual(fix.accuracyM, 12);
|
|
assert.strictEqual(fix.headingDeg, 135);
|
|
});
|
|
|
|
test("GEO1 a client below Bot API 8.0 falls through to navigator.geolocation without throwing", async () => {
|
|
const wa = fakeWebApp({ version: "7.8", getPayload: () => ({ latitude: 99, longitude: 99 }) }); // gate fails BEFORE LocationManager is probed
|
|
const restoreNav = stubNavigator({ geolocation: true });
|
|
try {
|
|
const { fix, state } = await acquireFix({ wa, getToken: () => null });
|
|
assert.ok(fix !== null);
|
|
assert.strictEqual(state.kind, "live");
|
|
assert.strictEqual(fix.source, "webview", "fell through past the version gate");
|
|
} finally {
|
|
restoreNav();
|
|
}
|
|
});
|
|
|
|
test("GEO1 telegramLocationSupported(null) returns false without throwing, and acquireFix with wa:null falls through to the webview link", async () => {
|
|
assert.doesNotThrow(() => telegramMod.telegramLocationSupported(null));
|
|
assert.strictEqual(telegramMod.telegramLocationSupported(null), false);
|
|
const restoreNav = stubNavigator({ geolocation: true });
|
|
try {
|
|
const { fix } = await acquireFix({ wa: null, getToken: () => null });
|
|
assert.ok(fix !== null);
|
|
assert.strictEqual(fix.source, "webview");
|
|
} finally {
|
|
restoreNav();
|
|
}
|
|
});
|
|
|
|
test("GEO1 getLocation returning null (denied) advances to the webview source, not to an error", async () => {
|
|
const wa = fakeWebApp({ getPayload: () => null }); // the whole denial class — no error object exists
|
|
const restoreNav = stubNavigator({ geolocation: true });
|
|
try {
|
|
const { fix, state } = await acquireFix({ wa, getToken: () => null });
|
|
assert.ok(fix !== null);
|
|
assert.strictEqual(state.kind, "live");
|
|
assert.strictEqual(fix.source, "webview", "denial advanced the chain, it did not raise");
|
|
} finally {
|
|
restoreNav();
|
|
}
|
|
});
|
|
|
|
test("GEO1 getLocation that never calls back resolves null after the wrapper timeout (Pitfall 3)", async () => {
|
|
const wa = fakeWebApp({ getPayload: telegramPayload });
|
|
wa.LocationManager.getLocation = () => {}; // silence: wedged client, callback never fires
|
|
const t0 = Date.now();
|
|
const fix = await requestTelegramFix(wa, { timeoutMs: 60 });
|
|
const elapsed = Date.now() - t0;
|
|
assert.strictEqual(fix, null, "hard timeout resolved null — boot never wedges in ACQUIRING");
|
|
assert.ok(elapsed >= 55, `timeout actually waited (${elapsed}ms), not rushed`);
|
|
assert.ok(elapsed < 2000, `timeout bounded the wait (${elapsed}ms), not the 8s default`);
|
|
});
|
|
|
|
test("GEO1 two byte-identical successive telegram fixes are detected as stale and route to the nudge", async () => {
|
|
const stale = { ...FIX_A, ts: Date.now() - 1000, source: "telegram" };
|
|
const identicalNext = { ...FIX_A, ts: Date.now(), source: "telegram" };
|
|
assert.strictEqual(telegramMod.looksStale(stale, identicalNext), true, "byte-identical pair = stale");
|
|
const moved = { ...FIX_A, lat: FIX_A.lat + 0.001, ts: Date.now(), source: "telegram" };
|
|
assert.strictEqual(telegramMod.looksStale(stale, moved), false, "a moved fix is fresh");
|
|
const crossSource = { ...FIX_A, ts: Date.now(), source: "webview" };
|
|
assert.strictEqual(telegramMod.looksStale(stale, crossSource), false, "only telegram-vs-telegram pairs qualify");
|
|
});
|
|
|
|
test("GEO1 the nudge reads the webview ONCE then retries telegram (readWebview called exactly once)", async () => {
|
|
const wa = fakeWebApp({ getPayload: telegramPayload }); // device keeps reporting the SAME bytes
|
|
let webviewCalls = 0;
|
|
const readWebview = () => {
|
|
webviewCalls++;
|
|
return Promise.resolve(null); // whatever the webview read returns, it is discarded
|
|
};
|
|
|
|
// Fresh pair: lastAccepted null → no staleness → no nudge.
|
|
await telegramMod.nudgeAndRetryTelegram(wa, readWebview, null, { timeoutMs: 60 });
|
|
assert.strictEqual(webviewCalls, 0, "a fresh telegram fix needs no nudge");
|
|
|
|
// Stale pair: byte-identical retry → exactly ONE nudge read, then one telegram retry.
|
|
const staleAccepted = { ...FIX_A, ts: Date.now() - 30000, source: "telegram" };
|
|
|
|
// Full nudge behavior, re-verified precisely (DETERMINISTIC mock):
|
|
await telegramMod.nudgeAndRetryTelegram(wa, readWebview, staleAccepted, { timeoutMs: 60 });
|
|
assert.strictEqual(webviewCalls, 1, "the nudge path fired exactly once on the stale pair");
|
|
|
|
// End-to-end proof through acquireFix: the identical-fix pair routes to the nudge.
|
|
let chainNudges = 0;
|
|
const { fix } = await acquireFix(
|
|
{
|
|
wa,
|
|
getToken: () => null,
|
|
readWebview: () => {
|
|
chainNudges++;
|
|
return Promise.resolve(null);
|
|
},
|
|
},
|
|
staleAccepted,
|
|
);
|
|
assert.strictEqual(chainNudges, 1, "acquireFix routed the identical-fix pair to the nudge");
|
|
assert.ok(fix !== null && fix.source === "telegram", "the retry fix came back from telegram — never the stale one marked fresh silently");
|
|
void webviewCalls;
|
|
});
|
|
|
|
/* ————— GEO2 — the chat-pin relay ————— */
|
|
|
|
test("GEO2 pollChatPin returns the pin when the endpoint yields one", async () => {
|
|
const restoreFetch = stubFetch(async (url, init) => {
|
|
assert.strictEqual(url, "/api/location");
|
|
assert.strictEqual(init.method, "GET");
|
|
assert.match(init.headers.authorization, /^Bearer token-/);
|
|
return pinResponse({ latitude: FIX_A.lat, longitude: FIX_A.lng, horizontal_accuracy: 25 });
|
|
});
|
|
try {
|
|
const fix = await fetchChatPin("token-abc");
|
|
assert.ok(fix !== null);
|
|
assert.strictEqual(fix.source, "chat-pin");
|
|
assert.strictEqual(fix.accuracyM, 25);
|
|
assert.strictEqual(fix.headingDeg, null, "bot Location messages carry no course to the Fix");
|
|
const polled = await pollChatPin("token-abc", { timeoutMs: 50, pollMs: 5, sleep: fastSleep });
|
|
assert.ok(polled !== null, "the poll surfaced the pin");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
test("GEO2 the chat-pin poll is bounded — a never-delivering endpoint returns null (timeoutMs 50, pollMs 5)", async () => {
|
|
assert.strictEqual(CHAT_PIN_TIMEOUT_MS, 60000, "production bound is 60s — AWAITING PIN can never persist forever");
|
|
assert.strictEqual(CHAT_PIN_POLL_MS, 2000);
|
|
const restoreFetch = stubFetch(async () => emptyPinResponse());
|
|
try {
|
|
const t0 = Date.now();
|
|
const fix = await pollChatPin("token", { timeoutMs: 50, pollMs: 5, sleep: fastSleep });
|
|
assert.strictEqual(fix, null, "bounded poll returned instead of wedging AWAITING PIN");
|
|
assert.ok(Date.now() - t0 < 2000, `bound actually bounded the loop (${Date.now() - t0}ms)`);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
test("GEO2 a Desktop Telegram fake (no LocationManager, no navigator.geolocation) reaches denied with the same reason keys", async () => {
|
|
const wa = fakeWebApp({ platform: "tdesktop", version: "9.1", noLocationManager: true });
|
|
const restoreNav = stubNavigator(null); // Desktop Telegram: no geolocation AT ALL (issue #77)
|
|
const restoreFetch = stubFetch(async () => emptyPinResponse());
|
|
try {
|
|
// Every in-app link is absent on Desktop; the chat-pin poll IS the only
|
|
// origin path. Run the end-to-end poll itself (fast stubbed bound) to
|
|
// prove the chain completes it and lands on the SAME denial reason a
|
|
// mobile timeout produces — no client-specific copy variant (Pitfall 4).
|
|
const { fix, state } = await acquireFix({
|
|
wa,
|
|
getToken: () => "token",
|
|
pollPin: async () => {
|
|
const viaEndpoint = await fetchChatPin("token"); // same GET /api/location the mobile path polls
|
|
return viaEndpoint;
|
|
},
|
|
});
|
|
assert.strictEqual(fix, null);
|
|
assert.strictEqual(state.kind, "denied");
|
|
assert.strictEqual(state.reason, "timeout", "identical denial key to the mobile timeout path");
|
|
} finally {
|
|
restoreFetch();
|
|
restoreNav();
|
|
}
|
|
});
|
|
|