| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- import { generateKeyPairSync, verify } from 'node:crypto'
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
- import { createServer } from 'node:http2'
- import type { AddressInfo } from 'node:net'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { afterEach, describe, expect, it } from 'vitest'
- import {
- APNS_DEVELOPMENT_ENDPOINT,
- APNS_PRODUCTION_ENDPOINT,
- APNsProvider,
- NodeHttp2APNsTransport,
- classifyAPNsResponse,
- createAPNsProviderToken,
- type APNsTransport,
- } from '../../src/server/apns.js'
- import type { APNsProviderConfig } from '../../src/server/config.js'
- const roots: string[] = []
- afterEach(() => {
- for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
- })
- function keyFixture(): { config: APNsProviderConfig; publicKey: ReturnType<typeof generateKeyPairSync>['publicKey'] } {
- const root = mkdtempSync(join(tmpdir(), 'yaoyao-apns-'))
- roots.push(root)
- const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
- const keyFile = join(root, 'AuthKey_TEST.p8')
- writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
- return {
- config: { keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes' },
- publicKey,
- }
- }
- describe('APNs provider token', () => {
- it('creates a verifiable ES256 JWT with the Apple key and team identifiers', () => {
- const { config, publicKey } = keyFixture()
- const now = Date.UTC(2026, 7, 28, 10, 0, 0)
- const token = createAPNsProviderToken(config, now)
- const [header, claims, signature] = token.split('.') as [string, string, string]
- expect(JSON.parse(Buffer.from(header, 'base64url').toString('utf8'))).toEqual({ alg: 'ES256', kid: config.keyId })
- expect(JSON.parse(Buffer.from(claims, 'base64url').toString('utf8'))).toEqual({
- iss: config.teamId,
- iat: Math.floor(now / 1_000),
- })
- expect(verify(
- 'sha256',
- Buffer.from(`${header}.${claims}`, 'ascii'),
- { key: publicKey, dsaEncoding: 'ieee-p1363' },
- Buffer.from(signature, 'base64url'),
- )).toBe(true)
- })
- })
- describe('APNs response classification', () => {
- it('separates retry, registration cleanup, provider configuration, and terminal failures', () => {
- expect(classifyAPNsResponse({ status: 200 }).disposition).toBe('success')
- expect(classifyAPNsResponse({ status: 410, body: '{"reason":"Unregistered","timestamp":123}' })).toMatchObject({
- disposition: 'unregister', reason: 'Unregistered', timestamp: 123,
- })
- expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadDeviceToken"}' }).disposition).toBe('unregister')
- expect(classifyAPNsResponse({ status: 403, body: '{"reason":"InvalidProviderToken"}' }).disposition).toBe('configuration')
- expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadCollapseId"}' }).disposition).toBe('failed')
- expect(classifyAPNsResponse({ status: 400, body: '{"reason":"DeviceTokenNotForTopic"}' }).disposition).toBe('unregister')
- expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadTopic"}' }).disposition).toBe('configuration')
- expect(classifyAPNsResponse({ status: 429, headers: { 'retry-after': '3' } })).toMatchObject({
- disposition: 'retry', retryAfterMs: 3_000,
- })
- expect(classifyAPNsResponse({ status: 503 }).disposition).toBe('retry')
- })
- })
- describe('APNsProvider', () => {
- it('sends alert payloads to the selected environment without assuming a fixed token length', async () => {
- const { config } = keyFixture()
- const calls: Array<{ endpoint: URL; headers: Record<string, unknown>; body: Uint8Array }> = []
- const transport: APNsTransport = {
- async send(endpoint, headers, body) {
- calls.push({ endpoint, headers: headers as Record<string, unknown>, body })
- return { status: 200, headers: { 'apns-id': 'returned-id' } }
- },
- }
- const provider = new APNsProvider(config, { transport, now: () => 1_777_777_777_000 })
- await expect(provider.send({
- deviceToken: 'ab'.repeat(16),
- environment: 'development',
- payload: { aps: { alert: {
- title: '## 完成 ✅',
- body: '**报告** [下载](https://example.com/private)',
- } } },
- collapseId: 'chat-1',
- })).resolves.toMatchObject({ disposition: 'success', apnsId: 'returned-id' })
- await provider.send({
- deviceToken: 'cd'.repeat(48),
- environment: 'production',
- payload: { aps: { alert: '完成' } },
- })
- expect(calls[0]!.endpoint.href).toBe(APNS_DEVELOPMENT_ENDPOINT.href)
- expect(calls[1]!.endpoint.href).toBe(APNS_PRODUCTION_ENDPOINT.href)
- expect(calls[0]!.headers[':path']).toBe(`/3/device/${'ab'.repeat(16)}`)
- expect(calls[0]!.headers['apns-topic']).toBe(config.topic)
- expect(calls[0]!.headers['apns-push-type']).toBe('alert')
- expect(calls[0]!.headers.authorization).toMatch(/^bearer /)
- expect(JSON.parse(Buffer.from(calls[0]!.body).toString('utf8'))).toEqual({
- aps: { alert: { title: '完成 ✅', body: '报告 下载' } },
- })
- })
- it('does not contact APNs for malformed or oversized requests and retries transport failures', async () => {
- const { config } = keyFixture()
- let calls = 0
- const provider = new APNsProvider(config, {
- transport: {
- async send() {
- calls += 1
- throw new Error('network unavailable')
- },
- },
- })
- await expect(provider.send({
- deviceToken: 'not-a-token', environment: 'production', payload: { aps: {} },
- })).resolves.toMatchObject({ disposition: 'unregister', reason: 'BadDeviceToken' })
- await expect(provider.send({
- deviceToken: 'ef'.repeat(16), environment: 'production', payload: { value: 'x'.repeat(5_000) },
- })).resolves.toMatchObject({ disposition: 'failed', reason: 'PayloadTooLarge' })
- await expect(provider.send({
- deviceToken: 'ef'.repeat(16), environment: 'production', payload: { aps: {} },
- })).resolves.toMatchObject({ disposition: 'retry', reason: 'network unavailable' })
- expect(calls).toBe(1)
- })
- it('never sends to an APNs environment excluded by the active configuration', async () => {
- const { config } = keyFixture()
- let calls = 0
- const provider = new APNsProvider({ ...config, environments: ['development'] }, {
- transport: {
- async send() {
- calls += 1
- return { status: 200 }
- },
- },
- })
- await expect(provider.send({
- deviceToken: 'ef'.repeat(32), environment: 'production', payload: { aps: {} },
- })).resolves.toMatchObject({ disposition: 'configuration', reason: 'EnvironmentNotConfigured' })
- expect(calls).toBe(0)
- })
- })
- describe('Node HTTP/2 APNs transport', () => {
- it('times out a half-open stream instead of blocking the outbox forever', async () => {
- const server = createServer()
- server.on('stream', () => {
- // Intentionally leave the stream open without response headers.
- })
- await new Promise<void>((resolve, reject) => {
- server.once('error', reject)
- server.listen(0, '127.0.0.1', resolve)
- })
- const port = (server.address() as AddressInfo).port
- const transport = new NodeHttp2APNsTransport(25)
- try {
- await expect(transport.send(
- new URL(`http://127.0.0.1:${port}`),
- { ':method': 'POST', ':path': '/3/device/token' },
- Buffer.from('{}'),
- )).rejects.toThrow(/timed out/)
- } finally {
- transport.close()
- await new Promise<void>(resolve => server.close(() => resolve()))
- }
- })
- })
|