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