@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.
- package/README.md +10 -3
- package/clients/dsh-remote/dsh-bridge.mjs +30 -0
- package/clients/dsh-remote/e2ee-client.mjs +17 -0
- package/clients/dsh-remote/e2ee-shim-script.js +43 -3
- package/clients/dsh-remote/e2ee-shim.mjs +5 -0
- package/clients/dsh-remote/mobile-adapter.mjs +56 -2
- package/clients/dsh-remote/test/e2ee-client.test.mjs +13 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +410 -133
- package/packages/dsh-remote-web/lib/index.js +109 -8
- package/packages/dsh-remote-web/test/access-key-sessions.test.mjs +79 -9
- package/packages/dsh-remote-web/test/e2ee-state.test.mjs +1 -1
- package/packages/dsh-remote-web/test/feedback-proxy.test.mjs +2 -1
- package/packages/dsh-remote-web/test/password-reset.test.mjs +430 -0
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +104 -7
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +56 -5
|
@@ -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
|
-
|
|
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, "
|
|
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 =
|
|
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, "
|
|
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,
|
|
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/);
|