// server/supabase/functions/_shared/apns.test.ts // Apple Push Notification service (token/.p8) transport tests. Firebase-free. import { createApnsToken, readApnsConfig, sendApnsMessage, type ApnsConfig, } from './apns.ts' import { PushContractError, type PushNotification } from './push-contract.ts' function assert(condition: boolean, message: string): asserts condition { if (!condition) throw new Error(message) } async function assertPushError( action: () => unknown | Promise, code: string, status: number, stale?: boolean, ): Promise { let actual: unknown try { await action() } catch (error) { actual = error } assert(actual instanceof PushContractError, `expected PushContractError for ${code}`) assert(actual.code === code, `expected ${code}, received ${actual.code}`) assert(actual.status === status, `expected status ${status}, received ${actual.status}`) if (stale !== undefined) { assert(actual.staleRegistration === stale, `expected stale=${stale}`) } } function toPem(bytes: Uint8Array): string { let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) const b64 = btoa(binary) const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64 return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----\n` } async function makeP8(): Promise { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'], ) return toPem(new Uint8Array(await crypto.subtle.exportKey('pkcs8', keys.privateKey))) } function b64urlDecode(value: string): string { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) return new TextDecoder().decode(Uint8Array.from(atob(padded), (character) => character.charCodeAt(0))) } const DEVICE_TOKEN = 'a'.repeat(64) const notification: PushNotification = { title: 'Transcription complete', body: 'Open D3RO Voice', data: { schema_version: '1', event_type: 'transcription.completed', resource_id: '11111111-2222-4333-8444-555555555555', route: 'HistoryDetail', history_id: '11111111-2222-4333-8444-555555555555' }, } Deno.test('apns config requires valid credentials', () => { const empty = () => undefined assertPushError(() => readApnsConfig(empty), 'apns_not_configured', 503) const malformed = (name: string) => ({ APNS_KEY_ID: 'too-short', APNS_TEAM_ID: 'ABCDEFGHIJ', APNS_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----', APNS_TOPIC: 'com.d3ro.voice', })[name] assertPushError(() => readApnsConfig(malformed), 'apns_credentials_invalid', 503) }) Deno.test('apns provider token carries kid and ES256', async () => { const pem = await makeP8() const config: ApnsConfig = { keyId: 'ABCDEFGHIJ', teamId: 'KLMNOPQRST', privateKey: pem, topic: 'com.d3ro.voice', environment: 'production', } const token = await createApnsToken(config, 1_700_000_000) const [header, claims] = token.split('.') const decodedHeader = JSON.parse(b64urlDecode(header)) as Record const decodedClaims = JSON.parse(b64urlDecode(claims)) as Record assert(decodedHeader.alg === 'ES256', 'alg must be ES256') assert(decodedHeader.kid === 'ABCDEFGHIJ', 'kid must be the key id') assert(decodedClaims.iss === 'KLMNOPQRST', 'iss must be the team id') assert(typeof decodedClaims.iat === 'number', 'iat must be set') assert(token.split('.').length === 3, 'token is a JWT') }) Deno.test('apns send targets the right host with topic headers', async () => { const config: ApnsConfig = { keyId: 'ABCDEFGHIJ', teamId: 'KLMNOPQRST', privateKey: 'unused', topic: 'com.d3ro.voice', environment: 'production', } const calls: Array<{ url: string; init: RequestInit }> = [] const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init: init ?? {} }) return Promise.resolve(new Response('{}', { status: 200 })) }) as unknown as typeof fetch const result = await sendApnsMessage(DEVICE_TOKEN, notification, { config, fetchImpl, getAccessToken: () => Promise.resolve('test-token'), }) assert(result.status === 200, 'ok status returned') assert(calls.length === 1, 'one request issued') assert(calls[0].url === `https://api.push.apple.com/3/device/${DEVICE_TOKEN}`, 'production host used') const headers = calls[0].init.headers as Record assert(headers['apns-topic'] === 'com.d3ro.voice', 'topic header present') assert(headers['apns-push-type'] === 'alert', 'push type header present') assert(headers.authorization === 'bearer test-token', 'bearer token present') }) Deno.test('apns send maps stale and auth responses', async () => { const config: ApnsConfig = { keyId: 'ABCDEFGHIJ', teamId: 'KLMNOPQRST', privateKey: 'unused', topic: 'com.d3ro.voice', environment: 'sandbox', } const respond = (status: number, body: unknown) => ( (() => Promise.resolve(new Response(JSON.stringify(body), { status }))) as unknown as typeof fetch ) const options = { config, getAccessToken: () => Promise.resolve('t') } await assertPushError( () => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(410, { reason: 'Unregistered' }) }), 'apns_registration_stale', 410, true, ) await assertPushError( () => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(400, { reason: 'BadDeviceToken' }) }), 'apns_registration_stale', 410, true, ) await assertPushError( () => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(403, { reason: 'InvalidProviderToken' }) }), 'apns_credentials_invalid', 503, ) await assertPushError( () => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(503, { reason: 'ServiceUnavailable' }) }), 'apns_send_failed', 502, ) await assertPushError( () => sendApnsMessage('not-a-token', notification, { ...options, fetchImpl: respond(200, {}) }), 'apns_registration_invalid', 400, true, ) }) Deno.test('apns send uses the sandbox host when configured', async () => { const config: ApnsConfig = { keyId: 'ABCDEFGHIJ', teamId: 'KLMNOPQRST', privateKey: 'unused', topic: 'com.d3ro.voice', environment: 'sandbox', } const calls: string[] = [] const fetchImpl = ((url: string | URL | Request) => { calls.push(String(url)) return Promise.resolve(new Response('{}', { status: 200 })) }) as unknown as typeof fetch await sendApnsMessage(DEVICE_TOKEN, notification, { config, fetchImpl, getAccessToken: () => Promise.resolve('t'), }) assert(calls[0].startsWith('https://api.sandbox.push.apple.com/'), 'sandbox host used') })