@mrrisega/dsh-remote 0.6.0-beta.9 → 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.
@@ -17,7 +17,7 @@
17
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
18
18
  import { join, dirname, sep } from "node:path";
19
19
  import { execSync, spawn } from "node:child_process";
20
- import { homedir, hostname, platform } from "node:os";
20
+ import { homedir, platform } from "node:os";
21
21
  import { fileURLToPath } from "node:url";
22
22
 
23
23
  /** 本插件在 host 侧的服务依赖。 */
@@ -653,7 +653,7 @@ async function relayAccount(relayDir) {
653
653
  if (!r.ok || !r.body || !r.body.user) return null;
654
654
  const u = r.body.user;
655
655
  return {
656
- phone: u.phone || "",
656
+ phone: maskPhone(u.phone || ""),
657
657
  plan: u.plan || "free",
658
658
  plan_source: u.plan_source || "plan",
659
659
  plan_ends_at: u.plan_ends_at ?? null,
@@ -875,6 +875,15 @@ export function readE2eeStateFile(relayDir) {
875
875
  }
876
876
  }
877
877
 
878
+ /**
879
+ * 手机号脱敏(隐私审计 2026-09):面板/镜像 UI 一律不下发明文手机号。
880
+ * 明文只在 bridge 本机 config / 服务端账号体系内流转,浏览器侧仅见掩码。
881
+ */
882
+ function maskPhone(p) {
883
+ const s = String(p || "");
884
+ return s.length >= 7 ? s.slice(0, 3) + "****" + s.slice(-4) : (s ? s.slice(0, 1) + "****" : "");
885
+ }
886
+
878
887
  async function composeStatus(relayDir) {
879
888
  const cfg = loadConfig(relayDir);
880
889
  const launchd = launchdStatus();
@@ -893,7 +902,8 @@ async function composeStatus(relayDir) {
893
902
  return {
894
903
  ok: true,
895
904
  config: {
896
- phone: cfg.phone || "",
905
+ phone: maskPhone(cfg.phone || ""),
906
+ hasPhone: Boolean(cfg.phone),
897
907
  hasPassword: Boolean(cfg.password),
898
908
  deviceId: cfg.device_id || "",
899
909
  apiUrl,
@@ -913,7 +923,7 @@ async function composeStatus(relayDir) {
913
923
  // {enabled, reason, profile, epoch, caps},供面板「📱 远程访问」卡展示加密状态。
914
924
  e2ee: readE2eeStateFile(relayDir),
915
925
  },
916
- host: hostname(),
926
+ // 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
917
927
  };
918
928
  }
919
929
 
@@ -1020,7 +1030,7 @@ const PLUGIN_ID = "dsh-remote-web";
1020
1030
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1021
1031
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1022
1032
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1023
- const PLUGIN_VERSION = "0.6.0-beta.9";
1033
+ const PLUGIN_VERSION = "0.6.0";
1024
1034
  const UPDATE_LOG = ".dsh-update.log";
1025
1035
  const UPDATE_MARKER = ".dsh-update-running";
1026
1036
 
@@ -1382,7 +1392,11 @@ function registerRoutes(ctx, relayDir) {
1382
1392
  handler: async (req, res) => {
1383
1393
  const body = await readJsonBody(req);
1384
1394
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1385
- const phone = String(body.phone ?? "").trim();
1395
+ let phone = String(body.phone ?? "").trim();
1396
+ if (!phone) {
1397
+ // 隐私审计(2026-09):账号卡修改密码不再下发明文手机号——服务端以本机账号为准
1398
+ phone = String((loadConfig(relayDir).phone) || "").trim();
1399
+ }
1386
1400
  if (!phone) return sendJson(res, 400, { ok: false, error: "手机号必填" });
1387
1401
  const r = await relayFetch(relayDir, "/api/sms-code", {
1388
1402
  method: "POST", headers: { "content-type": "application/json" },
@@ -1391,6 +1405,31 @@ function registerRoutes(ctx, relayDir) {
1391
1405
  sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
1392
1406
  },
1393
1407
  },
1408
+ {
1409
+ method: "POST",
1410
+ path: "/dsh-remote/password/reset",
1411
+ handler: async (req, res) => {
1412
+ // 公开 POST /api/password/reset(无需 Bearer):短信验证码重置密码。
1413
+ // 成功 → 企业端使该账号全部授权设备/会话失效(含 E2EE 派生口令)。
1414
+ const body = await readJsonBody(req);
1415
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1416
+ let phone = String(body.phone ?? "").trim();
1417
+ if (!phone) {
1418
+ // 隐私审计(2026-09):同上,账号卡改密不传明文手机号,服务端回填本机账号
1419
+ phone = String((loadConfig(relayDir).phone) || "").trim();
1420
+ }
1421
+ const smsCode = String(body.sms_code ?? "").trim();
1422
+ const newPassword = String(body.new_password ?? body.password ?? "");
1423
+ if (!phone || !smsCode || !newPassword) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与新密码必填" });
1424
+ const r = await relayFetch(relayDir, "/api/password/reset", {
1425
+ method: "POST",
1426
+ headers: { "content-type": "application/json" },
1427
+ body: JSON.stringify({ phone, sms_code: smsCode, new_password: newPassword }),
1428
+ });
1429
+ // 透传企业端 ok/error(成功 200 {ok:true};失败保留 status 与错误体)
1430
+ sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
1431
+ },
1432
+ },
1394
1433
  {
1395
1434
  method: "GET",
1396
1435
  path: "/dsh-remote/captcha",
@@ -1483,7 +1522,7 @@ function registerRoutes(ctx, relayDir) {
1483
1522
  feedbackUrl: api,
1484
1523
  reachable,
1485
1524
  deviceId: cfg.device_id || "",
1486
- phone: cfg.phone || "",
1525
+ phone: maskPhone(cfg.phone || ""), // 隐私:浏览器端只见掩码(代理提交时服务端另附真号)
1487
1526
  // 登录态(已配置账号或自建密钥)→ 节点半自动附加 JWT,免图形验证码
1488
1527
  auth: cfg.local_key || (cfg.phone && cfg.password) ? "account" : "anonymous"
1489
1528
  });
@@ -104,7 +104,8 @@ test("反馈配置:默认走 api_url(生产 relay-api 同源反馈端点)
104
104
  assert.equal(body.ok, true);
105
105
  assert.equal(body.reachable, true);
106
106
  assert.equal(body.deviceId, "dev-cfgcheck");
107
- assert.equal(body.phone, "13900000000");
107
+ // 隐私契约(2026-09):反馈配置不下发明文手机号,只给掩码(提交时服务端另附 x-dsh-phone 真号)
108
+ assert.equal(body.phone, "139****0000");
108
109
  // 代理把 /dsh-remote/feedback/api/feedback/captcha 映射到 {api_url}/api/feedback/captcha
109
110
  const cap = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/feedback/api/feedback/captcha`);
110
111
  assert.equal(cap.status, 201);
@@ -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
+ });
@@ -323,7 +323,7 @@ test("二维码缺失容错 + 未登录引导文案(源码级约束)", () =>
323
323
  assert.match(SOURCE, /window\.open/);
324
324
  // 自动刷新/状态轮询定时器与清理
325
325
  assert.match(SOURCE, /KEY_AUTO_REFRESH_MS = 25000/);
326
- assert.match(SOURCE, /STATUS_POLL_MS = 5000/);
326
+ assert.match(SOURCE, /STATUS_POLL_MS = 30000/); // 审计降频:原 5s→30s,页面隐藏暂停
327
327
  assert.match(SOURCE, /clearInterval\(rotateIv\)/);
328
328
  assert.match(SOURCE, /clearInterval\(pollIv\)/);
329
329
  // 文案与行为关键词