pushRoutes.test.ts 19 KB

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