d3ro-voice/server/supabase/functions/_shared/webpush.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

251 lines
9.2 KiB
TypeScript

// 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<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}`)
}
}
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<Uint8Array> {
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<Uint8Array> {
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<WebPushConfig> {
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,
)
})