app.test.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  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. const imagesRoot = join(home, 'images')
  26. mkdirSync(mediaRoot)
  27. mkdirSync(attachmentsRoot)
  28. mkdirSync(imagesRoot)
  29. homes.push(home)
  30. return {
  31. host: '127.0.0.1',
  32. port: 8800,
  33. upstream: new URL(upstream),
  34. allowedHosts: new Set(),
  35. home,
  36. mediaRoot,
  37. attachmentsRoot,
  38. imagesRoot,
  39. mediaOwner: 'samien',
  40. allowInsecureLan: false,
  41. insecureLan: false,
  42. production: false,
  43. }
  44. }
  45. function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
  46. return new Response(JSON.stringify(value), {
  47. ...init,
  48. headers: { 'content-type': 'application/json', ...init.headers },
  49. })
  50. }
  51. function fakeGateway(records: RecordedRequest[]): typeof fetch {
  52. return (async (input: string | URL | Request, init?: RequestInit) => {
  53. const url = new URL(input instanceof Request ? input.url : String(input))
  54. const headers = new Headers(init?.headers)
  55. const rawBody = typeof init?.body === 'string' ? init.body : undefined
  56. const recorded: RecordedRequest = {
  57. path: url.pathname,
  58. method: init?.method ?? 'GET',
  59. search: url.searchParams,
  60. headers,
  61. body: rawBody ? JSON.parse(rawBody) : undefined,
  62. }
  63. records.push(recorded)
  64. switch (url.pathname) {
  65. case '/api/status':
  66. return jsonResponse({ auth_required: true }, {
  67. headers: { 'set-cookie': 'hermes_session_at=access-cookie; Path=/; HttpOnly; SameSite=Lax' },
  68. })
  69. case '/api/auth/me':
  70. return jsonResponse({
  71. user_id: 'user-1', display_name: '测试用户', provider: 'basic',
  72. })
  73. case '/api/profiles':
  74. return jsonResponse({ profiles: [{ name: 'default', is_default: true }] })
  75. case '/api/plugins/yaoyao/profiles':
  76. return jsonResponse({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称', isDefault: true }] })
  77. case '/api/auth/providers':
  78. return jsonResponse({ providers: [{ name: 'basic', supports_password: true }] })
  79. case '/auth/password-login':
  80. return jsonResponse({ ok: true })
  81. case '/api/profiles/sessions':
  82. case '/api/sessions':
  83. return jsonResponse({ sessions: [] })
  84. case '/api/sessions/session-1/messages':
  85. return jsonResponse({ messages: [], pagination: { returned: 0 } })
  86. case '/api/plugins/yaoyao/v1/rooms/11111111-1111-4111-8111-111111111111/messages':
  87. return jsonResponse({ accepted: true })
  88. default:
  89. return jsonResponse({ ok: true })
  90. }
  91. }) as typeof fetch
  92. }
  93. function cookieHeader(response: request.Response): string {
  94. const values = response.headers['set-cookie'] as unknown as string[]
  95. return values.map((value) => value.split(';', 1)[0]).join('; ')
  96. }
  97. describe('8800 BFF', () => {
  98. it('keeps existing CSRF tokens valid across an 8800 restart', async () => {
  99. const config = makeConfig()
  100. const first = createApplication({ config, fetchImpl: fakeGateway([]) })
  101. const bootstrap = await request(first.app.callback())
  102. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  103. const cookies = cookieHeader(bootstrap)
  104. first.close()
  105. const restarted = createApplication({ config, fetchImpl: fakeGateway([]) })
  106. runtimes.push(restarted)
  107. expect(restarted.csrf.verify(cookies, bootstrap.body.csrfToken)).toBe(true)
  108. })
  109. it('installs a fresh Yaoyao plugin through authenticated 9119 without restarting it', async () => {
  110. const records: RecordedRequest[] = []
  111. const baseGateway = fakeGateway(records)
  112. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  113. const url = new URL(input instanceof Request ? input.url : String(input))
  114. if (url.pathname === '/api/dashboard/plugins') return jsonResponse([])
  115. if (url.pathname === '/api/dashboard/agent-plugins/install') {
  116. const rawBody = typeof init?.body === 'string' ? init.body : '{}'
  117. records.push({
  118. path: url.pathname,
  119. method: init?.method ?? 'GET',
  120. search: url.searchParams,
  121. headers: new Headers(init?.headers),
  122. body: JSON.parse(rawBody),
  123. })
  124. return jsonResponse({ ok: true, plugin_name: 'yaoyao', enabled: true })
  125. }
  126. return baseGateway(input, init)
  127. }) as typeof fetch
  128. const runtime = createApplication({
  129. config: makeConfig(),
  130. fetchImpl,
  131. })
  132. runtimes.push(runtime)
  133. const bootstrap = await request(runtime.app.callback())
  134. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  135. const response = await request(runtime.app.callback())
  136. .post('/api/app/plugins/yaoyao/install')
  137. .set('Host', '127.0.0.1:8800')
  138. .set('Origin', 'http://127.0.0.1:8800')
  139. .set('Cookie', cookieHeader(bootstrap))
  140. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  141. .send({ force: false })
  142. .expect(200)
  143. const install = records.find((entry) => entry.path === '/api/dashboard/agent-plugins/install')
  144. expect(install?.body).toEqual({
  145. identifier: 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao',
  146. force: false,
  147. enable: true,
  148. })
  149. expect(response.body).toMatchObject({
  150. ok: true,
  151. plugin_name: 'yaoyao',
  152. restarted: false,
  153. restartRequired: false,
  154. })
  155. })
  156. it('automatically reconciles an older Yaoyao plugin through 9119', async () => {
  157. const records: RecordedRequest[] = []
  158. const baseGateway = fakeGateway(records)
  159. let pluginVersion = '1.7.1'
  160. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  161. const url = new URL(input instanceof Request ? input.url : String(input))
  162. if (url.pathname === '/api/dashboard/plugins') {
  163. return jsonResponse([{ name: 'yaoyao', version: pluginVersion }])
  164. }
  165. if (url.pathname === '/api/plugins/yaoyao/maintenance/storage') {
  166. return jsonResponse({ ready: true })
  167. }
  168. if (url.pathname === '/api/dashboard/agent-plugins/install') {
  169. const rawBody = typeof init?.body === 'string' ? init.body : '{}'
  170. records.push({
  171. path: url.pathname,
  172. method: init?.method ?? 'GET',
  173. search: url.searchParams,
  174. headers: new Headers(init?.headers),
  175. body: JSON.parse(rawBody),
  176. })
  177. pluginVersion = '1.7.2'
  178. return jsonResponse({ ok: true, plugin_name: 'yaoyao', enabled: true })
  179. }
  180. return baseGateway(input, init)
  181. }) as typeof fetch
  182. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  183. runtimes.push(runtime)
  184. const bootstrap = await request(runtime.app.callback())
  185. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  186. const response = await request(runtime.app.callback())
  187. .post('/api/app/plugins/yaoyao/reconcile')
  188. .set('Host', '127.0.0.1:8800')
  189. .set('Origin', 'http://127.0.0.1:8800')
  190. .set('Cookie', cookieHeader(bootstrap))
  191. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  192. .send({})
  193. .expect(200)
  194. expect(response.body).toMatchObject({
  195. updated: true,
  196. installedPluginVersion: '1.7.2',
  197. expectedPluginVersion: '1.7.2',
  198. restarted: false,
  199. restartRequired: false,
  200. })
  201. expect(records.find(entry => entry.path === '/api/dashboard/agent-plugins/install')?.body)
  202. .toMatchObject({ force: true, enable: true })
  203. })
  204. it('refuses to replace an existing Yaoyao before durable storage is ready', async () => {
  205. const records: RecordedRequest[] = []
  206. const baseGateway = fakeGateway(records)
  207. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  208. const url = new URL(input instanceof Request ? input.url : String(input))
  209. if (url.pathname === '/api/dashboard/plugins') {
  210. return jsonResponse([{ name: 'yaoyao', version: '1.7.1' }])
  211. }
  212. if (url.pathname === '/api/plugins/yaoyao/maintenance/storage') {
  213. return jsonResponse({ detail: 'Not Found' }, { status: 404 })
  214. }
  215. return baseGateway(input, init)
  216. }) as typeof fetch
  217. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  218. runtimes.push(runtime)
  219. const bootstrap = await request(runtime.app.callback())
  220. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  221. const response = await request(runtime.app.callback())
  222. .post('/api/app/plugins/yaoyao/install')
  223. .set('Host', '127.0.0.1:8800')
  224. .set('Origin', 'http://127.0.0.1:8800')
  225. .set('Cookie', cookieHeader(bootstrap))
  226. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  227. .send({ force: true })
  228. .expect(409)
  229. expect(response.body.code).toBe('yaoyao_storage_migration_required')
  230. expect(records.some((entry) => entry.path === '/api/dashboard/agent-plugins/install')).toBe(false)
  231. })
  232. it('upgrades an existing Yaoyao only after storage readiness and explicit force', async () => {
  233. const records: RecordedRequest[] = []
  234. const baseGateway = fakeGateway(records)
  235. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  236. const url = new URL(input instanceof Request ? input.url : String(input))
  237. if (url.pathname === '/api/dashboard/plugins') {
  238. return jsonResponse([{ name: 'yaoyao', version: '1.7.1' }])
  239. }
  240. if (url.pathname === '/api/plugins/yaoyao/maintenance/storage') {
  241. return jsonResponse({ ready: true, profiles: [{ profile: 'default', ready: true }] })
  242. }
  243. if (url.pathname === '/api/dashboard/agent-plugins/install') {
  244. const rawBody = typeof init?.body === 'string' ? init.body : '{}'
  245. records.push({
  246. path: url.pathname,
  247. method: init?.method ?? 'GET',
  248. search: url.searchParams,
  249. headers: new Headers(init?.headers),
  250. body: JSON.parse(rawBody),
  251. })
  252. return jsonResponse({ ok: true, plugin_name: 'yaoyao', enabled: true })
  253. }
  254. return baseGateway(input, init)
  255. }) as typeof fetch
  256. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  257. runtimes.push(runtime)
  258. const bootstrap = await request(runtime.app.callback())
  259. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  260. const common = request(runtime.app.callback())
  261. .post('/api/app/plugins/yaoyao/install')
  262. .set('Host', '127.0.0.1:8800')
  263. .set('Origin', 'http://127.0.0.1:8800')
  264. .set('Cookie', cookieHeader(bootstrap))
  265. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  266. await common.send({ force: false }).expect(409)
  267. await request(runtime.app.callback())
  268. .post('/api/app/plugins/yaoyao/install')
  269. .set('Host', '127.0.0.1:8800')
  270. .set('Origin', 'http://127.0.0.1:8800')
  271. .set('Cookie', cookieHeader(bootstrap))
  272. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  273. .send({ force: true })
  274. .expect(200)
  275. const install = records.find((entry) => entry.path === '/api/dashboard/agent-plugins/install')
  276. expect(install?.body).toMatchObject({ force: true, enable: true })
  277. })
  278. it('serves authenticated historical media only from the configured root', async () => {
  279. const config = makeConfig()
  280. writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')
  281. const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
  282. runtimes.push(runtime)
  283. const bootstrap = await request(runtime.app.callback())
  284. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  285. const cookies = cookieHeader(bootstrap)
  286. const response = await request(runtime.app.callback())
  287. .get('/Users/samien/Agents/报告.txt').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(200)
  288. expect(response.text).toBe('历史文件内容')
  289. expect(response.headers['content-disposition']).toContain('inline')
  290. await request(runtime.app.callback())
  291. .get('/Users/samien/Agents/../state.db').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(404)
  292. })
  293. it('serves persisted Hermes attachment references from the restricted attachment root', async () => {
  294. const config = makeConfig()
  295. writeFileSync(join(config.attachmentsRoot, '报告.docx'), 'document bytes')
  296. const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
  297. runtimes.push(runtime)
  298. const bootstrap = await request(runtime.app.callback())
  299. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  300. const response = await request(runtime.app.callback())
  301. .get('/Users/samien/.hermes/attachments/报告.docx')
  302. .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
  303. expect(response.headers['content-length']).toBe(String(Buffer.byteLength('document bytes')))
  304. expect(response.headers['content-disposition']).toContain('inline')
  305. await request(runtime.app.callback())
  306. .get('/attachments/报告.docx')
  307. .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
  308. await request(runtime.app.callback())
  309. .get('/attachments/../state.db')
  310. .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(404)
  311. })
  312. it('serves persisted Hermes image references from the restricted images root', async () => {
  313. const config = makeConfig()
  314. writeFileSync(join(config.imagesRoot, '照片.png'), 'image bytes')
  315. const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
  316. runtimes.push(runtime)
  317. const bootstrap = await request(runtime.app.callback())
  318. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  319. const response = await request(runtime.app.callback())
  320. .get('/Users/samien/.hermes/images/照片.png')
  321. .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
  322. expect(response.body.toString()).toBe('image bytes')
  323. await request(runtime.app.callback())
  324. .get('/Users/samien/.hermes/images/../state.db')
  325. .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(404)
  326. })
  327. it('bootstraps sequentially without exposing the upstream address', async () => {
  328. const records: RecordedRequest[] = []
  329. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  330. runtimes.push(runtime)
  331. const response = await request(runtime.app.callback())
  332. .get('/api/app/bootstrap')
  333. .set('Host', '127.0.0.1:8800')
  334. .expect(200)
  335. expect(records.map((entry) => entry.path)).toEqual([
  336. '/api/status', '/api/auth/me', '/api/profiles', '/api/plugins/yaoyao/profiles',
  337. ])
  338. expect(response.body).toMatchObject({
  339. authRequired: true,
  340. authenticated: true,
  341. user: { user_id: 'user-1' },
  342. profiles: [{ name: 'default', agentName: '竹儿' }],
  343. groupUploadsEnabled: true,
  344. })
  345. expect(response.body.csrfToken).toEqual(expect.any(String))
  346. expect(response.body.status).toEqual({
  347. state: 'unknown',
  348. gatewayRunning: false,
  349. })
  350. expect(JSON.stringify(response.body)).not.toContain('9119')
  351. expect(response.headers['content-security-policy']).toContain("default-src 'self'")
  352. })
  353. it('refreshes profiles from Hermes Bot names and ignores legacy plugin names', async () => {
  354. const records: RecordedRequest[] = []
  355. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  356. runtimes.push(runtime)
  357. const response = await request(runtime.app.callback())
  358. .get('/api/app/profiles')
  359. .set('Host', '127.0.0.1:8800')
  360. .expect(200)
  361. expect(records.map(entry => entry.path)).toEqual([
  362. '/api/profiles', '/api/plugins/yaoyao/profiles',
  363. ])
  364. expect(response.body.profiles).toEqual([
  365. { name: 'default', is_default: true, agentName: '竹儿' },
  366. ])
  367. expect(JSON.stringify(response.body)).not.toContain('旧插件名称')
  368. })
  369. it('omits opener isolation on plaintext LAN origins but keeps it on loopback', async () => {
  370. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  371. runtimes.push(runtime)
  372. const lan = await request(runtime.app.callback())
  373. .get('/healthz')
  374. .set('Host', '192.168.153.155:8800')
  375. .expect(200)
  376. expect(lan.headers['cross-origin-opener-policy']).toBeUndefined()
  377. const loopback = await request(runtime.app.callback())
  378. .get('/healthz')
  379. .set('Host', '127.0.0.1:8800')
  380. .expect(200)
  381. expect(loopback.headers['cross-origin-opener-policy']).toBe('same-origin')
  382. })
  383. it('degrades unsupported Gateway unread endpoints without browser 404 responses', async () => {
  384. const records: RecordedRequest[] = []
  385. const baseGateway = fakeGateway(records)
  386. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  387. const url = new URL(input instanceof Request ? input.url : String(input))
  388. if (url.pathname.startsWith('/api/session-unread')) {
  389. records.push({
  390. path: url.pathname,
  391. method: init?.method ?? 'GET',
  392. search: url.searchParams,
  393. headers: new Headers(init?.headers),
  394. })
  395. const status = (init?.method ?? 'GET') === 'PATCH' ? 405 : 404
  396. return jsonResponse({ detail: status === 405 ? 'Method Not Allowed' : 'Not Found' }, { status })
  397. }
  398. return baseGateway(input, init)
  399. }) as typeof fetch
  400. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  401. runtimes.push(runtime)
  402. const unread = await request(runtime.app.callback())
  403. .get('/api/app/sessions/unread?profile=yaoer')
  404. .set('Host', '127.0.0.1:8800')
  405. .expect(200)
  406. expect(unread.body).toEqual({ profile: 'yaoer', total_unread: 0, sessions: [], supported: false })
  407. const bootstrap = await request(runtime.app.callback())
  408. .get('/api/app/bootstrap')
  409. .set('Host', '127.0.0.1:8800')
  410. .expect(200)
  411. const marked = await request(runtime.app.callback())
  412. .patch('/api/app/sessions/unread/session-1?profile=yaoer')
  413. .set('Host', '127.0.0.1:8800')
  414. .set('Origin', 'http://127.0.0.1:8800')
  415. .set('Cookie', cookieHeader(bootstrap))
  416. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  417. .send({ readMessageCount: 12 })
  418. .expect(200)
  419. expect(marked.body).toEqual({ ok: true, supported: false })
  420. })
  421. it('requires exact Origin and CSRF on login, then sequences the Gateway login', async () => {
  422. const records: RecordedRequest[] = []
  423. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  424. runtimes.push(runtime)
  425. const bootstrap = await request(runtime.app.callback())
  426. .get('/api/app/bootstrap')
  427. .set('Host', '127.0.0.1:8800')
  428. .expect(200)
  429. const cookies = cookieHeader(bootstrap)
  430. await request(runtime.app.callback())
  431. .post('/api/app/login')
  432. .set('Host', '127.0.0.1:8800')
  433. .set('Cookie', cookies)
  434. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  435. .send({ username: 'user', password: 'secret' })
  436. .expect(403)
  437. records.length = 0
  438. const response = await request(runtime.app.callback())
  439. .post('/api/app/login')
  440. .set('Host', '127.0.0.1:8800')
  441. .set('Origin', 'http://127.0.0.1:8800')
  442. .set('Cookie', cookies)
  443. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  444. .send({ username: 'user', password: 'secret' })
  445. .expect(200)
  446. expect(records.map((entry) => entry.path)).toEqual([
  447. '/api/auth/providers',
  448. '/auth/password-login',
  449. '/api/status',
  450. '/api/auth/me',
  451. '/api/profiles',
  452. '/api/plugins/yaoyao/profiles',
  453. ])
  454. expect(records[1]?.body).toEqual({ provider: 'basic', username: 'user', password: 'secret', next: '' })
  455. expect(response.body.authenticated).toBe(true)
  456. expect(JSON.stringify(response.body)).not.toContain('secret')
  457. })
  458. it('forces visibility and compressed-history query invariants', async () => {
  459. const records: RecordedRequest[] = []
  460. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  461. runtimes.push(runtime)
  462. await request(runtime.app.callback())
  463. .get('/api/app/sessions?exclude_sources=evil')
  464. .set('Host', '127.0.0.1:8800')
  465. .expect(200)
  466. expect(records.at(-1)?.path).toBe('/api/profiles/sessions')
  467. expect(records.at(-1)?.search.get('exclude_sources')).toBe('cron,ios_group')
  468. await request(runtime.app.callback())
  469. .get('/api/app/sessions/session-1/messages?offset=3&limit=50&order=oldest&include_compacted=false&profile=default')
  470. .set('Host', '127.0.0.1:8800')
  471. .expect(200)
  472. const history = records.at(-1)!
  473. expect(history.search.get('offset')).toBe('3')
  474. expect(history.search.get('limit')).toBe('50')
  475. expect(history.search.get('order')).toBe('latest')
  476. expect(history.search.get('include_compacted')).toBe('true')
  477. expect(history.search.get('profile')).toBe('default')
  478. })
  479. it('proxies topic pagination and forwards topic-scoped message queries', async () => {
  480. const records: RecordedRequest[] = []
  481. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  482. runtimes.push(runtime)
  483. const roomID = '11111111-1111-4111-8111-111111111111'
  484. const topicID = '22222222-2222-4222-8222-222222222222'
  485. await request(runtime.app.callback())
  486. .get('/api/app/groups/topics?limit=40&cursor=next-global-topic&archived=true&ignored=drop-me')
  487. .set('Host', '127.0.0.1:8800')
  488. .expect(200)
  489. const globalTopics = records.at(-1)!
  490. expect(globalTopics.path).toBe('/api/plugins/yaoyao/v1/topics')
  491. expect(globalTopics.search.get('limit')).toBe('40')
  492. expect(globalTopics.search.get('cursor')).toBe('next-global-topic')
  493. expect(globalTopics.search.has('archived')).toBe(false)
  494. expect(globalTopics.search.has('ignored')).toBe(false)
  495. await request(runtime.app.callback())
  496. .get(`/api/app/groups/rooms/${roomID}/topics?limit=25&cursor=next-topic&archived=true&ignored=drop-me`)
  497. .set('Host', '127.0.0.1:8800')
  498. .expect(200)
  499. const topics = records.at(-1)!
  500. expect(topics.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics`)
  501. expect(topics.search.get('limit')).toBe('25')
  502. expect(topics.search.get('cursor')).toBe('next-topic')
  503. expect(topics.search.get('archived')).toBe('true')
  504. expect(topics.search.has('ignored')).toBe(false)
  505. const bootstrap = await request(runtime.app.callback())
  506. .get('/api/app/bootstrap')
  507. .set('Host', '127.0.0.1:8800')
  508. .expect(200)
  509. await request(runtime.app.callback())
  510. .patch(`/api/app/groups/rooms/${roomID}/topics/${topicID}`)
  511. .set('Host', '127.0.0.1:8800')
  512. .set('Origin', 'http://127.0.0.1:8800')
  513. .set('Cookie', cookieHeader(bootstrap))
  514. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  515. .send({ requestId: '33333333-3333-4333-8333-333333333333', title: '新标题' })
  516. .expect(200)
  517. const renamed = records.at(-1)!
  518. expect(renamed.method).toBe('PATCH')
  519. expect(renamed.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}`)
  520. expect(renamed.body).toEqual({ requestId: '33333333-3333-4333-8333-333333333333', title: '新标题' })
  521. await request(runtime.app.callback())
  522. .delete(`/api/app/groups/rooms/${roomID}/topics/${topicID}`)
  523. .set('Host', '127.0.0.1:8800')
  524. .set('Origin', 'http://127.0.0.1:8800')
  525. .set('Cookie', cookieHeader(bootstrap))
  526. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  527. .send({ requestId: '55555555-5555-4555-8555-555555555555' })
  528. .expect(200)
  529. expect(records.at(-1)?.method).toBe('DELETE')
  530. expect(records.at(-1)?.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}`)
  531. await request(runtime.app.callback())
  532. .post(`/api/app/groups/rooms/${roomID}/topics/${topicID}/restore`)
  533. .set('Host', '127.0.0.1:8800')
  534. .set('Origin', 'http://127.0.0.1:8800')
  535. .set('Cookie', cookieHeader(bootstrap))
  536. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  537. .send({ requestId: '66666666-6666-4666-8666-666666666666' })
  538. .expect(200)
  539. expect(records.at(-1)?.method).toBe('POST')
  540. expect(records.at(-1)?.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}/restore`)
  541. await request(runtime.app.callback())
  542. .patch(`/api/app/groups/rooms/${roomID}/topics/${topicID}/read`)
  543. .set('Host', '127.0.0.1:8800')
  544. .set('Origin', 'http://127.0.0.1:8800')
  545. .set('Cookie', cookieHeader(bootstrap))
  546. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  547. .send({ requestId: '44444444-4444-4444-8444-444444444444', throughSeq: 42 })
  548. .expect(200)
  549. const markedRead = records.at(-1)!
  550. expect(markedRead.method).toBe('PATCH')
  551. expect(markedRead.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}/read`)
  552. expect(markedRead.body).toEqual({ requestId: '44444444-4444-4444-8444-444444444444', throughSeq: 42 })
  553. await request(runtime.app.callback())
  554. .get(`/api/app/groups/rooms/${roomID}/messages?topicId=${topicID}&beforeSeq=40&limit=20`)
  555. .set('Host', '127.0.0.1:8800')
  556. .expect(200)
  557. const messages = records.at(-1)!
  558. expect(messages.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/messages`)
  559. expect(messages.search.get('topicId')).toBe(topicID)
  560. expect(messages.search.get('beforeSeq')).toBe('40')
  561. expect(messages.search.get('limit')).toBe('20')
  562. })
  563. it('keeps unknown API routes out of the SPA fallback', async () => {
  564. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  565. runtimes.push(runtime)
  566. const response = await request(runtime.app.callback())
  567. .get('/api/anything')
  568. .set('Host', '127.0.0.1:8800')
  569. .expect(404)
  570. expect(response.body.code).toBe('not_found')
  571. })
  572. it('reuses a valid CSRF token across tabs instead of invalidating the first tab', async () => {
  573. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
  574. runtimes.push(runtime)
  575. const first = await request(runtime.app.callback())
  576. .get('/api/app/bootstrap')
  577. .set('Host', '127.0.0.1:8800')
  578. .expect(200)
  579. const second = await request(runtime.app.callback())
  580. .get('/api/app/bootstrap')
  581. .set('Host', '127.0.0.1:8800')
  582. .set('Cookie', cookieHeader(first))
  583. .expect(200)
  584. expect(second.body.csrfToken).toBe(first.body.csrfToken)
  585. })
  586. it('refuses anonymous group uploads before reading a multipart body', async () => {
  587. const fetchImpl = (async (input: string | URL | Request) => {
  588. const url = new URL(input instanceof Request ? input.url : String(input))
  589. if (url.pathname === '/api/status') return jsonResponse({ auth_required: true })
  590. if (url.pathname === '/api/auth/me') return jsonResponse({ detail: 'Unauthorized' }, { status: 401 })
  591. return jsonResponse({ profiles: [] })
  592. }) as typeof fetch
  593. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  594. runtimes.push(runtime)
  595. const bootstrap = await request(runtime.app.callback())
  596. .get('/api/app/bootstrap')
  597. .set('Host', '127.0.0.1:8800')
  598. .expect(200)
  599. await request(runtime.app.callback())
  600. .post('/api/app/group-uploads')
  601. .set('Host', '127.0.0.1:8800')
  602. .set('Origin', 'http://127.0.0.1:8800')
  603. .set('Cookie', cookieHeader(bootstrap))
  604. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  605. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  606. .expect(401)
  607. })
  608. it('forces active file content to download instead of executing on the 8800 origin', async () => {
  609. const fetchImpl = (async () => new Response('<script>window.pwned=true</script>', {
  610. status: 200,
  611. headers: { 'content-type': 'text/html; charset=utf-8' },
  612. })) as typeof fetch
  613. const runtime = createApplication({ config: makeConfig(), fetchImpl })
  614. runtimes.push(runtime)
  615. const response = await request(runtime.app.callback())
  616. .get('/api/app/files/1/preview')
  617. .set('Host', '127.0.0.1:8800')
  618. .expect(200)
  619. expect(response.headers['content-type']).toMatch(/^application\/octet-stream/)
  620. expect(response.headers['content-disposition']).toContain('attachment')
  621. })
  622. it('stores group files opaquely and injects the local path only upstream', async () => {
  623. const records: RecordedRequest[] = []
  624. const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
  625. runtimes.push(runtime)
  626. const bootstrap = await request(runtime.app.callback())
  627. .get('/api/app/bootstrap')
  628. .set('Host', '127.0.0.1:8800')
  629. .expect(200)
  630. const cookies = cookieHeader(bootstrap)
  631. const upload = await request(runtime.app.callback())
  632. .post('/api/app/group-uploads')
  633. .set('Host', '127.0.0.1:8800')
  634. .set('Origin', 'http://127.0.0.1:8800')
  635. .set('Cookie', cookies)
  636. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  637. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  638. .expect(201)
  639. expect(upload.body.files[0]).toMatchObject({ name: 'hello.txt', size: 5 })
  640. expect(upload.body.files[0].path).toBeUndefined()
  641. await request(runtime.app.callback())
  642. .post('/api/app/groups/rooms/11111111-1111-4111-8111-111111111111/messages')
  643. .set('Host', '127.0.0.1:8800')
  644. .set('Origin', 'http://127.0.0.1:8800')
  645. .set('Cookie', cookies)
  646. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  647. .send({
  648. requestId: '22222222-2222-4222-8222-222222222222',
  649. clientMessageId: '33333333-3333-4333-8333-333333333333',
  650. content: '请看文件',
  651. mentionAgentIds: [],
  652. uploadIds: [upload.body.files[0].id],
  653. })
  654. .expect(200)
  655. const sent = records.at(-1)?.body as { content: string; uploadIds?: unknown }
  656. expect(sent.uploadIds).toBeUndefined()
  657. expect(sent.content).toContain('请看文件')
  658. expect(sent.content).toContain(runtime.uploads.uploadRoot)
  659. })
  660. })