mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 11:43:17 +08:00
Add manual IM bot credential access
This commit is contained in:
parent
d5d2c18ce0
commit
7123fce47a
42 changed files with 2458 additions and 889 deletions
|
|
@ -19,6 +19,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,6 +148,30 @@ test('successful QR poll stores secret then config then starts runtime without p
|
|||
await controller.close();
|
||||
});
|
||||
|
||||
test('manual DingTalk Client ID and Client Secret binding stores credentials off the public plane', async () => {
|
||||
const events = [];
|
||||
const credentials = credentialsFixture(events);
|
||||
const configs = configFixture([], events);
|
||||
const runtimes = runtimeFactory({ events });
|
||||
const controller = new DingtalkController({
|
||||
deviceAuth: successfulDeviceAuth(),
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
clock: () => 1_000,
|
||||
});
|
||||
|
||||
const status = await controller.bindCredentials({
|
||||
clientId: 'manual-ding-client',
|
||||
clientSecret: 'manual-ding-secret',
|
||||
});
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal([...credentials.values.values()][0], 'manual-ding-secret');
|
||||
assert.equal(runtimes.runtimes[0].clientSecret, 'manual-ding-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-ding-client|manual-ding-secret|secretRef/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('scanning the same client ID is idempotent and replaces its one runtime', async () => {
|
||||
const events = [];
|
||||
const credentials = credentialsFixture(events);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function controller() {
|
|||
startProvisioning() {},
|
||||
registrationStatus() {},
|
||||
cancelProvisioning() {},
|
||||
bindCredentials() {},
|
||||
reconnectBot() {},
|
||||
deleteBot() {},
|
||||
approveSender() {},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function controller(overrides = {}) {
|
|||
}),
|
||||
registrationStatus: async () => ({ attemptId: 'attempt_1', status: 'pending' }),
|
||||
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => ({ bots: [] }),
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
approveSender: async () => ({ bots: [] }),
|
||||
|
|
@ -76,6 +77,24 @@ test('RPC validates mutating requests before invoking the controller', async ()
|
|||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('credential RPC accepts Client ID fields while keeping Client Secret host-only', async () => {
|
||||
let received;
|
||||
const handler = createDingtalkRpcHandler(controller({
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], clientSecret: payload.clientSecret };
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await handler(DINGTALK_ENDPOINTS.bindCredentials, {
|
||||
clientId: 'manual-client', clientSecret: 'manual-secret',
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(received, { clientId: 'manual-client', clientSecret: 'manual-secret' });
|
||||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|clientSecret/);
|
||||
assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false);
|
||||
});
|
||||
|
||||
test('RPC is registered for loopback clients only', () => {
|
||||
const registrations = [];
|
||||
const dispose = () => {};
|
||||
|
|
|
|||
|
|
@ -47,4 +47,5 @@ test('isAllowedSender enforces an open-id allowlist', () => {
|
|||
assert.equal(isAllowedSender(event, new Set()), false);
|
||||
assert.equal(isAllowedSender(event, new Set(['ou_allowed'])), true);
|
||||
assert.equal(isAllowedSender(event, new Set(['ou_other'])), false);
|
||||
assert.equal(isAllowedSender(event, new Set(['*'])), true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -143,6 +143,23 @@ async function completeScan(fx, result) {
|
|||
return fx.controller.registrationStatus(attemptId);
|
||||
}
|
||||
|
||||
test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => {
|
||||
const fx = fixture({ createBotIds: ['bot_manual'] });
|
||||
|
||||
const status = await fx.controller.bindCredentials({
|
||||
appId: 'cli_manual',
|
||||
appSecret: 'manual-private-secret',
|
||||
});
|
||||
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(fx.configStore.bots.length, 1);
|
||||
assert.deepEqual(fx.configStore.bots[0].ownerOpenIds, ['*']);
|
||||
assert.equal(fx.values.get(fx.configStore.bots[0].secretRef), 'manual-private-secret');
|
||||
assert.equal(fx.runtimes.get('bot_manual')[0].appSecret, 'manual-private-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-private-secret|ownerOpenIds|secretRef/);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('initialization isolates failures and starts every bot with available credentials', async () => {
|
||||
const missing = bot('bot_missing', 'missing');
|
||||
const healthy = bot('bot_healthy', 'healthy');
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
return current;
|
||||
},
|
||||
reconnect: async () => { calls.push('test'); return current; },
|
||||
bindCredentials: async ({ appId, appSecret }) => {
|
||||
calls.push(`bind:${appId}:${appSecret}`);
|
||||
return current;
|
||||
},
|
||||
disconnect: async () => { calls.push('disconnect'); return status(); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
|
@ -155,6 +159,14 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
const tested = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
|
||||
assert.equal(tested.ok, true);
|
||||
|
||||
const bound = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.bindCredentials,
|
||||
{ appId: 'cli_manual', appSecret: 'manual-private-secret' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(bound.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(bound), /manual-private-secret|appSecret/);
|
||||
|
||||
const cancelled = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||
{ attemptId: '7' },
|
||||
|
|
@ -169,7 +181,9 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
signal(),
|
||||
);
|
||||
assert.equal(disconnected.ok, true);
|
||||
assert.deepEqual(calls, ['begin', 'test', 'cancel', 'disconnect']);
|
||||
assert.deepEqual(calls, [
|
||||
'begin', 'test', 'bind:cli_manual:manual-private-secret', 'cancel', 'disconnect',
|
||||
]);
|
||||
|
||||
const attemptedSecret = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
|
|
|
|||
|
|
@ -79,3 +79,25 @@ test('QQ bridge accepts only the scanner and requires an at-message event in gro
|
|||
assert.equal(asks, 0);
|
||||
assert.equal(bridge.status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('QQ credential-bound bots accept senders within the platform visibility scope', async () => {
|
||||
let asks = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async () => {} },
|
||||
ownerUserOpenid: '*',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { asks += 1; return 'ok'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
});
|
||||
await bridge.accept(message({ messageId: 'visible', senderId: 'visible-user' }));
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(bridge.status.messagesRejected, 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ test('QQ settings uses the shared compact channel toolbar', () => {
|
|||
}));
|
||||
assert.match(markup, /class="ddt-page dqq-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定QQ机器人</);
|
||||
assert.match(markup, /aria-label="扫码接入 QQ 机器人"/);
|
||||
assert.match(markup, /class="dim-actionIcon"[^]*扫码接入机器人/);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
|
|
@ -36,5 +37,16 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
|
|||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|QQ WebSocket 长连接运行正常/);
|
||||
|
||||
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: false, state: 'error',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: '连接失败,请检查凭据', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(offlineMarkup, /class="ddt-summary dim-cardSummary">连接失败,请检查凭据</);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,42 @@ test('QQ QR success stores the secret off-config and starts a scanner-owned runt
|
|||
qr.resolve();
|
||||
});
|
||||
|
||||
test('QQ AppID and AppSecret binding stores the secret and accepts the platform visibility scope', async () => {
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new QqController({
|
||||
qrAuth: { start() { return () => {}; } },
|
||||
credentials: {
|
||||
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
|
||||
set: async (ref, value) => values.set(ref, value),
|
||||
unset: async (ref) => values.delete(ref),
|
||||
},
|
||||
configStore: {
|
||||
list: () => [...configs],
|
||||
get: (id) => configs.find((value) => value.botId === id) ?? null,
|
||||
getByAppId: (id) => configs.find((value) => value.appId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return {
|
||||
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const status = await controller.bindCredentials({ appId: 'manual-app', appSecret: 'manual-secret' });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(configs[0].ownerUserOpenid, '*');
|
||||
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
|
||||
assert.equal(runtimeArgs.appSecret, 'manual-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-secret|ownerUserOpenid|secretRef/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
|
|
@ -63,6 +99,7 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
|
|||
}),
|
||||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
|
|
@ -71,3 +108,23 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
|
|||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
|
||||
});
|
||||
|
||||
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
|
||||
let received;
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}), registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], totals: { configured: 0, connected: 0 }, appSecret: payload.appSecret };
|
||||
},
|
||||
});
|
||||
const result = await handler(QQ_ENDPOINTS.bindCredentials, {
|
||||
appId: 'manual-app', appSecret: 'manual-secret',
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(received, { appId: 'manual-app', appSecret: 'manual-secret' });
|
||||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
|
||||
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ test('Enterprise WeChat settings uses the shared compact channel toolbar', () =>
|
|||
}));
|
||||
assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定企业微信机器人</);
|
||||
assert.match(markup, /aria-label="扫码接入企业微信机器人"/);
|
||||
assert.match(markup, /class="dim-actionIcon"[^]*扫码接入机器人/);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
|
|
@ -39,5 +40,5 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
|
|||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,40 @@ test('Enterprise WeChat QR success stores Secret off-config and starts its runti
|
|||
assert.doesNotMatch(JSON.stringify(status), /private-secret|secretRef|remote-bot|host-only-code/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts its runtime', async () => {
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new WecomController({
|
||||
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
|
||||
credentials: {
|
||||
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
|
||||
set: async (ref, value) => values.set(ref, value),
|
||||
unset: async (ref) => values.delete(ref),
|
||||
},
|
||||
configStore: {
|
||||
list: () => [...configs],
|
||||
get: (id) => configs.find((value) => value.botId === id) ?? null,
|
||||
getByRemoteBotId: (id) => configs.find((value) => value.remoteBotId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return {
|
||||
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const status = await controller.bindCredentials({ botId: 'remote-manual', secret: 'manual-secret' });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
|
||||
assert.equal(runtimeArgs.secret, 'manual-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-secret|remote-manual|secretRef/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('Enterprise WeChat RPC encodes the QR on Host and strips every authorization field', async () => {
|
||||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
|
|
@ -66,6 +100,7 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
|
|||
}),
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
|
|
@ -74,3 +109,23 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
|
|||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat credential RPC accepts official Bot ID fields and redacts Secret', async () => {
|
||||
let received;
|
||||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}), registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], totals: { configured: 0, connected: 0 }, secret: payload.secret };
|
||||
},
|
||||
});
|
||||
const result = await handler(WECOM_ENDPOINTS.bindCredentials, {
|
||||
botId: 'remote-manual', secret: 'manual-secret',
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(received, { botId: 'remote-manual', secret: 'manual-secret' });
|
||||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|"secret"/);
|
||||
assert.equal((await handler(WECOM_ENDPOINTS.bindCredentials, { botId: 'remote-manual' })).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import React from 'react';
|
|||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||
import { CredentialBindingPanel } from '../plugin-src/client/credential-binding.js';
|
||||
import { DINGTALK_ENDPOINTS } from '../plugin-src/client/channels/dingtalk/api.js';
|
||||
import {
|
||||
AccountCard as DingtalkAccountCard,
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
AccountCard as WecomAccountCard,
|
||||
WecomSettingsTab,
|
||||
} from '../plugin-src/client/channels/wecom/index.js';
|
||||
import { QqSettingsTab } from '../plugin-src/client/channels/qq/index.js';
|
||||
|
||||
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
|
||||
const FEISHU_STYLES_URL = new URL(
|
||||
|
|
@ -58,8 +60,13 @@ const WECOM_SOURCE_URL = new URL(
|
|||
'../plugin-src/client/channels/wecom/index.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const QQ_SOURCE_URL = new URL(
|
||||
'../plugin-src/client/channels/qq/index.js',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', () => {
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -81,6 +88,7 @@ test('IM settings renders five compact logo channel tabs without enable switches
|
|||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.match(markup, /dim-logoWecom/);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 5);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
|
|
@ -130,6 +138,7 @@ test('Feishu bot cards place the application identifier under the bot name', asy
|
|||
assert.match(markup, /class="bxf-healthPill dim-botHealth"/);
|
||||
assert.match(markup, /<button[^>]*aria-label="检查连接今天是牢梁"[^>]*><span>检查连接<\/span><\/button>/);
|
||||
assert.match(markup, /class="bxf-connectedFooter dim-cardFooter"/);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary|长连接运行正常/);
|
||||
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(markup, /连接状态:|bxf-divider/);
|
||||
assert.doesNotMatch(markup, /custom-bot-avatar/);
|
||||
|
|
@ -145,13 +154,60 @@ test('Feishu keeps its heading controls on one row without a plus icon', async (
|
|||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /class="bxf-button bxf-bindButton dim-scanButton"[^>]*><span>扫码绑定机器人<\/span><\/button>/);
|
||||
assert.match(markup, /aria-label="扫码接入飞书机器人"/);
|
||||
assert.match(markup, /class="dim-actionIcon"[^]*<span>扫码接入机器人<\/span>/);
|
||||
assert.doesNotMatch(markup, />添加机器人</);
|
||||
assert.match(styles, /\.bxf-headingTools \{[^}]*justify-content: space-between;[^}]*flex-wrap: nowrap;/);
|
||||
assert.match(styles, /@container \(max-width: 620px\)[^]*\.bxf-headingTools \{ gap: 6px; \}/);
|
||||
assert.doesNotMatch(styles, /\.bxf-headingTools \.bxf-button \{ margin-left: auto; \}/);
|
||||
});
|
||||
|
||||
test('credential binding is a distinct secondary action beside QR binding in four channels', async () => {
|
||||
const settings = [
|
||||
['飞书', FeishuSettingsTab],
|
||||
['QQ', QqSettingsTab],
|
||||
['钉钉', DingtalkSettingsTab],
|
||||
['企业微信', WecomSettingsTab],
|
||||
];
|
||||
for (const [channel, Component] of settings) {
|
||||
const markup = renderToStaticMarkup(React.createElement(Component, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
const scanIndex = markup.indexOf('dim-scanButton');
|
||||
const credentialIndex = markup.indexOf('dim-credentialButton');
|
||||
assert.ok(scanIndex >= 0, `${channel} should render a QR button`);
|
||||
assert.ok(credentialIndex > scanIndex, `${channel} should place credential binding after QR binding`);
|
||||
assert.match(markup, /data-kind="credential"/);
|
||||
const credentialMarkup = markup.slice(credentialIndex, markup.indexOf('</button>', credentialIndex));
|
||||
assert.match(credentialMarkup, /dim-actionIcon/);
|
||||
assert.match(credentialMarkup, /手动接入/);
|
||||
}
|
||||
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
assert.match(styles, /\.dim-panel \.dim-bindActions \{[^}]*flex-wrap: nowrap;/);
|
||||
assert.match(styles, /\.dim-panel \.dim-credentialButton \{[^}]*border: 1px solid #86909c;[^}]*background: var\(--dsw-alias-bg-body, #fff\)/);
|
||||
assert.match(styles, /\.dim-panel \.dim-actionIcon \{[^}]*flex: 0 0 15px;/);
|
||||
assert.doesNotMatch(styles, /\.dim-panel \.dim-credentialPanel \{[^}]*border-left:/);
|
||||
});
|
||||
|
||||
test('credential form stays compact while using a protected password input', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(CredentialBindingPanel, {
|
||||
channel: '企业微信',
|
||||
identityLabel: 'Bot ID',
|
||||
identityPlaceholder: '填写 Bot ID',
|
||||
secretLabel: 'Secret',
|
||||
secretPlaceholder: '填写 Secret',
|
||||
onSubmit() {},
|
||||
onCancel() {},
|
||||
}));
|
||||
assert.match(markup, />Bot ID</);
|
||||
assert.match(markup, /type="password"/);
|
||||
assert.match(markup, /autoComplete="new-password"/i);
|
||||
assert.match(markup, />手动接入企业微信机器人</);
|
||||
assert.doesNotMatch(markup, /已有机器人应用|Harness 会校验凭据|可见范围|受保护的凭据存储/);
|
||||
assert.doesNotMatch(markup, /value="[^"]+"/);
|
||||
});
|
||||
|
||||
test('scan actions align left while online totals align right in every channel', async () => {
|
||||
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, wecomStyles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
|
|
@ -179,10 +235,10 @@ test('scan actions align left while online totals align right in every channel',
|
|||
const weixinHeading = headingSource(weixinSource);
|
||||
const dingtalkHeading = headingSource(dingtalkSource);
|
||||
const wecomHeading = headingSource(wecomSource);
|
||||
assert.ok(feishuHeading.indexOf('扫码绑定机器人') < feishuHeading.indexOf('bxf-totalBadge'));
|
||||
assert.ok(weixinHeading.indexOf('扫码绑定微信') < weixinHeading.indexOf('dxw-badge'));
|
||||
assert.ok(dingtalkHeading.indexOf('扫码接入钉钉') < dingtalkHeading.indexOf('ddt-badge'));
|
||||
assert.ok(wecomHeading.indexOf('扫码绑定企业微信机器人') < wecomHeading.indexOf('ddt-badge'));
|
||||
assert.ok(feishuHeading.indexOf('扫码接入机器人') < feishuHeading.indexOf('bxf-totalBadge'));
|
||||
assert.ok(weixinHeading.indexOf('扫码接入机器人') < weixinHeading.indexOf('dxw-badge'));
|
||||
assert.ok(dingtalkHeading.indexOf('扫码接入机器人') < dingtalkHeading.indexOf('ddt-badge'));
|
||||
assert.ok(wecomHeading.indexOf('扫码接入机器人') < wecomHeading.indexOf('ddt-badge'));
|
||||
|
||||
for (const heading of [feishuHeading, weixinHeading, dingtalkHeading, wecomHeading]) {
|
||||
assert.match(heading, /dim-scanButton/);
|
||||
|
|
@ -205,6 +261,20 @@ test('channel headings omit the redundant local credential badge', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('bot list headings omit the total already shown by the online badge', async () => {
|
||||
const sources = await Promise.all([
|
||||
FEISHU_SOURCE_URL,
|
||||
WEIXIN_SOURCE_URL,
|
||||
DINGTALK_CLIENT_SOURCE_URL,
|
||||
WECOM_SOURCE_URL,
|
||||
QQ_SOURCE_URL,
|
||||
].map((url) => readFile(url, 'utf8')));
|
||||
|
||||
for (const source of sources) {
|
||||
assert.doesNotMatch(source, /length} 个/);
|
||||
}
|
||||
});
|
||||
|
||||
test('all channel settings states use the DingTalk page treatment', async () => {
|
||||
const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
|
|
@ -277,6 +347,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
|
|||
assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/);
|
||||
assert.match(accountMarkup, /class="dxw-health dim-botHealth"/);
|
||||
assert.match(accountMarkup, /class="dxw-accountFooter dim-cardFooter"/);
|
||||
assert.doesNotMatch(accountMarkup, /dim-cardSummary|微信消息长轮询运行正常/);
|
||||
assert.equal((accountMarkup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.equal((accountMarkup.match(/class="dxw-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(accountMarkup, /收到 \/ 回复/);
|
||||
|
|
@ -316,6 +387,7 @@ test('DingTalk bot cards omit the redundant received and replied metric', () =>
|
|||
assert.match(markup, /class="ddt-health dim-botHealth"/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, /class="ddt-accountFooter dim-cardFooter"/);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary|Stream 长连接运行正常/);
|
||||
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.doesNotMatch(markup, /收到 \/ 回复/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue