pushCoordinator.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, 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. const home = root()
  50. expect(loadServerConfig({ HERMES_YAOYAO_HOME: home }).apns).toBeUndefined()
  51. const partial = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567' })
  52. expect(partial.apns).toBeUndefined()
  53. expect(partial.apnsConfigurationError).toMatch(/absolute local path/)
  54. })
  55. it('loads a complete provider configuration and the default topic', () => {
  56. const directory = root()
  57. const keyFile = join(directory, 'AuthKey.p8')
  58. const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
  59. writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
  60. const config = loadServerConfig({
  61. HERMES_YAOYAO_HOME: directory,
  62. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  63. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  64. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  65. })
  66. expect(config.apns).toEqual({
  67. keyFile: realpathSync(keyFile),
  68. keyId: 'KEY1234567',
  69. teamId: 'TEAM123456',
  70. topic: 'cn.samien.yaoyao.hermes',
  71. environments: ['development', 'production'],
  72. })
  73. })
  74. it('disables an unreadable provider key as a capability error', () => {
  75. const keyFile = join(root(), 'AuthKey.p8')
  76. writeFileSync(keyFile, 'not a private key')
  77. const config = loadServerConfig({
  78. HERMES_YAOYAO_HOME: dirname(keyFile),
  79. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  80. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  81. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  82. })
  83. expect(config.apns).toBeUndefined()
  84. expect(config.apnsConfigurationError).toMatch(/ES256 private key/)
  85. })
  86. })
  87. describe('PushCoordinator durable state and delivery', () => {
  88. it('hot-swaps during an active flush without closing the in-flight provider early', async () => {
  89. const home = root()
  90. let releaseSend!: (value: APNsSendResult) => void
  91. const oldSender: PushSender & { closed: boolean } = {
  92. closed: false,
  93. send: () => new Promise(resolve => { releaseSend = resolve }),
  94. close() { this.closed = true },
  95. }
  96. const replacement = new FakeSender()
  97. const coordinator = new PushCoordinator({
  98. home, apns, provider: oldSender, autoFlush: false,
  99. providerFactory: () => replacement,
  100. })
  101. register(coordinator)
  102. coordinator.enqueue({
  103. eventId: 'active-flush', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '已完成',
  104. })
  105. const flush = coordinator.flushDue()
  106. const replacementOperation = coordinator.configureAPNs({ ...apns, keyId: 'NEWKEY1234' })
  107. await replacementOperation
  108. expect(oldSender.closed).toBe(false)
  109. expect(coordinator.capabilities().enabled).toBe(true)
  110. releaseSend({ disposition: 'unregister', status: 400, reason: 'BadDeviceToken' })
  111. await flush
  112. await Promise.resolve()
  113. expect(oldSender.closed).toBe(true)
  114. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
  115. await coordinator.flushDue()
  116. expect(replacement.requests).toHaveLength(1)
  117. expect(coordinator.status().pendingCount).toBe(0)
  118. })
  119. it('hot-enables providers and only queues installations from selected environments', async () => {
  120. const home = root()
  121. const senders: FakeSender[] = []
  122. const coordinator = new PushCoordinator({
  123. home,
  124. autoFlush: false,
  125. providerFactory: () => {
  126. const sender = new FakeSender()
  127. senders.push(sender)
  128. return sender
  129. },
  130. })
  131. const changes: boolean[] = []
  132. coordinator.onEnabledChange(enabled => { changes.push(enabled) })
  133. coordinator.registerInstallation({
  134. userId: 'user-a', installationId: 'phone-development', clientAccountId: 'account-a',
  135. deviceToken: 'ab'.repeat(32), environment: 'development',
  136. })
  137. coordinator.registerInstallation({
  138. userId: 'user-a', installationId: 'phone-production', clientAccountId: 'account-a',
  139. deviceToken: 'cd'.repeat(32), environment: 'production',
  140. })
  141. await coordinator.configureAPNs({ ...apns, environments: ['production'] })
  142. expect(coordinator.capabilities()).toMatchObject({ enabled: true, environments: ['production'] })
  143. expect(coordinator.enqueue({
  144. eventId: 'environment-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '已完成',
  145. })).toBe(1)
  146. await coordinator.flushDue()
  147. expect(senders[0]!.requests).toHaveLength(1)
  148. expect(senders[0]!.requests[0]!.environment).toBe('production')
  149. await coordinator.configureAPNs()
  150. expect(senders[0]!.closed).toBe(true)
  151. expect(coordinator.capabilities().enabled).toBe(false)
  152. expect(changes).toEqual([true, false])
  153. })
  154. it('persists private registrations, subscriptions, outbox delivery, and badge state', async () => {
  155. const home = root()
  156. const sender = new FakeSender()
  157. const coordinator = new PushCoordinator({ home, apns, provider: sender, autoFlush: false })
  158. expect(coordinator.capabilities()).toMatchObject({
  159. protocolVersion: 1, enabled: true, topic: apns.topic, maxSummaryCharacters: 180,
  160. })
  161. expect(register(coordinator)).not.toHaveProperty('deviceToken')
  162. expect(coordinator.setGroupSubscription('user-a', 'room-1', true)).toMatchObject({ roomId: 'room-1', enabled: true })
  163. expect(coordinator.isGroupSubscribed('user-a', 'room-1')).toBe(true)
  164. expect(coordinator.subscribedUserIds('room-1')).toEqual(['user-a'])
  165. expect(coordinator.enqueue({
  166. eventId: 'event-1',
  167. userId: 'user-a',
  168. kind: 'group.message.completed',
  169. title: '任务完成',
  170. body: '好'.repeat(250),
  171. roomId: 'room-1',
  172. threadId: 'topic-1',
  173. collapseId: 'message-1',
  174. data: { roomId: 'room-1', aps: 'untrusted', version: 99 },
  175. })).toBe(1)
  176. expect(coordinator.enqueue({
  177. eventId: 'event-1', userId: 'user-a', kind: 'group.message.completed', title: '重复', body: '重复',
  178. })).toBe(0)
  179. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1, topic: apns.topic })
  180. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  181. expect(sender.requests).toHaveLength(1)
  182. expect(sender.requests[0]!.payload).toMatchObject({
  183. version: 1,
  184. eventId: 'event-1',
  185. kind: 'group.message.completed',
  186. clientAccountId: 'account-a',
  187. aps: { alert: { title: '任务完成' }, badge: 1, 'thread-id': 'topic-1' },
  188. })
  189. expect(Array.from(((sender.requests[0]!.payload.aps as { alert: { body: string } }).alert.body))).toHaveLength(180)
  190. expect(sender.requests[0]!.payload).not.toHaveProperty('userId')
  191. expect(coordinator.status()).toMatchObject({ healthy: true, pendingCount: 0 })
  192. const statePath = join(home, 'push', 'state.json')
  193. expect(statSync(statePath).mode & 0o777).toBe(0o600)
  194. const stored = readFileSync(statePath, 'utf8')
  195. expect(stored).toContain('ab'.repeat(17))
  196. expect(stored).not.toContain(apns.keyFile)
  197. coordinator.close()
  198. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  199. expect(restored.status()).toMatchObject({ registrationCount: 1, subscriptionCount: 1 })
  200. expect(restored.listGroupSubscriptions('user-a')).toEqual([
  201. expect.objectContaining({ roomId: 'room-1', enabled: true }),
  202. ])
  203. restored.close()
  204. })
  205. it('retries transient failures exponentially and removes invalid registrations', async () => {
  206. const home = root()
  207. let now = 1_000_000
  208. const sender = new FakeSender()
  209. sender.results.push(
  210. { disposition: 'retry', status: 503, reason: 'Shutdown' },
  211. { disposition: 'unregister', status: 410, reason: 'Unregistered' },
  212. )
  213. const coordinator = new PushCoordinator({
  214. home, apns, provider: sender, autoFlush: false, now: () => now, baseRetryMilliseconds: 100,
  215. })
  216. register(coordinator)
  217. coordinator.enqueue({ eventId: 'retry-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  218. await expect(coordinator.flushDue()).resolves.toMatchObject({ retried: 1 })
  219. expect(coordinator.status()).toMatchObject({ pendingCount: 1, healthy: false })
  220. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 0, retried: 0 })
  221. expect(sender.requests).toHaveLength(1)
  222. now += 100
  223. await expect(coordinator.flushDue()).resolves.toMatchObject({ removedRegistrations: 1 })
  224. expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
  225. coordinator.close()
  226. })
  227. it('does not delete a token that rotated while an APNs request was in flight', async () => {
  228. let release!: (result: APNsSendResult) => void
  229. let started!: () => void
  230. const began = new Promise<void>(resolve => { started = resolve })
  231. const first = new Promise<APNsSendResult>(resolve => { release = resolve })
  232. const requests: APNsRequest[] = []
  233. const sender: PushSender = {
  234. send: async request => {
  235. requests.push(request)
  236. if (requests.length === 1) {
  237. started()
  238. return first
  239. }
  240. return { disposition: 'success', status: 200 }
  241. },
  242. }
  243. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender, autoFlush: false })
  244. register(coordinator)
  245. coordinator.enqueue({ eventId: 'rotated-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  246. const flushing = coordinator.flushDue()
  247. await began
  248. coordinator.registerInstallation({
  249. userId: 'user-a', installationId: 'phone-1', clientAccountId: 'account-a',
  250. deviceToken: 'cd'.repeat(32), environment: 'development', appVersion: '1.2.4',
  251. })
  252. release({ disposition: 'unregister', status: 410, reason: 'Unregistered' })
  253. await expect(flushing).resolves.toMatchObject({ retried: 1, removedRegistrations: 0 })
  254. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
  255. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  256. expect(requests[0]!.deviceToken).not.toBe(requests[1]!.deviceToken)
  257. expect(requests[1]!.deviceToken).toBe('cd'.repeat(32))
  258. coordinator.close()
  259. })
  260. it('fails closed when a user is disabled or their authentication version changes', async () => {
  261. let active = true
  262. let authVersion = 1
  263. const sender = new FakeSender()
  264. const coordinator = new PushCoordinator({
  265. home: root(), apns, provider: sender, autoFlush: false,
  266. isUserActive: () => active,
  267. userAuthorizationVersion: () => authVersion,
  268. })
  269. coordinator.registerInstallation({
  270. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  271. deviceToken: 'ab'.repeat(32), environment: 'development', authorizationVersion: 1,
  272. })
  273. coordinator.enqueue({ eventId: 'version-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  274. authVersion = 2
  275. await expect(coordinator.flushDue()).resolves.toMatchObject({ failed: 1, delivered: 0 })
  276. expect(sender.requests).toHaveLength(0)
  277. expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
  278. coordinator.registerInstallation({
  279. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  280. deviceToken: 'cd'.repeat(32), environment: 'development', authorizationVersion: 2,
  281. })
  282. active = false
  283. expect(coordinator.enqueue({
  284. eventId: 'disabled', userId: 'user-a', kind: 'chat.completed', title: '不应发送', body: '正文',
  285. })).toBe(0)
  286. coordinator.close()
  287. })
  288. it('cancels pending team notifications on unsubscribe and removes all user-owned state', () => {
  289. const home = root()
  290. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  291. register(coordinator)
  292. coordinator.setGroupSubscription('user-a', 'room-1', true)
  293. expect(coordinator.enqueueGroupEvent({
  294. eventID: 'group-1', localUserID: 'user-a', kind: 'group.message.completed',
  295. title: '团队消息', body: '正文', collapseID: 'group-1', data: {}, roomID: 'room-1',
  296. })).toBe('enqueued')
  297. coordinator.setGroupSubscription('user-a', 'room-1', false)
  298. expect(coordinator.status().pendingCount).toBe(0)
  299. coordinator.setGroupSubscription('user-a', 'room-2', true)
  300. const removed = coordinator.removeUser('user-a')
  301. expect(removed).toMatchObject({ registrations: 1, subscriptions: 1 })
  302. expect(coordinator.status()).toMatchObject({ registrationCount: 0, subscriptionCount: 0 })
  303. expect(coordinator.listGroupSubscriptions('user-a')).toEqual([])
  304. coordinator.close()
  305. })
  306. it('persists the global group cursor and resumable chat jobs in the same state file', () => {
  307. const home = root()
  308. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  309. expect(coordinator.resetGroupCursor({ epoch: 'epoch-1', cursor: 10, reason: 'initial' })).toMatchObject({
  310. epoch: 'epoch-1', cursor: 10,
  311. })
  312. coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 11 })
  313. expect(() => coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 9 })).toThrow(/backwards/)
  314. coordinator.saveChatJob({
  315. id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  316. storedSessionID: 'stored-1', requestID: 'request-1', queued: false, phase: 'watching',
  317. submittedAt: 1, expiresAt: Date.now() + 60_000,
  318. })
  319. coordinator.close()
  320. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  321. expect(restored.groupWatchAnchor()).toMatchObject({ epoch: 'epoch-1', cursor: 11 })
  322. expect(restored.pendingChatJobs()).toEqual([expect.objectContaining({ id: 'job-1', localUserID: 'user-a' })])
  323. restored.completeChatJob('job-1')
  324. expect(restored.pendingChatJobs()).toEqual([])
  325. restored.close()
  326. })
  327. it('fails closed when more than one local user has written the same stored session', () => {
  328. const coordinator = new PushCoordinator({ home: root(), apns, provider: new FakeSender(), autoFlush: false })
  329. const job = (id: string, localUserID: string) => ({
  330. id, localUserID, profile: 'default', runtimeSessionID: `runtime-${id}`, storedSessionID: 'shared',
  331. requestID: `request-${id}`, queued: false, phase: 'accepted' as const,
  332. submittedAt: 1, expiresAt: Date.now() + 60_000,
  333. })
  334. const first = job('a', 'user-a')
  335. coordinator.saveChatJob(first)
  336. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(true)
  337. const second = job('b', 'user-b')
  338. coordinator.saveChatJob(second)
  339. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
  340. expect(coordinator.chatJobRecoveryAllowed(second)).toBe(false)
  341. coordinator.completeChatJob('b')
  342. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
  343. coordinator.close()
  344. })
  345. it('keeps prompt correlation private and stable across restart', () => {
  346. const home = root()
  347. const first = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  348. const digest = first.promptDigest('user-a', '继续执行')
  349. expect(digest).not.toContain('继续执行')
  350. expect(first.promptDigest('user-b', '继续执行')).not.toBe(digest)
  351. first.close()
  352. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  353. expect(restored.promptDigest('user-a', '继续执行')).toBe(digest)
  354. expect(statSync(join(home, 'push', 'correlation.key')).mode & 0o777).toBe(0o600)
  355. restored.close()
  356. })
  357. it('automatically flushes newly queued work and closes the injected provider', async () => {
  358. const sender = new FakeSender()
  359. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender })
  360. register(coordinator)
  361. coordinator.enqueue({ eventId: 'auto-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  362. await expect.poll(() => sender.requests.length).toBe(1)
  363. expect(coordinator.status().pendingCount).toBe(0)
  364. coordinator.close()
  365. expect(sender.closed).toBe(true)
  366. })
  367. it('protects and preserves a malformed existing state file instead of overwriting it', () => {
  368. const home = root()
  369. const directory = join(home, 'push')
  370. const statePath = join(directory, 'state.json')
  371. mkdirSync(directory)
  372. writeFileSync(statePath, '{malformed', { mode: 0o644, flag: 'w' })
  373. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  374. expect(coordinator.capabilities()).toMatchObject({ enabled: false })
  375. expect(coordinator.status().lastError).toMatch(/避免覆盖/)
  376. expect(() => register(coordinator)).toThrow(/推送状态文件无法读取/)
  377. expect(readFileSync(statePath, 'utf8')).toBe('{malformed')
  378. expect(statSync(statePath).mode & 0o777).toBe(0o600)
  379. coordinator.close()
  380. })
  381. })