fix(feishu): add one-click card callback repair

This commit is contained in:
xmanrui 2026-08-21 13:43:02 +08:00
parent 0e62c69382
commit c1a3b0cf97
24 changed files with 3601 additions and 248 deletions

View file

@ -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, /修复完成/);
});

View file

@ -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,

View file

@ -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) => {

View 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/);
});

View file

@ -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();
});

View file

@ -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'] });

View file

@ -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: {

View 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.',
});
});