app.test.ts 18 KB

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