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:
araharah-executor
2026-10-22 23:46:17 +08:00
parent 0a35bce256
commit 2659b7f352
5 changed files with 687 additions and 3 deletions
+193
View File
@@ -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();
}