@mrrisega/dsh-remote 0.7.12-beta.3 → 0.7.12-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dingtalk-auth.mjs +631 -0
- package/clients/dsh-remote/dsh-bridge.mjs +35 -0
- package/clients/dsh-remote/test/dingtalk-auth-e2e.test.mjs +138 -0
- package/clients/dsh-remote/test/dingtalk-auth.test.mjs +465 -0
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +140 -50
- package/packages/dsh-remote-web/lib/index.js +77 -145
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +49 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +56 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +197 -13
- package/packages/dsh-remote-web/test/im-bots.test.mjs +80 -123
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +143 -25
|
@@ -632,143 +632,100 @@ test("POST /im-deps/feishu:安装失败 → phase=failed + 人话原因(netw
|
|
|
632
632
|
});
|
|
633
633
|
});
|
|
634
634
|
|
|
635
|
-
// ───────────────────
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
if (u.endsWith("/begin")) {
|
|
650
|
-
if (plan.beginRejected) return resp({ errmsg: "rejected" }, false, 400);
|
|
651
|
-
return resp(plan.begin || { device_code: "dc-secret", verification_uri_complete: "https://oapi.dingtalk.com/auth?x=1", interval: 2, expires_in: 120 });
|
|
652
|
-
}
|
|
653
|
-
if (u.endsWith("/poll")) {
|
|
654
|
-
if (plan.pollThrows) throw new Error("ETIMEDOUT");
|
|
655
|
-
return resp(plan.polls && plan.polls.length ? plan.polls.shift() : { status: "WAITING" });
|
|
656
|
-
}
|
|
657
|
-
throw new Error("unexpected url " + u);
|
|
658
|
-
};
|
|
659
|
-
fn.calls = calls;
|
|
660
|
-
return fn;
|
|
661
|
-
}
|
|
635
|
+
// ─────────────────── 钉钉扫码授权:**委托桥侧持久化**(2026-11-08 架构修正) ───────────────────
|
|
636
|
+
//
|
|
637
|
+
// 契约(与桥约定):
|
|
638
|
+
// · 插件只写命令文件 <relayDir>/im-dingtalk-auth-cmd.json {action:"begin"|"cancel", at}
|
|
639
|
+
// · 桥持久化 device_code/断点续轮询,把进度写进 im-bot-status.json 的 dingtalk.auth
|
|
640
|
+
// · 插件 GET status 直接读状态文件;**自身不存内存会话、不调 oapi.dingtalk.com**
|
|
641
|
+
|
|
642
|
+
const readCmd = async (relayDir) => {
|
|
643
|
+
const raw = await readFile(path.join(relayDir, "im-dingtalk-auth-cmd.json"), "utf8");
|
|
644
|
+
return { data: JSON.parse(raw), mode: (await stat(path.join(relayDir, "im-dingtalk-auth-cmd.json"))).mode & 0o777 };
|
|
645
|
+
};
|
|
646
|
+
const writeAuthStatus = (relayDir, auth) => writeFile(path.join(relayDir, "im-bot-status.json"),
|
|
647
|
+
JSON.stringify({ updatedAt: Date.now(), dingtalk: { enabled: true, connected: false, auth } }));
|
|
662
648
|
|
|
663
|
-
test("POST
|
|
649
|
+
test("POST begin:只写命令文件 {action:'begin', at}(0600),**不返回 URL**(URL 在桥状态里)", async () => {
|
|
664
650
|
const relayDir = await tmpRelay();
|
|
665
|
-
|
|
666
|
-
|
|
651
|
+
await withHost(relayDir, {}, async (base) => {
|
|
652
|
+
const before = Date.now();
|
|
667
653
|
const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
668
654
|
assert.equal(r.status, 200);
|
|
669
655
|
assert.equal(r.body.ok, true);
|
|
670
|
-
assert.
|
|
671
|
-
assert.equal(r.body.
|
|
672
|
-
|
|
673
|
-
assert.equal(
|
|
674
|
-
|
|
675
|
-
assert.ok(
|
|
676
|
-
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
test("POST begin 限流:同一时间只允许一个授权,旧 authId 查询回 cancelled", async () => {
|
|
680
|
-
const relayDir = await tmpRelay();
|
|
681
|
-
const imAuthFetch = makeAuthFetch();
|
|
682
|
-
await withHost(relayDir, { imAuthFetch }, async (base) => {
|
|
683
|
-
const a = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
684
|
-
const b = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
685
|
-
assert.ok(a.body.authId && b.body.authId && a.body.authId !== b.body.authId);
|
|
686
|
-
const old = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(a.body.authId)}`);
|
|
687
|
-
assert.equal(old.body.status, "failed");
|
|
688
|
-
assert.equal(old.body.code, "cancelled", "旧授权必须被作废(而不是继续有效)");
|
|
689
|
-
const cur = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}`);
|
|
690
|
-
assert.equal(cur.body.status, "waiting");
|
|
691
|
-
});
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
test("GET status:waiting → success 时把凭据写入 im-bots.json(0600、credentialSource=scan),响应不回 secret", async () => {
|
|
695
|
-
const relayDir = await tmpRelay();
|
|
696
|
-
const imAuthFetch = makeAuthFetch({
|
|
697
|
-
polls: [
|
|
698
|
-
{ status: "WAITING" },
|
|
699
|
-
{ status: "SUCCESS", client_id: "ding-client-8899", client_secret: "DING-SCAN-SECRET" },
|
|
700
|
-
],
|
|
701
|
-
});
|
|
702
|
-
await withHost(relayDir, { imAuthFetch }, async (base) => {
|
|
703
|
-
const begin = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
704
|
-
const id = encodeURIComponent(begin.body.authId);
|
|
705
|
-
let st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}`);
|
|
706
|
-
assert.equal(st.body.status, "waiting", "用户还没确认 → waiting");
|
|
707
|
-
st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}`);
|
|
708
|
-
assert.equal(st.body.status, "success");
|
|
709
|
-
assert.equal(st.body.credentialSource, "scan");
|
|
710
|
-
assert.equal(st.body.hasSecret, true);
|
|
711
|
-
assert.equal(st.body.clientIdTail, "8899");
|
|
712
|
-
assert.ok(!JSON.stringify(st.body).includes("DING-SCAN-SECRET"), "★ 扫码拿到的 secret 绝不能回给浏览器");
|
|
713
|
-
|
|
714
|
-
const cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
715
|
-
assert.equal(cfg.dingtalk.clientId, "ding-client-8899");
|
|
716
|
-
assert.equal(cfg.dingtalk.clientSecret, "DING-SCAN-SECRET", "凭据要写入配置文件(桥读)");
|
|
717
|
-
assert.equal(cfg.dingtalk.credentialSource, "scan");
|
|
718
|
-
assert.equal(cfg.dingtalk.enabled, true, "扫码成功即可用");
|
|
719
|
-
const mode = (await stat(path.join(relayDir, "im-bots.json"))).mode & 0o777;
|
|
720
|
-
assert.equal(mode, 0o600, "配置文件必须 0600");
|
|
721
|
-
|
|
722
|
-
// 面板 GET 里也只见 hasSecret,不见 secret
|
|
723
|
-
const view = await json(`${base}/dsh-remote/im-bots`);
|
|
724
|
-
assert.equal(view.body.dingtalk.hasSecret, true);
|
|
725
|
-
assert.equal(view.body.dingtalk.credentialSource, "scan");
|
|
726
|
-
assert.ok(!JSON.stringify(view.body).includes("DING-SCAN-SECRET"));
|
|
656
|
+
assert.equal(r.body.url, undefined, "★ begin 不得返回 URL(授权链接要等桥跑完写状态)");
|
|
657
|
+
assert.equal(r.body.authId, undefined, "★ 不再有插件内存会话 authId");
|
|
658
|
+
const { data, mode } = await readCmd(relayDir);
|
|
659
|
+
assert.equal(data.action, "begin");
|
|
660
|
+
assert.equal(typeof data.at, "number");
|
|
661
|
+
assert.ok(data.at >= before, "at 应是本次点击时间戳(桥据此判断是否新命令)");
|
|
662
|
+
assert.equal(mode, 0o600, "命令文件必须 0600");
|
|
727
663
|
});
|
|
728
664
|
});
|
|
729
665
|
|
|
730
|
-
test("
|
|
666
|
+
test("POST cancel:写 {action:'cancel'} 命令(桥停轮询 + 删持久化会话)", async () => {
|
|
731
667
|
const relayDir = await tmpRelay();
|
|
732
|
-
await withHost(relayDir, {
|
|
733
|
-
const
|
|
734
|
-
assert.equal(
|
|
735
|
-
|
|
668
|
+
await withHost(relayDir, {}, async (base) => {
|
|
669
|
+
const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/cancel`);
|
|
670
|
+
assert.equal(r.body.ok, true);
|
|
671
|
+
const { data, mode } = await readCmd(relayDir);
|
|
672
|
+
assert.equal(data.action, "cancel");
|
|
673
|
+
assert.equal(mode, 0o600);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
736
676
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
677
|
+
test("GET status:直接读 im-bot-status.json 的 dingtalk.auth(白名单 + 只透 https 链接 + 人话兜底)", async () => {
|
|
678
|
+
const relayDir = await tmpRelay();
|
|
679
|
+
await withHost(relayDir, {}, async (base) => {
|
|
680
|
+
// 1) idle(无 auth 段)
|
|
681
|
+
let st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
|
|
682
|
+
assert.equal(st.body.state, "idle");
|
|
683
|
+
assert.equal(st.body.url, "");
|
|
684
|
+
assert.equal(st.body.message, "", "idle 不该有错误文案");
|
|
685
|
+
|
|
686
|
+
// 2) waiting(带链接/剩余时间/未知字段)
|
|
687
|
+
await writeAuthStatus(relayDir, {
|
|
688
|
+
state: "waiting", url: "https://oapi.dingtalk.com/x?nonce=1", expiresAt: 123, remainingSec: 300,
|
|
689
|
+
deviceCode: "LEAK-DEVICE", clientSecret: "LEAK-SECRET",
|
|
746
690
|
});
|
|
691
|
+
st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
|
|
692
|
+
assert.equal(st.body.state, "waiting");
|
|
693
|
+
assert.equal(st.body.url, "https://oapi.dingtalk.com/x?nonce=1");
|
|
694
|
+
assert.equal(st.body.remainingSec, 300);
|
|
695
|
+
assert.equal(st.body.expiresAt, 123);
|
|
696
|
+
assert.ok(!JSON.stringify(st.body).includes("LEAK"), "★ 状态文件里的 device_code/secret 不能透出");
|
|
747
697
|
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
698
|
+
// 3) 非 https 链接必须过滤(防状态文件被塞 javascript:/data:)
|
|
699
|
+
await writeAuthStatus(relayDir, { state: "waiting", url: "javascript:alert(1)" });
|
|
700
|
+
st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
|
|
701
|
+
assert.equal(st.body.url, "", "只允许 https 链接");
|
|
702
|
+
|
|
703
|
+
// 4) failed:桥给裸 code/ok 时也要翻成中文人话
|
|
704
|
+
await writeAuthStatus(relayDir, { state: "failed", message: "ok" });
|
|
705
|
+
st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
|
|
706
|
+
assert.equal(st.body.state, "failed");
|
|
707
|
+
assert.match(st.body.message, /[\u4e00-\u9fa5]/, "message 必须中文人话");
|
|
708
|
+
assert.notEqual(st.body.message, "ok", "★ 绝不裸渲染 ok");
|
|
757
709
|
});
|
|
758
710
|
});
|
|
759
711
|
|
|
760
|
-
test("
|
|
761
|
-
const
|
|
762
|
-
await withHost(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
assert.equal(
|
|
767
|
-
assert.
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
await withHost(dir2, { imAuthFetch: makeAuthFetch({ beginRejected: true }) }, async (base) => {
|
|
771
|
-
const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
772
|
-
assert.equal(r.body.code, "dingtalk_api_rejected");
|
|
712
|
+
test("GET /im-bots:status.dingtalk.auth 也按同一白名单透出(面板打开时恢复等待态)", async () => {
|
|
713
|
+
const relayDir = await tmpRelay();
|
|
714
|
+
await withHost(relayDir, {}, async (base) => {
|
|
715
|
+
await writeAuthStatus(relayDir, { state: "waiting", url: "https://oapi.dingtalk.com/y", remainingSec: 120, clientIdTail: "8899" });
|
|
716
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
717
|
+
const auth = r.body.status.dingtalk.auth;
|
|
718
|
+
assert.equal(auth.state, "waiting");
|
|
719
|
+
assert.equal(auth.url, "https://oapi.dingtalk.com/y");
|
|
720
|
+
assert.equal(auth.remainingSec, 120);
|
|
721
|
+
assert.ok(!JSON.stringify(r.body).includes("LEAK"));
|
|
773
722
|
});
|
|
774
723
|
});
|
|
724
|
+
|
|
725
|
+
test("源码约束:插件侧**不再有内存授权会话、不再直连 oapi.dingtalk.com**(只委托桥)", async () => {
|
|
726
|
+
const src = await readFile(new URL("../lib/index.js", import.meta.url), "utf8");
|
|
727
|
+
assert.ok(src.includes("im-dingtalk-auth-cmd.json"), "必须有命令文件契约");
|
|
728
|
+
assert.ok(!src.includes("oapi.dingtalk.com"), "★ 插件不得再自己调钉钉 registration 接口(由桥负责)");
|
|
729
|
+
assert.ok(!src.includes("dingtalkAuthSessions"), "★ 不得再有插件内存授权会话 Map(进程重启就丢)");
|
|
730
|
+
assert.ok(!/setInterval\([^)]*dingtalkAuthPollOnce/.test(src), "★ 不得再有插件侧后台轮询 loop");
|
|
731
|
+
});
|
|
@@ -196,8 +196,8 @@ function loadPlugin(opts = {}) {
|
|
|
196
196
|
let imBotsPutReply = { status: 200, body: { ok: true, wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 } };
|
|
197
197
|
let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
|
|
198
198
|
let imDepsReply = { installed: false, phase: "idle", version: "", lastError: "" };
|
|
199
|
-
let dingAuthBeginReply = { status: 200, body: { ok: true
|
|
200
|
-
let
|
|
199
|
+
let dingAuthBeginReply = { status: 200, body: { ok: true } };
|
|
200
|
+
let dingAuthStatusReply = { ok: true, state: "idle", url: "", remainingSec: 0, message: "" };
|
|
201
201
|
const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
|
|
202
202
|
|
|
203
203
|
const makeEl = () => ({
|
|
@@ -265,10 +265,13 @@ function loadPlugin(opts = {}) {
|
|
|
265
265
|
dingAuthCalls.push({ kind: "begin", method: options.method || "POST" });
|
|
266
266
|
return response(dingAuthBeginReply.status, dingAuthBeginReply.body);
|
|
267
267
|
}
|
|
268
|
+
if (path === "/dsh-remote/im-dingtalk-auth/cancel") {
|
|
269
|
+
dingAuthCalls.push({ kind: "cancel", method: options.method || "POST" });
|
|
270
|
+
return response(200, { ok: true });
|
|
271
|
+
}
|
|
268
272
|
if (path.startsWith("/dsh-remote/im-dingtalk-auth/status")) {
|
|
269
273
|
dingAuthCalls.push({ kind: "status", path });
|
|
270
|
-
|
|
271
|
-
return response(200, next);
|
|
274
|
+
return response(200, dingAuthStatusReply);
|
|
272
275
|
}
|
|
273
276
|
if (path === "/dsh-remote/im-deps") {
|
|
274
277
|
depsCalls.push(Date.now());
|
|
@@ -347,7 +350,7 @@ function loadPlugin(opts = {}) {
|
|
|
347
350
|
setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
|
|
348
351
|
setImDeps(body) { imDepsReply = body; },
|
|
349
352
|
setDingAuthBegin(body, status = 200) { dingAuthBeginReply = { status, body }; },
|
|
350
|
-
setDingAuthStatus(
|
|
353
|
+
setDingAuthStatus(body) { dingAuthStatusReply = body || { ok: true, state: "idle" }; },
|
|
351
354
|
dingAuthCalls() { return dingAuthCalls.slice(); },
|
|
352
355
|
depsCalls() { return depsCalls.slice(); },
|
|
353
356
|
depsInstalls() { return depsInstalls.slice(); },
|
|
@@ -713,7 +716,7 @@ test("平台列表:企业微信 / 钉钉 / 飞书 / Webhook 都是真入口(
|
|
|
713
716
|
assert.ok(!texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书不再是即将支持");
|
|
714
717
|
});
|
|
715
718
|
|
|
716
|
-
test("
|
|
719
|
+
test("钉钉主路径:点扫码→begin→轮询桥状态:先生成链接、再等待确认、成功自动切已连接", async () => {
|
|
717
720
|
const plugin = loadPlugin();
|
|
718
721
|
plugin.setImBotsGet({
|
|
719
722
|
ok: true,
|
|
@@ -721,7 +724,8 @@ test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成
|
|
|
721
724
|
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
|
|
722
725
|
status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
|
|
723
726
|
});
|
|
724
|
-
|
|
727
|
+
// 桥还没跑完 begin:状态是 idle → 面板显示"正在生成授权链接…"
|
|
728
|
+
plugin.setDingAuthStatus({ ok: true, state: "idle", url: "", remainingSec: 0 });
|
|
725
729
|
let tree = await plugin.settle();
|
|
726
730
|
openEntry(tree, "channel");
|
|
727
731
|
tree = await plugin.settle();
|
|
@@ -729,41 +733,43 @@ test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成
|
|
|
729
733
|
tree = await plugin.settle();
|
|
730
734
|
let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
731
735
|
assert.ok(dlg, "钉钉配置页应打开");
|
|
732
|
-
|
|
733
|
-
assert.ok(textOf(dlg).includes("不用去开发者后台创建应用") || textOf(dlg).includes("不需要去开发者后台创建应用"),
|
|
734
|
-
"顶部要说明不需要去开发者后台创建应用");
|
|
736
|
+
assert.ok(textOf(dlg).includes("不用去开发者后台创建应用"), "顶部要说明不需要去开发者后台创建应用");
|
|
735
737
|
const scanBtn = btnWith(dlg, "用钉钉扫码,一键创建机器人");
|
|
736
738
|
assert.ok(scanBtn, "主路径必须是一个「扫码一键创建」按钮");
|
|
737
|
-
|
|
738
|
-
assert.equal(find(dlg, (n) => n.props?.id === "dru-ding-client"), undefined, "手动字段默认不应出现(折叠在高级里)");
|
|
739
|
+
assert.equal(find(dlg, (n) => n.props?.id === "dru-ding-client"), undefined, "手动字段默认折叠");
|
|
739
740
|
|
|
740
|
-
// 点扫码 → begin
|
|
741
|
+
// 点扫码 → begin;此时还没有 URL(桥正在创建)
|
|
741
742
|
scanBtn.props.onClick();
|
|
742
743
|
await flush(); await flush();
|
|
743
744
|
tree = await plugin.settle(3);
|
|
744
|
-
assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "begin"), "★ 点扫码必须 POST begin");
|
|
745
|
+
assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "begin"), "★ 点扫码必须 POST begin(写命令文件)");
|
|
746
|
+
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
747
|
+
assert.ok(textOf(dlg).includes("正在生成授权链接"), "URL 未就绪时如实显示正在生成(不瞎编链接)");
|
|
748
|
+
|
|
749
|
+
// 桥写回 waiting + url/remainingSec → 面板轮询拿到并展示
|
|
750
|
+
plugin.setDingAuthStatus({ ok: true, state: "waiting", url: "https://oapi.dingtalk.com/x?nonce=1", remainingSec: 300, expiresAt: Date.now() + 300000 });
|
|
751
|
+
await plugin.advance(2100);
|
|
752
|
+
await flush(); await flush();
|
|
753
|
+
tree = await plugin.settle(3);
|
|
745
754
|
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
746
|
-
assert.ok(textOf(dlg).includes("等待你在钉钉里确认"), "
|
|
747
|
-
assert.ok(textOf(dlg).includes("
|
|
755
|
+
assert.ok(textOf(dlg).includes("等待你在钉钉里确认"), "有 URL 后显示等待确认");
|
|
756
|
+
assert.ok(textOf(dlg).includes("剩余约 5 分钟"), "要显示剩余分钟(remainingSec/60)");
|
|
748
757
|
const link = find(dlg, (n) => n.type === "a" && String(n.props?.href || "").includes("oapi.dingtalk.com"));
|
|
749
758
|
assert.ok(link, "要给出可点击的授权链接");
|
|
750
759
|
assert.equal(link.props.target, "_blank");
|
|
751
760
|
assert.equal(link.props.rel, "noopener");
|
|
752
761
|
assert.ok(btnWith(dlg, "复制授权链接"), "要有复制授权链接按钮");
|
|
762
|
+
assert.ok(btnWith(dlg, "我已完成授权,检查一下"), "要有手动检查按钮");
|
|
763
|
+
assert.ok(btnWith(dlg, "取消授权"), "要有取消授权(写 cancel 命令)");
|
|
753
764
|
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
await flush(); await flush();
|
|
757
|
-
tree = await plugin.settle(3);
|
|
765
|
+
// 桥把进度推进到 success → 面板下一次轮询自动切已连接并收起授权块
|
|
766
|
+
plugin.setDingAuthStatus({ ok: true, state: "success", clientIdTail: "8899", message: "" });
|
|
758
767
|
await plugin.advance(2100);
|
|
759
768
|
await flush(); await flush();
|
|
760
769
|
tree = await plugin.settle(4);
|
|
761
|
-
assert.ok(
|
|
762
|
-
assert.ok(textOf(tree).includes("已创建机器人并建立连接"), "成功后要自动切到已连接状态");
|
|
770
|
+
assert.ok(textOf(tree).includes("来源:扫码创建"), "成功后自动切到已连接状态(显示来源)");
|
|
763
771
|
assert.ok(textOf(tree).includes("8899"), "成功提示要带 Client ID 尾号");
|
|
764
|
-
|
|
765
|
-
assert.ok(dlg, "成功后弹层仍开着(用户能直接看到结果/去钉钉试)");
|
|
766
|
-
assert.ok(!textOf(dlg).includes("等待你在钉钉里确认"), "成功后不再显示等待态");
|
|
772
|
+
assert.equal(btnWith(tree, "我已完成授权,检查一下"), undefined, "成功后授权块自动收起");
|
|
767
773
|
});
|
|
768
774
|
|
|
769
775
|
test("钉钉手动模式:折叠展开后 7 步 + 三个坑 + 三字段;官方链接全部 target=_blank rel=noopener 且提交体字段锁死", async () => {
|
|
@@ -1288,3 +1294,115 @@ test("飞书依赖下载:点下载 → POST 安装 → 轮询 /im-deps,装
|
|
|
1288
1294
|
assert.ok(textOf(tree).includes("1.40.0"), "装好后面板直接显示版本(用户不需要重开弹层)");
|
|
1289
1295
|
assert.ok(find(tree, (n) => n.props?.id === "dru-fs-appid"), "装好后应出现飞书配置表单");
|
|
1290
1296
|
});
|
|
1297
|
+
|
|
1298
|
+
// ─────────── ⑫ 钉钉授权修正(2026-11-08 生产踩坑:TTL/人话/未配置措辞) ───────────
|
|
1299
|
+
|
|
1300
|
+
test("钉钉等待中:「我已完成授权,检查一下」再读一次桥状态;成功即切已连接(关面板也不丢)", async () => {
|
|
1301
|
+
const plugin = loadPlugin();
|
|
1302
|
+
plugin.setImBotsGet({
|
|
1303
|
+
ok: true, wecom: {}, feishu: {}, dingtalk: {},
|
|
1304
|
+
status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1305
|
+
});
|
|
1306
|
+
plugin.setDingAuthStatus({ ok: true, state: "idle", url: "", remainingSec: 0 });
|
|
1307
|
+
let tree = await plugin.settle();
|
|
1308
|
+
openEntry(tree, "channel");
|
|
1309
|
+
tree = await plugin.settle();
|
|
1310
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1311
|
+
tree = await plugin.settle();
|
|
1312
|
+
btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
|
|
1313
|
+
plugin.setDingAuthStatus({ ok: true, state: "waiting", url: "https://oapi.dingtalk.com/z", remainingSec: 120 });
|
|
1314
|
+
await flush(); await flush();
|
|
1315
|
+
tree = await plugin.settle(3);
|
|
1316
|
+
await plugin.advance(2100); // 让轮询读到 waiting(url 到位)
|
|
1317
|
+
await flush(); await flush();
|
|
1318
|
+
tree = await plugin.settle(3);
|
|
1319
|
+
const checkBtn = btnWith(tree, "我已完成授权,检查一下");
|
|
1320
|
+
assert.ok(checkBtn, "等待中要有「我已完成授权,检查一下」按钮");
|
|
1321
|
+
const before = plugin.dingAuthCalls().filter((c) => c.kind === "status").length;
|
|
1322
|
+
// 用户已在手机钉钉确认 → 桥把状态写成 success;手动检查只再读一次状态
|
|
1323
|
+
plugin.setDingAuthStatus({ ok: true, state: "success", clientIdTail: "0001", message: "" });
|
|
1324
|
+
checkBtn.props.onClick();
|
|
1325
|
+
await flush(); await flush();
|
|
1326
|
+
tree = await plugin.settle(3);
|
|
1327
|
+
const after = plugin.dingAuthCalls().filter((c) => c.kind === "status").length;
|
|
1328
|
+
assert.ok(after > before, "手动检查必须再读一次 status(桥持久化,不需要面板传 check 参数)");
|
|
1329
|
+
assert.ok(textOf(tree).includes("来源:扫码创建"), "手动检查成功后自动切已连接并收起授权块");
|
|
1330
|
+
assert.equal(btnWith(tree, "我已完成授权,检查一下"), undefined, "成功后授权块自动收起");
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test("钉钉授权失败/过期:显示「授权失败:<中文人话>」+ 重试按钮(绝不裸渲染)", async () => {
|
|
1334
|
+
const plugin = loadPlugin();
|
|
1335
|
+
plugin.setImBotsGet({
|
|
1336
|
+
ok: true, wecom: {}, feishu: {}, dingtalk: {},
|
|
1337
|
+
status: { dingtalk: { connected: false, auth: { state: "failed", message: "ok" } }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1338
|
+
});
|
|
1339
|
+
let tree = await plugin.settle();
|
|
1340
|
+
openEntry(tree, "channel");
|
|
1341
|
+
tree = await plugin.settle();
|
|
1342
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1343
|
+
tree = await plugin.settle();
|
|
1344
|
+
const t = textOf(tree);
|
|
1345
|
+
assert.ok(t.includes("授权失败"), "桥报 failed 要显示授权失败");
|
|
1346
|
+
assert.ok(t.includes("操作失败,请稍后重试"), "裸 ok 要兜底成中文人话");
|
|
1347
|
+
assert.ok(!/授权失败:\s*ok\s*/m.test(t), "绝不裸渲染 ok");
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("钉钉未配置措辞:enabled=false 显示「未配置」,不是「连接失败」", async () => {
|
|
1351
|
+
const plugin = loadPlugin();
|
|
1352
|
+
plugin.setImBotsGet({
|
|
1353
|
+
ok: true, wecom: {}, feishu: {},
|
|
1354
|
+
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
|
|
1355
|
+
status: { dingtalk: { connected: false, enabled: false, state: "idle", lastError: "未启用(配置里 dingtalk.enabled=false)" }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1356
|
+
});
|
|
1357
|
+
let tree = await plugin.settle();
|
|
1358
|
+
openEntry(tree, "channel");
|
|
1359
|
+
tree = await plugin.settle();
|
|
1360
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1361
|
+
tree = await plugin.settle();
|
|
1362
|
+
const t = textOf(tree);
|
|
1363
|
+
assert.ok(t.includes("未配置"), "没有任何凭据时显示「未配置」");
|
|
1364
|
+
assert.ok(!t.includes("连接失败"), "未配置 ≠ 连接失败(业主明确要求)");
|
|
1365
|
+
assert.ok(!t.includes("未启用(配置里 dingtalk.enabled=false)"), "不得把底层 enabled=false 原文透给用户");
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
test("钉钉连接失败:只有 state=failed 才显示「连接失败:<中文人话>」,绝不裸渲染 code/ok", async () => {
|
|
1369
|
+
const plugin = loadPlugin();
|
|
1370
|
+
plugin.setImBotsGet({
|
|
1371
|
+
ok: true, wecom: {}, feishu: {},
|
|
1372
|
+
dingtalk: { enabled: true, clientId: "ding-x", hasSecret: true, robotCode: "rc", credentialSource: "scan" },
|
|
1373
|
+
status: { dingtalk: { connected: false, enabled: true, state: "failed", lastError: "ok" }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1374
|
+
});
|
|
1375
|
+
let tree = await plugin.settle();
|
|
1376
|
+
openEntry(tree, "channel");
|
|
1377
|
+
tree = await plugin.settle();
|
|
1378
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1379
|
+
tree = await plugin.settle();
|
|
1380
|
+
const t = textOf(tree);
|
|
1381
|
+
assert.ok(t.includes("连接失败"), "state=failed 时才显示连接失败");
|
|
1382
|
+
assert.ok(t.includes("操作失败,请稍后重试"), "后端给了裸 ok 时要兜底成中文人话");
|
|
1383
|
+
assert.ok(!/连接失败:\s*ok\s*$/m.test(t), "绝不把裸 ok 渲染出来");
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test("钉钉等待中:点「取消授权」写 cancel 命令并收起授权块", async () => {
|
|
1387
|
+
const plugin = loadPlugin();
|
|
1388
|
+
plugin.setImBotsGet({
|
|
1389
|
+
ok: true, wecom: {}, feishu: {}, dingtalk: {},
|
|
1390
|
+
status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1391
|
+
});
|
|
1392
|
+
plugin.setDingAuthStatus({ ok: true, state: "idle", url: "", remainingSec: 0 });
|
|
1393
|
+
let tree = await plugin.settle();
|
|
1394
|
+
openEntry(tree, "channel");
|
|
1395
|
+
tree = await plugin.settle();
|
|
1396
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1397
|
+
tree = await plugin.settle();
|
|
1398
|
+
btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
|
|
1399
|
+
await flush(); await flush();
|
|
1400
|
+
tree = await plugin.settle(3);
|
|
1401
|
+
const cancel = btnWith(tree, "取消授权");
|
|
1402
|
+
assert.ok(cancel, "等待中要有「取消授权」");
|
|
1403
|
+
cancel.props.onClick();
|
|
1404
|
+
await flush(); await flush();
|
|
1405
|
+
tree = await plugin.settle(3);
|
|
1406
|
+
assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "cancel"), "取消授权必须写 cancel 命令(桥停轮询)");
|
|
1407
|
+
assert.equal(btnWith(tree, "取消授权"), undefined, "取消后授权块收起");
|
|
1408
|
+
});
|