231 lines
8.7 KiB
TypeScript
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();
|
||
}
|
||
}
|
||
});
|