mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 21:30:49 +08:00
feat(feishu): add group message permission authorization
This commit is contained in:
parent
7b892c02f5
commit
7bf12628ce
17 changed files with 1581 additions and 258 deletions
|
|
@ -16,6 +16,7 @@ import {
|
|||
|
||||
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.beginGroupMessagePermission, 'bot.group-message-permission.begin');
|
||||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||
|
|
@ -35,6 +36,7 @@ test('client normalizes multiple independent bots and derives authoritative tota
|
|||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: 'all',
|
||||
groupMessagePermissionGranted: true,
|
||||
bot: {
|
||||
name: '销售助手',
|
||||
appIdMasked: 'cli_aaaa••••1111',
|
||||
|
|
@ -61,7 +63,9 @@ test('client normalizes multiple independent bots and derives authoritative tota
|
|||
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal(snapshot.bots[0].groupResponseMode, 'all');
|
||||
assert.equal(snapshot.bots[0].groupMessagePermissionGranted, true);
|
||||
assert.equal(snapshot.bots[1].groupResponseMode, 'mention');
|
||||
assert.equal(snapshot.bots[1].groupMessagePermissionGranted, false);
|
||||
assert.equal(snapshot.bots[1].state, 'connecting');
|
||||
assert.equal(snapshot.bots[1].bot.domain, 'lark');
|
||||
assert.equal(snapshot.bots[1].error.message, '连接失败');
|
||||
|
|
@ -170,6 +174,31 @@ test('client restores a submitted callback repair without requiring an expired Q
|
|||
assert.equal(provisioning.qrCodeDataUrl, undefined);
|
||||
});
|
||||
|
||||
test('client preserves group-message permission identity across QR and poll projections', () => {
|
||||
const provisioning = normalizeProvisioning({
|
||||
attemptId: 'reg_group_permission',
|
||||
operation: 'group_message_permission',
|
||||
botId: 'bot_target',
|
||||
verificationUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=x',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
});
|
||||
assert.equal(provisioning.operation, 'group_message_permission');
|
||||
assert.equal(provisioning.botId, 'bot_target');
|
||||
|
||||
const poll = normalizePollResult({
|
||||
status: 'connecting',
|
||||
operation: 'group_message_permission',
|
||||
botId: 'bot_target',
|
||||
});
|
||||
assert.equal(poll.operation, 'group_message_permission');
|
||||
assert.equal(poll.botId, 'bot_target');
|
||||
assert.throws(() => normalizeProvisioning({
|
||||
attemptId: 'reg_broken_permission',
|
||||
operation: 'group_message_permission',
|
||||
verificationUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=x',
|
||||
}), /botId/);
|
||||
});
|
||||
|
||||
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||
connected: true,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ test('Feishu connection check requests and displays test-message feedback', asyn
|
|||
assert.match(markup, /aria-label="修复飞书测试机器人的卡片按钮"/);
|
||||
assert.match(markup, /<select[^>]*aria-label="群聊响应方式"/);
|
||||
assert.match(markup, /仅在 @机器人时响应(推荐)/);
|
||||
assert.match(markup, /响应所有群消息(需飞书敏感权限)/);
|
||||
assert.match(markup, /响应所有群消息/);
|
||||
assert.match(markup, /选择全部消息后会打开飞书官方授权流程/);
|
||||
});
|
||||
|
||||
test('Feishu bot card saves group response mode from a dropdown', async () => {
|
||||
|
|
@ -91,6 +92,221 @@ test('Feishu bot card saves group response mode from a dropdown', async () => {
|
|||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('Feishu bot card offers authorization recovery for all-message mode', () => {
|
||||
const baseConnection = {
|
||||
botId: 'bot-permission-recovery',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
groupResponseMode: 'all',
|
||||
bot: { name: '权限恢复机器人', appIdMasked: 'cli_reco••••very' },
|
||||
health: { summary: '长连接运行正常', lastCheckedAt: Date.now() },
|
||||
};
|
||||
const reauthorizeMarkup = renderToStaticMarkup(React.createElement(BotCard, {
|
||||
connection: { ...baseConnection, groupMessagePermissionGranted: true },
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(reauthorizeMarkup, /aria-label="重新授权群消息权限"/);
|
||||
assert.match(reauthorizeMarkup, />重新授权</);
|
||||
|
||||
const legacyMarkup = renderToStaticMarkup(React.createElement(BotCard, {
|
||||
connection: { ...baseConnection, groupMessagePermissionGranted: false },
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(legacyMarkup, /尚未确认“获取群组中所有消息”权限/);
|
||||
assert.match(legacyMarkup, />去授权</);
|
||||
});
|
||||
|
||||
test('selecting all group messages opens the official permission flow before saving mode', 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_before_permission',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: 'mention',
|
||||
groupMessagePermissionGranted: false,
|
||||
bot: { name: '前一个机器人', appIdMasked: 'cli_bef••••ore' },
|
||||
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||
}, {
|
||||
botId: 'bot_group_permission',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: 'mention',
|
||||
groupMessagePermissionGranted: false,
|
||||
bot: { name: '权限机器人', appIdMasked: 'cli_per••••sion' },
|
||||
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.beginGroupMessagePermission) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
attemptId: 'reg_group_permission',
|
||||
operation: 'group_message_permission',
|
||||
botId: 'bot_group_permission',
|
||||
verificationUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_permission&addons=encoded',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
pollIntervalMs: 800,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const targetCard = () => renderer.root.findByProps({ 'data-bot-id': 'bot_group_permission' });
|
||||
const select = targetCard().findByProps({ 'aria-label': '群聊响应方式' });
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'all' } });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
assert.ok(calls.some(({ endpoint, payload }) => (
|
||||
endpoint === FEISHU_ENDPOINTS.beginGroupMessagePermission
|
||||
&& payload.botId === 'bot_group_permission'
|
||||
)));
|
||||
assert.equal(calls.some(({ endpoint }) => endpoint === FEISHU_ENDPOINTS.setGroupResponseMode), false);
|
||||
const permissionPanel = targetCard().findByProps({
|
||||
'data-provision-for': 'bot_group_permission',
|
||||
});
|
||||
assert.equal(permissionPanel.findByType('a').props.href,
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_permission&addons=encoded');
|
||||
assert.match(textOf(permissionPanel), /只增量开通“获取群组中所有消息”权限/);
|
||||
assert.equal(renderer.root.findByProps({ 'data-bot-id': 'bot_before_permission' })
|
||||
.findAllByProps({ 'data-provision-for': 'bot_group_permission' }).length, 0);
|
||||
assert.equal(targetCard().findByProps({ 'aria-label': '群聊响应方式' }).props.value, 'mention');
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('reauthorizing all-message mode starts the same bot-scoped permission flow', 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_reauthorize',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: 'all',
|
||||
groupMessagePermissionGranted: true,
|
||||
bot: { name: '重新授权机器人', appIdMasked: 'cli_reau••••thorize' },
|
||||
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.beginGroupMessagePermission) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
attemptId: 'reg_reauthorize',
|
||||
operation: 'group_message_permission',
|
||||
botId: 'bot_reauthorize',
|
||||
verificationUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_reauthorize&addons=encoded',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
pollIntervalMs: 800,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const targetCard = () => renderer.root.findByProps({ 'data-bot-id': 'bot_reauthorize' });
|
||||
await act(async () => {
|
||||
targetCard().findByProps({ 'aria-label': '重新授权群消息权限' }).props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
assert.ok(calls.some(({ endpoint, payload }) => (
|
||||
endpoint === FEISHU_ENDPOINTS.beginGroupMessagePermission
|
||||
&& payload.botId === 'bot_reauthorize'
|
||||
)));
|
||||
assert.equal(calls.some(({ endpoint }) => endpoint === FEISHU_ENDPOINTS.setGroupResponseMode), false);
|
||||
assert.match(textOf(targetCard().findByProps({ 'data-provision-for': 'bot_reauthorize' })),
|
||||
/正在为「重新授权机器人」开通群消息权限/);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
FEISHU_GROUP_MESSAGE_SCOPE,
|
||||
GroupMessagePermissionManager,
|
||||
assertGroupMessagePermissionUrl,
|
||||
} from '../../../src/channels/feishu/group-message-permission-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('GroupMessagePermissionManager updates one real app with only im:message.group_msg', async () => {
|
||||
let observed;
|
||||
let resolveRegistration;
|
||||
const accepted = [];
|
||||
const manager = new GroupMessagePermissionManager({
|
||||
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,
|
||||
scopes: { tenant: [FEISHU_GROUP_MESSAGE_SCOPE] },
|
||||
});
|
||||
assert.equal(Object.hasOwn(observed.addons, 'events'), false);
|
||||
assert.equal(Object.hasOwn(observed.addons, 'callbacks'), 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.doesNotMatch(JSON.stringify(manager.status()), /private-secret/);
|
||||
});
|
||||
|
||||
test('group-message permission URLs stay on the exact official SDK launcher', () => {
|
||||
assert.equal(
|
||||
assertGroupMessagePermissionUrl(
|
||||
'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',
|
||||
);
|
||||
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/page/launcher?tp=sdk&clientID=other_app&addons=x',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x&createOnly=true',
|
||||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertGroupMessagePermissionUrl(unsafe, 'cli_real_app'),
|
||||
/unsafe verification URL/,
|
||||
unsafe,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -191,6 +191,7 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
|
||||
test('group response mode defaults to mention and updates the live runtime without reconnecting', async () => {
|
||||
const existing = bot('bot_response_mode', 'response_mode');
|
||||
existing.groupMessagePermissionGranted = true;
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
|
|
@ -198,6 +199,7 @@ test('group response mode defaults to mention and updates the live runtime witho
|
|||
await fx.controller.initialize();
|
||||
|
||||
assert.equal(fx.controller.status().bots[0].groupResponseMode, 'mention');
|
||||
assert.equal(fx.controller.status().bots[0].groupMessagePermissionGranted, true);
|
||||
const runtime = fx.runtimes.get(existing.id)[0];
|
||||
const updated = await fx.controller.updateGroupResponseMode(existing.id, 'all');
|
||||
|
||||
|
|
@ -212,6 +214,96 @@ test('group response mode defaults to mention and updates the live runtime witho
|
|||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('all-message mode requires authorization before direct updates', async () => {
|
||||
const existing = bot('bot_response_permission_required', 'response_permission_required');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
|
||||
await assert.rejects(
|
||||
fx.controller.updateGroupResponseMode(existing.id, 'all'),
|
||||
(error) => error?.code === 'group_message_permission_required',
|
||||
);
|
||||
assert.equal(fx.configStore.getBot(existing.id).groupResponseMode, undefined);
|
||||
assert.equal(fx.controller.status().bots[0].groupResponseMode, 'mention');
|
||||
assert.equal(fx.controller.status().bots[0].groupMessagePermissionGranted, false);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('group-message authorization grants only its scope, enables all mode, and restarts one bot', async () => {
|
||||
const existing = bot('bot_group_permission', 'group_permission');
|
||||
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.startGroupMessagePermission(existing.id);
|
||||
const duplicate = fx.controller.startGroupMessagePermission(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
assert.equal(duplicate.registration.attempt, attemptId);
|
||||
assert.equal(started.registration.operation, 'group_message_permission');
|
||||
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(Object.hasOwn(run.options, 'createOnly'), false);
|
||||
assert.deepEqual(run.options.addons, {
|
||||
preset: false,
|
||||
scopes: { tenant: ['im:message.group_msg'] },
|
||||
});
|
||||
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: existing.domain },
|
||||
});
|
||||
|
||||
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||
const saved = fx.configStore.getBot(existing.id);
|
||||
assert.equal(saved.groupMessagePermissionGranted, true);
|
||||
assert.equal(saved.groupResponseMode, 'all');
|
||||
assert.equal(oldRuntime.stops, 1);
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 2);
|
||||
assert.equal(fx.runtimes.get(existing.id)[1].config.groupResponseMode, 'all');
|
||||
const status = fx.controller.registrationStatus(attemptId);
|
||||
assert.equal(status.bots[0].groupMessagePermissionGranted, true);
|
||||
assert.equal(status.bots[0].groupResponseMode, 'all');
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('cancelling group-message authorization before confirmation preserves mention mode', async () => {
|
||||
const existing = bot('bot_group_permission_cancel', 'group_permission_cancel');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
|
||||
const started = fx.controller.startGroupMessagePermission(existing.id);
|
||||
const attemptId = started.registration.attempt;
|
||||
await waitFor(() => fx.registrationRuns.length === 1);
|
||||
const cancelled = await fx.controller.cancelRegistration(attemptId);
|
||||
assert.equal(cancelled.registration.state, 'cancelled');
|
||||
const saved = fx.configStore.getBot(existing.id);
|
||||
assert.equal(saved.groupMessagePermissionGranted, undefined);
|
||||
assert.equal(saved.groupResponseMode, undefined);
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -24,9 +24,16 @@ test('PluginConfigStore persists non-secret onboarding facts', async () => {
|
|||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().appId, 'cli_test');
|
||||
assert.equal(store.get().groupResponseMode, 'mention');
|
||||
assert.equal(store.get().groupMessagePermissionGranted, false);
|
||||
|
||||
await store.save({ ...store.get(), groupResponseMode: 'all' });
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().groupResponseMode, 'all');
|
||||
await store.save({
|
||||
...store.get(),
|
||||
groupResponseMode: 'all',
|
||||
groupMessagePermissionGranted: true,
|
||||
});
|
||||
const reloaded = (await new PluginConfigStore(path).load()).get();
|
||||
assert.equal(reloaded.groupResponseMode, 'all');
|
||||
assert.equal(reloaded.groupMessagePermissionGranted, true);
|
||||
|
||||
await store.clear();
|
||||
assert.equal(store.get(), null);
|
||||
|
|
@ -71,6 +78,7 @@ test('PluginConfigStore migrates a v1 bot atomically without moving its credenti
|
|||
assert.equal(store.get().ownerOpenId, 'ou_legacy');
|
||||
assert.equal(store.list()[0].appId, 'cli_legacy');
|
||||
assert.equal(store.list()[0].groupResponseMode, 'mention');
|
||||
assert.equal(store.list()[0].groupMessagePermissionGranted, false);
|
||||
});
|
||||
|
||||
test('PluginConfigStore rejects an invalid or duplicate v2 document without dropping entries', async () => {
|
||||
|
|
|
|||
|
|
@ -403,6 +403,78 @@ test('callback repair begins for exactly one bot and returns only a safe officia
|
|||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('group-message permission begins for one existing bot and returns a safe official QR projection', async () => {
|
||||
const calls = [];
|
||||
const permission = status({
|
||||
schemaVersion: 2,
|
||||
phase: 'registering',
|
||||
configured: true,
|
||||
registration: {
|
||||
state: 'qr_ready',
|
||||
attempt: 'reg_group_permission',
|
||||
operation: 'group_message_permission',
|
||||
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,
|
||||
groupResponseMode: 'mention',
|
||||
groupMessagePermissionGranted: false,
|
||||
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget', domain: 'feishu' },
|
||||
connection: { ready: true, feishuLongConnectionState: 'connected', harnessReachable: true },
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => permission,
|
||||
registrationStatus: async () => permission,
|
||||
startRegistration: async () => status(),
|
||||
startGroupMessagePermission: async (botId) => { calls.push(botId); return permission; },
|
||||
cancelRegistration: async () => permission,
|
||||
disconnect: async () => status(),
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const result = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginGroupMessagePermission,
|
||||
{ botId: 'bot_target' },
|
||||
signal(),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ['bot_target']);
|
||||
assert.equal(result.value.operation, 'group_message_permission');
|
||||
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, 'group_message_permission');
|
||||
assert.equal(restored.value.provisioning.botId, 'bot_target');
|
||||
assert.equal(restored.value.bots[0].groupMessagePermissionGranted, false);
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ botId: '../target' },
|
||||
{ botId: 'bot_target', appSecret: 'must-not-leak' },
|
||||
]) {
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginGroupMessagePermission,
|
||||
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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue