apns.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { generateKeyPairSync, verify } from 'node:crypto'
  2. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  3. import { createServer } from 'node:http2'
  4. import type { AddressInfo } from 'node:net'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. import {
  9. APNS_DEVELOPMENT_ENDPOINT,
  10. APNS_PRODUCTION_ENDPOINT,
  11. APNsProvider,
  12. NodeHttp2APNsTransport,
  13. classifyAPNsResponse,
  14. createAPNsProviderToken,
  15. type APNsTransport,
  16. } from '../../src/server/apns.js'
  17. import type { APNsProviderConfig } from '../../src/server/config.js'
  18. const roots: string[] = []
  19. afterEach(() => {
  20. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  21. })
  22. function keyFixture(): { config: APNsProviderConfig; publicKey: ReturnType<typeof generateKeyPairSync>['publicKey'] } {
  23. const root = mkdtempSync(join(tmpdir(), 'yaoyao-apns-'))
  24. roots.push(root)
  25. const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
  26. const keyFile = join(root, 'AuthKey_TEST.p8')
  27. writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
  28. return {
  29. config: { keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes' },
  30. publicKey,
  31. }
  32. }
  33. describe('APNs provider token', () => {
  34. it('creates a verifiable ES256 JWT with the Apple key and team identifiers', () => {
  35. const { config, publicKey } = keyFixture()
  36. const now = Date.UTC(2026, 7, 28, 10, 0, 0)
  37. const token = createAPNsProviderToken(config, now)
  38. const [header, claims, signature] = token.split('.') as [string, string, string]
  39. expect(JSON.parse(Buffer.from(header, 'base64url').toString('utf8'))).toEqual({ alg: 'ES256', kid: config.keyId })
  40. expect(JSON.parse(Buffer.from(claims, 'base64url').toString('utf8'))).toEqual({
  41. iss: config.teamId,
  42. iat: Math.floor(now / 1_000),
  43. })
  44. expect(verify(
  45. 'sha256',
  46. Buffer.from(`${header}.${claims}`, 'ascii'),
  47. { key: publicKey, dsaEncoding: 'ieee-p1363' },
  48. Buffer.from(signature, 'base64url'),
  49. )).toBe(true)
  50. })
  51. })
  52. describe('APNs response classification', () => {
  53. it('separates retry, registration cleanup, provider configuration, and terminal failures', () => {
  54. expect(classifyAPNsResponse({ status: 200 }).disposition).toBe('success')
  55. expect(classifyAPNsResponse({ status: 410, body: '{"reason":"Unregistered","timestamp":123}' })).toMatchObject({
  56. disposition: 'unregister', reason: 'Unregistered', timestamp: 123,
  57. })
  58. expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadDeviceToken"}' }).disposition).toBe('unregister')
  59. expect(classifyAPNsResponse({ status: 403, body: '{"reason":"InvalidProviderToken"}' }).disposition).toBe('configuration')
  60. expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadCollapseId"}' }).disposition).toBe('failed')
  61. expect(classifyAPNsResponse({ status: 400, body: '{"reason":"DeviceTokenNotForTopic"}' }).disposition).toBe('unregister')
  62. expect(classifyAPNsResponse({ status: 400, body: '{"reason":"BadTopic"}' }).disposition).toBe('configuration')
  63. expect(classifyAPNsResponse({ status: 429, headers: { 'retry-after': '3' } })).toMatchObject({
  64. disposition: 'retry', retryAfterMs: 3_000,
  65. })
  66. expect(classifyAPNsResponse({ status: 503 }).disposition).toBe('retry')
  67. })
  68. })
  69. describe('APNsProvider', () => {
  70. it('sends alert payloads to the selected environment without assuming a fixed token length', async () => {
  71. const { config } = keyFixture()
  72. const calls: Array<{ endpoint: URL; headers: Record<string, unknown>; body: Uint8Array }> = []
  73. const transport: APNsTransport = {
  74. async send(endpoint, headers, body) {
  75. calls.push({ endpoint, headers: headers as Record<string, unknown>, body })
  76. return { status: 200, headers: { 'apns-id': 'returned-id' } }
  77. },
  78. }
  79. const provider = new APNsProvider(config, { transport, now: () => 1_777_777_777_000 })
  80. await expect(provider.send({
  81. deviceToken: 'ab'.repeat(16),
  82. environment: 'development',
  83. payload: { aps: { alert: '完成' } },
  84. collapseId: 'chat-1',
  85. })).resolves.toMatchObject({ disposition: 'success', apnsId: 'returned-id' })
  86. await provider.send({
  87. deviceToken: 'cd'.repeat(48),
  88. environment: 'production',
  89. payload: { aps: { alert: '完成' } },
  90. })
  91. expect(calls[0]!.endpoint.href).toBe(APNS_DEVELOPMENT_ENDPOINT.href)
  92. expect(calls[1]!.endpoint.href).toBe(APNS_PRODUCTION_ENDPOINT.href)
  93. expect(calls[0]!.headers[':path']).toBe(`/3/device/${'ab'.repeat(16)}`)
  94. expect(calls[0]!.headers['apns-topic']).toBe(config.topic)
  95. expect(calls[0]!.headers['apns-push-type']).toBe('alert')
  96. expect(calls[0]!.headers.authorization).toMatch(/^bearer /)
  97. expect(JSON.parse(Buffer.from(calls[0]!.body).toString('utf8'))).toEqual({ aps: { alert: '完成' } })
  98. })
  99. it('does not contact APNs for malformed or oversized requests and retries transport failures', async () => {
  100. const { config } = keyFixture()
  101. let calls = 0
  102. const provider = new APNsProvider(config, {
  103. transport: {
  104. async send() {
  105. calls += 1
  106. throw new Error('network unavailable')
  107. },
  108. },
  109. })
  110. await expect(provider.send({
  111. deviceToken: 'not-a-token', environment: 'production', payload: { aps: {} },
  112. })).resolves.toMatchObject({ disposition: 'unregister', reason: 'BadDeviceToken' })
  113. await expect(provider.send({
  114. deviceToken: 'ef'.repeat(16), environment: 'production', payload: { value: 'x'.repeat(5_000) },
  115. })).resolves.toMatchObject({ disposition: 'failed', reason: 'PayloadTooLarge' })
  116. await expect(provider.send({
  117. deviceToken: 'ef'.repeat(16), environment: 'production', payload: { aps: {} },
  118. })).resolves.toMatchObject({ disposition: 'retry', reason: 'network unavailable' })
  119. expect(calls).toBe(1)
  120. })
  121. })
  122. describe('Node HTTP/2 APNs transport', () => {
  123. it('times out a half-open stream instead of blocking the outbox forever', async () => {
  124. const server = createServer()
  125. server.on('stream', () => {
  126. // Intentionally leave the stream open without response headers.
  127. })
  128. await new Promise<void>((resolve, reject) => {
  129. server.once('error', reject)
  130. server.listen(0, '127.0.0.1', resolve)
  131. })
  132. const port = (server.address() as AddressInfo).port
  133. const transport = new NodeHttp2APNsTransport(25)
  134. try {
  135. await expect(transport.send(
  136. new URL(`http://127.0.0.1:${port}`),
  137. { ':method': 'POST', ':path': '/3/device/token' },
  138. Buffer.from('{}'),
  139. )).rejects.toThrow(/timed out/)
  140. } finally {
  141. transport.close()
  142. await new Promise<void>(resolve => server.close(() => resolve()))
  143. }
  144. })
  145. })