app.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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. const mediaRoot = join(home, 'media')
  24. const attachmentsRoot = join(home, 'attachments')
  25. mkdirSync(mediaRoot)
  26. mkdirSync(attachmentsRoot)
  27. homes.push(home)
  28. return {
  29. host: '127.0.0.1',
  30. port: 8800,
  31. upstream: new URL(upstream),
  32. allowedHosts: new Set(),
  33. home,
  34. mediaRoot,
  35. attachmentsRoot,
  36. mediaOwner: 'samien',
  37. allowInsecureLan: false,
  38. insecureLan: false,
  39. production: false,
  40. }
  41. }
  42. function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
  43. return new Response(JSON.stringify(value), {
  44. ...init,
  45. headers: { 'content-type': 'application/json', ...init.headers },
  46. })
  47. }
  48. function fakeGateway(records: RecordedRequest[]): typeof fetch {
  49. return (async (input: string | URL | Request, init?: RequestInit) => {
  50. const url = new URL(input instanceof Request ? input.url : String(input))
  51. const headers = new Headers(init?.headers)
  52. const rawBody = typeof init?.body === 'string' ? init.body : undefined
  53. const recorded: RecordedRequest = {
  54. path: url.pathname,
  55. method: init?.method ?? 'GET',
  56. search: url.searchParams,
  57. headers,
  58. body: rawBody ? JSON.parse(rawBody) : undefined,
  59. }
  60. records.push(recorded)
  61. switch (url.pathname) {
  62. case '/api/status':
  63. return jsonResponse({ auth_required: true }, {
  64. headers: { 'set-cookie': 'hermes_session_at=access-cookie; Path=/; HttpOnly; SameSite=Lax' },
  65. })
  66. case '/api/auth/me':
  67. return jsonResponse({
  68. user_id: 'user-1', display_name: '测试用户', provider: 'basic',
  69. })
  70. case '/api/profiles':
  71. return jsonResponse({ profiles: [{ name: 'default', is_default: true }] })
  72. case '/api/plugins/yaoyao/profiles':
  73. return jsonResponse({ profiles: [{ name: 'default', agentName: '夭夭', isDefault: true }] })
  74. case '/api/auth/providers':
  75. return jsonResponse({ providers: [{ name: 'basic', supports_password: true }] })
  76. case '/auth/password-login':
  77. return jsonResponse({ ok: true })
  78. case '/api/profiles/sessions':
  79. case '/api/sessions':
  80. return jsonResponse({ sessions: [] })
  81. case '/api/sessions/session-1/messages':
  82. return jsonResponse({ messages: [], pagination: { returned: 0 } })
  83. case '/api/plugins/yaoyao/v1/rooms/11111111-1111-4111-8111-111111111111/messages':
  84. return jsonResponse({ accepted: true })
  85. default:
  86. return jsonResponse({ ok: true })
  87. }
  88. }) as typeof fetch
  89. }
  90. function cookieHeader(response: request.Response): string {
  91. const values = response.headers['set-cookie'] as unknown as string[]
  92. return values.map((value) => value.split(';', 1)[0]).join('; ')
  93. }
  94. describe('8800 BFF', () => {
  95. it('keeps existing CSRF tokens valid across an 8800 restart', async () => {
  96. const config = makeConfig()
  97. const first = createApplication({ config, fetchImpl: fakeGateway([]) })
  98. const bootstrap = await request(first.app.callback())
  99. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  100. const cookies = cookieHeader(bootstrap)
  101. first.close()
  102. const restarted = createApplication({ config, fetchImpl: fakeGateway([]) })
  103. runtimes.push(restarted)
  104. expect(restarted.csrf.verify(cookies, bootstrap.body.csrfToken)).toBe(true)
  105. })
  106. it('serves authenticated historical media only from the configured root', async () => {
  107. const config = makeConfig()
  108. writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')
  109. const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
  110. runtimes.push(runtime)
  111. const bootstrap = await request(runtime.app.callback())
  112. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  113. const cookies = cookieHeader(bootstrap)
  114. const response = await request(runtime.app.callback())
  115. .get('/Users/samien/Agents/报告.txt').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(200)
  116. expect(response.text).toBe('历史文件内容')
  117. expect(response.headers['content-disposition']).toContain('inline')
  118. await request(runtime.app.callback())
  119. .get('/Users/samien/Agents/../state.db').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(404)
  120. })
  121. it('serves persisted Hermes attachment references from the restricted attachment root', async () => {
  122. const config = makeConfig()
  123. writeFileSync(join(config.attachmentsRoot, '报告.docx'), 'document bytes')
  124. const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
  125. runtimes.push(runtime)
  126. const bootstrap = await request(runtime.app.callback())
  127. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  128. const response = await request(runtime.app.callback())
  129. .get('/Users/samien/.hermes/attachments/报告.docx')
  130. .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
  131. expect(response.headers['content-length']).toBe(String(Buffer.byteLength('document bytes')))
  132. expect(response.headers['content-disposition']).toContain('inline')
  133. })
  134. it('bootstraps sequentially without exposing the upstream address', async () => {
  135. const records: RecordedRequest[] = []
  136. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  137. runtimes.push(runtime)
  138. const response = await request(runtime.app.callback())
  139. .get('/api/app/bootstrap')
  140. .set('Host', '127.0.0.1:8800')
  141. .expect(200)
  142. expect(records.map((entry) => entry.path)).toEqual([
  143. '/api/status', '/api/auth/me', '/api/profiles', '/api/plugins/yaoyao/profiles',
  144. ])
  145. expect(response.body).toMatchObject({
  146. authRequired: true,
  147. authenticated: true,
  148. user: { user_id: 'user-1' },
  149. profiles: [{ name: 'default', agentName: '夭夭' }],
  150. groupUploadsEnabled: true,
  151. })
  152. expect(response.body.csrfToken).toEqual(expect.any(String))
  153. expect(response.body.status).toEqual({
  154. state: 'unknown',
  155. gatewayRunning: false,
  156. })
  157. expect(JSON.stringify(response.body)).not.toContain('9119')
  158. expect(response.headers['content-security-policy']).toContain("default-src 'self'")
  159. })
  160. it('omits opener isolation on plaintext LAN origins but keeps it on loopback', async () => {
  161. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  162. runtimes.push(runtime)
  163. const lan = await request(runtime.app.callback())
  164. .get('/healthz')
  165. .set('Host', '192.168.153.155:8800')
  166. .expect(200)
  167. expect(lan.headers['cross-origin-opener-policy']).toBeUndefined()
  168. const loopback = await request(runtime.app.callback())
  169. .get('/healthz')
  170. .set('Host', '127.0.0.1:8800')
  171. .expect(200)
  172. expect(loopback.headers['cross-origin-opener-policy']).toBe('same-origin')
  173. })
  174. it('degrades unsupported Gateway unread endpoints without browser 404 responses', async () => {
  175. const records: RecordedRequest[] = []
  176. const baseGateway = fakeGateway(records)
  177. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  178. const url = new URL(input instanceof Request ? input.url : String(input))
  179. if (url.pathname.startsWith('/api/session-unread')) {
  180. records.push({
  181. path: url.pathname,
  182. method: init?.method ?? 'GET',
  183. search: url.searchParams,
  184. headers: new Headers(init?.headers),
  185. })
  186. const status = (init?.method ?? 'GET') === 'PATCH' ? 405 : 404
  187. return jsonResponse({ detail: status === 405 ? 'Method Not Allowed' : 'Not Found' }, { status })
  188. }
  189. return baseGateway(input, init)
  190. }) as typeof fetch
  191. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  192. runtimes.push(runtime)
  193. const unread = await request(runtime.app.callback())
  194. .get('/api/app/sessions/unread?profile=yaoer')
  195. .set('Host', '127.0.0.1:8800')
  196. .expect(200)
  197. expect(unread.body).toEqual({ profile: 'yaoer', total_unread: 0, sessions: [], supported: false })
  198. const bootstrap = await request(runtime.app.callback())
  199. .get('/api/app/bootstrap')
  200. .set('Host', '127.0.0.1:8800')
  201. .expect(200)
  202. const marked = await request(runtime.app.callback())
  203. .patch('/api/app/sessions/unread/session-1?profile=yaoer')
  204. .set('Host', '127.0.0.1:8800')
  205. .set('Origin', 'http://127.0.0.1:8800')
  206. .set('Cookie', cookieHeader(bootstrap))
  207. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  208. .send({ readMessageCount: 12 })
  209. .expect(200)
  210. expect(marked.body).toEqual({ ok: true, supported: false })
  211. })
  212. it('requires exact Origin and CSRF on login, then sequences the Gateway login', async () => {
  213. const records: RecordedRequest[] = []
  214. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  215. runtimes.push(runtime)
  216. const bootstrap = await request(runtime.app.callback())
  217. .get('/api/app/bootstrap')
  218. .set('Host', '127.0.0.1:8800')
  219. .expect(200)
  220. const cookies = cookieHeader(bootstrap)
  221. await request(runtime.app.callback())
  222. .post('/api/app/login')
  223. .set('Host', '127.0.0.1:8800')
  224. .set('Cookie', cookies)
  225. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  226. .send({ username: 'user', password: 'secret' })
  227. .expect(403)
  228. records.length = 0
  229. const response = await request(runtime.app.callback())
  230. .post('/api/app/login')
  231. .set('Host', '127.0.0.1:8800')
  232. .set('Origin', 'http://127.0.0.1:8800')
  233. .set('Cookie', cookies)
  234. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  235. .send({ username: 'user', password: 'secret' })
  236. .expect(200)
  237. expect(records.map((entry) => entry.path)).toEqual([
  238. '/api/auth/providers',
  239. '/auth/password-login',
  240. '/api/status',
  241. '/api/auth/me',
  242. '/api/profiles',
  243. '/api/plugins/yaoyao/profiles',
  244. ])
  245. expect(records[1]?.body).toEqual({ provider: 'basic', username: 'user', password: 'secret', next: '' })
  246. expect(response.body.authenticated).toBe(true)
  247. expect(JSON.stringify(response.body)).not.toContain('secret')
  248. })
  249. it('forces visibility and compressed-history query invariants', async () => {
  250. const records: RecordedRequest[] = []
  251. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  252. runtimes.push(runtime)
  253. await request(runtime.app.callback())
  254. .get('/api/app/sessions?exclude_sources=evil')
  255. .set('Host', '127.0.0.1:8800')
  256. .expect(200)
  257. expect(records.at(-1)?.path).toBe('/api/profiles/sessions')
  258. expect(records.at(-1)?.search.get('exclude_sources')).toBe('cron,ios_group')
  259. await request(runtime.app.callback())
  260. .get('/api/app/sessions/session-1/messages?offset=3&limit=50&order=oldest&include_compacted=false&profile=default')
  261. .set('Host', '127.0.0.1:8800')
  262. .expect(200)
  263. const history = records.at(-1)!
  264. expect(history.search.get('offset')).toBe('3')
  265. expect(history.search.get('limit')).toBe('50')
  266. expect(history.search.get('order')).toBe('latest')
  267. expect(history.search.get('include_compacted')).toBe('true')
  268. expect(history.search.get('profile')).toBe('default')
  269. })
  270. it('keeps unknown API routes out of the SPA fallback', async () => {
  271. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  272. runtimes.push(runtime)
  273. const response = await request(runtime.app.callback())
  274. .get('/api/anything')
  275. .set('Host', '127.0.0.1:8800')
  276. .expect(404)
  277. expect(response.body.code).toBe('not_found')
  278. })
  279. it('reuses a valid CSRF token across tabs instead of invalidating the first tab', async () => {
  280. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  281. runtimes.push(runtime)
  282. const first = await request(runtime.app.callback())
  283. .get('/api/app/bootstrap')
  284. .set('Host', '127.0.0.1:8800')
  285. .expect(200)
  286. const second = await request(runtime.app.callback())
  287. .get('/api/app/bootstrap')
  288. .set('Host', '127.0.0.1:8800')
  289. .set('Cookie', cookieHeader(first))
  290. .expect(200)
  291. expect(second.body.csrfToken).toBe(first.body.csrfToken)
  292. })
  293. it('refuses anonymous group uploads before reading a multipart body', async () => {
  294. const fetchImpl = (async (input: string | URL | Request) => {
  295. const url = new URL(input instanceof Request ? input.url : String(input))
  296. if (url.pathname === '/api/status') return jsonResponse({ auth_required: true })
  297. if (url.pathname === '/api/auth/me') return jsonResponse({ detail: 'Unauthorized' }, { status: 401 })
  298. return jsonResponse({ profiles: [] })
  299. }) as typeof fetch
  300. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  301. runtimes.push(runtime)
  302. const bootstrap = await request(runtime.app.callback())
  303. .get('/api/app/bootstrap')
  304. .set('Host', '127.0.0.1:8800')
  305. .expect(200)
  306. await request(runtime.app.callback())
  307. .post('/api/app/group-uploads')
  308. .set('Host', '127.0.0.1:8800')
  309. .set('Origin', 'http://127.0.0.1:8800')
  310. .set('Cookie', cookieHeader(bootstrap))
  311. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  312. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  313. .expect(401)
  314. })
  315. it('forces active file content to download instead of executing on the 8800 origin', async () => {
  316. const fetchImpl = (async () => new Response('<script>window.pwned=true</script>', {
  317. status: 200,
  318. headers: { 'content-type': 'text/html; charset=utf-8' },
  319. })) as typeof fetch
  320. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  321. runtimes.push(runtime)
  322. const response = await request(runtime.app.callback())
  323. .get('/api/app/files/1/preview')
  324. .set('Host', '127.0.0.1:8800')
  325. .expect(200)
  326. expect(response.headers['content-type']).toMatch(/^application\/octet-stream/)
  327. expect(response.headers['content-disposition']).toContain('attachment')
  328. })
  329. it('stores group files opaquely and injects the local path only upstream', async () => {
  330. const records: RecordedRequest[] = []
  331. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  332. runtimes.push(runtime)
  333. const bootstrap = await request(runtime.app.callback())
  334. .get('/api/app/bootstrap')
  335. .set('Host', '127.0.0.1:8800')
  336. .expect(200)
  337. const cookies = cookieHeader(bootstrap)
  338. const upload = await request(runtime.app.callback())
  339. .post('/api/app/group-uploads')
  340. .set('Host', '127.0.0.1:8800')
  341. .set('Origin', 'http://127.0.0.1:8800')
  342. .set('Cookie', cookies)
  343. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  344. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  345. .expect(201)
  346. expect(upload.body.files[0]).toMatchObject({ name: 'hello.txt', size: 5 })
  347. expect(upload.body.files[0].path).toBeUndefined()
  348. await request(runtime.app.callback())
  349. .post('/api/app/groups/rooms/11111111-1111-4111-8111-111111111111/messages')
  350. .set('Host', '127.0.0.1:8800')
  351. .set('Origin', 'http://127.0.0.1:8800')
  352. .set('Cookie', cookies)
  353. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  354. .send({
  355. requestId: '22222222-2222-4222-8222-222222222222',
  356. clientMessageId: '33333333-3333-4333-8333-333333333333',
  357. content: '请看文件',
  358. mentionAgentIds: [],
  359. uploadIds: [upload.body.files[0].id],
  360. })
  361. .expect(200)
  362. const sent = records.at(-1)?.body as { content: string; uploadIds?: unknown }
  363. expect(sent.uploadIds).toBeUndefined()
  364. expect(sent.content).toContain('请看文件')
  365. expect(sent.content).toContain(runtime.uploads.uploadRoot)
  366. })
  367. })