mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 00:03:26 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
428
test/channels/dingtalk/dingtalk-controller.test.mjs
Normal file
428
test/channels/dingtalk/dingtalk-controller.test.mjs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
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/,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue