@mrrisega/dsh-remote 0.6.0-beta.8 → 0.6.0

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,430 @@
1
+ // 🔒 修改密码 / 忘记密码(短信验证码重置):
2
+ // - node 半代理:POST /dsh-remote/password/reset → 企业端公开 /api/password/reset(无需 Bearer,透传 ok/error);
3
+ // - 浏览器半:账号卡「🔒 修改密码」小表单 + 登录卡「忘记密码?」共用同一重置表单
4
+ // (手机号[账号卡只读/登录卡可编辑预填] + 图形验证码 + 短信验证码 + 新密码≥8),
5
+ // 成功后本地登出(清反馈线程凭据)并提示用新密码重新登录(登录会覆盖桌面端 config 密码)。
6
+ import assert from "node:assert/strict";
7
+ import http from "node:http";
8
+ import { readFileSync } from "node:fs";
9
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+ import vm from "node:vm";
14
+ import { apply } from "../lib/index.js";
15
+
16
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
17
+ const NODE_SOURCE = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
18
+
19
+ // ---------- node 半:/dsh-remote/password/reset 代理路由 ----------
20
+
21
+ test("修改密码代理:POST /dsh-remote/password/reset → 企业端 /api/password/reset(无 Bearer,透传 ok/error)", async () => {
22
+ let upstream;
23
+ const seen = [];
24
+ const relay = http.createServer(async (req, res) => {
25
+ const url = new URL(req.url, "http://x");
26
+ let raw = "";
27
+ for await (const chunk of req) raw += chunk;
28
+ seen.push({ method: req.method, path: url.pathname, auth: req.headers.authorization || "", body: JSON.parse(raw || "{}") });
29
+ if (url.pathname === "/api/password/reset") {
30
+ if (JSON.parse(raw).sms_code === "bad-code") {
31
+ res.writeHead(400, { "content-type": "application/json" });
32
+ res.end(JSON.stringify({ ok: false, error: { code: "sms_code_invalid", message: "短信验证码错误或已过期" } }));
33
+ return;
34
+ }
35
+ res.writeHead(200, { "content-type": "application/json" });
36
+ res.end(JSON.stringify({ ok: true }));
37
+ return;
38
+ }
39
+ res.writeHead(404, { "content-type": "application/json" });
40
+ res.end(JSON.stringify({ error: "not_found" }));
41
+ });
42
+ await new Promise((resolve) => relay.listen(0, "127.0.0.1", resolve));
43
+
44
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-pwd-test-"));
45
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
46
+ api_url: `http://127.0.0.1:${relay.address().port}`
47
+ }));
48
+
49
+ const routes = new Map();
50
+ apply({
51
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
52
+ effect(register) { return register(); },
53
+ logger: { info() {}, warn() {} },
54
+ }, { relayDir: tempDir });
55
+ const host = http.createServer((req, res) => routes.get(new URL(req.url, "http://x").pathname)(req, res));
56
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
57
+
58
+ try {
59
+ // 成功路径:公开接口无需 Bearer,原样转发 {phone,sms_code,new_password}
60
+ const okRes = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/password/reset`, {
61
+ method: "POST",
62
+ headers: { "content-type": "application/json" },
63
+ body: JSON.stringify({ phone: "13800000000", sms_code: "218937", new_password: "newpass123" }),
64
+ });
65
+ assert.equal(okRes.status, 200);
66
+ const okBody = await okRes.json();
67
+ assert.equal(okBody.ok, true, "成功应透传 ok:true");
68
+ assert.deepEqual(upstream = seen[0], {
69
+ method: "POST",
70
+ path: "/api/password/reset",
71
+ auth: "",
72
+ body: { phone: "13800000000", sms_code: "218937", new_password: "newpass123" },
73
+ }, "转发到企业端 /api/password/reset,且不带任何 Bearer");
74
+
75
+ // 失败路径:企业端 error 原样透传(含状态码与错误体)
76
+ const badRes = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/password/reset`, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json" },
79
+ body: JSON.stringify({ phone: "13800000000", sms_code: "bad-code", new_password: "newpass123" }),
80
+ });
81
+ assert.equal(badRes.status, 400, "企业端失败状态码应透传");
82
+ const badBody = await badRes.json();
83
+ assert.equal(badBody.ok, false);
84
+ assert.equal(badBody.body.error.code, "sms_code_invalid", "企业端错误体应透传(UI 可读提示)");
85
+
86
+ // 缺参校验
87
+ const missing = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/password/reset`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({ phone: "13800000000" }),
91
+ });
92
+ assert.equal(missing.status, 400);
93
+ } finally {
94
+ host.close();
95
+ relay.close();
96
+ await rm(tempDir, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ // ---------- 浏览器半:账号卡「修改密码」小表单点击流 ----------
101
+
102
+ function walk(node, visit) {
103
+ if (!node || typeof node !== "object") return;
104
+ visit(node);
105
+ for (const child of node.children || []) {
106
+ if (Array.isArray(child)) child.forEach((item) => walk(item, visit));
107
+ else walk(child, visit);
108
+ }
109
+ }
110
+ function find(tree, predicate) {
111
+ let match;
112
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
113
+ return match;
114
+ }
115
+ function textHas(tree, substr) {
116
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
117
+ }
118
+
119
+ function makeEl(tag) {
120
+ return {
121
+ tag, className: "", attributes: {}, style: {}, children: [], parentNode: null, listeners: {},
122
+ setAttribute(k, v) { this.attributes[k] = v; },
123
+ addEventListener(t, f) { this.listeners[t] = f; },
124
+ appendChild(c) { c.parentNode = this; this.children.push(c); },
125
+ querySelector() { return null; },
126
+ };
127
+ }
128
+
129
+ /** vm 沙箱加载 client.js 并 apply;fetch 可按路径注入。opts.fetch 覆盖默认(返回 response(status,body))。 */
130
+ function loadPlugin(opts = {}) {
131
+ let moduleFactory;
132
+ const registered = new Map();
133
+ const injects = new Map();
134
+ const requests = [];
135
+ const removed = [];
136
+ const states = [];
137
+ let hook = 0;
138
+
139
+ const react = {
140
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
141
+ useState(initial) {
142
+ const index = hook++;
143
+ if (!(index in states)) states[index] = initial;
144
+ return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
145
+ },
146
+ useEffect() {},
147
+ useCallback(fn) { return fn; },
148
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
149
+ };
150
+
151
+ const response = (status, body) => Promise.resolve({
152
+ ok: status >= 200 && status < 300,
153
+ status,
154
+ text: async () => JSON.stringify(body),
155
+ });
156
+
157
+ const doc = {
158
+ createElement(tag) { return makeEl(tag); },
159
+ head: makeEl("head"),
160
+ body: makeEl("body"),
161
+ querySelector() { return null; },
162
+ querySelectorAll() { return []; },
163
+ getElementById() { return null; },
164
+ };
165
+
166
+ const localStorage = {
167
+ _store: new Map(Object.entries(opts.localStorageSeed || {})),
168
+ getItem(k) { return this._store.has(k) ? this._store.get(k) : null; },
169
+ setItem(k, v) { this._store.set(k, String(v)); },
170
+ removeItem(k) { removed.push(k); this._store.delete(k); },
171
+ };
172
+
173
+ const defaultFetch = (pathname, options = {}) => {
174
+ requests.push({ path: pathname, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
175
+ if (pathname === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-pwd-1", svg: "<svg></svg>" });
176
+ if (pathname === "/dsh-remote/sms-code") return response(200, { ok: true, status: 200, body: { ok: true, test_code: "123456" } });
177
+ if (pathname === "/dsh-remote/password/reset") {
178
+ return opts.resetFail
179
+ ? response(200, { ok: false, status: 400, body: { error: { code: "sms_code_invalid", message: "短信验证码错误或已过期" } } })
180
+ : response(200, { ok: true, status: 200, body: { ok: true } });
181
+ }
182
+ if (pathname === "/dsh-remote/logout") {
183
+ return response(200, { ok: true, config: { phone: "", deviceId: "dev-x" }, service: { running: false } });
184
+ }
185
+ if (pathname === "/dsh-remote/status") {
186
+ return response(200, { ok: true, config: { phone: "", deviceId: "dev-x" }, service: { running: false } });
187
+ }
188
+ return response(200, { ok: true });
189
+ };
190
+ const fetchImpl = opts.fetch || defaultFetch;
191
+
192
+ const sandbox = {
193
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } } },
194
+ document: doc,
195
+ localStorage,
196
+ MutationObserver: class { constructor() {} observe() {} disconnect() {} },
197
+ fetch: fetchImpl,
198
+ navigator: { clipboard: { writeText: async () => {} } },
199
+ setInterval() { return 1; },
200
+ clearInterval() {},
201
+ setTimeout() { return 1; },
202
+ Set,
203
+ Symbol,
204
+ };
205
+
206
+ vm.runInNewContext(SOURCE, sandbox);
207
+ const plugin = moduleFactory((name) => {
208
+ assert.equal(name, "react");
209
+ return react;
210
+ });
211
+ plugin.apply({ slots: {
212
+ inject(name, cb) { injects.set(name, cb); cb(); },
213
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
214
+ } });
215
+
216
+ return {
217
+ registered,
218
+ requests,
219
+ removed,
220
+ states,
221
+ render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
222
+ };
223
+ }
224
+
225
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
226
+ const LOGGED_IN = { config: { phone: "13800000000", deviceId: "dev-x", mode: "saas" }, service: { running: true }, remoteUrl: "https://app.test/" };
227
+
228
+ /** 驱动「修改密码」完整成功流并返回 plugin(请求记录已就绪)。 */
229
+ async function runResetFlow(opts = {}) {
230
+ const plugin = loadPlugin({ localStorageSeed: { "dsh-feedback-threads": '[{"id":"fb_1","token":"tok-abc","at":1}]' }, ...opts });
231
+ plugin.states[0] = LOGGED_IN;
232
+ let tree = plugin.render();
233
+
234
+ // 账号卡应出现「修改密码」入口
235
+ const entry = find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("修改密码")));
236
+ assert.ok(entry, "已登录账号卡应提供「🔒 修改密码」入口");
237
+ entry.props.onClick();
238
+ await flush();
239
+ await flush();
240
+ tree = plugin.render();
241
+
242
+ // 小表单展开:图形验证码 + 手机号(当前账号,不可改) + 新密码提示(与登录卡「忘记密码」共用表单/文案)
243
+ assert.ok(textHas(tree, "修改后所有已授权设备/会话将失效,需重新登录与解锁"), "应提示设备/会话失效与重新登录解锁");
244
+ const phoneBox = find(tree, (n) => n.props?.value === "13800000000" && n.props?.disabled === true);
245
+ assert.ok(phoneBox, "手机号应预填当前账号且不可修改");
246
+
247
+ // 图形验证码 + 获取短信验证码
248
+ const capInput = find(tree, (n) => n.props?.placeholder === "图中数字");
249
+ assert.ok(capInput, "应展示图形验证码输入框");
250
+ capInput.props.onChange({ target: { value: "654321" } });
251
+ tree = plugin.render();
252
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("获取验证码"))).props.onClick();
253
+ await flush();
254
+ await flush();
255
+
256
+ const smsReq = plugin.requests.find((r) => r.path === "/dsh-remote/sms-code");
257
+ assert.ok(smsReq, "应请求 /dsh-remote/sms-code(复用短信防刷路径)");
258
+ // 隐私契约(2026-09):账号卡改密不再把明文手机号发给浏览器/请求体 —— client 传空,由插件节点半回填本机账号
259
+ assert.deepEqual(smsReq.body, { phone: "", captcha_id: "cap-pwd-1", captcha_answer: "654321" });
260
+
261
+ // 填写短信验证码 + 新密码(≥8) → 确认修改
262
+ tree = plugin.render();
263
+ find(tree, (n) => n.props?.placeholder === "6 位验证码").props.onChange({ target: { value: "123456" } });
264
+ tree = plugin.render();
265
+ find(tree, (n) => n.props?.placeholder === "至少 8 位").props.onChange({ target: { value: "newpass123" } });
266
+ tree = plugin.render();
267
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认修改密码"))).props.onClick();
268
+ await flush();
269
+ await flush();
270
+ await flush();
271
+ await flush();
272
+ return plugin;
273
+ }
274
+
275
+ test("修改密码成功流:sms-code → password/reset → 本地登出(清反馈线程凭据)→ 提示用新密码重新登录", async () => {
276
+ const plugin = await runResetFlow();
277
+
278
+ const resetReq = plugin.requests.find((r) => r.path === "/dsh-remote/password/reset");
279
+ assert.ok(resetReq, "应 POST /dsh-remote/password/reset");
280
+ assert.equal(resetReq.method, "POST");
281
+ assert.deepEqual(resetReq.body, { phone: "", sms_code: "123456", new_password: "newpass123" },
282
+ "应提交 {phone,sms_code,new_password}(账号卡改密 phone 为空,由服务端回填本机账号)");
283
+
284
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "重置成功后应本地登出(清旧口令配置)");
285
+ assert.ok(plugin.removed.includes("dsh-feedback-threads"), "重置成功登出应清除本机反馈线程凭据");
286
+
287
+ const tree = plugin.render();
288
+ assert.ok(textHas(tree, "密码已修改成功"), "应给出成功提示");
289
+ assert.ok(textHas(tree, "新密码"), "应提示用新密码登录");
290
+ assert.ok(textHas(tree, "重新登录"), "应提示重新登录(登录会覆盖本地 config 密码)");
291
+ assert.ok(!find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认修改密码"))),
292
+ "成功后小表单应收起并回到未登录账号卡");
293
+ });
294
+
295
+ test("修改密码失败路径:企业端错误透传为可读提示,不登出", async () => {
296
+ const plugin = await runResetFlow({ resetFail: true });
297
+
298
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "失败时不应本地登出");
299
+ const tree = plugin.render();
300
+ assert.ok(textHas(tree, "短信验证码错误或已过期"), "应展示企业端透传的可读错误");
301
+ assert.ok(textHas(tree, "修改失败"), "错误应带「修改失败」前缀");
302
+ });
303
+
304
+ test("忘记密码(登录卡):「忘记密码?」展开共用重置表单(预填手机号)→ sms-code → password/reset → 本地登出并提示请用新密码登录", async () => {
305
+ const plugin = loadPlugin({ localStorageSeed: { "dsh-feedback-threads": '[{"id":"fb_1","token":"tok-abc","at":1}]' } });
306
+ plugin.states[0] = { config: { phone: "", deviceId: "dev-x", mode: "saas" }, service: { running: false } };
307
+ let tree = plugin.render();
308
+
309
+ // 登录 tab(SaaS 登录表单)应提供小字「忘记密码?」入口(紧邻登录按钮,不抢占官方按钮)
310
+ const forgetLink = find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("忘记密码")));
311
+ assert.ok(forgetLink, "登录表单内应提供「忘记密码?」入口");
312
+ assert.ok(!textHas(tree, "确认重置密码"), "未点击前不应展开重置表单");
313
+
314
+ // 先在登录手机号输入当前号码 → 展开后应预填
315
+ find(tree, (n) => n.props?.placeholder === "11 位手机号").props.onChange({ target: { value: "13800000000" } });
316
+ tree = plugin.render();
317
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("忘记密码"))).props.onClick();
318
+ await flush();
319
+ await flush();
320
+ tree = plugin.render();
321
+
322
+ // 展开表单与账号卡「修改密码」共用字段/文案/函数
323
+ assert.ok(textHas(tree, "修改后所有已授权设备/会话将失效,需重新登录与解锁"), "应提示设备/会话失效与重新登录解锁");
324
+ assert.ok(find(tree, (n) => n.props?.value === "13800000000"), "重置表单手机号应预填当前输入(可改)");
325
+ const capInput = find(tree, (n) => n.props?.placeholder === "图中数字");
326
+ assert.ok(capInput, "应展示图形验证码输入框(现有 captcha 通道)");
327
+ capInput.props.onChange({ target: { value: "654321" } });
328
+ tree = plugin.render();
329
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("获取验证码"))).props.onClick();
330
+ await flush();
331
+ await flush();
332
+
333
+ const smsReq = plugin.requests.find((r) => r.path === "/dsh-remote/sms-code");
334
+ assert.ok(smsReq, "应请求 /dsh-remote/sms-code(与修改密码同一短信防刷路径)");
335
+ assert.deepEqual(smsReq.body, { phone: "13800000000", captcha_id: "cap-pwd-1", captcha_answer: "654321" });
336
+
337
+ tree = plugin.render();
338
+ find(tree, (n) => n.props?.placeholder === "6 位验证码").props.onChange({ target: { value: "123456" } });
339
+ tree = plugin.render();
340
+ find(tree, (n) => n.props?.placeholder === "至少 8 位").props.onChange({ target: { value: "newpass123" } });
341
+ tree = plugin.render();
342
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))).props.onClick();
343
+ await flush();
344
+ await flush();
345
+ await flush();
346
+ await flush();
347
+
348
+ const resetReq = plugin.requests.find((r) => r.path === "/dsh-remote/password/reset");
349
+ assert.ok(resetReq, "应 POST /dsh-remote/password/reset(复用已有 node 半代理)");
350
+ assert.equal(resetReq.method, "POST");
351
+ assert.deepEqual(resetReq.body, { phone: "13800000000", sms_code: "123456", new_password: "newpass123" },
352
+ "应提交 {phone,sms_code,new_password}");
353
+
354
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "重置成功后应本地登出");
355
+ assert.ok(plugin.removed.includes("dsh-feedback-threads"), "重置成功登出应清除本机反馈线程凭据");
356
+
357
+ tree = plugin.render();
358
+ assert.ok(textHas(tree, "密码已重置成功"), "应给出重置成功提示");
359
+ assert.ok(textHas(tree, "请用新密码登录"), "应提示「请用新密码登录」");
360
+ assert.ok(!find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))),
361
+ "成功后重置表单应收起");
362
+ assert.ok(find(tree, (n) => n.props?.placeholder === "密码"), "应回到登录表单(可直接输入新密码登录)");
363
+ });
364
+
365
+ test("忘记密码失败路径:企业端错误透传为可读提示(重置失败前缀),不本地登出", async () => {
366
+ const plugin = loadPlugin({ resetFail: true });
367
+ plugin.states[0] = { config: { phone: "", deviceId: "dev-x", mode: "saas" }, service: { running: false } };
368
+ let tree = plugin.render();
369
+
370
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("忘记密码"))).props.onClick();
371
+ await flush();
372
+ await flush();
373
+ tree = plugin.render();
374
+ find(tree, (n) => n.props?.placeholder === "11 位手机号").props.onChange({ target: { value: "13800000000" } });
375
+ tree = plugin.render();
376
+ find(tree, (n) => n.props?.placeholder === "图中数字").props.onChange({ target: { value: "654321" } });
377
+ tree = plugin.render();
378
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("获取验证码"))).props.onClick();
379
+ await flush();
380
+ await flush();
381
+ tree = plugin.render();
382
+ find(tree, (n) => n.props?.placeholder === "6 位验证码").props.onChange({ target: { value: "123456" } });
383
+ tree = plugin.render();
384
+ find(tree, (n) => n.props?.placeholder === "至少 8 位").props.onChange({ target: { value: "newpass123" } });
385
+ tree = plugin.render();
386
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))).props.onClick();
387
+ await flush();
388
+ await flush();
389
+ await flush();
390
+ await flush();
391
+
392
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "失败时不应本地登出");
393
+ tree = plugin.render();
394
+ assert.ok(textHas(tree, "短信验证码错误或已过期"), "应展示企业端透传的可读错误");
395
+ assert.ok(textHas(tree, "重置失败"), "错误应带「重置失败」前缀");
396
+ assert.ok(find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))),
397
+ "失败后重置表单保留,可修改后重试");
398
+ });
399
+
400
+ test("源码约束:账号区含「修改密码」/登录卡含「忘记密码」入口、共用重置逻辑、reset 代理路由、新密码≥8 与重新登录提示", () => {
401
+ // 浏览器半:账号卡修改密码入口
402
+ assert.match(SOURCE, /修改密码/);
403
+ assert.match(SOURCE, /🔒 修改密码/);
404
+ assert.match(SOURCE, /确认修改密码/);
405
+ // 浏览器半:登录卡「忘记密码」小字入口 → 展开共用重置表单(复用同一组 pwd* 字段/函数)
406
+ assert.match(SOURCE, /忘记密码/);
407
+ assert.match(SOURCE, /忘记密码?/);
408
+ assert.match(SOURCE, /确认重置密码/);
409
+ assert.match(SOURCE, /请用新密码登录/);
410
+ assert.match(SOURCE, /修改后所有已授权设备\/会话将失效,需重新登录与解锁/);
411
+ // 共用重置:同一 doResetPwd/sendPwdSms/renderResetPwdForm(fromLogin 分流手机号来源与提示)
412
+ assert.match(SOURCE, /doResetPwd\(ph, fromLogin\)/);
413
+ assert.match(SOURCE, /sendPwdSms\(ph\)/);
414
+ assert.match(SOURCE, /function renderResetPwdForm\(fromLogin\)/);
415
+ assert.match(SOURCE, /dsh-remote\/password\/reset/);
416
+ assert.match(SOURCE, /new_password/);
417
+ assert.match(SOURCE, /新密码至少 8 位/);
418
+ assert.match(SOURCE, /pwdNew\.length < 8/);
419
+ assert.match(SOURCE, /所有已授权设备与会话已失效/);
420
+ assert.match(SOURCE, /重新登录/);
421
+ assert.match(SOURCE, /E2EE/);
422
+ assert.match(SOURCE, /fbClearThreads\(\)/);
423
+ // 修改密码/忘记密码的短信验证码复用 /dsh-remote/sms-code(含图形验证码防刷)
424
+ assert.match(SOURCE, /post\("\/dsh-remote\/sms-code"/);
425
+ assert.match(SOURCE, /captcha_invalid/);
426
+ // node 半代理路由
427
+ assert.match(NODE_SOURCE, /path: "\/dsh-remote\/password\/reset"/);
428
+ assert.match(NODE_SOURCE, /\/api\/password\/reset/);
429
+ assert.doesNotMatch(NODE_SOURCE, /"\/dsh-remote\/password\/reset"[\s\S]{0,600}authorization/, "重置为公开接口,不应附加 Bearer");
430
+ });
@@ -22,6 +22,12 @@ function find(tree, predicate) {
22
22
  return match;
23
23
  }
24
24
 
25
+ function findAll(tree, predicate) {
26
+ const out = [];
27
+ walk(tree, (node) => { if (predicate(node)) out.push(node); });
28
+ return out;
29
+ }
30
+
25
31
  /** 文本子节点包含子串(用于动态拼接文案断言)。 */
26
32
  function textHas(tree, substr) {
27
33
  return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
@@ -108,11 +114,19 @@ function loadPlugin(opts = {}) {
108
114
  }
109
115
  if (path === "/dsh-remote/mobile-sessions") {
110
116
  sessionsCalls++;
111
- return response(200, { ok: true, sessions: sessionsCalls === 1 ? SESSIONS_1 : [] }); // 取消配对后的刷新 → 空列表
117
+ // 默认:首次给列表,后续刷新给空(模拟已全部清理);测试可传 opts.sessions 固定列表
118
+ const list = opts.sessions !== undefined ? opts.sessions : (sessionsCalls === 1 ? SESSIONS_1 : []);
119
+ return response(200, { ok: true, sessions: list });
112
120
  }
113
121
  if (path === "/dsh-remote/mobile-sessions/revoke") {
114
122
  return response(200, { ok: true });
115
123
  }
124
+ if (path === "/dsh-remote/mobile-sessions/delete") {
125
+ return response(200, { ok: true });
126
+ }
127
+ if (path === "/dsh-remote/mobile-sessions/purge") {
128
+ return response(200, { ok: true, removed: 1 });
129
+ }
116
130
  if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "13800000000", plan: "free", plan_source: "plan", invite_code: "ABC12345" } });
117
131
  return response(200, { ok: true });
118
132
  },
@@ -172,7 +186,7 @@ test("📱 远程访问卡:bridge 在线 → 绿点文案;登录态点「生
172
186
  assert.ok(qr && qr.props?.alt === "远程访问二维码", "应渲染服务端返回的二维码 <img>");
173
187
  // 到期倒计时/有效至文案
174
188
  assert.ok(textHas(tree, "有效至") && textHas(tree, "剩余"), "应显示「有效至 HH:MM:SS / 剩余 xx:xx」倒计时");
175
- assert.ok(textHas(tree, "访问一次后失效"), "应说明一次性/30 分钟语义");
189
+ assert.ok(textHas(tree, "用一次即失效"), "应说明一次性/30 分钟语义(精简一句)");
176
190
  });
177
191
 
178
192
  test("已授权设备:展开列表(label/os/browser/时间)→ 取消配对二次确认 → 成功提示并刷新", async () => {
@@ -216,6 +230,76 @@ test("已授权设备:展开列表(label/os/browser/时间)→ 取消配
216
230
  assert.ok(textHas(tree, "暂无已授权设备(手机扫码后出现)"), "刷新后空态文案应出现");
217
231
  });
218
232
 
233
+ test("已授权设备行内操作:活跃行有 取消配对+删除记录,已取消行有 删除记录;删除记录二次确认 → DELETE delete {id}", async () => {
234
+ const plugin = loadPlugin({ sessions: SESSIONS_1 }); // 固定列表:操作后重拉仍保留,便于断言行内按钮
235
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
236
+ let tree = plugin.render();
237
+ const openBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("已授权设备")));
238
+ openBtn.props.onClick();
239
+ await flush();
240
+ await flush();
241
+ tree = plugin.render();
242
+
243
+ // 行内按钮:活跃行(ms_1)应有「取消配对」+「删除记录」;已取消行(ms_2)只应有「删除记录」
244
+ const delBtns = findAll(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("删除记录")));
245
+ assert.ok(delBtns.length >= 2, "每行(含已取消/历史)都应有「删除记录」按钮");
246
+ assert.ok(find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("取消配对"))),
247
+ "活跃行应有「取消配对」按钮");
248
+ // 底部有「清理已解绑」入口(卡片底部)
249
+ assert.ok(textHas(tree, "清理已解绑"), "卡片底部应提供「清理已解绑」");
250
+
251
+ // 删除 ms_1:第一次点击进入确认态(不请求)
252
+ delBtns[0].props.onClick();
253
+ tree = plugin.render();
254
+ assert.ok(textHas(tree, "再点一次确认删除记录"), "删除记录需二次确认");
255
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions/delete"), "首次点击不应发 DELETE");
256
+
257
+ // 第二次点击 → DELETE /dsh-remote/mobile-sessions/delete {id} → 提示 + 刷新列表
258
+ const listBefore = plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length;
259
+ find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("再点一次确认删除记录"))).props.onClick();
260
+ await flush();
261
+ await flush();
262
+ await flush();
263
+ const del = plugin.requests.find((r) => r.path === "/dsh-remote/mobile-sessions/delete");
264
+ assert.ok(del, "确认后应 DELETE /dsh-remote/mobile-sessions/delete");
265
+ assert.equal(del.method, "DELETE");
266
+ assert.deepEqual(del.body, { id: "ms_1" }, "行内删除应带上该行 session id");
267
+ assert.ok(plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length > listBefore, "删除后应重拉设备列表");
268
+ tree = plugin.render();
269
+ assert.ok(textHas(tree, "已删除该设备的记录"), "删除成功应有可读提示");
270
+ });
271
+
272
+ test("已授权设备:卡片底部「清理已解绑」→ 二次确认 → POST purge → 提示条数 + 刷新列表", async () => {
273
+ const plugin = loadPlugin({ sessions: SESSIONS_1 });
274
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
275
+ let tree = plugin.render();
276
+ const openBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("已授权设备")));
277
+ openBtn.props.onClick();
278
+ await flush();
279
+ await flush();
280
+ tree = plugin.render();
281
+
282
+ // 底部 purge 按钮:带 1 条已解绑计数
283
+ const purgeBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("清理已解绑")));
284
+ assert.ok(purgeBtn, "卡片底部应有「清理已解绑」按钮");
285
+ purgeBtn.props.onClick();
286
+ tree = plugin.render();
287
+ assert.ok(textHas(tree, "再点一次确认清理已解绑"), "清理已解绑需二次确认");
288
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions/purge"), "首次点击不应发请求");
289
+
290
+ const listBefore = plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length;
291
+ find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("再点一次确认清理已解绑"))).props.onClick();
292
+ await flush();
293
+ await flush();
294
+ await flush();
295
+ const purge = plugin.requests.find((r) => r.path === "/dsh-remote/mobile-sessions/purge");
296
+ assert.ok(purge, "确认后应 POST /dsh-remote/mobile-sessions/purge");
297
+ assert.equal(purge.method, "POST");
298
+ assert.ok(plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length > listBefore, "清理后应重拉设备列表");
299
+ tree = plugin.render();
300
+ assert.ok(textHas(tree, "已清理 1 条已解绑记录"), "应提示清理条数(可读)");
301
+ });
302
+
219
303
  test("升级/续费按钮:点击 → GET /dsh-remote/access-key → window.open(url)(带登录态打开)", async () => {
220
304
  const plugin = loadPlugin();
221
305
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
@@ -239,7 +323,7 @@ test("二维码缺失容错 + 未登录引导文案(源码级约束)", () =>
239
323
  assert.match(SOURCE, /window\.open/);
240
324
  // 自动刷新/状态轮询定时器与清理
241
325
  assert.match(SOURCE, /KEY_AUTO_REFRESH_MS = 25000/);
242
- assert.match(SOURCE, /STATUS_POLL_MS = 5000/);
326
+ assert.match(SOURCE, /STATUS_POLL_MS = 30000/); // 审计降频:原 5s→30s,页面隐藏暂停
243
327
  assert.match(SOURCE, /clearInterval\(rotateIv\)/);
244
328
  assert.match(SOURCE, /clearInterval\(pollIv\)/);
245
329
  // 文案与行为关键词
@@ -250,6 +334,18 @@ test("二维码缺失容错 + 未登录引导文案(源码级约束)", () =>
250
334
  assert.match(SOURCE, /已连接(可远程访问)/);
251
335
  assert.match(SOURCE, /带登录态/);
252
336
  assert.match(SOURCE, /通过手机或另一台电脑远程使用同一份 dsh web/);
337
+ // 已授权设备管理(delete/purge 代理路由与按钮)
338
+ assert.match(SOURCE, /dsh-remote\/mobile-sessions\/delete/);
339
+ assert.match(SOURCE, /dsh-remote\/mobile-sessions\/purge/);
340
+ assert.match(SOURCE, /删除记录/);
341
+ assert.match(SOURCE, /清理已解绑/);
342
+ assert.match(SOURCE, /再点一次确认删除记录/);
343
+ assert.match(SOURCE, /再点一次确认清理已解绑/);
344
+ // 文案精简:二维码说明压成一句、右侧长段压成一句
345
+ assert.match(SOURCE, /扫码即进入,30 分钟有效、用一次即失效。/);
346
+ assert.match(SOURCE, /打开链接\/扫码进入即登录态;同设备重复扫码只更新授权,不新增设备。/);
347
+ assert.doesNotMatch(SOURCE, /每次生成的链接 30 分钟有效、访问一次后失效/);
348
+ assert.doesNotMatch(SOURCE, /手机上打开链接点「进入」即可像在本机一样使用 dsh web/);
253
349
  });
254
350
 
255
351
  // ---------- 端到端加密(E2EE,Phase-5)状态徽标 ----------
@@ -282,9 +378,9 @@ test("E2EE 徽标:已启用 → “🔒 端到端加密已启用(手机解
282
378
  assert.ok(textHas(tree, "手机解锁后生效"), "应提示“手机解锁后生效”(bridge 就绪、手机解锁后方生效)");
283
379
  });
284
380
 
285
- test("E2EE 徽标:未启用原因映射可读文案(灰度等待 / 参数不可达 / 本地关闭 / 改密 / 未知兜底)", () => {
381
+ test("E2EE 徽标:未启用原因映射可读文案(服务端关闭 / 参数不可达 / 本地关闭 / 改密 / 未知兜底)", () => {
286
382
  const cases = [
287
- [E2EE_STATE.serverDisabled, "等待服务端开启 E2EE"],
383
+ [E2EE_STATE.serverDisabled, "端到端加密暂不可用(当前为普通安全连接 HTTPS)"],
288
384
  [E2EE_STATE.paramsUnreachable, "当前为普通安全连接(HTTPS)"],
289
385
  [E2EE_STATE.localDisabled, "当前为普通安全连接(HTTPS)"],
290
386
  [E2EE_STATE.deriveFailed, "账号密码已变更"],
@@ -316,8 +412,9 @@ test("E2EE 徽标:未登录 / host 未下发 e2ee → 不打扰(不渲染状
316
412
  test("E2EE 徽标(源码级约束):client 含徽标字段/文案与 reason 映射表", () => {
317
413
  assert.match(SOURCE, /service\.e2ee/);
318
414
  assert.match(SOURCE, /\.e2ee-state\.json/);
319
- assert.match(SOURCE, /端到端加密已启用(手机解锁后生效)/);
320
- assert.match(SOURCE, /等待服务端开启 E2EE(灰度中,当前为加密准备)/);
415
+ assert.match(SOURCE, /🔒 端到端加密已启用(手机解锁后生效)/);
416
+ assert.match(SOURCE, /端到端加密暂不可用(当前为普通安全连接 HTTPS)/);
417
+ assert.doesNotMatch(SOURCE, /等待服务端开启 E2EE/); // 已正式开启,不再出现“灰度等待”措辞
321
418
  assert.match(SOURCE, /当前为普通安全连接(HTTPS)/);
322
419
  assert.match(SOURCE, /server_disabled/);
323
420
  assert.match(SOURCE, /params_unreachable/);