realtimeMigration.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  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: 15300,
  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, channels: ['chat'] })
  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. expect(pairedFrames).toEqual([])
  147. const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
  148. method: 'DELETE', headers: { Authorization: authorization },
  149. })
  150. expect(revoke.status).toBe(200)
  151. expect(await fetch(`${claim.serverUrl}/api/status`, {
  152. headers: { Authorization: authorization },
  153. })).toHaveProperty('status', 401)
  154. })
  155. it('rejects client WebSocket upgrades instead of keeping legacy compatibility', async () => {
  156. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  157. homes.push(home)
  158. const config: ServerConfig = {
  159. host: '127.0.0.1',
  160. port: 15300,
  161. upstream: new URL('http://127.0.0.1:9119'),
  162. allowedHosts: new Set(),
  163. home,
  164. allowInsecureLan: false,
  165. insecureLan: false,
  166. production: true,
  167. }
  168. const application = createApplication({ config })
  169. const node = createNodeServer(application)
  170. await listen(node.server)
  171. closers.push(closeRuntime(node))
  172. const port = (node.server.address() as AddressInfo).port
  173. config.port = port
  174. const status = await new Promise<number>((resolve, reject) => {
  175. const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
  176. origin: `http://127.0.0.1:${port}`,
  177. })
  178. socket.once('unexpected-response', (_request, response) => {
  179. resolve(response.statusCode ?? 0)
  180. response.resume()
  181. })
  182. socket.once('error', reject)
  183. })
  184. expect(status).toBe(410)
  185. })
  186. })