mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 18:53:17 +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
240
test/channels/feishu/bridge.test.mjs
Normal file
240
test/channels/feishu/bridge.test.mjs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
|
||||
function event(messageId, text) {
|
||||
return {
|
||||
sender: { sender_type: 'user', sender_id: { open_id: 'ou_user' } },
|
||||
message: {
|
||||
message_id: messageId,
|
||||
message_type: 'text',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
content: JSON.stringify({ text }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
|
||||
const sent = [];
|
||||
const reactions = [];
|
||||
const removedReactions = [];
|
||||
const streamed = [];
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
const asked = [];
|
||||
const client = {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
sent.push(request);
|
||||
return { code: 0 };
|
||||
} } } },
|
||||
};
|
||||
const channel = {
|
||||
addReaction: async (messageId, emojiType) => {
|
||||
reactions.push({ messageId, emojiType });
|
||||
return `reaction-${emojiType}`;
|
||||
},
|
||||
removeReaction: async (messageId, reactionId) => {
|
||||
removedReactions.push({ messageId, reactionId });
|
||||
},
|
||||
stream: async (chatId, input, options) => {
|
||||
const updates = [];
|
||||
await input.markdown({
|
||||
setContent: async (content) => updates.push(content),
|
||||
});
|
||||
streamed.push({ chatId, options, updates });
|
||||
return { messageId: 'om_reply' };
|
||||
},
|
||||
};
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async (sessionId) => sessionId === 'session-test',
|
||||
createSession: async () => 'session-test',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push({ sessionId, text });
|
||||
await options.onUpdate({ type: 'text', text: 'Harness' });
|
||||
return 'Harness reply';
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||||
clearSession: async (key) => sessions.delete(key),
|
||||
};
|
||||
const status = {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel,
|
||||
harness,
|
||||
state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_1', '你好'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(sessions.get('p2p:ou_user'), 'session-test');
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-test', text: '你好' }]);
|
||||
assert.deepEqual(streamed, [{
|
||||
chatId: 'oc_chat',
|
||||
options: { replyTo: 'om_1' },
|
||||
updates: ['Harness', 'Harness reply'],
|
||||
}]);
|
||||
assert.deepEqual(reactions, [
|
||||
{ messageId: 'om_1', emojiType: 'OnIt' },
|
||||
{ messageId: 'om_1', emojiType: 'DONE' },
|
||||
]);
|
||||
assert.deepEqual(removedReactions, [
|
||||
{ messageId: 'om_1', reactionId: 'reaction-OnIt' },
|
||||
]);
|
||||
assert.equal(sent.length, 0);
|
||||
assert.equal(status.messagesReceived, 1);
|
||||
assert.equal(status.messagesReplied, 1);
|
||||
assert.equal(status.streamResponses, 1);
|
||||
|
||||
bridge.accept(event('om_1', '重复消息'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(asked.length, 1);
|
||||
|
||||
bridge.accept({
|
||||
...event('om_2', '越权消息'),
|
||||
sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } },
|
||||
});
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(asked.length, 1);
|
||||
assert.equal(status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('reaction failures do not block streaming replies', async () => {
|
||||
const seen = new Set();
|
||||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {},
|
||||
channel: {
|
||||
addReaction: async () => { throw new Error('reaction unavailable'); },
|
||||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onUpdate({ type: 'tool', name: 'web_search' });
|
||||
return '天气结果';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_reaction_failure', '深圳天气'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(status.messagesReplied, 1);
|
||||
assert.equal(status.reactionErrors, 2);
|
||||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
|
||||
const seen = new Set();
|
||||
const sent = [];
|
||||
const finalReactions = [];
|
||||
let askCount = 0;
|
||||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
sent.push(JSON.parse(request.data.content).text);
|
||||
return { code: 0 };
|
||||
} } } },
|
||||
},
|
||||
channel: {
|
||||
addReaction: async (_messageId, emojiType) => {
|
||||
finalReactions.push(emojiType);
|
||||
return `reaction-${emojiType}`;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization failed');
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
askCount += 1;
|
||||
return '已经生成的最终回答';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_stream_finalize_failure', '不要重复提交'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(sent, ['已经生成的最终回答']);
|
||||
assert.deepEqual(finalReactions, ['OnIt', 'DONE']);
|
||||
assert.equal(status.messagesReplied, 1);
|
||||
assert.equal(status.streamFallbacks, 1);
|
||||
assert.equal(status.streamErrors, 1);
|
||||
});
|
||||
|
||||
test('bridge does not expose internal error details in a Feishu failure reply', async () => {
|
||||
const sent = [];
|
||||
const seen = new Set();
|
||||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
sent.push(JSON.parse(request.data.content).text);
|
||||
return { code: 0 };
|
||||
} } } },
|
||||
},
|
||||
channel: {
|
||||
addReaction: async (_messageId, emojiType) => `reaction-${emojiType}`,
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
throw new Error('secret-shaped-internal-detail /private/path');
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_internal_failure', '触发错误'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /处理失败,请稍后重试/);
|
||||
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
||||
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
||||
});
|
||||
194
test/channels/feishu/client-api.test.mjs
Normal file
194
test/channels/feishu/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
normalizeBotsSnapshot,
|
||||
normalizeConnectionSnapshot,
|
||||
normalizePollResult,
|
||||
normalizeProvisioning,
|
||||
presentError,
|
||||
reconnectBot,
|
||||
retryConnection,
|
||||
screenFromSnapshot,
|
||||
unwrapRpcResult,
|
||||
} from '../../../plugin-src/client/channels/feishu/api.js';
|
||||
|
||||
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
||||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||
assert.equal(FEISHU_ENDPOINTS.testConnection, 'connection.test');
|
||||
});
|
||||
|
||||
test('client normalizes multiple independent bots and derives authoritative totals', () => {
|
||||
const snapshot = normalizeBotsSnapshot({
|
||||
schemaVersion: 2,
|
||||
revision: 9,
|
||||
totals: { configured: 99, connected: 99 },
|
||||
bots: [
|
||||
{
|
||||
botId: 'bot-a',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
bot: {
|
||||
name: '销售助手',
|
||||
appIdMasked: 'cli_aaaa••••1111',
|
||||
domain: 'feishu',
|
||||
clientSecret: 'must-not-leak',
|
||||
},
|
||||
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||
},
|
||||
{
|
||||
botId: 'bot-b',
|
||||
// A stale state label must not make an offline bot appear connected.
|
||||
state: 'connected',
|
||||
connected: false,
|
||||
configured: true,
|
||||
bot: { name: '研发助手', domain: 'lark' },
|
||||
health: { status: 'offline', summary: '等待重连' },
|
||||
error: { code: 'connection_failed', message: '连接失败' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(snapshot.schemaVersion, 2);
|
||||
assert.equal(snapshot.revision, 9);
|
||||
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal(snapshot.bots[1].state, 'connecting');
|
||||
assert.equal(snapshot.bots[1].bot.domain, 'lark');
|
||||
assert.equal(snapshot.bots[1].error.message, '连接失败');
|
||||
assert.equal('clientSecret' in snapshot.bots[0].bot, false);
|
||||
});
|
||||
|
||||
test('multi-bot snapshot rejects entries without an opaque botId', () => {
|
||||
assert.throws(
|
||||
() => normalizeBotsSnapshot({ schemaVersion: 2, bots: [{ connected: true }] }),
|
||||
/botId/,
|
||||
);
|
||||
});
|
||||
|
||||
test('provision polling preserves the newly connected botId', () => {
|
||||
assert.deepEqual(normalizePollResult({
|
||||
status: 'connected',
|
||||
botId: 'bot-new',
|
||||
}), {
|
||||
status: 'connected',
|
||||
botId: 'bot-new',
|
||||
message: undefined,
|
||||
connection: undefined,
|
||||
provisioning: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('reconnect helper scopes the mutation to one bot before refreshing the list', async () => {
|
||||
const calls = [];
|
||||
const status = { schemaVersion: 2, bots: [] };
|
||||
const value = await reconnectBot(async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
return endpoint === 'connection.status' ? status : { accepted: true };
|
||||
}, 'bot-a');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ endpoint: 'bot.reconnect', payload: { botId: 'bot-a' } },
|
||||
{ endpoint: 'connection.status', payload: {} },
|
||||
]);
|
||||
assert.equal(value, status);
|
||||
});
|
||||
|
||||
test('client only shows connected when the Host confirms connected=true', () => {
|
||||
assert.equal(normalizeConnectionSnapshot({
|
||||
state: 'connected',
|
||||
connected: false,
|
||||
}).state, 'connecting');
|
||||
assert.equal(normalizeConnectionSnapshot({
|
||||
state: 'connecting',
|
||||
connected: true,
|
||||
}).state, 'connected');
|
||||
});
|
||||
|
||||
test('client accepts a Host-rendered QR code without exposing credentials', () => {
|
||||
const provisioning = normalizeProvisioning({
|
||||
attemptId: '7',
|
||||
verificationUrl: 'https://accounts.feishu.cn/device',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
expiresAt: 100_000,
|
||||
pollIntervalMs: 1_800,
|
||||
}, 1_000);
|
||||
|
||||
assert.equal(provisioning.attemptId, '7');
|
||||
assert.equal(provisioning.qrCodeDataUrl, 'data:image/png;base64,AAAA');
|
||||
assert.equal('clientSecret' in provisioning, false);
|
||||
});
|
||||
|
||||
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||
connected: true,
|
||||
});
|
||||
assert.throws(
|
||||
() => unwrapRpcResult({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'failed' },
|
||||
}),
|
||||
/failed/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
presentError(new Error('app_secret=do-not-show token=also-hidden')).message,
|
||||
/do-not-show|also-hidden/,
|
||||
);
|
||||
});
|
||||
|
||||
test('configured offline and startup errors never become the create screen', () => {
|
||||
const offline = normalizeConnectionSnapshot({
|
||||
state: 'disconnected',
|
||||
connected: false,
|
||||
configured: true,
|
||||
bot: { name: '北汇星河助手' },
|
||||
health: { status: 'offline', summary: '长连接已断开' },
|
||||
});
|
||||
assert.equal(offline.configured, true);
|
||||
assert.equal(screenFromSnapshot(offline).phase, 'offline');
|
||||
|
||||
const failed = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||
state: 'error',
|
||||
connected: false,
|
||||
configured: true,
|
||||
error: { message: '启动失败' },
|
||||
}));
|
||||
assert.equal(failed.phase, 'error');
|
||||
assert.equal(failed.retry, 'test');
|
||||
assert.equal(failed.configured, true);
|
||||
|
||||
const failedBeforeConfiguration = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||
state: 'error',
|
||||
connected: false,
|
||||
configured: false,
|
||||
error: { message: '二维码已过期' },
|
||||
}));
|
||||
assert.equal(failedBeforeConfiguration.phase, 'error');
|
||||
assert.equal(failedBeforeConfiguration.retry, 'begin');
|
||||
|
||||
const fresh = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||
state: 'disconnected',
|
||||
connected: false,
|
||||
configured: false,
|
||||
}));
|
||||
assert.deepEqual(fresh, { phase: 'disconnected', configured: false });
|
||||
});
|
||||
|
||||
test('retry connection calls connection.test before authoritative connection.status', async () => {
|
||||
const calls = [];
|
||||
const status = { state: 'connected', connected: true, configured: true };
|
||||
const value = await retryConnection(async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
return endpoint === 'connection.status' ? status : { accepted: true };
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ endpoint: 'connection.test', payload: {} },
|
||||
{ endpoint: 'connection.status', payload: {} },
|
||||
]);
|
||||
assert.equal(value, status);
|
||||
});
|
||||
50
test/channels/feishu/config.test.mjs
Normal file
50
test/channels/feishu/config.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../../../src/channels/feishu/config.mjs';
|
||||
|
||||
const KEYS = [
|
||||
'FEISHU_APP_ID',
|
||||
'FEISHU_APP_SECRET',
|
||||
'FEISHU_SECRET_SERVICE',
|
||||
'FEISHU_ALLOWED_OPEN_IDS',
|
||||
'HARNESS_WORKSPACE',
|
||||
'HARNESS_AGENT_PRESET',
|
||||
];
|
||||
|
||||
function withEnvironment(values, callback) {
|
||||
const previous = Object.fromEntries(KEYS.map((key) => [key, process.env[key]]));
|
||||
try {
|
||||
for (const key of KEYS) delete process.env[key];
|
||||
Object.assign(process.env, values);
|
||||
return callback();
|
||||
} finally {
|
||||
for (const key of KEYS) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('loadConfig fails closed when the sender allowlist is empty', () => {
|
||||
withEnvironment({
|
||||
FEISHU_APP_ID: 'cli_test',
|
||||
FEISHU_APP_SECRET: 'test-secret',
|
||||
HARNESS_WORKSPACE: '/tmp/test-workspace',
|
||||
FEISHU_ALLOWED_OPEN_IDS: ' , ',
|
||||
}, () => {
|
||||
assert.throws(() => loadConfig(), /FEISHU_ALLOWED_OPEN_IDS/);
|
||||
});
|
||||
});
|
||||
|
||||
test('loadConfig parses a deduplicated sender allowlist and defaults to standard', () => {
|
||||
withEnvironment({
|
||||
FEISHU_APP_ID: 'cli_test',
|
||||
FEISHU_APP_SECRET: 'test-secret',
|
||||
HARNESS_WORKSPACE: '/tmp/test-workspace',
|
||||
FEISHU_ALLOWED_OPEN_IDS: 'ou_one, ou_two,ou_one',
|
||||
}, () => {
|
||||
const config = loadConfig();
|
||||
assert.equal(config.harnessAgentPreset, 'standard');
|
||||
assert.deepEqual([...config.allowedSenderOpenIds], ['ou_one', 'ou_two']);
|
||||
});
|
||||
});
|
||||
98
test/channels/feishu/connection-supervisor.test.mjs
Normal file
98
test/channels/feishu/connection-supervisor.test.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/feishu/connection-supervisor.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function scheduler() {
|
||||
const pending = [];
|
||||
return {
|
||||
pending,
|
||||
setTimeoutImpl(callback, delay) {
|
||||
const handle = { callback, delay, cancelled: false, unref() {} };
|
||||
pending.push(handle);
|
||||
return handle;
|
||||
},
|
||||
clearTimeoutImpl(handle) {
|
||||
handle.cancelled = true;
|
||||
},
|
||||
async runNext() {
|
||||
const handle = pending.shift();
|
||||
assert.ok(handle, 'expected a scheduled supervisor pass');
|
||||
assert.equal(handle.cancelled, false);
|
||||
handle.callback();
|
||||
await flush();
|
||||
await flush();
|
||||
return handle.delay;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('supervisor waits for the in-process Harness Host before initializing bots', async () => {
|
||||
const timers = scheduler();
|
||||
const warnings = [];
|
||||
let healthChecks = 0;
|
||||
let initializations = 0;
|
||||
const controller = {
|
||||
async initialize() { initializations += 1; },
|
||||
status() { return { totals: { configured: 1, connected: 1 } }; },
|
||||
};
|
||||
const supervisor = new ConnectionSupervisor({
|
||||
controller,
|
||||
harness: {
|
||||
async ensureRunning() {
|
||||
healthChecks += 1;
|
||||
if (healthChecks < 3) throw new Error('Host is still starting');
|
||||
},
|
||||
},
|
||||
logger: { warn: (...args) => warnings.push(args) },
|
||||
retryDelaysMs: [5, 10],
|
||||
healthyIntervalMs: 100,
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
}).start();
|
||||
|
||||
assert.equal(await timers.runNext(), 0);
|
||||
assert.equal(initializations, 0);
|
||||
assert.equal(timers.pending[0].delay, 5);
|
||||
await timers.runNext();
|
||||
assert.equal(initializations, 0);
|
||||
assert.equal(timers.pending[0].delay, 10);
|
||||
await timers.runNext();
|
||||
assert.equal(await supervisor.ready.then((status) => status.totals.connected), 1);
|
||||
assert.equal(initializations, 1);
|
||||
assert.equal(timers.pending[0].delay, 100);
|
||||
assert.equal(warnings.length, 2);
|
||||
|
||||
await supervisor.close();
|
||||
assert.equal(timers.pending[0].cancelled, true);
|
||||
});
|
||||
|
||||
test('supervisor retries an offline bot and leaves the recovered connection on the health interval', async () => {
|
||||
const timers = scheduler();
|
||||
let initializations = 0;
|
||||
const controller = {
|
||||
async initialize() { initializations += 1; },
|
||||
status() {
|
||||
return { totals: { configured: 1, connected: initializations >= 2 ? 1 : 0 } };
|
||||
},
|
||||
};
|
||||
const supervisor = new ConnectionSupervisor({
|
||||
controller,
|
||||
harness: { async ensureRunning() {} },
|
||||
logger: { warn() {} },
|
||||
retryDelaysMs: [7],
|
||||
healthyIntervalMs: 101,
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
}).start();
|
||||
|
||||
await timers.runNext();
|
||||
assert.equal(initializations, 1);
|
||||
assert.equal(timers.pending[0].delay, 7);
|
||||
await timers.runNext();
|
||||
assert.equal(initializations, 2);
|
||||
assert.equal(timers.pending[0].delay, 101);
|
||||
|
||||
await supervisor.close();
|
||||
});
|
||||
48
test/channels/feishu/feishu-app.test.mjs
Normal file
48
test/channels/feishu/feishu-app.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
||||
|
||||
function response(body, ok = true, status = 200) {
|
||||
return { ok, status, async json() { return body; } };
|
||||
}
|
||||
|
||||
test('verifyFeishuApp validates credentials and returns a safe bot identity', async () => {
|
||||
const requests = [];
|
||||
const result = await verifyFeishuApp({
|
||||
appId: 'cli_test',
|
||||
appSecret: 'never-return-this',
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (requests.length === 1) {
|
||||
return response({ code: 0, tenant_access_token: 'tenant-token' });
|
||||
}
|
||||
return response({
|
||||
code: 0,
|
||||
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
appId: 'cli_test',
|
||||
name: '北汇星河助手',
|
||||
openId: 'ou_bot',
|
||||
activated: 1,
|
||||
});
|
||||
assert.equal('appSecret' in result, false);
|
||||
assert.match(requests[0].options.body, /never-return-this/);
|
||||
assert.equal(requests[1].options.headers.authorization, 'Bearer tenant-token');
|
||||
});
|
||||
|
||||
test('verifyFeishuApp rejects invalid credentials before reading bot info', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(verifyFeishuApp({
|
||||
appId: 'cli_bad',
|
||||
appSecret: 'bad',
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return response({ code: 10003, msg: 'invalid app secret' });
|
||||
},
|
||||
}), /invalid app secret/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
131
test/channels/feishu/feishu-channel.test.mjs
Normal file
131
test/channels/feishu/feishu-channel.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-channel.mjs';
|
||||
|
||||
function fakeClient(overrides = {}) {
|
||||
const calls = {
|
||||
replies: [],
|
||||
updates: [],
|
||||
settings: [],
|
||||
recalls: [],
|
||||
reactionsAdded: [],
|
||||
reactionsRemoved: [],
|
||||
};
|
||||
const client = {
|
||||
cardkit: { v1: {
|
||||
card: {
|
||||
create: async () => ({ code: 0, data: { card_id: 'card-test' } }),
|
||||
settings: async (request) => {
|
||||
calls.settings.push(request);
|
||||
return { code: 0 };
|
||||
},
|
||||
},
|
||||
cardElement: {
|
||||
content: async (request) => {
|
||||
calls.updates.push(request);
|
||||
return { code: 0 };
|
||||
},
|
||||
},
|
||||
} },
|
||||
im: { v1: {
|
||||
message: {
|
||||
reply: async (request) => {
|
||||
calls.replies.push(request);
|
||||
return { code: 0, data: { message_id: 'om-stream' } };
|
||||
},
|
||||
create: async () => ({ code: 0, data: { message_id: 'om-stream' } }),
|
||||
delete: async (request) => {
|
||||
calls.recalls.push(request);
|
||||
return { code: 0 };
|
||||
},
|
||||
},
|
||||
messageReaction: {
|
||||
create: async (request) => {
|
||||
calls.reactionsAdded.push(request);
|
||||
return { code: 0, data: { reaction_id: 'reaction-test' } };
|
||||
},
|
||||
delete: async (request) => {
|
||||
calls.reactionsRemoved.push(request);
|
||||
return { code: 0 };
|
||||
},
|
||||
},
|
||||
} },
|
||||
};
|
||||
|
||||
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
test('VerifiedFeishuChannel streams content and verifies terminal settings', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
|
||||
|
||||
const result = await channel.stream('oc_chat', {
|
||||
markdown: async (controller) => {
|
||||
await controller.setContent('第一段');
|
||||
await controller.setContent('第一段和第二段');
|
||||
},
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.deepEqual(result, { messageId: 'om-stream' });
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.deepEqual(calls.updates.map((call) => ({
|
||||
content: call.data.content,
|
||||
sequence: call.data.sequence,
|
||||
})), [
|
||||
{ content: '第一段', sequence: 1 },
|
||||
{ content: '第一段和第二段', sequence: 2 },
|
||||
]);
|
||||
assert.equal(calls.settings[0].data.sequence, 3);
|
||||
assert.deepEqual(JSON.parse(calls.settings[0].data.settings), {
|
||||
config: {
|
||||
streaming_mode: false,
|
||||
summary: { content: '第一段和第二段' },
|
||||
},
|
||||
});
|
||||
assert.equal(calls.recalls.length, 0);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel rejects failed updates and recalls the partial card', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
updateContent: async () => ({ code: 230099, msg: 'element update failed' }),
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.stream('oc_chat', {
|
||||
markdown: async (controller) => controller.setContent('最终回答'),
|
||||
}, { replyTo: 'om_user' }), /cardElement\.content failed/);
|
||||
|
||||
assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]);
|
||||
assert.equal(calls.settings.length, 0);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel rejects failed finalization and recalls the card', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
finishCard: async (request) => {
|
||||
calls.settings.push(request);
|
||||
return { code: 230099, msg: 'card finalization failed' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.stream('oc_chat', {
|
||||
markdown: async (controller) => controller.setContent('已经生成的回答'),
|
||||
}, { replyTo: 'om_user' }), /card\.settings failed/);
|
||||
|
||||
assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]);
|
||||
assert.equal(calls.settings.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel checks reaction API results', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const reactionId = await channel.addReaction('om_user', 'OnIt');
|
||||
await channel.removeReaction('om_user', reactionId);
|
||||
|
||||
assert.equal(reactionId, 'reaction-test');
|
||||
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
|
||||
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
|
||||
});
|
||||
120
test/channels/feishu/feishu-runtime.test.mjs
Normal file
120
test/channels/feishu/feishu-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
|
||||
|
||||
class FakeClient {}
|
||||
|
||||
class FakeDispatcher {
|
||||
register(handlers) {
|
||||
this.handlers = handlers;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWSClient {
|
||||
static instances = [];
|
||||
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.state = 'idle';
|
||||
FakeWSClient.instances.push(this);
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.state = 'connecting';
|
||||
}
|
||||
|
||||
becomeReady() {
|
||||
this.state = 'connected';
|
||||
this.options.onReady();
|
||||
}
|
||||
|
||||
getConnectionStatus() {
|
||||
return { state: this.state };
|
||||
}
|
||||
|
||||
close() {
|
||||
this.state = 'closed';
|
||||
}
|
||||
}
|
||||
|
||||
function fakeLark() {
|
||||
FakeWSClient.instances.length = 0;
|
||||
return {
|
||||
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
|
||||
LoggerLevel: { info: 'info' },
|
||||
Client: FakeClient,
|
||||
EventDispatcher: FakeDispatcher,
|
||||
WSClient: FakeWSClient,
|
||||
};
|
||||
}
|
||||
|
||||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||
let harnessChecks = 0;
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_test',
|
||||
appSecret: 'secret',
|
||||
ownerOpenId: 'ou_owner',
|
||||
harness: {
|
||||
async ensureRunning() { harnessChecks += 1; },
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
});
|
||||
|
||||
assert.equal(runtime.status.ready, false);
|
||||
let settled = false;
|
||||
const starting = runtime.start().then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
const status = await starting;
|
||||
assert.equal(harnessChecks, 1);
|
||||
assert.equal(status.ready, true);
|
||||
assert.equal(status.feishuLongConnectionState, 'connected');
|
||||
assert.equal(status.harnessReachable, true);
|
||||
|
||||
const stopped = await runtime.stop();
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_test',
|
||||
appSecret: 'secret',
|
||||
ownerOpenId: 'ou_owner',
|
||||
harness: { async ensureRunning() {} },
|
||||
state: { hasSeen: () => false },
|
||||
connectTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.start(), /handshake timed out/);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when Harness is unavailable', async () => {
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_test',
|
||||
appSecret: 'secret',
|
||||
ownerOpenId: 'ou_owner',
|
||||
harness: {
|
||||
async ensureRunning() { throw new Error('Harness unavailable'); },
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.start(), /Harness unavailable/);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
assert.equal(runtime.status.lastError, 'Harness unavailable');
|
||||
});
|
||||
132
test/channels/feishu/harness-client.test.mjs
Normal file
132
test/channels/feishu/harness-client.test.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessReplyTracker,
|
||||
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||
|
||||
test('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
|
||||
const methods = [];
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
const request = JSON.parse(options.body);
|
||||
methods.push(request.method);
|
||||
const value = request.method === 'workspace.list'
|
||||
? { items: [], archivedSessionIds: [] }
|
||||
: {
|
||||
workspace: {
|
||||
workspaceId: 'workspace-new',
|
||||
path: '/tmp/dsh-feishu-workspace',
|
||||
},
|
||||
created: true,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
type: 'server-response',
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value },
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
agentPreset: 'standard',
|
||||
autostart: false,
|
||||
dshBin: 'dsh',
|
||||
});
|
||||
|
||||
assert.equal(await client.workspaceId(), 'workspace-new');
|
||||
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
|
||||
});
|
||||
|
||||
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
|
||||
|
||||
assert.equal(tracker.consume([
|
||||
{ event: { type: 'turn/start', seq: 11, data: { turn: 4 } } },
|
||||
{ event: {
|
||||
type: 'user/message',
|
||||
seq: 12,
|
||||
data: { source: { rpcId: 'someone-else' } },
|
||||
} },
|
||||
{ event: {
|
||||
type: 'assistant/chunk',
|
||||
seq: 13,
|
||||
data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: '忽略' } },
|
||||
} },
|
||||
{ event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } },
|
||||
]), null);
|
||||
assert.equal(tracker.finished, false);
|
||||
|
||||
const first = tracker.consume([
|
||||
{ event: { type: 'turn/start', seq: 15, data: { turn: 5 } } },
|
||||
{ event: {
|
||||
type: 'user/message',
|
||||
seq: 16,
|
||||
data: { source: { rpcId: 'prompt-1' } },
|
||||
} },
|
||||
{ event: {
|
||||
type: 'assistant/chunk',
|
||||
seq: 17,
|
||||
data: { turn: 5, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: '不能泄露' } },
|
||||
} },
|
||||
{ event: {
|
||||
type: 'assistant/chunk',
|
||||
seq: 18,
|
||||
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '深圳' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(first, { type: 'text', text: '深圳' });
|
||||
|
||||
const second = tracker.consume([
|
||||
{ event: {
|
||||
type: 'assistant/chunk',
|
||||
seq: 18,
|
||||
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '重复' } },
|
||||
} },
|
||||
{ event: {
|
||||
type: 'assistant/chunk',
|
||||
seq: 19,
|
||||
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '明天有雨' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(second, { type: 'text', text: '深圳明天有雨' });
|
||||
|
||||
const final = tracker.consume([
|
||||
{ event: {
|
||||
type: 'assistant/message',
|
||||
seq: 20,
|
||||
data: {
|
||||
turn: 5,
|
||||
step: 1,
|
||||
message: { content: [
|
||||
{ type: 'reasoning', text: '仍然不能泄露' },
|
||||
{ type: 'text', text: '深圳明天有阵雨。' },
|
||||
] },
|
||||
},
|
||||
} },
|
||||
{ event: { type: 'turn/end', seq: 21, data: { turn: 5, reason: { kind: 'completed' } } } },
|
||||
]);
|
||||
assert.deepEqual(final, { type: 'text', text: '深圳明天有阵雨。' });
|
||||
assert.equal(tracker.finished, true);
|
||||
assert.equal(tracker.answer, '深圳明天有阵雨。');
|
||||
assert.deepEqual(tracker.reason, { kind: 'completed' });
|
||||
});
|
||||
|
||||
test('HarnessReplyTracker emits tool progress without exposing tool results', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-tool' });
|
||||
const update = tracker.consume([
|
||||
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-tool' } } },
|
||||
{ type: 'tool/call', seq: 3, data: { turn: 1, step: 1, name: 'web_search' } },
|
||||
]);
|
||||
assert.deepEqual(update, { type: 'tool', name: 'web_search' });
|
||||
|
||||
assert.deepEqual(tracker.consume([
|
||||
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
|
||||
]), { type: 'status', text: '正在整理结果…' });
|
||||
});
|
||||
50
test/channels/feishu/message-utils.test.mjs
Normal file
50
test/channels/feishu/message-utils.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
conversationKey,
|
||||
extractText,
|
||||
isAllowedSender,
|
||||
isBotSender,
|
||||
splitText,
|
||||
} from '../../../src/channels/feishu/message-utils.mjs';
|
||||
|
||||
test('extractText removes bot mentions', () => {
|
||||
const event = {
|
||||
message: {
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '@_user_1 你好' }),
|
||||
mentions: [{ key: '@_user_1' }],
|
||||
},
|
||||
};
|
||||
assert.equal(extractText(event), '你好');
|
||||
});
|
||||
|
||||
test('conversationKey isolates p2p users and groups', () => {
|
||||
assert.equal(conversationKey({
|
||||
sender: { sender_id: { open_id: 'ou_test' } },
|
||||
message: { chat_type: 'p2p', chat_id: 'oc_private' },
|
||||
}), 'p2p:ou_test');
|
||||
assert.equal(conversationKey({
|
||||
sender: { sender_id: { open_id: 'ou_test' } },
|
||||
message: { chat_type: 'group', chat_id: 'oc_group' },
|
||||
}), 'group:oc_group');
|
||||
});
|
||||
|
||||
test('splitText preserves all text', () => {
|
||||
const input = `${'a'.repeat(12)}\n${'b'.repeat(12)}`;
|
||||
const chunks = splitText(input, 15);
|
||||
assert.equal(chunks.join('\n'), input);
|
||||
assert.ok(chunks.every((chunk) => chunk.length <= 15));
|
||||
});
|
||||
|
||||
test('isBotSender rejects bot loops', () => {
|
||||
assert.equal(isBotSender({ sender: { sender_type: 'bot' } }), true);
|
||||
assert.equal(isBotSender({ sender: { sender_type: 'user' } }), false);
|
||||
});
|
||||
|
||||
test('isAllowedSender enforces an open-id allowlist', () => {
|
||||
const event = { sender: { sender_id: { open_id: 'ou_allowed' } } };
|
||||
assert.equal(isAllowedSender(event, new Set()), false);
|
||||
assert.equal(isAllowedSender(event, new Set(['ou_allowed'])), true);
|
||||
assert.equal(isAllowedSender(event, new Set(['ou_other'])), false);
|
||||
});
|
||||
485
test/channels/feishu/multi-bot-controller.test.mjs
Normal file
485
test/channels/feishu/multi-bot-controller.test.mjs
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
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('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);
|
||||
});
|
||||
101
test/channels/feishu/plugin-config-store.test.mjs
Normal file
101
test/channels/feishu/plugin-config-store.test.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { PluginConfigStore } from '../../../src/channels/feishu/plugin-config-store.mjs';
|
||||
|
||||
test('PluginConfigStore persists non-secret onboarding facts', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-'));
|
||||
const path = join(dir, 'nested', 'config.json');
|
||||
const store = await new PluginConfigStore(path).load();
|
||||
assert.equal(store.get(), null);
|
||||
|
||||
await store.save({
|
||||
appId: 'cli_test',
|
||||
ownerOpenId: 'ou_owner',
|
||||
domain: 'feishu',
|
||||
botName: '北汇星河助手',
|
||||
appSecret: 'must-not-be-written',
|
||||
});
|
||||
|
||||
const raw = await readFile(path, 'utf8');
|
||||
assert.doesNotMatch(raw, /must-not-be-written/);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().appId, 'cli_test');
|
||||
|
||||
await store.clear();
|
||||
assert.equal(store.get(), null);
|
||||
});
|
||||
|
||||
test('PluginConfigStore stays unconfigured after a failed write and can retry', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-retry-'));
|
||||
const blockedParent = join(dir, 'blocked');
|
||||
const path = join(blockedParent, 'config.json');
|
||||
const store = await new PluginConfigStore(path).load();
|
||||
await writeFile(blockedParent, 'not a directory');
|
||||
const value = { appId: 'cli_retry', ownerOpenId: 'ou_retry', domain: 'feishu' };
|
||||
|
||||
await assert.rejects(store.save(value));
|
||||
assert.equal(store.get(), null);
|
||||
|
||||
await unlink(blockedParent);
|
||||
await mkdir(blockedParent);
|
||||
await store.save(value);
|
||||
assert.equal(store.get().appId, 'cli_retry');
|
||||
});
|
||||
|
||||
test('PluginConfigStore migrates a v1 bot atomically without moving its credential', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-migrate-'));
|
||||
const path = join(dir, 'config.json');
|
||||
await writeFile(path, JSON.stringify({
|
||||
version: 1,
|
||||
appId: 'cli_legacy',
|
||||
ownerOpenId: 'ou_legacy',
|
||||
domain: 'feishu',
|
||||
botName: '旧机器人',
|
||||
}));
|
||||
|
||||
const store = await new PluginConfigStore(path).load();
|
||||
const migrated = JSON.parse(await readFile(path, 'utf8'));
|
||||
|
||||
assert.equal(migrated.version, 2);
|
||||
assert.equal(migrated.bots.length, 1);
|
||||
assert.match(migrated.bots[0].id, /^bot_[a-f0-9]{24}$/);
|
||||
assert.equal(migrated.bots[0].secretRef, 'DSH_FEISHU_APP_SECRET');
|
||||
assert.deepEqual(migrated.bots[0].ownerOpenIds, ['ou_legacy']);
|
||||
assert.equal(store.get().ownerOpenId, 'ou_legacy');
|
||||
assert.equal(store.list()[0].appId, 'cli_legacy');
|
||||
});
|
||||
|
||||
test('PluginConfigStore rejects an invalid or duplicate v2 document without dropping entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-invalid-v2-'));
|
||||
const invalidPath = join(dir, 'invalid.json');
|
||||
await writeFile(invalidPath, JSON.stringify({
|
||||
version: 2,
|
||||
bots: [
|
||||
{
|
||||
id: 'bot_good', appId: 'cli_good', secretRef: 'DSH_FEISHU_APP_SECRET_GOOD',
|
||||
ownerOpenIds: ['ou_good'], domain: 'feishu',
|
||||
},
|
||||
{ id: '../bad', appId: 'cli_bad', secretRef: 'not-a-credential-ref', ownerOpenIds: ['ou_bad'] },
|
||||
],
|
||||
}));
|
||||
await assert.rejects(new PluginConfigStore(invalidPath).load(), /invalid bot entry/);
|
||||
|
||||
const duplicatePath = join(dir, 'duplicate.json');
|
||||
await writeFile(duplicatePath, JSON.stringify({
|
||||
version: 2,
|
||||
bots: [
|
||||
{
|
||||
id: 'bot_one', appId: 'cli_same', secretRef: 'DSH_FEISHU_APP_SECRET_ONE',
|
||||
ownerOpenIds: ['ou_one'], domain: 'feishu',
|
||||
},
|
||||
{
|
||||
id: 'bot_two', appId: 'cli_same', secretRef: 'DSH_FEISHU_APP_SECRET_TWO',
|
||||
ownerOpenIds: ['ou_two'], domain: 'feishu',
|
||||
},
|
||||
],
|
||||
}));
|
||||
await assert.rejects(new PluginConfigStore(duplicatePath).load(), /duplicate bot identities/);
|
||||
});
|
||||
139
test/channels/feishu/plugin-controller.test.mjs
Normal file
139
test/channels/feishu/plugin-controller.test.mjs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DshFeishuController,
|
||||
FEISHU_SECRET_REF,
|
||||
} from '../../../src/channels/feishu/plugin-controller.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function fixture({ initialConfig = null, failConfigSave = false } = {}) {
|
||||
let sdkOptions;
|
||||
let config = initialConfig;
|
||||
let storedSecret = null;
|
||||
const runtimes = [];
|
||||
const controller = new DshFeishuController({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return new Promise((resolve) => { fixture.resolveRegistration = resolve; });
|
||||
},
|
||||
verifyApp: async () => ({ name: '北汇星河助手', openId: 'ou_bot', activated: 1 }),
|
||||
credentials: {
|
||||
async resolve(ref) {
|
||||
assert.equal(ref, FEISHU_SECRET_REF);
|
||||
return storedSecret ? { value: storedSecret, source: 'file' } : undefined;
|
||||
},
|
||||
async set(ref, value) {
|
||||
assert.equal(ref, FEISHU_SECRET_REF);
|
||||
storedSecret = value;
|
||||
},
|
||||
async unset(ref) {
|
||||
assert.equal(ref, FEISHU_SECRET_REF);
|
||||
storedSecret = null;
|
||||
},
|
||||
},
|
||||
configStore: {
|
||||
get: () => config ? structuredClone(config) : null,
|
||||
async save(next) {
|
||||
if (failConfigSave) throw new Error('config write failed');
|
||||
config = structuredClone(next);
|
||||
return structuredClone(config);
|
||||
},
|
||||
async clear() { config = null; },
|
||||
},
|
||||
createRuntime: async () => {
|
||||
const status = {
|
||||
ready: false,
|
||||
feishuLongConnectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
};
|
||||
const runtime = {
|
||||
get status() { return structuredClone(status); },
|
||||
async start() {
|
||||
status.ready = true;
|
||||
status.feishuLongConnectionState = 'connected';
|
||||
status.harnessReachable = true;
|
||||
},
|
||||
async stop() {
|
||||
status.ready = false;
|
||||
status.feishuLongConnectionState = 'idle';
|
||||
},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
return {
|
||||
controller,
|
||||
getSdkOptions: () => sdkOptions,
|
||||
getConfig: () => config,
|
||||
getSecret: () => storedSecret,
|
||||
runtimes,
|
||||
};
|
||||
}
|
||||
|
||||
test('QR success stores the secret off-config and becomes immediately chat-ready', async () => {
|
||||
const fx = fixture();
|
||||
const start = fx.controller.startRegistration();
|
||||
assert.equal(start.phase, 'registering');
|
||||
await flush();
|
||||
assert.equal(fx.getSdkOptions().createOnly, true);
|
||||
assert.equal(fx.getSdkOptions().addons.preset, false);
|
||||
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1']);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||
fixture.resolveRegistration({
|
||||
client_id: 'cli_created',
|
||||
client_secret: 'new-secret',
|
||||
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const status = fx.controller.status();
|
||||
assert.equal(status.phase, 'connected');
|
||||
assert.equal(status.connected, true);
|
||||
assert.equal(status.bot.name, '北汇星河助手');
|
||||
assert.equal(fx.getSecret(), 'new-secret');
|
||||
assert.equal(fx.getConfig().appSecret, undefined);
|
||||
assert.doesNotMatch(JSON.stringify(status), /new-secret|client_secret/);
|
||||
});
|
||||
|
||||
test('disconnect removes configuration and stops automatic reuse', async () => {
|
||||
const fx = fixture();
|
||||
fx.controller.startRegistration();
|
||||
await flush();
|
||||
fixture.resolveRegistration({
|
||||
client_id: 'cli_created',
|
||||
client_secret: 'new-secret',
|
||||
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
await flush();
|
||||
await fx.controller.disconnect();
|
||||
|
||||
assert.equal(fx.controller.status().phase, 'unconfigured');
|
||||
assert.equal(fx.getConfig(), null);
|
||||
assert.equal(fx.getSecret(), null);
|
||||
});
|
||||
|
||||
test('credential is removed when non-secret configuration cannot be persisted', async () => {
|
||||
const fx = fixture({ failConfigSave: true });
|
||||
fx.controller.startRegistration();
|
||||
await flush();
|
||||
fixture.resolveRegistration({
|
||||
client_id: 'cli_created',
|
||||
client_secret: 'new-secret',
|
||||
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
assert.equal(fx.getSecret(), null);
|
||||
assert.equal(fx.getConfig(), null);
|
||||
assert.equal(fx.controller.status().phase, 'error');
|
||||
});
|
||||
716
test/channels/feishu/plugin-host.test.mjs
Normal file
716
test/channels/feishu/plugin-host.test.mjs
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
FEISHU_APP_ID_REF,
|
||||
FEISHU_APP_SECRET_REF,
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_MULTI_ENDPOINTS,
|
||||
apply,
|
||||
createDshCredentialStore,
|
||||
createProductionController,
|
||||
createProvisioningBackedController,
|
||||
} from '../../../plugin-src/host/channels/feishu/index.mjs';
|
||||
|
||||
const signal = () => new AbortController().signal;
|
||||
|
||||
function status(overrides = {}) {
|
||||
return {
|
||||
phase: 'unconfigured',
|
||||
connected: false,
|
||||
configured: false,
|
||||
bot: null,
|
||||
registration: { state: 'idle', attempt: 0, updatedAt: 100 },
|
||||
connection: {
|
||||
ready: false,
|
||||
feishuLongConnectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
},
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function rpcFixture(controller) {
|
||||
let registration;
|
||||
let disposed = false;
|
||||
const ctx = {
|
||||
connection: {
|
||||
rpc: {
|
||||
handle(channel, handler, options) {
|
||||
registration = { channel, handler, options };
|
||||
return async () => { disposed = true; };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const dispose = await apply(ctx, { controller });
|
||||
return {
|
||||
dispose,
|
||||
get registration() { return registration; },
|
||||
get disposed() { return disposed; },
|
||||
};
|
||||
}
|
||||
|
||||
test('Host plugin registers the real rc.6 Connection RPC shape as loopback-only', async () => {
|
||||
const controller = {
|
||||
status: async () => status(),
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => status(),
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
assert.equal(fx.registration.channel, '/feishu');
|
||||
assert.deepEqual(fx.registration.options, { authority: 'loopback' });
|
||||
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.state, 'disconnected');
|
||||
assert.equal(result.value.connected, false);
|
||||
assert.equal(result.value.configured, false);
|
||||
assert.equal(result.value.bot.name, '飞书机器人');
|
||||
assert.equal(result.value.health.status, 'offline');
|
||||
assert.equal('registration' in result.value, false);
|
||||
|
||||
await fx.dispose();
|
||||
assert.equal(fx.disposed, true);
|
||||
});
|
||||
|
||||
test('Host exposes configured offline as a redacted capability fact', async () => {
|
||||
const secret = 'offline-secret-must-stay-host-only';
|
||||
const controller = {
|
||||
status: async () => status({
|
||||
phase: 'disconnected',
|
||||
configured: true,
|
||||
bot: { name: '北汇星河助手', appSecret: secret },
|
||||
credentials: { client_secret: secret },
|
||||
connection: {
|
||||
ready: false,
|
||||
feishuLongConnectionState: 'failed',
|
||||
harnessReachable: true,
|
||||
token: secret,
|
||||
},
|
||||
}),
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => status(),
|
||||
reconnect: async () => status({ configured: true }),
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.state, 'disconnected');
|
||||
assert.equal(result.value.connected, false);
|
||||
assert.equal(result.value.configured, true);
|
||||
assert.equal(result.value.health.status, 'offline');
|
||||
assert.doesNotMatch(JSON.stringify(result), new RegExp(secret));
|
||||
assert.equal('credentials' in result.value, false);
|
||||
});
|
||||
|
||||
test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
||||
const calls = [];
|
||||
let current = status({
|
||||
phase: 'registering',
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 7,
|
||||
updatedAt: 100,
|
||||
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
const controller = {
|
||||
status: async () => current,
|
||||
startRegistration: async () => { calls.push('begin'); return current; },
|
||||
cancelRegistration: async () => {
|
||||
calls.push('cancel');
|
||||
current = status({ registration: { state: 'cancelled', attempt: 7, updatedAt: 200 } });
|
||||
return current;
|
||||
},
|
||||
reconnect: async () => { calls.push('test'); return current; },
|
||||
disconnect: async () => { calls.push('disconnect'); return status(); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const begun = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
{ locale: 'zh-CN' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(begun.ok, true);
|
||||
assert.equal(begun.value.attemptId, '7');
|
||||
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||
|
||||
const polled = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.pollProvisioning,
|
||||
{ attemptId: '7' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(polled.ok, true);
|
||||
assert.equal(polled.value.status, 'pending');
|
||||
|
||||
const tested = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
|
||||
assert.equal(tested.ok, true);
|
||||
|
||||
const cancelled = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||
{ attemptId: '7' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(cancelled.ok, true);
|
||||
assert.equal(cancelled.value.status, 'failed');
|
||||
|
||||
const disconnected = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.disconnect,
|
||||
{ removeCredentials: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(disconnected.ok, true);
|
||||
assert.deepEqual(calls, ['begin', 'test', 'cancel', 'disconnect']);
|
||||
|
||||
const attemptedSecret = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
{ appSecret: 'must-not-cross-browser-boundary' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(attemptedSecret.ok, false);
|
||||
assert.equal(attemptedSecret.error.code, 'bad-request');
|
||||
assert.doesNotMatch(JSON.stringify(attemptedSecret), /must-not-cross-browser-boundary/);
|
||||
});
|
||||
|
||||
test('connection.test does not restart an already healthy long connection', async () => {
|
||||
let reconnects = 0;
|
||||
const healthy = status({
|
||||
phase: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
connection: {
|
||||
ready: true,
|
||||
feishuLongConnectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
},
|
||||
});
|
||||
const controller = {
|
||||
status: async () => healthy,
|
||||
startRegistration: async () => healthy,
|
||||
cancelRegistration: async () => healthy,
|
||||
reconnect: async () => { reconnects += 1; return healthy; },
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const result = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.connected, true);
|
||||
assert.equal(reconnects, 0);
|
||||
});
|
||||
|
||||
test('provision.begin waits through starting until onQRCodeReady is observable', async () => {
|
||||
let current = status({
|
||||
phase: 'registering',
|
||||
registration: { state: 'starting', attempt: 3, updatedAt: 100 },
|
||||
});
|
||||
const controller = {
|
||||
status: async () => current,
|
||||
startRegistration: async () => {
|
||||
setTimeout(() => {
|
||||
current = status({
|
||||
phase: 'registering',
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 3,
|
||||
updatedAt: 110,
|
||||
qrCodeUrl: 'https://accounts.feishu.cn/async-qr',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
}, 5);
|
||||
return current;
|
||||
},
|
||||
cancelRegistration: async () => status(),
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const begun = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
{ locale: 'zh-CN' },
|
||||
signal(),
|
||||
);
|
||||
|
||||
assert.equal(begun.ok, true);
|
||||
assert.equal(begun.value.attemptId, '3');
|
||||
assert.equal(begun.value.verificationUrl, 'https://accounts.feishu.cn/async-qr');
|
||||
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
test('cancelling during credential activation tears down the eventual runtime', async () => {
|
||||
const calls = [];
|
||||
const current = status({
|
||||
phase: 'connecting',
|
||||
configured: true,
|
||||
registration: { state: 'saving', attempt: 11, updatedAt: 100 },
|
||||
});
|
||||
const controller = {
|
||||
status: async () => current,
|
||||
startRegistration: async () => current,
|
||||
cancelRegistration: async () => { calls.push('cancel-only'); return current; },
|
||||
disconnect: async () => { calls.push('disconnect'); return status(); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||
{ attemptId: '11' },
|
||||
signal(),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ['disconnect']);
|
||||
});
|
||||
|
||||
test('status returns qrCodeDataUrl while dropping all credential-shaped fields', async () => {
|
||||
const secret = 'sdk-super-secret-value';
|
||||
const controller = {
|
||||
status: async () => status({
|
||||
phase: 'registering',
|
||||
appSecret: secret,
|
||||
credentials: { client_secret: secret },
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 1,
|
||||
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
client_secret: secret,
|
||||
},
|
||||
connection: {
|
||||
ready: true,
|
||||
connected: false,
|
||||
state: 'connecting',
|
||||
token: secret,
|
||||
},
|
||||
}),
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => status(),
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.state, 'provisioning');
|
||||
assert.equal(result.value.provisioning.verificationUrl, 'https://accounts.feishu.cn/device');
|
||||
assert.match(result.value.provisioning.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||
assert.doesNotMatch(JSON.stringify(result), /sdk-super-secret-value|client_secret|credentials|token/);
|
||||
});
|
||||
|
||||
test('polling a new QR stays pending when another bot is already connected', async () => {
|
||||
const current = status({
|
||||
schemaVersion: 2,
|
||||
phase: 'registering',
|
||||
connected: false,
|
||||
configured: true,
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 'reg_new_bot',
|
||||
qrCodeUrl: 'https://accounts.feishu.cn/new-bot',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
bots: [{
|
||||
botId: 'bot_existing',
|
||||
phase: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
bot: { name: '现有机器人', appIdMasked: 'cli_old••••1234' },
|
||||
connection: {
|
||||
ready: true,
|
||||
feishuLongConnectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
},
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => current,
|
||||
registrationStatus: async () => current,
|
||||
startRegistration: async () => current,
|
||||
cancelRegistration: async () => current,
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.pollProvisioning,
|
||||
{ attemptId: 'reg_new_bot' },
|
||||
signal(),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.status, 'pending');
|
||||
assert.equal('botId' in result.value, false);
|
||||
assert.equal('connection' in result.value, false);
|
||||
});
|
||||
|
||||
test('multi-bot RPC operations require a botId and never expose host-only fields', async () => {
|
||||
const calls = [];
|
||||
const multi = status({
|
||||
schemaVersion: 2,
|
||||
revision: 9,
|
||||
configured: true,
|
||||
bots: [{
|
||||
botId: 'bot_safe',
|
||||
phase: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
secretRef: 'DSH_FEISHU_APP_SECRET_PRIVATE',
|
||||
ownerOpenIds: ['ou_private'],
|
||||
bot: {
|
||||
name: '安全机器人',
|
||||
appId: 'cli_raw_private',
|
||||
appIdMasked: 'cli_safe••••1234',
|
||||
domain: 'feishu',
|
||||
},
|
||||
connection: {
|
||||
ready: true,
|
||||
feishuLongConnectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
token: 'private-token',
|
||||
},
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => multi,
|
||||
startRegistration: async () => multi,
|
||||
cancelRegistration: async () => multi,
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async (botId) => { calls.push(['reconnect', botId]); return multi; },
|
||||
disconnectBot: async (botId) => { calls.push(['disconnect', botId]); return multi; },
|
||||
deleteBot: async (botId) => { calls.push(['delete', botId]); return status({ schemaVersion: 2, bots: [] }); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
for (const [endpoint, payload] of [
|
||||
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe' }],
|
||||
[FEISHU_MULTI_ENDPOINTS.disconnectBot, { botId: 'bot_safe' }],
|
||||
[FEISHU_MULTI_ENDPOINTS.deleteBot, { botId: 'bot_safe', confirm: true }],
|
||||
]) {
|
||||
const result = await fx.registration.handler(endpoint, payload, signal());
|
||||
assert.equal(result.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(result), /DSH_FEISHU_APP_SECRET|ou_private|cli_raw_private|private-token/);
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['reconnect', 'bot_safe'],
|
||||
['disconnect', 'bot_safe'],
|
||||
['delete', 'bot_safe'],
|
||||
]);
|
||||
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.deleteBot,
|
||||
{ botId: '../private', confirm: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
assert.doesNotMatch(JSON.stringify(invalid), /\.\.\/private/);
|
||||
});
|
||||
|
||||
test('dependency failures and AbortSignal use valid RpcResult error branches', async () => {
|
||||
const secret = 'should-never-be-reflected';
|
||||
const controller = {
|
||||
status: async () => { throw new Error(`SDK failed with ${secret}`); },
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => status(),
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const failure = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
assert.deepEqual(failure, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'The Feishu integration operation failed.',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(failure), new RegExp(secret));
|
||||
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const cancelled = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, abort.signal);
|
||||
assert.equal(cancelled.ok, false);
|
||||
assert.equal(cancelled.error.code, 'cancelled');
|
||||
|
||||
const unknown = await fx.registration.handler('credentials.read', {}, signal());
|
||||
assert.equal(unknown.ok, false);
|
||||
assert.equal(unknown.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('provisioning callback stores credentials and connects before connected is visible', async () => {
|
||||
const secret = 'host-only-app-secret';
|
||||
let onCredentials;
|
||||
let registrationState = 'idle';
|
||||
let configured = false;
|
||||
let connectionStatus = {
|
||||
ready: false,
|
||||
feishuLongConnectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
};
|
||||
const saved = [];
|
||||
const connectedWith = [];
|
||||
const controller = createProvisioningBackedController({
|
||||
createProvisioningManager(callbacks) {
|
||||
onCredentials = callbacks.onCredentials;
|
||||
return {
|
||||
start() { registrationState = 'qr_ready'; return this.status(); },
|
||||
status() {
|
||||
return {
|
||||
state: registrationState,
|
||||
attempt: 1,
|
||||
updatedAt: 100,
|
||||
...(registrationState === 'qr_ready'
|
||||
? { qrCodeUrl: 'https://accounts.feishu.cn/qr', expiresAt: Date.now() + 60_000 }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
cancel() { registrationState = 'cancelled'; return this.status(); },
|
||||
};
|
||||
},
|
||||
credentialStore: {
|
||||
async save(credentials) { saved.push(credentials); configured = true; },
|
||||
async clear() { configured = false; },
|
||||
async configured() { return configured; },
|
||||
},
|
||||
connectionManager: {
|
||||
async connect(credentials) {
|
||||
connectedWith.push(credentials);
|
||||
connectionStatus = {
|
||||
ready: true,
|
||||
feishuLongConnectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
};
|
||||
},
|
||||
async disconnect() {
|
||||
connectionStatus = {
|
||||
ready: false,
|
||||
feishuLongConnectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
};
|
||||
},
|
||||
status: () => connectionStatus,
|
||||
},
|
||||
});
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const begun = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
{ locale: 'zh-CN' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(begun.ok, true);
|
||||
|
||||
await onCredentials({
|
||||
client_id: 'cli_created',
|
||||
client_secret: secret,
|
||||
user_info: { open_id: 'ou_owner' },
|
||||
});
|
||||
registrationState = 'succeeded';
|
||||
const ready = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
|
||||
assert.equal(ready.ok, true);
|
||||
assert.equal(ready.value.state, 'connected');
|
||||
assert.equal(ready.value.connected, true);
|
||||
assert.equal(saved[0].appSecret, secret);
|
||||
assert.equal(connectedWith[0].appSecret, secret);
|
||||
assert.doesNotMatch(JSON.stringify(ready), new RegExp(secret));
|
||||
|
||||
const disconnected = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.disconnect,
|
||||
{ removeCredentials: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(disconnected.ok, true);
|
||||
assert.equal(disconnected.value.state, 'disconnected');
|
||||
assert.equal(configured, false);
|
||||
});
|
||||
|
||||
test('DSH credential adapter stores refs off the browser plane and clears them', async () => {
|
||||
const values = new Map();
|
||||
const provider = {
|
||||
async resolve(ref) {
|
||||
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
|
||||
},
|
||||
async describe(ref) {
|
||||
return { configured: values.has(ref), source: values.has(ref) ? 'file' : undefined, writable: true };
|
||||
},
|
||||
async set(ref, value) { values.set(ref, value); },
|
||||
async unset(ref) { values.delete(ref); },
|
||||
};
|
||||
const store = createDshCredentialStore(provider);
|
||||
|
||||
await store.save({ appId: 'cli_created', appSecret: 'stored-secret' });
|
||||
assert.equal(values.get(FEISHU_APP_ID_REF), 'cli_created');
|
||||
assert.equal(values.get(FEISHU_APP_SECRET_REF), 'stored-secret');
|
||||
assert.equal(await store.configured(), true);
|
||||
|
||||
await store.clear();
|
||||
assert.equal(values.size, 0);
|
||||
assert.equal(await store.configured(), false);
|
||||
});
|
||||
|
||||
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
|
||||
const constructed = {};
|
||||
class FakeConfigStore {
|
||||
constructor(path) { constructed.configPath = path; }
|
||||
async load() { return this; }
|
||||
}
|
||||
class FakeStateStore {
|
||||
constructor(path) {
|
||||
constructed.statePath = path;
|
||||
(constructed.statePaths ??= []).push(path);
|
||||
}
|
||||
async load() { return this; }
|
||||
}
|
||||
class FakeHarness {
|
||||
constructor(options) { constructed.harness = options; }
|
||||
async ensureRunning() { constructed.harnessReadyChecks = (constructed.harnessReadyChecks ?? 0) + 1; }
|
||||
stopManagedProcess() { constructed.harnessStopped = true; }
|
||||
}
|
||||
class FakeRuntime {
|
||||
constructor(options) { constructed.runtime = options; }
|
||||
}
|
||||
class FakeController {
|
||||
constructor(options) { constructed.controller = options; }
|
||||
async initialize() { constructed.initialized = true; }
|
||||
status() { return { totals: { configured: 0, connected: 0 } }; }
|
||||
async close() { constructed.closed = true; }
|
||||
}
|
||||
const credentials = {};
|
||||
const production = await createProductionController({
|
||||
credentials,
|
||||
webServer: { port: 43123, host: '127.0.0.1' },
|
||||
logger: console,
|
||||
}, {
|
||||
dshHome: '/tmp/dsh-feishu-host-test',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
}, {
|
||||
lark: { registerApp: async () => ({}) },
|
||||
Controller: FakeController,
|
||||
ConfigStore: FakeConfigStore,
|
||||
StateStore: FakeStateStore,
|
||||
HarnessClient: FakeHarness,
|
||||
FeishuRuntime: FakeRuntime,
|
||||
verifyFeishuApp: async () => ({}),
|
||||
});
|
||||
|
||||
assert.equal(constructed.initialized, undefined);
|
||||
await production.ready;
|
||||
assert.equal(constructed.initialized, true);
|
||||
assert.equal(constructed.harnessReadyChecks, 1);
|
||||
assert.equal(constructed.controller.credentials, credentials);
|
||||
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
|
||||
assert.equal(constructed.harness.autostart, false);
|
||||
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
|
||||
|
||||
await constructed.controller.createRuntime({
|
||||
config: {
|
||||
appId: 'cli_created',
|
||||
domain: 'feishu',
|
||||
ownerOpenId: 'ou_owner',
|
||||
},
|
||||
appSecret: 'host-only',
|
||||
});
|
||||
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_alpha',
|
||||
config: {
|
||||
id: 'bot_alpha',
|
||||
appId: 'cli_alpha',
|
||||
secretRef: 'DSH_FEISHU_APP_SECRET_ALPHA',
|
||||
domain: 'feishu',
|
||||
ownerOpenIds: ['ou_alpha'],
|
||||
},
|
||||
appSecret: 'alpha-secret',
|
||||
});
|
||||
const alphaState = constructed.runtime.state;
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_beta',
|
||||
config: {
|
||||
id: 'bot_beta',
|
||||
appId: 'cli_beta',
|
||||
secretRef: 'DSH_FEISHU_APP_SECRET_BETA',
|
||||
domain: 'feishu',
|
||||
ownerOpenIds: ['ou_beta'],
|
||||
},
|
||||
appSecret: 'beta-secret',
|
||||
});
|
||||
const betaState = constructed.runtime.state;
|
||||
assert.notEqual(alphaState, betaState);
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));
|
||||
await production.close();
|
||||
assert.equal(constructed.closed, true);
|
||||
assert.equal(constructed.harnessStopped, true);
|
||||
});
|
||||
|
||||
test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting', async () => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-feishu-production-state-isolation-'));
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await writeFile(join(dataDir, 'state.json'), '{corrupt legacy state');
|
||||
const legacy = {
|
||||
id: 'bot_legacy',
|
||||
appId: 'cli_legacy',
|
||||
secretRef: 'DSH_FEISHU_APP_SECRET',
|
||||
ownerOpenIds: ['ou_legacy'],
|
||||
domain: 'feishu',
|
||||
botName: '旧机器人',
|
||||
};
|
||||
const healthy = {
|
||||
id: 'bot_healthy',
|
||||
appId: 'cli_healthy',
|
||||
secretRef: 'DSH_FEISHU_APP_SECRET_HEALTHY',
|
||||
ownerOpenIds: ['ou_healthy'],
|
||||
domain: 'feishu',
|
||||
botName: '健康机器人',
|
||||
};
|
||||
await writeFile(join(dataDir, 'config.json'), JSON.stringify({ version: 2, bots: [legacy, healthy] }));
|
||||
const secrets = new Map([
|
||||
[legacy.secretRef, 'legacy-secret'],
|
||||
[healthy.secretRef, 'healthy-secret'],
|
||||
]);
|
||||
class FakeRuntime {
|
||||
#status = { ready: false, feishuLongConnectionState: 'idle', harnessReachable: false };
|
||||
get status() { return structuredClone(this.#status); }
|
||||
async start() {
|
||||
this.#status = { ready: true, feishuLongConnectionState: 'connected', harnessReachable: true };
|
||||
}
|
||||
async stop() {
|
||||
this.#status = { ready: false, feishuLongConnectionState: 'idle', harnessReachable: false };
|
||||
}
|
||||
}
|
||||
class FakeHarness {
|
||||
async ensureRunning() {}
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
const production = await createProductionController({
|
||||
credentials: {
|
||||
async resolve(ref) { return secrets.has(ref) ? { value: secrets.get(ref) } : undefined; },
|
||||
async set(ref, value) { secrets.set(ref, value); },
|
||||
async unset(ref) { secrets.delete(ref); },
|
||||
},
|
||||
webServer: { port: 43124 },
|
||||
logger: console,
|
||||
}, { dataDir, workspace: dataDir }, {
|
||||
lark: { registerApp: async () => ({}) },
|
||||
HarnessClient: FakeHarness,
|
||||
FeishuRuntime: FakeRuntime,
|
||||
verifyFeishuApp: async () => ({}),
|
||||
});
|
||||
|
||||
await production.ready;
|
||||
const statusValue = production.controller.status();
|
||||
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_legacy').phase, 'error');
|
||||
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_healthy').connected, true);
|
||||
assert.equal(statusValue.totals.connected, 1);
|
||||
await production.close();
|
||||
});
|
||||
280
test/channels/feishu/registration-manager.test.mjs
Normal file
280
test/channels/feishu/registration-manager.test.mjs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RegistrationManager } from '../../../src/channels/feishu/registration-manager.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fakeClock() {
|
||||
let time = 0;
|
||||
let sequence = 0;
|
||||
const timers = new Map();
|
||||
|
||||
return {
|
||||
now: () => time,
|
||||
setTimeout(fn, delay) {
|
||||
const id = ++sequence;
|
||||
timers.set(id, { at: time + delay, fn });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
advance(milliseconds) {
|
||||
const target = time + milliseconds;
|
||||
for (;;) {
|
||||
const due = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.at <= target)
|
||||
.sort((left, right) => left[1].at - right[1].at)[0];
|
||||
if (!due) break;
|
||||
const [id, timer] = due;
|
||||
timers.delete(id);
|
||||
time = timer.at;
|
||||
timer.fn();
|
||||
}
|
||||
time = target;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test('RegistrationManager captures QR countdown and SDK polling states', async () => {
|
||||
const clock = fakeClock();
|
||||
const registration = deferred();
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
now: clock.now,
|
||||
setTimeout: clock.setTimeout,
|
||||
clearTimeout: clock.clearTimeout,
|
||||
});
|
||||
|
||||
assert.deepEqual(manager.start({ source: 'dsh-feishu' }), {
|
||||
state: 'starting',
|
||||
attempt: 1,
|
||||
updatedAt: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
assert.equal(sdkOptions.source, 'dsh-feishu');
|
||||
assert.ok(sdkOptions.signal instanceof AbortSignal);
|
||||
|
||||
sdkOptions.onQRCodeReady({ url: 'https://accounts.feishu.cn/device', expireIn: 5 });
|
||||
assert.deepEqual(manager.status(), {
|
||||
state: 'qr_ready',
|
||||
attempt: 1,
|
||||
updatedAt: 0,
|
||||
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||
expiresAt: 5000,
|
||||
remainingSeconds: 5,
|
||||
});
|
||||
|
||||
clock.advance(1200);
|
||||
assert.equal(manager.status().remainingSeconds, 4);
|
||||
|
||||
sdkOptions.onStatusChange({ status: 'polling' });
|
||||
assert.equal(manager.status().state, 'polling');
|
||||
|
||||
sdkOptions.onStatusChange({ status: 'slow_down', interval: 10 });
|
||||
assert.equal(manager.status().state, 'slow_down');
|
||||
assert.equal(manager.status().pollIntervalSeconds, 10);
|
||||
|
||||
sdkOptions.onStatusChange({ status: 'domain_switched' });
|
||||
assert.equal(manager.status().state, 'domain_switched');
|
||||
assert.equal(manager.status().remainingSeconds, 4);
|
||||
|
||||
manager.cancel();
|
||||
});
|
||||
|
||||
test('RegistrationManager only sends credentials to callback and never exposes the secret', async () => {
|
||||
const registration = deferred();
|
||||
const persistence = deferred();
|
||||
const received = [];
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: (credentials) => {
|
||||
received.push(credentials);
|
||||
return persistence.promise;
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
sdkOptions.onQRCodeReady({ url: 'https://example.test/qr', expireIn: 60 });
|
||||
registration.resolve({
|
||||
client_id: 'cli_test',
|
||||
client_secret: 'super-secret-value',
|
||||
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
|
||||
unexpected: 'must-not-be-forwarded',
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
assert.deepEqual(received, [{
|
||||
client_id: 'cli_test',
|
||||
client_secret: 'super-secret-value',
|
||||
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
|
||||
}]);
|
||||
assert.equal(manager.status().state, 'saving');
|
||||
assert.equal('qrCodeUrl' in manager.status(), false);
|
||||
assert.equal('remainingSeconds' in manager.status(), false);
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret/);
|
||||
|
||||
persistence.resolve();
|
||||
await flushPromises();
|
||||
assert.equal(manager.status().state, 'succeeded');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret|cli_test|ou_test/);
|
||||
});
|
||||
|
||||
test('a concurrent start aborts and ignores the previous attempt', async () => {
|
||||
const registrations = [deferred(), deferred()];
|
||||
const sdkCalls = [];
|
||||
const received = [];
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
const index = sdkCalls.length;
|
||||
sdkCalls.push(options);
|
||||
return registrations[index].promise;
|
||||
},
|
||||
onCredentials: async (credentials) => received.push(credentials.client_id),
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
manager.start();
|
||||
assert.equal(sdkCalls[0].signal.aborted, true);
|
||||
assert.equal(manager.status().attempt, 2);
|
||||
await flushPromises();
|
||||
|
||||
sdkCalls[0].onQRCodeReady({ url: 'https://stale.test', expireIn: 30 });
|
||||
registrations[0].resolve({ client_id: 'cli_stale', client_secret: 'stale-secret' });
|
||||
registrations[1].resolve({ client_id: 'cli_current', client_secret: 'current-secret' });
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
assert.deepEqual(received, ['cli_current']);
|
||||
assert.equal(manager.status().state, 'succeeded');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /stale-secret|current-secret/);
|
||||
});
|
||||
|
||||
test('cancel is terminal and ignores a late SDK result', async () => {
|
||||
const registration = deferred();
|
||||
const received = [];
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: async (credentials) => received.push(credentials),
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
const status = manager.cancel();
|
||||
|
||||
assert.equal(status.state, 'cancelled');
|
||||
assert.equal(status.error.code, 'abort');
|
||||
assert.equal(sdkOptions.signal.aborted, true);
|
||||
|
||||
registration.resolve({ client_id: 'cli_late', client_secret: 'late-secret' });
|
||||
await flushPromises();
|
||||
assert.deepEqual(received, []);
|
||||
assert.equal(manager.status().state, 'cancelled');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /late-secret/);
|
||||
});
|
||||
|
||||
test('QR expiry aborts polling and reports an explicit expired state', async () => {
|
||||
const clock = fakeClock();
|
||||
const registration = deferred();
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: async () => assert.fail('expired credentials must not be stored'),
|
||||
now: clock.now,
|
||||
setTimeout: clock.setTimeout,
|
||||
clearTimeout: clock.clearTimeout,
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
sdkOptions.onQRCodeReady({ url: 'https://example.test/expiring', expireIn: 3 });
|
||||
clock.advance(2999);
|
||||
assert.equal(manager.status().remainingSeconds, 1);
|
||||
clock.advance(1);
|
||||
|
||||
assert.equal(manager.status().state, 'expired');
|
||||
assert.equal(manager.status().error.code, 'expired_token');
|
||||
assert.equal('qrCodeUrl' in manager.status(), false);
|
||||
assert.equal(sdkOptions.signal.aborted, true);
|
||||
});
|
||||
|
||||
test('SDK expiration and errors become distinct safe terminal states', async (t) => {
|
||||
await t.test('expired_token is expired', async () => {
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: async () => {
|
||||
throw { code: 'expired_token', description: 'Polling timed out' };
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
});
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
assert.equal(manager.status().state, 'expired');
|
||||
assert.equal(manager.status().error.code, 'expired_token');
|
||||
});
|
||||
|
||||
await t.test('unknown error does not reflect its message', async () => {
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: async () => {
|
||||
throw new Error('request failed with client_secret=do-not-leak');
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
});
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
const status = manager.status();
|
||||
assert.equal(status.state, 'error');
|
||||
assert.equal(status.error.code, 'registration_failed');
|
||||
assert.doesNotMatch(JSON.stringify(status), /do-not-leak|client_secret/);
|
||||
});
|
||||
});
|
||||
|
||||
test('credential persistence failure is safe and does not expose the secret', async () => {
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: async () => ({
|
||||
client_id: 'cli_test',
|
||||
client_secret: 'secret-mentioned-by-callback',
|
||||
user_info: { open_id: 'ou_test' },
|
||||
}),
|
||||
onCredentials: async ({ client_secret: secret }) => {
|
||||
throw new Error(`failed to persist ${secret}`);
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const status = manager.status();
|
||||
assert.equal(status.state, 'error');
|
||||
assert.equal(status.error.code, 'credentials_callback_failed');
|
||||
assert.doesNotMatch(JSON.stringify(status), /secret-mentioned-by-callback|client_secret/);
|
||||
});
|
||||
34
test/channels/feishu/state-store.test.mjs
Normal file
34
test/channels/feishu/state-store.test.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { StateStore } from '../../../src/channels/feishu/state-store.mjs';
|
||||
|
||||
test('StateStore persists sessions and dedupe ids', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-state-'));
|
||||
const path = join(dir, 'state.json');
|
||||
const first = await new StateStore(path).load();
|
||||
await first.setSession('group:one', 'session-one');
|
||||
await first.markSeen('message-one');
|
||||
|
||||
const second = await new StateStore(path).load();
|
||||
assert.equal(second.sessionFor('group:one'), 'session-one');
|
||||
assert.equal(second.hasSeen('message-one'), true);
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 1);
|
||||
});
|
||||
|
||||
test('separate bot StateStores isolate identical conversations and message ids', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-state-bots-'));
|
||||
const alpha = await new StateStore(join(dir, 'bot-alpha', 'state.json')).load();
|
||||
const beta = await new StateStore(join(dir, 'bot-beta', 'state.json')).load();
|
||||
|
||||
await alpha.setSession('p2p:ou_same', 'session-alpha');
|
||||
await beta.setSession('p2p:ou_same', 'session-beta');
|
||||
await alpha.markSeen('om_same');
|
||||
|
||||
assert.equal(alpha.sessionFor('p2p:ou_same'), 'session-alpha');
|
||||
assert.equal(beta.sessionFor('p2p:ou_same'), 'session-beta');
|
||||
assert.equal(alpha.hasSeen('om_same'), true);
|
||||
assert.equal(beta.hasSeen('om_same'), false);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue