workspaceRoutes.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  6. import request from 'supertest'
  7. import type Koa from 'koa'
  8. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  9. import { loadServerConfig } from '../../src/server/config'
  10. import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
  11. import { WorkspaceAssets } from '../../src/server/workspaceAssets'
  12. let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[]
  13. const first: LocalUser = {
  14. id: 'first',
  15. username: 'first',
  16. role: 'admin',
  17. enabled: true,
  18. mustChangePassword: false,
  19. createdAt: 1,
  20. updatedAt: 1,
  21. }
  22. class TestAuth extends LocalAuthStore {
  23. override current(ctx: Koa.Context) {
  24. return this.require(ctx)
  25. }
  26. override require(ctx: Koa.Context) {
  27. return { ...first, id: ctx.get('x-test-user') || 'first' }
  28. }
  29. override requireAdmin(ctx: Koa.Context) {
  30. return this.require(ctx)
  31. }
  32. override currentFromCookieHeader() {
  33. return first
  34. }
  35. override isUserActive() {
  36. return true
  37. }
  38. }
  39. function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, user = 'first') {
  40. return request(runtime.app.callback())
  41. [method](path)
  42. .set('Host', '127.0.0.1:8800')
  43. .set('Origin', 'http://127.0.0.1:8800')
  44. .set('Cookie', cookie)
  45. .set('X-CSRF-Token', csrf)
  46. .set('x-test-user', user)
  47. }
  48. beforeEach(async () => {
  49. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-routes-'))
  50. upstream = []
  51. const config = loadServerConfig({
  52. HERMES_YAOYAO_HOME: home,
  53. HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119',
  54. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  55. })
  56. runtime = createApplication({
  57. config,
  58. auth: new TestAuth(home, false),
  59. fetchImpl: (async (input) => {
  60. const path = new URL(String(input)).pathname
  61. upstream.push(path)
  62. const body =
  63. path === '/api/status'
  64. ? { auth_required: true }
  65. : path === '/api/auth/me'
  66. ? { user_id: 'upstream' }
  67. : path === '/api/profiles'
  68. ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
  69. : { ok: true }
  70. return new Response(JSON.stringify(body), {
  71. status: path.includes('/plugins/') ? 404 : 200,
  72. headers: { 'Content-Type': 'application/json' },
  73. })
  74. }) as typeof fetch,
  75. })
  76. const b = await request(runtime.app.callback())
  77. .get('/api/app/bootstrap')
  78. .set('Host', '127.0.0.1:8800')
  79. .expect(200)
  80. cookie = (b.headers['set-cookie'] as unknown as string[]).map((v) => v.split(';')[0]).join('; ')
  81. csrf = b.body.csrfToken
  82. })
  83. afterEach(() => {
  84. runtime.close()
  85. rmSync(home, { recursive: true, force: true })
  86. })
  87. describe('application workspace HTTP contract', () => {
  88. it('archives native-chat uploads through the standard file API with user ownership', async () => {
  89. const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
  90. await assets.archiveText(
  91. 'first',
  92. '{"path":"/tmp/native-upload.txt"}',
  93. 'local',
  94. 'default',
  95. 'native-session',
  96. 'native-message',
  97. 'user',
  98. )
  99. const page = await req('get', '/api/app/files').expect(200)
  100. expect(page.body.items[0]).toMatchObject({
  101. name: 'native-upload.txt',
  102. sender: 'user',
  103. profile: 'default',
  104. })
  105. expect(upstream).toContain('/api/files/download')
  106. expect((await req('get', '/api/app/files', 'second')).body.items).toEqual([])
  107. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  108. })
  109. it('works without plugin discovery, installation, or any plugin HTTP request', async () => {
  110. const capabilities = (await req('get', '/api/app/capabilities').expect(200)).body.features
  111. expect(capabilities).toContain('editableGroups')
  112. expect(capabilities).not.toContain('immutableGroups')
  113. await req('get', '/api/app/files').expect(200)
  114. await req('get', '/api/app/voice/runtime').expect(200)
  115. await req('get', '/api/app/system/update/status').expect(200)
  116. await req('post', '/api/app/plugins/yaoyao/reconcile').send({}).expect(410)
  117. await req('get', '/api/plugins/yaoyao/v1/capabilities').expect(410)
  118. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  119. })
  120. it('changes members over HTTP while protecting the administrator and ownership', async () => {
  121. const a = (
  122. await req('post', '/api/app/agents').send({ name: '编辑', profile: 'default' }).expect(201)
  123. ).body.agent
  124. const b = (
  125. await req('post', '/api/app/agents').send({ name: '审查', profile: 'default' }).expect(201)
  126. ).body.agent
  127. const c = (
  128. await req('post', '/api/app/conversations')
  129. .send({ name: '团队', memberIds: [a.id, b.id], administratorId: a.id })
  130. .expect(201)
  131. ).body.conversation
  132. const rows = await req('get', '/api/app/conversations').expect(200)
  133. expect(rows.body.conversations).toHaveLength(3)
  134. await req('patch', `/api/app/conversations/${c.id}`)
  135. .send({ memberIds: [b.id], administratorId: b.id })
  136. .expect(400)
  137. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id] }).expect(200)
  138. expect((await req('get', `/api/app/conversations/${c.id}`)).body.conversation.memberIds).toEqual([a.id])
  139. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id, b.id] }).expect(200)
  140. await req('get', `/api/app/conversations/${c.id}`, 'second').expect(404)
  141. await req('patch', `/api/app/agents/${a.id}`, 'second').send({ name: '入侵' }).expect(404)
  142. expect((await req('get', '/api/app/events', 'second')).body.events).toEqual([])
  143. expect(upstream.some((p) => p.includes('sessions'))).toBe(false)
  144. })
  145. it('archives files on the Web server, preserves native library fields, and blocks cross-user downloads', async () => {
  146. const f = (
  147. await req('post', '/api/app/uploads')
  148. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  149. .expect(201)
  150. ).body.files[0]
  151. expect(f.path).toBeUndefined()
  152. expect(runtime.uploads.cleanupUncommitted(0)).toBe(0)
  153. const list = await req('get', '/api/app/files').expect(200),
  154. item = list.body.items[0]
  155. expect(typeof item.id).toBe('number')
  156. expect(item.path).toBe(`/api/app/files/${f.id}/download`)
  157. expect(item).toMatchObject({ name: 'hello.txt', size: 5, exists: true, archiveStatus: 'ready' })
  158. expect((await req('get', item.path).expect(200)).text).toBe('hello')
  159. await req('get', item.path, 'second').expect(404)
  160. await req('get', `/api/app/files/${item.id}/download`, 'second').expect(404)
  161. expect((await req('get', '/api/app/files', 'second')).body.total).toBe(0)
  162. expect(upstream.some((p) => p.includes('/files'))).toBe(false)
  163. })
  164. it('does not serve uploaded HTML or SVG as executable same-origin previews', async () => {
  165. const f = (
  166. await req('post', '/api/app/uploads')
  167. .attach('files', Buffer.from('<svg onload="alert(1)"/>'), {
  168. filename: 'unsafe.svg',
  169. contentType: 'image/svg+xml',
  170. })
  171. .expect(201)
  172. ).body.files[0]
  173. const response = await req('get', `/api/app/files/${f.id}/preview`).expect(200)
  174. expect(response.headers['content-type']).toContain('application/octet-stream')
  175. expect(response.headers['content-disposition']).toContain('attachment')
  176. })
  177. it('persists encrypted voice credentials while giving each user their own selected voice', async () => {
  178. await req('put', '/api/app/admin/duplex-voice')
  179. .send({
  180. apiKey: 'private-test-key',
  181. voices: [
  182. { id: 'a', name: '甲' },
  183. { id: 'b', name: '乙' },
  184. ],
  185. currentVoiceId: 'a',
  186. })
  187. .expect(200)
  188. const publicResponse = await req('get', '/api/app/admin/duplex-voice').expect(200)
  189. expect(publicResponse.body.hasApiKey).toBe(true)
  190. expect(publicResponse.body.apiKey).toBeUndefined()
  191. await req('put', '/api/app/voice/current-voice', 'second')
  192. .send({ currentVoiceId: 'b' })
  193. .expect(200)
  194. expect((await req('get', '/api/app/voice/runtime')).body.currentVoiceId).toBe('a')
  195. expect((await req('get', '/api/app/voice/runtime', 'second')).body.currentVoiceId).toBe('b')
  196. expect(
  197. readFileSync(join(home, 'workspace.sqlite3')).includes(Buffer.from('private-test-key')),
  198. ).toBe(false)
  199. })
  200. it('stores monotonic profile-scoped context snapshots without a plugin', async () => {
  201. const path = '/api/app/session-context/session-one?profile=default'
  202. const b = { usedTokens: 200, limitTokens: 1000, percent: 20, observedAt: 20 }
  203. await req('put', path).send(b).expect(200)
  204. await req('put', path)
  205. .send({ ...b, usedTokens: 10, observedAt: 10 })
  206. .expect(200)
  207. expect((await req('get', path)).body.snapshot).toMatchObject({ ...b, sessionId: 'session-one' })
  208. expect((await req('get', path, 'second')).body.snapshot).toBeNull()
  209. expect(
  210. (await req('get', '/api/app/session-context/session-one?profile=other')).body.snapshot,
  211. ).toBeNull()
  212. })
  213. it('protects internal Hermes session IDs on native HTTP endpoints', async () => {
  214. runtime.workspace.put('first', 'binding', 'binding', {
  215. storedId: 'internal-session',
  216. runtimeId: 'internal-runtime',
  217. aliases: ['old-id'],
  218. })
  219. await req('get', '/api/app/sessions/internal-session/messages').expect(404)
  220. await req('get', '/api/sessions/old-id/messages', 'second').expect(404)
  221. expect(upstream.some((p) => p.includes('internal-session') || p.includes('old-id'))).toBe(false)
  222. })
  223. })