pushRoutes.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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: 15300, 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:15300').expect(200)
  62. const mutation = () => agent.put('/api/app/system/push-config/fcm')
  63. .set('Host', '127.0.0.1:15300')
  64. .set('Origin', 'http://127.0.0.1:15300')
  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:15300')
  94. .set('Origin', 'http://127.0.0.1:15300')
  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:15300')
  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: 15300, 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:15300').expect(200)
  140. const mutation = () => agent.put('/api/app/system/push-config')
  141. .set('Host', '127.0.0.1:15300')
  142. .set('Origin', 'http://127.0.0.1:15300')
  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:15300')
  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: 15300, 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:15300').expect(200)
  198. const response = await agent.put('/api/app/system/push-config')
  199. .set('Host', '127.0.0.1:15300')
  200. .set('Origin', 'http://127.0.0.1:15300')
  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: 15300, 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:15300')
  236. .set('Origin', 'http://127.0.0.1:15300')
  237. const installationID = '11111111-1111-4111-8111-111111111111'
  238. const accountID = '22222222-2222-4222-8222-222222222222'
  239. const a = runtime.workspace.createAgent('test-admin', {name:'甲',profile:'default'})
  240. const b = runtime.workspace.createAgent('test-admin', {name:'乙',profile:'default'})
  241. const roomID = runtime.workspace.createGroup('test-admin', {name:'通知测试',memberIds:[a.id,b.id],administratorId:a.id}).id
  242. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  243. const capabilities = await host(agent.get('/api/push/v1/capabilities')).expect(200)
  244. expect(capabilities.body).toMatchObject({
  245. protocolVersion: 1,
  246. enabled: true,
  247. topic: 'cn.samien.yaoyao.hermes',
  248. maximumSummaryCharacters: 180,
  249. })
  250. await agent.get('/api/app/push/v1/capabilities').set('Host', '127.0.0.1:15300').expect(200)
  251. const registered = await host(agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
  252. .send({ token: 'ab'.repeat(32), environment: 'development', appVersion: '1.2 (139)' })
  253. .expect(200)
  254. expect(registered.body.installation).toMatchObject({
  255. installationId: installationID,
  256. clientAccountId: accountID,
  257. environment: 'development',
  258. })
  259. await host(agent.put(`/api/app/push/v1/group-subscriptions/${roomID}`))
  260. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  261. .send({ enabled: true })
  262. .expect(200, /"enabled":true/)
  263. const subscriptions = await host(agent.get('/api/app/push/v1/group-subscriptions')).expect(200)
  264. expect(subscriptions.body.roomIds).toEqual([roomID])
  265. await host(agent.post(`/api/push/v1/installations/${installationID}/accounts/${accountID}/badge-reset`))
  266. .send({})
  267. .expect(200, { badge: 0 })
  268. const status = await host(agent.get('/api/app/system/push-status')).expect(200)
  269. expect(status.body).toMatchObject({ configured: true, healthy: true, registrationCount: 1, topic: 'cn.samien.yaoyao.hermes' })
  270. await host(agent.delete(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
  271. .expect(200, /"removed":true/)
  272. expect(push.status().registrationCount).toBe(0)
  273. })
  274. it('rejects malformed registration and subscription input', async () => {
  275. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-validation-'))
  276. roots.push(home)
  277. const config: ServerConfig = {
  278. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  279. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  280. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  281. }
  282. const runtime = createAuthenticatedApplication({ config })
  283. runtimes.push(runtime)
  284. const agent = request.agent(runtime.app.callback())
  285. const headers = (value: request.Test) => value
  286. .set('Host', '127.0.0.1:15300')
  287. .set('Origin', 'http://127.0.0.1:15300')
  288. await headers(agent.put('/api/push/v1/installations/11111111-1111-4111-8111-111111111111/accounts/22222222-2222-4222-8222-222222222222'))
  289. .send({ token: 'not-a-token', environment: 'unknown' })
  290. .expect(400, /environment must be development or production/)
  291. await headers(agent.put('/api/push/v1/group-subscriptions/33333333-3333-4333-8333-333333333333'))
  292. .send({ enabled: 'yes' })
  293. .expect(400, /enabled must be a boolean/)
  294. })
  295. })