fcm.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import {
  7. classifyFCMResponse,
  8. createFCMServiceAccountJWT,
  9. FCMProbeError,
  10. FCMProvider,
  11. type FCMTransport,
  12. } from '../../src/server/fcm.js'
  13. const roots: string[] = []
  14. afterEach(() => {
  15. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  16. })
  17. function serviceAccount(): { path: string; privateKey: string } {
  18. const root = mkdtempSync(join(tmpdir(), 'yaoyao-fcm-'))
  19. roots.push(root)
  20. const { privateKey } = generateKeyPairSync('rsa', {
  21. modulusLength: 2048,
  22. privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  23. publicKeyEncoding: { type: 'spki', format: 'pem' },
  24. })
  25. const path = join(root, 'service-account.json')
  26. writeFileSync(path, JSON.stringify({
  27. type: 'service_account',
  28. project_id: 'yaoyao-test-project',
  29. client_email: 'push@yaoyao-test-project.iam.gserviceaccount.com',
  30. private_key: privateKey,
  31. token_uri: 'https://oauth2.googleapis.com/token',
  32. }), { mode: 0o600 })
  33. return { path, privateKey }
  34. }
  35. describe('FCM HTTP v1 provider', () => {
  36. it('creates a short-lived RS256 OAuth assertion with the messaging scope', () => {
  37. const { privateKey } = serviceAccount()
  38. const jwt = createFCMServiceAccountJWT({
  39. clientEmail: 'push@example.test',
  40. privateKey,
  41. tokenUri: 'https://oauth2.googleapis.com/token',
  42. }, 1_800_000)
  43. const [header, claims] = jwt.split('.').slice(0, 2).map(value => (
  44. JSON.parse(Buffer.from(value!, 'base64url').toString('utf8')) as Record<string, unknown>
  45. ))
  46. expect(header).toMatchObject({ alg: 'RS256', typ: 'JWT' })
  47. expect(claims).toMatchObject({
  48. iss: 'push@example.test',
  49. aud: 'https://oauth2.googleapis.com/token',
  50. scope: 'https://www.googleapis.com/auth/firebase.messaging',
  51. iat: 1_800,
  52. exp: 5_400,
  53. })
  54. })
  55. it('uses fid data-only delivery, package restriction, validate_only, and cached OAuth', async () => {
  56. const account = serviceAccount()
  57. const requests: Array<{ url: URL; init: RequestInit }> = []
  58. const transport: FCMTransport = {
  59. request: async (url, init) => {
  60. requests.push({ url: new URL(url), init })
  61. if (url.hostname === 'oauth2.googleapis.com') {
  62. return { status: 200, body: JSON.stringify({ access_token: 'oauth-access-token', expires_in: 3_600 }) }
  63. }
  64. return { status: 200, body: JSON.stringify({ name: 'projects/test/messages/1' }) }
  65. },
  66. }
  67. const provider = new FCMProvider({
  68. serviceAccountFile: account.path,
  69. projectId: 'yaoyao-test-project',
  70. packageName: 'cn.samien.yaoyao.hermes',
  71. }, { transport, now: () => 1_800_000 })
  72. await expect(provider.send({
  73. fid: 'fcm-registration-id-1234567890',
  74. data: { eventId: 'event-1', collapseId: 'chat:1' },
  75. collapseId: 'must-not-be-sent-as-android-collapse-key',
  76. ttlSeconds: 30,
  77. })).resolves.toMatchObject({ disposition: 'success' })
  78. await expect(provider.send({ fid: 'fcm-registration-id-2', data: { eventId: 'event-2' } }))
  79. .resolves.toMatchObject({ disposition: 'success' })
  80. expect(requests.filter(item => item.url.hostname === 'oauth2.googleapis.com')).toHaveLength(1)
  81. const delivery = requests.find(item => item.url.hostname === 'fcm.googleapis.com')!
  82. const body = JSON.parse(String(delivery.init.body)) as Record<string, any>
  83. expect(delivery.url.pathname).toBe('/v1/projects/yaoyao-test-project/messages:send')
  84. expect(body.message).toMatchObject({
  85. fid: 'fcm-registration-id-1234567890',
  86. data: { eventId: 'event-1', collapseId: 'chat:1' },
  87. android: {
  88. priority: 'HIGH',
  89. ttl: '30s',
  90. restricted_package_name: 'cn.samien.yaoyao.hermes',
  91. },
  92. })
  93. expect(body.message).not.toHaveProperty('token')
  94. expect(body.message.android).not.toHaveProperty('collapse_key')
  95. expect(body).not.toHaveProperty('validate_only')
  96. requests.length = 0
  97. const probeTransport: FCMTransport = {
  98. request: async (url, init) => {
  99. requests.push({ url: new URL(url), init })
  100. if (url.hostname === 'oauth2.googleapis.com') {
  101. return { status: 200, body: JSON.stringify({ access_token: 'probe-token', expires_in: 3_600 }) }
  102. }
  103. return {
  104. status: 400,
  105. body: JSON.stringify({ error: { status: 'INVALID_ARGUMENT', details: [{ errorCode: 'INVALID_ARGUMENT' }] } }),
  106. }
  107. },
  108. }
  109. const probe = new FCMProvider(provider.config, { transport: probeTransport, now: () => 1_800_000 })
  110. await expect(probe.probe()).resolves.toBeUndefined()
  111. expect(requests.at(-1)!.url.searchParams.has('validate_only')).toBe(false)
  112. expect(JSON.parse(String(requests.at(-1)!.init.body))).toMatchObject({ validate_only: true })
  113. const successfulProbe = new FCMProvider(provider.config, {
  114. transport: {
  115. request: async (url) => url.hostname === 'oauth2.googleapis.com'
  116. ? { status: 200, body: JSON.stringify({ access_token: 'probe-token', expires_in: 3_600 }) }
  117. : { status: 200, body: JSON.stringify({ name: 'projects/test/messages/probe' }) },
  118. },
  119. now: () => 1_800_000,
  120. })
  121. await expect(successfulProbe.probe()).resolves.toBeUndefined()
  122. const unavailableProbe = new FCMProvider(provider.config, {
  123. transport: {
  124. request: async (url) => url.hostname === 'oauth2.googleapis.com'
  125. ? { status: 200, body: JSON.stringify({ access_token: 'probe-token', expires_in: 3_600 }) }
  126. : { status: 503, body: '{}' },
  127. },
  128. now: () => 1_800_000,
  129. })
  130. await expect(unavailableProbe.probe()).rejects.toBeInstanceOf(FCMProbeError)
  131. })
  132. it('retries throttling, removes only explicit invalid targets, and rejects oversized data locally', async () => {
  133. expect(classifyFCMResponse({ status: 404, body: '{}' })).toMatchObject({ disposition: 'failed' })
  134. expect(classifyFCMResponse({
  135. status: 404,
  136. body: JSON.stringify({ error: { status: 'NOT_FOUND', details: [{ errorCode: 'UNREGISTERED' }] } }),
  137. })).toMatchObject({ disposition: 'unregister', reason: 'UNREGISTERED' })
  138. expect(classifyFCMResponse({ status: 429, headers: { 'retry-after': '2' }, body: '{}' }))
  139. .toMatchObject({ disposition: 'retry', retryAfterMs: 2_000 })
  140. expect(classifyFCMResponse({ status: 429, body: '{}' }))
  141. .toMatchObject({ disposition: 'retry', retryAfterMs: 60_000 })
  142. expect(classifyFCMResponse({ status: 503, body: '{}' }))
  143. .toMatchObject({ disposition: 'retry', retryAfterMs: 10_000 })
  144. const account = serviceAccount()
  145. let contacted = false
  146. const provider = new FCMProvider({
  147. serviceAccountFile: account.path,
  148. projectId: 'yaoyao-test-project',
  149. packageName: 'cn.samien.yaoyao.hermes',
  150. }, { transport: { request: async () => { contacted = true; return { status: 500 } } } })
  151. await expect(provider.send({ fid: 'fcm-registration-id-1234', data: { value: 'x'.repeat(4_097) } }))
  152. .resolves.toMatchObject({ disposition: 'failed', reason: 'PayloadTooLarge' })
  153. expect(contacted).toBe(false)
  154. await expect(provider.send({ fid: 'fcm-registration-id-1234', data: { 'google.internal': 'value' } }))
  155. .resolves.toMatchObject({ disposition: 'failed', reason: 'ReservedDataKey' })
  156. expect(contacted).toBe(false)
  157. })
  158. })