api-reference.test.ts 6.9 KB

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