@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.
@@ -632,143 +632,100 @@ test("POST /im-deps/feishu:安装失败 → phase=failed + 人话原因(netw
632
632
  });
633
633
  });
634
634
 
635
- // ─────────────────── 钉钉「扫码一键创建机器人」device-flow(2026-11-08) ───────────────────
636
-
637
- /** 假钉钉接口(注入 imAuthFetch;测试绝不真的打 oapi.dingtalk.com)。 */
638
- function makeAuthFetch(plan = {}) {
639
- const calls = [];
640
- const resp = (body, ok = true, status = 200) => ({ ok, status, json: async () => body });
641
- const fn = async (url, opts) => {
642
- const body = opts && opts.body ? JSON.parse(opts.body) : {};
643
- calls.push({ url: String(url), body });
644
- const u = String(url);
645
- if (u.endsWith("/init")) {
646
- if (plan.initThrows) throw new Error("ECONNRESET");
647
- return resp(plan.init || { nonce: "nonce-1" });
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 /im-dingtalk-auth/begin:返回 authId/url/interval/expiresIn;device_code 绝不落浏览器", async () => {
649
+ test("POST begin:只写命令文件 {action:'begin', at}(0600),**不返回 URL**(URL 在桥状态里)", async () => {
664
650
  const relayDir = await tmpRelay();
665
- const imAuthFetch = makeAuthFetch();
666
- await withHost(relayDir, { imAuthFetch }, async (base) => {
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.ok(r.body.authId, "必须给浏览器一个 authId 句柄");
671
- assert.equal(r.body.url, "https://oapi.dingtalk.com/auth?x=1");
672
- assert.equal(r.body.interval, 2);
673
- assert.equal(r.body.expiresIn, 120);
674
- const raw = JSON.stringify(r.body);
675
- assert.ok(!raw.includes("dc-secret"), "★ device_code 不能下发给浏览器");
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("GET status:未知/过期 authId → expired;钉钉拒绝 → failed(dingtalk_api_rejected);网络不通 → failed(network_error)", async () => {
666
+ test("POST cancel:写 {action:'cancel'} 命令(桥停轮询 + 删持久化会话)", async () => {
731
667
  const relayDir = await tmpRelay();
732
- await withHost(relayDir, { imAuthFetch: makeAuthFetch() }, async (base) => {
733
- const none = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=nope`);
734
- assert.equal(none.body.status, "expired");
735
- assert.equal(none.body.code, "expired");
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
- // 钉钉拒绝 poll
738
- const rejDir = await tmpRelay();
739
- const rejFetch = makeAuthFetch({ polls: [{ status: "DENIED", message: "用户取消了创建" }] });
740
- await withHost(rejDir, { imAuthFetch: rejFetch }, async (base2) => {
741
- const b = await post(base2, `${base2}/dsh-remote/im-dingtalk-auth/begin`);
742
- const st = await json(`${base2}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}`);
743
- assert.equal(st.body.status, "failed");
744
- assert.equal(st.body.code, "dingtalk_api_rejected");
745
- assert.ok(st.body.message.includes("用户取消"), "钉钉原文原因要带给用户");
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
- // 网络错误(poll 抛错)
749
- const netDir = await tmpRelay();
750
- const netFetch = makeAuthFetch({ pollThrows: true });
751
- await withHost(netDir, { imAuthFetch: netFetch }, async (base3) => {
752
- const b = await post(base3, `${base3}/dsh-remote/im-dingtalk-auth/begin`);
753
- const st = await json(`${base3}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}`);
754
- assert.equal(st.body.status, "failed");
755
- assert.equal(st.body.code, "network_error");
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("POST begin:钉钉接口不可用 → network_error / dingtalk_api_rejected(人话)", async () => {
761
- const dir1 = await tmpRelay();
762
- await withHost(dir1, { imAuthFetch: makeAuthFetch({ initThrows: true }) }, async (base) => {
763
- const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
764
- assert.equal(r.status, 502);
765
- assert.equal(r.body.ok, false);
766
- assert.equal(r.body.code, "network_error");
767
- assert.match(r.body.message, /网络不通/);
768
- });
769
- const dir2 = await tmpRelay();
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, authId: "auth-1", url: "https://oapi.dingtalk.com/x?nonce=1", interval: 2, expiresIn: 120 } };
200
- let dingAuthStatusReplies = [];
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
- const next = dingAuthStatusReplies.length ? dingAuthStatusReplies.shift() : { status: "waiting" };
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(list) { dingAuthStatusReplies = (list || []).slice(); },
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("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成功)+ 顶部「不用去后台」说明;手动配置折叠", async () => {
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
- plugin.setDingAuthStatus([{ status: "waiting" }, { status: "success", clientIdTail: "8899", credentialSource: "scan", hasSecret: true }]);
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
- // 轮询:waiting → success(fake timers 推进 2 轮)
755
- await plugin.advance(2100);
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(plugin.dingAuthCalls().filter((c) => c.kind === "status").length >= 1, "要轮询授权状态");
762
- assert.ok(textOf(tree).includes("已创建机器人并建立连接"), "成功后要自动切到已连接状态");
770
+ assert.ok(textOf(tree).includes("来源:扫码创建"), "成功后自动切到已连接状态(显示来源)");
763
771
  assert.ok(textOf(tree).includes("8899"), "成功提示要带 Client ID 尾号");
764
- dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
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
+ });