merge: integrate leader summary webhook delivery
# Conflicts: # control-plane/README.md # control-plane/src/db.ts # control-plane/test/control-plane.test.ts
This commit is contained in:
commit
295409bff0
21 files changed
+1260
-1269
No files matched your search
@@ -491,6 +491,6 @@ test('account authorization editor uses a scroll-safe open layout without overri
|
||||
assert.match(openLayoutSource, /overflow:\s*visible/);
|
||||
assert.doesNotMatch(styles, /\.account-panel\s*\{\s*grid-template-rows:/);
|
||||
|
||||
assert.match(index, /styles\.css\?v=20260907-admin-task-isolation-1/);
|
||||
assert.match(index, /app\.js\?v=20260907-admin-task-isolation-1/);
|
||||
assert.match(index, /styles\.css\?v=20260908-leader-webhook-1/);
|
||||
assert.match(index, /app\.js\?v=20260908-leader-webhook-1/);
|
||||
});
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
AgentBusListener,
|
||||
type AgentBusSocket,
|
||||
type AgentBusTaskGateway,
|
||||
type LeaderNotificationGateway,
|
||||
createLeaderTaskSummaryFrame,
|
||||
createTaskResultFrame,
|
||||
createTaskProgressFrame,
|
||||
extractAgentBusBusinessText,
|
||||
@@ -33,7 +31,6 @@ import {
|
||||
} from '../src/agentbus-delivery.js';
|
||||
import { resolveBusinessRoute } from '../src/business-routes.js';
|
||||
import type { AgentBusDelivery, PublicTask } from '../src/task-service.js';
|
||||
import type { LeaderTaskSummaryDelivery } from '../src/leader-notification-service.js';
|
||||
|
||||
function makeTask(status: string, overrides: Partial<PublicTask> = {}): PublicTask {
|
||||
return {
|
||||
@@ -175,78 +172,6 @@ test('AgentBus configuration stays disabled until connection fields are supplied
|
||||
assert.equal(config.AGENTBUS_LOG_PAYLOADS, false);
|
||||
});
|
||||
|
||||
test('team-lead listener automatically observes its inbound AgentBus route', async (t) => {
|
||||
const socket = new FakeSocket();
|
||||
const observed: Array<Record<string, unknown>> = [];
|
||||
let ingested = 0;
|
||||
const tasks: AgentBusTaskGateway = {
|
||||
events: new EventEmitter(),
|
||||
async ingestMessage() {
|
||||
ingested += 1;
|
||||
return { task: makeTask('completed'), attached: false, created: true };
|
||||
},
|
||||
async getTask() {
|
||||
return makeTask('completed');
|
||||
}
|
||||
};
|
||||
const leaderNotifications: LeaderNotificationGateway = {
|
||||
async observeLeaderRoute(input) {
|
||||
observed.push(input as unknown as Record<string, unknown>);
|
||||
},
|
||||
async claimDeliveries() {
|
||||
return [];
|
||||
},
|
||||
async markDeliveryDelivered() {},
|
||||
async markDeliveryFailed() {},
|
||||
async releaseDeliveries() {}
|
||||
};
|
||||
const listener = new AgentBusListener({
|
||||
config: testConfig(),
|
||||
tasks,
|
||||
leaderNotifications,
|
||||
organizationId: 'org-1',
|
||||
scheduleParseQueue: async () => {},
|
||||
socketFactory: () => socket as unknown as AgentBusSocket,
|
||||
channel: {
|
||||
id: 'channel-leader-route',
|
||||
displayName: '组长微信',
|
||||
wsUrl: 'wss://mesh.nianxx.cn/ws',
|
||||
wsToken: 'leader-route-token',
|
||||
botAddress: 'bot:leader-route:listener',
|
||||
ownerUserId: 'leader-route-user',
|
||||
ownerRole: 'team_lead'
|
||||
}
|
||||
});
|
||||
t.after(() => listener.stop());
|
||||
|
||||
listener.start();
|
||||
socket.readyState = 1;
|
||||
socket.emit('open');
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'ready-leader-route',
|
||||
type: 'event',
|
||||
session_id: 'session-leader-route',
|
||||
epoch: 1,
|
||||
to: 'bot:leader-route:listener',
|
||||
payload: { event: 'session.ready' }
|
||||
}));
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'leader-route-message-1',
|
||||
type: 'event',
|
||||
from: 'channel:wechat:leader-route-user',
|
||||
payload: { text: '查询今天的任务' }
|
||||
}));
|
||||
|
||||
await waitFor(() => observed.length === 1 && ingested === 1);
|
||||
assert.deepEqual(observed, [{
|
||||
organizationId: 'org-1',
|
||||
leaderUserId: 'leader-route-user',
|
||||
channelId: 'channel-leader-route',
|
||||
recipientAddress: 'channel:wechat:leader-route-user',
|
||||
conversationId: 'agentbus:channel:wechat:leader-route-user'
|
||||
}]);
|
||||
});
|
||||
|
||||
test('AgentBus accepted delivery payloads keep final ownership metadata server-only', () => {
|
||||
const waitingPayload = createAgentBusAcceptedDeliveryPayload({
|
||||
text: AGENTBUS_ROSTER_WAITING_TEXT,
|
||||
@@ -485,142 +410,6 @@ test('AgentBus protocol helpers preserve reply routing fields', () => {
|
||||
assert.equal(fallbackProgress.conversation_id, 'agentbus:channel:wechat:user-2');
|
||||
});
|
||||
|
||||
test('leader summary frame uses explicit proactive routing and cannot become an inbound task', () => {
|
||||
const delivery: LeaderTaskSummaryDelivery = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
channel_id: '22222222-2222-4222-8222-222222222222',
|
||||
task_id: 'TASK-20260907-001',
|
||||
recipient_address: 'channel:wechat:leader-a',
|
||||
recipient_fingerprint: 'abc123def456',
|
||||
conversation_id: 'wechat-conversation-a',
|
||||
conversation_fingerprint: 'def456abc123',
|
||||
payload: {
|
||||
event: 'task.summary',
|
||||
status: 'completed',
|
||||
task_id: 'TASK-20260907-001',
|
||||
text: '【员工任务摘要】\n员工:employee-a'
|
||||
},
|
||||
attempt_count: 1
|
||||
};
|
||||
const frame = createLeaderTaskSummaryFrame(delivery, {
|
||||
id: 'session-leader-1',
|
||||
epoch: 9,
|
||||
address: 'bot:leader-a:listener'
|
||||
});
|
||||
assert.equal(frame.id, `leader-summary-${delivery.id}`);
|
||||
assert.equal(frame.from, 'bot:leader-a:listener');
|
||||
assert.equal(frame.to, delivery.recipient_address);
|
||||
assert.equal(frame.conversation_id, delivery.conversation_id);
|
||||
assert.equal(Object.hasOwn(frame, 'reply_to'), false);
|
||||
assert.deepEqual(frame.payload, delivery.payload);
|
||||
assert.equal(isInboundAgentBusTask(frame), false);
|
||||
});
|
||||
|
||||
test('listener sends employee outbox first, then a redacted-log leader summary batch', async (t) => {
|
||||
const socket = new FakeSocket();
|
||||
const order: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
const released: string[] = [];
|
||||
const logs: Array<Record<string, unknown>> = [];
|
||||
const delivery: LeaderTaskSummaryDelivery = {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
channel_id: 'channel-leader-a',
|
||||
task_id: 'TASK-PRIVATE-1',
|
||||
recipient_address: 'channel:wechat:private-leader-address',
|
||||
recipient_fingerprint: 'a1b2c3d4e5f6',
|
||||
conversation_id: 'private-wechat-conversation',
|
||||
conversation_fingerprint: 'f6e5d4c3b2a1',
|
||||
payload: {
|
||||
event: 'task.summary',
|
||||
status: 'completed',
|
||||
task_id: 'TASK-PRIVATE-1',
|
||||
text: '【员工任务摘要】\n员工:private-employee'
|
||||
},
|
||||
attempt_count: 1
|
||||
};
|
||||
let claimed = false;
|
||||
const tasks: AgentBusTaskGateway = {
|
||||
events: new EventEmitter(),
|
||||
async ingestMessage() {
|
||||
return { task: makeTask('failed'), attached: false, created: true };
|
||||
},
|
||||
async getTask() {
|
||||
return makeTask('failed');
|
||||
},
|
||||
async listAgentBusFinalizationCandidates() {
|
||||
return [];
|
||||
},
|
||||
async enqueueAgentBusResult() {},
|
||||
async claimAgentBusDeliveries() {
|
||||
order.push('employee');
|
||||
return [];
|
||||
},
|
||||
async markAgentBusDeliveryDelivered() {},
|
||||
async markAgentBusDeliveryFailed() {},
|
||||
async releaseAgentBusDeliveries() {}
|
||||
};
|
||||
const leaderNotifications: LeaderNotificationGateway = {
|
||||
async claimDeliveries() {
|
||||
order.push('leader');
|
||||
if (claimed) return [];
|
||||
claimed = true;
|
||||
return [delivery];
|
||||
},
|
||||
async markDeliveryDelivered(deliveryId) {
|
||||
delivered.push(deliveryId);
|
||||
},
|
||||
async markDeliveryFailed() {},
|
||||
async releaseDeliveries(channelId) {
|
||||
released.push(channelId);
|
||||
}
|
||||
};
|
||||
const listener = new AgentBusListener({
|
||||
config: testConfig(),
|
||||
tasks,
|
||||
leaderNotifications,
|
||||
organizationId: 'org-1',
|
||||
scheduleParseQueue: async () => {},
|
||||
socketFactory: () => socket as unknown as AgentBusSocket,
|
||||
channel: {
|
||||
id: 'channel-leader-a',
|
||||
displayName: '组长 A',
|
||||
wsUrl: 'wss://mesh.nianxx.cn/ws',
|
||||
wsToken: 'leader-channel-token',
|
||||
botAddress: 'bot:leader-a:listener',
|
||||
ownerUserId: 'leader-a',
|
||||
ownerRole: 'team_lead'
|
||||
},
|
||||
logger: {
|
||||
info(metadata) { logs.push(metadata); },
|
||||
warn(metadata) { logs.push(metadata); },
|
||||
error(metadata) { logs.push(metadata); }
|
||||
}
|
||||
});
|
||||
t.after(() => listener.stop());
|
||||
listener.start();
|
||||
socket.readyState = 1;
|
||||
socket.emit('open');
|
||||
socket.emit('message', JSON.stringify({
|
||||
id: 'ready-leader-summary',
|
||||
type: 'event',
|
||||
session_id: 'session-leader-summary',
|
||||
epoch: 1,
|
||||
to: 'bot:leader-a:listener',
|
||||
payload: { event: 'session.ready' }
|
||||
}));
|
||||
|
||||
await waitFor(() => socket.sent.length === 1 && delivered.length === 1);
|
||||
assert.deepEqual(order.slice(0, 2), ['employee', 'leader']);
|
||||
assert.equal(delivered[0], delivery.id);
|
||||
assert.equal(socket.sent[0].reply_to, undefined);
|
||||
assert.equal((socket.sent[0].payload as Record<string, unknown>).event, 'task.summary');
|
||||
const logText = JSON.stringify(logs);
|
||||
assert.doesNotMatch(logText, /private-leader-address|private-wechat-conversation|private-employee/);
|
||||
assert.match(logText, /a1b2c3d4e5f6/);
|
||||
listener.stop();
|
||||
await waitFor(() => released.includes('channel-leader-a'));
|
||||
});
|
||||
|
||||
test('AgentBus result text uses the unified important message and preserves the confirmation gate', () => {
|
||||
const needsInput = makeTask('awaiting_user_input', {
|
||||
input_request: {
|
||||
|
||||
@@ -443,11 +443,11 @@ test('migration contains the durable state tables and safety fields', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('control plane requires the administrator task-isolation migration before readiness', async () => {
|
||||
test('control plane requires the leader summary webhook migration before readiness', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
|
||||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '022_shared_child_order_batch_create');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '023_leader_summary_webhook_delivery');
|
||||
assert.match(db, /schema_migrations/);
|
||||
assert.match(db, /databaseReadiness/);
|
||||
assert.match(db, /assertDatabaseSchema/);
|
||||
@@ -1361,8 +1361,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
||||
assert.match(index, /id="loginPanel"/);
|
||||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||||
assert.match(index, /styles\.css\?v=20260907-admin-task-isolation-1/);
|
||||
assert.match(index, /app\.js\?v=20260907-admin-task-isolation-1/);
|
||||
assert.match(index, /styles\.css\?v=20260908-leader-webhook-1/);
|
||||
assert.match(index, /app\.js\?v=20260908-leader-webhook-1/);
|
||||
assert.match(index, /id="statusDetailsPopover"/);
|
||||
assert.match(index, /id="statusDetailsRefresh"/);
|
||||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -6,118 +6,114 @@ async function source(relativePath: string): Promise<string> {
|
||||
return readFile(new URL(relativePath, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
test('migration keeps the automatic feature fail-closed in storage and separate from employee replies', async () => {
|
||||
const sql = await source('../migrations/020_leader_task_summary_notifications.sql');
|
||||
assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_subscriptions/);
|
||||
// The runtime reconciler is the only component that activates rows. A raw
|
||||
// insert must remain inert when identity or routing checks have not run.
|
||||
assert.match(sql, /enabled boolean NOT NULL DEFAULT false/);
|
||||
assert.match(sql, /scope text NOT NULL DEFAULT 'organization'/);
|
||||
assert.match(sql, /CHECK \(include_manual OR include_agentbus\)/);
|
||||
assert.match(sql, /recipient_address_ciphertext text NOT NULL/);
|
||||
assert.match(sql, /conversation_id_ciphertext text NOT NULL/);
|
||||
test('migration retires only the legacy leader-summary transport and creates an encrypted webhook outbox', async () => {
|
||||
const sql = await source('../migrations/023_leader_summary_webhook_delivery.sql');
|
||||
assert.match(sql, /UPDATE leader_task_summary_subscriptions[\s\S]*?SET enabled = false/);
|
||||
assert.match(sql, /UPDATE leader_task_summary_deliveries[\s\S]*?delivery_status = 'cancelled'/);
|
||||
assert.match(sql, /WHERE delivery_status IN \('pending', 'sending', 'failed'\)/);
|
||||
assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_webhook_state/);
|
||||
assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_webhook_deliveries/);
|
||||
assert.match(sql, /configuration_fingerprint text NOT NULL/);
|
||||
assert.match(sql, /payload_ciphertext text NOT NULL/);
|
||||
assert.match(sql, /target_verified_at timestamptz/);
|
||||
assert.match(sql, /UNIQUE \(organization_id, leader_user_id\)/);
|
||||
assert.match(sql, /UNIQUE \(subscription_id, subscription_revision, task_id, milestone\)/);
|
||||
assert.doesNotMatch(sql, /INSERT\s+INTO/iu, 'schema migration must not backfill or send historical tasks');
|
||||
assert.doesNotMatch(sql, /REFERENCES\s+agentbus_deliveries/iu);
|
||||
assert.doesNotMatch(sql, /recipient_address\s+text/iu);
|
||||
assert.doesNotMatch(sql, /conversation_id\s+text/iu);
|
||||
assert.doesNotMatch(sql, /payload\s+jsonb/iu);
|
||||
assert.match(sql, /payload_fingerprint text NOT NULL/);
|
||||
assert.match(sql, /delivery_status IN \('pending', 'sending', 'accepted', 'failed', 'uncertain', 'cancelled'\)/);
|
||||
assert.match(sql, /CHECK \(attempt_count BETWEEN 0 AND 1\)/);
|
||||
assert.match(sql, /UNIQUE \(organization_id, webhook_revision, task_id, milestone\)/);
|
||||
assert.match(sql, /FOREIGN KEY \(organization_id, task_id\)[\s\S]*?REFERENCES tasks \(organization_id, id\)/);
|
||||
const newOutbox = sql.slice(sql.indexOf('CREATE TABLE IF NOT EXISTS leader_task_summary_webhook_deliveries'));
|
||||
assert.doesNotMatch(newOutbox, /\b(channel_id|leader_user_id|recipient_address|conversation_id|token|webhook_url)\b/i);
|
||||
assert.doesNotMatch(sql, /INSERT\s+INTO\s+leader_task_summary_webhook_deliveries/i,
|
||||
'schema migration must not backfill historical employee activity');
|
||||
assert.doesNotMatch(sql, /UPDATE\s+(tasks|task_events|task_attempts|agentbus_deliveries)\b/i,
|
||||
'transport migration must not mutate normal task or employee AgentBus state');
|
||||
});
|
||||
|
||||
test('automatic reconciliation derives enabled subscriptions from leader identity and AgentBus routing', async () => {
|
||||
test('webhook worker is independent from normal task execution and AgentBus delivery', async () => {
|
||||
const service = await source('../src/leader-notification-service.ts');
|
||||
const channels = await source('../src/agentbus-channels.ts');
|
||||
assert.match(service, /reconcileAutomaticSubscriptions/);
|
||||
assert.match(service, /leader\.role = 'team_lead'/);
|
||||
assert.match(service, /leader\.is_active = true/);
|
||||
assert.match(service, /leader\.erp_account IS NOT NULL/);
|
||||
assert.match(service, /channel\.owner_user_id = leader\.id/);
|
||||
assert.match(service, /latest_route\.inbound_from/);
|
||||
assert.match(service, /channel\.external_user_ref/);
|
||||
assert.match(service, /route_task\.assigned_user_id = leader\.id/);
|
||||
assert.match(service, /source: inboundRecipient \? 'agentbus_inbound' : 'channel_external_user_ref'/);
|
||||
assert.match(service, /`agentbus:\$\{recipientAddress\}`/);
|
||||
assert.match(service, /VALUES \(\$1, \$2, \$3, true, true, true, now\(\), 0/);
|
||||
assert.match(service, /target_verified_at = now\(\)/);
|
||||
assert.match(service, /automatic_disabled/);
|
||||
assert.match(service, /channel_disabled.*route_unavailable.*role_or_channel_invalid/s);
|
||||
assert.doesNotMatch(service, /upsertSubscription|expectedRevision|leader_summary_target_unverified/);
|
||||
assert.match(channels, /ownerChanged[\s\S]*?\? null[\s\S]*?: currentExternalUserRef/);
|
||||
});
|
||||
|
||||
test('projection remains organization-scoped, future-only, role-safe and encrypted', async () => {
|
||||
const service = await source('../src/leader-notification-service.ts');
|
||||
assert.match(service, /event\.topic = 'task\.updated'/);
|
||||
assert.match(service, /event\.created_at >= subscription\.starts_at/);
|
||||
assert.match(service, /task\.assigned_user_id <> subscription\.leader_user_id/);
|
||||
assert.match(service, /assignee\.role <> 'admin'/);
|
||||
assert.match(service, /task\.source IN \('manual', 'agentbus'\)/);
|
||||
assert.match(service, /subscription\.include_manual/);
|
||||
assert.match(service, /subscription\.include_agentbus/);
|
||||
assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /subscription\.target_verified_at IS NOT NULL/);
|
||||
assert.match(service, /pg_try_advisory_xact_lock/);
|
||||
assert.match(service, /encryptText\(this\.config, payloadText\)/);
|
||||
assert.match(service, /FOR UPDATE OF delivery SKIP LOCKED/);
|
||||
assert.match(service, /sha256Text\(payloadText\).*payload_fingerprint/s);
|
||||
assert.match(service, /sha256Text\(recipientAddress\).*recipient_address_fingerprint/s);
|
||||
assert.match(service, /sha256Text\(conversationId\).*conversation_id_fingerprint/s);
|
||||
assert.match(service, /delivery_status = 'cancelled'.*旧路由待发送摘要已取消/s);
|
||||
assert.doesNotMatch(service, /reply_to/);
|
||||
});
|
||||
|
||||
test('HTTP exposes administrator status only and no manual mutation or live test-send endpoint', async () => {
|
||||
const server = await source('../src/server.ts');
|
||||
const routeStart = server.indexOf("app.get('/api/settings/leader-summary-subscriptions'");
|
||||
const routeEnd = server.indexOf("app.post('/api/auth/logout'", routeStart);
|
||||
const routes = server.slice(routeStart, routeEnd);
|
||||
assert.ok(routeStart > 0 && routeEnd > routeStart);
|
||||
assert.match(routes, /requireAdminSession/);
|
||||
assert.doesNotMatch(routes, /app\.(put|post|patch|delete)/);
|
||||
assert.doesNotMatch(server, /leaderSummarySubscriptionSchema|upsertSubscription/);
|
||||
assert.doesNotMatch(server, /leader-summary-subscriptions.*test-send|leader-summary-subscriptions.*test\/send/s);
|
||||
});
|
||||
|
||||
test('AgentBus sends summaries as reserved low-priority proactive events without plaintext frame logging', async () => {
|
||||
const agentbus = await source('../src/agentbus.ts');
|
||||
const channels = await source('../src/agentbus-channels.ts');
|
||||
|
||||
assert.match(service, /new ExternalWebhookClient\(/);
|
||||
assert.match(service, /config\.leaderSummaryWebhookEnabled/);
|
||||
assert.match(server, /if \(config\.leaderSummaryWebhookEnabled\)[\s\S]*?leaderNotificationService\.startProjector/);
|
||||
assert.doesNotMatch(server, /new AgentBusManager\([\s\S]*?leaderNotifications:/);
|
||||
assert.doesNotMatch(agentbus, /LeaderNotificationGateway|createLeaderTaskSummaryFrame|flushLeaderDeliveries|sendLeaderDelivery|observeLeaderRoute/);
|
||||
assert.doesNotMatch(channels, /leaderNotifications:/);
|
||||
|
||||
const flushStart = agentbus.indexOf('private async flushOutboundDeliveries');
|
||||
const flushEnd = agentbus.indexOf('private async sendDurableDelivery', flushStart);
|
||||
const employeeFlush = agentbus.slice(flushStart, flushEnd);
|
||||
assert.match(employeeFlush, /await this\.flushDurableDeliveries\(\)/);
|
||||
assert.doesNotMatch(employeeFlush, /leader|webhook/i);
|
||||
|
||||
const ignoreStart = agentbus.indexOf('function inboundFrameIgnoreReason');
|
||||
const ignoreEnd = agentbus.indexOf('export function parseAgentBusFrame', ignoreStart);
|
||||
assert.match(agentbus.slice(ignoreStart, ignoreEnd), /'task\.summary'/);
|
||||
assert.match(agentbus.slice(ignoreStart, ignoreEnd), /reserved_event/);
|
||||
assert.match(agentbus, /id: `leader-summary-\$\{delivery\.id\}`/);
|
||||
assert.match(agentbus, /to: delivery\.recipient_address/);
|
||||
assert.match(agentbus, /conversation_id: delivery\.conversation_id/);
|
||||
const frameStart = agentbus.indexOf('export function createLeaderTaskSummaryFrame');
|
||||
const frameEnd = agentbus.indexOf('export function taskResultStatus', frameStart);
|
||||
assert.doesNotMatch(agentbus.slice(frameStart, frameEnd), /reply_to/);
|
||||
const flushStart = agentbus.indexOf('private async flushOutboundDeliveries');
|
||||
const sendEnd = agentbus.indexOf('private async sendDurableDelivery', flushStart);
|
||||
const leaderDelivery = agentbus.slice(flushStart, sendEnd);
|
||||
assert.match(leaderDelivery, /await this\.flushDurableDeliveries\(\)/);
|
||||
assert.match(leaderDelivery, /await this\.flushLeaderDeliveries\(\)/);
|
||||
assert.match(leaderDelivery, /recipient_fingerprint/);
|
||||
assert.match(leaderDelivery, /conversation_fingerprint/);
|
||||
assert.doesNotMatch(leaderDelivery, /this\.logFrame/);
|
||||
assert.match(agentbus, /ownerRole === 'team_lead'/);
|
||||
assert.match(agentbus, /observeLeaderRoute/);
|
||||
assert.match(agentbus, /recipientAddress: text\(frame\.from\),\s*conversationId/s);
|
||||
});
|
||||
|
||||
test('administrator UI is read-only and explains role-driven automatic delivery', async () => {
|
||||
test('projection is organization-scoped, future-only, role-safe, source-limited and encrypted', async () => {
|
||||
const service = await source('../src/leader-notification-service.ts');
|
||||
assert.match(service, /event\.topic = 'task\.updated'/);
|
||||
assert.match(service, /event\.created_at >= state\.starts_at/);
|
||||
assert.match(service, /task\.assigned_user_id IS NOT NULL/);
|
||||
assert.match(service, /assignee\.organization_id = task\.organization_id/);
|
||||
assert.match(service, /assignee\.role <> 'admin'/);
|
||||
assert.match(service, /task\.source IN \('manual', 'agentbus'\)/);
|
||||
assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /pg_try_advisory_xact_lock/);
|
||||
assert.match(service, /VALUES \(\$1::uuid, NULL, \$2, 'leader_task_summary_webhook', \$1::uuid::text, NULL, \$3\)/,
|
||||
'organization IDs must be explicitly cast when shared by UUID and text audit columns');
|
||||
assert.match(service, /encryptText\(this\.config, payloadText\)/);
|
||||
assert.match(service, /sha256Text\(payloadText\)/);
|
||||
assert.match(service, /ON CONFLICT \(organization_id, webhook_revision, task_id, milestone\) DO NOTHING/);
|
||||
assert.doesNotMatch(service, /UPDATE\s+(tasks|task_events|task_attempts|agentbus_deliveries)\b/i,
|
||||
'summary projection must never write normal task or employee reply state');
|
||||
});
|
||||
|
||||
test('delivery makes one attempt and preserves ambiguous outcomes instead of retrying', async () => {
|
||||
const service = await source('../src/leader-notification-service.ts');
|
||||
assert.match(service, /delivery_status = 'sending'[\s\S]*?updated_at < now\(\) - interval '1 minute'/);
|
||||
assert.match(service, /SET delivery_status = 'uncertain'[\s\S]*?发送租约过期/);
|
||||
assert.match(service, /delivery\.attempt_count = 0/);
|
||||
assert.match(service, /attempt_count = attempt_count \+ 1/);
|
||||
assert.match(service, /FOR UPDATE OF delivery SKIP LOCKED/);
|
||||
assert.match(service, /result\.outcome === 'accepted'[\s\S]*?'accepted'[\s\S]*?'failed'[\s\S]*?'uncertain'/);
|
||||
assert.match(service, /automatic retry disabled/);
|
||||
const outcomeStart = service.indexOf('private async markDeliveryOutcome');
|
||||
const outcomeEnd = service.indexOf('private async dispatchPending', outcomeStart);
|
||||
const outcomeWriter = service.slice(outcomeStart, outcomeEnd);
|
||||
assert.ok(outcomeStart > 0 && outcomeEnd > outcomeStart);
|
||||
assert.doesNotMatch(outcomeWriter, /'pending'/,
|
||||
'terminal delivery outcomes must never be placed back into the pending queue');
|
||||
});
|
||||
|
||||
test('HTTP exposes administrator-only status and no mutation or live test-send endpoint', async () => {
|
||||
const server = await source('../src/server.ts');
|
||||
const routeStart = server.indexOf("app.get('/api/settings/leader-summary-webhook'");
|
||||
const routeEnd = server.indexOf("app.post('/api/auth/logout'", routeStart);
|
||||
const routes = server.slice(routeStart, routeEnd);
|
||||
assert.ok(routeStart > 0 && routeEnd > routeStart);
|
||||
assert.match(routes, /requireAdminSession/);
|
||||
assert.match(routes, /getWebhookStatus/);
|
||||
assert.doesNotMatch(routes, /app\.(put|post|patch|delete)/);
|
||||
assert.doesNotMatch(server, /leader-summary-webhook.*test-send|leader-summary-webhook.*test\/send/s);
|
||||
});
|
||||
|
||||
test('administrator UI explains the isolated fixed webhook and accepted-not-delivered semantics', async () => {
|
||||
const html = await source('../../LianSyn-platform/index.html');
|
||||
const app = await source('../../LianSyn-platform/app.js');
|
||||
assert.match(html, /组长任务摘要抄送/);
|
||||
assert.match(html, /无需单独配置或启用/);
|
||||
assert.match(html, /只处理自动启用后的新结果,不补发历史任务/);
|
||||
assert.match(html, /已经到达微信的消息无法撤回/);
|
||||
assert.doesNotMatch(html, /leaderSummary(Form|ChannelId|RecipientAddress|ConversationId|TargetVerified|Enabled|Save)/);
|
||||
assert.match(app, /自动推送/);
|
||||
assert.match(app, /等待路由/);
|
||||
assert.match(app, /无需维护收件地址或微信会话 ID/);
|
||||
assert.doesNotMatch(app, /leader-summary-subscriptions\/\$\{|expected_revision: subscription\.revision/);
|
||||
assert.doesNotMatch(app, /leader-summary-subscriptions[^'"\n]*backfill/);
|
||||
assert.match(html, /组长任务摘要推送/);
|
||||
assert.match(html, /不再依赖组长 AgentBus 账号或渠道/);
|
||||
assert.match(html, /WEBHOOK_SEND_URL/);
|
||||
assert.match(html, /WEBHOOK_EXTERNAL_TOKEN/);
|
||||
assert.match(html, /只处理配置生效后的新结果,不补发历史任务/);
|
||||
assert.match(html, /已受理.*不代表微信已送达/);
|
||||
assert.match(html, /不会自动重试/);
|
||||
assert.match(app, /\/api\/settings\/leader-summary-webhook/);
|
||||
assert.match(app, /人工任务、AgentBus 任务/);
|
||||
assert.match(app, /已使用独立外部 Webhook,不受该渠道影响/);
|
||||
assert.doesNotMatch(app, /leader-summary-subscriptions/);
|
||||
});
|
||||
Reference in new issue
Block a user