mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
428 lines
16 KiB
JavaScript
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/,
|
|
);
|
|
});
|