Files
arah2/web/src/mock/telegram-mock.ts
T
araharah-executor aec8b07530 test(07-01): FS1-FS9 fullscreen gates + mock satisfies the grown contract
- telegram-mock gains requestFullscreen/exitFullscreen/lockOrientation/unlockOrientation + __fullscreen* state fields in the SAME change as the contract (contract + fake + test together)
- FS1-FS7: both-halves guard vectors, the throwing-platform path (FS4), the exit mirror (FS5), the event categories (FS6), the old-client fallback (FS7, SC2.e)
- FS8: safeAreaChanged re-resolution through the union authority, with a minimal document stub restored in finally
- FS9: comment-stripped source-scan ban on the DOM-fullscreen/orientation-lock escape hatches (T-07-10) + the guard-is-not-optional assertion
- suite 157 passed, 0 failed (148 baseline + 9); mutation check: neutering the 8.0 guard fails FS2/FS5
2026-10-25 03:45:49 +08:00

515 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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`.
*
* Phase 4 (04-04): the mock satisfies the contract's three new OPTIONAL
* members in the SAME change that introduces them — `startParam` (appended
* to the fake initData as `&start_param=<encoded>`, the direct-link carrier
* the ONBD-02 harness leg exercises), `homeScreenStatus` (what the mock's
* checkHomeScreenStatus callback delivers; default "unknown", the iOS
* reality), and the addToHomeScreen stub whose `__homeScreenAdded` counter
* tests read (the `__events` / `__backButtonClicks` exposure precedent).
* The mock's initData is HMAC-unvalidated by design — the runner page
* stubs `/api/session`.
*
* The ?drive= replay knob (06-08, D-16's browser leg): `?drive=<name>` makes
* THIS mock replace navigator.geolocation with a replay source that streams
* a committed drive fixture (web/test/fixtures/drives/*.json — the same JSON
* the Node replays prove the brain with) through the LIVE shell — the real
* pump, real builders, real camera consume the fixes via the real
* createGuidanceWatch. The replay clock starts lazily at the FIRST
* watchPosition arm (the route draw — the pump's ensureWatch), so no fix is
* ever emitted before something consumes it, and plays REAL-TIME: the
* machine's grace/storm clocks read wall time (route.ts's now()), so a
* compressed playback would desync them. window.__ARAHARAH_DRIVE.pause()/
* resume() freeze/resume emission mid-stream — the capture script's freeze
* for stable scripted taps (confirm/tripend legs) and the REROUTING leg's
* window. A pause stops EMISSION only; the shell's wall clock keeps running
* (grace windows inflate across a pause — legs that pause close before any
* machine timing matters). Console emits NOTHING per fix; an unknown drive
* name is the knob's one honest token (`ARAHARAH-DRIVE state=unknown`) and
* falls back to the default geolocation — never a crash, never replayed
* arbitrary data (the fixture/kernel mismatch mitigation, T-06-08).
*/
import type { TelegramWebAppLike, HomeScreenStatus } from "../telegram/webapp.ts";
export interface MockOptions {
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
version?: string;
colorScheme?: "light" | "dark";
/** Telegram language_code carried in the fake initData's user JSON —
* lets main.ts's localeFromInitData exercise its real parsing path
* and lets the runner page switch locales via ?locale=. */
languageCode?: string;
/** Artificial delivery delay (ms) for LocationManager.init()/getLocation()
* callbacks — the runner page's ?fixdelay= knob. At localhost speeds the
* whole three-source chain resolves in a few ms, so the staged boot cards
* (BOOT-01 validating / BOOT-02 acquiring) cannot otherwise be photographed;
* a delayed fix delivery holds the earlier stage on screen (03-04 capture
* harness only — defaults to 0, the fallback dev install is unchanged). */
deliveryDelayMs?: number;
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
grantLocation?: boolean;
/** The direct-link start parameter (04-04): appended to the mock initData
* as `&start_param=<encoded>` — the SECONDARY destination carrier's
* harness leg. The URL query carrier needs no mock: page.goto preserves
* `?dest=` in window.location.search. */
startParam?: string | null;
/** What the mock's checkHomeScreenStatus callback delivers (04-04).
* Defaults to "unknown" — the documented iOS reality, treated by the
* app as "supported but indeterminate", never an error. */
homeScreenStatus?: HomeScreenStatus;
}
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>;
__homeScreenAdded: number;
__fullscreenRequested: number;
__fullscreenExited: number;
__orientationLocked: number;
__orientationUnlocked: number;
__isFullscreen: boolean;
}
/* ————— 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;
/**
* Harness initData: a non-empty, correctly-SHAPED Telegram wire string
* (`user=<url-encoded JSON>&auth_date=1&query_id=h&hash=harness`). The
* capture runner stubs POST /api/session OUTSIDE any dead-code strip, so
* this never reaches an HMAC validator — it exists so main.ts's
* localeFromInitData parses the real wire format instead of falling
* back to "en" (locale = options.languageCode, defaulting to "en").
* Untouched when no languageCode is given (the plain-dev fallback mock
* keeps initData "" exactly as before) — unless a startParam arrives
* (04-04), in which case the wire carries it as `&start_param=<encoded>`
* so the SECONDARY destination carrier is exercisable end-to-end.
*/
function buildMockInitData(languageCode?: string, startParam?: string | null): string {
if (languageCode === undefined && !startParam) return "";
const user = JSON.stringify({ language_code: languageCode ?? "en", id: 1, first_name: "Harness" });
const base = `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`;
return startParam ? `${base}&start_param=${encodeURIComponent(startParam)}` : base;
}
/* ————— the ?drive= fixture-replay source (06-08, D-16's browser leg) ————— */
/** A committed drive fixture's structural minimum (the 06-02 replay shape:
* { _notice, pairId, maneuverIdx, kinds, fixes[] } with Fix-shaped fixes). */
interface DriveFixture {
fixes: Array<{ lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null; ts: number; source?: string }>;
}
/** The pause/resume surface the capture script drives (window.__ARAHARAH_DRIVE). */
export interface DriveReplayControls {
/** Freeze the replay clock mid-stream — emission stops until resume(). */
pause(): void;
/** Continue mid-stream: unpaused wall time resumes driving the schedule. */
resume(): void;
readonly name: string;
}
declare global {
interface Window {
/** Installed ONLY when ?drive= names a committed fixture — the capture
* script's freeze surface (and its diagnostics: .name). */
__ARAHARAH_DRIVE?: DriveReplayControls;
}
}
/** Drive names are slugs (the pairId/destinationId pattern — bounds the
* lookup against hostile query strings before the fixture record is read). */
const DRIVE_NAME_PATTERN = /^[a-z0-9-]{1,64}$/;
/** The committed D-16 drive replays, inlined into the HARNESS bundle at build
* time (eager glob, the route-profiles BA-exclusion posture: this module is
* imported by main.ts's dead harness branch and tree-shaken from production —
* BA5 asserts the absence). The pattern is ROOT-ABSOLUTE (/test/... — Vite 8's
* importGlob matches no relative pattern that escapes src/; verified by the
* 06-08 dev-server probe). */
const DRIVE_FIXTURES = import.meta.glob<unknown>("/test/fixtures/drives/*.json", {
eager: true,
import: "default",
});
/** Defensive shape check (the fixture-source isUsablePair posture): a
* malformed drive degrades to the honest unknown-fallback, never a crash
* and never a partial replay (T-06-08's Tampering mitigation). */
function isDriveFixture(value: unknown): value is DriveFixture {
if (value === null || typeof value !== "object") return false;
const fixes = (value as { fixes?: unknown }).fixes;
if (!Array.isArray(fixes) || fixes.length < 2) return false;
return fixes.every((f) => {
if (f === null || typeof f !== "object") return false;
const p = f as { lat?: unknown; lng?: unknown; ts?: unknown };
return (
typeof p.lat === "number" &&
Number.isFinite(p.lat) &&
typeof p.lng === "number" &&
Number.isFinite(p.lng) &&
typeof p.ts === "number" &&
Number.isFinite(p.ts)
);
});
}
function driveFixtureByName(name: string): DriveFixture | null {
if (!DRIVE_NAME_PATTERN.test(name)) return null;
const hit = DRIVE_FIXTURES[`/test/fixtures/drives/${name}.json`];
return isDriveFixture(hit) ? hit : null;
}
/** Position assembled from a fixture fix at a replayed wall timestamp. The
* watch (createGuidanceWatch) normalizes exactly the four consumed fields;
* the GeolocationPosition type's remaining members are cast — the harness
* source is the fixture itself, not a lying platform. */
function replayedPosition(f: DriveFixture["fixes"][number], timestamp: number): GeolocationPosition {
return {
coords: {
latitude: f.lat,
longitude: f.lng,
accuracy: f.accuracyM ?? undefined,
altitude: null,
altitudeAccuracy: null,
altitudeAngle: null,
heading: f.headingDeg ?? undefined,
speed: null,
},
timestamp,
} as unknown as GeolocationPosition;
}
/**
* Replace navigator.geolocation with the drive replay (harness-only): the
* REAL pump arms its REAL watch against this object, so the shell consumes
* the committed stream through the unmodified D-15 throttle — replay proves
* the shell, never bypassing it (the trust boundary's law).
*
* Clock semantics: the schedule is real-time (offset = fix.ts − fixes[0].ts;
* a 200ms tick delivers every fix whose offset has elapsed). pos.timestamp
* is t0 + offset so the machine's now()-anchored windows stay aligned with
* the fixes that drive them. Exhaustion stops the timer; a later re-arm
* delivers nothing (the drive is over).
*/
function installDriveReplay(name: string, fixture: DriveFixture): DriveReplayControls {
const fixes = fixture.fixes;
const first = fixes[0]!;
const offsets = fixes.map((f) => Math.max(0, f.ts - first.ts));
let clockStart: number | null = null; // performance.now() at the first watch arm
let wallT0 = 0; // Date.now() at the same instant — pos.timestamp = wallT0 + offset
let pausedAt: number | null = null;
let pausedTotal = 0;
let delivered = 0;
let nextId = 0;
let timer: ReturnType<typeof setInterval> | null = null;
const sinks = new Map<number, (pos: GeolocationPosition) => void>();
const elapsedMs = (): number => {
if (clockStart === null) return -1;
const nowP = performance.now();
return (nowP - clockStart) - pausedTotal - (pausedAt !== null ? nowP - pausedAt : 0);
};
const stopTimer = (): void => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
};
const tick = (): void => {
const e = elapsedMs();
if (e < 0) return;
while (delivered < fixes.length) {
const offset = offsets[delivered]!;
if (offset > e) break;
const fix = fixes[delivered]!;
const pos = replayedPosition(fix, wallT0 + offset);
for (const sink of sinks.values()) sink(pos);
delivered += 1;
}
if (delivered >= fixes.length) stopTimer(); // fully replayed — the drive is over
};
const ensureTimer = (): void => {
if (timer === null && delivered < fixes.length) timer = setInterval(tick, 200);
};
const fakeGeo = {
getCurrentPosition(success: PositionCallback, _error?: PositionErrorCallback | null, _opts?: PositionOptions): void {
// the chain's LINK-2 shape (unused while the Telegram mock serves LINK 1):
// the drive's FIRST fix, at wall-now, like a real one-shot read
const f = first;
setTimeout(() => success(replayedPosition(f, Date.now())), 0);
},
watchPosition(success: PositionCallback, _error?: PositionErrorCallback | null, _opts?: PositionOptions): number {
const id = ++nextId;
sinks.set(id, success);
if (clockStart === null) {
clockStart = performance.now();
wallT0 = Date.now();
}
ensureTimer(); // the first arm starts the clock; re-arms resume delivery
return id;
},
clearWatch(id: number): void {
sinks.delete(id);
if (sinks.size === 0) stopTimer(); // the pump stopped the watch — the replay stops with it
},
};
Object.defineProperty(navigator, "geolocation", { value: fakeGeo, configurable: true });
const controls: DriveReplayControls = {
get name(): string {
return name;
},
pause(): void {
if (pausedAt === null) pausedAt = performance.now();
},
resume(): void {
if (pausedAt !== null) {
pausedTotal += performance.now() - pausedAt;
pausedAt = null;
}
ensureTimer();
},
};
window.__ARAHARAH_DRIVE = controls;
return controls;
}
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike {
const version = options.version ?? "8.0";
const telegramFix = options.telegramFix ?? null;
const delayMs = options.deliveryDelayMs ?? 0;
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();
}, delayMs);
},
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).
// The DELIVERY SHAPE is the Telegram LocationManager wire format —
// { latitude, longitude, horizontal_accuracy, course } — because the
// consumer is requestTelegramFix, which maps exactly those keys. The
// MockOptions.telegramFix keys (lat/lng/accuracyM/headingDeg) are the
// authoring shape; delivering them verbatim maps to undefined → a NaN
// LngLat pageerror in marker.update (found by the 03-04 capture harness,
// the first end-to-end consumer of this callback).
if (options.grantLocation !== false && telegramFix !== null) {
setTimeout(() => cb({
latitude: telegramFix.lat,
longitude: telegramFix.lng,
horizontal_accuracy: telegramFix.accuracyM ?? undefined,
course: telegramFix.headingDeg ?? undefined,
}), delayMs);
} 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> = [];
let homeScreenAddedCount = 0;
const homeScreenStatus: HomeScreenStatus = options.homeScreenStatus ?? "unknown";
const fake: MockWebApp = {
initData: buildMockInitData(options.languageCode, options.startParam),
startParam: options.startParam ?? undefined,
colorScheme: options.colorScheme ?? "light",
themeParams: {},
version,
platform: options.platform ?? "android",
__events: events,
__backButtonClicks: backButtonClicks,
__homeScreenAdded: 0,
__fullscreenRequested: 0,
__fullscreenExited: 0,
__orientationLocked: 0,
__orientationUnlocked: 0,
__isFullscreen: false,
isVersionAtLeast(v: string) {
return versionAtLeast(version, v);
},
close() {
/* the runner page listens to window-event hooks; nothing to close here */
},
addToHomeScreen() {
homeScreenAddedCount += 1;
fake.__homeScreenAdded = homeScreenAddedCount;
},
checkHomeScreenStatus(cb: (status: HomeScreenStatus) => void) {
setTimeout(() => cb(homeScreenStatus), 0);
},
// PX-04/D-03 (07-01): the fullscreen members ship with the contract —
// the mock satisfies the grown contract in the SAME change ("contract +
// fake + test together"). The ?mock= profile's `version` below 8.0 makes
// the fullscreen path a silent no-op via the version guard — never
// hard-coded, the mock's own versionAtLeast drives the guard.
requestFullscreen() {
fake.__fullscreenRequested = (fake.__fullscreenRequested ?? 0) + 1;
fake.__isFullscreen = true;
},
exitFullscreen() {
fake.__fullscreenExited = (fake.__fullscreenExited ?? 0) + 1;
fake.__isFullscreen = false;
},
lockOrientation() {
fake.__orientationLocked = (fake.__orientationLocked ?? 0) + 1;
},
unlockOrientation() {
fake.__orientationUnlocked = (fake.__orientationUnlocked ?? 0) + 1;
},
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 };
// The ?drive= replay knob (06-08): read from location.search HERE — the
// mock module is the harness surface, so the knob rides every install path
// (the runner page's profile install AND main.ts's plain-dev fallback)
// without a new carrier. Unknown name → one honest token + the default
// geolocation stays; never a crash, never replayed arbitrary data.
let driveName: string | null = null;
try {
driveName = new URLSearchParams(window.location?.search ?? "").get("drive");
} catch {
driveName = null;
}
if (driveName !== null) {
const fixture = driveFixtureByName(driveName);
if (fixture === null) console.log("ARAHARAH-DRIVE state=unknown");
else installDriveReplay(driveName, fixture);
}
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();
}