app.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { mkdtempSync, rmSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import request from 'supertest'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. interface RecordedRequest {
  9. path: string
  10. method: string
  11. search: URLSearchParams
  12. headers: Headers
  13. body?: unknown
  14. }
  15. const runtimes: ApplicationRuntime[] = []
  16. const homes: string[] = []
  17. afterEach(() => {
  18. for (const runtime of runtimes.splice(0)) runtime.close()
  19. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  20. })
  21. function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
  22. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-server-'))
  23. homes.push(home)
  24. return {
  25. host: '127.0.0.1',
  26. port: 8800,
  27. upstream: new URL(upstream),
  28. allowedHosts: new Set(),
  29. home,
  30. allowInsecureLan: false,
  31. insecureLan: false,
  32. production: false,
  33. }
  34. }
  35. function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
  36. return new Response(JSON.stringify(value), {
  37. ...init,
  38. headers: { 'content-type': 'application/json', ...init.headers },
  39. })
  40. }
  41. function fakeGateway(records: RecordedRequest[]): typeof fetch {
  42. return (async (input: string | URL | Request, init?: RequestInit) => {
  43. const url = new URL(input instanceof Request ? input.url : String(input))
  44. const headers = new Headers(init?.headers)
  45. const rawBody = typeof init?.body === 'string' ? init.body : undefined
  46. const recorded: RecordedRequest = {
  47. path: url.pathname,
  48. method: init?.method ?? 'GET',
  49. search: url.searchParams,
  50. headers,
  51. body: rawBody ? JSON.parse(rawBody) : undefined,
  52. }
  53. records.push(recorded)
  54. switch (url.pathname) {
  55. case '/api/status':
  56. return jsonResponse({ auth_required: true }, {
  57. headers: { 'set-cookie': 'hermes_session_at=access-cookie; Path=/; HttpOnly; SameSite=Lax' },
  58. })
  59. case '/api/auth/me':
  60. return jsonResponse({
  61. user_id: 'user-1', display_name: '测试用户', provider: 'basic',
  62. })
  63. case '/api/profiles':
  64. return jsonResponse({ profiles: [{ name: 'default', is_default: true }] })
  65. case '/api/plugins/yaoyao/profiles':
  66. return jsonResponse({ profiles: [{ name: 'default', agentName: '夭夭', isDefault: true }] })
  67. case '/api/auth/providers':
  68. return jsonResponse({ providers: [{ name: 'basic', supports_password: true }] })
  69. case '/auth/password-login':
  70. return jsonResponse({ ok: true })
  71. case '/api/profiles/sessions':
  72. case '/api/sessions':
  73. return jsonResponse({ sessions: [] })
  74. case '/api/sessions/session-1/messages':
  75. return jsonResponse({ messages: [], pagination: { returned: 0 } })
  76. case '/api/plugins/yaoyao/v1/rooms/11111111-1111-4111-8111-111111111111/messages':
  77. return jsonResponse({ accepted: true })
  78. default:
  79. return jsonResponse({ ok: true })
  80. }
  81. }) as typeof fetch
  82. }
  83. function cookieHeader(response: request.Response): string {
  84. const values = response.headers['set-cookie'] as unknown as string[]
  85. return values.map((value) => value.split(';', 1)[0]).join('; ')
  86. }
  87. describe('8800 BFF', () => {
  88. it('bootstraps sequentially without exposing the upstream address', async () => {
  89. const records: RecordedRequest[] = []
  90. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  91. runtimes.push(runtime)
  92. const response = await request(runtime.app.callback())
  93. .get('/api/app/bootstrap')
  94. .set('Host', '127.0.0.1:8800')
  95. .expect(200)
  96. expect(records.map((entry) => entry.path)).toEqual([
  97. '/api/status', '/api/auth/me', '/api/profiles', '/api/plugins/yaoyao/profiles',
  98. ])
  99. expect(response.body).toMatchObject({
  100. authRequired: true,
  101. authenticated: true,
  102. user: { user_id: 'user-1' },
  103. profiles: [{ name: 'default', agentName: '夭夭' }],
  104. groupUploadsEnabled: true,
  105. })
  106. expect(response.body.csrfToken).toEqual(expect.any(String))
  107. expect(response.body.status).toEqual({
  108. state: 'unknown',
  109. gatewayRunning: false,
  110. })
  111. expect(JSON.stringify(response.body)).not.toContain('9119')
  112. expect(response.headers['content-security-policy']).toContain("default-src 'self'")
  113. })
  114. it('requires exact Origin and CSRF on login, then sequences the Gateway login', async () => {
  115. const records: RecordedRequest[] = []
  116. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  117. runtimes.push(runtime)
  118. const bootstrap = await request(runtime.app.callback())
  119. .get('/api/app/bootstrap')
  120. .set('Host', '127.0.0.1:8800')
  121. .expect(200)
  122. const cookies = cookieHeader(bootstrap)
  123. await request(runtime.app.callback())
  124. .post('/api/app/login')
  125. .set('Host', '127.0.0.1:8800')
  126. .set('Cookie', cookies)
  127. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  128. .send({ username: 'user', password: 'secret' })
  129. .expect(403)
  130. records.length = 0
  131. const response = await request(runtime.app.callback())
  132. .post('/api/app/login')
  133. .set('Host', '127.0.0.1:8800')
  134. .set('Origin', 'http://127.0.0.1:8800')
  135. .set('Cookie', cookies)
  136. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  137. .send({ username: 'user', password: 'secret' })
  138. .expect(200)
  139. expect(records.map((entry) => entry.path)).toEqual([
  140. '/api/auth/providers',
  141. '/auth/password-login',
  142. '/api/status',
  143. '/api/auth/me',
  144. '/api/profiles',
  145. '/api/plugins/yaoyao/profiles',
  146. ])
  147. expect(records[1]?.body).toEqual({ provider: 'basic', username: 'user', password: 'secret', next: '' })
  148. expect(response.body.authenticated).toBe(true)
  149. expect(JSON.stringify(response.body)).not.toContain('secret')
  150. })
  151. it('forces visibility and compressed-history query invariants', async () => {
  152. const records: RecordedRequest[] = []
  153. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  154. runtimes.push(runtime)
  155. await request(runtime.app.callback())
  156. .get('/api/app/sessions?exclude_sources=evil')
  157. .set('Host', '127.0.0.1:8800')
  158. .expect(200)
  159. expect(records.at(-1)?.path).toBe('/api/profiles/sessions')
  160. expect(records.at(-1)?.search.get('exclude_sources')).toBe('cron,ios_group')
  161. await request(runtime.app.callback())
  162. .get('/api/app/sessions/session-1/messages?offset=3&limit=50&order=oldest&include_compacted=false&profile=default')
  163. .set('Host', '127.0.0.1:8800')
  164. .expect(200)
  165. const history = records.at(-1)!
  166. expect(history.search.get('offset')).toBe('3')
  167. expect(history.search.get('limit')).toBe('50')
  168. expect(history.search.get('order')).toBe('latest')
  169. expect(history.search.get('include_compacted')).toBe('true')
  170. expect(history.search.get('profile')).toBe('default')
  171. })
  172. it('keeps unknown API routes out of the SPA fallback', async () => {
  173. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  174. runtimes.push(runtime)
  175. const response = await request(runtime.app.callback())
  176. .get('/api/anything')
  177. .set('Host', '127.0.0.1:8800')
  178. .expect(404)
  179. expect(response.body.code).toBe('not_found')
  180. })
  181. it('reuses a valid CSRF token across tabs instead of invalidating the first tab', async () => {
  182. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  183. runtimes.push(runtime)
  184. const first = await request(runtime.app.callback())
  185. .get('/api/app/bootstrap')
  186. .set('Host', '127.0.0.1:8800')
  187. .expect(200)
  188. const second = await request(runtime.app.callback())
  189. .get('/api/app/bootstrap')
  190. .set('Host', '127.0.0.1:8800')
  191. .set('Cookie', cookieHeader(first))
  192. .expect(200)
  193. expect(second.body.csrfToken).toBe(first.body.csrfToken)
  194. })
  195. it('refuses anonymous group uploads before reading a multipart body', async () => {
  196. const fetchImpl = (async (input: string | URL | Request) => {
  197. const url = new URL(input instanceof Request ? input.url : String(input))
  198. if (url.pathname === '/api/status') return jsonResponse({ auth_required: true })
  199. if (url.pathname === '/api/auth/me') return jsonResponse({ detail: 'Unauthorized' }, { status: 401 })
  200. return jsonResponse({ profiles: [] })
  201. }) as typeof fetch
  202. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  203. runtimes.push(runtime)
  204. const bootstrap = await request(runtime.app.callback())
  205. .get('/api/app/bootstrap')
  206. .set('Host', '127.0.0.1:8800')
  207. .expect(200)
  208. await request(runtime.app.callback())
  209. .post('/api/app/group-uploads')
  210. .set('Host', '127.0.0.1:8800')
  211. .set('Origin', 'http://127.0.0.1:8800')
  212. .set('Cookie', cookieHeader(bootstrap))
  213. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  214. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  215. .expect(401)
  216. })
  217. it('forces active file content to download instead of executing on the 8800 origin', async () => {
  218. const fetchImpl = (async () => new Response('<script>window.pwned=true</script>', {
  219. status: 200,
  220. headers: { 'content-type': 'text/html; charset=utf-8' },
  221. })) as typeof fetch
  222. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  223. runtimes.push(runtime)
  224. const response = await request(runtime.app.callback())
  225. .get('/api/app/files/1/preview')
  226. .set('Host', '127.0.0.1:8800')
  227. .expect(200)
  228. expect(response.headers['content-type']).toMatch(/^application\/octet-stream/)
  229. expect(response.headers['content-disposition']).toContain('attachment')
  230. })
  231. it('stores group files opaquely and injects the local path only upstream', async () => {
  232. const records: RecordedRequest[] = []
  233. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  234. runtimes.push(runtime)
  235. const bootstrap = await request(runtime.app.callback())
  236. .get('/api/app/bootstrap')
  237. .set('Host', '127.0.0.1:8800')
  238. .expect(200)
  239. const cookies = cookieHeader(bootstrap)
  240. const upload = await request(runtime.app.callback())
  241. .post('/api/app/group-uploads')
  242. .set('Host', '127.0.0.1:8800')
  243. .set('Origin', 'http://127.0.0.1:8800')
  244. .set('Cookie', cookies)
  245. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  246. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  247. .expect(201)
  248. expect(upload.body.files[0]).toMatchObject({ name: 'hello.txt', size: 5 })
  249. expect(upload.body.files[0].path).toBeUndefined()
  250. await request(runtime.app.callback())
  251. .post('/api/app/groups/rooms/11111111-1111-4111-8111-111111111111/messages')
  252. .set('Host', '127.0.0.1:8800')
  253. .set('Origin', 'http://127.0.0.1:8800')
  254. .set('Cookie', cookies)
  255. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  256. .send({
  257. requestId: '22222222-2222-4222-8222-222222222222',
  258. clientMessageId: '33333333-3333-4333-8333-333333333333',
  259. content: '请看文件',
  260. mentionAgentIds: [],
  261. uploadIds: [upload.body.files[0].id],
  262. })
  263. .expect(200)
  264. const sent = records.at(-1)?.body as { content: string; uploadIds?: unknown }
  265. expect(sent.uploadIds).toBeUndefined()
  266. expect(sent.content).toContain('请看文件')
  267. expect(sent.content).toContain(runtime.uploads.uploadRoot)
  268. })
  269. })