api-reference.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  2. import pino from 'pino'
  3. import { createApp, type App } from '../src/app'
  4. import { API_EVENTS, API_GROUPS } from '../src/api-reference/reference'
  5. import { BOT_EVENTS, BOT_GROUPS } from '../src/api-reference/bot-reference'
  6. import { REGISTRY_GROUPS } from '../src/api-reference/registry-reference'
  7. import { renderApiReferenceHtml } from '../src/api-reference/render'
  8. import { findChrome } from '../src/api-reference/pdf'
  9. import { buildServer } from '../src/http/server'
  10. import { registeredRegistryRoutes } from '../src/http/routes/registry'
  11. import { registeredV1Routes } from '../src/http/routes/v1'
  12. import { registeredBotRoutes } from '../src/teams/routes'
  13. import type { EventPayloads, TeamEventPayloads } from '@agent-server/shared'
  14. let app: App
  15. let server: Awaited<ReturnType<typeof buildServer>>
  16. beforeAll(async () => {
  17. const log = pino({ level: 'silent' })
  18. app = createApp(log, { providers: {} })
  19. server = await buildServer(app, { logger: log })
  20. })
  21. afterAll(() => server.close())
  22. describe('API 协议文档', () => {
  23. it('与实际注册的 /v1 路由完全一致(新增或删除接口时必须同步修改 reference.ts)', () => {
  24. const key = (m: string, p: string) => `${m} ${p}`
  25. const actual = new Set(registeredV1Routes.map(r => key(r.method, r.path)))
  26. const documented = new Set(API_GROUPS.flatMap(g => g.endpoints.map(e => key(e.method, e.path))))
  27. expect([...actual].filter(x => !documented.has(x)), '未写入文档的接口').toEqual([])
  28. expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
  29. })
  30. it('Bot API:与实际注册的 /bot/v1 路由完全一致(新增或删除接口时必须同步修改 bot-reference.ts)', () => {
  31. const key = (m: string, p: string) => `${m} ${p}`
  32. const actual = new Set(registeredBotRoutes.map(r => key(r.method, r.path)))
  33. const documented = new Set(BOT_GROUPS.flatMap(g => g.endpoints.map(e => key(e.method, e.path))))
  34. expect([...actual].filter(x => !documented.has(x)), '未写入文档的接口').toEqual([])
  35. expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
  36. })
  37. it('技能发布 API:与实际注册的 /skills/registry/v1 路由完全一致(新增或删除接口时必须同步修改 registry-reference.ts)', () => {
  38. const key = (m: string, p: string) => `${m} ${p}`
  39. const actual = new Set(registeredRegistryRoutes.map(r => key(r.method, r.path)))
  40. const documented = new Set(REGISTRY_GROUPS.flatMap(g => g.endpoints.map(e => key(e.method, e.path))))
  41. expect(actual.size).toBeGreaterThan(0)
  42. expect([...actual].filter(x => !documented.has(x)), '未写入文档的接口').toEqual([])
  43. expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
  44. })
  45. it('Bot API 事件类型与工作组事件模型一致', () => {
  46. const all: Record<keyof TeamEventPayloads, true> = {
  47. 'message.created': true, 'bot.status': true, 'interaction.required': true, 'interaction.resolved': true, 'file.changed': true, 'team.updated': true,
  48. 'job.updated': true, 'task.updated': true, 'step.updated': true,
  49. }
  50. expect(BOT_EVENTS.map(e => e.type).sort()).toEqual(Object.keys(all).sort())
  51. })
  52. it('事件类型与统一事件模型一致', () => {
  53. // EventPayloads 的键在类型层面,这里用一个完整的对象字面量确保编译期同步
  54. const all: Record<keyof EventPayloads, true> = {
  55. 'user.message': true, 'run.queued': true, 'run.started': true, 'message.delta': true, 'message.completed': true,
  56. 'reasoning.delta': true, 'reasoning.completed': true, 'tool.started': true, 'tool.completed': true, 'file.changed': true,
  57. 'interaction.required': true, 'interaction.resolved': true, 'script.progress': true, usage: true, 'run.completed': true, 'run.failed': true, 'run.interrupted': true,
  58. }
  59. expect(API_EVENTS.map(e => e.type).sort()).toEqual(Object.keys(all).sort())
  60. })
  61. it('渲染:示例使用传入的服务地址;转义特殊字符;没有脚本', () => {
  62. const html = renderApiReferenceHtml({ baseUrl: 'https://agent.example.com/', theme: 'dark', version: 'v1', generatedAt: new Date('2026-10-23T00:00:00Z') })
  63. expect(html).toContain('curl -X POST https://agent.example.com/v1/sessions')
  64. // 两套协议都在同一份文档中
  65. expect(html).toContain('第 1 部分 · Agent API')
  66. expect(html).toContain('第 2 部分 · Bot API')
  67. expect(html).toContain('https://agent.example.com/bot/v1/teams')
  68. expect(html).toContain('第 3 部分 · 技能发布 API')
  69. expect(html).toContain('https://agent.example.com/skills/registry/v1/submissions')
  70. // 技能发布 API 没有事件,不出现"事件类型"一节
  71. expect(html).not.toContain('id="registry-event-types"')
  72. expect(html).not.toContain('$BASE')
  73. expect(html).toContain('data-theme="dark"')
  74. expect(html).toContain('&lt;凭证&gt;')
  75. expect(html).not.toMatch(/<script/i)
  76. })
  77. it('后台接口:HTML 需要登录且带 CSP;base 参数只接受 http(s) 源', async () => {
  78. expect((await server.inject({ method: 'GET', url: '/admin/v1/api-reference.html' })).statusCode).toBe(401)
  79. const cookies = { agent_admin: app.adminAuth.login('test-password').id }
  80. const r = await server.inject({ method: 'GET', url: '/admin/v1/api-reference.html?base=https://agent.zenpower.cc', cookies })
  81. expect(r.headers['content-security-policy']).toContain("default-src 'none'")
  82. expect(r.body).toContain('https://agent.zenpower.cc/v1/sessions')
  83. const bad = await server.inject({ method: 'GET', url: `/admin/v1/api-reference.html?base=${encodeURIComponent('javascript:alert(1)')}`, cookies })
  84. expect(bad.body).not.toContain('javascript:')
  85. expect((await server.inject({ method: 'GET', url: '/admin/v1/api-reference/meta', cookies })).json()).toHaveProperty('pdfAvailable')
  86. })
  87. it('调试:协议 JSON 含概述与示例请求体;以平台身份调试时签发 10 分钟令牌,停用的 Key 不能调试', async () => {
  88. const cookies = { agent_admin: app.adminAuth.login('test-password').id }
  89. const ref = (await server.inject({ method: 'GET', url: '/admin/v1/api-reference.json', cookies })).json()
  90. expect(ref.specs.map((s: { id: string }) => s.id)).toEqual(['agent', 'bot', 'registry'])
  91. expect(ref.specs[0].overview.points.length).toBeGreaterThan(3)
  92. expect(ref.specs[0].groups.flatMap((g: { endpoints: { path: string; sampleBody?: unknown }[] }) => g.endpoints).find((e: { path: string }) => e.path === '/v1/sessions/:id/messages').sampleBody).toHaveProperty('text')
  93. const builtin = app.profiles.getBuiltin().id
  94. const k = app.apiKeys.create('调试', { allowedProfileIds: [builtin], defaultProfileId: builtin })
  95. expect((await server.inject({ method: 'POST', url: `/admin/v1/api-keys/${k.apiKey.id}/debug-token` })).statusCode).toBe(401)
  96. const r = await server.inject({ method: 'POST', url: `/admin/v1/api-keys/${k.apiKey.id}/debug-token`, cookies })
  97. const { token, expiresAt } = r.json()
  98. expect(Date.parse(expiresAt) - Date.now()).toBeLessThanOrEqual(600_000)
  99. expect(app.browserTokens.verify(token)).toBe(k.apiKey.id)
  100. app.apiKeys.setEnabled(k.apiKey.id, false)
  101. expect((await server.inject({ method: 'POST', url: `/admin/v1/api-keys/${k.apiKey.id}/debug-token`, cookies })).statusCode).toBe(400)
  102. })
  103. })
  104. describe.skipIf(!findChrome())('PDF', () => {
  105. it('后台下载 PDF:A4 文档、附件文件名', async () => {
  106. const cookies = { agent_admin: app.adminAuth.login('test-password').id }
  107. const r = await server.inject({ method: 'GET', url: '/admin/v1/api-reference.pdf', cookies })
  108. expect(r.statusCode).toBe(200)
  109. expect(r.headers['content-type']).toBe('application/pdf')
  110. expect(r.headers['content-disposition']).toMatch(/^attachment; filename\*=UTF-8''AgentServer/)
  111. expect(r.rawPayload.subarray(0, 5).toString()).toBe('%PDF-')
  112. expect(r.rawPayload.length).toBeGreaterThan(20_000)
  113. }, 60_000)
  114. })