/** * test/push-apns.test.ts (T-iOS-20) — APNs sender + device-token registry. * * Unit layer (no network, fake ApnsHttp2Client, mocked clock): * - loadApnsConfig: env group all-or-disabled, defaults, host validation * - normalizeApnsToken: 64–160 hex, lowercase-normalized, everything else null * - ApnsTokenStore: idempotent upsert/remove, FIFO cap, 0600 persist, tolerant load * - buildApnsJwt: ES256 header/claims, ieee-p1363 signature verifies, mocked iat * - createApnsService: payload shape NEEDS-INPUT vs DONE (category/priority/ * minimization — cwd/command NEVER in the payload), DND/NOTIFY_DONE gates, * JWT ~50min cache + 403 cache-drop, evict on 410/BadDeviceToken, * connection errors never throw * - combineNotifyServices: both paths fire, one failing never blocks the other * * Integration layer (real startServer on an ephemeral port): * - disabled mode: no APNS_* env → routes absent (404), startup never crashes * - POST/DELETE /push/apns-token: Origin guard 403, invalid 400, idempotent * 204s, rate limit 429 (frozen wire shape) * - coexistence (PTY): held gate → web-push AND APNs both fire, carrying the * SAME capability token; /hook/decision with that token still works (204) */ import net from 'node:net' import os from 'node:os' import path from 'node:path' import fs from 'node:fs/promises' import http2 from 'node:http2' import type { IncomingHttpHeaders } from 'node:http2' import { generateKeyPairSync, verify as cryptoVerify } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import * as nodePty from 'node-pty' import { loadConfig } from '../src/config.js' import { startServer } from '../src/server.js' import type { Config, NotifyService, Session } from '../src/types.js' import { buildApnsJwt, combineNotifyServices, createApnsService, initApns, loadApnsConfig, loadApnsKey, loadApnsTokenStore, normalizeApnsToken, type ApnsConfig, type ApnsHttp2Client, type ApnsRequest, type ApnsResponse, } from '../src/push/apns.js' // ── web-push mock (captures every signed payload for coexistence tests) ─────── const { sentWebPushPayloads } = vi.hoisted(() => ({ sentWebPushPayloads: [] as string[] })) vi.mock('web-push', () => ({ default: { setVapidDetails: (): void => {}, sendNotification: async (_sub: unknown, payload: string): Promise => { sentWebPushPayloads.push(payload) }, }, })) const PTY_AVAILABLE = (() => { try { const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) p.kill() return true } catch { return false } })() const itPty = PTY_AVAILABLE ? it : it.skip // ── fixtures ─────────────────────────────────────────────────────────────────── const BASE_CFG: Config = { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 10_000, scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', maxSessions: 50, maxMsgsPerSec: 2000, permTimeoutMs: 300_000, reapIntervalMs: 60_000, previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], projectRoots: ['/home/tester'], projectScanDepth: 4, projectScanTtlMs: 10_000, projectDirtyCheck: true, editorCmd: 'code', vapidPublicKey: 'PUBKEY', vapidPrivateKey: 'PRIVKEY', vapidSubject: 'mailto:admin@example.com', pushStorePath: '/tmp/push-subs.json', pushMaxSubs: 50, notifyDone: true, notifyDnd: false, decisionTokenTtlMs: 300_000, timelineMax: 200, timelineEnabled: true, stuckTtlMs: 600_000, stuckAlert: true, diffTimeoutMs: 2000, diffMaxBytes: 2 * 1024 * 1024, diffMaxFiles: 300, statuslineTtlMs: 30_000, worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, defaultPermissionMode: 'default', allowAutoMode: false, } function cfg(overrides: Partial = {}): Config { return { ...BASE_CFG, ...overrides } } const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' const SECRET_CWD = '/home/tester/super-secret-project' function fakeSession(id = SID, cwd: string | null = SECRET_CWD): Session { return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session } /** A fresh EC P-256 keypair; private side exported as .p8 (PKCS#8 PEM). */ function makeP8(): { pem: string; publicPem: string } { const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) return { pem: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string, publicPem: publicKey.export({ type: 'spki', format: 'pem' }) as string, } } const tmpFiles: string[] = [] function tmpPath(name: string): string { const p = path.join(os.tmpdir(), `webterm-apns-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`) tmpFiles.push(p) return p } const HEX64 = 'a'.repeat(64) const HEX64_B = 'b'.repeat(64) const APNS_ENV_OK = { APNS_KEY_PATH: '/tmp/AuthKey_TEST.p8', APNS_KEY_ID: 'ABCDEF1234', APNS_TEAM_ID: 'TEAM123456', } function apnsCfgFixture(overrides: Partial = {}): ApnsConfig { return { keyPath: '/unused/AuthKey.p8', keyId: 'ABCDEF1234', teamId: 'TEAM123456', bundleId: 'com.yaojia.webterm', storePath: tmpPath('svc-store'), host: 'https://api.push.apple.com', ...overrides, } } interface FakeClient extends ApnsHttp2Client { requests: ApnsRequest[] } /** Fake HTTP/2 seam: records requests; per-call responder decides the outcome. */ function makeClient( respond: (req: ApnsRequest, callIndex: number) => ApnsResponse | Promise = () => ({ status: 200, body: '' }), ): FakeClient { const requests: ApnsRequest[] = [] return { requests, async request(req: ApnsRequest): Promise { requests.push(req) return respond(req, requests.length - 1) }, } } async function makeService(options: { respond?: (req: ApnsRequest, i: number) => ApnsResponse | Promise cfgOverrides?: Partial apnsOverrides?: Partial tokens?: string[] now?: () => number }): Promise<{ service: NotifyService; client: FakeClient; store: ReturnType; key: ReturnType }> { const key = makeP8() const keyPath = tmpPath('unit-key.p8') await fs.writeFile(keyPath, key.pem) const keyObject = loadApnsKey(keyPath) if (keyObject === null) throw new Error('test fixture: key load failed') const store = loadApnsTokenStore(tmpPath('unit-tokens.json'), 50) for (const t of options.tokens ?? [HEX64]) store.add({ token: t, createdAt: 1 }) const client = makeClient(options.respond) const service = createApnsService( cfg(options.cfgOverrides), apnsCfgFixture(options.apnsOverrides), keyObject, store, { client, now: options.now }, ) return { service, client, store, key } } afterEach(async () => { vi.restoreAllMocks() sentWebPushPayloads.length = 0 for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: loadApnsConfig // ═══════════════════════════════════════════════════════════════════════════════ describe('loadApnsConfig', () => { it('returns ok with defaults when the critical trio is set', () => { const r = loadApnsConfig(APNS_ENV_OK) expect(r.ok).toBe(true) if (!r.ok) return expect(r.config.keyPath).toBe('/tmp/AuthKey_TEST.p8') expect(r.config.keyId).toBe('ABCDEF1234') expect(r.config.teamId).toBe('TEAM123456') expect(r.config.bundleId).toBe('com.yaojia.webterm') expect(r.config.storePath.endsWith('.web-terminal-apns-tokens.json')).toBe(true) expect(r.config.host).toBe('https://api.push.apple.com') }) it('honors APNS_BUNDLE_ID / APNS_STORE_PATH / APNS_HOST overrides', () => { const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_BUNDLE_ID: 'com.example.app', APNS_STORE_PATH: '/tmp/custom-tokens.json', APNS_HOST: 'https://api.sandbox.push.apple.com', }) expect(r.ok).toBe(true) if (!r.ok) return expect(r.config.bundleId).toBe('com.example.app') expect(r.config.storePath).toBe('/tmp/custom-tokens.json') expect(r.config.host).toBe('https://api.sandbox.push.apple.com') }) it.each(['APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID'])( 'disables the whole group when %s is missing (reason names the var)', (missing) => { const env: Record = { ...APNS_ENV_OK } delete env[missing] const r = loadApnsConfig(env) expect(r.ok).toBe(false) if (r.ok) return expect(r.reason).toContain(missing) }, ) it('treats an empty-string critical var as missing', () => { const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_KEY_ID: '' }) expect(r.ok).toBe(false) }) it('rejects a malformed APNS_HOST instead of crashing', () => { const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_HOST: 'not a url' }) expect(r.ok).toBe(false) }) it('rejects a non-http(s) APNS_HOST scheme', () => { const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_HOST: 'ftp://api.push.apple.com' }) expect(r.ok).toBe(false) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: normalizeApnsToken (frozen wire shape: 64–160 hex, lowercase-normalized) // ═══════════════════════════════════════════════════════════════════════════════ describe('normalizeApnsToken', () => { it('accepts 64 lowercase hex chars verbatim', () => { expect(normalizeApnsToken(HEX64)).toBe(HEX64) }) it('lowercase-normalizes uppercase hex', () => { expect(normalizeApnsToken('A'.repeat(64))).toBe(HEX64) }) it('accepts the 160-char upper bound', () => { expect(normalizeApnsToken('f'.repeat(160))).toBe('f'.repeat(160)) }) it.each([ ['63 chars', 'a'.repeat(63)], ['161 chars', 'a'.repeat(161)], ['non-hex', 'g'.repeat(64)], ['embedded whitespace', `${'a'.repeat(63)} `], ['empty', ''], ])('rejects %s', (_label, raw) => { expect(normalizeApnsToken(raw)).toBeNull() }) it.each([ ['number', 42], ['null', null], ['undefined', undefined], ['object', { token: HEX64 }], ])('rejects non-string input (%s)', (_label, raw) => { expect(normalizeApnsToken(raw)).toBeNull() }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: ApnsTokenStore // ═══════════════════════════════════════════════════════════════════════════════ describe('loadApnsTokenStore', () => { it('starts empty when the file does not exist', () => { const store = loadApnsTokenStore(tmpPath('missing.json'), 50) expect(store.list()).toEqual([]) }) it('upserts idempotently by token (same token twice → one record)', () => { const store = loadApnsTokenStore(tmpPath('upsert.json'), 50) store.add({ token: HEX64, createdAt: 1 }) store.add({ token: HEX64, createdAt: 2 }) expect(store.list().length).toBe(1) expect(store.list()[0]?.createdAt).toBe(2) }) it('remove is idempotent (unknown token is a no-op)', () => { const store = loadApnsTokenStore(tmpPath('remove.json'), 50) store.add({ token: HEX64, createdAt: 1 }) store.remove(HEX64_B) expect(store.list().length).toBe(1) store.remove(HEX64) store.remove(HEX64) expect(store.list()).toEqual([]) }) it('FIFO-caps at maxTokens (oldest evicted)', () => { const store = loadApnsTokenStore(tmpPath('cap.json'), 2) store.add({ token: 'a'.repeat(64), createdAt: 1 }) store.add({ token: 'b'.repeat(64), createdAt: 2 }) store.add({ token: 'c'.repeat(64), createdAt: 3 }) expect(store.list().map((r) => r.token)).toEqual(['b'.repeat(64), 'c'.repeat(64)]) }) it('throws on a structurally invalid record', () => { const store = loadApnsTokenStore(tmpPath('invalid.json'), 50) expect(() => store.add({ token: 'not-hex', createdAt: 1 })).toThrow(TypeError) }) it('persists with 0600 permissions and round-trips through load', async () => { const file = tmpPath('persist.json') const store = loadApnsTokenStore(file, 50) store.add({ token: HEX64, createdAt: 7 }) await store.persist() const st = await fs.stat(file) expect(st.mode & 0o777).toBe(0o600) const reloaded = loadApnsTokenStore(file, 50) expect(reloaded.list()).toEqual([{ token: HEX64, createdAt: 7 }]) }) it('tolerates a malformed store file (starts empty, no throw)', async () => { const file = tmpPath('malformed.json') await fs.writeFile(file, 'not json at all') const store = loadApnsTokenStore(file, 50) expect(store.list()).toEqual([]) }) it('filters invalid entries out of a persisted file', async () => { const file = tmpPath('mixed.json') await fs.writeFile( file, JSON.stringify([{ token: HEX64, createdAt: 1 }, { token: 'nope', createdAt: 1 }, 42]), ) const store = loadApnsTokenStore(file, 50) expect(store.list()).toEqual([{ token: HEX64, createdAt: 1 }]) }) it('prune removes a batch of dead tokens', () => { const store = loadApnsTokenStore(tmpPath('prune.json'), 50) store.add({ token: HEX64, createdAt: 1 }) store.add({ token: HEX64_B, createdAt: 2 }) store.prune([HEX64]) expect(store.list().map((r) => r.token)).toEqual([HEX64_B]) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: loadApnsKey + buildApnsJwt (ES256, ieee-p1363, mocked clock) // ═══════════════════════════════════════════════════════════════════════════════ describe('loadApnsKey', () => { it('loads a valid EC P-256 .p8 file', async () => { const { pem } = makeP8() const file = tmpPath('good.p8') await fs.writeFile(file, pem) expect(loadApnsKey(file)).not.toBeNull() }) it('returns null (never throws) for a missing file', () => { expect(loadApnsKey('/nonexistent/AuthKey.p8')).toBeNull() }) it('returns null for garbage key material', async () => { const file = tmpPath('garbage.p8') await fs.writeFile(file, 'this is not a key') expect(loadApnsKey(file)).toBeNull() }) it('returns null for a non-EC key', async () => { const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) const file = tmpPath('rsa.p8') await fs.writeFile(file, privateKey.export({ type: 'pkcs8', format: 'pem' }) as string) expect(loadApnsKey(file)).toBeNull() }) }) describe('buildApnsJwt', () => { it('produces ES256 header/claims with the mocked clock, and the ieee-p1363 signature verifies', async () => { const { pem, publicPem } = makeP8() const file = tmpPath('jwt.p8') await fs.writeFile(file, pem) const key = loadApnsKey(file) if (key === null) throw new Error('key load failed') const nowMs = 1_700_000_000_123 const jwt = buildApnsJwt(key, 'ABCDEF1234', 'TEAM123456', nowMs) const [h, c, s] = jwt.split('.') expect(h).toBeDefined() expect(c).toBeDefined() expect(s).toBeDefined() const header = JSON.parse(Buffer.from(h as string, 'base64url').toString('utf8')) as Record expect(header).toEqual({ alg: 'ES256', kid: 'ABCDEF1234' }) const claims = JSON.parse(Buffer.from(c as string, 'base64url').toString('utf8')) as Record expect(claims).toEqual({ iss: 'TEAM123456', iat: 1_700_000_000 }) const valid = cryptoVerify( 'sha256', Buffer.from(`${h}.${c}`), { key: publicPem, dsaEncoding: 'ieee-p1363' }, Buffer.from(s as string, 'base64url'), ) expect(valid).toBe(true) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: createApnsService — payload shapes + minimization (security invariant) // ═══════════════════════════════════════════════════════════════════════════════ describe('createApnsService payload shape', () => { const NOW = 1_700_000_000_000 it('NEEDS-INPUT: priority 10, category WEBTERM_GATE, minimal alert, {sessionId, token} custom payload', async () => { const { service, client } = await makeService({ now: () => NOW }) await service.notify(fakeSession(), 'needs-input', 'cap-token-1') expect(client.requests.length).toBe(1) const req = client.requests[0] as ApnsRequest expect(req.path).toBe(`/3/device/${HEX64}`) expect(req.headers['apns-priority']).toBe('10') expect(req.headers['apns-push-type']).toBe('alert') expect(req.headers['apns-topic']).toBe('com.yaojia.webterm') expect(req.headers['apns-collapse-id']).toBe(SID.replace(/-/g, '')) // apns-expiration = now + decisionTokenTtl (300 s) in epoch seconds. expect(req.headers['apns-expiration']).toBe(String(1_700_000_000 + 300)) expect(req.headers['authorization']).toMatch(/^bearer /) const body = JSON.parse(req.body) as Record const aps = body['aps'] as Record expect(aps['category']).toBe('WEBTERM_GATE') const alert = aps['alert'] as Record // Minimization: alert carries session short-prefix + status word ONLY. expect(`${alert['title']} ${alert['body']}`).toContain(SID.slice(0, 8)) expect(`${alert['title']} ${alert['body']}`).not.toContain(SID) // Custom payload: what /hook/decision needs — full sessionId + capability token. expect(body['sessionId']).toBe(SID) expect(body['token']).toBe('cap-token-1') expect(body['cls']).toBe('needs-input') }) it('DONE: priority 5, no decision token, no gate category', async () => { const { service, client } = await makeService({ now: () => NOW }) await service.notify(fakeSession(), 'done') expect(client.requests.length).toBe(1) const req = client.requests[0] as ApnsRequest expect(req.headers['apns-priority']).toBe('5') const body = JSON.parse(req.body) as Record expect(body['cls']).toBe('done') expect('token' in body).toBe(false) const aps = body['aps'] as Record expect('category' in aps).toBe(false) }) it('NEVER leaks cwd, command content, or terminal output into the payload', async () => { const { service, client } = await makeService({ now: () => NOW }) await service.notify(fakeSession(SID, SECRET_CWD), 'needs-input', 'tok') await service.notify(fakeSession(SID, SECRET_CWD), 'done') for (const req of client.requests) { expect(req.body).not.toContain('super-secret-project') expect(req.body).not.toContain(SECRET_CWD) expect(req.body).not.toContain('cwd') expect(req.body).not.toContain('command') } }) it('P1 signal list is NEEDS-INPUT + DONE only: stuck sends nothing over APNs', async () => { const { service, client } = await makeService({}) await service.notify(fakeSession(), 'stuck') expect(client.requests.length).toBe(0) }) it('fans out to every registered token', async () => { const { service, client } = await makeService({ tokens: [HEX64, HEX64_B] }) await service.notify(fakeSession(), 'done') expect(client.requests.map((r) => r.path).sort()).toEqual([ `/3/device/${HEX64}`, `/3/device/${HEX64_B}`, ]) }) it('sends nothing when no tokens are registered', async () => { const { service, client } = await makeService({ tokens: [] }) await service.notify(fakeSession(), 'needs-input', 'tok') expect(client.requests.length).toBe(0) }) it('honors NOTIFY_DND (mirrors web-push semantics)', async () => { const { service, client } = await makeService({ cfgOverrides: { notifyDnd: true } }) await service.notify(fakeSession(), 'needs-input', 'tok') expect(client.requests.length).toBe(0) }) it('honors NOTIFY_DONE=false for done but still sends needs-input', async () => { const { service, client } = await makeService({ cfgOverrides: { notifyDone: false } }) await service.notify(fakeSession(), 'done') expect(client.requests.length).toBe(0) await service.notify(fakeSession(), 'needs-input', 'tok') expect(client.requests.length).toBe(1) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: JWT caching (~50 min) + 403 cache drop // ═══════════════════════════════════════════════════════════════════════════════ describe('createApnsService JWT cache', () => { it('reuses the cached JWT within the ~50 min window', async () => { let nowMs = 1_700_000_000_000 const { service, client } = await makeService({ now: () => nowMs }) await service.notify(fakeSession(), 'done') nowMs += 10 * 60_000 // +10 min: still cached await service.notify(fakeSession(), 'done') const [a, b] = client.requests.map((r) => r.headers['authorization']) expect(a).toBe(b) }) it('re-signs after the cache window expires', async () => { let nowMs = 1_700_000_000_000 const { service, client } = await makeService({ now: () => nowMs }) await service.notify(fakeSession(), 'done') nowMs += 51 * 60_000 // past the ~50 min cache await service.notify(fakeSession(), 'done') const [a, b] = client.requests.map((r) => r.headers['authorization']) expect(a).not.toBe(b) }) it('drops the cached JWT on a 403 so the next send re-signs', async () => { const { service, client } = await makeService({ respond: (_req, i) => i === 0 ? { status: 403, body: '{"reason":"ExpiredProviderToken"}' } : { status: 200, body: '' }, }) await service.notify(fakeSession(), 'done') await service.notify(fakeSession(), 'done') const [a, b] = client.requests.map((r) => r.headers['authorization']) // ECDSA signatures are randomized: a re-sign always yields a fresh string. expect(a).not.toBe(b) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: response handling — evict on 410 / BadDeviceToken, resilience // ═══════════════════════════════════════════════════════════════════════════════ describe('createApnsService response handling', () => { it('evicts a token on 410 Unregistered and keeps healthy ones', async () => { const { service, store } = await makeService({ tokens: [HEX64, HEX64_B], respond: (req) => req.path.endsWith(HEX64) ? { status: 410, body: '{"reason":"Unregistered"}' } : { status: 200, body: '' }, }) await service.notify(fakeSession(), 'done') expect(store.list().map((r) => r.token)).toEqual([HEX64_B]) }) it('evicts a token on 400 BadDeviceToken', async () => { const { service, store } = await makeService({ respond: () => ({ status: 400, body: '{"reason":"BadDeviceToken"}' }), }) await service.notify(fakeSession(), 'done') expect(store.list()).toEqual([]) }) it('keeps the token on other 400s and on 429 (transient)', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { service, store } = await makeService({ respond: (_req, i) => i === 0 ? { status: 400, body: '{"reason":"BadMessageId"}' } : { status: 429, body: '{"reason":"TooManyRequests"}' }, }) await service.notify(fakeSession(), 'done') await service.notify(fakeSession(), 'done') expect(store.list().length).toBe(1) expect(errSpy).toHaveBeenCalled() // Never log the device token or key material. for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain(HEX64) } }) it('never throws on connection errors and keeps the token', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { service, store } = await makeService({ respond: () => { throw new Error('ECONNREFUSED 127.0.0.1:443') }, }) await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined() expect(store.list().length).toBe(1) expect(errSpy).toHaveBeenCalled() }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: combineNotifyServices (coexistence with web-push at the seam) // ═══════════════════════════════════════════════════════════════════════════════ describe('combineNotifyServices', () => { function spyService(enabled: boolean): NotifyService & { calls: unknown[][] } { const calls: unknown[][] = [] return { calls, isEnabled: () => enabled, async notify(session, cls, token?) { calls.push([session.meta.id, cls, token]) }, } } it('fans out to every service with identical arguments (both paths fire)', async () => { const a = spyService(true) const b = spyService(true) const combined = combineNotifyServices(a, b) await combined.notify(fakeSession(), 'needs-input', 'tok-9') expect(a.calls).toEqual([[SID, 'needs-input', 'tok-9']]) expect(b.calls).toEqual([[SID, 'needs-input', 'tok-9']]) }) it('isEnabled is the OR of the children', () => { expect(combineNotifyServices(spyService(false), spyService(true)).isEnabled()).toBe(true) expect(combineNotifyServices(spyService(false), spyService(false)).isEnabled()).toBe(false) }) it('one failing service never blocks the other (and never throws)', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const boom: NotifyService = { isEnabled: () => true, notify: async () => { throw new Error('web-push exploded') }, } const b = spyService(true) await expect(combineNotifyServices(boom, b).notify(fakeSession(), 'done')).resolves.toBeUndefined() expect(b.calls.length).toBe(1) expect(errSpy).toHaveBeenCalled() }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: initApns — disabled group semantics (startup never crashes, one log line) // ═══════════════════════════════════════════════════════════════════════════════ describe('initApns', () => { it('returns null with one log line when nothing is configured', () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(initApns(cfg(), {})).toBeNull() const apnsLines = errSpy.mock.calls.filter((c) => String(c[0]).includes('apns')) expect(apnsLines.length).toBe(1) }) it('returns null when the group is only partially configured', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) expect(initApns(cfg(), { APNS_KEY_PATH: '/tmp/AuthKey.p8' })).toBeNull() }) it('returns null (no crash) when the key file is unreadable or garbage', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const file = tmpPath('bad.p8') await fs.writeFile(file, 'garbage') expect( initApns(cfg(), { ...APNS_ENV_OK, APNS_KEY_PATH: file, APNS_STORE_PATH: tmpPath('s1.json') }), ).toBeNull() }) it('returns a runtime with a working store when fully configured — never logging key material', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { pem } = makeP8() const keyFile = tmpPath('init-good.p8') await fs.writeFile(keyFile, pem) const rt = initApns(cfg(), { ...APNS_ENV_OK, APNS_KEY_PATH: keyFile, APNS_STORE_PATH: tmpPath('init-store.json'), }) expect(rt).not.toBeNull() if (rt === null) return expect(rt.service.isEnabled()).toBe(true) rt.store.add({ token: HEX64, createdAt: 1 }) expect(rt.store.list().length).toBe(1) for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain('PRIVATE KEY') } }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Integration: real startServer on an ephemeral port // ═══════════════════════════════════════════════════════════════════════════════ function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer() srv.listen(0, '127.0.0.1', () => { const addr = srv.address() if (addr === null || typeof addr === 'string') { srv.close() reject(new Error('bad addr')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } const handles: { close(): Promise }[] = [] const APNS_ENV_KEYS = [ 'APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID', 'APNS_BUNDLE_ID', 'APNS_STORE_PATH', 'APNS_HOST', ] as const /** Run startServer with APNS_* temporarily injected into process.env (the server * reads the group once at startup), restoring the previous values right after. */ async function spawnServer(options: { apnsEnv?: Partial> cfgEnv?: Record } = {}): Promise<{ port: number; origin: string; apnsStorePath: string }> { const port = await getFreePort() const pushStorePath = tmpPath(`web-subs-${port}.json`) const apnsStorePath = options.apnsEnv?.APNS_STORE_PATH ?? tmpPath(`apns-tokens-${port}.json`) const saved = new Map() for (const k of APNS_ENV_KEYS) saved.set(k, process.env[k]) for (const k of APNS_ENV_KEYS) delete process.env[k] if (options.apnsEnv !== undefined) { for (const [k, v] of Object.entries(options.apnsEnv)) process.env[k] = v if (options.apnsEnv.APNS_STORE_PATH === undefined) process.env['APNS_STORE_PATH'] = apnsStorePath } try { const config = loadConfig({ PORT: String(port), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, USE_TMUX: '0', IDLE_TTL: '86400', PUSH_STORE_PATH: pushStorePath, ...options.cfgEnv, }) handles.push(startServer(config)) } finally { for (const [k, v] of saved) { if (v === undefined) delete process.env[k] else process.env[k] = v } } await new Promise((r) => setTimeout(r, 80)) return { port, origin: `http://127.0.0.1:${port}`, apnsStorePath } } async function makeApnsEnv(host?: string): Promise> { const { pem } = makeP8() const keyPath = tmpPath('server-key.p8') await fs.writeFile(keyPath, pem) return { APNS_KEY_PATH: keyPath, APNS_KEY_ID: 'ABCDEF1234', APNS_TEAM_ID: 'TEAM123456', // Point away from Apple: tests must NEVER touch the real APNs host. APNS_HOST: host ?? 'http://127.0.0.1:9', } } function postToken(port: number, token: unknown, origin?: string, method: 'POST' | 'DELETE' = 'POST'): Promise { return fetch(`http://127.0.0.1:${port}/push/apns-token`, { method, headers: { 'Content-Type': 'application/json', ...(origin !== undefined ? { Origin: origin } : {}) }, body: JSON.stringify({ token }), }) } afterEach(async () => { while (handles.length > 0) await handles.pop()?.close() await new Promise((r) => setTimeout(r, 30)) }) describe('disabled mode (no APNS_* env)', () => { it('does not mount the token routes and the server still works', async () => { const { port, origin } = await spawnServer() const res = await postToken(port, HEX64, origin) expect(res.status).toBe(404) const del = await postToken(port, HEX64, origin, 'DELETE') expect(del.status).toBe(404) const alive = await fetch(`http://127.0.0.1:${port}/live-sessions`) expect(alive.status).toBe(200) }) it('stays disabled (no crash) when the group is partial or the key is garbage', async () => { const badKey = tmpPath('garbage-server.p8') await fs.writeFile(badKey, 'garbage') const { port, origin } = await spawnServer({ apnsEnv: { APNS_KEY_PATH: badKey, APNS_KEY_ID: 'ABCDEF1234', APNS_TEAM_ID: 'TEAM123456' }, }) expect((await postToken(port, HEX64, origin)).status).toBe(404) expect((await fetch(`http://127.0.0.1:${port}/live-sessions`)).status).toBe(200) }) }) describe('POST/DELETE /push/apns-token (frozen wire shape)', () => { it('rejects foreign and missing Origin with 403', async () => { const { port } = await spawnServer({ apnsEnv: await makeApnsEnv() }) expect((await postToken(port, HEX64, 'http://evil.example')).status).toBe(403) expect((await postToken(port, HEX64)).status).toBe(403) expect((await postToken(port, HEX64, 'http://evil.example', 'DELETE')).status).toBe(403) }) it('rejects invalid tokens with 400', async () => { const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv() }) expect((await postToken(port, 'a'.repeat(63), origin)).status).toBe(400) expect((await postToken(port, 'g'.repeat(64), origin)).status).toBe(400) expect((await postToken(port, 42, origin)).status).toBe(400) }) it('registers idempotently (204 twice), lowercase-normalizing the stored token', async () => { const { port, origin, apnsStorePath } = await spawnServer({ apnsEnv: await makeApnsEnv() }) expect((await postToken(port, 'A'.repeat(64), origin)).status).toBe(204) expect((await postToken(port, 'a'.repeat(64), origin)).status).toBe(204) const stored = JSON.parse(await fs.readFile(apnsStorePath, 'utf8')) as { token: string }[] expect(stored.length).toBe(1) expect(stored[0]?.token).toBe('a'.repeat(64)) }) it('unregisters idempotently (204 even for an unknown token)', async () => { const { port, origin, apnsStorePath } = await spawnServer({ apnsEnv: await makeApnsEnv() }) expect((await postToken(port, HEX64, origin)).status).toBe(204) expect((await postToken(port, 'A'.repeat(64), origin, 'DELETE')).status).toBe(204) expect((await postToken(port, HEX64_B, origin, 'DELETE')).status).toBe(204) const stored = JSON.parse(await fs.readFile(apnsStorePath, 'utf8')) as { token: string }[] expect(stored.length).toBe(0) }) it('rate-limits more than 5 calls/min from one IP (429)', async () => { const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv() }) const codes: number[] = [] for (let i = 0; i < 6; i++) { codes.push((await postToken(port, String(i).repeat(64), origin)).status) } expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true) expect(codes[5]).toBe(429) }) }) // ── coexistence: held gate → BOTH web-push and APNs fire with the same token ── interface FakeApnsServer { url: string requests: { headers: IncomingHttpHeaders; body: string }[] close(): Promise } /** Local h2c stand-in for api.push.apple.com — captures requests, answers 200. */ function startFakeApnsServer(): Promise { return new Promise((resolve, reject) => { const requests: { headers: IncomingHttpHeaders; body: string }[] = [] const server = http2.createServer() server.on('stream', (stream, headers) => { let body = '' stream.setEncoding('utf8') stream.on('data', (chunk: string) => { body += chunk }) stream.on('end', () => { requests.push({ headers, body }) stream.respond({ ':status': 200 }) stream.end() }) }) server.on('error', reject) server.listen(0, '127.0.0.1', () => { const addr = server.address() if (addr === null || typeof addr === 'string') { reject(new Error('bad addr')) return } resolve({ url: `http://127.0.0.1:${addr.port}`, requests, close: () => new Promise((r) => { server.close(() => r()) }), }) }) }) } function waitForOpen(ws: WebSocket): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('open timeout')), 3000) ws.once('open', () => { clearTimeout(t) resolve() }) ws.once('error', (e) => { clearTimeout(t) reject(e) }) }) } async function createDetachedSession(port: number, origin: string): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const sessionId = await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('attach timeout')), 5000) ws.on('message', (raw) => { try { const m = JSON.parse(String(raw)) as Record if (m['type'] === 'attached') { clearTimeout(t) resolve(m['sessionId'] as string) } } catch { /* ignore non-JSON frames */ } }) }) ws.close() await new Promise((r) => setTimeout(r, 150)) return sessionId } describe('coexistence: web-push + APNs on a held gate', () => { itPty('fires BOTH paths with the same capability token; /hook/decision accepts it', async () => { const fakeApns = await startFakeApnsServer() try { const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv(fakeApns.url), cfgEnv: { VAPID_PUBLIC_KEY: 'test-public-key', VAPID_PRIVATE_KEY: 'test-private-key', VAPID_SUBJECT: 'mailto:test@localhost', PERM_TIMEOUT_MS: '4000', }, }) // Register a web-push subscription AND an APNs device token. const sub = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ endpoint: 'https://push.example/ep-1', keys: { p256dh: 'p', auth: 'a' } }), }) expect(sub.status).toBe(204) expect((await postToken(port, HEX64, origin)).status).toBe(204) const sessionId = await createDetachedSession(port, origin) // Fire the held permission hook (loopback); do NOT await — it must be HELD. sentWebPushPayloads.length = 0 let resolved = false const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'Bash' }), }).then(async (r) => { resolved = true return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } }) await new Promise((r) => setTimeout(r, 400)) expect(resolved).toBe(false) // Web-push path: unchanged. expect(sentWebPushPayloads.length).toBe(1) const webToken = (JSON.parse(sentWebPushPayloads[0] as string) as { token?: string }).token expect(typeof webToken).toBe('string') // APNs path: same gate, same capability token, frozen shape. expect(fakeApns.requests.length).toBe(1) const apnsReq = fakeApns.requests[0] as { headers: IncomingHttpHeaders; body: string } expect(apnsReq.headers[':path']).toBe(`/3/device/${HEX64}`) expect(apnsReq.headers['apns-priority']).toBe('10') expect(String(apnsReq.headers['authorization'])).toMatch(/^bearer /) const apnsBody = JSON.parse(apnsReq.body) as Record expect((apnsBody['aps'] as Record)['category']).toBe('WEBTERM_GATE') expect(apnsBody['sessionId']).toBe(sessionId) expect(apnsBody['token']).toBe(webToken) expect(apnsReq.body).not.toContain('"cwd"') // The capability token semantics are UNCHANGED: single decision resolves it. const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId, decision: 'allow', token: apnsBody['token'] }), }) expect(dec.status).toBe(204) const decision = await permPromise expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') } finally { await fakeApns.close() } }) itPty('an APNs token alone (zero web subs, zero clients) is enough to HOLD the gate', async () => { const fakeApns = await startFakeApnsServer() try { const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv(fakeApns.url), cfgEnv: { PERM_TIMEOUT_MS: '4000' }, }) expect((await postToken(port, HEX64, origin)).status).toBe(204) const sessionId = await createDetachedSession(port, origin) let resolved = false void fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'Bash' }), }).then(() => { resolved = true }) await new Promise((r) => setTimeout(r, 400)) expect(resolved).toBe(false) // held — not fallen through to Claude's own prompt expect(fakeApns.requests.length).toBe(1) // Release it so the server can shut down cleanly. const body = JSON.parse((fakeApns.requests[0] as { body: string }).body) as Record const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId, decision: 'deny', token: body['token'] }), }) expect(dec.status).toBe(204) } finally { await fakeApns.close() } }) })