apns.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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. title: '## 完成 ✅',
  85. body: '**报告** [下载](https://example.com/private)',
  86. } } },
  87. collapseId: 'chat-1',
  88. })).resolves.toMatchObject({ disposition: 'success', apnsId: 'returned-id' })
  89. await provider.send({
  90. deviceToken: 'cd'.repeat(48),
  91. environment: 'production',
  92. payload: { aps: { alert: '完成' } },
  93. })
  94. expect(calls[0]!.endpoint.href).toBe(APNS_DEVELOPMENT_ENDPOINT.href)
  95. expect(calls[1]!.endpoint.href).toBe(APNS_PRODUCTION_ENDPOINT.href)
  96. expect(calls[0]!.headers[':path']).toBe(`/3/device/${'ab'.repeat(16)}`)
  97. expect(calls[0]!.headers['apns-topic']).toBe(config.topic)
  98. expect(calls[0]!.headers['apns-push-type']).toBe('alert')
  99. expect(calls[0]!.headers.authorization).toMatch(/^bearer /)
  100. expect(JSON.parse(Buffer.from(calls[0]!.body).toString('utf8'))).toEqual({
  101. aps: { alert: { title: '完成 ✅', body: '报告 下载' } },
  102. })
  103. })
  104. it('does not contact APNs for malformed or oversized requests and retries transport failures', async () => {
  105. const { config } = keyFixture()
  106. let calls = 0
  107. const provider = new APNsProvider(config, {
  108. transport: {
  109. async send() {
  110. calls += 1
  111. throw new Error('network unavailable')
  112. },
  113. },
  114. })
  115. await expect(provider.send({
  116. deviceToken: 'not-a-token', environment: 'production', payload: { aps: {} },
  117. })).resolves.toMatchObject({ disposition: 'unregister', reason: 'BadDeviceToken' })
  118. await expect(provider.send({
  119. deviceToken: 'ef'.repeat(16), environment: 'production', payload: { value: 'x'.repeat(5_000) },
  120. })).resolves.toMatchObject({ disposition: 'failed', reason: 'PayloadTooLarge' })
  121. await expect(provider.send({
  122. deviceToken: 'ef'.repeat(16), environment: 'production', payload: { aps: {} },
  123. })).resolves.toMatchObject({ disposition: 'retry', reason: 'network unavailable' })
  124. expect(calls).toBe(1)
  125. })
  126. it('never sends to an APNs environment excluded by the active configuration', async () => {
  127. const { config } = keyFixture()
  128. let calls = 0
  129. const provider = new APNsProvider({ ...config, environments: ['development'] }, {
  130. transport: {
  131. async send() {
  132. calls += 1
  133. return { status: 200 }
  134. },
  135. },
  136. })
  137. await expect(provider.send({
  138. deviceToken: 'ef'.repeat(32), environment: 'production', payload: { aps: {} },
  139. })).resolves.toMatchObject({ disposition: 'configuration', reason: 'EnvironmentNotConfigured' })
  140. expect(calls).toBe(0)
  141. })
  142. })
  143. describe('Node HTTP/2 APNs transport', () => {
  144. it('times out a half-open stream instead of blocking the outbox forever', async () => {
  145. const server = createServer()
  146. server.on('stream', () => {
  147. // Intentionally leave the stream open without response headers.
  148. })
  149. await new Promise<void>((resolve, reject) => {
  150. server.once('error', reject)
  151. server.listen(0, '127.0.0.1', resolve)
  152. })
  153. const port = (server.address() as AddressInfo).port
  154. const transport = new NodeHttp2APNsTransport(25)
  155. try {
  156. await expect(transport.send(
  157. new URL(`http://127.0.0.1:${port}`),
  158. { ':method': 'POST', ':path': '/3/device/token' },
  159. Buffer.from('{}'),
  160. )).rejects.toThrow(/timed out/)
  161. } finally {
  162. transport.close()
  163. await new Promise<void>(resolve => server.close(() => resolve()))
  164. }
  165. })
  166. })