@mrrisega/dsh-remote 0.7.12-beta.4 → 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 +85 -48
- package/packages/dsh-remote-web/lib/index.js +62 -202
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/im-bots.test.mjs +76 -166
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +82 -29
|
@@ -124,6 +124,15 @@ import { createAgentRuntime } from "./agent/index.mjs";
|
|
|
124
124
|
import { createWecomBotController } from "./wecom-bot.mjs";
|
|
125
125
|
// 钉钉 Stream 模式长连接(2026-11-08):与企微同构、零新增依赖;默认关闭,配置在 im-bots.json 的 dingtalk 段。
|
|
126
126
|
import { createDingtalkBotController } from "./dingtalk-bot.mjs";
|
|
127
|
+
// 钉钉「扫码一键创建机器人」授权会话:**持久化 + 断点续轮询**(2026-11-08)。
|
|
128
|
+
// 根因:device_code 只存在插件进程内存 → 面板关/宿主重启就丢,而凭据只在 poll SUCCESS 那次返回。
|
|
129
|
+
// 现在授权会话落盘在 <relayDir>/im-dingtalk-auth.json,bridge 启动即续轮询,桥在跑就能接住。
|
|
130
|
+
import {
|
|
131
|
+
resumeDingtalkAuth,
|
|
132
|
+
startDingtalkAuthCommandWatcher,
|
|
133
|
+
stopDingtalkAuthCommandWatcher,
|
|
134
|
+
stopDingtalkAuthPolling
|
|
135
|
+
} from "./dingtalk-auth.mjs";
|
|
127
136
|
// 飞书通道:官方 SDK 按需下载(41MB,不随包分发)、懒加载;未启用时零副作用
|
|
128
137
|
import { createFeishuBotController } from "./feishu-bot.mjs";
|
|
129
138
|
|
|
@@ -3358,6 +3367,29 @@ function imBotLog(level, message) {
|
|
|
3358
3367
|
}
|
|
3359
3368
|
|
|
3360
3369
|
function startImBots() {
|
|
3370
|
+
// ⓪ 钉钉扫码授权断点续轮询(fire-and-forget,绝不阻塞隧道/连接主链路):
|
|
3371
|
+
// 上次没等到用户确认就重启/关面板也没关系 —— 会话在磁盘上,桥一跑就继续 poll,
|
|
3372
|
+
// 成功后自动写 im-bots.json(credentialSource:"scan")→ controller 热更新把机器人连上。
|
|
3373
|
+
try {
|
|
3374
|
+
void resumeDingtalkAuth({
|
|
3375
|
+
relayDir: RELAY_DIR,
|
|
3376
|
+
logger: imBotLog,
|
|
3377
|
+
onWarn: (m) => imBotLog("warn", m),
|
|
3378
|
+
onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
|
|
3379
|
+
}).catch((e) => {
|
|
3380
|
+
console.warn(`[bridge] 钉钉扫码授权续轮询失败(不影响远程访问): ${redactText(e)}`);
|
|
3381
|
+
});
|
|
3382
|
+
// 控制命令文件:插件写 <relayDir>/im-dingtalk-auth-cmd.json(begin/cancel),桥读并执行。
|
|
3383
|
+
// 插件不需要 device_code,也不需要 HTTP 端点 —— 只表达意图;桥在跑就能接住。
|
|
3384
|
+
startDingtalkAuthCommandWatcher({
|
|
3385
|
+
relayDir: RELAY_DIR,
|
|
3386
|
+
logger: imBotLog,
|
|
3387
|
+
onWarn: (m) => imBotLog("warn", m),
|
|
3388
|
+
onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
|
|
3389
|
+
});
|
|
3390
|
+
} catch (e) {
|
|
3391
|
+
console.warn(`[bridge] 钉钉扫码授权续轮询初始化失败(忽略): ${redactText(e)}`);
|
|
3392
|
+
}
|
|
3361
3393
|
// ① 企业微信(现有)
|
|
3362
3394
|
try {
|
|
3363
3395
|
if (!imBots) {
|
|
@@ -3437,6 +3469,9 @@ function startImBots() {
|
|
|
3437
3469
|
|
|
3438
3470
|
/** 优雅退出 IM 机器人通道(企微 + 钉钉,互不影响)。失败只记日志。 */
|
|
3439
3471
|
async function stopImBots() {
|
|
3472
|
+
// 先停扫码授权轮询/命令监听(**保留** auth 文件:下次启动能续轮询;用户显式取消才删文件)
|
|
3473
|
+
try { stopDingtalkAuthCommandWatcher(); } catch { /* 忽略 */ }
|
|
3474
|
+
try { stopDingtalkAuthPolling(); } catch { /* 忽略 */ }
|
|
3440
3475
|
const wecom = imBots;
|
|
3441
3476
|
const dingtalk = imBotsDingtalk;
|
|
3442
3477
|
const feishu = imBotsFeishu;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
startDingtalkAuthCommandWatcher,
|
|
9
|
+
stopDingtalkAuthCommandWatcher,
|
|
10
|
+
handleDingtalkAuthCommand,
|
|
11
|
+
beginDingtalkAuth,
|
|
12
|
+
resumeDingtalkAuth,
|
|
13
|
+
cancelDingtalkAuth,
|
|
14
|
+
authStatus,
|
|
15
|
+
readDingtalkAuthSession,
|
|
16
|
+
DINGTALK_AUTH_FILE,
|
|
17
|
+
DINGTALK_AUTH_CMD_FILE,
|
|
18
|
+
__resetDingtalkAuthForTest,
|
|
19
|
+
__resetDingtalkAuthCmdForTest
|
|
20
|
+
} from "../dingtalk-auth.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 工程化端到端:**扫码授权整条生命周期**(全注入 fetch,零真实网络)。
|
|
24
|
+
*
|
|
25
|
+
* 这组用例存在的理由:真机上发生过「钉钉侧显示连接成功、我们没接住」——
|
|
26
|
+
* 根因是授权会话只在内存里,进程一重启就丢。这里把 5 个真实场景钉死:
|
|
27
|
+
* ① 面板写命令文件 → 桥监听 → begin 落盘(0600)→ 状态 waiting
|
|
28
|
+
* ② 模拟桥重启(停监听、保留文件)→ resume 续轮询 → 拿到凭据写 im-bots.json → 清理 auth
|
|
29
|
+
* ③ 同一 at 的重复命令不重复执行
|
|
30
|
+
* ④ 钉钉明确拒绝 → 中文人话 + 清理
|
|
31
|
+
* ⑤ 用户取消 → 清理
|
|
32
|
+
* 任何一环退化(例如把会话放回内存、忘记重启后续轮询、泄露 deviceCode/secret)都会红。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
const fileMode = (f) => fs.statSync(f).mode & 0o777;
|
|
37
|
+
const tmpDir = () => {
|
|
38
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), "dt-auth-e2e-"));
|
|
39
|
+
fs.writeFileSync(path.join(d, "im-bots.json"), "{}\n");
|
|
40
|
+
fs.chmodSync(path.join(d, "im-bots.json"), 0o600);
|
|
41
|
+
return d;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function makeFetch(state) {
|
|
45
|
+
return async (url) => {
|
|
46
|
+
const u = String(url);
|
|
47
|
+
const json = (o) => ({ ok: true, status: 200, json: async () => o });
|
|
48
|
+
if (u.endsWith("/init")) return json({ errcode: 0, nonce: "nr_e2e_" + state.seq++, expires_in: 300 });
|
|
49
|
+
if (u.endsWith("/begin")) {
|
|
50
|
+
return json({
|
|
51
|
+
errcode: 0,
|
|
52
|
+
device_code: "DEV_e2e_" + state.seq++,
|
|
53
|
+
interval: 1,
|
|
54
|
+
expires_in: 7200,
|
|
55
|
+
verification_uri_complete: "https://open-dev.dingtalk.com/openapp/registration/openClaw?user_code=E2E-CODE&source=DING_DWS_CLAW"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (u.endsWith("/poll")) {
|
|
59
|
+
state.polls += 1;
|
|
60
|
+
if (state.mode === "fail") return json({ errcode: 0, status: "FAIL", fail_reason: "user_cancelled" });
|
|
61
|
+
if (state.mode === "waiting") return json({ errcode: 0, status: "WAITING" });
|
|
62
|
+
if (state.polls < 2) return json({ errcode: 0, status: "WAITING" });
|
|
63
|
+
return json({ errcode: 0, status: "SUCCESS", client_id: "cli_e2e_123456", client_secret: "SECRET_E2E_VALUE" });
|
|
64
|
+
}
|
|
65
|
+
throw new Error("unexpected url " + u);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("钉钉扫码授权端到端:命令→落盘→重启续轮询→成功写配置→失败/取消", async () => {
|
|
70
|
+
const state = { mode: "waiting", polls: 0, seq: 1 };
|
|
71
|
+
const fetchImpl = makeFetch(state);
|
|
72
|
+
|
|
73
|
+
// ① 面板写命令文件 → 桥监听 → begin 落盘
|
|
74
|
+
let relayDir = tmpDir();
|
|
75
|
+
fs.writeFileSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE), JSON.stringify({ action: "begin", at: Date.now() }));
|
|
76
|
+
fs.chmodSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE), 0o600);
|
|
77
|
+
startDingtalkAuthCommandWatcher({ relayDir, fetchImpl, intervalMs: 50, pollMs: 50 });
|
|
78
|
+
await sleep(500);
|
|
79
|
+
assert.equal(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE)), false, "命令文件必须被消费删除");
|
|
80
|
+
const session = readDingtalkAuthSession(relayDir);
|
|
81
|
+
assert.ok(session && session.deviceCode && session.url, "auth 文件必须落盘 deviceCode/url");
|
|
82
|
+
assert.equal(fileMode(path.join(relayDir, DINGTALK_AUTH_FILE)), 0o600, "auth 文件必须 0600");
|
|
83
|
+
const st = authStatus({ relayDir });
|
|
84
|
+
assert.equal(st.status, "waiting");
|
|
85
|
+
const stFile = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
|
|
86
|
+
assert.equal(stFile.dingtalk.auth.state, "waiting", "状态文件必须透出 dingtalk.auth.state");
|
|
87
|
+
assert.ok(stFile.dingtalk.auth.remainingSec > 0);
|
|
88
|
+
assert.ok(!JSON.stringify(stFile).includes("DEV_e2e_"), "状态文件不得含 deviceCode");
|
|
89
|
+
|
|
90
|
+
// ② 模拟桥重启 → resume 续轮询 → 成功写配置
|
|
91
|
+
stopDingtalkAuthCommandWatcher(relayDir);
|
|
92
|
+
assert.ok(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), "桥退出后 auth 文件必须保留(否则重启丢会话)");
|
|
93
|
+
state.mode = "success";
|
|
94
|
+
state.polls = 0;
|
|
95
|
+
await resumeDingtalkAuth({ relayDir, fetchImpl, pollMs: 50 });
|
|
96
|
+
await sleep(400);
|
|
97
|
+
const bots = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
98
|
+
assert.equal(bots.dingtalk.enabled, true);
|
|
99
|
+
assert.equal(bots.dingtalk.credentialSource, "scan");
|
|
100
|
+
assert.equal(bots.dingtalk.robotCode, bots.dingtalk.clientId, "robotCode 缺省应等于 clientId");
|
|
101
|
+
assert.equal(fileMode(path.join(relayDir, "im-bots.json")), 0o600, "im-bots.json 必须 0600");
|
|
102
|
+
assert.equal(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), false, "成功后必须删除 auth 文件");
|
|
103
|
+
const st2 = authStatus({ relayDir });
|
|
104
|
+
assert.equal(st2.status, "success");
|
|
105
|
+
assert.ok(!JSON.stringify(st2).includes("SECRET_E2E_VALUE"), "摘要不得泄露 secret");
|
|
106
|
+
|
|
107
|
+
// ③ 同一 at 重复命令不重复执行
|
|
108
|
+
const atSame = Date.now();
|
|
109
|
+
fs.writeFileSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE), JSON.stringify({ action: "begin", at: atSame }));
|
|
110
|
+
await handleDingtalkAuthCommand({ relayDir, fetchImpl });
|
|
111
|
+
fs.writeFileSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE), JSON.stringify({ action: "begin", at: atSame }));
|
|
112
|
+
const dup = await handleDingtalkAuthCommand({ relayDir, fetchImpl });
|
|
113
|
+
assert.equal(dup.skipped, true, "同一 at 的命令必须跳过");
|
|
114
|
+
|
|
115
|
+
// ④ 钉钉明确拒绝 → 中文人话 + 清理
|
|
116
|
+
__resetDingtalkAuthForTest();
|
|
117
|
+
__resetDingtalkAuthCmdForTest();
|
|
118
|
+
relayDir = tmpDir();
|
|
119
|
+
state.mode = "fail";
|
|
120
|
+
state.polls = 0;
|
|
121
|
+
await beginDingtalkAuth({ relayDir, fetchImpl, pollMs: 50 });
|
|
122
|
+
await sleep(300);
|
|
123
|
+
const failed = authStatus({ relayDir });
|
|
124
|
+
assert.equal(failed.status, "failed");
|
|
125
|
+
assert.match(String(failed.message || ""), /[\u4e00-\u9fa5]/, "失败原因必须是中文人话");
|
|
126
|
+
assert.equal(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), false, "失败必须清理 auth 文件");
|
|
127
|
+
|
|
128
|
+
// ⑤ 取消
|
|
129
|
+
__resetDingtalkAuthForTest();
|
|
130
|
+
relayDir = tmpDir();
|
|
131
|
+
state.mode = "waiting";
|
|
132
|
+
state.polls = 0;
|
|
133
|
+
await beginDingtalkAuth({ relayDir, fetchImpl, pollMs: 50 });
|
|
134
|
+
assert.ok(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)));
|
|
135
|
+
cancelDingtalkAuth({ relayDir });
|
|
136
|
+
await sleep(100);
|
|
137
|
+
assert.equal(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), false, "取消必须清理 auth 文件");
|
|
138
|
+
});
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dingtalk-auth — 扫码授权会话「持久化 + 断点续轮询」单测(2026-11-08)。
|
|
3
|
+
*
|
|
4
|
+
* 零真实网络:注入假 fetch 模拟 `/app/registration/{init,begin,poll}`,用临时 relayDir 验证落盘/清理/状态。
|
|
5
|
+
* 覆盖:begin 写 0600 文件、重启后 resume 继续轮询、成功后写 im-bots.json 并删 auth 文件、
|
|
6
|
+
* FAIL/EXPIRED 清理、网络错误继续重试、过期不重试、并发 begin 覆盖旧会话、secret 不进日志、bridge 接线源级契约。
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
DINGTALK_AUTH_CMD_FILE,
|
|
16
|
+
DINGTALK_AUTH_FILE,
|
|
17
|
+
__resetDingtalkAuthForTest,
|
|
18
|
+
authStatus,
|
|
19
|
+
beginDingtalkAuth,
|
|
20
|
+
cancelDingtalkAuth,
|
|
21
|
+
handleDingtalkAuthCommand,
|
|
22
|
+
readDingtalkAuthSession,
|
|
23
|
+
resumeDingtalkAuth,
|
|
24
|
+
startDingtalkAuthCommandWatcher,
|
|
25
|
+
stopDingtalkAuthCommandWatcher,
|
|
26
|
+
stopDingtalkAuthPolling
|
|
27
|
+
} from "../dingtalk-auth.mjs";
|
|
28
|
+
import {
|
|
29
|
+
IM_BOTS_FILE,
|
|
30
|
+
IM_BOT_STATUS_FILE,
|
|
31
|
+
fileMode
|
|
32
|
+
} from "../wecom-bot.mjs";
|
|
33
|
+
|
|
34
|
+
const SECRET = "ScanSecret-Never-Log-987654";
|
|
35
|
+
const CLIENT = "ding-scan-client-xyz";
|
|
36
|
+
const URL_OK = "https://dingtalk.test/registration/verify?nonce=abc";
|
|
37
|
+
|
|
38
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
39
|
+
|
|
40
|
+
async function waitFor(fn, { label = "条件", timeout = 5000, interval = 15 } = {}) {
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
for (;;) {
|
|
43
|
+
let v;
|
|
44
|
+
try { v = await fn(); } catch { v = undefined; }
|
|
45
|
+
if (v) return v;
|
|
46
|
+
if (Date.now() - t0 > timeout) throw new Error(`等待超时:${label}`);
|
|
47
|
+
await sleep(interval);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tmpRelayDir() {
|
|
52
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-ding-auth-"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 假钉钉注册 API。
|
|
57
|
+
* @param {{init?:object, begins?:object[], polls?:any[]}} script
|
|
58
|
+
* polls 每项:{status:"WAITING"|"SUCCESS"|"FAIL"|"EXPIRED", client_id?, client_secret?} 或 "throw"(网络错误)
|
|
59
|
+
*/
|
|
60
|
+
function fakeApi(script = {}) {
|
|
61
|
+
const calls = [];
|
|
62
|
+
let initCount = 0;
|
|
63
|
+
let beginCount = 0;
|
|
64
|
+
let pollCount = 0;
|
|
65
|
+
const respond = (json, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => json });
|
|
66
|
+
const fetchImpl = async (url, init0) => {
|
|
67
|
+
const u = String(url);
|
|
68
|
+
let body = {};
|
|
69
|
+
try { body = JSON.parse(init0 && init0.body ? init0.body : "{}"); } catch { body = {}; }
|
|
70
|
+
calls.push({ url: u, body });
|
|
71
|
+
if (u.endsWith("/app/registration/init")) {
|
|
72
|
+
initCount += 1;
|
|
73
|
+
return respond(script.init || { nonce: `nonce-${initCount}` });
|
|
74
|
+
}
|
|
75
|
+
if (u.endsWith("/app/registration/begin")) {
|
|
76
|
+
const begins = Array.isArray(script.begins) ? script.begins : [];
|
|
77
|
+
const item = begins[beginCount] || {
|
|
78
|
+
device_code: `dc-${beginCount + 1}`,
|
|
79
|
+
verification_uri_complete: URL_OK,
|
|
80
|
+
interval: 1,
|
|
81
|
+
expires_in: 7200
|
|
82
|
+
};
|
|
83
|
+
beginCount += 1;
|
|
84
|
+
if (item && item.__throw) throw new Error("network down");
|
|
85
|
+
return respond(item);
|
|
86
|
+
}
|
|
87
|
+
if (u.endsWith("/app/registration/poll")) {
|
|
88
|
+
const polls = Array.isArray(script.polls) ? script.polls : [];
|
|
89
|
+
const item = polls[pollCount];
|
|
90
|
+
pollCount += 1;
|
|
91
|
+
if (item === "throw") throw new Error("network down");
|
|
92
|
+
if (!item) return respond({ status: "WAITING" });
|
|
93
|
+
if (typeof item === "function") return respond(item(body));
|
|
94
|
+
return respond(item);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`unexpected url ${u}`);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
fetchImpl,
|
|
100
|
+
calls,
|
|
101
|
+
get initCalls() { return calls.filter((c) => c.url.endsWith("/init")); },
|
|
102
|
+
get beginCalls() { return calls.filter((c) => c.url.endsWith("/begin")); },
|
|
103
|
+
get pollCalls() { return calls.filter((c) => c.url.endsWith("/poll")); }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readJson(file) {
|
|
108
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cleanup(relayDir) {
|
|
112
|
+
try { cancelDingtalkAuth({ relayDir }); } catch { /* 忽略 */ }
|
|
113
|
+
__resetDingtalkAuthForTest();
|
|
114
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─────────────────────────── begin ───────────────────────────
|
|
118
|
+
|
|
119
|
+
test("★ begin:init({source}) → begin({nonce});会话原子落盘 0600(deviceCode/interval/expiresAt/url);返回 {url,expiresIn}", async () => {
|
|
120
|
+
const relayDir = tmpRelayDir();
|
|
121
|
+
const api = fakeApi();
|
|
122
|
+
const logs = [];
|
|
123
|
+
try {
|
|
124
|
+
const r = await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: (l, m) => logs.push(m), pollMs: 30 });
|
|
125
|
+
assert.equal(r.ok, true);
|
|
126
|
+
assert.equal(r.url, URL_OK);
|
|
127
|
+
assert.ok(r.expiresIn >= 60, "expiresIn 来自钉钉 expires_in(分钟级)");
|
|
128
|
+
assert.equal(r.interval, 1);
|
|
129
|
+
// 请求体
|
|
130
|
+
assert.deepEqual(api.initCalls[0].body, { source: "DING_DWS_CLAW" });
|
|
131
|
+
assert.deepEqual(api.beginCalls[0].body, { nonce: "nonce-1" });
|
|
132
|
+
// 落盘
|
|
133
|
+
const file = path.join(relayDir, DINGTALK_AUTH_FILE);
|
|
134
|
+
assert.ok(fs.existsSync(file), "★ 授权会话必须落盘(重启不丢)");
|
|
135
|
+
const sess = readDingtalkAuthSession(relayDir);
|
|
136
|
+
assert.equal(sess.deviceCode, "dc-1");
|
|
137
|
+
assert.equal(sess.url, URL_OK);
|
|
138
|
+
assert.equal(sess.interval, 1);
|
|
139
|
+
assert.ok(sess.expiresAt > Date.now());
|
|
140
|
+
assert.ok(sess.createdAt > 0);
|
|
141
|
+
const disk = readJson(file);
|
|
142
|
+
assert.equal(disk.credentialSource, "scan");
|
|
143
|
+
if (process.platform !== "win32") {
|
|
144
|
+
assert.equal(fileMode(file) & 0o777, 0o600, "★ auth 文件必须 0600(含 device_code)");
|
|
145
|
+
}
|
|
146
|
+
// 插件可读状态
|
|
147
|
+
const st = authStatus({ relayDir });
|
|
148
|
+
assert.equal(st.status, "waiting");
|
|
149
|
+
assert.ok(st.remainingSeconds > 0);
|
|
150
|
+
assert.equal(st.url, URL_OK);
|
|
151
|
+
// 日志不泄漏 device_code
|
|
152
|
+
assert.ok(!logs.join("\n").includes("dc-1"), "日志不得打印 device_code");
|
|
153
|
+
} finally { cleanup(relayDir); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─────────────────────────── 断点续轮询 ───────────────────────────
|
|
157
|
+
|
|
158
|
+
test("★ 重启后 resume 继续轮询 → 拿到凭据写 im-bots.json(scan/enabled/robotCode 缺省 clientId)→ 删 auth 文件 + 状态 connected", async () => {
|
|
159
|
+
const relayDir = tmpRelayDir();
|
|
160
|
+
const logs = [];
|
|
161
|
+
const logger = (l, m) => logs.push(m);
|
|
162
|
+
const api = fakeApi({ polls: [{ status: "WAITING" }, { status: "WAITING" }, { status: "SUCCESS", client_id: CLIENT, client_secret: SECRET }] });
|
|
163
|
+
try {
|
|
164
|
+
await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger, pollMs: 25 });
|
|
165
|
+
await waitFor(() => api.pollCalls.length >= 1, { label: "begin 后立刻 poll" });
|
|
166
|
+
// 模拟「bridge 正常退出」:只停定时器,保留 auth 文件
|
|
167
|
+
stopDingtalkAuthPolling();
|
|
168
|
+
const callsAtStop = api.pollCalls.length;
|
|
169
|
+
await sleep(60);
|
|
170
|
+
assert.equal(api.pollCalls.length, callsAtStop, "停表后不得再 poll");
|
|
171
|
+
assert.ok(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), "★ 停表必须保留 auth 文件(否则重启就丢)");
|
|
172
|
+
|
|
173
|
+
// 模拟「bridge 重启」:resume 继续轮询(同一个 device_code)
|
|
174
|
+
const successes = [];
|
|
175
|
+
const resumed = await resumeDingtalkAuth({
|
|
176
|
+
relayDir, fetchImpl: api.fetchImpl, logger, pollMs: 20,
|
|
177
|
+
onSuccess: (info) => successes.push(info)
|
|
178
|
+
});
|
|
179
|
+
assert.equal(resumed.ok, true);
|
|
180
|
+
assert.equal(resumed.status, "waiting");
|
|
181
|
+
await waitFor(() => readJson(path.join(relayDir, IM_BOTS_FILE)), { label: "写 im-bots.json" });
|
|
182
|
+
|
|
183
|
+
const bots = readJson(path.join(relayDir, IM_BOTS_FILE));
|
|
184
|
+
const d = bots.dingtalk;
|
|
185
|
+
assert.equal(d.enabled, true, "★ 扫码成功后自动启用");
|
|
186
|
+
assert.equal(d.clientId, CLIENT);
|
|
187
|
+
assert.equal(d.clientSecret, SECRET);
|
|
188
|
+
assert.equal(d.credentialSource, "scan", "★ 标记扫码来源");
|
|
189
|
+
assert.equal(d.robotCode, CLIENT, "robotCode 缺省用 clientId(Lead 约定)");
|
|
190
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), "★ 成功后必须删除 auth 文件(device_code 作废)");
|
|
191
|
+
const st = readJson(path.join(relayDir, IM_BOT_STATUS_FILE));
|
|
192
|
+
assert.equal(st.dingtalk.state, "connected", "★ 写状态 dingtalk.state=connected(实际连接由 controller 热更新完成)");
|
|
193
|
+
assert.equal(st.dingtalk.credentialSource, "scan");
|
|
194
|
+
assert.deepEqual(successes, [{ clientIdTail: CLIENT.slice(-4), credentialSource: "scan" }], "onSuccess 回调一次");
|
|
195
|
+
assert.equal(authStatus({ relayDir }).status, "success");
|
|
196
|
+
// 所有 poll 必须带同一个 device_code(来自磁盘)
|
|
197
|
+
assert.ok(api.pollCalls.every((c) => c.body.device_code === "dc-1"));
|
|
198
|
+
// secret 不落日志
|
|
199
|
+
assert.ok(!logs.join("\n").includes(SECRET), "★ secret 不得进日志(只打 clientId 尾号)");
|
|
200
|
+
assert.ok(!logs.join("\n").includes(SECRET.slice(0, 8)));
|
|
201
|
+
} finally { cleanup(relayDir); }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("★ FAIL / EXPIRED:写状态原因并清理 auth 文件,不无限重试", async () => {
|
|
205
|
+
for (const [status, expectCode] of [["FAIL", "rejected"], ["EXPIRED", "expired"]]) {
|
|
206
|
+
const relayDir = tmpRelayDir();
|
|
207
|
+
const api = fakeApi({ polls: [{ status }] });
|
|
208
|
+
try {
|
|
209
|
+
await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, pollMs: 20 });
|
|
210
|
+
await waitFor(() => authStatus({ relayDir }).status === "failed", { label: `${status} → failed` });
|
|
211
|
+
const st = readJson(path.join(relayDir, IM_BOT_STATUS_FILE));
|
|
212
|
+
assert.equal(st.dingtalk.state, "failed");
|
|
213
|
+
assert.ok(/授权|过期|拒绝/.test(String(st.dingtalk.lastError || "")), `失败原因要是中文人话(实际:${st.dingtalk.lastError})`);
|
|
214
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), "失败后清理 auth 文件");
|
|
215
|
+
const calls = api.pollCalls.length;
|
|
216
|
+
await sleep(80);
|
|
217
|
+
assert.equal(api.pollCalls.length, calls, "失败后不得继续轮询");
|
|
218
|
+
assert.equal(authStatus({ relayDir }).code, expectCode);
|
|
219
|
+
} finally { cleanup(relayDir); }
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("★ 网络错误继续重试(不丢会话):几次 throw 之后成功", async () => {
|
|
224
|
+
const relayDir = tmpRelayDir();
|
|
225
|
+
const api = fakeApi({
|
|
226
|
+
polls: ["throw", "throw", { status: "WAITING" }, { status: "SUCCESS", client_id: CLIENT, client_secret: SECRET }]
|
|
227
|
+
});
|
|
228
|
+
try {
|
|
229
|
+
await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, pollMs: 15 });
|
|
230
|
+
await waitFor(() => readJson(path.join(relayDir, IM_BOTS_FILE)), { label: "网络恢复后拿到凭据", timeout: 5000 });
|
|
231
|
+
assert.ok(api.pollCalls.length >= 4, `网络错误必须继续重试(实际 poll ${api.pollCalls.length} 次)`);
|
|
232
|
+
assert.equal(readJson(path.join(relayDir, IM_BOTS_FILE)).dingtalk.clientId, CLIENT);
|
|
233
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)));
|
|
234
|
+
} finally { cleanup(relayDir); }
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("★ 已过期会话:resume 不轮询、清理文件并写失败状态;无会话时 authStatus=idle", async () => {
|
|
238
|
+
const relayDir = tmpRelayDir();
|
|
239
|
+
const api = fakeApi();
|
|
240
|
+
try {
|
|
241
|
+
fs.writeFileSync(path.join(relayDir, DINGTALK_AUTH_FILE), JSON.stringify({
|
|
242
|
+
version: 1, deviceCode: "dc-expired", interval: 1,
|
|
243
|
+
expiresAt: Date.now() - 1000, url: URL_OK, createdAt: Date.now() - 60_000, credentialSource: "scan"
|
|
244
|
+
}), { mode: 0o600 });
|
|
245
|
+
const r = await resumeDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: () => {} });
|
|
246
|
+
assert.equal(r.ok, false);
|
|
247
|
+
assert.equal(r.code, "expired");
|
|
248
|
+
assert.equal(api.pollCalls.length, 0, "★ 过期不得再 poll");
|
|
249
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), "过期要清理 auth 文件");
|
|
250
|
+
const st = readJson(path.join(relayDir, IM_BOT_STATUS_FILE));
|
|
251
|
+
assert.equal(st.dingtalk.state, "failed");
|
|
252
|
+
assert.match(String(st.dingtalk.lastError), /过期/);
|
|
253
|
+
// 空目录 → idle
|
|
254
|
+
const empty = tmpRelayDir();
|
|
255
|
+
try { assert.equal(authStatus({ relayDir: empty }).status, "idle"); }
|
|
256
|
+
finally { fs.rmSync(empty, { recursive: true, force: true }); }
|
|
257
|
+
} finally { cleanup(relayDir); }
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────── 并发覆盖 / 取消 / 安全 ───────────────────────────
|
|
261
|
+
|
|
262
|
+
test("★ 并发 begin:新授权覆盖旧会话(旧 device_code 停止轮询、文件只留新的)", async () => {
|
|
263
|
+
const relayDir = tmpRelayDir();
|
|
264
|
+
const api = fakeApi({
|
|
265
|
+
begins: [
|
|
266
|
+
{ device_code: "dc-old", verification_uri_complete: "https://dingtalk.test/old", interval: 1, expires_in: 7200 },
|
|
267
|
+
{ device_code: "dc-new", verification_uri_complete: "https://dingtalk.test/new", interval: 1, expires_in: 7200 }
|
|
268
|
+
],
|
|
269
|
+
polls: [] // 全部 WAITING(新会话不会提前结束)
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, pollMs: 20 });
|
|
273
|
+
await waitFor(() => readDingtalkAuthSession(relayDir).deviceCode === "dc-old", { label: "旧会话" });
|
|
274
|
+
const oldPolls = api.pollCalls.filter((c) => c.body.device_code === "dc-old").length;
|
|
275
|
+
// 立刻发起新的 begin(用户重新扫码)
|
|
276
|
+
const r2 = await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, pollMs: 20 });
|
|
277
|
+
assert.equal(r2.ok, true);
|
|
278
|
+
assert.equal(readDingtalkAuthSession(relayDir).deviceCode, "dc-new", "★ 文件只保留最新会话");
|
|
279
|
+
await sleep(90);
|
|
280
|
+
assert.equal(api.pollCalls.filter((c) => c.body.device_code === "dc-old").length, oldPolls, "★ 旧会话的轮询必须停止(旧 device_code 不再被 poll)");
|
|
281
|
+
assert.ok(api.pollCalls.some((c) => c.body.device_code === "dc-new"), "新会话在轮询");
|
|
282
|
+
} finally { cleanup(relayDir); }
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("★ 取消:停轮询 + 删文件 + 状态标记取消;不泄漏 secret", async () => {
|
|
286
|
+
const relayDir = tmpRelayDir();
|
|
287
|
+
const logs = [];
|
|
288
|
+
const api = fakeApi({ polls: [] }); // 一直 WAITING:专门测取消
|
|
289
|
+
try {
|
|
290
|
+
cancelDingtalkAuth({ relayDir }); // 无会话也可安全调用
|
|
291
|
+
await beginDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: (l, m) => logs.push(m), pollMs: 50 });
|
|
292
|
+
await waitFor(() => fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "会话写入" });
|
|
293
|
+
const polls = api.pollCalls.length;
|
|
294
|
+
cancelDingtalkAuth({ relayDir });
|
|
295
|
+
await sleep(80);
|
|
296
|
+
assert.equal(api.pollCalls.length, polls, "取消后不得继续轮询");
|
|
297
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)));
|
|
298
|
+
assert.equal(authStatus({ relayDir }).status, "failed");
|
|
299
|
+
assert.equal(authStatus({ relayDir }).code, "cancelled");
|
|
300
|
+
assert.ok(!logs.join("\n").includes(SECRET));
|
|
301
|
+
} finally { cleanup(relayDir); }
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("★ bridge 接线(源级契约):启动时 fire-and-forget resumeDingtalkAuth,退出时 stopDingtalkAuthPolling", () => {
|
|
305
|
+
const bridge = fs.readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
|
|
306
|
+
assert.match(bridge, /from "\.\/dingtalk-auth\.mjs"/);
|
|
307
|
+
assert.match(bridge, /resumeDingtalkAuth\(\{/, "bridge 启动必须调用 resumeDingtalkAuth");
|
|
308
|
+
assert.match(bridge, /void resumeDingtalkAuth\(/, "必须是 fire-and-forget(不阻塞隧道启动)");
|
|
309
|
+
assert.match(bridge, /stopDingtalkAuthPolling\(\)/, "退出时必须停轮询");
|
|
310
|
+
assert.ok(/stopDingtalkAuthPolling\(\);/.test(bridge.split("async function stopImBots")[1] || ""), "停轮询要挂在 stopImBots 里");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ─────────────────────── 控制命令文件(插件 → 桥)───────────────────────
|
|
314
|
+
|
|
315
|
+
function writeCmd(relayDir, cmd) {
|
|
316
|
+
fs.writeFileSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE), JSON.stringify(cmd), { mode: 0o600 });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function authSnapshot(relayDir) {
|
|
320
|
+
const st = readJson(path.join(relayDir, IM_BOT_STATUS_FILE));
|
|
321
|
+
return st && st.dingtalk && st.dingtalk.auth ? st.dingtalk.auth : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
test("★ 命令文件 begin:桥执行 beginDingtalkAuth → 删命令文件;`dingtalk.auth` 就绪且不含 deviceCode/secret", async () => {
|
|
325
|
+
const relayDir = tmpRelayDir();
|
|
326
|
+
const api = fakeApi({ polls: [] });
|
|
327
|
+
try {
|
|
328
|
+
writeCmd(relayDir, { action: "begin", at: 111 });
|
|
329
|
+
const w = startDingtalkAuthCommandWatcher({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, intervalMs: 15, pollMs: 20 });
|
|
330
|
+
assert.equal(w.ok, true);
|
|
331
|
+
await waitFor(() => fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "begin 生效(auth 文件)" });
|
|
332
|
+
await waitFor(() => !fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE)), { label: "命令文件被删除" });
|
|
333
|
+
const auth = await waitFor(() => authSnapshot(relayDir), { label: "状态外露 dingtalk.auth" });
|
|
334
|
+
assert.equal(auth.state, "waiting");
|
|
335
|
+
assert.equal(auth.url, URL_OK);
|
|
336
|
+
assert.ok(auth.expiresAt > Date.now());
|
|
337
|
+
assert.ok(auth.remainingSec > 0, "要有剩余秒数(面板显示倒计时)");
|
|
338
|
+
assert.equal(auth.clientIdTail, "");
|
|
339
|
+
const raw = fs.readFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), "utf8");
|
|
340
|
+
assert.ok(!raw.includes("dc-1"), "★ dingtalk.auth 不得含 deviceCode");
|
|
341
|
+
assert.ok(!raw.includes(SECRET), "★ 不得含 secret");
|
|
342
|
+
assert.ok(api.initCalls.length === 1);
|
|
343
|
+
} finally {
|
|
344
|
+
stopDingtalkAuthCommandWatcher();
|
|
345
|
+
cleanup(relayDir);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("★ 重复 at 不重复处理(同 at 再来一次不会重新发起 begin)", async () => {
|
|
350
|
+
const relayDir = tmpRelayDir();
|
|
351
|
+
const api = fakeApi({ polls: [] });
|
|
352
|
+
try {
|
|
353
|
+
writeCmd(relayDir, { action: "begin", at: 222 });
|
|
354
|
+
startDingtalkAuthCommandWatcher({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, intervalMs: 15, pollMs: 20 });
|
|
355
|
+
await waitFor(() => fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "第一次 begin 生效" });
|
|
356
|
+
const initCalls = api.initCalls.length;
|
|
357
|
+
// 删掉 auth 文件以便检测"是否又被 begin 了一次",再写同 at 命令
|
|
358
|
+
fs.rmSync(path.join(relayDir, DINGTALK_AUTH_FILE), { force: true });
|
|
359
|
+
writeCmd(relayDir, { action: "begin", at: 222 });
|
|
360
|
+
await sleep(120);
|
|
361
|
+
assert.equal(api.initCalls.length, initCalls, "★ 同一 at 不得重复执行");
|
|
362
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), "不得重新生成会话");
|
|
363
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE)), "重复命令文件也要删掉(避免刷轮询)");
|
|
364
|
+
} finally {
|
|
365
|
+
stopDingtalkAuthCommandWatcher();
|
|
366
|
+
cleanup(relayDir);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("★ 命令文件 cancel:停轮询 + 删 auth 文件;`dingtalk.auth.state=failed` 且 message 是人话", async () => {
|
|
371
|
+
const relayDir = tmpRelayDir();
|
|
372
|
+
const api = fakeApi({ polls: [] });
|
|
373
|
+
try {
|
|
374
|
+
writeCmd(relayDir, { action: "begin", at: 301 });
|
|
375
|
+
startDingtalkAuthCommandWatcher({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, intervalMs: 15, pollMs: 20 });
|
|
376
|
+
await waitFor(() => fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "begin" });
|
|
377
|
+
writeCmd(relayDir, { action: "cancel", at: 302 });
|
|
378
|
+
await waitFor(() => !fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "cancel 生效(auth 文件删除)" });
|
|
379
|
+
await waitFor(() => { const a = authSnapshot(relayDir); return a && a.state === "failed"; }, { label: "状态 failed" });
|
|
380
|
+
const auth = authSnapshot(relayDir);
|
|
381
|
+
assert.match(String(auth.message), /取消/);
|
|
382
|
+
assert.equal(auth.remainingSec, 0);
|
|
383
|
+
assert.equal(auth.url, "");
|
|
384
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE)));
|
|
385
|
+
// 取消后不得继续 poll
|
|
386
|
+
const polls = api.pollCalls.length;
|
|
387
|
+
await sleep(80);
|
|
388
|
+
assert.equal(api.pollCalls.length, polls);
|
|
389
|
+
} finally {
|
|
390
|
+
stopDingtalkAuthCommandWatcher();
|
|
391
|
+
cleanup(relayDir);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("★ 扫码成功后 `dingtalk.auth`:state=success + clientIdTail;状态文件不含 deviceCode/secret", async () => {
|
|
396
|
+
const relayDir = tmpRelayDir();
|
|
397
|
+
const api = fakeApi({ polls: [{ status: "WAITING" }, { status: "SUCCESS", client_id: CLIENT, client_secret: SECRET }] });
|
|
398
|
+
try {
|
|
399
|
+
writeCmd(relayDir, { action: "begin", at: 401 });
|
|
400
|
+
startDingtalkAuthCommandWatcher({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, intervalMs: 15, pollMs: 20 });
|
|
401
|
+
await waitFor(() => readJson(path.join(relayDir, IM_BOTS_FILE)), { label: "凭据落盘" });
|
|
402
|
+
const auth = await waitFor(() => { const a = authSnapshot(relayDir); return a && a.state === "success" && a; }, { label: "auth success" });
|
|
403
|
+
assert.equal(auth.clientIdTail, CLIENT.slice(-4));
|
|
404
|
+
assert.equal(auth.remainingSec, 0);
|
|
405
|
+
const raw = fs.readFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), "utf8");
|
|
406
|
+
assert.ok(!raw.includes(SECRET), "★ 状态文件不得含 secret");
|
|
407
|
+
assert.ok(!raw.includes("dc-1"), "★ 状态文件不得含 deviceCode");
|
|
408
|
+
assert.ok(!fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)));
|
|
409
|
+
const botsRaw = fs.readFileSync(path.join(relayDir, IM_BOTS_FILE), "utf8");
|
|
410
|
+
assert.ok(botsRaw.includes(SECRET), "凭据只应写进 im-bots.json(0600)");
|
|
411
|
+
if (process.platform !== "win32") assert.equal(fileMode(path.join(relayDir, IM_BOTS_FILE)) & 0o777, 0o600);
|
|
412
|
+
} finally {
|
|
413
|
+
stopDingtalkAuthCommandWatcher();
|
|
414
|
+
cleanup(relayDir);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("★ 重启后 resume 与命令文件互不干扰:先续轮询旧会话,cancel 命令能停掉它", async () => {
|
|
419
|
+
const relayDir = tmpRelayDir();
|
|
420
|
+
const api = fakeApi({ polls: [] });
|
|
421
|
+
try {
|
|
422
|
+
// 模拟上一轮 bridge 退出时留下的未完成会话
|
|
423
|
+
fs.writeFileSync(path.join(relayDir, DINGTALK_AUTH_FILE), JSON.stringify({
|
|
424
|
+
version: 1, deviceCode: "dc-seed", interval: 1, expiresAt: Date.now() + 60_000,
|
|
425
|
+
url: URL_OK, createdAt: Date.now(), credentialSource: "scan"
|
|
426
|
+
}), { mode: 0o600 });
|
|
427
|
+
const resumed = await resumeDingtalkAuth({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, pollMs: 20 });
|
|
428
|
+
assert.equal(resumed.ok, true);
|
|
429
|
+
await waitFor(() => api.pollCalls.some((c) => c.body.device_code === "dc-seed"), { label: "重启后续轮询旧 device_code" });
|
|
430
|
+
// 同时启动命令监听(bridge 启动流程就是 resume + watcher)
|
|
431
|
+
startDingtalkAuthCommandWatcher({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, intervalMs: 15, pollMs: 20 });
|
|
432
|
+
await waitFor(() => authSnapshot(relayDir), { label: "auth 状态外露" });
|
|
433
|
+
assert.equal(authSnapshot(relayDir).state, "waiting");
|
|
434
|
+
// cancel 命令应停掉续轮询的会话
|
|
435
|
+
writeCmd(relayDir, { action: "cancel", at: 501 });
|
|
436
|
+
await waitFor(() => !fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "cancel 清掉旧会话" });
|
|
437
|
+
const polls = api.pollCalls.length;
|
|
438
|
+
await sleep(80);
|
|
439
|
+
assert.equal(api.pollCalls.length, polls, "cancel 后旧会话轮询必须停止(互不干扰)");
|
|
440
|
+
} finally {
|
|
441
|
+
stopDingtalkAuthCommandWatcher();
|
|
442
|
+
cleanup(relayDir);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("★ stop 命令监听后不再处理命令(bridge 退出语义;文件保留给下次处理)", async () => {
|
|
447
|
+
const relayDir = tmpRelayDir();
|
|
448
|
+
const api = fakeApi({ polls: [] });
|
|
449
|
+
try {
|
|
450
|
+
startDingtalkAuthCommandWatcher({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, intervalMs: 15, pollMs: 20 });
|
|
451
|
+
stopDingtalkAuthCommandWatcher();
|
|
452
|
+
writeCmd(relayDir, { action: "begin", at: 601 });
|
|
453
|
+
await sleep(100);
|
|
454
|
+
assert.equal(api.initCalls.length, 0, "★ 停监听后不得再处理命令");
|
|
455
|
+
assert.ok(fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE)), "文件保留(下次启动会先检查一次)");
|
|
456
|
+
// handleDingtalkAuthCommand 可被直接调用(启动时的一次性检查路径)
|
|
457
|
+
const r = await handleDingtalkAuthCommand({ relayDir, fetchImpl: api.fetchImpl, logger: () => {}, pollMs: 20 });
|
|
458
|
+
assert.equal(r.ok, true);
|
|
459
|
+
assert.equal(r.action, "begin");
|
|
460
|
+
await waitFor(() => fs.existsSync(path.join(relayDir, DINGTALK_AUTH_FILE)), { label: "手动检查能处理" });
|
|
461
|
+
} finally {
|
|
462
|
+
stopDingtalkAuthCommandWatcher();
|
|
463
|
+
cleanup(relayDir);
|
|
464
|
+
}
|
|
465
|
+
});
|
package/dsh-setup.mjs
CHANGED
|
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
|
|
|
149
149
|
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
150
150
|
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
|
|
151
151
|
*/
|
|
152
|
-
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
152
|
+
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* 把"客户端脚本"补齐到配置目录。
|