import crypto from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createApnsRuntime } from './apns-runtime.js'; // A real P-256 key so the ES256 signing path (direct mode) runs for real. const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); const APNS_CONFIG = { keyId: 'KEY123', teamId: 'TEAM123', p8: P8, bundleId: 'com.openchamber.app', environment: 'sandbox' }; // In-memory fs so add-then-read reflects within a test. const createMemoryFs = () => { let content = null; return { mkdir: vi.fn(async () => {}), readFile: vi.fn(async () => { if (content == null) { const err = new Error('ENOENT'); err.code = 'ENOENT'; throw err; } return content; }), writeFile: vi.fn(async (_path, data) => { content = data; }), }; }; const makeDeps = (overrides = {}) => { // Stateful settings so the auto-generated relay signing keypair persists + reads back. let settings = {}; return { fsPromises: createMemoryFs(), path: { dirname: () => '/tmp' }, crypto, http2: { connect: vi.fn(() => { throw new Error('http2 must not be used in relay mode'); }) }, APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json', readSettingsFromDiskMigrated: vi.fn(async () => settings), writeSettingsToDisk: vi.fn(async (next) => { settings = next; }), ...overrides, }; }; const jsonResponse = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); // Mirror of the relay's verifier (crypto.subtle), to prove the server's signatures are valid. const verifyRelaySignature = async (publicKeyJwk, message, sigB64Url) => { const key = await crypto.subtle.importKey( 'jwk', { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'], ); return crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, key, new Uint8Array(Buffer.from(sigB64Url, 'base64url')), new TextEncoder().encode(message), ); }; const isRegister = ([url]) => String(url).endsWith('/register-token'); const isSend = ([url]) => String(url) === 'https://relay.test/v1/push/send'; afterEach(() => { vi.unstubAllGlobals(); delete process.env.OPENCHAMBER_PUSH_RELAY_URL; delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED; delete process.env.OPENCHAMBER_APNS_ENVIRONMENT; }); describe('apns runtime relay mode (default)', () => { it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => { const fetchMock = vi.fn(async (url) => isRegister([url]) ? jsonResponse({ ok: true }) : jsonResponse({ results: [ { token: 'tokenA', ok: true, drop: false }, { token: 'tokenDead', ok: false, drop: true }, ], }), ); vi.stubGlobal('fetch', fetchMock); process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; const runtime = createApnsRuntime(makeDeps()); await runtime.addOrUpdateApnsToken('s1', 'tokenA'); await runtime.addOrUpdateApnsToken('s2', 'tokenDead'); // Each new token is bound on the relay with a signed register-token call. const registerCalls = fetchMock.mock.calls.filter(isRegister); expect(registerCalls).toHaveLength(2); for (const [url, init] of registerCalls) { expect(url).toBe('https://relay.test/v1/push/register-token'); const body = JSON.parse(init.body); expect(body.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); expect(typeof body.ts).toBe('number'); expect(body.platform).toBe('ios'); expect(await verifyRelaySignature(body.publicKeyJwk, `${body.ts}.${body.token}.${body.platform}`, body.sig)).toBe(true); } fetchMock.mockClear(); await runtime.sendApnsToAllUiSessions( { title: 'Agent response is ready', body: 'My session', badge: 3, tag: 'ready-x', data: { sessionId: 'sess1' } }, {}, ); const sendCall = fetchMock.mock.calls.find(isSend); expect(sendCall).toBeTruthy(); const sent = JSON.parse(sendCall[1].body); expect(sendCall[1].headers.authorization).toBeUndefined(); expect(new Set(sent.tokens)).toEqual(new Set(['tokenA', 'tokenDead'])); expect(sent.title).toBe('Agent response is ready'); expect(sent.body).toBe('My session'); expect(sent.badge).toBe(3); expect(sent.env).toBe('production'); expect(sent.data).toEqual({ sessionId: 'sess1' }); expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`; expect(await verifyRelaySignature(sent.publicKeyJwk, sendMessage, sent.sig)).toBe(true); // tokenDead should have been dropped → next send targets only tokenA. fetchMock.mockClear(); await runtime.sendApnsToAllUiSessions({ title: 'x', body: 'y', tag: 't' }, {}); expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['tokenA']); }); it('reuses one persisted keypair (same serverId) across register + send', async () => { const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); vi.stubGlobal('fetch', fetchMock); process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; const deps = makeDeps(); const runtime = createApnsRuntime(deps); await runtime.addOrUpdateApnsToken('s1', 'tokenA'); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'x' }, {}); const keys = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).publicKeyJwk); expect(keys.length).toBeGreaterThanOrEqual(2); expect(keys.every((k) => k.x === keys[0].x && k.y === keys[0].y)).toBe(true); // Keypair was generated + persisted exactly once. expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1); }); it('honors an explicit sandbox environment override for every token', async () => { const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); vi.stubGlobal('fetch', fetchMock); process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox'; const runtime = createApnsRuntime(makeDeps()); await runtime.addOrUpdateApnsToken('s1', 'tokenA', undefined, 'ios', 'production'); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body); expect(sent.env).toBe('sandbox'); }); it('routes each token to its registered environment (dev build sandbox, release production)', async () => { const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); vi.stubGlobal('fetch', fetchMock); process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; const runtime = createApnsRuntime(makeDeps()); await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox'); await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production'); await runtime.addOrUpdateApnsToken('s3', 'tokenLegacy'); // no environment → production fetchMock.mockClear(); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); const sends = fetchMock.mock.calls.filter(isSend).map(([, init]) => JSON.parse(init.body)); expect(sends).toHaveLength(2); const byEnv = Object.fromEntries(sends.map((s) => [s.env, new Set(s.tokens)])); expect(byEnv.sandbox).toEqual(new Set(['tokenXcode'])); expect(byEnv.production).toEqual(new Set(['tokenStore', 'tokenLegacy'])); }); it('no-ops (no relay call) when no tokens are registered', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const runtime = createApnsRuntime(makeDeps()); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe('apns runtime direct fallback (relay disabled)', () => { it('leaves direct APNs environment unset without an explicit override (per-token routing)', async () => { const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG; const runtime = createApnsRuntime( makeDeps({ readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }), ); await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: null }); }); it('sends each token to the APNs host of its registered environment', async () => { process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true'; const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG; const hosts = []; const http2 = { connect: (host) => { const targeted = []; hosts.push({ host, targeted }); return { on: () => {}, close: () => {}, request: (headers) => { targeted.push(String(headers[':path']).replace('/3/device/', '')); const listeners = {}; const req = { on: (event, cb) => { listeners[event] = cb; return req; }, setEncoding: () => req, end: () => { queueMicrotask(() => { listeners.response?.({ ':status': '200' }); listeners.end?.(); }); }, }; return req; }, }; }, }; const runtime = createApnsRuntime( makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }), ); await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox'); await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production'); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' }); const byHost = Object.fromEntries(hosts.map(({ host, targeted }) => [host, targeted])); expect(byHost['https://api.sandbox.push.apple.com']).toEqual(['tokenXcode']); expect(byHost['https://api.push.apple.com']).toEqual(['tokenStore']); }); it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => { process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true'; const targeted = []; const http2 = { connect: () => ({ on: () => {}, close: () => {}, request: (headers) => { targeted.push(String(headers[':path']).replace('/3/device/', '')); const listeners = {}; const req = { on: (event, cb) => { listeners[event] = cb; return req; }, setEncoding: () => req, end: () => { queueMicrotask(() => { listeners.response?.({ ':status': '200' }); listeners.end?.(); }); }, }; return req; }, }), }; const runtime = createApnsRuntime( makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }), ); await runtime.addOrUpdateApnsToken('s', 'tokenDirect'); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' }); expect(targeted).toEqual(['tokenDirect']); }); it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => { const runtime = createApnsRuntime(makeDeps()); const parts = runtime.signApnsJwt(APNS_CONFIG).split('.'); expect(parts).toHaveLength(3); expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' }); expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123'); }); });