app.test.ts 31 KB

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