mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
fix: add actionable DingTalk connection diagnostics
This commit is contained in:
parent
3010535409
commit
1b05fa8d32
18 changed files with 1038 additions and 299 deletions
|
|
@ -36,6 +36,20 @@ test('RPC envelopes are required and sensitive error details are replaced', () =
|
|||
() => unwrapRpcResult({ value: {} }),
|
||||
/无法识别/,
|
||||
);
|
||||
assert.throws(
|
||||
() => unwrapRpcResult({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'stream-connect-failed',
|
||||
message: '钉钉 Stream 消息连接建立失败。',
|
||||
hint: '请按参考号查看 dsh web 日志。',
|
||||
referenceId: 'DT-CONN-DEADBEEF',
|
||||
},
|
||||
}),
|
||||
(error) => error.code === 'stream-connect-failed'
|
||||
&& error.hint === '请按参考号查看 dsh web 日志。'
|
||||
&& error.referenceId === 'DT-CONN-DEADBEEF',
|
||||
);
|
||||
assert.throws(
|
||||
() => unwrapRpcResult({
|
||||
ok: false,
|
||||
|
|
@ -143,6 +157,29 @@ test('presentation helpers redact sensitive messages and format countdowns', ()
|
|||
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
|
||||
{ code: 'UPSTREAM_FAILED', message: '钉钉操作失败,请稍后重试' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentError({
|
||||
code: 'stream-connect-failed',
|
||||
message: '钉钉 Stream 消息连接建立失败。',
|
||||
hint: '请按参考号查看 dsh web 日志。',
|
||||
referenceId: 'DT-CONN-DEADBEEF',
|
||||
}),
|
||||
{
|
||||
code: 'stream-connect-failed',
|
||||
message: '钉钉 Stream 消息连接建立失败。',
|
||||
hint: '请按参考号查看 dsh web 日志。',
|
||||
referenceId: 'DT-CONN-DEADBEEF',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentError({
|
||||
code: 'stream-connect-failed',
|
||||
message: '连接失败',
|
||||
hint: 'clientSecret=must-not-leak',
|
||||
referenceId: 'unsafe-reference',
|
||||
}),
|
||||
{ code: 'stream-connect-failed', message: '连接失败' },
|
||||
);
|
||||
assert.equal(formatRemaining(61_000), '01:01');
|
||||
assert.equal(formatRemaining(-1), '00:00');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -256,6 +256,65 @@ test('connection-check failure stays on the matching card with locale-safe wordi
|
|||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('QR setup surfaces a saved bot connection diagnostic instead of polling forever', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
let statusCalls = 0;
|
||||
const failedBot = {
|
||||
botId: 'dt_failed',
|
||||
connected: false,
|
||||
state: 'error',
|
||||
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••fail' },
|
||||
health: { status: 'offline', summary: '钉钉 Stream 连接失败', lastCheckedAt: Date.now() },
|
||||
error: {
|
||||
code: 'stream-proxy-dependency-incompatible',
|
||||
message: '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||
hint: '请将 agent-base@6 固定为 6.0.2 后重新安装依赖。',
|
||||
referenceId: 'DT-CONN-DEADBEEF',
|
||||
},
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
statusCalls += 1;
|
||||
return ok(statusCalls === 1
|
||||
? snapshot()
|
||||
: snapshot({ state: 'offline', bots: [failedBot] }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
|
||||
return ok(provisioning('attempt-failed'));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) {
|
||||
return ok(provisioning('attempt-failed', {
|
||||
status: 'connected',
|
||||
botId: 'dt_failed',
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer, '生成钉钉二维码').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
clock.runTimeout(1_000);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const text = nodeText(renderer.root);
|
||||
assert.match(text, /机器人已保存,但连接未就绪/);
|
||||
assert.match(text, /agent-base 6\.0\.0/);
|
||||
assert.match(text, /DT-CONN-DEADBEEF/);
|
||||
findButton(renderer, '查看已保存的机器人');
|
||||
assert.equal(clock.timeouts.size, 0, 'a diagnosed connection failure stops QR polling');
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('a later disconnect removes stale success feedback and exposes the account error', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
|
|
|
|||
80
test/channels/dingtalk/connection-error.test.mjs
Normal file
80
test/channels/dingtalk/connection-error.test.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
describeDingtalkConnectionFailure,
|
||||
dingtalkPublicConnectionError,
|
||||
dingtalkRuntimeStartError,
|
||||
installedDingtalkConnectionDependencies,
|
||||
} from '../../../src/channels/dingtalk/connection-error.mjs';
|
||||
|
||||
const FIXED_REFERENCE = 'DT-CONN-DEADBEEF';
|
||||
|
||||
test('connection diagnostics identify the pnpm mirror proxy dependency failure without leaking values', () => {
|
||||
const cause = new Error(
|
||||
'request for client-id-private failed via https://name:password@proxy.example because clientSecret=secret-private',
|
||||
);
|
||||
cause.name = 'AxiosError';
|
||||
cause.code = 'ECONNRESET';
|
||||
cause.response = { status: 502, data: { code: 'GatewayFailure' } };
|
||||
const error = dingtalkRuntimeStartError('dingtalk-stream-connect-failed', cause);
|
||||
|
||||
const failure = describeDingtalkConnectionFailure(error, {
|
||||
clientId: 'client-id-private',
|
||||
clientSecret: 'secret-private',
|
||||
environment: { HTTPS_PROXY: 'https://name:password@proxy.example' },
|
||||
dependencies: {
|
||||
dingtalkStream: '2.1.4',
|
||||
axios: '1.19.0',
|
||||
httpsProxyAgent: '5.0.1',
|
||||
agentBase: '6.0.0',
|
||||
},
|
||||
nodeVersion: '24.19.0',
|
||||
referenceId: FIXED_REFERENCE,
|
||||
});
|
||||
|
||||
assert.deepEqual(failure.publicError, {
|
||||
code: 'stream-proxy-dependency-incompatible',
|
||||
message: '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||
hint: '请将 DSH profile 中的 agent-base@6 固定为 6.0.2 后重新安装依赖,或升级 pnpm 后重新解析 lockfile。',
|
||||
referenceId: FIXED_REFERENCE,
|
||||
});
|
||||
assert.equal(failure.diagnostic.stage, 'dingtalk-stream-connect-failed');
|
||||
assert.deepEqual(failure.diagnostic.proxy, {
|
||||
configured: true,
|
||||
variables: ['HTTPS_PROXY'],
|
||||
});
|
||||
assert.equal(failure.diagnostic.dependencies.agentBase, '6.0.0');
|
||||
assert.equal(failure.diagnostic.errors.at(-1).providerCode, 'GatewayFailure');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(failure.diagnostic),
|
||||
/client-id-private|secret-private|name:password/,
|
||||
);
|
||||
});
|
||||
|
||||
test('connection diagnostics classify common stages and keep a public-only RPC projection', () => {
|
||||
const cause = Object.assign(new Error('getaddrinfo ENOTFOUND api.dingtalk.com'), {
|
||||
code: 'ENOTFOUND',
|
||||
});
|
||||
const staged = dingtalkRuntimeStartError('dingtalk-stream-connect-failed', cause);
|
||||
const failure = describeDingtalkConnectionFailure(staged, {
|
||||
environment: {},
|
||||
dependencies: { agentBase: '6.0.2' },
|
||||
referenceId: FIXED_REFERENCE,
|
||||
});
|
||||
|
||||
assert.equal(failure.publicError.code, 'stream-dns-failed');
|
||||
assert.match(failure.publicError.message, /无法解析/);
|
||||
const outward = dingtalkPublicConnectionError(failure.publicError, staged);
|
||||
assert.equal(outward.name, 'DingtalkPublicConnectionError');
|
||||
assert.deepEqual(outward.publicError, failure.publicError);
|
||||
assert.equal(outward.cause, staged);
|
||||
});
|
||||
|
||||
test('installed connection diagnostics follow the DingTalk dependency chain', () => {
|
||||
const versions = installedDingtalkConnectionDependencies();
|
||||
assert.match(versions.dingtalkStream, /^\d+\.\d+\.\d+/);
|
||||
assert.match(versions.axios, /^\d+\.\d+\.\d+/);
|
||||
assert.match(versions.httpsProxyAgent, /^\d+\.\d+\.\d+/);
|
||||
assert.match(versions.agentBase, /^6\./);
|
||||
});
|
||||
|
|
@ -276,6 +276,7 @@ test('sender approval uses opaque request and sender keys while raw staff IDs st
|
|||
test('runtime activation failure retains the authorized bot for reconnect without exposing detail', async () => {
|
||||
let startCount = 0;
|
||||
const events = [];
|
||||
const logs = [];
|
||||
const credentials = credentialsFixture(events);
|
||||
const configs = configFixture([], events);
|
||||
const runtimes = runtimeFactory({ events, failStart: () => startCount++ === 0 });
|
||||
|
|
@ -284,7 +285,7 @@ test('runtime activation failure retains the authorized bot for reconnect withou
|
|||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
logger: { error: (...args) => logs.push(args), warn() {} },
|
||||
clock: () => 1_000,
|
||||
});
|
||||
const begun = await controller.startProvisioning();
|
||||
|
|
@ -296,10 +297,15 @@ test('runtime activation failure retains the authorized bot for reconnect withou
|
|||
const status = controller.status();
|
||||
assert.deepEqual(status.totals, { configured: 1, connected: 0 });
|
||||
assert.equal(status.bots[0].state, 'error');
|
||||
assert.equal(status.bots[0].error.code, 'connection-failed');
|
||||
assert.equal(status.bots[0].error.code, 'stream-connect-failed');
|
||||
assert.match(status.bots[0].error.referenceId, /^DT-CONN-[A-F0-9]{8}$/);
|
||||
assert.match(status.bots[0].error.hint, /dsh web 日志/);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.match(logs[0][0], new RegExp(status.bots[0].error.referenceId));
|
||||
assert.equal(logs[0][1].category, 'stream-connect-failed');
|
||||
assert.equal(events.some(([event]) => event === 'unset' || event === 'remove'), false);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify({ completed, status }),
|
||||
JSON.stringify({ completed, status, logs }),
|
||||
/private-secret|client-secret-private|device-code-private|secretRef/,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -376,10 +376,16 @@ test('runtime never reports ready when connect resolves before the socket opens
|
|||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.start(), /handshake timed out/);
|
||||
await assert.rejects(
|
||||
runtime.start(),
|
||||
(error) => error.code === 'dingtalk-stream-connect-failed'
|
||||
&& /handshake timed out/.test(error.message)
|
||||
&& error.cause?.message === error.message,
|
||||
);
|
||||
assert.equal(disconnects, 1);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.dingtalkStreamState, 'failed');
|
||||
assert.match(runtime.status.lastError, /handshake timed out/);
|
||||
});
|
||||
|
||||
test('runtime bounds a stalled SDK gateway lookup and disconnects a late connection', async () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
createDingtalkRpcHandler,
|
||||
installDingtalkRpc,
|
||||
} from '../../../plugin-src/host/channels/dingtalk/rpc.mjs';
|
||||
import { dingtalkPublicConnectionError } from '../../../src/channels/dingtalk/connection-error.mjs';
|
||||
|
||||
function controller(overrides = {}) {
|
||||
return {
|
||||
|
|
@ -96,6 +97,28 @@ test('credential RPC accepts Client ID fields while keeping Client Secret host-o
|
|||
assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false);
|
||||
});
|
||||
|
||||
test('RPC returns only the safe connection diagnostic projection', async () => {
|
||||
const cause = new Error('request body contains clientSecret=must-not-leak');
|
||||
const publicError = {
|
||||
code: 'stream-proxy-dependency-incompatible',
|
||||
message: '钉钉 Stream 连接失败。',
|
||||
hint: '请修复代理依赖后重试。',
|
||||
referenceId: 'DT-CONN-DEADBEEF',
|
||||
};
|
||||
const handler = createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => {
|
||||
throw dingtalkPublicConnectionError(publicError, cause);
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await handler(DINGTALK_ENDPOINTS.reconnectBot, {
|
||||
botId: 'dt_abc', sendTest: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, error: publicError });
|
||||
assert.doesNotMatch(JSON.stringify(result), /must-not-leak|clientSecret|cause/);
|
||||
});
|
||||
|
||||
test('RPC is registered for loopback clients only', () => {
|
||||
const registrations = [];
|
||||
const dispose = () => {};
|
||||
|
|
|
|||
|
|
@ -528,6 +528,34 @@ test('DingTalk bot cards omit the redundant received and replied metric', () =>
|
|||
assert.doesNotMatch(markup, /收到 \/ 回复/);
|
||||
});
|
||||
|
||||
test('DingTalk connection failures show actionable guidance and a log reference', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(DingtalkAccountCard, {
|
||||
account: {
|
||||
botId: 'bot-dingtalk-failed',
|
||||
state: 'error',
|
||||
connected: false,
|
||||
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••fail' },
|
||||
health: { summary: '连接失败', lastCheckedAt: null },
|
||||
error: {
|
||||
code: 'stream-proxy-dependency-incompatible',
|
||||
message: '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。',
|
||||
hint: '请将 agent-base@6 固定为 6.0.2 后重新安装依赖。',
|
||||
referenceId: 'DT-CONN-DEADBEEF',
|
||||
},
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
|
||||
assert.match(markup, /agent-base 6\.0\.0/);
|
||||
assert.match(markup, /agent-base@6 固定为 6\.0\.2/);
|
||||
assert.match(markup, /stream-proxy-dependency-incompatible/);
|
||||
assert.match(markup, /DT-CONN-DEADBEEF/);
|
||||
assert.match(markup, /class="ddt-errorDiagnostic"/);
|
||||
});
|
||||
|
||||
test('all IM channel cards keep localized actions visible above full-width feedback', async () => {
|
||||
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue