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();
|
||||
}
|
||||
Reference in New Issue
Block a user