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.
1086 lines
44 KiB
TypeScript
1086 lines
44 KiB
TypeScript
/**
|
||
* 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<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: 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<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()
|
||
}
|
||
})
|
||
})
|