Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<hmac>`, 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=<hmac>`, 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.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2407,7 +2407,7 @@ toolRoots: # 可选,每机器的工具根目录(见下

**载荷。** 仅发送白名单内的非敏感字段:`skill-use` 发送 `skillName`,session 事件发送 `sessionId`;`push`/`pull` 只带事件与元数据。原始工具入参与工具输出**绝不**外发,且整个请求体在离开本机前会经过 teamai 的密钥脱敏处理。

**签名。** 设置 `secret` 后,每个请求都会带上 `X-TeamAI-Signature: sha256=<hmac>`——对**实际发送的请求体**计算的 HMAC-SHA256,供接收端校验真实性。`teamai webhook list` 与 `teamai webhook test` 可查看和测试已配置的端点。
**签名。** 设置 `secret` 后,每个请求都会带上 `X-TeamAI-Signature: sha256=<hmac>`——对**实际发送的请求体**计算的 HMAC-SHA256,供接收端校验真实性。`teamai webhook list` 与 `teamai webhook test` 可查看和测试已配置的端点;`teamai webhook test --dry-run` 只预览将发送到多少个端点,不发送请求。

---

Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/e2e/webhook-test-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions src/__tests__/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,8 +828,9 @@ webhookCmd
.description('Send test event to webhook endpoints')
.option('--url <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 ─────────────────────────────
Expand Down
9 changes: 7 additions & 2 deletions src/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,18 @@ export async function listWebhooks(): Promise<WebhookEndpoint[]> {
/**
* Test webhook by sending a test event.
*/
export async function testWebhook(url?: string): Promise<void> {
const config = await loadWebhookConfig();
export async function testWebhook(url?: string, options: { dryRun?: boolean } = {}): Promise<void> {
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;
Expand Down
Loading