dsh-im-ops/test/channels/dingtalk/dingtalk-controller.test.mjs
2026-08-30 11:24:53 +08:00

475 lines
17 KiB
JavaScript
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 {
deriveDingtalkBotIdentity,
} from '../../../src/channels/dingtalk/config-store.mjs';
import { DingtalkController } from '../../../src/channels/dingtalk/dingtalk-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function credentialsFixture(events = []) {
const values = new Map();
return {
values,
provider: {
resolve: async (ref) => values.has(ref)
? { configured: true, source: 'settings', value: values.get(ref) }
: { configured: false },
set: async (ref, value) => {
events.push(['set', ref]);
values.set(ref, value);
},
unset: async (ref) => {
events.push(['unset', ref]);
values.delete(ref);
},
},
};
}
function configFixture(initial = [], events = []) {
const bots = new Map(initial.map((bot) => [bot.botId, structuredClone(bot)]));
return {
bots,
store: {
list: () => [...bots.values()].map((bot) => structuredClone(bot)),
get: (botId) => bots.has(botId) ? structuredClone(bots.get(botId)) : null,
getByClientId: (clientId) => {
const found = [...bots.values()].find((bot) => bot.clientId === clientId);
return found ? structuredClone(found) : null;
},
save: async (bot) => {
events.push(['save', bot.botId]);
bots.set(bot.botId, structuredClone(bot));
return structuredClone(bot);
},
remove: async (botId) => {
events.push(['remove', botId]);
const value = bots.get(botId) ?? null;
bots.delete(botId);
return value;
},
},
};
}
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
return {
runtimes,
connectionTests,
proactiveSends,
createRuntime: async ({ botId, config, clientSecret }) => {
events.push(['create', botId]);
let ready = false;
const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
const pending = (pendingByClient.get(config.clientId) ?? [])
.filter((sender) => !approvedIds.has(sender.staffId));
const runtime = {
botId,
config,
clientSecret,
get status() {
return {
ready,
state: ready ? 'connected' : 'offline',
pendingSenders: pending,
messagesReceived: 2,
messagesReplied: 1,
};
},
pendingSender(requestId) {
return pending.find((sender) => sender.requestId === requestId) ?? null;
},
async start() {
events.push(['start', botId]);
if (typeof failStart === 'function' ? failStart() : failStart) {
throw new Error('runtime failure containing private-secret');
}
ready = true;
},
async stop() {
events.push(['stop', botId]);
ready = false;
},
async sendConnectionTest(text) {
connectionTests.push({ botId, text });
},
async sendProactiveText(...args) {
proactiveSends.push({ botId, args });
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
},
};
}
function successfulDeviceAuth(clientId = 'ding-client-private', clientSecret = 'client-secret-private') {
return {
start: async () => ({
deviceCode: 'device-code-private',
verificationUrl: 'https://oapi.dingtalk.com/verify?code=public-qr-code',
expiresAt: 100_000,
pollIntervalMs: 1_000,
}),
poll: async ({ deviceCode }) => {
assert.equal(deviceCode, 'device-code-private');
return { status: 'SUCCESS', clientId, clientSecret };
},
};
}
test('successful QR poll stores secret then config then starts runtime without public leakage', async () => {
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
assert.equal(begun.status, 'pending');
assert.doesNotMatch(JSON.stringify(begun), /device-code-private|client-secret-private|secretRef/);
const completed = await controller.registrationStatus(begun.attemptId);
assert.equal(completed.status, 'connected');
assert.match(completed.botId, /^dt_[a-f0-9]{24}$/);
assert.deepEqual(events.slice(0, 4).map(([event]) => event), ['set', 'save', 'create', 'start']);
assert.equal(credentials.values.size, 1);
assert.equal([...credentials.values.values()][0], 'client-secret-private');
assert.equal(configs.bots.size, 1);
assert.equal(runtimes.runtimes[0].clientSecret, 'client-secret-private');
const publicJson = JSON.stringify(controller.status());
assert.doesNotMatch(
publicJson,
/device-code-private|client-secret-private|secretRef|ding-client-private/,
);
assert.equal(controller.status().totals.connected, 1);
await controller.sendConnectionTest(completed.botId);
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
const target = { kind: 'user', route: { userId: 'staff-one' } };
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(runtimes.proactiveSends, [{
botId: completed.botId,
args: [target, '主动投递', {}],
}]);
await controller.close();
});
test('manual DingTalk Client ID and Client Secret binding stores credentials off the public plane', async () => {
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => 1_000,
});
const status = await controller.bindCredentials({
clientId: 'manual-ding-client',
clientSecret: 'manual-ding-secret',
});
assert.equal(status.totals.connected, 1);
assert.equal([...credentials.values.values()][0], 'manual-ding-secret');
assert.equal(runtimes.runtimes[0].clientSecret, 'manual-ding-secret');
assert.doesNotMatch(JSON.stringify(status), /manual-ding-client|manual-ding-secret|secretRef/);
await controller.close();
});
test('scanning the same client ID is idempotent and replaces its one runtime', async () => {
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => 1_000,
});
const first = await controller.startProvisioning();
await controller.registrationStatus(first.attemptId);
const second = await controller.startProvisioning();
const completed = await controller.registrationStatus(second.attemptId);
assert.equal(completed.alreadyConnected, true);
assert.equal(configs.bots.size, 1);
assert.equal(credentials.values.size, 1);
assert.equal(controller.status().bots.length, 1);
assert.equal(runtimes.runtimes.length, 2);
assert.equal(runtimes.runtimes[0].status.ready, false);
assert.equal(runtimes.runtimes[1].status.ready, true);
await controller.close();
});
test('sender approval uses opaque request and sender keys while raw staff IDs stay host-only', async () => {
const events = [];
const identity = deriveDingtalkBotIdentity('ding-client-one');
const config = {
...identity,
clientId: 'ding-client-one',
approvedSenders: [],
};
const credentials = credentialsFixture(events);
credentials.values.set(identity.secretRef, 'client-secret-private');
const configs = configFixture([config], events);
const pendingByClient = new Map([[
'ding-client-one',
[{
staffId: 'staff-private-one',
senderNick: '待审批用户',
requestedAt: '2026-08-15T00:00:00.000Z',
requestId: 'ding_sender_abc123',
}],
]]);
const runtimes = runtimeFactory({ events, pendingByClient });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => Date.parse('2026-08-15T01:00:00.000Z'),
});
await controller.initialize();
const pending = controller.status().bots[0].senders.pending[0];
assert.equal(pending.requestId, 'ding_sender_abc123');
assert.equal(pending.displayName, '待审批用户');
assert.doesNotMatch(JSON.stringify(controller.status()), /staff-private-one|secretRef/);
await controller.approveSender(identity.botId, pending.requestId);
const approvedStatus = controller.status().bots[0].senders;
assert.equal(approvedStatus.pending.length, 0);
assert.equal(approvedStatus.approved.length, 1);
assert.match(approvedStatus.approved[0].senderKey, /^dt_sender_[a-f0-9]{32}$/);
assert.equal(approvedStatus.approved[0].approvedAt, '2026-08-15T01:00:00.000Z');
assert.equal(configs.bots.get(identity.botId).approvedSenders[0].staffId, 'staff-private-one');
assert.doesNotMatch(JSON.stringify(controller.status()), /staff-private-one|client-secret-private/);
await controller.revokeSender(identity.botId, approvedStatus.approved[0].senderKey);
assert.equal(configs.bots.get(identity.botId).approvedSenders.length, 0);
assert.equal(controller.status().bots[0].senders.approved.length, 0);
await controller.close();
});
test('runtime activation failure retains the authorized bot for reconnect without exposing detail', async () => {
let startCount = 0;
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events, failStart: () => startCount++ === 0 });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
logger: { error() {}, warn() {} },
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const completed = await controller.registrationStatus(begun.attemptId);
assert.equal(completed.status, 'connected');
assert.equal(credentials.values.size, 1);
assert.equal(configs.bots.size, 1);
const status = controller.status();
assert.deepEqual(status.totals, { configured: 1, connected: 0 });
assert.equal(status.bots[0].state, 'error');
assert.equal(status.bots[0].error.code, 'connection-failed');
assert.equal(events.some(([event]) => event === 'unset' || event === 'remove'), false);
assert.doesNotMatch(
JSON.stringify({ completed, status }),
/private-secret|client-secret-private|device-code-private|secretRef/,
);
const reconnected = await controller.reconnectBot(completed.botId);
assert.deepEqual(reconnected.totals, { configured: 1, connected: 1 });
assert.equal(reconnected.bots[0].state, 'connected');
assert.equal(reconnected.bots[0].error, null);
assert.equal(credentials.values.size, 1);
assert.equal(configs.bots.size, 1);
await controller.close();
});
test('config persistence failure rolls back the newly stored credential', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: {
...configs.store,
save: async () => {
throw new Error('config failure containing private-secret');
},
},
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const failed = await controller.registrationStatus(begun.attemptId);
assert.equal(failed.status, 'failed');
assert.equal(failed.error.code, 'activation-failed');
assert.equal(credentials.values.size, 0);
assert.equal(configs.bots.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /private-secret|client-secret-private|device-code-private/);
await controller.close();
});
test('cancelling while the persisted bot is starting rolls its binding back', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
let enteredStart;
let releaseStart;
const startEntered = new Promise((resolve) => { enteredStart = resolve; });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: async () => ({
status: { ready: false },
start: async () => new Promise((resolve) => {
releaseStart = resolve;
enteredStart();
}),
stop: async () => {},
}),
logger: { error() {}, warn() {} },
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const polling = controller.registrationStatus(begun.attemptId);
await startEntered;
assert.equal(credentials.values.size, 1);
assert.equal(configs.bots.size, 1);
const cancelling = controller.cancelProvisioning(begun.attemptId);
releaseStart();
const [cancelled, polled] = await Promise.all([cancelling, polling]);
assert.equal(cancelled.status, 'cancelled');
assert.equal(polled.status, 'cancelled');
assert.equal(credentials.values.size, 0);
assert.equal(configs.bots.size, 0);
await controller.close();
});
test('cancelling an in-flight poll aborts it and writes no credential', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const controller = new DingtalkController({
deviceAuth: {
start: successfulDeviceAuth().start,
poll: async ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const polling = controller.registrationStatus(begun.attemptId);
await flush();
const cancelled = await controller.cancelProvisioning(begun.attemptId);
assert.equal(cancelled.status, 'cancelled');
assert.equal((await polling).status, 'cancelled');
assert.equal(credentials.values.size, 0);
assert.equal(configs.bots.size, 0);
await controller.close();
});
test('initialize manages multiple configured bot runtimes independently', async () => {
const firstIdentity = deriveDingtalkBotIdentity('ding-client-one');
const secondIdentity = deriveDingtalkBotIdentity('ding-client-two');
const configs = configFixture([
{ ...firstIdentity, clientId: 'ding-client-one', approvedSenders: [] },
{ ...secondIdentity, clientId: 'ding-client-two', approvedSenders: [] },
]);
const credentials = credentialsFixture();
credentials.values.set(firstIdentity.secretRef, 'secret-one');
credentials.values.set(secondIdentity.secretRef, 'secret-two');
const runtimes = runtimeFactory();
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
});
const status = await controller.initialize();
assert.deepEqual(status.totals, { configured: 2, connected: 2 });
assert.equal(runtimes.runtimes.length, 2);
await controller.close();
});
test('close waits for an in-flight transition and leaves no connected runtime behind', async () => {
const identity = deriveDingtalkBotIdentity('ding-client-close');
const configs = configFixture([{
...identity,
clientId: 'ding-client-close',
approvedSenders: [],
}]);
const credentials = credentialsFixture();
credentials.values.set(identity.secretRef, 'secret-close');
let rejectStart;
let stops = 0;
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: async () => ({
status: { ready: false },
start: async () => new Promise((_, reject) => { rejectStart = reject; }),
stop: async () => {
stops += 1;
rejectStart?.(new DOMException('Runtime stopped', 'AbortError'));
},
}),
logger: { warn() {}, error() {} },
});
const initializing = controller.initialize();
await flush();
const closing = controller.close();
await Promise.race([
closing,
new Promise((_, reject) => setTimeout(
() => reject(new Error('controller close did not stop the provisional runtime')),
100,
)),
]);
await Promise.allSettled([initializing, closing]);
assert.ok(stops >= 1);
assert.equal(controller.status().totals.connected, 0);
await assert.rejects(
controller.reconnectBot(identity.botId),
/controller is closed/,
);
});