realtimeMigration.test.ts 10 KB

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