dsh-im-ops/test/channels/dingtalk/dingtalk-controller.test.mjs
2026-08-15 15:40:53 +08:00

428 lines
16 KiB
JavaScript

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 = [];
return {
runtimes,
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;
},
};
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.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/,
);
});