pushCoordinator.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, 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 type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
  7. import { loadServerConfig, type APNsProviderConfig } from '../../src/server/config.js'
  8. import {
  9. PushCoordinator,
  10. type PushSender,
  11. } from '../../src/server/pushCoordinator.js'
  12. const roots: string[] = []
  13. afterEach(() => {
  14. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  15. })
  16. function root(): string {
  17. const value = mkdtempSync(join(tmpdir(), 'yaoyao-push-'))
  18. roots.push(value)
  19. return value
  20. }
  21. const apns: APNsProviderConfig = {
  22. keyFile: '/private/not-read-when-provider-is-injected.p8',
  23. keyId: 'KEY1234567',
  24. teamId: 'TEAM123456',
  25. topic: 'cn.samien.yaoyao.hermes',
  26. }
  27. class FakeSender implements PushSender {
  28. readonly requests: APNsRequest[] = []
  29. readonly results: APNsSendResult[] = []
  30. closed = false
  31. async send(request: APNsRequest): Promise<APNsSendResult> {
  32. this.requests.push(request)
  33. return this.results.shift() ?? { disposition: 'success', status: 200 }
  34. }
  35. close(): void { this.closed = true }
  36. }
  37. function register(coordinator: PushCoordinator, suffix = '1') {
  38. return coordinator.registerInstallation({
  39. userId: 'user-a',
  40. installationId: `phone-${suffix}`,
  41. clientAccountId: 'account-a',
  42. deviceToken: 'ab'.repeat(16 + Number(suffix)),
  43. environment: 'development',
  44. appVersion: '1.2.3',
  45. })
  46. }
  47. describe('push configuration', () => {
  48. it('keeps APNs optional and reports partial configuration without preventing startup', () => {
  49. expect(loadServerConfig({}).apns).toBeUndefined()
  50. const partial = loadServerConfig({ HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567' })
  51. expect(partial.apns).toBeUndefined()
  52. expect(partial.apnsConfigurationError).toMatch(/must be set together/)
  53. })
  54. it('loads a complete provider configuration and the default topic', () => {
  55. const directory = root()
  56. const keyFile = join(directory, 'AuthKey.p8')
  57. const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
  58. writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
  59. const config = loadServerConfig({
  60. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  61. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  62. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  63. })
  64. expect(config.apns).toEqual({
  65. keyFile,
  66. keyId: 'KEY1234567',
  67. teamId: 'TEAM123456',
  68. topic: 'cn.samien.yaoyao.hermes',
  69. })
  70. })
  71. it('disables an unreadable provider key as a capability error', () => {
  72. const keyFile = join(root(), 'AuthKey.p8')
  73. writeFileSync(keyFile, 'not a private key')
  74. const config = loadServerConfig({
  75. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  76. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  77. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  78. })
  79. expect(config.apns).toBeUndefined()
  80. expect(config.apnsConfigurationError).toMatch(/ES256 private key/)
  81. })
  82. })
  83. describe('PushCoordinator durable state and delivery', () => {
  84. it('persists private registrations, subscriptions, outbox delivery, and badge state', async () => {
  85. const home = root()
  86. const sender = new FakeSender()
  87. const coordinator = new PushCoordinator({ home, apns, provider: sender, autoFlush: false })
  88. expect(coordinator.capabilities()).toMatchObject({
  89. protocolVersion: 1, enabled: true, topic: apns.topic, maxSummaryCharacters: 180,
  90. })
  91. expect(register(coordinator)).not.toHaveProperty('deviceToken')
  92. expect(coordinator.setGroupSubscription('user-a', 'room-1', true)).toMatchObject({ roomId: 'room-1', enabled: true })
  93. expect(coordinator.isGroupSubscribed('user-a', 'room-1')).toBe(true)
  94. expect(coordinator.subscribedUserIds('room-1')).toEqual(['user-a'])
  95. expect(coordinator.enqueue({
  96. eventId: 'event-1',
  97. userId: 'user-a',
  98. kind: 'group.message.completed',
  99. title: '任务完成',
  100. body: '好'.repeat(250),
  101. roomId: 'room-1',
  102. threadId: 'topic-1',
  103. collapseId: 'message-1',
  104. data: { roomId: 'room-1', aps: 'untrusted', version: 99 },
  105. })).toBe(1)
  106. expect(coordinator.enqueue({
  107. eventId: 'event-1', userId: 'user-a', kind: 'group.message.completed', title: '重复', body: '重复',
  108. })).toBe(0)
  109. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1, topic: apns.topic })
  110. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  111. expect(sender.requests).toHaveLength(1)
  112. expect(sender.requests[0]!.payload).toMatchObject({
  113. version: 1,
  114. eventId: 'event-1',
  115. kind: 'group.message.completed',
  116. clientAccountId: 'account-a',
  117. aps: { alert: { title: '任务完成' }, badge: 1, 'thread-id': 'topic-1' },
  118. })
  119. expect(Array.from(((sender.requests[0]!.payload.aps as { alert: { body: string } }).alert.body))).toHaveLength(180)
  120. expect(sender.requests[0]!.payload).not.toHaveProperty('userId')
  121. expect(coordinator.status()).toMatchObject({ healthy: true, pendingCount: 0 })
  122. const statePath = join(home, 'push', 'state.json')
  123. expect(statSync(statePath).mode & 0o777).toBe(0o600)
  124. const stored = readFileSync(statePath, 'utf8')
  125. expect(stored).toContain('ab'.repeat(17))
  126. expect(stored).not.toContain(apns.keyFile)
  127. coordinator.close()
  128. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  129. expect(restored.status()).toMatchObject({ registrationCount: 1, subscriptionCount: 1 })
  130. expect(restored.listGroupSubscriptions('user-a')).toEqual([
  131. expect.objectContaining({ roomId: 'room-1', enabled: true }),
  132. ])
  133. restored.close()
  134. })
  135. it('retries transient failures exponentially and removes invalid registrations', async () => {
  136. const home = root()
  137. let now = 1_000_000
  138. const sender = new FakeSender()
  139. sender.results.push(
  140. { disposition: 'retry', status: 503, reason: 'Shutdown' },
  141. { disposition: 'unregister', status: 410, reason: 'Unregistered' },
  142. )
  143. const coordinator = new PushCoordinator({
  144. home, apns, provider: sender, autoFlush: false, now: () => now, baseRetryMilliseconds: 100,
  145. })
  146. register(coordinator)
  147. coordinator.enqueue({ eventId: 'retry-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  148. await expect(coordinator.flushDue()).resolves.toMatchObject({ retried: 1 })
  149. expect(coordinator.status()).toMatchObject({ pendingCount: 1, healthy: false })
  150. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 0, retried: 0 })
  151. expect(sender.requests).toHaveLength(1)
  152. now += 100
  153. await expect(coordinator.flushDue()).resolves.toMatchObject({ removedRegistrations: 1 })
  154. expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
  155. coordinator.close()
  156. })
  157. it('does not delete a token that rotated while an APNs request was in flight', async () => {
  158. let release!: (result: APNsSendResult) => void
  159. let started!: () => void
  160. const began = new Promise<void>(resolve => { started = resolve })
  161. const first = new Promise<APNsSendResult>(resolve => { release = resolve })
  162. const requests: APNsRequest[] = []
  163. const sender: PushSender = {
  164. send: async request => {
  165. requests.push(request)
  166. if (requests.length === 1) {
  167. started()
  168. return first
  169. }
  170. return { disposition: 'success', status: 200 }
  171. },
  172. }
  173. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender, autoFlush: false })
  174. register(coordinator)
  175. coordinator.enqueue({ eventId: 'rotated-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  176. const flushing = coordinator.flushDue()
  177. await began
  178. coordinator.registerInstallation({
  179. userId: 'user-a', installationId: 'phone-1', clientAccountId: 'account-a',
  180. deviceToken: 'cd'.repeat(32), environment: 'development', appVersion: '1.2.4',
  181. })
  182. release({ disposition: 'unregister', status: 410, reason: 'Unregistered' })
  183. await expect(flushing).resolves.toMatchObject({ retried: 1, removedRegistrations: 0 })
  184. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
  185. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  186. expect(requests[0]!.deviceToken).not.toBe(requests[1]!.deviceToken)
  187. expect(requests[1]!.deviceToken).toBe('cd'.repeat(32))
  188. coordinator.close()
  189. })
  190. it('fails closed when a user is disabled or their authentication version changes', async () => {
  191. let active = true
  192. let authVersion = 1
  193. const sender = new FakeSender()
  194. const coordinator = new PushCoordinator({
  195. home: root(), apns, provider: sender, autoFlush: false,
  196. isUserActive: () => active,
  197. userAuthorizationVersion: () => authVersion,
  198. })
  199. coordinator.registerInstallation({
  200. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  201. deviceToken: 'ab'.repeat(32), environment: 'development', authorizationVersion: 1,
  202. })
  203. coordinator.enqueue({ eventId: 'version-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  204. authVersion = 2
  205. await expect(coordinator.flushDue()).resolves.toMatchObject({ failed: 1, delivered: 0 })
  206. expect(sender.requests).toHaveLength(0)
  207. expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
  208. coordinator.registerInstallation({
  209. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  210. deviceToken: 'cd'.repeat(32), environment: 'development', authorizationVersion: 2,
  211. })
  212. active = false
  213. expect(coordinator.enqueue({
  214. eventId: 'disabled', userId: 'user-a', kind: 'chat.completed', title: '不应发送', body: '正文',
  215. })).toBe(0)
  216. coordinator.close()
  217. })
  218. it('cancels pending team notifications on unsubscribe and removes all user-owned state', () => {
  219. const home = root()
  220. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  221. register(coordinator)
  222. coordinator.setGroupSubscription('user-a', 'room-1', true)
  223. expect(coordinator.enqueueGroupEvent({
  224. eventID: 'group-1', localUserID: 'user-a', kind: 'group.message.completed',
  225. title: '团队消息', body: '正文', collapseID: 'group-1', data: {}, roomID: 'room-1',
  226. })).toBe('enqueued')
  227. coordinator.setGroupSubscription('user-a', 'room-1', false)
  228. expect(coordinator.status().pendingCount).toBe(0)
  229. coordinator.setGroupSubscription('user-a', 'room-2', true)
  230. const removed = coordinator.removeUser('user-a')
  231. expect(removed).toMatchObject({ registrations: 1, subscriptions: 1 })
  232. expect(coordinator.status()).toMatchObject({ registrationCount: 0, subscriptionCount: 0 })
  233. expect(coordinator.listGroupSubscriptions('user-a')).toEqual([])
  234. coordinator.close()
  235. })
  236. it('persists the global group cursor and resumable chat jobs in the same state file', () => {
  237. const home = root()
  238. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  239. expect(coordinator.resetGroupCursor({ epoch: 'epoch-1', cursor: 10, reason: 'initial' })).toMatchObject({
  240. epoch: 'epoch-1', cursor: 10,
  241. })
  242. coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 11 })
  243. expect(() => coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 9 })).toThrow(/backwards/)
  244. coordinator.saveChatJob({
  245. id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  246. storedSessionID: 'stored-1', requestID: 'request-1', queued: false, phase: 'watching',
  247. submittedAt: 1, expiresAt: Date.now() + 60_000,
  248. })
  249. coordinator.close()
  250. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  251. expect(restored.groupWatchAnchor()).toMatchObject({ epoch: 'epoch-1', cursor: 11 })
  252. expect(restored.pendingChatJobs()).toEqual([expect.objectContaining({ id: 'job-1', localUserID: 'user-a' })])
  253. restored.completeChatJob('job-1')
  254. expect(restored.pendingChatJobs()).toEqual([])
  255. restored.close()
  256. })
  257. it('fails closed when more than one local user has written the same stored session', () => {
  258. const coordinator = new PushCoordinator({ home: root(), apns, provider: new FakeSender(), autoFlush: false })
  259. const job = (id: string, localUserID: string) => ({
  260. id, localUserID, profile: 'default', runtimeSessionID: `runtime-${id}`, storedSessionID: 'shared',
  261. requestID: `request-${id}`, queued: false, phase: 'accepted' as const,
  262. submittedAt: 1, expiresAt: Date.now() + 60_000,
  263. })
  264. const first = job('a', 'user-a')
  265. coordinator.saveChatJob(first)
  266. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(true)
  267. const second = job('b', 'user-b')
  268. coordinator.saveChatJob(second)
  269. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
  270. expect(coordinator.chatJobRecoveryAllowed(second)).toBe(false)
  271. coordinator.completeChatJob('b')
  272. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
  273. coordinator.close()
  274. })
  275. it('keeps prompt correlation private and stable across restart', () => {
  276. const home = root()
  277. const first = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  278. const digest = first.promptDigest('user-a', '继续执行')
  279. expect(digest).not.toContain('继续执行')
  280. expect(first.promptDigest('user-b', '继续执行')).not.toBe(digest)
  281. first.close()
  282. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  283. expect(restored.promptDigest('user-a', '继续执行')).toBe(digest)
  284. expect(statSync(join(home, 'push', 'correlation.key')).mode & 0o777).toBe(0o600)
  285. restored.close()
  286. })
  287. it('automatically flushes newly queued work and closes the injected provider', async () => {
  288. const sender = new FakeSender()
  289. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender })
  290. register(coordinator)
  291. coordinator.enqueue({ eventId: 'auto-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  292. await expect.poll(() => sender.requests.length).toBe(1)
  293. expect(coordinator.status().pendingCount).toBe(0)
  294. coordinator.close()
  295. expect(sender.closed).toBe(true)
  296. })
  297. it('protects and preserves a malformed existing state file instead of overwriting it', () => {
  298. const home = root()
  299. const directory = join(home, 'push')
  300. const statePath = join(directory, 'state.json')
  301. mkdirSync(directory)
  302. writeFileSync(statePath, '{malformed', { mode: 0o644, flag: 'w' })
  303. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  304. expect(coordinator.capabilities()).toMatchObject({ enabled: false })
  305. expect(coordinator.status().lastError).toMatch(/避免覆盖/)
  306. expect(() => register(coordinator)).toThrow(/推送状态文件无法读取/)
  307. expect(readFileSync(statePath, 'utf8')).toBe('{malformed')
  308. expect(statSync(statePath).mode & 0o777).toBe(0o600)
  309. coordinator.close()
  310. })
  311. })