realtimeMigration.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { createServer, type Server } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import WebSocket, { WebSocketServer } from 'ws'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { createNodeServer, type NodeServerRuntime } from '../../src/server/app.js'
  6. import type { ServerConfig } from '../../src/server/config.js'
  7. import { mkdtempSync, rmSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
  11. const closers: Array<() => Promise<void>> = []
  12. const homes: string[] = []
  13. afterEach(async () => {
  14. for (const close of closers.splice(0).reverse()) await close()
  15. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  16. })
  17. function listen(server: Server): Promise<number> {
  18. return new Promise((resolve, reject) => {
  19. server.once('error', reject)
  20. server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
  21. })
  22. }
  23. function closeServer(server: Server): Promise<void> {
  24. return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
  25. }
  26. function cookies(response: Response): string {
  27. return response.headers.getSetCookie().map((value) => value.split(';', 1)[0]).join('; ')
  28. }
  29. function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
  30. return () => runtime.close()
  31. }
  32. describe('HTTP+SSE migration', () => {
  33. it('lets a QR-paired iOS node use authenticated HTTP+SSE and rejects old tickets', async () => {
  34. let pairedCookie = ''
  35. const pairedFrames: Array<Record<string, unknown>> = []
  36. const upstream = createServer((request, response) => {
  37. response.setHeader('Content-Type', 'application/json')
  38. if (request.url === '/api/status') {
  39. pairedCookie = request.headers.cookie ?? pairedCookie
  40. response.setHeader('Set-Cookie', 'hermes_session_at=paired-user; Path=/; HttpOnly')
  41. response.end(JSON.stringify({ auth_required: true }))
  42. } else if (request.url === '/api/auth/me') {
  43. pairedCookie = request.headers.cookie ?? pairedCookie
  44. response.end(JSON.stringify({ user_id: 'paired-user', display_name: 'Paired User' }))
  45. } else if (request.url === '/api/profiles') {
  46. pairedCookie = request.headers.cookie ?? pairedCookie
  47. response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
  48. } else if (request.url === '/api/plugins/yaoyao/profiles') {
  49. response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
  50. } else if (request.url === '/api/auth/providers') {
  51. response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
  52. } else if (request.url === '/auth/password-login' && request.method === 'POST') {
  53. response.setHeader('Set-Cookie', 'hermes_session_rt=paired-refresh; Path=/; HttpOnly')
  54. response.end(JSON.stringify({ ok: true }))
  55. } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
  56. pairedCookie = request.headers.cookie ?? pairedCookie
  57. response.end(JSON.stringify({ ticket: 'paired-upstream-ticket' }))
  58. } else {
  59. response.statusCode = 404
  60. response.end(JSON.stringify({ error: 'not found' }))
  61. }
  62. })
  63. const upstreamSockets = new WebSocketServer({ noServer: true })
  64. upstream.on('upgrade', (request, socket, head) => {
  65. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  66. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'paired-upstream-ticket') {
  67. socket.destroy()
  68. return
  69. }
  70. upstreamSockets.handleUpgrade(request, socket, head, client => {
  71. client.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { paired: true } } }))
  72. client.on('message', data => {
  73. const frame = JSON.parse(data.toString()) as Record<string, unknown>
  74. pairedFrames.push(frame)
  75. client.send(JSON.stringify({ id: frame.id, result: frame.method === 'session.create' ? { session_id: 'runtime-test', stored_session_id: 'stored-test' } : {} }))
  76. })
  77. })
  78. })
  79. const upstreamPort = await listen(upstream)
  80. closers.push(async () => {
  81. for (const client of upstreamSockets.clients) client.terminate()
  82. upstreamSockets.close()
  83. await closeServer(upstream)
  84. })
  85. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
  86. homes.push(home)
  87. const config: ServerConfig = {
  88. host: '127.0.0.1', port: 8800,
  89. upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
  90. allowedHosts: new Set(), home,
  91. mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
  92. allowInsecureLan: false, insecureLan: false, production: false,
  93. }
  94. const application = createApplication({ config })
  95. const node = createNodeServer(application)
  96. await listen(node.server)
  97. closers.push(closeRuntime(node))
  98. const port = (node.server.address() as AddressInfo).port
  99. config.port = port
  100. const origin = `http://127.0.0.1:${port}`
  101. const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
  102. const bootstrapBody = await bootstrap.json() as { csrfToken: string }
  103. const cookie = cookies(bootstrap)
  104. const pairingResponse = await fetch(`${origin}/api/app/pairings`, {
  105. method: 'POST',
  106. headers: {
  107. 'Content-Type': 'application/json', Cookie: cookie, Origin: origin,
  108. 'X-CSRF-Token': bootstrapBody.csrfToken,
  109. },
  110. body: JSON.stringify({
  111. username: 'paired-user', password: 'paired-password',
  112. }),
  113. })
  114. expect(pairingResponse.status).toBe(201)
  115. const pairing = await pairingResponse.json() as { pairingId: string; qrPayload: string }
  116. const qr = new URL(pairing.qrPayload)
  117. const claimResponse = await fetch(`${origin}/api/pair/v1/claim`, {
  118. method: 'POST',
  119. headers: { 'Content-Type': 'application/json' },
  120. body: JSON.stringify({
  121. pairingId: pairing.pairingId,
  122. secret: qr.searchParams.get('secret'),
  123. deviceName: 'Test iPhone',
  124. }),
  125. })
  126. expect(claimResponse.status).toBe(201)
  127. const claim = await claimResponse.json() as { serverUrl: string; token: string; deviceId: string }
  128. const authorization = `Bearer ${claim.token}`
  129. const status = await fetch(`${claim.serverUrl}/api/status`, {
  130. headers: { Authorization: authorization },
  131. })
  132. expect(status.status).toBe(200)
  133. expect((await status.json() as { auth_required: boolean }).auth_required).toBe(true)
  134. const retired = await fetch(`${claim.serverUrl}/api/auth/ws-ticket`, {
  135. method: 'POST', headers: { Authorization: authorization },
  136. })
  137. expect(retired.status).toBe(410)
  138. const capabilities = await fetch(`${claim.serverUrl}/api/realtime/capabilities`, { headers: { Authorization: authorization } })
  139. expect(await capabilities.json()).toMatchObject({ protocolVersion: 1 })
  140. const opened = await fetch(`${claim.serverUrl}/api/realtime/channels`, {
  141. method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
  142. body: JSON.stringify({ channel: 'chat' }),
  143. })
  144. expect(opened.status).toBe(201)
  145. const channel = await opened.json() as { id: string }
  146. const stream = await fetch(`${claim.serverUrl}/api/realtime/channels/${channel.id}/events`, {
  147. headers: { Authorization: authorization, Accept: 'text/event-stream' },
  148. })
  149. expect(stream.headers.get('content-type')).toContain('text/event-stream')
  150. await stream.body!.cancel()
  151. const send = async (id: string, method: string, params: object) => {
  152. const response = await fetch(`${claim.serverUrl}/api/realtime/channels/${channel.id}/commands`, {
  153. method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json', 'Idempotency-Key': id },
  154. body: JSON.stringify({ method, params }),
  155. })
  156. expect(response.status).toBe(200)
  157. expect(await response.json()).toMatchObject({ state: 'confirmed' })
  158. }
  159. await send('profiles', 'profiles.list', { include_sessions: true })
  160. await send('profile-meta', 'profiles.configure', { name: 'default', ui_meta: { 'hermes-bots': { chat: 'stored-session' } } })
  161. await send('profile-avatar', 'profiles.set_asset', { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' })
  162. await send('hidden-bot', 'session.create', { profile: 'default', title: 'Bot Chat', source: 'ios_bot_group', hidden: true })
  163. expect(pairedFrames.map(frame => frame.method)).toEqual(['profiles.list', 'profiles.configure', 'profiles.set_asset', 'session.create'])
  164. expect(pairedFrames[3]).toMatchObject({ params: { profile: 'default', source: 'ios_bot_group', hidden: true } })
  165. const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
  166. method: 'DELETE', headers: { Authorization: authorization },
  167. })
  168. expect(revoke.status).toBe(200)
  169. expect(await fetch(`${claim.serverUrl}/api/status`, {
  170. headers: { Authorization: authorization },
  171. })).toHaveProperty('status', 401)
  172. })
  173. it('rejects client WebSocket upgrades instead of keeping legacy compatibility', async () => {
  174. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  175. homes.push(home)
  176. const config: ServerConfig = {
  177. host: '127.0.0.1',
  178. port: 8800,
  179. upstream: new URL('http://127.0.0.1:9119'),
  180. allowedHosts: new Set(),
  181. home,
  182. allowInsecureLan: false,
  183. insecureLan: false,
  184. production: true,
  185. }
  186. const application = createApplication({ config })
  187. const node = createNodeServer(application)
  188. await listen(node.server)
  189. closers.push(closeRuntime(node))
  190. const port = (node.server.address() as AddressInfo).port
  191. config.port = port
  192. const status = await new Promise<number>((resolve, reject) => {
  193. const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
  194. origin: `http://127.0.0.1:${port}`,
  195. })
  196. socket.once('unexpected-response', (_request, response) => {
  197. resolve(response.statusCode ?? 0)
  198. response.resume()
  199. })
  200. socket.once('error', reject)
  201. })
  202. expect(status).toBe(410)
  203. })
  204. })