mirror of https://github.com/garrytan/gstack.git
Merge ec58d9e561 into c43c850cae
This commit is contained in:
commit
271ae17b38
|
|
@ -13,6 +13,12 @@
|
|||
# the first paired connected device is used)
|
||||
# GSTACK_IOS_TARGET_BUNDLE_ID — bundle ID of the iOS app hosting StateServer
|
||||
# (default com.gstack.iosqa.fixture)
|
||||
# GSTACK_IOS_LAUNCH_ENV — JSON dict of env vars set when the daemon
|
||||
# cold-launches the app, forwarded to
|
||||
# `devicectl ... --environment-variables`.
|
||||
# Required for apps that gate their debug
|
||||
# bridge behind a flag, e.g. BuckHound:
|
||||
# '{"BH_ENABLE_IOS_QA_BRIDGE":"1"}'
|
||||
#
|
||||
# Readiness protocol: prints `READY: port=<n> pid=<pid>` to stdout once both
|
||||
# listeners are bound. Spawners read stdin with a ~5s timeout to confirm.
|
||||
|
|
|
|||
|
|
@ -270,8 +270,17 @@ export function launchApp(
|
|||
udid: string,
|
||||
bundleId: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
env?: Record<string, string>,
|
||||
): { ok: boolean; error?: string } {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]);
|
||||
const args = ['devicectl', 'device', 'process', 'launch', '--device', udid];
|
||||
// Forward launch-time env vars (e.g. an app's debug-bridge enable flag) so a
|
||||
// cold start actually boots the in-app StateServer. Bundle id stays the
|
||||
// trailing positional arg, after every flag.
|
||||
if (env && Object.keys(env).length > 0) {
|
||||
args.push('--environment-variables', JSON.stringify(env));
|
||||
}
|
||||
args.push(bundleId);
|
||||
const r = spawn('xcrun', args);
|
||||
if (r.status === 0) return { ok: true };
|
||||
const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? '');
|
||||
if (err.includes('was not, or could not be, unlocked')) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { SessionTokenStore } from './session-tokens';
|
|||
import { mintForCaller } from './auth-mint';
|
||||
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
|
||||
import { bootstrapTunnel } from './tunnel-bootstrap';
|
||||
import { acquireTunnel } from './session-cache';
|
||||
import { startTunnelKeepalive } from './devicectl';
|
||||
import type { Capability } from './types';
|
||||
|
||||
|
|
@ -399,6 +399,18 @@ if (import.meta.main) {
|
|||
const tailnet = process.argv.includes('--tailnet');
|
||||
const targetUDID = process.env.GSTACK_IOS_TARGET_UDID;
|
||||
const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture';
|
||||
// Env vars to set when the daemon cold-launches the app. Apps that gate their
|
||||
// debug bridge behind a flag (BuckHound: BH_ENABLE_IOS_QA_BRIDGE=1) MUST set
|
||||
// this, or a cold start launches the app without the bridge and the
|
||||
// StateServer never binds. Malformed JSON is ignored (warn + no launch env).
|
||||
let launchEnv: Record<string, string> | undefined;
|
||||
if (process.env.GSTACK_IOS_LAUNCH_ENV) {
|
||||
try {
|
||||
launchEnv = JSON.parse(process.env.GSTACK_IOS_LAUNCH_ENV) as Record<string, string>;
|
||||
} catch {
|
||||
process.stderr.write('GSTACK_IOS_LAUNCH_ENV is not valid JSON; ignoring\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
|
||||
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
|
||||
|
|
@ -410,17 +422,22 @@ if (import.meta.main) {
|
|||
// without a poke every few seconds the IPv6 becomes unroutable.
|
||||
let keepalive: { stop: () => void } | null = null;
|
||||
const realTunnelProvider = async () => {
|
||||
const result = await bootstrapTunnel({
|
||||
// acquireTunnel reuses a cached rotated bearer (warm-start) when the device
|
||||
// still honors it, and only falls back to a full single-use boot-token
|
||||
// rotate on a genuinely fresh app launch. This is what lets a real device
|
||||
// be driven across tunnel-cache refreshes, daemon restarts, and repeat
|
||||
// /ios-qa sessions instead of exactly once per app launch.
|
||||
const tunnel = await acquireTunnel({
|
||||
udid: targetUDID,
|
||||
bundleId,
|
||||
port: 9999, // in-app StateServer port (not the daemon's loopback port)
|
||||
launchEnv,
|
||||
logImpl: (m) => process.stderr.write(m + '\n'),
|
||||
});
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
|
||||
return null;
|
||||
}
|
||||
if (!tunnel) return null;
|
||||
if (keepalive) keepalive.stop();
|
||||
keepalive = startTunnelKeepalive(result.tunnel.udid);
|
||||
return result.tunnel;
|
||||
keepalive = startTunnelKeepalive(tunnel.udid);
|
||||
return tunnel;
|
||||
};
|
||||
|
||||
const shutdown = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
// Rotated-token session cache + reuse. This is the Mac-side half of the
|
||||
// "bootstrap once per app launch" contract that SKILL.md Phase 0 (warm-start)
|
||||
// describes but the daemon never implemented.
|
||||
//
|
||||
// WHY this exists: the in-app StateServer boot token is single-use. The first
|
||||
// POST /auth/rotate sets bootTokenValid=false AND deletes the on-disk token
|
||||
// file. The daemon, however, re-runs the full bootstrap (copy boot token +
|
||||
// rotate) on every tunnel-cache refresh, daemon restart, and new /ios-qa
|
||||
// session. The second bootstrap then finds the token file gone ->
|
||||
// `boot_token_unavailable`, so a real device can be driven exactly once per
|
||||
// app launch. Persisting the rotated bearer the daemon already holds, and
|
||||
// reusing it after a cheap authenticated probe, makes re-bootstrap unnecessary.
|
||||
//
|
||||
// SECURITY: this changes nothing on the device. The StateServer stays
|
||||
// loopback-only, every endpoint still requires the rotated bearer, and the boot
|
||||
// token stays single-use. The only new artifact is a Mac-side 0600 cache file
|
||||
// holding the rotated bearer (the same value SKILL.md Phase 0 already specifies
|
||||
// the session cache holds), valid only while the app stays launched — a probe
|
||||
// that returns 401 means the app was relaunched, so we drop the stale token and
|
||||
// re-bootstrap from a freshly written boot token.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { dirname, join } from 'path';
|
||||
import { bootstrapTunnel, type BootstrapOptions } from './tunnel-bootstrap';
|
||||
import { getDeviceTunnelIPv6FromDevicectl, type SpawnImpl, type ResolveImpl } from './devicectl';
|
||||
import type { DeviceTunnel } from './proxy';
|
||||
|
||||
export interface SessionCache {
|
||||
udid: string;
|
||||
bundleId: string;
|
||||
port: number;
|
||||
rotatedToken: string;
|
||||
ipv6: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function defaultSessionCachePath(): string {
|
||||
return process.env.GSTACK_IOS_SESSION_CACHE
|
||||
?? join(homedir(), '.gstack', 'ios-qa-session.json');
|
||||
}
|
||||
|
||||
/** Read the session cache. Returns null on missing/unreadable/corrupt file. */
|
||||
export function readSessionCache(path: string = defaultSessionCachePath()): SessionCache | null {
|
||||
try {
|
||||
const obj = JSON.parse(readFileSync(path, 'utf-8')) as Partial<SessionCache>;
|
||||
if (
|
||||
obj && typeof obj.udid === 'string' && typeof obj.bundleId === 'string'
|
||||
&& typeof obj.port === 'number' && typeof obj.rotatedToken === 'string'
|
||||
&& typeof obj.ipv6 === 'string' && typeof obj.createdAt === 'number'
|
||||
) {
|
||||
return obj as SessionCache;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the session cache with owner-only (0600) perms. */
|
||||
export function writeSessionCache(cache: SessionCache, path: string = defaultSessionCachePath()): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(cache), { mode: 0o600 });
|
||||
// writeFileSync's mode only applies on create; force 0600 on overwrite too.
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
/** Remove the session cache (best-effort). */
|
||||
export function clearSessionCache(path: string = defaultSessionCachePath()): void {
|
||||
try { rmSync(path, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export interface AcquireTunnelOptions {
|
||||
udid?: string;
|
||||
bundleId: string;
|
||||
port: number;
|
||||
/** Env vars to set if a cold-start bootstrap has to launch the app. */
|
||||
launchEnv?: Record<string, string>;
|
||||
// Injection seams (real defaults wire the production impls).
|
||||
cachePath?: string;
|
||||
readCacheImpl?: (path?: string) => SessionCache | null;
|
||||
writeCacheImpl?: (cache: SessionCache, path?: string) => void;
|
||||
clearCacheImpl?: (path?: string) => void;
|
||||
resolveIPv6Impl?: (udid: string) => string | null | Promise<string | null>;
|
||||
probeImpl?: (ipv6: string, port: number, token: string) => Promise<number>;
|
||||
bootstrapImpl?: (opts: BootstrapOptions) => Promise<import('./tunnel-bootstrap').BootstrapResult>;
|
||||
/** Diagnostic sink (defaults to no-op; the CLI wires it to stderr). */
|
||||
logImpl?: (msg: string) => void;
|
||||
// Pass-throughs to the underlying bootstrap.
|
||||
spawnImpl?: SpawnImpl;
|
||||
resolveImpl?: ResolveImpl;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a usable DeviceTunnel, reusing a cached rotated bearer when the
|
||||
* device still honors it and only falling back to a full boot-token bootstrap
|
||||
* when there is no usable cache or the cached bearer is rejected (app
|
||||
* relaunched). Returns null on a transient device-unreachable condition (cache
|
||||
* preserved) or a failed bootstrap.
|
||||
*/
|
||||
export async function acquireTunnel(opts: AcquireTunnelOptions): Promise<DeviceTunnel | null> {
|
||||
const cachePath = opts.cachePath;
|
||||
const readCache = opts.readCacheImpl ?? readSessionCache;
|
||||
const writeCache = opts.writeCacheImpl ?? writeSessionCache;
|
||||
const clearCache = opts.clearCacheImpl ?? clearSessionCache;
|
||||
const resolveIPv6 = opts.resolveIPv6Impl ?? ((udid: string) => getDeviceTunnelIPv6FromDevicectl(udid, opts.spawnImpl));
|
||||
const probe = opts.probeImpl ?? defaultProbe(opts.fetchImpl);
|
||||
const bootstrap = opts.bootstrapImpl ?? bootstrapTunnel;
|
||||
const log = opts.logImpl ?? (() => {});
|
||||
|
||||
const cache = readCache(cachePath);
|
||||
const cacheUsable = !!cache
|
||||
&& cache.bundleId === opts.bundleId
|
||||
&& cache.port === opts.port
|
||||
&& (!opts.udid || cache.udid === opts.udid);
|
||||
|
||||
if (cache && cacheUsable) {
|
||||
const ipv6 = await resolveIPv6(cache.udid);
|
||||
if (!ipv6) { log('device unresolvable; keeping cached session token, will retry'); return null; }
|
||||
const status = await probe(ipv6, cache.port, cache.rotatedToken);
|
||||
if (status === 200) {
|
||||
if (ipv6 !== cache.ipv6) writeCache({ ...cache, ipv6 }, cachePath);
|
||||
log(`reusing cached session token for ${cache.udid} (no app relaunch needed)`);
|
||||
return { udid: cache.udid, ipv6Addr: ipv6, port: cache.port, bootTokenRotated: cache.rotatedToken };
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
log(`cached session token rejected (HTTP ${status}); app was relaunched — re-bootstrapping`);
|
||||
clearCache(cachePath); // app relaunched -> stale rotated token; re-bootstrap below
|
||||
} else {
|
||||
log(`device unreachable during token probe (HTTP ${status}); keeping cached token, will retry`);
|
||||
return null; // 0 (connection error) / 5xx — transient; do not discard a good token
|
||||
}
|
||||
}
|
||||
|
||||
const result = await bootstrap({
|
||||
udid: opts.udid,
|
||||
bundleId: opts.bundleId,
|
||||
port: opts.port,
|
||||
launchEnv: opts.launchEnv,
|
||||
spawnImpl: opts.spawnImpl,
|
||||
resolveImpl: opts.resolveImpl,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
});
|
||||
if (!result.ok) {
|
||||
log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`);
|
||||
return null;
|
||||
}
|
||||
log(`bootstrapped fresh session token for ${result.tunnel.udid}`);
|
||||
|
||||
writeCache({
|
||||
udid: result.tunnel.udid,
|
||||
bundleId: opts.bundleId,
|
||||
port: opts.port,
|
||||
rotatedToken: result.tunnel.bootTokenRotated,
|
||||
ipv6: result.tunnel.ipv6Addr,
|
||||
createdAt: Date.now(),
|
||||
}, cachePath);
|
||||
return result.tunnel;
|
||||
}
|
||||
|
||||
function defaultProbe(fetchFn: typeof fetch = fetch) {
|
||||
return async (ipv6: string, port: number, token: string): Promise<number> => {
|
||||
const isIPv6 = (ipv6.match(/:/g)?.length ?? 0) >= 2;
|
||||
const host = isIPv6 ? `[${ipv6}]` : ipv6;
|
||||
try {
|
||||
const r = await fetchFn(`http://${host}:${port}/state/snapshot`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(4_000),
|
||||
});
|
||||
return r.status;
|
||||
} catch {
|
||||
return 0; // connection refused / timeout / tunnel down
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ export interface BootstrapOptions {
|
|||
port?: number;
|
||||
/** Token-path inside the app sandbox (relative to data container). */
|
||||
bootTokenPath?: string;
|
||||
/** Env vars to set when the daemon launches the app (cold start). Forwarded
|
||||
* to `devicectl ... --environment-variables`. Apps that gate their debug
|
||||
* bridge behind a flag (e.g. BuckHound's BH_ENABLE_IOS_QA_BRIDGE) need this
|
||||
* or a cold launch never boots the StateServer. */
|
||||
launchEnv?: Record<string, string>;
|
||||
/** Max time to wait for the StateServer to start after launch (ms). */
|
||||
startupTimeoutMs?: number;
|
||||
/** Test injection. */
|
||||
|
|
@ -91,7 +96,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
|||
|
||||
// Step 2: launch app (idempotent — devicectl returns success if already running)
|
||||
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
|
||||
const launched = launchApp(target.identifier, opts.bundleId, spawn);
|
||||
const launched = launchApp(target.identifier, opts.bundleId, spawn, opts.launchEnv);
|
||||
if (!launched.ok) {
|
||||
return { ok: false, error: launched.error === 'device_locked' ? 'device_locked' : 'launch_failed', detail: launched.error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
// Cold-start launch env. BuckHound (and any app that gates its debug bridge
|
||||
// behind an env var) needs the daemon to pass that var when it launches the
|
||||
// app itself. Without it, a cold start (app not already running) launches the
|
||||
// app WITHOUT the bridge -> the StateServer never binds -> state_server_unreachable,
|
||||
// and `/ios-qa` only works if the operator pre-launches by hand. The daemon
|
||||
// reads GSTACK_IOS_LAUNCH_ENV (a JSON dict) and forwards it to
|
||||
// `devicectl device process launch --environment-variables`.
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { launchApp, type SpawnImpl } from '../src/devicectl';
|
||||
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
function makeReturn(exit: number, stdout = '', stderr = '') {
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, Buffer.from(stdout), Buffer.from(stderr)],
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
status: exit,
|
||||
signal: null,
|
||||
} as ReturnType<SpawnImpl>;
|
||||
}
|
||||
|
||||
describe('launchApp environment variables', () => {
|
||||
test('passes --environment-variables with the JSON dict, bundle id stays last', () => {
|
||||
let captured: string[] = [];
|
||||
const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { captured = args; return makeReturn(0); }) as SpawnImpl;
|
||||
const r = launchApp('UDID-1', 'com.test.app', spawn, { BH_ENABLE_IOS_QA_BRIDGE: '1' });
|
||||
expect(r.ok).toBe(true);
|
||||
const i = captured.indexOf('--environment-variables');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
expect(JSON.parse(captured[i + 1]!)).toEqual({ BH_ENABLE_IOS_QA_BRIDGE: '1' });
|
||||
expect(captured[captured.length - 1]).toBe('com.test.app'); // bundle id is the trailing positional
|
||||
});
|
||||
|
||||
test('omits --environment-variables when env is undefined or empty', () => {
|
||||
for (const env of [undefined, {}]) {
|
||||
let captured: string[] = [];
|
||||
const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { captured = args; return makeReturn(0); }) as SpawnImpl;
|
||||
launchApp('UDID-1', 'com.test.app', spawn, env);
|
||||
expect(captured.includes('--environment-variables')).toBe(false);
|
||||
expect(captured[captured.length - 1]).toBe('com.test.app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrapTunnel forwards launchEnv on cold start', () => {
|
||||
test('threads launchEnv into the launch when the app is not already running', async () => {
|
||||
const calls: string[][] = [];
|
||||
const spawn: SpawnImpl = ((_cmd: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
const joined = args.join(' ');
|
||||
const writeJson = (obj: object) => {
|
||||
const fi = args.indexOf('--json-output');
|
||||
if (fi !== -1 && args[fi + 1]) writeFileSync(args[fi + 1]!, JSON.stringify(obj));
|
||||
};
|
||||
if (joined.includes('list devices')) {
|
||||
writeJson({ result: { devices: [{ identifier: 'UDID-1', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone17,1' } }] } });
|
||||
return makeReturn(0);
|
||||
}
|
||||
if (joined.includes('info processes')) { writeJson({ result: { runningProcesses: [] } }); return makeReturn(0); } // not running -> launch
|
||||
if (joined.includes('process launch')) { return makeReturn(0); }
|
||||
if (joined.includes('info details')) { writeJson({ result: { connectionProperties: { tunnelIPAddress: 'fd00::1' } } }); return makeReturn(0); }
|
||||
if (joined.includes('copy from')) {
|
||||
const fi = args.indexOf('--destination');
|
||||
if (fi !== -1 && args[fi + 1]) writeFileSync(args[fi + 1]!, 'BOOT-TOK\n');
|
||||
return makeReturn(0);
|
||||
}
|
||||
return makeReturn(1, '', 'unexpected ' + joined);
|
||||
}) as SpawnImpl;
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
udid: 'UDID-1',
|
||||
bundleId: 'com.test.app',
|
||||
launchEnv: { BH_ENABLE_IOS_QA_BRIDGE: '1' },
|
||||
spawnImpl: spawn,
|
||||
resolveImpl: async () => ['fd00::1'],
|
||||
fetchImpl: (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith('/healthz')) return new Response('{"version":"1.0.0"}', { status: 200 });
|
||||
if (u.endsWith('/auth/rotate')) return new Response('{"ok":true}', { status: 200 });
|
||||
return new Response('nope', { status: 404 });
|
||||
}) as typeof fetch,
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(true);
|
||||
const launchCall = calls.find((a) => a.join(' ').includes('process launch'));
|
||||
expect(launchCall).toBeDefined();
|
||||
const i = launchCall!.indexOf('--environment-variables');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
expect(JSON.parse(launchCall![i + 1]!)).toEqual({ BH_ENABLE_IOS_QA_BRIDGE: '1' });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
// Session-cache unit tests. The daemon persists the rotated bearer it obtains
|
||||
// from a one-time boot-token rotate so that subsequent bootstraps (30s tunnel
|
||||
// refresh, daemon restart, a new /ios-qa session) REUSE that bearer instead of
|
||||
// re-copying the now-deleted single-use boot token. Without this, the second
|
||||
// bootstrap after the first /auth/rotate fails with boot_token_unavailable
|
||||
// (the StateServer deletes + invalidates the boot token on rotate), so a real
|
||||
// device can be driven exactly once per app launch.
|
||||
//
|
||||
// All decision-logic tests inject cache + resolve + probe + bootstrap stubs so
|
||||
// no real device / filesystem is needed. The round-trip tests use a temp file.
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import {
|
||||
readSessionCache,
|
||||
writeSessionCache,
|
||||
clearSessionCache,
|
||||
acquireTunnel,
|
||||
type SessionCache,
|
||||
} from '../src/session-cache';
|
||||
import type { BootstrapResult } from '../src/tunnel-bootstrap';
|
||||
import { mkdtempSync, rmSync, existsSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const tmpFiles: string[] = [];
|
||||
function tmpCachePath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-sc-'));
|
||||
const p = join(dir, 'ios-qa-session.json');
|
||||
tmpFiles.push(dir);
|
||||
return p;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (tmpFiles.length) {
|
||||
const d = tmpFiles.pop()!;
|
||||
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
const SAMPLE: SessionCache = {
|
||||
udid: 'UDID-1',
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
rotatedToken: 'ROTATED-ABC-123',
|
||||
ipv6: 'fd00::1',
|
||||
createdAt: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
function okBootstrap(token = 'FRESH-ROTATED-999', ipv6 = 'fd00::2', udid = 'UDID-1'): () => Promise<BootstrapResult> {
|
||||
return async () => ({ ok: true, tunnel: { udid, ipv6Addr: ipv6, port: 9999, bootTokenRotated: token } });
|
||||
}
|
||||
|
||||
describe('session cache file I/O', () => {
|
||||
test('write then read round-trips the cache', () => {
|
||||
const p = tmpCachePath();
|
||||
writeSessionCache(SAMPLE, p);
|
||||
const got = readSessionCache(p);
|
||||
expect(got).toEqual(SAMPLE);
|
||||
});
|
||||
|
||||
test('writes the cache file with 0600 perms (owner-only)', () => {
|
||||
const p = tmpCachePath();
|
||||
writeSessionCache(SAMPLE, p);
|
||||
const mode = statSync(p).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
test('read returns null for a missing file', () => {
|
||||
expect(readSessionCache(join(tmpdir(), 'definitely-missing-xyz.json'))).toBeNull();
|
||||
});
|
||||
|
||||
test('clear removes the file and read then returns null', () => {
|
||||
const p = tmpCachePath();
|
||||
writeSessionCache(SAMPLE, p);
|
||||
expect(existsSync(p)).toBe(true);
|
||||
clearSessionCache(p);
|
||||
expect(existsSync(p)).toBe(false);
|
||||
expect(readSessionCache(p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireTunnel — reuse path', () => {
|
||||
test('reuses the cached rotated token when the probe succeeds (no bootstrap, no boot-token copy)', async () => {
|
||||
let bootstrapCalled = false;
|
||||
const tunnel = await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => SAMPLE,
|
||||
writeCacheImpl: () => {},
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => 'fd00::1',
|
||||
probeImpl: async () => 200,
|
||||
bootstrapImpl: async () => { bootstrapCalled = true; return { ok: false, error: 'no_devices' }; },
|
||||
});
|
||||
expect(bootstrapCalled).toBe(false);
|
||||
expect(tunnel).not.toBeNull();
|
||||
expect(tunnel!.bootTokenRotated).toBe('ROTATED-ABC-123');
|
||||
expect(tunnel!.udid).toBe('UDID-1');
|
||||
});
|
||||
|
||||
test('reuse picks up a refreshed tunnel IPv6 and rewrites the cache', async () => {
|
||||
let written: SessionCache | null = null;
|
||||
const tunnel = await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => SAMPLE, // cached ipv6 fd00::1
|
||||
writeCacheImpl: (c) => { written = c; },
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => 'fd00::beef', // device now on a different tunnel addr
|
||||
probeImpl: async () => 200,
|
||||
bootstrapImpl: okBootstrap(),
|
||||
});
|
||||
expect(tunnel!.ipv6Addr).toBe('fd00::beef');
|
||||
expect(tunnel!.bootTokenRotated).toBe('ROTATED-ABC-123'); // token unchanged
|
||||
expect(written).not.toBeNull();
|
||||
expect(written!.ipv6).toBe('fd00::beef');
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireTunnel — bootstrap path', () => {
|
||||
test('with no cache, runs a full bootstrap and persists the rotated token', async () => {
|
||||
let written: SessionCache | null = null;
|
||||
let probeCalled = false;
|
||||
const tunnel = await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => null,
|
||||
writeCacheImpl: (c) => { written = c; },
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => 'fd00::1',
|
||||
probeImpl: async () => { probeCalled = true; return 200; },
|
||||
bootstrapImpl: okBootstrap('FRESH-ROTATED-999', 'fd00::2', 'UDID-1'),
|
||||
});
|
||||
expect(probeCalled).toBe(false); // no cached token to probe
|
||||
expect(tunnel!.bootTokenRotated).toBe('FRESH-ROTATED-999');
|
||||
expect(written).not.toBeNull();
|
||||
expect(written!.rotatedToken).toBe('FRESH-ROTATED-999');
|
||||
expect(written!.ipv6).toBe('fd00::2');
|
||||
});
|
||||
|
||||
test('when the cached token is rejected (401), clears cache then re-bootstraps', async () => {
|
||||
let cleared = false;
|
||||
let bootstrapCalled = false;
|
||||
let written: SessionCache | null = null;
|
||||
const tunnel = await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => SAMPLE,
|
||||
writeCacheImpl: (c) => { written = c; },
|
||||
clearCacheImpl: () => { cleared = true; },
|
||||
resolveIPv6Impl: () => 'fd00::1',
|
||||
probeImpl: async () => 401, // app was relaunched → old rotated token dead
|
||||
bootstrapImpl: async () => { bootstrapCalled = true; return (await okBootstrap('NEW-TOKEN')()); },
|
||||
});
|
||||
expect(cleared).toBe(true);
|
||||
expect(bootstrapCalled).toBe(true);
|
||||
expect(tunnel!.bootTokenRotated).toBe('NEW-TOKEN');
|
||||
expect(written!.rotatedToken).toBe('NEW-TOKEN');
|
||||
});
|
||||
|
||||
test('ignores a cache whose udid does not match an explicitly requested udid', async () => {
|
||||
let bootstrapCalled = false;
|
||||
const tunnel = await acquireTunnel({
|
||||
udid: 'UDID-OTHER',
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => SAMPLE, // cache is for UDID-1
|
||||
writeCacheImpl: () => {},
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => 'fd00::1',
|
||||
probeImpl: async () => 200,
|
||||
bootstrapImpl: async () => { bootstrapCalled = true; return (await okBootstrap('X', 'fd00::9', 'UDID-OTHER')()); },
|
||||
});
|
||||
expect(bootstrapCalled).toBe(true);
|
||||
expect(tunnel!.udid).toBe('UDID-OTHER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireTunnel — transient + failure handling', () => {
|
||||
test('returns null WITHOUT clearing the cache when the probe hits a connection error', async () => {
|
||||
let cleared = false;
|
||||
let bootstrapCalled = false;
|
||||
const tunnel = await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => SAMPLE,
|
||||
writeCacheImpl: () => {},
|
||||
clearCacheImpl: () => { cleared = true; },
|
||||
resolveIPv6Impl: () => 'fd00::1',
|
||||
probeImpl: async () => 0, // connection refused / device momentarily unreachable
|
||||
bootstrapImpl: async () => { bootstrapCalled = true; return { ok: false, error: 'no_devices' }; },
|
||||
});
|
||||
expect(tunnel).toBeNull();
|
||||
expect(cleared).toBe(false); // do NOT destroy a good token on a transient blip
|
||||
expect(bootstrapCalled).toBe(false);
|
||||
});
|
||||
|
||||
test('returns null and does not write cache when bootstrap fails', async () => {
|
||||
let written = false;
|
||||
const tunnel = await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => null,
|
||||
writeCacheImpl: () => { written = true; },
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => null,
|
||||
probeImpl: async () => 200,
|
||||
bootstrapImpl: async () => ({ ok: false, error: 'boot_token_unavailable', detail: 'gone' }),
|
||||
});
|
||||
expect(tunnel).toBeNull();
|
||||
expect(written).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireTunnel — diagnostics', () => {
|
||||
test('surfaces the bootstrap failure reason via logImpl', async () => {
|
||||
const logs: string[] = [];
|
||||
await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => null,
|
||||
writeCacheImpl: () => {},
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => null,
|
||||
probeImpl: async () => 200,
|
||||
bootstrapImpl: async () => ({ ok: false, error: 'boot_token_unavailable', detail: 'gone' }),
|
||||
logImpl: (m) => logs.push(m),
|
||||
});
|
||||
expect(logs.some((l) => l.includes('boot_token_unavailable'))).toBe(true);
|
||||
});
|
||||
|
||||
test('logs that it reused a cached session token (no app relaunch needed)', async () => {
|
||||
const logs: string[] = [];
|
||||
await acquireTunnel({
|
||||
bundleId: 'com.test.app',
|
||||
port: 9999,
|
||||
readCacheImpl: () => SAMPLE,
|
||||
writeCacheImpl: () => {},
|
||||
clearCacheImpl: () => {},
|
||||
resolveIPv6Impl: () => 'fd00::1',
|
||||
probeImpl: async () => 200,
|
||||
bootstrapImpl: okBootstrap(),
|
||||
logImpl: (m) => logs.push(m),
|
||||
});
|
||||
expect(logs.some((l) => /reus/i.test(l))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue