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