pushRoutes.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import request from 'supertest'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import type { ApplicationRuntime } from '../../src/server/app.js'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. import { PushCoordinator } from '../../src/server/pushCoordinator.js'
  10. import {
  11. APNsConfigurationManager,
  12. apnsConfigurationPath,
  13. validateAPNsConfiguration,
  14. } from '../../src/server/apnsConfiguration.js'
  15. import { createAuthenticatedApplication } from './authenticatedApplication.js'
  16. const roots: string[] = []
  17. const runtimes: ApplicationRuntime[] = []
  18. afterEach(() => {
  19. for (const runtime of runtimes.splice(0)) runtime.close()
  20. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  21. })
  22. describe('iOS push routes', () => {
  23. it('validates and enables a server-local APNs key path without blocking broad permissions', async () => {
  24. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-web-config-'))
  25. roots.push(home)
  26. const keyFile = join(home, 'AuthKey_TEST.p8')
  27. const { privateKey } = generateKeyPairSync('ec', {
  28. namedCurve: 'prime256v1',
  29. privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  30. publicKeyEncoding: { type: 'spki', format: 'pem' },
  31. })
  32. writeFileSync(keyFile, privateKey, { mode: 0o644 })
  33. chmodSync(keyFile, 0o644)
  34. const config: ServerConfig = {
  35. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  36. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  37. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  38. }
  39. const push = new PushCoordinator({
  40. home,
  41. autoFlush: false,
  42. providerFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
  43. })
  44. const apnsConfiguration = new APNsConfigurationManager(
  45. home,
  46. { source: 'none', editable: true, warnings: [] },
  47. { probe: async () => undefined },
  48. )
  49. const runtime = createAuthenticatedApplication({ config, push, apnsConfiguration })
  50. runtimes.push(runtime)
  51. const agent = request.agent(runtime.app.callback())
  52. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  53. const mutation = () => agent.put('/api/app/system/push-config')
  54. .set('Host', '127.0.0.1:8800')
  55. .set('Origin', 'http://127.0.0.1:8800')
  56. .send({
  57. keyFile,
  58. keyId: 'KEY1234567',
  59. teamId: 'TEAM123456',
  60. topic: 'cn.samien.yaoyao.hermes',
  61. environments: ['development', 'production'],
  62. })
  63. await mutation().expect(403)
  64. const saved = await mutation().set('X-CSRF-Token', bootstrap.body.csrfToken).expect(200)
  65. expect(saved.body).toMatchObject({
  66. configured: true,
  67. healthy: true,
  68. source: 'file',
  69. editable: true,
  70. keyFile: realpathSync(keyFile),
  71. environments: ['development', 'production'],
  72. warnings: [{ code: 'apns_key_permissions', actualMode: '0644', recommendedMode: '0600' }],
  73. })
  74. expect(statSync(apnsConfigurationPath(home)).mode & 0o777).toBe(0o600)
  75. await agent.get('/api/app/system/push-status')
  76. .set('Host', '127.0.0.1:8800')
  77. .expect(200, /"source":"file"/)
  78. })
  79. it('keeps environment-managed APNs configuration read-only through the admin API', async () => {
  80. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-env-managed-'))
  81. roots.push(home)
  82. const keyFile = join(home, 'AuthKey_TEST.p8')
  83. const { privateKey } = generateKeyPairSync('ec', {
  84. namedCurve: 'prime256v1',
  85. privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  86. publicKeyEncoding: { type: 'spki', format: 'pem' },
  87. })
  88. writeFileSync(keyFile, privateKey, { mode: 0o600 })
  89. const validated = validateAPNsConfiguration({
  90. keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes',
  91. environments: ['development'],
  92. })
  93. const initial = { ...validated, source: 'environment' as const, editable: false }
  94. const config: ServerConfig = {
  95. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  96. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  97. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  98. apns: validated.config,
  99. apnsSettings: initial,
  100. }
  101. const push = new PushCoordinator({
  102. home, apns: validated.config,
  103. provider: { send: async () => ({ disposition: 'success', status: 200 }) },
  104. autoFlush: false,
  105. })
  106. const apnsConfiguration = new APNsConfigurationManager(home, initial, { probe: async () => undefined })
  107. const runtime = createAuthenticatedApplication({ config, push, apnsConfiguration })
  108. runtimes.push(runtime)
  109. const agent = request.agent(runtime.app.callback())
  110. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  111. const response = await agent.put('/api/app/system/push-config')
  112. .set('Host', '127.0.0.1:8800')
  113. .set('Origin', 'http://127.0.0.1:8800')
  114. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  115. .send({
  116. keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes',
  117. environments: ['development'],
  118. })
  119. .expect(409)
  120. expect(response.body).toMatchObject({ code: 'apns_environment_managed' })
  121. })
  122. it('registers an installation, manages team subscriptions, badge, and admin status', async () => {
  123. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-routes-'))
  124. roots.push(home)
  125. const config: ServerConfig = {
  126. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  127. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  128. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  129. apns: { keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes' },
  130. }
  131. const push = new PushCoordinator({
  132. home,
  133. apns: config.apns,
  134. provider: { send: async () => ({ disposition: 'success', status: 200 }) },
  135. autoFlush: false,
  136. })
  137. const fetchImpl = (async (input: string | URL | Request) => {
  138. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  139. if (path === '/api/status') return Response.json({ auth_required: false })
  140. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  141. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  142. if (path.endsWith('/messages')) return Response.json({ items: [{ id: 'baseline', seq: 12 }] })
  143. return Response.json({ ok: true })
  144. }) as typeof fetch
  145. const runtime = createAuthenticatedApplication({ config, push, fetchImpl })
  146. runtimes.push(runtime)
  147. const agent = request.agent(runtime.app.callback())
  148. const host = (value: request.Test) => value.set('Host', '127.0.0.1:8800')
  149. .set('Origin', 'http://127.0.0.1:8800')
  150. const installationID = '11111111-1111-4111-8111-111111111111'
  151. const accountID = '22222222-2222-4222-8222-222222222222'
  152. const roomID = '33333333-3333-4333-8333-333333333333'
  153. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  154. const capabilities = await host(agent.get('/api/push/v1/capabilities')).expect(200)
  155. expect(capabilities.body).toMatchObject({
  156. protocolVersion: 1,
  157. enabled: true,
  158. topic: 'cn.samien.yaoyao.hermes',
  159. maximumSummaryCharacters: 180,
  160. })
  161. await agent.get('/api/app/push/v1/capabilities').set('Host', '127.0.0.1:8800').expect(200)
  162. const registered = await host(agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
  163. .send({ token: 'ab'.repeat(32), environment: 'development', appVersion: '1.2 (139)' })
  164. .expect(200)
  165. expect(registered.body.installation).toMatchObject({
  166. installationId: installationID,
  167. clientAccountId: accountID,
  168. environment: 'development',
  169. })
  170. await host(agent.put(`/api/app/push/v1/group-subscriptions/${roomID}`))
  171. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  172. .send({ enabled: true })
  173. .expect(200, /"enabled":true/)
  174. const subscriptions = await host(agent.get('/api/app/push/v1/group-subscriptions')).expect(200)
  175. expect(subscriptions.body.roomIds).toEqual([roomID])
  176. await host(agent.post(`/api/push/v1/installations/${installationID}/accounts/${accountID}/badge-reset`))
  177. .send({})
  178. .expect(200, { badge: 0 })
  179. const status = await host(agent.get('/api/app/system/push-status')).expect(200)
  180. expect(status.body).toMatchObject({ configured: true, healthy: true, registrationCount: 1, topic: 'cn.samien.yaoyao.hermes' })
  181. await host(agent.delete(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
  182. .expect(200, /"removed":true/)
  183. expect(push.status().registrationCount).toBe(0)
  184. })
  185. it('rejects malformed registration and subscription input', async () => {
  186. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-validation-'))
  187. roots.push(home)
  188. const config: ServerConfig = {
  189. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  190. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  191. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  192. }
  193. const runtime = createAuthenticatedApplication({ config })
  194. runtimes.push(runtime)
  195. const agent = request.agent(runtime.app.callback())
  196. const headers = (value: request.Test) => value
  197. .set('Host', '127.0.0.1:8800')
  198. .set('Origin', 'http://127.0.0.1:8800')
  199. await headers(agent.put('/api/push/v1/installations/11111111-1111-4111-8111-111111111111/accounts/22222222-2222-4222-8222-222222222222'))
  200. .send({ token: 'not-a-token', environment: 'unknown' })
  201. .expect(400, /environment must be development or production/)
  202. await headers(agent.put('/api/push/v1/group-subscriptions/33333333-3333-4333-8333-333333333333'))
  203. .send({ enabled: 'yes' })
  204. .expect(400, /enabled must be a boolean/)
  205. })
  206. it('automatically subscribes the current user after Web or native team participation', async () => {
  207. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-auto-subscribe-'))
  208. roots.push(home)
  209. const config: ServerConfig = {
  210. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  211. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  212. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  213. }
  214. const fetchImpl = (async (input: string | URL | Request) => {
  215. const url = new URL(input instanceof Request ? input.url : String(input))
  216. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  217. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  218. if (url.pathname === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  219. if (/\/api\/plugins\/yaoyao\/v1\/rooms\/[0-9a-f-]{36}\/messages$/.test(url.pathname)) {
  220. return Response.json({ message: { id: 'message', seq: 7, senderKind: 'human', content: 'hello' }, runs: [] })
  221. }
  222. return Response.json({ ok: true })
  223. }) as typeof fetch
  224. const runtime = createAuthenticatedApplication({ config, fetchImpl })
  225. runtimes.push(runtime)
  226. const agent = request.agent(runtime.app.callback())
  227. const firstRoom = '44444444-4444-4444-8444-444444444444'
  228. const secondRoom = '55555555-5555-4555-8555-555555555555'
  229. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  230. await agent.post(`/api/app/groups/rooms/${firstRoom}/messages`)
  231. .set('Host', '127.0.0.1:8800')
  232. .set('Origin', 'http://127.0.0.1:8800')
  233. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  234. .send({ requestId: 'request-web', clientMessageId: 'client-web', content: 'Web 参与', mentionAgentIds: [] })
  235. .expect(200)
  236. await agent.post(`/api/plugins/yaoyao/v1/rooms/${secondRoom}/messages`)
  237. .set('Host', '127.0.0.1:8800')
  238. .set('Origin', 'http://127.0.0.1:8800')
  239. .send({ requestId: 'request-ios', clientMessageId: 'client-ios', content: 'iOS 参与', mentionAgentIds: [] })
  240. .expect(200)
  241. const subscriptions = await agent.get('/api/push/v1/group-subscriptions')
  242. .set('Host', '127.0.0.1:8800')
  243. .expect(200)
  244. expect(subscriptions.body.roomIds).toEqual([firstRoom, secondRoom])
  245. expect(subscriptions.body.subscriptions.every((item: { lastMessageSeq?: number }) => item.lastMessageSeq === 7)).toBe(true)
  246. })
  247. it('never turns a successful team send into a failure when optional push state is unavailable', async () => {
  248. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-state-failure-'))
  249. roots.push(home)
  250. mkdirSync(join(home, 'push'))
  251. writeFileSync(join(home, 'push', 'state.json'), '{broken', { mode: 0o600 })
  252. const config: ServerConfig = {
  253. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  254. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  255. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: true,
  256. }
  257. const fetchImpl = (async (input: string | URL | Request) => {
  258. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  259. if (path === '/api/status') return Response.json({ auth_required: false })
  260. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  261. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  262. if (path.endsWith('/messages')) {
  263. return Response.json({ message: { id: 'accepted', seq: 1, senderKind: 'human', content: '已接收' }, runs: [] })
  264. }
  265. return Response.json({ ok: true })
  266. }) as typeof fetch
  267. const push = new PushCoordinator({ home, autoFlush: false })
  268. const runtime = createAuthenticatedApplication({ config, fetchImpl, push })
  269. runtimes.push(runtime)
  270. // The error event is operational telemetry; suppress Koa's default stderr
  271. // output while asserting the primary response remains authoritative.
  272. runtime.app.on('error', () => undefined)
  273. const agent = request.agent(runtime.app.callback())
  274. const roomID = '66666666-6666-4666-8666-666666666666'
  275. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  276. await agent.post(`/api/app/groups/rooms/${roomID}/messages`)
  277. .set('Host', '127.0.0.1:8800')
  278. .set('Origin', 'http://127.0.0.1:8800')
  279. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  280. .send({ requestId: 'request', clientMessageId: 'client', content: '仍应成功', mentionAgentIds: [] })
  281. .expect(200, /accepted/)
  282. })
  283. })