dsh-im-ops/test/channels/feishu/multi-bot-controller.test.mjs
2026-08-16 01:54:06 +08:00

502 lines
19 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { MultiBotDshFeishuController } from '../../../src/channels/feishu/multi-bot-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function waitFor(predicate, timeoutMs = 1000) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('condition timed out');
await flush();
}
}
class MemoryConfigStore {
constructor(bots = []) {
this.bots = structuredClone(bots);
}
list() { return structuredClone(this.bots); }
getBot(id) {
const bot = this.bots.find((candidate) => candidate.id === id);
return bot ? structuredClone(bot) : null;
}
async saveBot(bot) {
const index = this.bots.findIndex((candidate) => candidate.id === bot.id);
if (index === -1) this.bots.push(structuredClone(bot));
else this.bots[index] = structuredClone(bot);
return structuredClone(bot);
}
async removeBot(id) {
const index = this.bots.findIndex((candidate) => candidate.id === id);
return index === -1 ? null : this.bots.splice(index, 1)[0];
}
}
function bot(id, suffix = id) {
return {
id,
appId: `cli_${suffix}`,
secretRef: `DSH_FEISHU_APP_SECRET_${suffix.toUpperCase()}`,
ownerOpenIds: [`ou_${suffix}`],
domain: 'feishu',
botName: `机器人 ${suffix}`,
botOpenId: `ou_bot_${suffix}`,
activated: 1,
};
}
function fixture({
bots = [],
secrets = {},
createBotIds = [],
failResolveRefs = new Set(),
failUnsetRefs = new Set(),
runtimeStart,
deleteState,
} = {}) {
const configStore = new MemoryConfigStore(bots);
const values = new Map(Object.entries(secrets));
const unsetCalls = [];
const registrationRuns = [];
const runtimes = new Map();
let registrationSequence = 0;
let botSequence = 0;
const controller = new MultiBotDshFeishuController({
registerApp(options) {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
registrationRuns.push({ options, resolve, reject });
return promise;
},
verifyApp: async ({ appId }) => ({
name: `已验证 ${appId}`,
openId: `ou_bot_${appId}`,
activated: 1,
}),
credentials: {
async resolve(ref) {
if (failResolveRefs.has(ref)) throw new Error('credential provider unavailable');
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
},
async set(ref, value) { values.set(ref, value); },
async unset(ref) {
unsetCalls.push(ref);
if (failUnsetRefs.has(ref)) throw new Error('credential provider is read-only');
values.delete(ref);
},
},
configStore,
createRuntime: async ({ botId, config, appSecret }) => {
const status = {
ready: false,
feishuLongConnectionState: 'idle',
harnessReachable: false,
};
const runtime = {
botId,
config: structuredClone(config),
appSecret,
starts: 0,
stops: 0,
get status() { return structuredClone(status); },
async start() {
runtime.starts += 1;
if (runtimeStart) await runtimeStart({ botId, runtime });
status.ready = true;
status.feishuLongConnectionState = 'connected';
status.harnessReachable = true;
},
async stop() {
runtime.stops += 1;
status.ready = false;
status.feishuLongConnectionState = 'idle';
},
};
const history = runtimes.get(botId) ?? [];
history.push(runtime);
runtimes.set(botId, history);
return runtime;
},
deleteState: deleteState ?? (async () => {}),
createBotId() {
const id = createBotIds[botSequence] ?? `bot_generated_${++botSequence}`;
botSequence += createBotIds.length > 0 ? 1 : 0;
return id;
},
createRegistrationId: () => `reg_${++registrationSequence}`,
});
return { controller, configStore, values, unsetCalls, registrationRuns, runtimes };
}
async function completeScan(fx, result) {
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length > 0);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: `https://accounts.feishu.cn/${attemptId}`, expireIn: 60 });
run.resolve(result);
await waitFor(() => ['succeeded', 'error'].includes(
fx.controller.registrationStatus(attemptId).registration.state,
));
return fx.controller.registrationStatus(attemptId);
}
test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => {
const fx = fixture({ createBotIds: ['bot_manual'] });
const status = await fx.controller.bindCredentials({
appId: 'cli_manual',
appSecret: 'manual-private-secret',
});
assert.equal(status.totals.connected, 1);
assert.equal(fx.configStore.bots.length, 1);
assert.deepEqual(fx.configStore.bots[0].ownerOpenIds, ['*']);
assert.equal(fx.values.get(fx.configStore.bots[0].secretRef), 'manual-private-secret');
assert.equal(fx.runtimes.get('bot_manual')[0].appSecret, 'manual-private-secret');
assert.doesNotMatch(JSON.stringify(status), /manual-private-secret|ownerOpenIds|secretRef/);
await fx.controller.close();
});
test('initialization isolates failures and starts every bot with available credentials', async () => {
const missing = bot('bot_missing', 'missing');
const healthy = bot('bot_healthy', 'healthy');
const fx = fixture({
bots: [missing, healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
failResolveRefs: new Set([missing.secretRef]),
});
await fx.controller.initialize();
const status = fx.controller.status();
assert.equal(status.totals.configured, 2);
assert.equal(status.totals.connected, 1);
assert.equal(status.bots.find((entry) => entry.botId === missing.id).phase, 'error');
assert.equal(status.bots.find((entry) => entry.botId === healthy.id).connected, true);
assert.equal(fx.runtimes.has(missing.id), false);
assert.equal(fx.runtimes.get(healthy.id).length, 1);
assert.doesNotMatch(JSON.stringify(status), /healthy-secret|DSH_FEISHU_APP_SECRET|ou_healthy/);
});
test('repeated initialization never restarts an already healthy bot', async () => {
const healthy = bot('bot_healthy', 'healthy');
const fx = fixture({
bots: [healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
});
await fx.controller.initialize();
await fx.controller.initialize();
assert.equal(fx.controller.status().totals.connected, 1);
assert.equal(fx.runtimes.get(healthy.id).length, 1);
assert.equal(fx.runtimes.get(healthy.id)[0].starts, 1);
assert.equal(fx.runtimes.get(healthy.id)[0].stops, 0);
});
test('multiple scans create independent bots, credential refs and runtimes', async () => {
const fx = fixture({ createBotIds: ['bot_alpha', 'bot_beta'] });
const alpha = completeScan(fx, {
client_id: 'cli_alpha', client_secret: 'secret-alpha',
user_info: { open_id: 'ou_alpha', tenant_brand: 'feishu' },
});
const beta = completeScan(fx, {
client_id: 'cli_beta', client_secret: 'secret-beta',
user_info: { open_id: 'ou_beta', tenant_brand: 'feishu' },
});
const [alphaStatus, betaStatus] = await Promise.all([alpha, beta]);
assert.equal(alphaStatus.registration.state, 'succeeded');
assert.equal(betaStatus.registration.state, 'succeeded');
assert.equal(fx.configStore.list().length, 2);
const [first, second] = fx.configStore.list();
assert.notEqual(first.id, second.id);
assert.notEqual(first.secretRef, second.secretRef);
assert.match(first.secretRef, /^[A-Za-z_][A-Za-z0-9_]*$/);
assert.equal(fx.runtimes.get(first.id).length, 1);
assert.equal(fx.runtimes.get(second.id).length, 1);
assert.equal(fx.controller.status().totals.connected, 2);
});
test('scanning an existing app is idempotent and reuses its bot and secret ref', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'old-secret' },
});
await fx.controller.initialize();
const completed = await completeScan(fx, {
client_id: existing.appId,
client_secret: 'rotated-secret',
user_info: { open_id: 'ou_second_owner', tenant_brand: 'feishu' },
});
assert.equal(completed.registration.state, 'succeeded');
assert.equal(completed.registration.botId, existing.id);
assert.equal(fx.configStore.list().length, 1);
assert.equal(fx.configStore.list()[0].secretRef, existing.secretRef);
assert.deepEqual(fx.configStore.list()[0].ownerOpenIds.sort(), ['ou_existing', 'ou_second_owner']);
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
assert.equal(fx.runtimes.get(existing.id).length, 2);
assert.equal(fx.runtimes.get(existing.id)[0].stops, 1);
});
test('deleting one bot clears only its secret and leaves the other runtime online', async () => {
const alpha = bot('bot_alpha', 'alpha');
const beta = bot('bot_beta', 'beta');
const fx = fixture({
bots: [alpha, beta],
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
});
await fx.controller.initialize();
const alphaRuntime = fx.runtimes.get(alpha.id)[0];
const betaRuntime = fx.runtimes.get(beta.id)[0];
const status = await fx.controller.deleteBot(alpha.id);
assert.deepEqual(fx.unsetCalls, [alpha.secretRef]);
assert.equal(fx.values.has(alpha.secretRef), false);
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
assert.equal(alphaRuntime.stops, 1);
assert.equal(betaRuntime.stops, 0);
assert.equal(status.totals.configured, 1);
assert.equal(status.totals.connected, 1);
assert.equal(status.bots[0].botId, beta.id);
});
test('cancelling a terminal attempt is a no-op and cannot roll back a newer same-app scan', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'original-secret' },
});
await fx.controller.initialize();
const first = await completeScan(fx, {
client_id: existing.appId, client_secret: 'first-secret',
user_info: { open_id: 'ou_first', tenant_brand: 'feishu' },
});
const oldAttemptId = first.registration.attempt;
const second = await completeScan(fx, {
client_id: existing.appId, client_secret: 'newest-secret',
user_info: { open_id: 'ou_second', tenant_brand: 'feishu' },
});
const cancelled = await fx.controller.cancelRegistration(oldAttemptId);
assert.equal(cancelled.registration.state, 'succeeded');
assert.equal(second.registration.botId, existing.id);
assert.equal(fx.configStore.list().length, 1);
assert.equal(fx.values.get(existing.secretRef), 'newest-secret');
assert.equal(fx.controller.status().bots[0].connected, true);
});
test('credential removal failure is retriable and cannot affect another bot', async () => {
const alpha = bot('bot_alpha', 'alpha');
const beta = bot('bot_beta', 'beta');
const fx = fixture({
bots: [alpha, beta],
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
failUnsetRefs: new Set([alpha.secretRef]),
});
await fx.controller.initialize();
const betaRuntime = fx.runtimes.get(beta.id)[0];
await assert.rejects(fx.controller.deleteBot(alpha.id), /remove the Feishu credential/);
assert.equal(fx.configStore.list().length, 2);
assert.equal(fx.values.get(alpha.secretRef), 'secret-a');
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
assert.equal(betaRuntime.stops, 0);
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === alpha.id).error.code,
'credential_removal_failed');
// Deletion intent is durable. Even though the immutable secret still
// exists, a fresh controller must not resurrect this bot on restart.
const restarted = fixture({
bots: fx.configStore.list(),
secrets: Object.fromEntries(fx.values),
});
await restarted.controller.initialize();
assert.equal(restarted.runtimes.has(alpha.id), false);
assert.equal(restarted.controller.status().bots.find((entry) => entry.botId === alpha.id).error.code,
'deletion_pending');
assert.equal(restarted.controller.status().bots.find((entry) => entry.botId === beta.id).connected, true);
});
test('one bot activation failure does not stop a healthy existing bot', async () => {
const healthy = bot('bot_healthy', 'healthy');
const fx = fixture({
bots: [healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
createBotIds: ['bot_failure'],
runtimeStart: async ({ botId }) => {
if (botId === 'bot_failure') throw new Error('new bot handshake failed');
},
});
await fx.controller.initialize();
const healthyRuntime = fx.runtimes.get(healthy.id)[0];
const result = await completeScan(fx, {
client_id: 'cli_failure', client_secret: 'failure-secret',
user_info: { open_id: 'ou_failure', tenant_brand: 'feishu' },
});
assert.equal(result.registration.state, 'error');
assert.equal(healthyRuntime.stops, 0);
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === healthy.id).connected, true);
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === 'bot_failure').phase, 'error');
});
test('close during credential activation cannot leave a late runtime or bot behind', async () => {
let releaseStart;
const startGate = new Promise((resolve) => { releaseStart = resolve; });
const fx = fixture({
createBotIds: ['bot_closing'],
runtimeStart: async ({ botId }) => {
if (botId === 'bot_closing') await startGate;
},
});
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length === 1);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/closing', expireIn: 60 });
run.resolve({
client_id: 'cli_closing', client_secret: 'closing-secret',
user_info: { open_id: 'ou_closing', tenant_brand: 'feishu' },
});
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'saving');
const closing = fx.controller.close();
releaseStart();
await closing;
assert.equal(fx.configStore.list().length, 0);
assert.equal(fx.values.size, 0);
assert.equal(fx.controller.status().totals.connected, 0);
assert.equal(fx.runtimes.get('bot_closing').at(-1).stops > 0, true);
assert.throws(() => fx.controller.startRegistration(), /closed/);
});
test('a failed repeat-scan restores the previously healthy config, secret and runtime', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
runtimeStart: async ({ runtime }) => {
if (runtime.appSecret === 'bad-rotated-secret') throw new Error('new handshake failed');
},
});
await fx.controller.initialize();
const result = await completeScan(fx, {
client_id: existing.appId,
client_secret: 'bad-rotated-secret',
user_info: { open_id: 'ou_new_owner', tenant_brand: 'feishu' },
});
assert.equal(result.registration.state, 'error');
assert.deepEqual(fx.configStore.list()[0], existing);
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
assert.equal(fx.controller.status().bots[0].connected, true);
assert.equal(fx.runtimes.get(existing.id).length, 3);
assert.equal(fx.runtimes.get(existing.id).at(-1).appSecret, 'stable-secret');
});
test('state cleanup failure keeps the bot visible and retryable with no live credential', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
deleteState: async () => { throw new Error('state file is busy'); },
});
await fx.controller.initialize();
await assert.rejects(fx.controller.deleteBot(existing.id), /session state/);
assert.equal(fx.configStore.list().length, 1);
assert.equal(fx.values.has(existing.secretRef), false);
const visible = fx.controller.status().bots[0];
assert.equal(visible.botId, existing.id);
assert.equal(visible.error.code, 'state_cleanup_failed');
assert.equal(visible.connected, false);
});
test('cancelling after a successful replacement start restores the old runtime exactly once', async () => {
const existing = bot('bot_existing', 'existing');
let releaseReplacement;
const replacementGate = new Promise((resolve) => { releaseReplacement = resolve; });
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
runtimeStart: async ({ runtime }) => {
if (runtime.appSecret === 'replacement-secret') await replacementGate;
},
});
await fx.controller.initialize();
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length === 1);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/replacement', expireIn: 60 });
run.resolve({
client_id: existing.appId,
client_secret: 'replacement-secret',
user_info: { open_id: 'ou_replacement', tenant_brand: 'feishu' },
});
await waitFor(() => fx.runtimes.get(existing.id)?.length === 2);
const cancelling = fx.controller.cancelRegistration(attemptId);
releaseReplacement();
await cancelling;
const history = fx.runtimes.get(existing.id);
assert.equal(history.length, 3);
assert.equal(history[1].appSecret, 'replacement-secret');
assert.equal(history[1].stops, 1);
assert.equal(history[2].appSecret, 'stable-secret');
assert.equal(history[2].starts, 1);
assert.deepEqual(fx.configStore.list()[0], existing);
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
});
test('a cancelled replacement whose start rejects still restores the old runtime', async () => {
const existing = bot('bot_existing', 'existing');
let releaseReplacement;
const replacementGate = new Promise((resolve) => { releaseReplacement = resolve; });
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
runtimeStart: async ({ runtime }) => {
if (runtime.appSecret === 'replacement-secret') {
await replacementGate;
throw new Error('replacement handshake rejected');
}
},
});
await fx.controller.initialize();
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length === 1);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/replacement-reject', expireIn: 60 });
run.resolve({
client_id: existing.appId,
client_secret: 'replacement-secret',
user_info: { open_id: 'ou_replacement', tenant_brand: 'feishu' },
});
await waitFor(() => fx.runtimes.get(existing.id)?.length === 2);
const cancelling = fx.controller.cancelRegistration(attemptId);
releaseReplacement();
await cancelling;
const history = fx.runtimes.get(existing.id);
assert.equal(history.length, 3);
assert.equal(history.at(-1).appSecret, 'stable-secret');
assert.equal(history.at(-1).starts, 1);
assert.deepEqual(fx.configStore.list()[0], existing);
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
assert.equal(fx.controller.status().bots[0].connected, true);
});