Files
LWLT-AIBOT/control-plane/test/external-webhook-client.test.ts
T

231 lines
8.7 KiB
TypeScript

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();
}
}
});