Every notification depended on Firebase Cloud Messaging, so a missing Firebase project, which is the current state, meant no notification could be delivered on any platform. Web Push and token-based Apple Push are now first class transports alongside FCM, chosen per registered device, and a scheduled Cloudflare Worker drain retries an outbox so a provider outage delays rather than drops a message.
189 lines
6.8 KiB
TypeScript
189 lines
6.8 KiB
TypeScript
// 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<unknown>,
|
|
code: string,
|
|
status: number,
|
|
stale?: boolean,
|
|
): Promise<void> {
|
|
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<string> {
|
|
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<string, unknown>
|
|
const decodedClaims = JSON.parse(b64urlDecode(claims)) as Record<string, unknown>
|
|
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<string, string>
|
|
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')
|
|
})
|