mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
fix(feishu): complete permissions repair flow
This commit is contained in:
parent
1a72e5491f
commit
10ca7eec2c
22 changed files with 574 additions and 392 deletions
|
|
@ -980,7 +980,10 @@ test('bridge tells users to grant im:message:readonly when Feishu rejects image
|
|||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /im:message:readonly/);
|
||||
assert.match(sent[0], /\/repair/);
|
||||
assert.match(sent[0], /发布新版本/);
|
||||
assert.match(sent[0], /插件页面/);
|
||||
assert.match(sent[0], /补全权限/);
|
||||
assert.doesNotMatch(sent[0], /99991672|HTTP 400|secret-shaped|private\/path/);
|
||||
});
|
||||
|
||||
|
|
@ -3682,15 +3685,20 @@ function repairCapability({
|
|||
return {
|
||||
calls,
|
||||
capability: {
|
||||
async start(args) { calls.start.push(args); return startStatus; },
|
||||
async start(args) {
|
||||
calls.start.push(args);
|
||||
return typeof startStatus === 'function'
|
||||
? startStatus(calls.start.length, args)
|
||||
: startStatus;
|
||||
},
|
||||
async status(args) {
|
||||
calls.status.push(args);
|
||||
return typeof status === 'function' ? status(calls.status.length) : status;
|
||||
return typeof status === 'function' ? status(calls.status.length, args) : status;
|
||||
},
|
||||
async cancel(args) {
|
||||
calls.cancel.push(args);
|
||||
return typeof cancelStatus === 'function'
|
||||
? cancelStatus(calls.cancel.length)
|
||||
? cancelStatus(calls.cancel.length, args)
|
||||
: cancelStatus;
|
||||
},
|
||||
},
|
||||
|
|
@ -3699,7 +3707,6 @@ function repairCapability({
|
|||
|
||||
function repairBridge({
|
||||
allowedSenderOpenIds = new Set(['ou_owner']),
|
||||
repairOwnerOpenIds,
|
||||
capability,
|
||||
client,
|
||||
sent = [],
|
||||
|
|
@ -3721,7 +3728,6 @@ function repairBridge({
|
|||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds,
|
||||
repairOwnerOpenIds,
|
||||
botId: REPAIR_BOT_ID,
|
||||
appId: REPAIR_APP_ID,
|
||||
repair: capability,
|
||||
|
|
@ -3747,10 +3753,64 @@ test('/repair sends a validated ordinary SDK link without prompting Harness', as
|
|||
assert.equal(fx.asks, 0);
|
||||
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||||
assert.match(message, /card\.action\.trigger/);
|
||||
assert.match(message, /im:message:readonly/);
|
||||
assert.match(message, /im:resource/);
|
||||
assert.match(message, new RegExp(REPAIR_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(message, /\/repair qr/);
|
||||
});
|
||||
|
||||
test('repeating bare /repair replaces a still-waiting one-time link', async () => {
|
||||
const oldUrl = `${REPAIR_URL}&user_code=old`;
|
||||
const freshUrl = `${REPAIR_URL}&user_code=fresh`;
|
||||
const repair = repairCapability({
|
||||
startStatus: (count) => repairStatus('qr_ready', {
|
||||
attempt: `repair_attempt_${count}`,
|
||||
qrCodeUrl: count === 1 ? oldUrl : freshUrl,
|
||||
}),
|
||||
status: (_count, args) => repairStatus('qr_ready', {
|
||||
attempt: args.attemptId,
|
||||
qrCodeUrl: args.attemptId === 'repair_attempt_1' ? oldUrl : freshUrl,
|
||||
}),
|
||||
cancelStatus: (_count, args) => repairStatus('cancelled', {
|
||||
attempt: args.attemptId,
|
||||
qrCodeUrl: undefined,
|
||||
}),
|
||||
});
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
||||
await fx.bridge.accept(event('repair-retry-first', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
await fx.bridge.accept(event('repair-retry-second', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
|
||||
assert.equal(repair.calls.start.length, 2);
|
||||
assert.equal(repair.calls.cancel.length, 1);
|
||||
assert.equal(repair.calls.cancel[0].attemptId, 'repair_attempt_1');
|
||||
const reply = JSON.parse(fx.sent.at(-1).content).text;
|
||||
assert.match(reply, /旧授权链接已作废/);
|
||||
assert.match(reply, /user_code=fresh/);
|
||||
assert.doesNotMatch(reply, /user_code=old/);
|
||||
assert.match(reply, /获取单聊、群组消息/);
|
||||
assert.match(reply, /im:resource/);
|
||||
assert.match(reply, /只会显示当前缺少的项/);
|
||||
});
|
||||
|
||||
test('repeating bare /repair never duplicates an update already being saved', async () => {
|
||||
const repair = repairCapability({
|
||||
status: repairStatus('saving', { qrCodeUrl: undefined }),
|
||||
});
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
||||
await fx.bridge.accept(event('repair-saving-first', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
await fx.bridge.accept(event('repair-saving-second', '/repair', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
|
||||
assert.equal(repair.calls.start.length, 1);
|
||||
assert.equal(repair.calls.cancel.length, 0);
|
||||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /正在等待专用测试按钮/);
|
||||
});
|
||||
|
||||
test('/repair status after a runtime restart never starts a duplicate authorization', async () => {
|
||||
const repair = repairCapability();
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
|
@ -3768,14 +3828,14 @@ test('/repair status after a runtime restart never starts a duplicate authorizat
|
|||
assert.match(message, /不会启动新的授权/);
|
||||
});
|
||||
|
||||
test('menu repair entry is number-only and reply 5 starts the same repair flow', async () => {
|
||||
test('menu permission-completion entry is number-only and reply 5 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), /\*\*5\*\*🔧修复/);
|
||||
assert.match(JSON.stringify(menu), /\*\*5\*\*🔧补全权限/);
|
||||
assert.equal(buttonsFromCard(menu).some((button) => callbackAction(button) === 'repair'), false);
|
||||
|
||||
await fx.bridge.accept(event('repair-menu-five', '5', { senderOpenId: 'ou_owner' }));
|
||||
|
|
@ -3783,9 +3843,11 @@ test('menu repair entry is number-only and reply 5 starts the same repair flow',
|
|||
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/);
|
||||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /im:message:readonly/);
|
||||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /im:resource/);
|
||||
});
|
||||
|
||||
test('chat repair requires a private chat and an exact owner; wildcard never authorizes it', async () => {
|
||||
test('chat repair follows the channel access policy without a separate administrator role', async () => {
|
||||
const wildcardRepair = repairCapability();
|
||||
const wildcard = repairBridge({
|
||||
allowedSenderOpenIds: new Set(['*']),
|
||||
|
|
@ -3793,21 +3855,8 @@ test('chat repair requires a private chat and an exact owner; wildcard never aut
|
|||
});
|
||||
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);
|
||||
assert.equal(wildcardRepair.calls.start.length, 1);
|
||||
assert.equal(wildcardRepair.calls.start[0].actorOpenId, 'ou_anyone');
|
||||
|
||||
const groupRepair = repairCapability();
|
||||
const group = repairBridge({ capability: groupRepair.capability });
|
||||
|
|
@ -3821,7 +3870,7 @@ test('chat repair requires a private chat and an exact owner; wildcard never aut
|
|||
assert.match(JSON.parse(group.sent.at(-1).content).text, /请私聊机器人/);
|
||||
});
|
||||
|
||||
test('/repair qr, status, verify and cancel stay scoped to the initiating owner', async () => {
|
||||
test('/repair qr, status, verify and cancel stay scoped to the initiating user', async () => {
|
||||
const sent = [];
|
||||
let sequence = 0;
|
||||
const client = {
|
||||
|
|
@ -3851,6 +3900,8 @@ test('/repair qr, status, verify and cancel stay scoped to the initiating owner'
|
|||
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();
|
||||
assert.equal(repair.calls.start.length, 1);
|
||||
assert.equal(repair.calls.cancel.length, 0);
|
||||
const textMessages = sent
|
||||
.filter((request) => request.data.msg_type === 'text')
|
||||
.map((request) => JSON.parse(request.data.content).text);
|
||||
|
|
|
|||
|
|
@ -60,10 +60,19 @@ test('Feishu connection check requests and displays test-message feedback', asyn
|
|||
assert.match(footerChildren[0].props.className, /\bbxf-botActions\b/);
|
||||
assert.equal(footerChildren[1].props.role, 'status');
|
||||
assert.match(footerChildren[1].props.className, /\bdim-cardFeedback\b/);
|
||||
const repairButton = renderer.root.findByProps({
|
||||
'aria-label': '为飞书测试机器人补全权限与回调',
|
||||
});
|
||||
const repairTooltip = renderer.root.findByProps({ className: 'bxf-repairTooltip' });
|
||||
assert.equal(repairTooltip.props.role, 'tooltip');
|
||||
assert.equal(repairButton.props['aria-describedby'], repairTooltip.props.id);
|
||||
assert.match(textOf(repairTooltip), /card\.action\.trigger/);
|
||||
assert.match(textOf(repairTooltip), /im:message:readonly/);
|
||||
assert.match(textOf(repairTooltip), /im:resource/);
|
||||
await act(async () => renderer.unmount());
|
||||
|
||||
assert.match(markup, /修复卡片按钮/);
|
||||
assert.match(markup, /aria-label="修复飞书测试机器人的卡片按钮"/);
|
||||
assert.match(markup, /补全权限/);
|
||||
assert.match(markup, /aria-label="为飞书测试机器人补全权限与回调"/);
|
||||
assert.match(markup, /<select[^>]*aria-label="群聊响应方式"/);
|
||||
assert.match(markup, /仅在 @机器人时响应(推荐)/);
|
||||
assert.match(markup, /响应所有群消息/);
|
||||
|
|
@ -416,7 +425,7 @@ test('Feishu callback repair keeps a Host-submitted attempt when a stale QR canc
|
|||
const card = renderer.root.findByProps({ 'data-bot-id': 'bot_target' });
|
||||
await act(async () => {
|
||||
card.findAllByType('button')
|
||||
.find((button) => textOf(button) === '修复卡片按钮').props.onClick();
|
||||
.find((button) => textOf(button) === '补全权限').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
|
|
@ -428,9 +437,13 @@ test('Feishu callback repair keeps a Host-submitted attempt when a stale QR canc
|
|||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||
);
|
||||
assert.match(textOf(renderer.toJSON()), /不会创建新应用/);
|
||||
assert.match(textOf(renderer.toJSON()), /im:message:readonly/);
|
||||
assert.match(textOf(renderer.toJSON()), /im:resource/);
|
||||
assert.match(textOf(renderer.toJSON()), /获取单聊、群组消息/);
|
||||
assert.match(textOf(renderer.toJSON()), /当前缺少/);
|
||||
|
||||
const staleCancel = renderer.root.findAllByType('button')
|
||||
.find((button) => textOf(button) === '取消修复');
|
||||
.find((button) => textOf(button) === '取消补全');
|
||||
assert.ok(staleCancel);
|
||||
await act(async () => {
|
||||
staleCancel.props.onClick();
|
||||
|
|
@ -440,7 +453,7 @@ test('Feishu callback repair keeps a Host-submitted attempt when a stale QR canc
|
|||
&& payload.attemptId === 'reg_repair'));
|
||||
assert.match(textOf(renderer.toJSON()), /此阶段无法取消/);
|
||||
assert.equal(renderer.root.findAllByType('button').some(
|
||||
(button) => textOf(button) === '取消修复',
|
||||
(button) => textOf(button) === '取消补全',
|
||||
), false);
|
||||
assert.ok(timeouts.size > 0, 'submitted repair keeps polling after the refused cancel');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
|
|
@ -526,7 +539,7 @@ test('Feishu callback repair recovers when a Host restart forgets the browser at
|
|||
});
|
||||
const repairButton = () => renderer.root.findByProps({ 'data-bot-id': 'bot_target' })
|
||||
.findAllByType('button')
|
||||
.find((button) => textOf(button) === '修复卡片按钮');
|
||||
.find((button) => textOf(button) === '补全权限');
|
||||
|
||||
await act(async () => {
|
||||
repairButton().props.onClick();
|
||||
|
|
@ -542,7 +555,7 @@ test('Feishu callback repair recovers when a Host restart forgets the browser at
|
|||
|
||||
await act(async () => {
|
||||
renderer.root.findAllByType('button')
|
||||
.find((button) => textOf(button) === '取消修复').props.onClick();
|
||||
.find((button) => textOf(button) === '取消补全').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.match(textOf(renderer.toJSON()), /The provisioning attempt is no longer active/);
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ function forms(value, result = []) {
|
|||
return result;
|
||||
}
|
||||
|
||||
test('menu exposes the increased command set and keeps repair number-only', () => {
|
||||
test('menu exposes the increased command set and keeps permission completion number-only', () => {
|
||||
const card = JSON.parse(menuCard());
|
||||
assert.match(JSON.stringify(card), /\*\*5\*\*\S*修复/);
|
||||
assert.match(JSON.stringify(card), /\*\*5\*\*\S*补全权限/);
|
||||
const actions = buttons(card).flatMap((button) => (
|
||||
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
|
||||
));
|
||||
|
|
@ -60,7 +60,7 @@ test('menu exposes the increased command set and keeps repair number-only', () =
|
|||
'presets', 'models', 'new', 'sessions', 'workspaces',
|
||||
'stop', 'compact', 'archive_toggle', 'status', 'help',
|
||||
]);
|
||||
// 修复不占位按钮:仅通过数字兜底「5🔧」触发(见 bridge)
|
||||
// 补全权限不占位按钮:仅通过数字兜底「5🔧」触发(见 bridge)
|
||||
assert.equal(actions.includes('repair'), false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ test('Feishu image loading maps the missing message scope to an actionable error
|
|||
await assert.rejects(message.images[0].load({ maxBytes: 1024 }), (error) => {
|
||||
assert.equal(error.code, 'feishu-image-permission-required');
|
||||
assert.match(error.userMessage, /im:message:readonly/);
|
||||
assert.match(error.userMessage, /\/repair/);
|
||||
assert.match(error.userMessage, /插件页面/);
|
||||
assert.match(error.userMessage, /补全权限/);
|
||||
assert.match(error.userMessage, /发布新版本/);
|
||||
assert.equal(error.cause, providerError);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ test('callback repair is deduplicated per bot, updates only its secret, and prov
|
|||
assert.equal(Object.hasOwn(run.options, 'appPreset'), false);
|
||||
assert.deepEqual(run.options.addons, {
|
||||
preset: false,
|
||||
scopes: { tenant: ['im:message:readonly', 'im:resource'] },
|
||||
callbacks: { items: ['card.action.trigger'] },
|
||||
});
|
||||
run.options.onQRCodeReady({
|
||||
|
|
@ -517,7 +518,7 @@ test('web callback repair accepts wildcard visibility but probes the precise SDK
|
|||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('chat callback repair rejects SDK authorization by a different operator', async () => {
|
||||
test('chat callback repair has no separate administrator role', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
|
|
@ -540,13 +541,18 @@ test('chat callback repair rejects SDK authorization by a different operator', a
|
|||
user_info: { open_id: 'ou_different_operator', tenant_brand: 'feishu' },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||
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, []);
|
||||
assert.equal(result.registration.stage, 'verified');
|
||||
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||
const history = fx.runtimes.get(existing.id);
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(runtime.stops, 1);
|
||||
assert.deepEqual(history[1].probes, [{
|
||||
expectedOperatorOpenId: existing.ownerOpenIds[0],
|
||||
timeoutMs: 50,
|
||||
chatId: 'oc_owner_chat',
|
||||
}]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
import {
|
||||
CallbackRepairManager,
|
||||
FEISHU_MESSAGE_READ_SCOPE,
|
||||
FEISHU_RESOURCE_SCOPE,
|
||||
assertCallbackRepairUrl,
|
||||
} from '../../../src/channels/feishu/repair-manager.mjs';
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ async function waitFor(predicate, timeoutMs = 1000) {
|
|||
}
|
||||
}
|
||||
|
||||
test('CallbackRepairManager targets one real app with callbacks only', async () => {
|
||||
test('CallbackRepairManager targets one real app with only the callback and media scopes', async () => {
|
||||
let observed;
|
||||
let resolveRegistration;
|
||||
const accepted = [];
|
||||
|
|
@ -37,9 +39,9 @@ test('CallbackRepairManager targets one real app with callbacks only', async ()
|
|||
assert.equal(Object.hasOwn(observed, 'appPreset'), false);
|
||||
assert.deepEqual(observed.addons, {
|
||||
preset: false,
|
||||
scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
|
||||
callbacks: { items: ['card.action.trigger'] },
|
||||
});
|
||||
assert.equal(Object.hasOwn(observed.addons, 'scopes'), false);
|
||||
assert.equal(Object.hasOwn(observed.addons, 'events'), false);
|
||||
|
||||
observed.onQRCodeReady({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue