// server/supabase/functions/_shared/webpush.test.ts // RFC 8291 (aes128gcm) + VAPID transport tests. Firebase-free web push. import { encryptWebPushPayload, parseWebPushSubscription, readWebPushConfig, sendWebPushMessage, type WebPushConfig, type WebPushSubscription, } from './webpush.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}`) } } const encoder = new TextEncoder() function b64url(bytes: Uint8Array): string { let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') } function unb64url(value: string): Uint8Array { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) const binary = atob(padded) return Uint8Array.from(binary, (character) => character.charCodeAt(0)) } function concat(...parts: Uint8Array[]): Uint8Array { const output = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)) let offset = 0 for (const part of parts) { output.set(part, offset) offset += part.byteLength } return output } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const buffer = new ArrayBuffer(bytes.byteLength) new Uint8Array(buffer).set(bytes) return buffer } async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { const imported = await crypto.subtle.importKey( 'raw', toArrayBuffer(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ) return new Uint8Array(await crypto.subtle.sign('HMAC', imported, toArrayBuffer(data))) } async function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number): Promise { const blocks = Math.ceil(length / 32) const output = new Uint8Array(blocks * 32) let previous = new Uint8Array(0) for (let index = 0; index < blocks; index += 1) { const block = await hmacSha256(prk, concat(previous, info, Uint8Array.of(index + 1))) output.set(block, index * 32) previous = new Uint8Array(block) } return output.slice(0, length) } async function makeVapidConfig(): Promise { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ) const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keys.publicKey)) const jwk = await crypto.subtle.exportKey('jwk', keys.privateKey) assert(typeof jwk.d === 'string', 'private scalar must be exportable') return { publicKey: b64url(publicRaw), privateKey: jwk.d, subject: 'mailto:push@d3ro.test', } } async function makeSubscription(): Promise<{ registrationId: string; subscription: WebPushSubscription; privateKey: CryptoKey; publicRaw: Uint8Array }> { const uaKeys = await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'], ) const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey)) const auth = crypto.getRandomValues(new Uint8Array(16)) const subscription: WebPushSubscription = { endpoint: 'https://push.example.com/subscriptions/abc123', p256dh: b64url(publicRaw), auth: b64url(auth), } return { registrationId: JSON.stringify({ endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }), subscription, privateKey: uaKeys.privateKey, publicRaw, } } 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('web push config requires valid VAPID credentials', () => { const empty = () => undefined assertPushError(() => readWebPushConfig(empty), 'webpush_not_configured', 503) const malformed = (name: string) => ({ WEBPUSH_VAPID_PUBLIC_KEY: 'not-a-key', WEBPUSH_VAPID_PRIVATE_KEY: 'also-bad', WEBPUSH_SUBJECT: 'mailto:push@d3ro.test', })[name] assertPushError(() => readWebPushConfig(malformed), 'webpush_credentials_invalid', 503) }) Deno.test('web push subscription parsing is strict', async () => { const { registrationId, subscription } = await makeSubscription() const parsed = parseWebPushSubscription(registrationId) assert(parsed.endpoint === subscription.endpoint, 'endpoint preserved') assert(parsed.p256dh === subscription.p256dh, 'p256dh preserved') assertPushError(() => parseWebPushSubscription('short'), 'webpush_registration_invalid', 400, true) assertPushError( () => parseWebPushSubscription(JSON.stringify({ endpoint: 'http://insecure.example.com', keys: { p256dh: subscription.p256dh, auth: subscription.auth } })), 'webpush_registration_invalid', 400, true, ) }) Deno.test('aes128gcm encryption round-trips per RFC 8291', async () => { const { registrationId, subscription, privateKey, publicRaw } = await makeSubscription() const fixedSalt = crypto.getRandomValues(new Uint8Array(16)) const payload = encoder.encode('hello web push') const body = await encryptWebPushPayload( parseWebPushSubscription(registrationId), payload, () => fixedSalt, ) // Header: salt(16) | rs(4) | idlen(1) | keyid(65) assert(body.byteLength === 16 + 4 + 1 + 65 + payload.byteLength + 1 + 16, 'body length matches aes128gcm') const salt = body.slice(0, 16) const recordSize = new DataView(body.buffer, body.byteOffset + 16, 4).getUint32(0, false) assert(recordSize === 4096, 'record size is 4096') assert(body[20] === 65, 'key id length is 65') const asPublic = body.slice(21, 86) const ciphertext = body.slice(86) assert(salt.every((byte, index) => byte === fixedSalt[index]), 'salt preserved') // Decrypt as the user agent would. const asKey = await crypto.subtle.importKey( 'raw', toArrayBuffer(asPublic), { name: 'ECDH', namedCurve: 'P-256' }, false, [], ) const shared = new Uint8Array( await crypto.subtle.deriveBits({ name: 'ECDH', public: asKey }, privateKey, 256), ) const prkKey = await hmacSha256(unb64url(subscription.auth), shared) const keyInfo = concat(encoder.encode('WebPush: info\x00'), publicRaw, asPublic) const ikm = await hkdfExpand(prkKey, keyInfo, 32) const prk = await hmacSha256(salt, ikm) const cek = await hkdfExpand(prk, encoder.encode('Content-Encoding: aes128gcm\x00'), 16) const nonce = await hkdfExpand(prk, encoder.encode('Content-Encoding: nonce\x00'), 12) const aesKey = await crypto.subtle.importKey('raw', toArrayBuffer(cek), 'AES-GCM', false, ['decrypt']) const plaintext = new Uint8Array( await crypto.subtle.decrypt( { name: 'AES-GCM', iv: toArrayBuffer(nonce), tagLength: 128 }, aesKey, toArrayBuffer(ciphertext), ), ) const decoded = plaintext.slice(0, plaintext.byteLength - 1) assert(plaintext[plaintext.byteLength - 1] === 0x02, 'last-record delimiter is 0x02') assert(new TextDecoder().decode(decoded) === 'hello web push', 'payload round-trips') }) Deno.test('web push send maps provider responses', async () => { const { registrationId } = await makeSubscription() const config = await makeVapidConfig() const okFetch = (() => Promise.resolve(new Response(null, { status: 201 }))) as unknown as typeof fetch const result = await sendWebPushMessage(registrationId, notification, { config, fetchImpl: okFetch }) assert(result.status === 201, 'created status returned') const staleFetch = (() => Promise.resolve(new Response(null, { status: 410 }))) as unknown as typeof fetch await assertPushError( () => sendWebPushMessage(registrationId, notification, { config, fetchImpl: staleFetch }), 'webpush_registration_stale', 410, true, ) const tooLarge = (() => Promise.resolve(new Response(null, { status: 413 }))) as unknown as typeof fetch await assertPushError( () => sendWebPushMessage(registrationId, notification, { config, fetchImpl: tooLarge }), 'webpush_payload_too_large', 500, ) const failed = (() => Promise.resolve(new Response(null, { status: 500 }))) as unknown as typeof fetch await assertPushError( () => sendWebPushMessage(registrationId, notification, { config, fetchImpl: failed }), 'webpush_send_failed', 502, ) }) Deno.test('web push send rejects a malformed registration id', async () => { const config = await makeVapidConfig() await assertPushError( () => sendWebPushMessage('not-json-registration-id', notification, { config }), 'webpush_registration_invalid', 400, true, ) })