pushProtocol.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import {
  3. getGroupPushSubscriptions,
  4. getPushCapabilities,
  5. getPushSystemStatus,
  6. saveFCMPushSystemConfig,
  7. savePushSystemConfig,
  8. setGroupPushSubscription,
  9. } from '@/api/push'
  10. import { clearApiSecurityContext, setApiCsrfToken } from '@/api/client'
  11. afterEach(() => {
  12. clearApiSecurityContext()
  13. vi.unstubAllGlobals()
  14. })
  15. describe('push client protocol', () => {
  16. it('normalizes FCM provider status and protects configuration mutations', async () => {
  17. const calls: Array<{ path: string; init?: RequestInit }> = []
  18. vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
  19. calls.push({ path, init })
  20. return new Response(JSON.stringify({
  21. configured: false,
  22. healthy: false,
  23. registrationCount: 0,
  24. pendingCount: 0,
  25. source: 'none',
  26. editable: true,
  27. environments: [],
  28. warnings: [],
  29. providers: {
  30. fcm: {
  31. configured: true,
  32. healthy: true,
  33. registration_count: 2,
  34. pending_count: 1,
  35. source: 'file',
  36. editable: true,
  37. service_account_file: '/srv/secrets/firebase-service-account.json',
  38. project_id: 'yaoyao-test-project',
  39. package_name: 'cn.samien.yaoyao.hermes',
  40. warnings: [{ code: 'fcm_service_account_permissions', message: '建议调整为 0600' }],
  41. },
  42. },
  43. }), { status: 200, headers: { 'content-type': 'application/json' } })
  44. }))
  45. setApiCsrfToken('csrf-fcm')
  46. await expect(getPushSystemStatus()).resolves.toMatchObject({
  47. providers: { fcm: {
  48. configured: true,
  49. registrationCount: 2,
  50. pendingCount: 1,
  51. serviceAccountFile: '/srv/secrets/firebase-service-account.json',
  52. projectId: 'yaoyao-test-project',
  53. warnings: [{ code: 'fcm_service_account_permissions' }],
  54. } },
  55. })
  56. await expect(saveFCMPushSystemConfig({
  57. serviceAccountFile: '/srv/secrets/firebase-service-account.json',
  58. projectId: 'yaoyao-test-project',
  59. packageName: 'cn.samien.yaoyao.hermes',
  60. })).resolves.toMatchObject({ providers: { fcm: { configured: true } } })
  61. expect(calls.map(call => call.path)).toEqual([
  62. '/api/app/system/push-status',
  63. '/api/app/system/push-config/fcm',
  64. ])
  65. expect(new Headers(calls[1]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-fcm')
  66. expect(calls[1]!.init?.body).toBe(JSON.stringify({
  67. serviceAccountFile: '/srv/secrets/firebase-service-account.json',
  68. projectId: 'yaoyao-test-project',
  69. packageName: 'cn.samien.yaoyao.hermes',
  70. }))
  71. })
  72. it('uses the versioned push API and protects subscription mutations', async () => {
  73. const calls: Array<{ path: string; init?: RequestInit }> = []
  74. vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
  75. calls.push({ path, init })
  76. const payload = path === '/api/app/push/v1/capabilities'
  77. ? { protocolVersion: 1, enabled: true, events: [], maximumSummaryCharacters: 180 }
  78. : path === '/api/app/push/v1/group-subscriptions'
  79. ? { subscriptions: [{ roomId: 'room-1', enabled: true }] }
  80. : path === '/api/app/system/push-status'
  81. ? {
  82. configured: true,
  83. healthy: true,
  84. registration_count: 1,
  85. pending_count: 0,
  86. source: 'file',
  87. editable: true,
  88. key_file: '/srv/secrets/AuthKey_TEST.p8',
  89. key_id: 'KEY123',
  90. team_id: 'TEAM123',
  91. environments: ['development', 'production'],
  92. warnings: [{ code: 'apns_key_permissions', message: '建议调整为 0600', actual_mode: '0644', recommended_mode: '0600' }],
  93. }
  94. : path === '/api/app/system/push-config'
  95. ? {
  96. configured: true,
  97. healthy: true,
  98. registrationCount: 1,
  99. pendingCount: 0,
  100. source: 'file',
  101. editable: true,
  102. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  103. keyId: 'KEY123',
  104. teamId: 'TEAM123',
  105. topic: 'cn.samien.yaoyao.hermes',
  106. environments: ['development', 'production'],
  107. warnings: [],
  108. }
  109. : { subscription: { roomId: 'room-1', enabled: false } }
  110. return new Response(JSON.stringify(payload), {
  111. status: 200,
  112. headers: { 'content-type': 'application/json' },
  113. })
  114. }))
  115. setApiCsrfToken('csrf-push')
  116. await expect(getPushCapabilities()).resolves.toMatchObject({ protocolVersion: 1, enabled: true })
  117. await expect(getGroupPushSubscriptions()).resolves.toEqual(new Set(['room-1']))
  118. await expect(setGroupPushSubscription('room-1', false)).resolves.toBe(false)
  119. await expect(getPushSystemStatus()).resolves.toMatchObject({
  120. configured: true,
  121. registrationCount: 1,
  122. source: 'file',
  123. editable: true,
  124. managementAvailable: true,
  125. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  126. environments: ['development', 'production'],
  127. warnings: [{ code: 'apns_key_permissions', actualMode: '0644', recommendedMode: '0600' }],
  128. })
  129. await expect(savePushSystemConfig({
  130. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  131. keyId: 'KEY123',
  132. teamId: 'TEAM123',
  133. topic: 'cn.samien.yaoyao.hermes',
  134. environments: ['development', 'production'],
  135. })).resolves.toMatchObject({ configured: true, source: 'file', editable: true })
  136. expect(calls.map(call => call.path)).toEqual([
  137. '/api/app/push/v1/capabilities',
  138. '/api/app/push/v1/group-subscriptions',
  139. '/api/app/push/v1/group-subscriptions/room-1',
  140. '/api/app/system/push-status',
  141. '/api/app/system/push-config',
  142. ])
  143. expect(new Headers(calls[2]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-push')
  144. expect(new Headers(calls[4]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-push')
  145. expect(calls[4]!.init?.body).toBe(JSON.stringify({
  146. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  147. keyId: 'KEY123',
  148. teamId: 'TEAM123',
  149. topic: 'cn.samien.yaoyao.hermes',
  150. environments: ['development', 'production'],
  151. }))
  152. })
  153. it('keeps an old status response read-only instead of guessing its configuration source', async () => {
  154. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
  155. configured: true,
  156. healthy: true,
  157. registrationCount: 2,
  158. pendingCount: 0,
  159. topic: 'cn.samien.yaoyao.hermes',
  160. }), { status: 200, headers: { 'content-type': 'application/json' } })))
  161. await expect(getPushSystemStatus()).resolves.toMatchObject({
  162. configured: true,
  163. source: 'none',
  164. editable: false,
  165. managementAvailable: false,
  166. environments: [],
  167. warnings: [],
  168. })
  169. })
  170. it('normalizes startup configuration errors as the visible push error', async () => {
  171. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
  172. configured: false,
  173. healthy: false,
  174. source: 'environment',
  175. editable: false,
  176. configuration_error: 'APNs key is invalid',
  177. registration_count: 1,
  178. pending_count: 0,
  179. }), { status: 200, headers: { 'content-type': 'application/json' } })))
  180. await expect(getPushSystemStatus()).resolves.toMatchObject({
  181. configured: false,
  182. source: 'environment',
  183. lastError: 'APNs key is invalid',
  184. })
  185. })
  186. })