pushRoutes.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import request from 'supertest'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import type { ApplicationRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. import { PushCoordinator } from '../../src/server/pushCoordinator.js'
  9. import { createAuthenticatedApplication } from './authenticatedApplication.js'
  10. const roots: string[] = []
  11. const runtimes: ApplicationRuntime[] = []
  12. afterEach(() => {
  13. for (const runtime of runtimes.splice(0)) runtime.close()
  14. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  15. })
  16. describe('iOS push routes', () => {
  17. it('registers an installation, manages team subscriptions, badge, and admin status', async () => {
  18. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-routes-'))
  19. roots.push(home)
  20. const config: ServerConfig = {
  21. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  22. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  23. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  24. apns: { keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes' },
  25. }
  26. const push = new PushCoordinator({
  27. home,
  28. apns: config.apns,
  29. provider: { send: async () => ({ disposition: 'success', status: 200 }) },
  30. autoFlush: false,
  31. })
  32. const fetchImpl = (async (input: string | URL | Request) => {
  33. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  34. if (path === '/api/status') return Response.json({ auth_required: false })
  35. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  36. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  37. if (path.endsWith('/messages')) return Response.json({ items: [{ id: 'baseline', seq: 12 }] })
  38. return Response.json({ ok: true })
  39. }) as typeof fetch
  40. const runtime = createAuthenticatedApplication({ config, push, fetchImpl })
  41. runtimes.push(runtime)
  42. const agent = request.agent(runtime.app.callback())
  43. const host = (value: request.Test) => value.set('Host', '127.0.0.1:8800')
  44. .set('Origin', 'http://127.0.0.1:8800')
  45. const installationID = '11111111-1111-4111-8111-111111111111'
  46. const accountID = '22222222-2222-4222-8222-222222222222'
  47. const roomID = '33333333-3333-4333-8333-333333333333'
  48. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  49. const capabilities = await host(agent.get('/api/push/v1/capabilities')).expect(200)
  50. expect(capabilities.body).toMatchObject({
  51. protocolVersion: 1,
  52. enabled: true,
  53. topic: 'cn.samien.yaoyao.hermes',
  54. maximumSummaryCharacters: 180,
  55. })
  56. await agent.get('/api/app/push/v1/capabilities').set('Host', '127.0.0.1:8800').expect(200)
  57. const registered = await host(agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
  58. .send({ token: 'ab'.repeat(32), environment: 'development', appVersion: '1.2 (139)' })
  59. .expect(200)
  60. expect(registered.body.installation).toMatchObject({
  61. installationId: installationID,
  62. clientAccountId: accountID,
  63. environment: 'development',
  64. })
  65. await host(agent.put(`/api/app/push/v1/group-subscriptions/${roomID}`))
  66. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  67. .send({ enabled: true })
  68. .expect(200, /"enabled":true/)
  69. const subscriptions = await host(agent.get('/api/app/push/v1/group-subscriptions')).expect(200)
  70. expect(subscriptions.body.roomIds).toEqual([roomID])
  71. await host(agent.post(`/api/push/v1/installations/${installationID}/accounts/${accountID}/badge-reset`))
  72. .send({})
  73. .expect(200, { badge: 0 })
  74. const status = await host(agent.get('/api/app/system/push-status')).expect(200)
  75. expect(status.body).toMatchObject({ configured: true, healthy: true, registrationCount: 1, topic: 'cn.samien.yaoyao.hermes' })
  76. await host(agent.delete(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
  77. .expect(200, /"removed":true/)
  78. expect(push.status().registrationCount).toBe(0)
  79. })
  80. it('rejects malformed registration and subscription input', async () => {
  81. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-validation-'))
  82. roots.push(home)
  83. const config: ServerConfig = {
  84. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  85. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  86. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  87. }
  88. const runtime = createAuthenticatedApplication({ config })
  89. runtimes.push(runtime)
  90. const agent = request.agent(runtime.app.callback())
  91. const headers = (value: request.Test) => value
  92. .set('Host', '127.0.0.1:8800')
  93. .set('Origin', 'http://127.0.0.1:8800')
  94. await headers(agent.put('/api/push/v1/installations/11111111-1111-4111-8111-111111111111/accounts/22222222-2222-4222-8222-222222222222'))
  95. .send({ token: 'not-a-token', environment: 'unknown' })
  96. .expect(400, /environment must be development or production/)
  97. await headers(agent.put('/api/push/v1/group-subscriptions/33333333-3333-4333-8333-333333333333'))
  98. .send({ enabled: 'yes' })
  99. .expect(400, /enabled must be a boolean/)
  100. })
  101. it('automatically subscribes the current user after Web or native team participation', async () => {
  102. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-auto-subscribe-'))
  103. roots.push(home)
  104. const config: ServerConfig = {
  105. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  106. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  107. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  108. }
  109. const fetchImpl = (async (input: string | URL | Request) => {
  110. const url = new URL(input instanceof Request ? input.url : String(input))
  111. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  112. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  113. if (url.pathname === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  114. if (/\/api\/plugins\/yaoyao\/v1\/rooms\/[0-9a-f-]{36}\/messages$/.test(url.pathname)) {
  115. return Response.json({ message: { id: 'message', seq: 7, senderKind: 'human', content: 'hello' }, runs: [] })
  116. }
  117. return Response.json({ ok: true })
  118. }) as typeof fetch
  119. const runtime = createAuthenticatedApplication({ config, fetchImpl })
  120. runtimes.push(runtime)
  121. const agent = request.agent(runtime.app.callback())
  122. const firstRoom = '44444444-4444-4444-8444-444444444444'
  123. const secondRoom = '55555555-5555-4555-8555-555555555555'
  124. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  125. await agent.post(`/api/app/groups/rooms/${firstRoom}/messages`)
  126. .set('Host', '127.0.0.1:8800')
  127. .set('Origin', 'http://127.0.0.1:8800')
  128. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  129. .send({ requestId: 'request-web', clientMessageId: 'client-web', content: 'Web 参与', mentionAgentIds: [] })
  130. .expect(200)
  131. await agent.post(`/api/plugins/yaoyao/v1/rooms/${secondRoom}/messages`)
  132. .set('Host', '127.0.0.1:8800')
  133. .set('Origin', 'http://127.0.0.1:8800')
  134. .send({ requestId: 'request-ios', clientMessageId: 'client-ios', content: 'iOS 参与', mentionAgentIds: [] })
  135. .expect(200)
  136. const subscriptions = await agent.get('/api/push/v1/group-subscriptions')
  137. .set('Host', '127.0.0.1:8800')
  138. .expect(200)
  139. expect(subscriptions.body.roomIds).toEqual([firstRoom, secondRoom])
  140. expect(subscriptions.body.subscriptions.every((item: { lastMessageSeq?: number }) => item.lastMessageSeq === 7)).toBe(true)
  141. })
  142. it('never turns a successful team send into a failure when optional push state is unavailable', async () => {
  143. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-state-failure-'))
  144. roots.push(home)
  145. mkdirSync(join(home, 'push'))
  146. writeFileSync(join(home, 'push', 'state.json'), '{broken', { mode: 0o600 })
  147. const config: ServerConfig = {
  148. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  149. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  150. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: true,
  151. }
  152. const fetchImpl = (async (input: string | URL | Request) => {
  153. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  154. if (path === '/api/status') return Response.json({ auth_required: false })
  155. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  156. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  157. if (path.endsWith('/messages')) {
  158. return Response.json({ message: { id: 'accepted', seq: 1, senderKind: 'human', content: '已接收' }, runs: [] })
  159. }
  160. return Response.json({ ok: true })
  161. }) as typeof fetch
  162. const push = new PushCoordinator({ home, autoFlush: false })
  163. const runtime = createAuthenticatedApplication({ config, fetchImpl, push })
  164. runtimes.push(runtime)
  165. // The error event is operational telemetry; suppress Koa's default stderr
  166. // output while asserting the primary response remains authoritative.
  167. runtime.app.on('error', () => undefined)
  168. const agent = request.agent(runtime.app.callback())
  169. const roomID = '66666666-6666-4666-8666-666666666666'
  170. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  171. await agent.post(`/api/app/groups/rooms/${roomID}/messages`)
  172. .set('Host', '127.0.0.1:8800')
  173. .set('Origin', 'http://127.0.0.1:8800')
  174. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  175. .send({ requestId: 'request', clientMessageId: 'client', content: '仍应成功', mentionAgentIds: [] })
  176. .expect(200, /accepted/)
  177. })
  178. })