Files
gstack/ios-qa/daemon/test/session-cache.test.ts
T
Garry Tanandcapy-ai[bot] d875313071 v1.91.171.0 fix(ios-qa): a restarted daemon resumes the device session instead of relaunching the app (#3132)
* fix(ios-qa): a restarted daemon resumes the persisted session bearer instead of relaunching the app

The boot token is one-use, so a new daemon process could only get back in by
relaunching the app with --terminate-existing, wiping its QA state (#1796).
The daemon now persists the rotated bearer to
$GSTACK_STATE_ROOT/ios-qa-session.json (0600, fresh temp file + rename),
bound to UDID, bundle ID and port, and resumes it once per process through
recoverTunnel's new pinned-address mode: devicectl-reported address only,
/healthz bundle check first. Mismatched binding, a foreign device or app, no
devicectl address, or a non-private file bootstraps without sending the
bearer. The file is cleared before every bootstrap, so a rejected bearer is
never replayed. /ios-qa Phase 0 now describes the real file.

Diagnosis and reuse scenarios from #1796 by @Bmathews721.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: take draft #3132 off the version slot (VERSION back to main's 1.91.84.0, CHANGELOG [Unreleased])

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.171.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-20 20:23:27 +00:00

266 lines
12 KiB
TypeScript

// Persisted session bearer (#1796): a restarted daemon resumes the rotated
// bearer instead of relaunching the app, without weakening device binding or
// letting a rejected or foreign bearer be replayed. Restart scenarios run the
// production deviceTunnelSource wiring against the simulated device: daemon A
// bootstraps and exits, daemon B starts with the same session file.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chmodSync, existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startDaemon, deviceTunnelSource } from '../src/index';
import { readSession, writeSession, type PersistedSession } from '../src/session-cache';
import { startFakeDevice, type FakeDevice } from './fake-device';
import type { SpawnImpl } from '../src/devicectl';
const BUNDLE = 'com.test.session';
let dir: string;
const cleanups: Array<() => Promise<void>> = [];
beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'ios-qa-session-')); });
afterAll(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
rmSync(dir, { recursive: true, force: true });
});
const TUNNEL = { udid: 'UDID-1', ipv6Addr: '::1', port: 9999, bootTokenRotated: 'bearer-abc' };
describe('session file', () => {
test('is written owner-only with the bearer and its binding, never a boot token', () => {
const path = join(dir, 'write.json');
writeSession(TUNNEL, BUNDLE, path);
expect(statSync(path).mode & 0o777).toBe(0o600);
const raw = JSON.parse(readFileSync(path, 'utf-8')) as PersistedSession;
expect(raw).toMatchObject({ version: 1, udid: 'UDID-1', bundle_id: BUNDLE, port: 9999, bearer: 'bearer-abc' });
expect(Object.keys(raw).sort()).toEqual(['bearer', 'bundle_id', 'created_at', 'port', 'udid', 'version']);
expect(readSession(path).session).toEqual(raw);
});
test('replaces a group-readable file or a symlink instead of writing through it', () => {
const loose = join(dir, 'loose.json');
writeFileSync(loose, '{}', { mode: 0o644 });
chmodSync(loose, 0o644);
writeSession(TUNNEL, BUNDLE, loose);
expect(statSync(loose).mode & 0o777).toBe(0o600);
const target = join(dir, 'link-target.txt');
writeFileSync(target, 'untouched');
const link = join(dir, 'link.json');
symlinkSync(target, link);
writeSession(TUNNEL, BUNDLE, link);
expect(lstatSync(link).isSymbolicLink()).toBe(false);
expect(readFileSync(target, 'utf-8')).toBe('untouched');
});
test('a file other users can read, a symlink, or an invalid session is removed unused', () => {
const loose = join(dir, 'read-loose.json');
writeSession(TUNNEL, BUNDLE, loose);
chmodSync(loose, 0o640);
expect(readSession(loose)).toEqual({ session: null, discarded: 'other users can access it (mode 640)' });
expect(existsSync(loose)).toBe(false);
const real = join(dir, 'read-real.json');
writeSession(TUNNEL, BUNDLE, real);
const link = join(dir, 'read-link.json');
symlinkSync(real, link);
expect(readSession(link).discarded).toBe('it is not a regular file');
expect(existsSync(link)).toBe(false);
for (const [name, body, reason] of [
['bad-json.json', '{', 'it is not valid JSON'],
['old-shape.json', JSON.stringify({ udid: 'U', bundleId: BUNDLE, port: 9999, rotatedToken: 't' }), 'it does not hold a version 1 session'],
] as const) {
const path = join(dir, name);
writeFileSync(path, body, { mode: 0o600 });
expect(readSession(path)).toEqual({ session: null, discarded: reason });
expect(existsSync(path)).toBe(false);
}
expect(readSession(join(dir, 'missing.json'))).toEqual({ session: null });
});
});
describe('daemon restart (simulated device)', () => {
async function device(opts: { udid?: string; bundleId?: string } = {}) {
const fake = await startFakeDevice({ bundleId: opts.bundleId ?? BUNDLE, udid: opts.udid ?? 'FAKE-UDID-1' });
cleanups.push(() => fake.close());
return fake;
}
async function daemon(name: string, fake: FakeDevice, opts: { udid?: string; spawn?: SpawnImpl; sessionPath?: string } = {}) {
const logs: string[] = [];
const source = deviceTunnelSource({
udid: opts.udid,
bundleId: BUNDLE,
port: fake.port,
sessionPath: opts.sessionPath ?? join(dir, `${name}.session.json`),
spawnImpl: opts.spawn ?? fake.spawn,
resolveImpl: async () => ['::1'],
keepalive: () => ({ stop() {} }),
log: (line) => logs.push(line),
});
const d = await startDaemon({
loopbackPort: 0,
tailnetEnabled: false,
pidfilePath: join(dir, `${name}-${Math.random().toString(36).slice(2)}.pid`),
proxyTimeoutMs: 300,
tunnelProvider: source.tunnelProvider,
tunnelRecovery: source.tunnelRecovery,
});
if ('error' in d) throw new Error(d.error);
let closed = false;
const close = async () => { if (!closed) { closed = true; source.stop(); await d.close(); } };
cleanups.push(close);
const get = async (path: string, method = 'GET') => {
const res = await fetch(`http://127.0.0.1:${d.loopbackPort}${path}`, { method, body: method === 'POST' ? '{}' : undefined });
return { status: res.status, body: await res.text() };
};
return { logs, close, get };
}
/** Daemon A bootstraps, taps, records its bearer and exits. */
async function firstRun(name: string, fake: FakeDevice) {
const a = await daemon(name, fake);
expect((await a.get('/tap', 'POST')).status).toBe(200);
const bearer = fake.requests.at(-1)!.authorization!;
await a.close();
return bearer;
}
const terminations = (fake: FakeDevice) => fake.calls.filter((c) => c.includes('--terminate-existing')).length;
const bootTokenReads = (fake: FakeDevice) => fake.calls.filter((c) => c.includes('device copy from')).length;
const sentBearer = (fake: FakeDevice, bearer: string, since = 0) => fake.requests.slice(since).filter((r) => r.authorization === bearer);
const persistedBearer = (path: string) => `Bearer ${readSession(path).session?.bearer}`;
test('a restarted daemon resumes the persisted bearer: no relaunch, app state kept', async () => {
const fake = await device();
const bearer = await firstRun('resume', fake);
expect(persistedBearer(join(dir, 'resume.session.json'))).toBe(bearer);
const b = await daemon('resume', fake);
const snap = await b.get('/state/snapshot');
expect(snap.status).toBe(200);
expect(JSON.parse(snap.body).state).toEqual({ generation: 1, marker: 'tapped' });
expect(terminations(fake)).toBe(0);
expect(bootTokenReads(fake)).toBe(1);
expect(fake.requests.at(-1)?.authorization).toBe(bearer);
expect(b.logs.some((l) => l.startsWith('session resumed: reused the persisted bearer for FAKE-UDID-1'))).toBe(true);
});
test('app relaunched between daemons: the old bearer is presented once, then a normal bootstrap', async () => {
const fake = await device();
const bearer = await firstRun('relaunched', fake);
fake.relaunchExternally();
const since = fake.requests.length;
const b = await daemon('relaunched', fake);
expect((await b.get('/screenshot')).status).toBe(200);
expect(sentBearer(fake, bearer, since)).toHaveLength(1);
expect(sentBearer(fake, bearer, since)[0]!.path).toBe('/state/snapshot');
expect(bootTokenReads(fake)).toBe(2);
expect(terminations(fake)).toBe(0);
const fresh = persistedBearer(join(dir, 'relaunched.session.json'));
expect(fresh).not.toBe(bearer);
expect(fake.requests.at(-1)?.authorization).toBe(fresh);
expect(b.logs.some((l) => l.includes('rejected the session bearer'))).toBe(true);
});
test('a resumed bearer that later gets 401 is never re-probed; the replacement is persisted', async () => {
const fake = await device();
const bearer = await firstRun('later-401', fake);
const b = await daemon('later-401', fake);
expect((await b.get('/screenshot')).status).toBe(200);
fake.relaunchExternally();
const since = fake.requests.length;
expect((await b.get('/screenshot')).status).toBe(200);
expect(sentBearer(fake, bearer, since)).toHaveLength(1);
expect(sentBearer(fake, bearer, since)[0]!.path).toBe('/screenshot');
expect(persistedBearer(join(dir, 'later-401.session.json'))).not.toBe(bearer);
expect(terminations(fake)).toBe(0);
});
test('another device at the same address: the old bearer is never sent to it', async () => {
const fake = await device({ udid: 'PHONE-UDID' });
const bearer = await firstRun('replaced', fake);
fake.replaceDevice('PAD-UDID', 'iPad');
const b = await daemon('replaced', fake);
expect((await b.get('/screenshot')).status).toBe(200);
const pad = fake.requests.filter((r) => r.udid === 'PAD-UDID');
expect(pad.length).toBeGreaterThan(0);
expect(pad.some((r) => r.authorization === bearer)).toBe(false);
expect(b.logs.some((l) => l.includes('target device changed from PHONE-UDID to PAD-UDID'))).toBe(true);
});
test('a session bound to another UDID or app is not sent anywhere', async () => {
const fake = await device();
const bearer = await firstRun('binding', fake);
const path = join(dir, 'binding.session.json');
const since = fake.requests.length;
const pinned = await daemon('binding-udid', fake, { udid: 'OTHER-UDID', sessionPath: path });
expect((await pinned.get('/screenshot')).status).toBe(503);
expect(sentBearer(fake, bearer, since)).toHaveLength(0);
expect(pinned.logs.some((l) => l.includes('persisted session belongs to com.test.session on FAKE-UDID-1'))).toBe(true);
expect(existsSync(path)).toBe(false);
});
test('a StateServer now owned by another app never receives the bearer', async () => {
const fake = await device({ bundleId: 'com.other.app' });
fake.relaunchExternally();
const path = join(dir, 'wrong-app.session.json');
writeSession({ udid: fake.udid, ipv6Addr: '::1', port: fake.port, bootTokenRotated: 'persisted-secret' }, BUNDLE, path);
const b = await daemon('wrong-app', fake, { sessionPath: path });
expect((await b.get('/screenshot')).status).toBe(503);
expect(sentBearer(fake, 'Bearer persisted-secret')).toHaveLength(0);
expect(b.logs.some((l) => l.includes('now belongs to com.other.app'))).toBe(true);
});
test('no devicectl address for the persisted UDID: the bearer is not sent to a fallback address', async () => {
const fake = await device();
const bearer = await firstRun('no-address', fake);
const since = fake.requests.length;
const noDetails: SpawnImpl = (cmd, args) => (args.join(' ').includes('info details')
? fake.spawn(cmd, ['unsupported'])
: fake.spawn(cmd, args));
const b = await daemon('no-address', fake, { spawn: noDetails });
expect((await b.get('/screenshot')).status).toBe(200);
expect(sentBearer(fake, bearer, since)).toHaveLength(0);
expect(terminations(fake)).toBe(1);
expect(b.logs.some((l) => l.includes('devicectl reported no tunnel address for FAKE-UDID-1'))).toBe(true);
});
test('an insecure session file is removed unused and replaced by a private one', async () => {
const fake = await device();
const bearer = await firstRun('insecure', fake);
const path = join(dir, 'insecure.session.json');
chmodSync(path, 0o644);
const since = fake.requests.length;
const b = await daemon('insecure', fake);
expect((await b.get('/screenshot')).status).toBe(200);
expect(sentBearer(fake, bearer, since)).toHaveLength(0);
expect(b.logs.some((l) => l.includes('because other users can access it (mode 644)'))).toBe(true);
expect(statSync(path).mode & 0o777).toBe(0o600);
});
test('device momentarily unreachable: the file is kept and the next request resumes', async () => {
const fake = await device();
const bearer = await firstRun('blip', fake);
const path = join(dir, 'blip.session.json');
const b = await daemon('blip', fake);
fake.dropConnections(1);
expect((await b.get('/screenshot')).status).toBe(503);
expect(persistedBearer(path)).toBe(bearer);
expect(b.logs.some((l) => l.includes(`kept ${path} for the next request`))).toBe(true);
expect((await b.get('/screenshot')).status).toBe(200);
expect(fake.requests.at(-1)?.authorization).toBe(bearer);
expect(terminations(fake)).toBe(0);
expect(bootTokenReads(fake)).toBe(1);
}, 15_000);
});