diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b0e365070..07497e765 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2574,7 +2574,7 @@ Notify external endpoints when team events happen. Each endpoint declares a `url **Payload.** Only whitelisted, non-sensitive fields are sent: `skillName` for `skill-use`, `sessionId` for session events; `push`/`pull` carry the event and metadata only. Raw tool input and tool output are **never** forwarded, and the whole body is passed through teamai's secret redactor before it leaves the machine. -**Signature.** When `secret` is set, each request carries `X-TeamAI-Signature: sha256=`, an HMAC-SHA256 computed over the exact request body — so a receiver can verify authenticity. `teamai webhook list` and `teamai webhook test` inspect and exercise configured endpoints. +**Signature.** When `secret` is set, each request carries `X-TeamAI-Signature: sha256=`, an HMAC-SHA256 computed over the exact request body — so a receiver can verify authenticity. `teamai webhook list` and `teamai webhook test` inspect and exercise configured endpoints; `teamai webhook test --dry-run` previews how many endpoints would receive a request without sending one. --- diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1e33ebf80..d8a076c6d 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2407,7 +2407,7 @@ toolRoots: # 可选,每机器的工具根目录(见下 **载荷。** 仅发送白名单内的非敏感字段:`skill-use` 发送 `skillName`,session 事件发送 `sessionId`;`push`/`pull` 只带事件与元数据。原始工具入参与工具输出**绝不**外发,且整个请求体在离开本机前会经过 teamai 的密钥脱敏处理。 -**签名。** 设置 `secret` 后,每个请求都会带上 `X-TeamAI-Signature: sha256=`——对**实际发送的请求体**计算的 HMAC-SHA256,供接收端校验真实性。`teamai webhook list` 与 `teamai webhook test` 可查看和测试已配置的端点。 +**签名。** 设置 `secret` 后,每个请求都会带上 `X-TeamAI-Signature: sha256=`——对**实际发送的请求体**计算的 HMAC-SHA256,供接收端校验真实性。`teamai webhook list` 与 `teamai webhook test` 可查看和测试已配置的端点;`teamai webhook test --dry-run` 只预览将发送到多少个端点,不发送请求。 --- diff --git a/src/__tests__/e2e/webhook-test-dry-run.test.ts b/src/__tests__/e2e/webhook-test-dry-run.test.ts new file mode 100644 index 000000000..1da88e2f0 --- /dev/null +++ b/src/__tests__/e2e/webhook-test-dry-run.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const cli = path.join(root, 'dist', 'index.js'); + +describe('webhook test --dry-run (real CLI)', () => { + let sandbox: string; + let home: string; + + beforeAll(() => { + if (!fs.existsSync(cli)) throw new Error(`CLI binary not found at ${cli}. Run "npm run build" first.`); + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-webhook-preview-')); + home = path.join(sandbox, 'home'); + const teamRepo = path.join(home, '.teamai', 'team-repo'); + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), [ + 'team: webhook-preview-e2e', + 'repo: https://example.com/team.git', + 'provider: git', + 'sharing:', + ' webhooks:', + ' enabled: true', + ' endpoints:', + ' - url: http://127.0.0.1:9/hook', + ' type: json', + ' events: ["*"]', + ' timeout: 500', + ' retries: 0', + ].join('\n')); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${teamRepo}`, + ' remote: https://example.com/team.git', + 'username: e2e-user', + 'updatePolicy: auto', + 'scope: user', + ].join('\n')); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it.each([ + { url: undefined, count: 1 }, + { url: 'http://127.0.0.1:9/unmatched', count: 0 }, + ])('previews $count endpoint(s) without sending a request or rewriting config', ({ url, count }) => { + const configPath = path.join(home, '.teamai', 'config.yaml'); + const before = fs.readFileSync(configPath); + const args = [cli, 'webhook', 'test', '--dry-run']; + if (url) args.push('--url', url); + const result = spawnSync(process.execPath, args, { + cwd: home, + env: { ...process.env, HOME: home, FORCE_COLOR: '0' }, + encoding: 'utf8', + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout + result.stderr).toContain(`[dry-run] Would send a test webhook to ${count} endpoint(s).`); + expect(result.stdout + result.stderr).not.toContain('No webhook endpoints configured.'); + expect(result.stdout + result.stderr).not.toContain('Webhook test failed'); + expect(fs.readFileSync(configPath)).toEqual(before); + }); +}); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts index fbcf849de..c3c1549b4 100644 --- a/src/__tests__/webhook.test.ts +++ b/src/__tests__/webhook.test.ts @@ -174,6 +174,34 @@ describe('webhook', () => { } describe('testWebhook', () => { + it('previews matching endpoints without sending a request or migrating config', async () => { + const { testWebhook } = await import('../webhook.js'); + const { log } = await import('../utils/logger.js'); + + await testWebhook('https://open.feishu.cn/open-apis/bot/v2/hook/test', { dryRun: true }); + + expect(mockAutoDetectInit).toHaveBeenCalledWith(undefined, { dryRun: true }); + expect(log.info).toHaveBeenCalledWith('[dry-run] Would send a test webhook to 1 endpoint(s).'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it.each(['unmatched URL', 'no configured endpoints'])('previews zero endpoints for %s', async (scenario) => { + const { testWebhook } = await import('../webhook.js'); + const { log } = await import('../utils/logger.js'); + if (scenario === 'no configured endpoints') { + const config = singleEndpointConfig(0); + config.teamConfig.sharing.webhooks.endpoints = []; + mockAutoDetectInit.mockResolvedValueOnce(config); + } + + await testWebhook('https://example.com/unmatched', { dryRun: true }); + + expect(mockAutoDetectInit).toHaveBeenCalledWith(undefined, { dryRun: true }); + expect(log.info).toHaveBeenCalledWith('[dry-run] Would send a test webhook to 0 endpoint(s).'); + expect(log.warn).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('should send test event to all endpoints', async () => { const { testWebhook } = await import('../webhook.js'); diff --git a/src/index.ts b/src/index.ts index ed85f4186..96bc3313a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -828,8 +828,9 @@ webhookCmd .description('Send test event to webhook endpoints') .option('--url ', 'Test specific endpoint URL') .action(async (cmdOpts) => { + const globalOpts = program.opts() as GlobalOptions; const { testWebhook } = await import('./webhook.js'); - await testWebhook(cmdOpts.url); + await testWebhook(cmdOpts.url, { dryRun: globalOpts.dryRun }); }); // ─── Model profile commands ───────────────────────────── diff --git a/src/webhook.ts b/src/webhook.ts index 4bd0712a0..45fd8cb96 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -157,13 +157,18 @@ export async function listWebhooks(): Promise { /** * Test webhook by sending a test event. */ -export async function testWebhook(url?: string): Promise { - const config = await loadWebhookConfig(); +export async function testWebhook(url?: string, options: { dryRun?: boolean } = {}): Promise { + const config = await loadWebhookConfig(undefined, { dryRun: options.dryRun }); const endpoints = url ? config.endpoints.filter((ep) => ep.url === url) : config.endpoints; + if (options.dryRun) { + log.info(`[dry-run] Would send a test webhook to ${endpoints.length} endpoint(s).`); + return; + } + if (endpoints.length === 0) { log.warn('No webhook endpoints configured.'); return;