Files
web-terminal/test/push-apns.test.ts
Yaojia Wang 9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00

1086 lines
44 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.

/**
* 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: 64160 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<void> => {
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,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
}
function cfg(overrides: Partial<Config> = {}): 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> = {}): 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<ApnsResponse> = () => ({ status: 200, body: '' }),
): FakeClient {
const requests: ApnsRequest[] = []
return {
requests,
async request(req: ApnsRequest): Promise<ApnsResponse> {
requests.push(req)
return respond(req, requests.length - 1)
},
}
}
async function makeService(options: {
respond?: (req: ApnsRequest, i: number) => ApnsResponse | Promise<ApnsResponse>
cfgOverrides?: Partial<Config>
apnsOverrides?: Partial<ApnsConfig>
tokens?: string[]
now?: () => number
}): Promise<{ service: NotifyService; client: FakeClient; store: ReturnType<typeof loadApnsTokenStore>; key: ReturnType<typeof makeP8> }> {
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<string, string | undefined> = { ...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: 64160 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<string, unknown>
expect(header).toEqual({ alg: 'ES256', kid: 'ABCDEF1234' })
const claims = JSON.parse(Buffer.from(c as string, 'base64url').toString('utf8')) as Record<string, unknown>
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<string, unknown>
const aps = body['aps'] as Record<string, unknown>
expect(aps['category']).toBe('WEBTERM_GATE')
const alert = aps['alert'] as Record<string, string>
// 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<string, unknown>
expect(body['cls']).toBe('done')
expect('token' in body).toBe(false)
const aps = body['aps'] as Record<string, unknown>
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<number> {
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<void> }[] = []
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<Record<(typeof APNS_ENV_KEYS)[number], string>>
cfgEnv?: Record<string, string | undefined>
} = {}): 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<string, string | undefined>()
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<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}`, apnsStorePath }
}
async function makeApnsEnv(host?: string): Promise<Record<string, string>> {
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<Response> {
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<void>((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<void>
}
/** Local h2c stand-in for api.push.apple.com — captures requests, answers 200. */
function startFakeApnsServer(): Promise<FakeApnsServer> {
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<void>((r) => {
server.close(() => r())
}),
})
})
})
}
function waitForOpen(ws: WebSocket): Promise<void> {
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<string> {
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<string>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('attach timeout')), 5000)
ws.on('message', (raw) => {
try {
const m = JSON.parse(String(raw)) as Record<string, unknown>
if (m['type'] === 'attached') {
clearTimeout(t)
resolve(m['sessionId'] as string)
}
} catch {
/* ignore non-JSON frames */
}
})
})
ws.close()
await new Promise<void>((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<void>((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<string, unknown>
expect((apnsBody['aps'] as Record<string, unknown>)['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<void>((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<string, unknown>
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()
}
})
})