app.test.ts 21 KB

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