agentManagementRoutes.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import { mkdtempSync, rmSync } 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 type { ApplicationRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. import { createAuthenticatedApplication, createUserAuthenticatedApplication } from './authenticatedApplication.js'
  9. const roots: string[] = []
  10. const runtimes: ApplicationRuntime[] = []
  11. afterEach(() => {
  12. for (const runtime of runtimes.splice(0)) runtime.close()
  13. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  14. })
  15. describe('Agent management admin routes', () => {
  16. it('rejects default-model writes from a regular 15300 user', async () => {
  17. const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-model-user-'))
  18. roots.push(home)
  19. const config: ServerConfig = {
  20. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  21. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  22. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  23. }
  24. const runtime = createUserAuthenticatedApplication({ config })
  25. runtimes.push(runtime)
  26. const agent = request.agent(runtime.app.callback())
  27. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  28. await agent.put('/api/app/admin/profiles/default/model')
  29. .set('Host', '127.0.0.1:15300')
  30. .set('Origin', 'http://127.0.0.1:15300')
  31. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  32. .send({ provider: 'opencode-free', model: 'free-a' })
  33. .expect(403, /子账号只能使用 Bot 模式/)
  34. })
  35. it('proxies profile-scoped model services and masked duplex voice settings through 9119', async () => {
  36. const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-management-'))
  37. roots.push(home)
  38. const config: ServerConfig = {
  39. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  40. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  41. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  42. }
  43. const calls: Array<{ url: URL; method: string; body?: unknown }> = []
  44. let unsupported = false
  45. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  46. const url = new URL(input instanceof Request ? input.url : String(input))
  47. const method = init?.method || (input instanceof Request ? input.method : 'GET')
  48. const requestBody = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
  49. calls.push({ url, method, body: requestBody })
  50. if (url.pathname === '/api/status') return Response.json({ auth_required: true }, { headers: { 'set-cookie': 'hermes_session_at=session; Path=/; HttpOnly' } })
  51. if (url.pathname === '/api/auth/me') return Response.json({ user_id: 'service-admin' })
  52. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  53. if (url.pathname === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  54. if (url.pathname === '/api/config' && method === 'GET') return Response.json({ custom_providers: [{ name: 'tingly', base_url: 'http://tingly.test/v1', key_env: 'TINGLY_API_KEY', model: 'omni', models: { omni: {}, old: {} }, models_discovered: true }] })
  55. if (url.pathname === '/api/config' && method === 'PUT') return Response.json({ ok: true })
  56. if (url.pathname === '/api/model/options') return Response.json({ provider: 'custom:tingly', model: 'omni', providers: [{ slug: 'custom:tingly', name: 'tingly', models: ['omni', 'old'], is_current: true }] })
  57. if (url.pathname === '/api/env') return Response.json({ ok: true })
  58. if (url.pathname === '/api/model/set') return Response.json({ ok: true, provider: 'custom:tingly', model: 'omni-2' })
  59. if (url.pathname === '/api/profiles/worker/model' && method === 'PUT') return Response.json({ ok: true, provider: 'opencode-free', model: 'free-a' })
  60. if (unsupported && url.pathname === '/api/providers/custom-endpoints') return Response.json({ detail: 'missing' }, { status: 404 })
  61. if (url.pathname === '/api/providers/custom-endpoints/validate') return Response.json({ ok: true, reachable: true, message: '', models: ['model-a'] })
  62. if (url.pathname === '/api/providers/custom-endpoints') return Response.json({ endpoints: [], current: {}, ok: true })
  63. if (url.pathname.endsWith('/activate')) return Response.json({ ok: true, provider: 'local', model: 'model-a' })
  64. if (url.pathname.startsWith('/api/providers/custom-endpoints/')) return Response.json({ ok: true, endpoints: [], current: {} })
  65. if (url.pathname === '/api/plugins/yaoyao/voice/settings') return Response.json({ hasApiKey: true, voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a', updatedAt: 1 })
  66. return Response.json({ ok: true })
  67. }) as typeof fetch
  68. const runtime = createAuthenticatedApplication({ config, fetchImpl })
  69. runtimes.push(runtime)
  70. const agent = request.agent(runtime.app.callback())
  71. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  72. const headers = (value: request.Test) => value
  73. .set('Host', '127.0.0.1:15300')
  74. .set('Origin', 'http://127.0.0.1:15300')
  75. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  76. await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:15300').expect(200)
  77. await headers(agent.post('/api/app/admin/model-services?profile=worker')).send({ name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', discover_models: true, make_default: false }).expect(200)
  78. await headers(agent.post('/api/app/admin/model-services/validate')).send({ name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', discover_models: true, make_default: false }).expect(200)
  79. await headers(agent.post('/api/app/admin/model-services/local/activate?profile=worker')).send({}).expect(200)
  80. await headers(agent.put('/api/app/admin/profiles/worker/model')).send({ provider: 'opencode-free', model: 'free-a' }).expect(200)
  81. await headers(agent.delete('/api/app/admin/model-services/local?profile=worker')).send({}).expect(200)
  82. const legacy = await agent.get('/api/app/admin/legacy-model-services?profile=worker').set('Host', '127.0.0.1:15300').expect(200)
  83. expect(legacy.body.items[0]).toMatchObject({ id: 'custom:tingly', models: ['omni', 'old'], has_api_key: true, can_edit_api_key: true, is_current: true })
  84. expect(JSON.stringify(legacy.body)).not.toContain('TINGLY_API_KEY')
  85. await headers(agent.put('/api/app/admin/legacy-model-services/custom%3Atingly?profile=worker')).send({
  86. name: 'tingly', base_url: 'http://tingly.test/v2', model: 'omni-2', models: ['omni', 'omni-2'],
  87. discover_models: false, make_default: false, api_key: 'replacement-secret',
  88. }).expect(200)
  89. const voice = await agent.get('/api/app/admin/duplex-voice').set('Host', '127.0.0.1:15300').expect(200)
  90. expect(voice.body).not.toHaveProperty('apiKey')
  91. await headers(agent.put('/api/app/admin/duplex-voice')).send({ voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a' }).expect(200)
  92. const featureCalls = calls.filter(call => call.url.pathname.includes('custom-endpoints') || call.url.pathname.includes('/voice/settings'))
  93. expect(featureCalls.map(call => [call.method, call.url.pathname, call.url.search])).toEqual([
  94. ['GET', '/api/providers/custom-endpoints', '?profile=worker'],
  95. ['POST', '/api/providers/custom-endpoints', '?profile=worker'],
  96. ['POST', '/api/providers/custom-endpoints/validate', ''],
  97. ['POST', '/api/providers/custom-endpoints/local/activate', '?profile=worker'],
  98. ['DELETE', '/api/providers/custom-endpoints/local', '?profile=worker'],
  99. ])
  100. expect(featureCalls[1]!.body).toMatchObject({ name: 'Local', model: 'model-a' })
  101. expect(calls.some(call => call.url.pathname.includes('/plugins/yaoyao'))).toBe(false)
  102. const configWrite = calls.find(call => call.url.pathname === '/api/config' && call.method === 'PUT')!
  103. expect(configWrite.body).toMatchObject({ config: { custom_providers: [{ name: 'tingly', base_url: 'http://tingly.test/v2', model: 'omni-2', models: { omni: {}, 'omni-2': {} }, models_discovered: false }] } })
  104. const envWrite = calls.find(call => call.url.pathname === '/api/env' && call.method === 'PUT')!
  105. expect(envWrite.body).toMatchObject({ key: 'TINGLY_API_KEY', value: 'replacement-secret' })
  106. expect(calls.some(call => call.url.pathname === '/api/model/set' && call.method === 'POST')).toBe(true)
  107. expect(calls.find(call => call.url.pathname === '/api/profiles/worker/model' && call.method === 'PUT')?.body)
  108. .toEqual({ provider: 'opencode-free', model: 'free-a' })
  109. unsupported = true
  110. await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:15300')
  111. .expect(501, /当前上游版本不支持此管理功能/)
  112. })
  113. })