forked from c-fu/arah2
feat(03-02): D-11 Mock-Telegram harness + the four requirement gates (17 cases green)
- 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
This commit is contained in:
@@ -70,8 +70,10 @@ export async function requestTelegramFix(
|
||||
* of the never-refreshing native cache. Only telegram-vs-telegram pairs
|
||||
* qualify — different sources bounding identically is coincidence, not stale.
|
||||
*/
|
||||
export function looksStale(prev: Fix | null, next: Fix): boolean {
|
||||
if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false;
|
||||
export function looksStale(prev: Fix | null | undefined, next: Fix): boolean {
|
||||
// undefined is normalized to null: acquireFix(deps) with no second
|
||||
// argument is the documented "first fix of the session" call.
|
||||
if (prev === null || prev === undefined || prev.source !== "telegram" || next.source !== "telegram") return false;
|
||||
return haversineMeters(prev, next) < 0.01;
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ export function haversineMeters(a: Fix, b: Fix): number {
|
||||
export async function nudgeAndRetryTelegram(
|
||||
wa: TelegramWebAppLike,
|
||||
readWebviewFix: (o?: { timeoutMs?: number }) => Promise<Fix | null>,
|
||||
lastAccepted: Fix | null,
|
||||
lastAccepted: Fix | null | undefined,
|
||||
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
||||
): Promise<Fix | null> {
|
||||
const first = await requestTelegramFix(wa, options);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* telegram-mock.ts — the D-11 Mock-Telegram harness.
|
||||
* -------------------------------------------------
|
||||
* Injects a fake `window.Telegram.WebApp` into the SAME window the app
|
||||
* runs in — never an iframe. Bot API 10.2 (enforced 2026-07-20) rejects
|
||||
* Mini App method calls from any origin other than the registered Mini
|
||||
* App domain, so an iframe harness would silently fail (RESEARCH
|
||||
* Pitfall 6).
|
||||
*
|
||||
* Dev-signed initData uses the SAME HMAC path as bot/src/security/initdata.ts
|
||||
* (`HMAC(bot_token, "WebAppData")`) so `POST /api/session` accepts it
|
||||
* unmodified against `bot/.dev.vars`. Changes nothing when the token
|
||||
* is real, and never logs it.
|
||||
*
|
||||
* Signing runs on the Web Crypto API (crypto.subtle.importKey + sign,
|
||||
* HMAC-SHA-256, TextEncoder) — the only crypto available in a browser
|
||||
* — so `makeDevInitData` is async. The test suites use their own
|
||||
* test-side signer instead.
|
||||
*
|
||||
* This module ships nothing to production: `no-storage.test.mjs` and
|
||||
* the build-artifact gate both assert it is excluded from `web/dist`.
|
||||
*/
|
||||
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
|
||||
|
||||
export interface MockOptions {
|
||||
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
|
||||
version?: string;
|
||||
colorScheme?: "light" | "dark";
|
||||
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
|
||||
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
|
||||
grantLocation?: boolean;
|
||||
}
|
||||
|
||||
interface MockLocationManager {
|
||||
isInited: boolean;
|
||||
isLocationAvailable: boolean;
|
||||
isAccessRequested: boolean;
|
||||
isAccessGranted: boolean;
|
||||
init(cb: () => void): void;
|
||||
getLocation(cb: (data: unknown | null) => void): void;
|
||||
}
|
||||
|
||||
interface MockWebApp extends TelegramWebAppLike {
|
||||
__events: Map<string, Array<() => void>>;
|
||||
__backButtonClicks: Array<() => void>;
|
||||
}
|
||||
|
||||
/* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */
|
||||
|
||||
function utf8Bytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
async function hmacBytes(message: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
key as unknown as ArrayBuffer,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", cryptoKey, message as unknown as ArrayBuffer);
|
||||
return new Uint8Array(sig);
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/** dataCheckString = fields minus hash/signature, sorted by key, `key=value`, joined with "\n". */
|
||||
function dataCheckString(fields: Array<[string, string]>): string {
|
||||
return fields
|
||||
.filter(([key]) => key !== "hash" && key !== "signature")
|
||||
.slice()
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** `POST /api/session` computes the same two-step HMAC: key = HMAC(botToken, salt). */
|
||||
export async function makeDevInitData(
|
||||
botToken: string,
|
||||
user: { id: number; first_name?: string; language_code?: string },
|
||||
): Promise<string> {
|
||||
const fields: Array<[string, string]> = [
|
||||
["user", JSON.stringify(user)],
|
||||
["auth_date", String(Math.floor(Date.now() / 1000))],
|
||||
];
|
||||
const dcs = dataCheckString(fields);
|
||||
// Step 1: secret key = HMAC(message = botToken bytes, key = the platform salt constant).
|
||||
const secretKey = await hmacBytes(utf8Bytes(botToken), utf8Bytes("WebAppData"));
|
||||
// Step 2: hash = HMAC(message = dataCheckString bytes, key = that secret key).
|
||||
const hash = bytesToHex(await hmacBytes(utf8Bytes(dcs), secretKey));
|
||||
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
|
||||
return `${wire}&hash=${hash}`;
|
||||
}
|
||||
|
||||
/* ————— install / uninstall ————— */
|
||||
|
||||
/** Dotted-numeric Bot API version compare: "8.0" >= "8.0", "9.1" >= "8.0". */
|
||||
function versionAtLeast(have: string, want: string): boolean {
|
||||
const a = have.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||||
const b = want.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||||
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;
|
||||
}
|
||||
|
||||
let installed: MockWebApp | null = null;
|
||||
|
||||
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike {
|
||||
const version = options.version ?? "8.0";
|
||||
const telegramFix = options.telegramFix ?? null;
|
||||
const lm: MockLocationManager = {
|
||||
isInited: false,
|
||||
isLocationAvailable: options.grantLocation !== false,
|
||||
isAccessRequested: options.grantLocation !== false,
|
||||
isAccessGranted: options.grantLocation !== false,
|
||||
init(cb: () => void) {
|
||||
setTimeout(() => {
|
||||
lm.isInited = true;
|
||||
cb();
|
||||
}, 0);
|
||||
},
|
||||
getLocation(cb: (data: unknown | null) => void) {
|
||||
// grantLocation true → delivers the configured fix on a macrotask so the
|
||||
// callback is asynchronous (a wedged client is cb(null) or silence).
|
||||
if (options.grantLocation !== false && telegramFix !== null) {
|
||||
setTimeout(() => cb(telegramFix), 0);
|
||||
} else {
|
||||
setTimeout(() => cb(null), 0); // the denial signal — synchronous null would hide async bugs
|
||||
}
|
||||
},
|
||||
};
|
||||
const events = new Map<string, Array<() => void>>();
|
||||
const backButtonClicks: Array<() => void> = [];
|
||||
const fake: MockWebApp = {
|
||||
initData: "",
|
||||
colorScheme: options.colorScheme ?? "light",
|
||||
themeParams: {},
|
||||
version,
|
||||
platform: options.platform ?? "android",
|
||||
__events: events,
|
||||
__backButtonClicks: backButtonClicks,
|
||||
isVersionAtLeast(v: string) {
|
||||
return versionAtLeast(version, v);
|
||||
},
|
||||
close() {
|
||||
/* the runner page listens to window-event hooks; nothing to close here */
|
||||
},
|
||||
onEvent(event: string, cb: () => void) {
|
||||
const list = events.get(event) ?? [];
|
||||
list.push(cb);
|
||||
events.set(event, list);
|
||||
},
|
||||
offEvent(event: string, cb: () => void) {
|
||||
events.set(event, (events.get(event) ?? []).filter((f) => f !== cb));
|
||||
},
|
||||
BackButton: {
|
||||
show() {},
|
||||
hide() {},
|
||||
onClick(cb: () => void) {
|
||||
backButtonClicks.push(cb);
|
||||
},
|
||||
offClick(cb: () => void) {
|
||||
const i = backButtonClicks.indexOf(cb);
|
||||
if (i >= 0) backButtonClicks.splice(i, 1);
|
||||
},
|
||||
},
|
||||
LocationManager: lm,
|
||||
};
|
||||
installed = fake;
|
||||
const w = window as unknown as { Telegram?: unknown };
|
||||
w.Telegram = { WebApp: fake };
|
||||
return fake;
|
||||
}
|
||||
|
||||
export function uninstallMockWebApp(): void {
|
||||
installed = null;
|
||||
const w = window as unknown as { Telegram?: unknown };
|
||||
delete w.Telegram;
|
||||
}
|
||||
|
||||
/** Fire the registered themeChanged listeners — theme.test.mjs PX-02 case 4. */
|
||||
export function emitThemeChanged(): void {
|
||||
const fake = installed;
|
||||
if (!fake) throw new Error("installMockWebApp must run before emitThemeChanged");
|
||||
for (const cb of fake.__events.get("themeChanged") ?? []) cb();
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* no-storage.test.mjs — the GEO-03 grep gate (zero persistence, source-introspection).
|
||||
* -----------------------------------------------------------------------------------
|
||||
* Inverted form of bot/test/privacy.test.mjs (PG1 pins writes to carry TTLs; this
|
||||
* pin that web/src contains NO persistence surface at all — D-03 (memory-only
|
||||
* token) + D-07 (coordinates live in memory only)).
|
||||
*
|
||||
* The gate MUST strip comments before scanning: web/src/mock/telegram-mock.ts's
|
||||
* header documents the ban in prose, and prose is not a call site. Strip block
|
||||
* comments (/* … */) and line comments (// …), THEN assert none of the banned
|
||||
* tokens appear — code occurrences only.
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "./harness.mjs";
|
||||
|
||||
const SRC = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src");
|
||||
|
||||
const BANNED = [
|
||||
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
|
||||
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
|
||||
];
|
||||
|
||||
/* path-scrape + comment strip (the plan's contract for this gate) */
|
||||
|
||||
function listFiles(dir, exts, acc = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = path.join(dir, entry);
|
||||
if (statSync(full).isDirectory()) listFiles(full, exts, acc);
|
||||
else if (exts.some((e) => entry.endsWith(e))) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
|
||||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading)
|
||||
}
|
||||
|
||||
function codeText(file) {
|
||||
return stripComments(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
/* ————— GEO3 — the mechanical gate over every web/src source ————— */
|
||||
|
||||
test("GEO3 no banned storage token appears in web/src code (comments stripped, all *.ts/*.html)", () => {
|
||||
const files = listFiles(SRC, [".ts", ".html"]);
|
||||
assert.ok(files.length >= 12, `scanned a real tree (found ${files.length} files)`);
|
||||
for (const file of files) {
|
||||
const code = codeText(file);
|
||||
for (const banned of BANNED) {
|
||||
assert.ok(!code.includes(banned), `${path.relative(process.cwd(), file)} references ${banned} — GEO-03 violated`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("GEO3 the runtime-probe-preceding invariant: no service worker / registration call exists", () => {
|
||||
// Vite emits no SW by default; this pins the "and none was added" half (RESEARCH GEO-03 §).
|
||||
for (const file of listFiles(SRC, [".ts", ".html"])) {
|
||||
const code = codeText(file);
|
||||
for (const token of ["serviceWorker.register", "navigator.serviceWorker", "workbox"]) {
|
||||
assert.ok(!code.includes(token), `${path.relative(process.cwd(), file)} references ${token} — GEO-03 surface added`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("GEO3 web/src/session/store.ts assigns nothing to any storage object (the D-03 half)", () => {
|
||||
const code = codeText(path.join(SRC, "session", "store.ts"));
|
||||
assert.ok(!/Storage\s*\.\s*setItem|Storage\s*\.\s*getItem/.test(code), "store.ts touches a Storage object");
|
||||
assert.ok(!/indexedDB\s*\.\s*open/.test(code), "store.ts opens IndexedDB");
|
||||
assert.ok(!/document\s*\.\s*cookie\s*=/.test(code), "store.ts writes a cookie");
|
||||
assert.ok(!/caches\s*\.\s*open/.test(code), "store.ts opens a CacheStorage");
|
||||
// ...and keeps the named TTL constant the client revalidates against (D-03).
|
||||
assert.ok(code.includes("SESSION_TTL_SECONDS = 900"), "the 900s TTL mirror by name is present");
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theme.test.mjs — PX-02: the Telegram theme signal → data-tg-theme slot switch.
|
||||
* -----------------------------------------------------------------------------
|
||||
* applyTheme/watchTheme touch `document.documentElement.dataset`, and the Node
|
||||
* runner has no DOM — so every case installs a minimal document stub and
|
||||
* restores globalThis.document in finally (a leaked stub would silently make a
|
||||
* later pass/fail for the wrong reason). The stub's documentElement.dataset is
|
||||
* a plain writable object — that IS the assertion target — and its style has a
|
||||
* no-op setProperty because safe-area.ts's applySafeArea may be imported
|
||||
* transitively.
|
||||
*
|
||||
* Pitfall 7's invariant is greppable: case 3 reads the shipped theme.ts source
|
||||
* and asserts no setStyle call exists (source-introspection, the
|
||||
* commands.test.mjs CS4 precedent).
|
||||
*/
|
||||
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 themeMod = await import("../src/telegram/theme.ts");
|
||||
const { resolveBranch, applyTheme, watchTheme } = themeMod;
|
||||
|
||||
const realDocument = globalThis.document;
|
||||
|
||||
function installDocumentStub() {
|
||||
const dataset = {};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset, style: { setProperty() {} } },
|
||||
};
|
||||
return dataset;
|
||||
}
|
||||
|
||||
function fakeWebApp(opts = {}) {
|
||||
return {
|
||||
initData: "",
|
||||
colorScheme: opts.colorScheme,
|
||||
themeParams: opts.themeParams ?? {},
|
||||
isVersionAtLeast() {
|
||||
return true;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
|
||||
/* ————— PX02 — colorScheme paths ————— */
|
||||
|
||||
test("PX02 colorScheme 'dark' sets data-tg-theme=dark; 'light' sets light", () => {
|
||||
const ds = installDocumentStub();
|
||||
try {
|
||||
applyTheme(fakeWebApp({ colorScheme: "dark" }));
|
||||
assert.strictEqual(ds.tgTheme, "dark");
|
||||
applyTheme(fakeWebApp({ colorScheme: "light" }));
|
||||
assert.strictEqual(ds.tgTheme, "light");
|
||||
} finally {
|
||||
globalThis.document = realDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test("PX02 a missing colorScheme falls back to header_bg_color luminance >= 0.5 (light) / < 0.5 (dark)", () => {
|
||||
const ds = installDocumentStub();
|
||||
try {
|
||||
// Luminance of the Rams dark header (#121212 ≈ 0.07) vs a light one (#f0ede8).
|
||||
const darkShell = fakeWebApp({ themeParams: { header_bg_color: "#121212" } });
|
||||
assert.strictEqual(resolveBranch(darkShell), "dark");
|
||||
const lightShell = fakeWebApp({ themeParams: { header_bg_color: "#f0ede8" } });
|
||||
assert.strictEqual(lightShell && resolveBranch(lightShell), "light");
|
||||
applyTheme(darkShell);
|
||||
assert.strictEqual(ds.tgTheme, "dark");
|
||||
applyTheme(lightShell);
|
||||
assert.strictEqual(ds.tgTheme, "light");
|
||||
// No usable signal at all → light default.
|
||||
assert.strictEqual(resolveBranch(fakeWebApp()), "light");
|
||||
} finally {
|
||||
globalThis.document = realDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test("PX02 applyTheme touches only the data-tg-theme attribute — the map style object is not re-set", () => {
|
||||
// Source-introspection form of the invariant (CS4 precedent): if the shipped
|
||||
// module ever acquires a setStyle call, this case fails the suite.
|
||||
const themeSrc = readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "telegram", "theme.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const code = themeSrc
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
||||
assert.ok(!code.includes("setStyle"), "theme.ts must never call setStyle (D-10 / Pitfall 7)");
|
||||
assert.ok(!/import.*maplibre/i.test(code.split("\n").filter((l) => !l.trim().startsWith("*")).join("\n")),
|
||||
"theme.ts must not import MapLibre — the map is out of its reach by construction");
|
||||
|
||||
const ds = installDocumentStub();
|
||||
try {
|
||||
applyTheme(fakeWebApp({ colorScheme: "dark" }));
|
||||
const touched = Object.keys(ds);
|
||||
assert.deepStrictEqual(touched, ["tgTheme"], "the attribute set is the ONLY observable mutation");
|
||||
} finally {
|
||||
globalThis.document = realDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test("PX02 watchTheme re-applies on the themeChanged event", () => {
|
||||
const ds = installDocumentStub();
|
||||
try {
|
||||
const listeners = [];
|
||||
const wa = fakeWebApp({ colorScheme: "light" });
|
||||
wa.onEvent = (event, cb) => {
|
||||
if (event === "themeChanged") listeners.push(cb);
|
||||
};
|
||||
watchTheme(wa);
|
||||
assert.strictEqual(ds.tgTheme, "light");
|
||||
assert.strictEqual(listeners.length, 1, "exactly one themeChanged listener registered");
|
||||
// The flip: colorScheme goes dark; the recomputation must land on the attribute.
|
||||
wa.colorScheme = "dark";
|
||||
listeners[0]();
|
||||
assert.strictEqual(ds.tgTheme, "dark", "themeChanged re-applied the slot switch");
|
||||
} finally {
|
||||
globalThis.document = realDocument;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user