d3ro-voice/server/supabase/functions/_shared/apns.test.ts
Yun Chan bb0e54dcee feat(server): deliver push without a Firebase project
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.
2026-09-16 23:24:44 +09:00

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')
})