Files
LWLT-AIBOT/control-plane/test/external-webhook-client.test.ts
2026-09-09 17:24:16 +08:00

231 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import { loadConfig } from '../src/config.js';
import { buildServer } from '../src/server.js';
import {
EXTERNAL_WEBHOOK_CONFIG_ID,
ExternalWebhookClient,
type ExternalWebhookFetch
} from '../src/external-webhook-client.js';
const webhookUrl = 'https://gateway.example.test/wechat/webhookInfo/sendMessageByOut';
const webhookToken = 't'.repeat(32);
test('external webhook request uses the confirmed fixed contract exactly', async () => {
let requestInput: string | URL | Request | undefined;
let requestInit: RequestInit | undefined;
const fetchImpl: ExternalWebhookFetch = async (input, init) => {
requestInput = input;
requestInit = init;
return new Response(JSON.stringify({ code: 0, data: true, msg: 'ok' }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
};
const client = new ExternalWebhookClient({
url: webhookUrl,
token: webhookToken,
fetchImpl
});
const result = await client.send('【员工任务摘要】\n员工employee-a');
assert.deepEqual(result, { outcome: 'accepted', httpStatus: 200, errorCode: null });
assert.equal(requestInput, webhookUrl);
assert.equal(requestInit?.method, 'POST');
assert.deepEqual(requestInit?.headers, {
'Content-Type': 'application/json',
'x-token': webhookToken
});
assert.deepEqual(JSON.parse(String(requestInit?.body)), {
id: EXTERNAL_WEBHOOK_CONFIG_ID,
content: '【员工任务摘要】\n员工employee-a'
});
assert.equal(EXTERNAL_WEBHOOK_CONFIG_ID, '9999');
assert.ok(requestInit?.signal instanceof AbortSignal);
});
test('external webhook accepts only HTTP 200 with code zero and data true', async () => {
const cases: Array<{
name: string;
response?: Response;
error?: Error;
expected: Record<string, unknown>;
}> = [
{
name: 'business rejection in HTTP 200',
response: new Response(JSON.stringify({ code: 1, data: false, msg: 'rejected' }), { status: 200 }),
expected: { outcome: 'rejected', httpStatus: 200, errorCode: 'webhook_business_rejected' }
},
{
name: 'invalid request',
response: new Response(JSON.stringify({ code: 400, data: false, msg: 'id and content must not be blank' }), { status: 400 }),
expected: { outcome: 'rejected', httpStatus: 400, errorCode: 'webhook_invalid_request' }
},
{
name: 'invalid token',
response: new Response(JSON.stringify({ code: 401, data: false, msg: 'Invalid token' }), { status: 401 }),
expected: { outcome: 'rejected', httpStatus: 401, errorCode: 'webhook_invalid_token' }
},
{
name: 'missing webhook configuration',
response: new Response(JSON.stringify({ code: 404, data: false, msg: 'Webhook configuration not found' }), { status: 404 }),
expected: { outcome: 'rejected', httpStatus: 404, errorCode: 'webhook_configuration_not_found' }
},
{
name: 'server error is uncertain',
response: new Response(JSON.stringify({ code: 500, data: false, msg: 'error' }), { status: 500 }),
expected: { outcome: 'uncertain', httpStatus: 500, errorCode: 'webhook_http_500_uncertain' }
},
{
name: 'invalid success response is uncertain',
response: new Response('not-json', { status: 200 }),
expected: { outcome: 'uncertain', httpStatus: 200, errorCode: 'webhook_response_invalid_json' }
},
{
name: 'network failure is uncertain',
error: new Error('connection reset'),
expected: { outcome: 'uncertain', httpStatus: null, errorCode: 'webhook_request_uncertain' }
},
{
name: 'timeout is uncertain',
error: new DOMException('timed out', 'TimeoutError'),
expected: { outcome: 'uncertain', httpStatus: null, errorCode: 'webhook_request_timeout' }
}
];
for (const item of cases) {
const client = new ExternalWebhookClient({
url: webhookUrl,
token: webhookToken,
fetchImpl: async () => {
if (item.error) throw item.error;
return item.response as Response;
}
});
assert.deepEqual(await client.send('summary'), item.expected, item.name);
}
});
test('webhook configuration is fail-closed and independent from AgentBus configuration', () => {
const encryptionKey = Buffer.alloc(32, 23).toString('base64');
const disabled = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: encryptionKey });
assert.equal(disabled.leaderSummaryWebhookEnabled, false);
assert.equal(disabled.agentBusEnabled, false);
const configured = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: webhookUrl,
WEBHOOK_EXTERNAL_TOKEN: webhookToken
});
assert.equal(configured.leaderSummaryWebhookEnabled, true);
assert.equal(configured.leaderSummaryWebhookConfigurationError, null);
assert.equal(configured.agentBusEnabled, false);
const incomplete = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_EXTERNAL_TOKEN: webhookToken
});
assert.equal(incomplete.leaderSummaryWebhookEnabled, false);
assert.equal(incomplete.leaderSummaryWebhookConfigurationError, 'webhook_configuration_incomplete');
assert.equal(incomplete.agentBusEnabled, false);
const shortToken = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: webhookUrl,
WEBHOOK_EXTERNAL_TOKEN: 'too-short'
});
assert.equal(shortToken.leaderSummaryWebhookEnabled, false);
assert.equal(shortToken.leaderSummaryWebhookConfigurationError, 'webhook_token_length_invalid');
const paddedToken = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: webhookUrl,
WEBHOOK_EXTERNAL_TOKEN: ` ${webhookToken}`
});
assert.equal(paddedToken.leaderSummaryWebhookEnabled, false);
assert.equal(paddedToken.leaderSummaryWebhookConfigurationError, 'webhook_token_length_invalid');
const whitespaceToken = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: webhookUrl,
WEBHOOK_EXTERNAL_TOKEN: ` ${'t'.repeat(31)}`
});
assert.equal(whitespaceToken.leaderSummaryWebhookEnabled, false);
assert.equal(whitespaceToken.leaderSummaryWebhookConfigurationError, 'webhook_token_whitespace_invalid');
const unconfirmedRoute = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: 'https://gateway.example.test/wechat/another-route',
WEBHOOK_EXTERNAL_TOKEN: webhookToken
});
assert.equal(unconfirmedRoute.leaderSummaryWebhookEnabled, false);
assert.equal(unconfirmedRoute.leaderSummaryWebhookConfigurationError, 'webhook_route_unconfirmed');
const insecureProductionUrl = loadConfig({
NODE_ENV: 'production',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: 'http://gateway.example.test/wechat/webhookInfo/sendMessageByOut',
WEBHOOK_EXTERNAL_TOKEN: webhookToken
});
assert.equal(insecureProductionUrl.leaderSummaryWebhookEnabled, false);
assert.equal(insecureProductionUrl.leaderSummaryWebhookConfigurationError, 'webhook_https_required');
});
test('client rejects blank content and malformed tokens before making a request', async () => {
let calls = 0;
const fetchImpl: ExternalWebhookFetch = async () => {
calls += 1;
return new Response('{}', { status: 200 });
};
await assert.rejects(
new ExternalWebhookClient({ url: webhookUrl, token: webhookToken, fetchImpl }).send(' '),
/must not be blank/
);
await assert.rejects(
new ExternalWebhookClient({ url: webhookUrl, token: 'short', fetchImpl }).send('summary'),
/exactly 32 non-whitespace characters/
);
await assert.rejects(
new ExternalWebhookClient({ url: webhookUrl, token: ` ${'t'.repeat(31)}`, fetchImpl }).send('summary'),
/exactly 32 non-whitespace characters/
);
assert.equal(calls, 0);
});
test('invalid configuration or webhook initialization failure does not block the normal HTTP service', async () => {
const encryptionKey = Buffer.alloc(32, 29).toString('base64');
const configs = [
loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_EXTERNAL_TOKEN: webhookToken,
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
}),
loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: encryptionKey,
WEBHOOK_SEND_URL: webhookUrl,
WEBHOOK_EXTERNAL_TOKEN: webhookToken,
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
})
];
for (const config of configs) {
const { app } = await buildServer({ config, startParserLoop: false });
try {
const response = await app.inject({ method: 'GET', url: '/health/live' });
assert.equal(response.statusCode, 200);
assert.equal(response.json().ok, true);
} finally {
await app.close();
}
}
});