agentManagementRoutes.test.ts 7.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  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 } 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('proxies profile-scoped model services and masked duplex voice settings through 9119', async () => {
  17. const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-management-'))
  18. roots.push(home)
  19. const config: ServerConfig = {
  20. host: '127.0.0.1', port: 8800, 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 calls: Array<{ url: URL; method: string; body?: unknown }> = []
  25. let unsupported = false
  26. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  27. const url = new URL(input instanceof Request ? input.url : String(input))
  28. const method = init?.method || (input instanceof Request ? input.method : 'GET')
  29. const requestBody = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
  30. calls.push({ url, method, body: requestBody })
  31. if (url.pathname === '/api/status') return Response.json({ auth_required: true }, { headers: { 'set-cookie': 'hermes_session_at=session; Path=/; HttpOnly' } })
  32. if (url.pathname === '/api/auth/me') return Response.json({ user_id: 'service-admin' })
  33. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  34. if (url.pathname === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  35. 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 }] })
  36. if (url.pathname === '/api/config' && method === 'PUT') return Response.json({ ok: true })
  37. 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 }] })
  38. if (url.pathname === '/api/env') return Response.json({ ok: true })
  39. if (url.pathname === '/api/model/set') return Response.json({ ok: true, provider: 'custom:tingly', model: 'omni-2' })
  40. if (unsupported && url.pathname === '/api/providers/custom-endpoints') return Response.json({ detail: 'missing' }, { status: 404 })
  41. if (url.pathname === '/api/providers/custom-endpoints/validate') return Response.json({ ok: true, reachable: true, message: '', models: ['model-a'] })
  42. if (url.pathname === '/api/providers/custom-endpoints') return Response.json({ endpoints: [], current: {}, ok: true })
  43. if (url.pathname.endsWith('/activate')) return Response.json({ ok: true, provider: 'local', model: 'model-a' })
  44. if (url.pathname.startsWith('/api/providers/custom-endpoints/')) return Response.json({ ok: true, endpoints: [], current: {} })
  45. if (url.pathname === '/api/plugins/yaoyao/voice/settings') return Response.json({ hasApiKey: true, voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a', updatedAt: 1 })
  46. return Response.json({ ok: true })
  47. }) as typeof fetch
  48. const runtime = createAuthenticatedApplication({ config, fetchImpl })
  49. runtimes.push(runtime)
  50. const agent = request.agent(runtime.app.callback())
  51. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  52. const headers = (value: request.Test) => value
  53. .set('Host', '127.0.0.1:8800')
  54. .set('Origin', 'http://127.0.0.1:8800')
  55. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  56. await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800').expect(200)
  57. 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)
  58. 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)
  59. await headers(agent.post('/api/app/admin/model-services/local/activate?profile=worker')).send({}).expect(200)
  60. await headers(agent.delete('/api/app/admin/model-services/local?profile=worker')).send({}).expect(200)
  61. const legacy = await agent.get('/api/app/admin/legacy-model-services?profile=worker').set('Host', '127.0.0.1:8800').expect(200)
  62. expect(legacy.body.items[0]).toMatchObject({ id: 'custom:tingly', models: ['omni', 'old'], has_api_key: true, can_edit_api_key: true, is_current: true })
  63. expect(JSON.stringify(legacy.body)).not.toContain('TINGLY_API_KEY')
  64. await headers(agent.put('/api/app/admin/legacy-model-services/custom%3Atingly?profile=worker')).send({
  65. name: 'tingly', base_url: 'http://tingly.test/v2', model: 'omni-2', models: ['omni', 'omni-2'],
  66. discover_models: false, make_default: false, api_key: 'replacement-secret',
  67. }).expect(200)
  68. const voice = await agent.get('/api/app/admin/duplex-voice').set('Host', '127.0.0.1:8800').expect(200)
  69. expect(voice.body).not.toHaveProperty('apiKey')
  70. await headers(agent.put('/api/app/admin/duplex-voice')).send({ voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a' }).expect(200)
  71. const featureCalls = calls.filter(call => call.url.pathname.includes('custom-endpoints') || call.url.pathname.includes('/voice/settings'))
  72. expect(featureCalls.map(call => [call.method, call.url.pathname, call.url.search])).toEqual([
  73. ['GET', '/api/providers/custom-endpoints', '?profile=worker'],
  74. ['POST', '/api/providers/custom-endpoints', '?profile=worker'],
  75. ['POST', '/api/providers/custom-endpoints/validate', ''],
  76. ['POST', '/api/providers/custom-endpoints/local/activate', '?profile=worker'],
  77. ['DELETE', '/api/providers/custom-endpoints/local', '?profile=worker'],
  78. ['GET', '/api/plugins/yaoyao/voice/settings', ''],
  79. ['PUT', '/api/plugins/yaoyao/voice/settings', ''],
  80. ])
  81. expect(featureCalls[1]!.body).toMatchObject({ name: 'Local', model: 'model-a' })
  82. expect(featureCalls[6]!.body).not.toHaveProperty('apiKey')
  83. const configWrite = calls.find(call => call.url.pathname === '/api/config' && call.method === 'PUT')!
  84. 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 }] } })
  85. const envWrite = calls.find(call => call.url.pathname === '/api/env' && call.method === 'PUT')!
  86. expect(envWrite.body).toMatchObject({ key: 'TINGLY_API_KEY', value: 'replacement-secret' })
  87. expect(calls.some(call => call.url.pathname === '/api/model/set' && call.method === 'POST')).toBe(true)
  88. unsupported = true
  89. await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800')
  90. .expect(501, /当前上游版本不支持此管理功能/)
  91. })
  92. })