mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 20:13:21 +08:00
fix(feishu): add one-click card callback repair
This commit is contained in:
parent
0e62c69382
commit
c1a3b0cf97
24 changed files with 3601 additions and 248 deletions
|
|
@ -2129,3 +2129,278 @@ test('session pagination preserves an explicitly selected workspace', async () =
|
|||
assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-11');
|
||||
assert.match(JSON.stringify(cards(sent).at(-1).content), new RegExp(workspaceB.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
});
|
||||
|
||||
const REPAIR_APP_ID = 'cli_repair_test';
|
||||
const REPAIR_BOT_ID = 'bot_repair_test';
|
||||
const REPAIR_URL = `https://open.feishu.cn/page/launcher?tp=sdk&clientID=${REPAIR_APP_ID}&addons=safe`;
|
||||
|
||||
function repairStatus(state = 'qr_ready', overrides = {}) {
|
||||
return {
|
||||
registration: {
|
||||
operation: 'callback_repair',
|
||||
state,
|
||||
attempt: 'repair_attempt_1',
|
||||
botId: REPAIR_BOT_ID,
|
||||
qrCodeUrl: REPAIR_URL,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
remainingSeconds: 60,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function repairCapability({
|
||||
startStatus = repairStatus(),
|
||||
status = startStatus,
|
||||
cancelStatus = repairStatus('cancelled', { qrCodeUrl: undefined }),
|
||||
} = {}) {
|
||||
const calls = { start: [], status: [], cancel: [] };
|
||||
return {
|
||||
calls,
|
||||
capability: {
|
||||
async start(args) { calls.start.push(args); return startStatus; },
|
||||
async status(args) {
|
||||
calls.status.push(args);
|
||||
return typeof status === 'function' ? status(calls.status.length) : status;
|
||||
},
|
||||
async cancel(args) {
|
||||
calls.cancel.push(args);
|
||||
return typeof cancelStatus === 'function'
|
||||
? cancelStatus(calls.cancel.length)
|
||||
: cancelStatus;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function repairBridge({
|
||||
allowedSenderOpenIds = new Set(['ou_owner']),
|
||||
repairOwnerOpenIds,
|
||||
capability,
|
||||
client,
|
||||
sent = [],
|
||||
} = {}) {
|
||||
const fixture = stateFixture();
|
||||
let asks = 0;
|
||||
const activeClient = client ?? cardClient(async (outgoing) => sent.push(outgoing));
|
||||
return {
|
||||
fixture,
|
||||
sent,
|
||||
get asks() { return asks; },
|
||||
bridge: new FeishuHarnessBridge({
|
||||
client: activeClient,
|
||||
channel: {},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
ask: async () => { asks += 1; return 'unexpected'; },
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds,
|
||||
repairOwnerOpenIds,
|
||||
botId: REPAIR_BOT_ID,
|
||||
appId: REPAIR_APP_ID,
|
||||
repair: capability,
|
||||
repairPollIntervalMs: 5,
|
||||
repairLinkWaitMs: 100,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('/repair sends a validated ordinary SDK link without prompting Harness', async () => {
|
||||
const repair = repairCapability();
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
||||
await fx.bridge.accept(event('repair-start', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
|
||||
assert.equal(repair.calls.start.length, 1);
|
||||
assert.deepEqual(repair.calls.start[0], {
|
||||
botId: REPAIR_BOT_ID,
|
||||
actorOpenId: 'ou_owner',
|
||||
chatId: 'oc_chat',
|
||||
});
|
||||
assert.equal(fx.asks, 0);
|
||||
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||||
assert.match(message, /card\.action\.trigger/);
|
||||
assert.match(message, new RegExp(REPAIR_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(message, /\/repair qr/);
|
||||
});
|
||||
|
||||
test('/repair status after a runtime restart never starts a duplicate authorization', async () => {
|
||||
const repair = repairCapability();
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
||||
await fx.bridge.accept(event('repair-restarted-status', '/repair status', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await fx.bridge.waitForIdle();
|
||||
|
||||
assert.equal(repair.calls.start.length, 0);
|
||||
assert.equal(repair.calls.status.length, 0);
|
||||
assert.equal(repair.calls.cancel.length, 0);
|
||||
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||||
assert.match(message, /没有可恢复的修复任务记录/);
|
||||
assert.match(message, /不会启动新的授权/);
|
||||
});
|
||||
|
||||
test('menu repair entry is number-only and reply 6 starts the same repair flow', async () => {
|
||||
const repair = repairCapability();
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
||||
await fx.bridge.accept(event('repair-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
const menu = cards(fx.sent)[0].content;
|
||||
assert.match(JSON.stringify(menu), /6 · 修复卡片按钮/);
|
||||
assert.equal(buttonsFromCard(menu).some((button) => callbackAction(button) === 'repair'), false);
|
||||
|
||||
await fx.bridge.accept(event('repair-menu-six', '6', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
assert.equal(repair.calls.start.length, 1);
|
||||
assert.equal(fx.asks, 0);
|
||||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /card\.action\.trigger/);
|
||||
});
|
||||
|
||||
test('chat repair requires a private chat and an exact owner; wildcard never authorizes it', async () => {
|
||||
const wildcardRepair = repairCapability();
|
||||
const wildcard = repairBridge({
|
||||
allowedSenderOpenIds: new Set(['*']),
|
||||
capability: wildcardRepair.capability,
|
||||
});
|
||||
await wildcard.bridge.accept(event('repair-wildcard', '/repair', { senderOpenId: 'ou_anyone' }));
|
||||
await wildcard.bridge.waitForIdle();
|
||||
assert.equal(wildcardRepair.calls.start.length, 0);
|
||||
assert.match(JSON.parse(wildcard.sent.at(-1).content).text, /没有可验证的接入者身份/);
|
||||
|
||||
const mixedRepair = repairCapability();
|
||||
const mixed = repairBridge({
|
||||
allowedSenderOpenIds: new Set(['*', 'ou_owner']),
|
||||
capability: mixedRepair.capability,
|
||||
});
|
||||
await mixed.bridge.accept(event('repair-mixed-intruder', '/repair', { senderOpenId: 'ou_other' }));
|
||||
await mixed.bridge.waitForIdle();
|
||||
assert.equal(mixedRepair.calls.start.length, 0);
|
||||
assert.match(JSON.parse(mixed.sent.at(-1).content).text, /只能由机器人接入者/);
|
||||
await mixed.bridge.accept(event('repair-mixed-owner', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await mixed.bridge.waitForIdle();
|
||||
assert.equal(mixedRepair.calls.start.length, 1);
|
||||
|
||||
const groupRepair = repairCapability();
|
||||
const group = repairBridge({ capability: groupRepair.capability });
|
||||
await group.bridge.accept(event('repair-group', '/repair', {
|
||||
senderOpenId: 'ou_owner',
|
||||
chat_type: 'group',
|
||||
chat_id: 'oc_group',
|
||||
}));
|
||||
await group.bridge.waitForIdle();
|
||||
assert.equal(groupRepair.calls.start.length, 0);
|
||||
assert.match(JSON.parse(group.sent.at(-1).content).text, /请私聊机器人/);
|
||||
});
|
||||
|
||||
test('/repair qr, status, verify and cancel stay scoped to the initiating owner', async () => {
|
||||
const sent = [];
|
||||
let sequence = 0;
|
||||
const client = {
|
||||
im: { v1: {
|
||||
image: { create: async ({ data }) => {
|
||||
assert.equal(data.image_type, 'message');
|
||||
assert.equal(Buffer.isBuffer(data.image), true);
|
||||
return { image_key: 'img_repair_qr' };
|
||||
} },
|
||||
message: { create: async (request) => {
|
||||
sent.push(request);
|
||||
sequence += 1;
|
||||
return { code: 0, data: { message_id: `om_repair_${sequence}` } };
|
||||
} },
|
||||
} },
|
||||
};
|
||||
const repair = repairCapability();
|
||||
const fx = repairBridge({ capability: repair.capability, client, sent });
|
||||
|
||||
await fx.bridge.accept(event('repair-commands-start', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
await fx.bridge.accept(event('repair-commands-qr', '/repair qr', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
assert.equal(sent.some((request) => request.data.msg_type === 'image'
|
||||
&& JSON.parse(request.data.content).image_key === 'img_repair_qr'), true);
|
||||
|
||||
await fx.bridge.accept(event('repair-commands-status', '/repair status', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.accept(event('repair-commands-verify', '/repair verify', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
const textMessages = sent
|
||||
.filter((request) => request.data.msg_type === 'text')
|
||||
.map((request) => JSON.parse(request.data.content).text);
|
||||
assert.equal(textMessages.some((text) => text.includes('修复任务正在等待授权')), true);
|
||||
assert.equal(textMessages.some((text) => text.includes('授权尚未完成')), true);
|
||||
|
||||
await fx.bridge.accept(event('repair-commands-cancel', '/repair cancel', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
assert.equal(repair.calls.cancel.length, 1);
|
||||
assert.equal(repair.calls.cancel[0].actorOpenId, 'ou_owner');
|
||||
});
|
||||
|
||||
test('/repair cancel only reports cancellation when the controller confirms it', async () => {
|
||||
for (const state of ['saving', 'succeeded']) {
|
||||
const repair = repairCapability({
|
||||
cancelStatus: repairStatus(state, { qrCodeUrl: undefined }),
|
||||
status: repairStatus(state, { qrCodeUrl: undefined }),
|
||||
});
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
await fx.bridge.accept(event(`repair-cancel-${state}-start`, '/repair', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await fx.bridge.waitForIdle();
|
||||
await fx.bridge.accept(event(`repair-cancel-${state}`, '/repair cancel', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await fx.bridge.waitForIdle();
|
||||
|
||||
const reply = JSON.parse(fx.sent.at(-1).content).text;
|
||||
assert.doesNotMatch(reply, /已取消本次修复授权/);
|
||||
assert.match(reply, state === 'saving' ? /正在等待专用测试按钮/ : /修复完成/);
|
||||
await eventually(() => repair.calls.status.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('/repair rejects placeholder or mismatched launcher links and cancels the attempt', async () => {
|
||||
for (const badUrl of [
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_other_app',
|
||||
`https://open.feishu.cn/page/launcher?tp=card&clientID=${REPAIR_APP_ID}`,
|
||||
]) {
|
||||
const repair = repairCapability({
|
||||
startStatus: repairStatus('qr_ready', { qrCodeUrl: badUrl }),
|
||||
});
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
await fx.bridge.accept(event(`repair-bad-${repair.calls.start.length}-${badUrl.length}`, '/repair', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await fx.bridge.waitForIdle();
|
||||
assert.equal(repair.calls.cancel.length, 1);
|
||||
const text = JSON.parse(fx.sent.at(-1).content).text;
|
||||
assert.match(text, /无法安全验证/);
|
||||
assert.doesNotMatch(text, /\{\{client_id\}\}|cli_other_app/);
|
||||
}
|
||||
});
|
||||
|
||||
test('repair monitor reports expiry without claiming that the callback was fixed', async () => {
|
||||
const repair = repairCapability({
|
||||
status: repairStatus('expired', {
|
||||
qrCodeUrl: undefined,
|
||||
remainingSeconds: 0,
|
||||
error: { code: 'expired_token', message: 'safe' },
|
||||
}),
|
||||
});
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
await fx.bridge.accept(event('repair-expiry', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
await eventually(() => fx.sent.some((outgoing) => (
|
||||
outgoing.msgType === 'text'
|
||||
&& JSON.parse(outgoing.content).text.includes('授权链接已过期')
|
||||
)));
|
||||
const terminal = fx.sent
|
||||
.filter((outgoing) => outgoing.msgType === 'text')
|
||||
.map((outgoing) => JSON.parse(outgoing.content).text)
|
||||
.find((text) => text.includes('授权链接已过期'));
|
||||
assert.doesNotMatch(terminal, /修复完成/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '../../../plugin-src/client/channels/feishu/api.js';
|
||||
|
||||
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
||||
assert.equal(FEISHU_ENDPOINTS.beginCallbackRepair, 'bot.callback-repair.begin');
|
||||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||
|
|
@ -76,6 +77,7 @@ test('provision polling preserves the newly connected botId', () => {
|
|||
botId: 'bot-new',
|
||||
}), {
|
||||
status: 'connected',
|
||||
operation: 'provision',
|
||||
botId: 'bot-new',
|
||||
message: undefined,
|
||||
connection: undefined,
|
||||
|
|
@ -123,6 +125,47 @@ test('client accepts a Host-rendered QR code without exposing credentials', () =
|
|||
assert.equal('clientSecret' in provisioning, false);
|
||||
});
|
||||
|
||||
test('client preserves callback repair identity across QR and poll projections', () => {
|
||||
const provisioning = normalizeProvisioning({
|
||||
attemptId: 'reg_repair',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
verificationUrl: 'https://accounts.feishu.cn/device?tp=sdk&clientID=cli_target',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
});
|
||||
assert.equal(provisioning.operation, 'callback_repair');
|
||||
assert.equal(provisioning.botId, 'bot_target');
|
||||
|
||||
const poll = normalizePollResult({
|
||||
status: 'connecting',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
});
|
||||
assert.equal(poll.operation, 'callback_repair');
|
||||
assert.equal(poll.botId, 'bot_target');
|
||||
assert.throws(() => normalizeProvisioning({
|
||||
attemptId: 'reg_broken',
|
||||
operation: 'callback_repair',
|
||||
verificationUrl: 'https://accounts.feishu.cn/device',
|
||||
}), /botId/);
|
||||
});
|
||||
|
||||
test('client restores a submitted callback repair without requiring an expired QR URL', () => {
|
||||
const provisioning = normalizeProvisioning({
|
||||
attemptId: 'reg_committed',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
submitted: true,
|
||||
pollIntervalMs: 800,
|
||||
});
|
||||
assert.equal(provisioning.attemptId, 'reg_committed');
|
||||
assert.equal(provisioning.operation, 'callback_repair');
|
||||
assert.equal(provisioning.botId, 'bot_target');
|
||||
assert.equal(provisioning.submitted, true);
|
||||
assert.equal(provisioning.verificationUrl, undefined);
|
||||
assert.equal(provisioning.qrCodeDataUrl, undefined);
|
||||
});
|
||||
|
||||
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||
connected: true,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,246 @@ test('Feishu connection check requests and displays test-message feedback', asyn
|
|||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
assert.match(markup, /修复卡片按钮/);
|
||||
assert.match(markup, /aria-label="修复飞书测试机器人的卡片按钮"/);
|
||||
});
|
||||
|
||||
test('Feishu callback repair keeps a Host-submitted attempt when a stale QR cancel races saving', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
let nextTimer = 0;
|
||||
const timeouts = new Map();
|
||||
const frames = new Map();
|
||||
globalThis.window = {
|
||||
setInterval() { return ++nextTimer; },
|
||||
clearInterval() {},
|
||||
setTimeout(callback) {
|
||||
const id = ++nextTimer;
|
||||
timeouts.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timeouts.delete(id); },
|
||||
requestAnimationFrame(callback) {
|
||||
const id = ++nextTimer;
|
||||
frames.set(id, callback);
|
||||
queueMicrotask(() => {
|
||||
const pending = frames.get(id);
|
||||
if (!pending) return;
|
||||
frames.delete(id);
|
||||
pending();
|
||||
});
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame(id) { frames.delete(id); },
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
bots: [{
|
||||
botId: 'bot_target',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget' },
|
||||
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||
}],
|
||||
};
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
|
||||
if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
attemptId: 'reg_repair',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
verificationUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
pollIntervalMs: 800,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
status: 'connecting',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
status: 'connecting',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
message: 'Callback repair was already submitted and is still being verified.',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'bot_target' });
|
||||
await act(async () => {
|
||||
card.findAllByType('button')
|
||||
.find((button) => textOf(button) === '修复卡片按钮').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
assert.ok(calls.some(({ endpoint, payload }) => endpoint === FEISHU_ENDPOINTS.beginCallbackRepair
|
||||
&& payload.botId === 'bot_target'));
|
||||
const officialLink = renderer.root.findByType('a');
|
||||
assert.equal(
|
||||
officialLink.props.href,
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||
);
|
||||
assert.match(textOf(renderer.toJSON()), /不会创建新应用/);
|
||||
|
||||
const staleCancel = renderer.root.findAllByType('button')
|
||||
.find((button) => textOf(button) === '取消修复');
|
||||
assert.ok(staleCancel);
|
||||
await act(async () => {
|
||||
staleCancel.props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.ok(calls.some(({ endpoint, payload }) => endpoint === FEISHU_ENDPOINTS.cancelProvisioning
|
||||
&& payload.attemptId === 'reg_repair'));
|
||||
assert.match(textOf(renderer.toJSON()), /此阶段无法取消/);
|
||||
assert.equal(renderer.root.findAllByType('button').some(
|
||||
(button) => textOf(button) === '取消修复',
|
||||
), false);
|
||||
assert.ok(timeouts.size > 0, 'submitted repair keeps polling after the refused cancel');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Feishu callback repair recovers when a Host restart forgets the browser attempt', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
let nextTimer = 0;
|
||||
const frames = new Map();
|
||||
globalThis.window = {
|
||||
setInterval() { return ++nextTimer; },
|
||||
clearInterval() {},
|
||||
setTimeout() { return ++nextTimer; },
|
||||
clearTimeout() {},
|
||||
requestAnimationFrame(callback) {
|
||||
const id = ++nextTimer;
|
||||
frames.set(id, callback);
|
||||
queueMicrotask(() => {
|
||||
const pending = frames.get(id);
|
||||
if (!pending) return;
|
||||
frames.delete(id);
|
||||
pending();
|
||||
});
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame(id) { frames.delete(id); },
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
bots: [{
|
||||
botId: 'bot_target',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget' },
|
||||
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||
}],
|
||||
};
|
||||
let beginCount = 0;
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
|
||||
if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||
beginCount += 1;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
attemptId: `reg_repair_${beginCount}`,
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
verificationUrl: `https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&attempt=${beginCount}`,
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
pollIntervalMs: 800,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'bad-request',
|
||||
message: 'The provisioning attempt is no longer active.',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const repairButton = () => renderer.root.findByProps({ 'data-bot-id': 'bot_target' })
|
||||
.findAllByType('button')
|
||||
.find((button) => textOf(button) === '修复卡片按钮');
|
||||
|
||||
await act(async () => {
|
||||
repairButton().props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findAllByType('button')
|
||||
.find((button) => textOf(button) === '换一个二维码').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.equal(beginCount, 2, 'a stale cancel cannot block the replacement begin');
|
||||
assert.match(renderer.root.findByType('a').props.href, /attempt=2$/);
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findAllByType('button')
|
||||
.find((button) => textOf(button) === '取消修复').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.match(textOf(renderer.toJSON()), /The provisioning attempt is no longer active/);
|
||||
await act(async () => {
|
||||
renderer.root.find((node) => node.props.role === 'alert')
|
||||
.findAllByType('button')
|
||||
.find((button) => textOf(button) === '关闭').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.equal(renderer.root.findAll((node) => node.props.role === 'alert').length, 0);
|
||||
assert.equal(repairButton().props.disabled, false);
|
||||
assert.ok(calls.some(({ endpoint }) => endpoint === FEISHU_ENDPOINTS.cancelProvisioning));
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Feishu reconnect failures render fixed English-safe feedback', async (t) => {
|
||||
|
|
|
|||
38
test/channels/feishu/feishu-cards.test.mjs
Normal file
38
test/channels/feishu/feishu-cards.test.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
cardActionProbeCard,
|
||||
menuCard,
|
||||
} from '../../../src/channels/feishu/feishu-cards.mjs';
|
||||
|
||||
function buttons(value, result = []) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) buttons(item, result);
|
||||
return result;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return result;
|
||||
if (value.tag === 'button') result.push(value);
|
||||
for (const child of Object.values(value)) buttons(child, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
test('menu exposes repair as number-only text instead of a callback button', () => {
|
||||
const card = JSON.parse(menuCard());
|
||||
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
|
||||
const actions = buttons(card).flatMap((button) => (
|
||||
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
|
||||
));
|
||||
assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help']);
|
||||
assert.equal(actions.includes('repair'), false);
|
||||
});
|
||||
|
||||
test('card-action probe carries only its action and opaque nonce', () => {
|
||||
const nonce = '0123456789abcdef0123456789abcdef';
|
||||
const card = JSON.parse(cardActionProbeCard(nonce));
|
||||
const probe = buttons(card)[0];
|
||||
assert.deepEqual(probe.behaviors, [{
|
||||
type: 'callback',
|
||||
value: { action: 'repair_verify', nonce },
|
||||
}]);
|
||||
assert.throws(() => cardActionProbeCard('{{client_id}}'), /safe card-action probe nonce/);
|
||||
});
|
||||
|
|
@ -39,8 +39,9 @@ class FakeWSClient {
|
|||
FakeWSClient.instances.push(this);
|
||||
}
|
||||
|
||||
async start() {
|
||||
async start({ eventDispatcher } = {}) {
|
||||
this.state = 'connecting';
|
||||
this.dispatcher = eventDispatcher;
|
||||
}
|
||||
|
||||
becomeReady() {
|
||||
|
|
@ -202,3 +203,154 @@ test('FeishuRuntime fails closed when Harness is unavailable', async () => {
|
|||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
assert.equal(runtime.status.lastError, 'Harness unavailable');
|
||||
});
|
||||
|
||||
async function startRuntimeForProbe(options = {}) {
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
botId: 'bot_probe',
|
||||
appId: 'cli_probe',
|
||||
appSecret: 'secret',
|
||||
ownerOpenIds: ['ou_owner'],
|
||||
harness: { async ensureRunning() {} },
|
||||
state: { hasSeen: () => false },
|
||||
...options,
|
||||
});
|
||||
const starting = runtime.start();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
await starting;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function probeAction({ messageId = 'message-1', nonce, operatorOpenId = 'ou_owner' } = {}) {
|
||||
return {
|
||||
operator: { open_id: operatorOpenId },
|
||||
action: { value: { action: 'repair_verify', nonce } },
|
||||
context: { open_message_id: messageId },
|
||||
};
|
||||
}
|
||||
|
||||
test('FeishuRuntime resolves a card-action probe only for the exact message, nonce and operator', async () => {
|
||||
const runtime = await startRuntimeForProbe();
|
||||
let settled = false;
|
||||
const probe = runtime.beginCardActionProbe({
|
||||
expectedOperatorOpenId: 'ou_owner',
|
||||
timeoutMs: 1_000,
|
||||
}).then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const request = FakeClient.sent[0];
|
||||
assert.deepEqual(request.params, { receive_id_type: 'open_id' });
|
||||
assert.equal(request.data.receive_id, 'ou_owner');
|
||||
assert.equal(request.data.msg_type, 'interactive');
|
||||
const card = JSON.parse(request.data.content);
|
||||
const behavior = card.body.elements[1].columns[0].elements[0].behaviors[0];
|
||||
assert.equal(behavior.value.action, 'repair_verify');
|
||||
const nonce = behavior.value.nonce;
|
||||
assert.match(nonce, /^[A-Za-z0-9_-]{16,128}$/);
|
||||
|
||||
const dispatch = FakeWSClient.instances[0].dispatcher.handlers['card.action.trigger'];
|
||||
dispatch(probeAction({ messageId: 'message-other', nonce }));
|
||||
dispatch(probeAction({ nonce: `${nonce}x` }));
|
||||
dispatch(probeAction({ nonce, operatorOpenId: 'ou_other' }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
|
||||
dispatch(probeAction({ nonce }));
|
||||
assert.deepEqual(await probe, {
|
||||
verified: true,
|
||||
messageId: 'message-1',
|
||||
operatorOpenId: 'ou_owner',
|
||||
});
|
||||
assert.equal(runtime.status.cardActionsReceived, 4);
|
||||
assert.equal(runtime.status.cardActionProbesVerified, 1);
|
||||
assert.equal(FakeClient.sent.length, 2);
|
||||
assert.deepEqual(FakeClient.sent[1], {
|
||||
params: { receive_id_type: 'open_id' },
|
||||
data: {
|
||||
receive_id: 'ou_owner',
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({
|
||||
text: '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime times out and aborts pending card-action probes with stable codes', async () => {
|
||||
const runtime = await startRuntimeForProbe();
|
||||
await assert.rejects(
|
||||
runtime.beginCardActionProbe({ expectedOperatorOpenId: 'ou_owner', timeoutMs: 10 }),
|
||||
(error) => error?.code === 'card_action_probe_timeout',
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(
|
||||
JSON.parse(FakeClient.sent.at(-1).data.content).text,
|
||||
/修复验证超时.*不能确认按钮已修复.*不要重复授权/,
|
||||
);
|
||||
|
||||
const pending = runtime.beginCardActionProbe({
|
||||
expectedOperatorOpenId: 'ou_owner',
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await runtime.stop();
|
||||
await assert.rejects(pending, (error) => error?.code === 'abort');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(
|
||||
JSON.parse(FakeClient.sent.at(-1).data.content).text,
|
||||
/修复验证中断.*不能确认修复成功.*不要重复授权/,
|
||||
);
|
||||
});
|
||||
|
||||
test('FeishuRuntime reports probe-card send failure without masking its stable error', async () => {
|
||||
const runtime = await startRuntimeForProbe();
|
||||
const client = FakeClient.instances[0];
|
||||
client.im.v1.message.create = async (payload) => {
|
||||
FakeClient.sent.push(payload);
|
||||
if (payload.data.msg_type === 'interactive') return { code: 230001 };
|
||||
return { code: 0, data: { message_id: 'failure-notice' } };
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
runtime.beginCardActionProbe({ expectedOperatorOpenId: 'ou_owner', timeoutMs: 1_000 }),
|
||||
(error) => error?.code === 'card_action_probe_send_failed',
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(FakeClient.sent.length, 2);
|
||||
assert.match(
|
||||
JSON.parse(FakeClient.sent[1].data.content).text,
|
||||
/修复验证失败.*不能确认 card\.action\.trigger 已恢复.*不要重复授权/,
|
||||
);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime rejects imprecise probe operators and probes before connection', async () => {
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
botId: 'bot_probe',
|
||||
appId: 'cli_probe',
|
||||
appSecret: 'secret',
|
||||
ownerOpenIds: ['*'],
|
||||
harness: { async ensureRunning() {} },
|
||||
state: { hasSeen: () => false },
|
||||
});
|
||||
await assert.rejects(
|
||||
runtime.beginCardActionProbe({ expectedOperatorOpenId: 'ou_owner' }),
|
||||
(error) => error?.code === 'card_action_probe_unavailable',
|
||||
);
|
||||
|
||||
const starting = runtime.start();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
await starting;
|
||||
await assert.rejects(
|
||||
runtime.beginCardActionProbe({ expectedOperatorOpenId: '*' }),
|
||||
/precise Feishu operator/,
|
||||
);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ function fixture({
|
|||
failResolveRefs = new Set(),
|
||||
failUnsetRefs = new Set(),
|
||||
runtimeStart,
|
||||
callbackProbe,
|
||||
verifyApp,
|
||||
credentialSet,
|
||||
deleteState,
|
||||
} = {}) {
|
||||
const configStore = new MemoryConfigStore(bots);
|
||||
|
|
@ -70,17 +73,20 @@ function fixture({
|
|||
registrationRuns.push({ options, resolve, reject });
|
||||
return promise;
|
||||
},
|
||||
verifyApp: async ({ appId }) => ({
|
||||
verifyApp: 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 set(ref, value) {
|
||||
if (credentialSet) await credentialSet({ ref, value, values });
|
||||
else values.set(ref, value);
|
||||
},
|
||||
async unset(ref) {
|
||||
unsetCalls.push(ref);
|
||||
if (failUnsetRefs.has(ref)) throw new Error('credential provider is read-only');
|
||||
|
|
@ -88,7 +94,7 @@ function fixture({
|
|||
},
|
||||
},
|
||||
configStore,
|
||||
createRuntime: async ({ botId, config, appSecret }) => {
|
||||
createRuntime: async ({ botId, config, appSecret, repair }) => {
|
||||
const status = {
|
||||
ready: false,
|
||||
feishuLongConnectionState: 'idle',
|
||||
|
|
@ -101,6 +107,8 @@ function fixture({
|
|||
starts: 0,
|
||||
stops: 0,
|
||||
sentTests: [],
|
||||
probes: [],
|
||||
repair,
|
||||
get status() { return structuredClone(status); },
|
||||
async start() {
|
||||
runtime.starts += 1;
|
||||
|
|
@ -118,6 +126,11 @@ function fixture({
|
|||
runtime.sentTests.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
async beginCardActionProbe(options) {
|
||||
runtime.probes.push(structuredClone(options));
|
||||
if (callbackProbe) return callbackProbe({ botId, runtime, options });
|
||||
return { verified: true };
|
||||
},
|
||||
};
|
||||
const history = runtimes.get(botId) ?? [];
|
||||
history.push(runtime);
|
||||
|
|
@ -131,6 +144,7 @@ function fixture({
|
|||
return id;
|
||||
},
|
||||
createRegistrationId: () => `reg_${++registrationSequence}`,
|
||||
callbackProbeTimeoutMs: 50,
|
||||
});
|
||||
return { controller, configStore, values, unsetCalls, registrationRuns, runtimes };
|
||||
}
|
||||
|
|
@ -148,6 +162,11 @@ async function completeScan(fx, result) {
|
|||
return fx.controller.registrationStatus(attemptId);
|
||||
}
|
||||
|
||||
function callbackRepairQrUrl(appId, domain = 'feishu') {
|
||||
const host = domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
|
||||
return `https://${host}/page/launcher?tp=sdk&clientID=${encodeURIComponent(appId)}&addons=encoded`;
|
||||
}
|
||||
|
||||
test('QR registration separates events from card callbacks', async () => {
|
||||
const fx = fixture({ createBotIds: ['bot_callbacks'] });
|
||||
const started = fx.controller.startRegistration();
|
||||
|
|
@ -165,6 +184,420 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair is deduplicated per bot, updates only its secret, and proves the callback', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({
|
||||
name: existing.botName,
|
||||
openId: existing.botOpenId,
|
||||
activated: existing.activated,
|
||||
}),
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||
|
||||
const started = fx.controller.startCallbackRepair(existing.id, {
|
||||
actorOpenId: existing.ownerOpenIds[0],
|
||||
chatId: 'oc_repair_chat',
|
||||
});
|
||||
const duplicate = fx.controller.startCallbackRepair(existing.id, {
|
||||
actorOpenId: existing.ownerOpenIds[0],
|
||||
chatId: 'oc_repair_chat',
|
||||
});
|
||||
const attemptId = started.registration.attempt;
|
||||
assert.equal(duplicate.registration.attempt, attemptId);
|
||||
assert.equal(started.registration.operation, 'callback_repair');
|
||||
assert.equal(started.registration.botId, existing.id);
|
||||
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
assert.equal(run.options.appId, existing.appId);
|
||||
assert.equal(run.options.domain, 'accounts.feishu.cn');
|
||||
assert.equal(Object.hasOwn(run.options, 'createOnly'), false);
|
||||
assert.equal(Object.hasOwn(run.options, 'appPreset'), false);
|
||||
assert.deepEqual(run.options.addons, {
|
||||
preset: false,
|
||||
callbacks: { items: ['card.action.trigger'] },
|
||||
});
|
||||
run.options.onQRCodeReady({
|
||||
url: callbackRepairQrUrl(existing.appId),
|
||||
expireIn: 60,
|
||||
});
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||
const result = fx.controller.registrationStatus(attemptId);
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(result.registration.operation, 'callback_repair');
|
||||
assert.equal(result.registration.botId, existing.id);
|
||||
assert.equal(result.registration.stage, 'verified');
|
||||
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(oldRuntime.stops, 1);
|
||||
assert.equal(history[1].appSecret, 'rotated-secret');
|
||||
assert.deepEqual(history[1].probes, [{
|
||||
expectedOperatorOpenId: existing.ownerOpenIds[0],
|
||||
timeoutMs: 50,
|
||||
chatId: 'oc_repair_chat',
|
||||
}]);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result),
|
||||
/rotated-secret|stable-secret|ownerOpenIds|secretRef/,
|
||||
);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair with an unchanged secret still refreshes the target runtime before probing', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'stable-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(oldRuntime.stops, 1);
|
||||
assert.deepEqual(oldRuntime.probes, []);
|
||||
assert.deepEqual(history[1].probes, [{
|
||||
expectedOperatorOpenId: existing.ownerOpenIds[0],
|
||||
timeoutMs: 50,
|
||||
}]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('close drains a repair runtime created after delayed credential verification', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
let releaseVerify;
|
||||
const verifyGate = new Promise((resolve) => { releaseVerify = resolve; });
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => {
|
||||
await verifyGate;
|
||||
return { openId: existing.botOpenId };
|
||||
},
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'saving');
|
||||
|
||||
const closing = fx.controller.close();
|
||||
await waitFor(() => oldRuntime.stops === 1);
|
||||
releaseVerify();
|
||||
await closing;
|
||||
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(history[1].starts, 1);
|
||||
assert.equal(history[1].stops, 1);
|
||||
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||
assert.equal(fx.controller.status().totals.connected, 0);
|
||||
});
|
||||
|
||||
test('close waits for a delayed callback probe before its final runtime drain', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
let releaseProbe;
|
||||
const probeGate = new Promise((resolve) => { releaseProbe = resolve; });
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
callbackProbe: async () => probeGate,
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
await waitFor(() => fx.runtimes.get(existing.id)?.at(-1).probes.length === 1);
|
||||
|
||||
let closeFinished = false;
|
||||
const closing = fx.controller.close().then(() => { closeFinished = true; });
|
||||
await flush();
|
||||
assert.equal(closeFinished, false);
|
||||
releaseProbe({ verified: true });
|
||||
await closing;
|
||||
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(history[1].stops, 1);
|
||||
assert.equal(closeFinished, true);
|
||||
assert.equal(fx.controller.status().totals.connected, 0);
|
||||
});
|
||||
|
||||
test('web callback repair accepts wildcard visibility but probes the precise SDK operator', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
existing.ownerOpenIds = ['*'];
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'stable-secret',
|
||||
user_info: { open_id: 'ou_sdk_operator', tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(oldRuntime.stops, 1);
|
||||
assert.deepEqual(history[1].probes, [{
|
||||
expectedOperatorOpenId: 'ou_sdk_operator',
|
||||
timeoutMs: 50,
|
||||
}]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('chat callback repair rejects SDK authorization by a different operator', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const runtime = fx.runtimes.get(existing.id)[0];
|
||||
const started = fx.controller.startCallbackRepair(existing.id, {
|
||||
actorOpenId: existing.ownerOpenIds[0],
|
||||
chatId: 'oc_owner_chat',
|
||||
});
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: 'ou_different_operator', tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||
const result = fx.controller.registrationStatus(attemptId);
|
||||
assert.equal(result.registration.error.code, 'repair_owner_mismatch');
|
||||
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||
assert.equal(runtime.stops, 0);
|
||||
assert.deepEqual(runtime.probes, []);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair rejects an app mismatch without changing local bot state', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
let verifyCalls = 0;
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => {
|
||||
verifyCalls += 1;
|
||||
return { openId: existing.botOpenId };
|
||||
},
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const runtime = fx.runtimes.get(existing.id)[0];
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: 'cli_wrong_app',
|
||||
client_secret: 'wrong-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||
const result = fx.controller.registrationStatus(attemptId);
|
||||
assert.equal(result.registration.error.code, 'repair_app_mismatch');
|
||||
assert.equal(verifyCalls, 0);
|
||||
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||
assert.equal(runtime.stops, 0);
|
||||
assert.deepEqual(runtime.probes, []);
|
||||
assert.doesNotMatch(JSON.stringify(result), /wrong-secret/);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback probe timeout keeps the verified rotated secret and ready runtime', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
callbackProbe: async () => {
|
||||
const error = new Error('probe timed out with sensitive diagnostics');
|
||||
error.code = 'card_action_probe_timeout';
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||
const result = fx.controller.registrationStatus(attemptId);
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(result.registration.error.code, 'card_action_probe_timeout');
|
||||
assert.equal(result.registration.stage, 'awaiting_callback');
|
||||
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(history.at(-1).appSecret, 'rotated-secret');
|
||||
assert.equal(result.bots[0].connected, true);
|
||||
assert.doesNotMatch(JSON.stringify(result), /sensitive diagnostics|rotated-secret/);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair restart failure keeps the remotely committed secret for reconnect', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
runtimeStart: async ({ runtime }) => {
|
||||
if (runtime.appSecret === 'rotated-secret') throw new Error('new secret handshake failed');
|
||||
},
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||
const result = fx.controller.registrationStatus(attemptId);
|
||||
assert.equal(result.registration.error.code, 'repair_connection_failed');
|
||||
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||
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).at(-1).appSecret, 'rotated-secret');
|
||||
assert.equal(result.bots[0].connected, false);
|
||||
assert.equal(result.bots[0].error.code, 'connection_failed');
|
||||
assert.doesNotMatch(JSON.stringify(result), /new secret handshake failed|rotated-secret/);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair leaves the existing runtime intact when the new secret cannot be stored', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||
credentialSet: async () => { throw new Error('credential provider is read-only'); },
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
const runtime = fx.runtimes.get(existing.id)[0];
|
||||
const started = fx.controller.startCallbackRepair(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const run = fx.registrationRuns.shift();
|
||||
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: existing.appId,
|
||||
client_secret: 'rotated-secret',
|
||||
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||
const result = fx.controller.registrationStatus(attemptId);
|
||||
assert.equal(result.registration.error.code, 'credential_update_failed');
|
||||
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||
assert.equal(runtime.stops, 0);
|
||||
assert.equal(result.bots[0].connected, true);
|
||||
assert.doesNotMatch(JSON.stringify(result), /credential provider is read-only|rotated-secret/);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('runtime repair capability is bot-bound and can cancel a pre-commit attempt', 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 alphaRepair = fx.runtimes.get(alpha.id)[0].repair;
|
||||
const betaRepair = fx.runtimes.get(beta.id)[0].repair;
|
||||
|
||||
const started = alphaRepair.start({
|
||||
actorOpenId: alpha.ownerOpenIds[0],
|
||||
chatId: 'oc_alpha',
|
||||
});
|
||||
const attemptId = started.registration.attempt;
|
||||
assert.equal(started.registration.botId, alpha.id);
|
||||
assert.equal(alphaRepair.status({ attemptId }).registration.attempt, attemptId);
|
||||
assert.equal(betaRepair.status({ attemptId }), null);
|
||||
const cancelled = await alphaRepair.cancel({ attemptId });
|
||||
assert.equal(cancelled.registration.state, 'cancelled');
|
||||
assert.deepEqual(fx.configStore.list(), [alpha, beta]);
|
||||
assert.equal(fx.values.get(alpha.secretRef), 'secret-a');
|
||||
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
|
||||
assert.equal(fx.runtimes.get(alpha.id).length, 1);
|
||||
assert.equal(fx.runtimes.get(beta.id).length, 1);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => {
|
||||
const fx = fixture({ createBotIds: ['bot_manual'] });
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,244 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
assert.doesNotMatch(JSON.stringify(attemptedSecret), /must-not-cross-browser-boundary/);
|
||||
});
|
||||
|
||||
test('callback repair begins for exactly one bot and returns only a safe official QR projection', async () => {
|
||||
const calls = [];
|
||||
const repair = status({
|
||||
schemaVersion: 2,
|
||||
phase: 'registering',
|
||||
configured: true,
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 'reg_repair',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
qrCodeUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=encoded&user_code=opaque',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
bots: [{
|
||||
botId: 'bot_target',
|
||||
connected: true,
|
||||
configured: true,
|
||||
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget' },
|
||||
connection: { ready: true, feishuLongConnectionState: 'connected', harnessReachable: true },
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => repair,
|
||||
registrationStatus: async () => repair,
|
||||
startRegistration: async () => status(),
|
||||
startCallbackRepair: async (botId) => { calls.push(botId); return repair; },
|
||||
cancelRegistration: async () => repair,
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginCallbackRepair,
|
||||
{ botId: 'bot_target' },
|
||||
signal(),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ['bot_target']);
|
||||
assert.equal(result.value.operation, 'callback_repair');
|
||||
assert.equal(result.value.botId, 'bot_target');
|
||||
assert.equal(
|
||||
result.value.verificationUrl,
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=encoded&user_code=opaque',
|
||||
);
|
||||
assert.match(result.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||
assert.doesNotMatch(JSON.stringify(result), /client_secret|appSecret/);
|
||||
|
||||
const restored = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
assert.equal(restored.value.provisioning.operation, 'callback_repair');
|
||||
assert.equal(restored.value.provisioning.botId, 'bot_target');
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ botId: '../target' },
|
||||
{ botId: 'bot_target', appSecret: 'must-not-leak' },
|
||||
]) {
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginCallbackRepair,
|
||||
payload,
|
||||
signal(),
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
assert.doesNotMatch(JSON.stringify(invalid), /must-not-leak|\.\.\/target/);
|
||||
}
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('status preserves a submitted callback repair attempt after its QR URL is discarded', async () => {
|
||||
const secret = 'must-never-cross-the-rpc-boundary';
|
||||
const saving = status({
|
||||
schemaVersion: 2,
|
||||
phase: 'connecting',
|
||||
configured: true,
|
||||
registration: {
|
||||
state: 'saving',
|
||||
attempt: 'reg_committed',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
},
|
||||
bots: [{
|
||||
botId: 'bot_target',
|
||||
configured: true,
|
||||
bot: { name: '目标机器人', domain: 'feishu', appSecret: secret },
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => saving,
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => saving,
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const restored = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
|
||||
assert.equal(restored.ok, true);
|
||||
assert.equal(restored.value.state, 'connecting');
|
||||
assert.deepEqual(
|
||||
{
|
||||
attemptId: restored.value.provisioning.attemptId,
|
||||
operation: restored.value.provisioning.operation,
|
||||
botId: restored.value.provisioning.botId,
|
||||
submitted: restored.value.provisioning.submitted,
|
||||
},
|
||||
{
|
||||
attemptId: 'reg_committed',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
submitted: true,
|
||||
},
|
||||
);
|
||||
assert.equal(restored.value.provisioning.verificationUrl, undefined);
|
||||
assert.equal(restored.value.provisioning.qrCodeDataUrl, undefined);
|
||||
assert.doesNotMatch(JSON.stringify(restored), new RegExp(secret));
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('callback repair failures cross RPC only as fixed safe public errors', async () => {
|
||||
const expected = new Map([
|
||||
['repair_app_mismatch', 'The authorized Feishu app does not match the selected bot.'],
|
||||
['repair_domain_mismatch', 'The authorized Feishu tenant does not match the selected bot.'],
|
||||
['repair_owner_mismatch', 'The authorizing Feishu account is not an owner of the selected bot.'],
|
||||
['repair_target_changed', 'The selected bot changed while repair was in progress. Start the repair again.'],
|
||||
['credential_update_failed', 'Unable to store the repaired Feishu credentials.'],
|
||||
['repair_connection_failed', 'The callback update was accepted, but the selected bot could not reconnect.'],
|
||||
['card_action_probe_send_failed', 'The callback update was accepted, but the verification card could not be sent.'],
|
||||
['card_action_probe_unavailable', 'The selected bot is not connected, so its card button cannot be verified.'],
|
||||
['card_action_probe_timeout', 'Feishu accepted the update, but the card button was not verified in time. Start the repair again and click the test button within two minutes.'],
|
||||
]);
|
||||
for (const [code, message] of expected) {
|
||||
const failed = status({
|
||||
phase: 'error',
|
||||
registration: {
|
||||
state: 'error',
|
||||
attempt: 'reg_failed',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
error: { code, message: 'secret=must-not-cross' },
|
||||
},
|
||||
});
|
||||
const controller = {
|
||||
status: async () => failed,
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => failed,
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||
assert.deepEqual(result.value.error, { code, message });
|
||||
assert.doesNotMatch(JSON.stringify(result), /must-not-cross/);
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('callback repair refuses placeholder, non-SDK, and non-official verification links', async () => {
|
||||
for (const qrCodeUrl of [
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D',
|
||||
'https://open.feishu.cn/page/launcher?tp=card&clientID=cli_target',
|
||||
'https://evil.example/device?tp=sdk&clientID=cli_target',
|
||||
'http://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||
'https://accounts.feishu.cn/device?tp=sdk&clientID=cli_target',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=x&createOnly=true',
|
||||
]) {
|
||||
const repair = status({
|
||||
phase: 'registering',
|
||||
configured: true,
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 'reg_unsafe',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
qrCodeUrl,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
const controller = {
|
||||
status: async () => repair,
|
||||
registrationStatus: async () => repair,
|
||||
startRegistration: async () => status(),
|
||||
startCallbackRepair: async () => repair,
|
||||
cancelRegistration: async () => repair,
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginCallbackRepair,
|
||||
{ botId: 'bot_target' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'internal');
|
||||
assert.equal(JSON.stringify(result).includes(qrCodeUrl), false);
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('callback repair cannot be cancelled after configuration enters saving', async () => {
|
||||
let cancels = 0;
|
||||
const saving = status({
|
||||
phase: 'connecting',
|
||||
configured: true,
|
||||
registration: {
|
||||
state: 'saving',
|
||||
attempt: 'reg_committed',
|
||||
operation: 'callback_repair',
|
||||
botId: 'bot_target',
|
||||
},
|
||||
});
|
||||
const controller = {
|
||||
status: async () => saving,
|
||||
registrationStatus: async () => saving,
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => { cancels += 1; return saving; },
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||
{ attemptId: 'reg_committed' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.status, 'connecting');
|
||||
assert.equal(result.value.operation, 'callback_repair');
|
||||
assert.equal(result.value.botId, 'bot_target');
|
||||
assert.equal(cancels, 1);
|
||||
|
||||
const polled = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.pollProvisioning,
|
||||
{ attemptId: 'reg_committed' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(polled.ok, true);
|
||||
assert.equal(polled.value.status, 'connecting');
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('connection.test does not restart an already healthy long connection', async () => {
|
||||
let reconnects = 0;
|
||||
const healthy = status({
|
||||
|
|
@ -739,6 +977,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
});
|
||||
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||
const repair = { start() {}, status() {}, cancel() {} };
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_alpha',
|
||||
config: {
|
||||
|
|
@ -749,8 +988,11 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
ownerOpenIds: ['ou_alpha'],
|
||||
},
|
||||
appSecret: 'alpha-secret',
|
||||
repair,
|
||||
});
|
||||
const alphaState = constructed.runtime.state;
|
||||
assert.equal(constructed.runtime.botId, 'bot_alpha');
|
||||
assert.equal(constructed.runtime.repair, repair);
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_beta',
|
||||
config: {
|
||||
|
|
|
|||
146
test/channels/feishu/repair-manager.test.mjs
Normal file
146
test/channels/feishu/repair-manager.test.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
CallbackRepairManager,
|
||||
assertCallbackRepairUrl,
|
||||
} from '../../../src/channels/feishu/repair-manager.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();
|
||||
}
|
||||
}
|
||||
|
||||
test('CallbackRepairManager targets one real app with callbacks only', async () => {
|
||||
let observed;
|
||||
let resolveRegistration;
|
||||
const accepted = [];
|
||||
const manager = new CallbackRepairManager({
|
||||
appId: 'cli_real_app',
|
||||
domain: 'feishu',
|
||||
registerApp(options) {
|
||||
observed = options;
|
||||
return new Promise((resolve) => { resolveRegistration = resolve; });
|
||||
},
|
||||
onCredentials: async (result) => { accepted.push(result); },
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await waitFor(() => observed !== undefined);
|
||||
assert.equal(observed.appId, 'cli_real_app');
|
||||
assert.equal(observed.domain, 'accounts.feishu.cn');
|
||||
assert.equal(Object.hasOwn(observed, 'createOnly'), false);
|
||||
assert.equal(Object.hasOwn(observed, 'appPreset'), false);
|
||||
assert.deepEqual(observed.addons, {
|
||||
preset: false,
|
||||
callbacks: { items: ['card.action.trigger'] },
|
||||
});
|
||||
assert.equal(Object.hasOwn(observed.addons, 'scopes'), false);
|
||||
assert.equal(Object.hasOwn(observed.addons, 'events'), false);
|
||||
|
||||
observed.onQRCodeReady({
|
||||
url: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=encoded',
|
||||
expireIn: 60,
|
||||
});
|
||||
assert.equal(manager.status().state, 'qr_ready');
|
||||
resolveRegistration({
|
||||
client_id: 'cli_real_app',
|
||||
client_secret: 'private-secret',
|
||||
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||
});
|
||||
await waitFor(() => manager.status().state === 'succeeded');
|
||||
assert.equal(accepted.length, 1);
|
||||
assert.equal(accepted[0].client_id, 'cli_real_app');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /private-secret/);
|
||||
});
|
||||
|
||||
test('CallbackRepairManager uses the Lark accounts domain', async () => {
|
||||
let observed;
|
||||
const manager = new CallbackRepairManager({
|
||||
appId: 'cli_lark_app',
|
||||
domain: 'lark',
|
||||
registerApp(options) {
|
||||
observed = options;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
});
|
||||
manager.start();
|
||||
await waitFor(() => observed !== undefined);
|
||||
assert.equal(observed.domain, 'accounts.larksuite.com');
|
||||
manager.cancel();
|
||||
});
|
||||
|
||||
test('callback repair accepts only the exact SDK URL origin and singleton repair params', () => {
|
||||
assert.equal(
|
||||
assertCallbackRepairUrl(
|
||||
'https://open.larksuite.com/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||
'cli_real_app',
|
||||
'lark',
|
||||
),
|
||||
'https://open.larksuite.com/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCallbackRepairUrl(
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D&addons=x',
|
||||
'cli_real_app',
|
||||
),
|
||||
/unsafe verification URL/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCallbackRepairUrl(
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x&createOnly=true',
|
||||
'cli_real_app',
|
||||
),
|
||||
/unsafe verification URL/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCallbackRepairUrl(
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app',
|
||||
'cli_real_app',
|
||||
),
|
||||
/unsafe verification URL/,
|
||||
);
|
||||
for (const unsafe of [
|
||||
'http://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||
'https://open.larksuite.com/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||
'https://open.feishu.cn:4430/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||
'https://user@open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||
'https://open.feishu.cn/page/launcher?clientID=cli_real_app&addons=x',
|
||||
'https://open.feishu.cn/page/launcher?tp=web&clientID=cli_real_app&addons=x',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&tp=sdk&clientID=cli_real_app&addons=x',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&clientID=cli_real_app&addons=x',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x&addons=y',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=%7B%7Baddons%7D%7D',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertCallbackRepairUrl(unsafe, 'cli_real_app'),
|
||||
/unsafe verification URL/,
|
||||
unsafe,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unsafe SDK URL becomes a safe terminal registration error', async () => {
|
||||
const manager = new CallbackRepairManager({
|
||||
appId: 'cli_real_app',
|
||||
registerApp(options) {
|
||||
options.onQRCodeReady({
|
||||
url: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D&addons=x',
|
||||
expireIn: 60,
|
||||
});
|
||||
return new Promise(() => {});
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
});
|
||||
manager.start();
|
||||
await waitFor(() => manager.status().state === 'error');
|
||||
assert.deepEqual(manager.status().error, {
|
||||
code: 'registration_failed',
|
||||
message: 'Unable to register the Feishu app.',
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue