@mrrisega/dsh-remote 0.7.12-beta.4 → 0.7.12-beta.6

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.
@@ -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
+ });