@mrrisega/dsh-remote 0.6.0 → 0.6.1
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 +14 -1
- package/package.json +4 -2
- package/packages/dsh-remote-web/lib/client.js +220 -17
- package/packages/dsh-remote-web/lib/index.js +285 -30
- package/packages/dsh-remote-web/package.json +2 -2
- package/packages/dsh-remote-web/test/community-qr-ui.test.mjs +287 -0
- package/packages/dsh-remote-web/test/first-login-selfheal.test.mjs +247 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +307 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// 交流群二维码(运营配置)在插件侧的两处展示回归:
|
|
2
|
+
// ① 设置面板:「💬 加入交流群」按钮 → 点击弹出二维码大图(未配置后台码 → 按钮不存在);
|
|
3
|
+
// ② 用户反馈页:底部直接展示企微交流群二维码 + 文案。
|
|
4
|
+
// 数据来源:node 半 GET /dsh-remote/community(读公开配置 community.qrcode 并拼绝对地址)。
|
|
5
|
+
// 采用真实 useEffect(依赖比较 + 提交后执行),与 login-selfheal-ui.test.mjs 同一套 vm 挂载方式。
|
|
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 QR_PATH = "/qr/group-qr.png";
|
|
18
|
+
const API_URL = "https://relay.test/relay-api";
|
|
19
|
+
const QR_ABS = API_URL + QR_PATH;
|
|
20
|
+
|
|
21
|
+
function walk(node, visit) {
|
|
22
|
+
if (node == null) return;
|
|
23
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
24
|
+
if (typeof node !== "object") return;
|
|
25
|
+
visit(node);
|
|
26
|
+
for (const child of node.children || []) walk(child, visit);
|
|
27
|
+
}
|
|
28
|
+
function find(tree, predicate) {
|
|
29
|
+
let match;
|
|
30
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
function textHas(tree, substr) {
|
|
34
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
35
|
+
}
|
|
36
|
+
function nodeWithText(tree, substr) {
|
|
37
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
38
|
+
}
|
|
39
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 迷你 React(真实 useEffect)+ 假定时器;fetch 覆盖 /dsh-remote/* 面板接口。
|
|
43
|
+
* @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
|
|
44
|
+
*/
|
|
45
|
+
function loadPlugin(opts = {}) {
|
|
46
|
+
let moduleFactory;
|
|
47
|
+
const registered = new Map();
|
|
48
|
+
const injects = new Map();
|
|
49
|
+
const requests = [];
|
|
50
|
+
|
|
51
|
+
let clock = 0;
|
|
52
|
+
let timerSeq = 1;
|
|
53
|
+
const timers = new Map();
|
|
54
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
|
|
55
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
56
|
+
const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
|
|
57
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
58
|
+
|
|
59
|
+
// ── 迷你 React:真实 useState/useEffect + 递归渲染函数组件(FeedbackCard 等嵌套组件) ──
|
|
60
|
+
// 每个组件实例按「树中位置路径」持有自己的 hook 槽位(近似 React 的位置化协调),故子组件状态
|
|
61
|
+
// 可跨渲染保持;effects 在对应子树展开后执行(cleanup 语义与 React 一致)。
|
|
62
|
+
const scopes = new Map(); // key -> { hook, effectCursor, slots, pending }
|
|
63
|
+
const scopeStack = [];
|
|
64
|
+
const currentScope = () => scopeStack[scopeStack.length - 1];
|
|
65
|
+
|
|
66
|
+
const react = {
|
|
67
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
68
|
+
useState(initial) {
|
|
69
|
+
const sc = currentScope();
|
|
70
|
+
const index = sc.hook++;
|
|
71
|
+
if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
|
|
72
|
+
return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
|
|
73
|
+
},
|
|
74
|
+
useEffect(fn, deps) {
|
|
75
|
+
const sc = currentScope();
|
|
76
|
+
const index = sc.effectCursor++;
|
|
77
|
+
const prev = sc.effects[index];
|
|
78
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
79
|
+
if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
80
|
+
},
|
|
81
|
+
useCallback(fn) { return fn; },
|
|
82
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** 渲染一棵树:函数组件递归展开(含 effects),普通节点保留并递归子节点。 */
|
|
86
|
+
function renderNode(node, path) {
|
|
87
|
+
if (node == null) return node;
|
|
88
|
+
if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
|
|
89
|
+
if (typeof node !== "object") return node;
|
|
90
|
+
if (typeof node.type === "function") {
|
|
91
|
+
const name = node.type.name || "anon";
|
|
92
|
+
const key = path + ":" + name;
|
|
93
|
+
const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
|
|
94
|
+
sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
|
|
95
|
+
scopes.set(key, sc);
|
|
96
|
+
scopeStack.push(sc);
|
|
97
|
+
let out;
|
|
98
|
+
try {
|
|
99
|
+
out = node.type(node.props || {});
|
|
100
|
+
} finally {
|
|
101
|
+
scopeStack.pop();
|
|
102
|
+
}
|
|
103
|
+
const expanded = renderNode(out, key);
|
|
104
|
+
const todos = sc.pending;
|
|
105
|
+
sc.pending = [];
|
|
106
|
+
for (const t of todos) {
|
|
107
|
+
const prev = sc.effects[t.index];
|
|
108
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
109
|
+
const cleanup = t.fn();
|
|
110
|
+
sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
111
|
+
}
|
|
112
|
+
return expanded;
|
|
113
|
+
}
|
|
114
|
+
return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const community = opts.community !== undefined ? opts.community : { ok: true, qrcode: QR_ABS, wechat: "dsh-remote-support" };
|
|
118
|
+
const status = opts.status || {
|
|
119
|
+
ok: true,
|
|
120
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
121
|
+
service: { running: true },
|
|
122
|
+
remoteUrl: "https://relay.test/app/",
|
|
123
|
+
};
|
|
124
|
+
const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
|
|
125
|
+
|
|
126
|
+
const makeEl = () => ({
|
|
127
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
128
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
129
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
130
|
+
appendChild(c) { this.children.push(c); },
|
|
131
|
+
addEventListener() {}, removeEventListener() {},
|
|
132
|
+
});
|
|
133
|
+
const doc = {
|
|
134
|
+
hidden: false,
|
|
135
|
+
createElement: makeEl,
|
|
136
|
+
head: makeEl(),
|
|
137
|
+
body: makeEl(),
|
|
138
|
+
querySelector() { return null; },
|
|
139
|
+
querySelectorAll() { return []; },
|
|
140
|
+
getElementById() { return null; },
|
|
141
|
+
addEventListener() {}, removeEventListener() {},
|
|
142
|
+
};
|
|
143
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
144
|
+
const localStorage = {
|
|
145
|
+
_s: new Map(),
|
|
146
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
147
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
148
|
+
removeItem(k) { this._s.delete(k); },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const sandbox = {
|
|
152
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
153
|
+
document: doc,
|
|
154
|
+
localStorage,
|
|
155
|
+
MutationObserver: MutationObserverMock,
|
|
156
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
157
|
+
setTimeout: fakeSetTimeout,
|
|
158
|
+
clearTimeout: fakeClear,
|
|
159
|
+
setInterval: fakeSetInterval,
|
|
160
|
+
clearInterval: fakeClear,
|
|
161
|
+
fetch(path, options = {}) {
|
|
162
|
+
requests.push({ path, method: options.method || "GET" });
|
|
163
|
+
if (path === "/dsh-remote/status") return response(200, status);
|
|
164
|
+
if (path === "/dsh-remote/community") return response(200, community);
|
|
165
|
+
return response(200, { ok: true });
|
|
166
|
+
},
|
|
167
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
171
|
+
const plugin = moduleFactory((name) => {
|
|
172
|
+
assert.equal(name, "react");
|
|
173
|
+
return react;
|
|
174
|
+
});
|
|
175
|
+
plugin.apply({
|
|
176
|
+
slots: {
|
|
177
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
178
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
requests,
|
|
184
|
+
/** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
|
|
185
|
+
async settle(times = 2) {
|
|
186
|
+
let tree = null;
|
|
187
|
+
for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
|
|
188
|
+
return this.render();
|
|
189
|
+
},
|
|
190
|
+
render() {
|
|
191
|
+
const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
|
|
192
|
+
return renderNode(root, "root");
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test("node 半 /dsh-remote/community:读公开配置 community.qrcode 并拼成企业端绝对地址", async () => {
|
|
198
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-community-"));
|
|
199
|
+
const relay = http.createServer((req, res) => {
|
|
200
|
+
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
201
|
+
if (req.url.startsWith("/api/public-config")) {
|
|
202
|
+
return send(200, {
|
|
203
|
+
service: "dsh-remote",
|
|
204
|
+
app_url: "https://relay.test/app/",
|
|
205
|
+
api_url: API_URL,
|
|
206
|
+
community: { qrcode: QR_PATH, wechat: "dsh-remote-support" },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
send(404, { error: { code: "not_found" } });
|
|
210
|
+
});
|
|
211
|
+
await new Promise((r) => relay.listen(0, "127.0.0.1", r));
|
|
212
|
+
const port = relay.address().port;
|
|
213
|
+
await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
|
|
214
|
+
phone: "13800000000", password: "pw", api_url: `http://127.0.0.1:${port}`, bridge_secret: "s",
|
|
215
|
+
}));
|
|
216
|
+
const routes = new Map();
|
|
217
|
+
apply({ webServer: { register(rt) { routes.set(rt.path, rt.handler); return () => {}; } }, effect(reg) { return reg(); }, logger: { info() {}, warn() {} } }, { relayDir: tempDir });
|
|
218
|
+
const host = http.createServer((req, res) => {
|
|
219
|
+
const url = new URL(req.url, "http://x");
|
|
220
|
+
const h = routes.get(url.pathname);
|
|
221
|
+
(h || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
222
|
+
});
|
|
223
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
224
|
+
try {
|
|
225
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
226
|
+
const r = await (await fetch(`${base}/dsh-remote/community`)).json();
|
|
227
|
+
assert.equal(r.ok, true);
|
|
228
|
+
// 相对路径按「api_url + 路径」拼绝对地址(与手机端 PWA 的 API_BASE + qr 同一约定)
|
|
229
|
+
assert.equal(r.qrcode, `http://127.0.0.1:${port}${QR_PATH}`);
|
|
230
|
+
assert.equal(r.wechat, "dsh-remote-support");
|
|
231
|
+
} finally {
|
|
232
|
+
host.close();
|
|
233
|
+
relay.close();
|
|
234
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("设置面板:后台已配置 → 「加入交流群」按钮出现;点击弹出二维码大图;关闭后收起", async () => {
|
|
239
|
+
const plugin = loadPlugin();
|
|
240
|
+
let tree = await plugin.settle();
|
|
241
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/community"), "面板应拉取交流群配置");
|
|
242
|
+
|
|
243
|
+
const btn = nodeWithText(tree, "加入交流群");
|
|
244
|
+
assert.ok(btn, "应出现「💬 加入交流群」按钮");
|
|
245
|
+
assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "未点击时不应有弹窗");
|
|
246
|
+
|
|
247
|
+
btn.props.onClick();
|
|
248
|
+
tree = plugin.render();
|
|
249
|
+
assert.ok(find(tree, (n) => n.props?.className === "dru-community-qr"), "点击后应弹出二维码");
|
|
250
|
+
const img = find(tree, (n) => n.props?.className === "dru-community-qr");
|
|
251
|
+
assert.equal(img.props.src, QR_ABS, "二维码用 node 半下发的绝对地址");
|
|
252
|
+
assert.equal(img.props.alt, "企微交流群二维码");
|
|
253
|
+
assert.ok(textHas(tree, "加入企微交流群"), "弹窗应有标题");
|
|
254
|
+
assert.ok(textHas(tree, "扫码入群"), "弹窗应有入群说明");
|
|
255
|
+
|
|
256
|
+
// 关闭:点「关闭」→ 弹窗消失
|
|
257
|
+
const closeBtn = nodeWithText(tree, "关闭");
|
|
258
|
+
assert.ok(closeBtn, "弹窗应有「关闭」按钮");
|
|
259
|
+
closeBtn.props.onClick();
|
|
260
|
+
tree = plugin.render();
|
|
261
|
+
assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "关闭后弹窗应消失");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("设置面板:后台未配置二维码 → 不展示任何入口(不留空壳按钮)", async () => {
|
|
265
|
+
const plugin = loadPlugin({ community: { ok: true, qrcode: "", wechat: "" } });
|
|
266
|
+
const tree = await plugin.settle();
|
|
267
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/community"), "仍应尝试拉取");
|
|
268
|
+
assert.ok(!nodeWithText(tree, "加入交流群"), "未配置时不展示按钮");
|
|
269
|
+
assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "未配置时不展示二维码");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("用户反馈页:底部展示企微交流群二维码(含客服微信兜底文案)", async () => {
|
|
273
|
+
const plugin = loadPlugin();
|
|
274
|
+
await plugin.settle();
|
|
275
|
+
// 进入「用户反馈」视图
|
|
276
|
+
let tree = plugin.render();
|
|
277
|
+
const entry = nodeWithText(tree, "用户反馈");
|
|
278
|
+
assert.ok(entry, "应有「用户反馈」入口");
|
|
279
|
+
entry.props.onClick();
|
|
280
|
+
tree = await plugin.settle();
|
|
281
|
+
|
|
282
|
+
assert.ok(textHas(tree, "加入企微交流群"), "反馈页应展示交流群标题");
|
|
283
|
+
const img = find(tree, (n) => n.props?.className === "dru-community-qr");
|
|
284
|
+
assert.ok(img, "反馈页应展示二维码图片");
|
|
285
|
+
assert.equal(img.props.src, QR_ABS);
|
|
286
|
+
assert.ok(textHas(tree, "dsh-remote-support"), "应展示客服微信兜底文案");
|
|
287
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// 首次安装后「立即登录」二维码/设备列表报红字 的回归(node 半)。
|
|
2
|
+
//
|
|
3
|
+
// 现场:全新安装只装插件(dsh plugin add / 插件市场)时,.dsh-config.json 里没有 bridge_secret
|
|
4
|
+
// (该字段原本只有一键安装器 dsh-setup.mjs 会写)。而企业端 POST /api/device-login 强制校验
|
|
5
|
+
// x-dsh-bridge-secret → 面板请求 /dsh-remote/access-key & /dsh-remote/mobile-sessions 时拿不到 token,
|
|
6
|
+
// 旧版一律回 401「尚未登录:请先在「账号」卡片登录手机号账号」——用户明明刚登录成功(误导性红字),
|
|
7
|
+
// 只能等后台自愈装完运行环境写回密钥、或手动刷新页面才恢复。
|
|
8
|
+
//
|
|
9
|
+
// 本用例锁死四点:
|
|
10
|
+
// 1. 缺 bridge_secret → 插件向 /api/public-config 自愈取一次并落盘,请求一次即成功(无需刷新);
|
|
11
|
+
// 2. 带了密钥仍失败(网络/5xx)→ 服务端退避重试一次;
|
|
12
|
+
// 3. 服务端轮换密钥 → 强制重取新密钥再试;
|
|
13
|
+
// 4. 凭证齐全却拿不到 token → 503 + retryable(绝不再谎报“尚未登录”);密码失效 → 401 relogin_required。
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import { apply } from "../lib/index.js";
|
|
21
|
+
|
|
22
|
+
const SECRET = "secret-aaa-111";
|
|
23
|
+
const NEW_SECRET = "secret-bbb-222";
|
|
24
|
+
const EXPIRES = 1893456000000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 假企业端(复刻生产行为):
|
|
28
|
+
* - GET /api/public-config:下发 bridge_secret(可关闭/可轮换)
|
|
29
|
+
* - POST /api/device-login:**强制**校验 x-dsh-bridge-secret,缺失/不符 → 401 需要有效设备密钥
|
|
30
|
+
* - POST /api/auth-key:需 Bearer → 返回一次性访问地址与二维码
|
|
31
|
+
*/
|
|
32
|
+
function startFakeRelay(opts = {}) {
|
|
33
|
+
const seen = [];
|
|
34
|
+
let secret = opts.secret === undefined ? SECRET : opts.secret;
|
|
35
|
+
let deviceLoginCalls = 0;
|
|
36
|
+
const srv = http.createServer(async (req, res) => {
|
|
37
|
+
const url = new URL(req.url, "http://x");
|
|
38
|
+
const send = (code, obj) => {
|
|
39
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
40
|
+
res.end(JSON.stringify(obj));
|
|
41
|
+
};
|
|
42
|
+
seen.push({
|
|
43
|
+
method: req.method,
|
|
44
|
+
path: url.pathname,
|
|
45
|
+
authorization: req.headers.authorization || "",
|
|
46
|
+
secret: req.headers["x-dsh-bridge-secret"] || "",
|
|
47
|
+
});
|
|
48
|
+
if (req.method === "GET" && url.pathname === "/api/public-config") {
|
|
49
|
+
if (opts.noPublicConfig) return send(503, { error: { message: "public-config down" } });
|
|
50
|
+
return send(200, { service: "dsh-remote", app_url: "https://app.test/", api_url: `http://127.0.0.1:${srv.address().port}`, bridge_secret: secret });
|
|
51
|
+
}
|
|
52
|
+
if (req.method === "POST" && url.pathname === "/api/device-login") {
|
|
53
|
+
deviceLoginCalls += 1;
|
|
54
|
+
// 前 N 次网络/5xx 类失败(模拟中继刚重启)
|
|
55
|
+
if (opts.deviceLoginFailTimes && deviceLoginCalls <= opts.deviceLoginFailTimes) {
|
|
56
|
+
if (opts.deviceLoginFailMode === "throw") { req.socket.destroy(); return; }
|
|
57
|
+
return send(503, { error: { code: "unavailable", message: "中继重启中" } });
|
|
58
|
+
}
|
|
59
|
+
if (opts.badCredentials) return send(401, { error: { code: "bad_credentials", message: "手机号或密码错误" } });
|
|
60
|
+
const got = req.headers["x-dsh-bridge-secret"] || "";
|
|
61
|
+
if (!secret || got !== secret) return send(401, { error: { code: "unauthorized", message: "device-login 需要有效设备密钥" } });
|
|
62
|
+
return send(200, { token: "jwt-abc" });
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "POST" && url.pathname === "/api/auth-key") {
|
|
65
|
+
if (!(req.headers.authorization || "").startsWith("Bearer ")) return send(401, { error: { code: "invalid_token", message: "无效或过期的 token" } });
|
|
66
|
+
return send(200, { ok: true, key: "K1", url: "https://app.test/a/K1", expires_at: EXPIRES, ttl_ms: 1800000, qr_data_url: "data:image/png;base64,AAAA" });
|
|
67
|
+
}
|
|
68
|
+
if (req.method === "GET" && url.pathname === "/api/mobile-sessions") {
|
|
69
|
+
if (!(req.headers.authorization || "").startsWith("Bearer ")) return send(401, { error: { code: "invalid_token", message: "无效或过期的 token" } });
|
|
70
|
+
return send(200, { ok: true, sessions: [{ id: "ms_1", label: "iPhone 15", created_at: 1700000000000, revoked_at: null }] });
|
|
71
|
+
}
|
|
72
|
+
send(404, { error: { code: "not_found" } });
|
|
73
|
+
});
|
|
74
|
+
return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({
|
|
75
|
+
srv, seen, port: srv.address().port,
|
|
76
|
+
rotateSecret(next) { secret = next === undefined ? NEW_SECRET : next; },
|
|
77
|
+
getSecret() { return secret; },
|
|
78
|
+
})));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 装载插件路由(boot),cfgExtra 覆盖默认配置。 */
|
|
82
|
+
async function bootRelay(relayPort, cfgExtra = {}, relayDirOverride) {
|
|
83
|
+
const tempDir = relayDirOverride || (await mkdtemp(path.join(os.tmpdir(), "dsh-first-login-")));
|
|
84
|
+
await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
|
|
85
|
+
phone: "13800000000",
|
|
86
|
+
password: "pw",
|
|
87
|
+
device_id: "dev-first-login",
|
|
88
|
+
api_url: `http://127.0.0.1:${relayPort}`,
|
|
89
|
+
...cfgExtra,
|
|
90
|
+
}));
|
|
91
|
+
const routes = new Map();
|
|
92
|
+
apply({
|
|
93
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
94
|
+
effect(register) { return register(); },
|
|
95
|
+
logger: { info() {}, warn() {} },
|
|
96
|
+
}, { relayDir: tempDir });
|
|
97
|
+
const host = http.createServer((req, res) => {
|
|
98
|
+
const url = new URL(req.url, "http://x");
|
|
99
|
+
const handler = routes.get(url.pathname);
|
|
100
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
101
|
+
});
|
|
102
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
103
|
+
return { host, base: `http://127.0.0.1:${host.address().port}`, tempDir };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readCfg(dir) {
|
|
107
|
+
return JSON.parse(await readFile(path.join(dir, ".dsh-config.json"), "utf8"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test("首次安装(配置缺 bridge_secret)→ 自愈取密钥并落盘:二维码一次就拿到,不再需要刷新页面", async () => {
|
|
111
|
+
const relay = await startFakeRelay();
|
|
112
|
+
const { host, base, tempDir } = await bootRelay(relay.port); // 无 bridge_secret:模拟只装插件的全新安装
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
115
|
+
assert.equal(res.status, 200, "首次登录后应立即拿到一次性访问地址(旧版这里 401「尚未登录」)");
|
|
116
|
+
const r = await res.json();
|
|
117
|
+
assert.equal(r.ok, true);
|
|
118
|
+
assert.equal(r.url, "https://app.test/a/K1");
|
|
119
|
+
assert.equal(r.qr_data_url, "data:image/png;base64,AAAA", "二维码应随首次请求一起返回");
|
|
120
|
+
|
|
121
|
+
const dl = relay.seen.filter((s) => s.path === "/api/device-login");
|
|
122
|
+
assert.equal(dl.length, 1, "device-login 只应调用一次(自愈取到密钥后再发请求)");
|
|
123
|
+
assert.equal(dl[0].secret, SECRET, "device-login 必须带上自愈得到的共享密钥");
|
|
124
|
+
assert.equal((await readCfg(tempDir)).bridge_secret, SECRET, "密钥应落盘,供 bridge 与后续重启复用");
|
|
125
|
+
} finally {
|
|
126
|
+
host.close();
|
|
127
|
+
relay.srv.close();
|
|
128
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("设备列表同样自愈:缺 bridge_secret 时 mobile-sessions 一次成功", async () => {
|
|
133
|
+
const relay = await startFakeRelay();
|
|
134
|
+
const { host, base, tempDir } = await bootRelay(relay.port);
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(`${base}/dsh-remote/mobile-sessions`);
|
|
137
|
+
assert.equal(res.status, 200);
|
|
138
|
+
const r = await res.json();
|
|
139
|
+
assert.equal(r.ok, true);
|
|
140
|
+
assert.equal(r.sessions.length, 1);
|
|
141
|
+
assert.equal(r.sessions[0].label, "iPhone 15");
|
|
142
|
+
} finally {
|
|
143
|
+
host.close();
|
|
144
|
+
relay.srv.close();
|
|
145
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("有密钥但中继 5xx(刚重启)→ 服务端退避重试一次即成功", async () => {
|
|
150
|
+
const relay = await startFakeRelay({ deviceLoginFailTimes: 1 });
|
|
151
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: SECRET });
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
154
|
+
assert.equal(res.status, 200, "中继抖动一次应被服务端重试吸收");
|
|
155
|
+
assert.equal(relay.seen.filter((s) => s.path === "/api/device-login").length, 2, "应重试一次");
|
|
156
|
+
} finally {
|
|
157
|
+
host.close();
|
|
158
|
+
relay.srv.close();
|
|
159
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("网络层失败(连接被断开)→ 服务端重试一次", async () => {
|
|
164
|
+
const relay = await startFakeRelay({ deviceLoginFailTimes: 1, deviceLoginFailMode: "throw" });
|
|
165
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: SECRET });
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(`${base}/dsh-remote/mobile-sessions`);
|
|
168
|
+
assert.equal(res.status, 200, "连接抖动(fetch 抛错)也应重试后成功");
|
|
169
|
+
} finally {
|
|
170
|
+
host.close();
|
|
171
|
+
relay.srv.close();
|
|
172
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("服务端轮换 shared secret → 强制重取新密钥再试,且写回配置", async () => {
|
|
177
|
+
const relay = await startFakeRelay({ secret: SECRET });
|
|
178
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: "secret-stale-999" });
|
|
179
|
+
try {
|
|
180
|
+
relay.rotateSecret(NEW_SECRET); // 企业端换了密钥,本机还存着旧的
|
|
181
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
182
|
+
assert.equal(res.status, 200, "旧密钥被拒后应自动取新密钥重试");
|
|
183
|
+
assert.equal((await readCfg(tempDir)).bridge_secret, NEW_SECRET, "新密钥应覆盖写回配置");
|
|
184
|
+
const secrets = relay.seen.filter((s) => s.path === "/api/device-login").map((s) => s.secret);
|
|
185
|
+
assert.deepEqual(secrets, ["secret-stale-999", NEW_SECRET], "先旧后新:第二次必须用轮换后的密钥");
|
|
186
|
+
} finally {
|
|
187
|
+
host.close();
|
|
188
|
+
relay.srv.close();
|
|
189
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("凭证齐全但中继未就绪(重试后仍失败)→ 503 + retryable,绝不谎报「尚未登录」", async () => {
|
|
194
|
+
const relay = await startFakeRelay({ noPublicConfig: true }); // 取不到密钥 → device-login 401 需要设备密钥
|
|
195
|
+
const { host, base, tempDir } = await bootRelay(relay.port);
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
198
|
+
assert.equal(res.status, 503, "应回可重试状态码,而不是 401");
|
|
199
|
+
const r = await res.json();
|
|
200
|
+
assert.equal(r.ok, false);
|
|
201
|
+
assert.equal(r.retryable, true, "必须标记 retryable,浏览器半据此自动重试");
|
|
202
|
+
assert.equal(r.hint, "relay_not_ready");
|
|
203
|
+
assert.ok(!/尚未登录/.test(String(r.error)), "账号已登录,文案不得再引导用户去登录");
|
|
204
|
+
assert.match(String(r.error), /尚未就绪|稍后重试/);
|
|
205
|
+
} finally {
|
|
206
|
+
host.close();
|
|
207
|
+
relay.srv.close();
|
|
208
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("密码被企业端拒绝 → 401 relogin_required(不可重试,提示重新登录)", async () => {
|
|
213
|
+
const relay = await startFakeRelay({ badCredentials: true });
|
|
214
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: SECRET });
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(`${base}/dsh-remote/mobile-sessions`);
|
|
217
|
+
assert.equal(res.status, 401);
|
|
218
|
+
const r = await res.json();
|
|
219
|
+
assert.equal(r.hint, "relogin_required");
|
|
220
|
+
assert.equal(r.retryable, false, "密码失效重试无意义");
|
|
221
|
+
assert.match(String(r.error), /重新登录/);
|
|
222
|
+
assert.ok(!/尚未登录/.test(String(r.error)), "与「未登录」分开,文案要能指导下一步");
|
|
223
|
+
} finally {
|
|
224
|
+
host.close();
|
|
225
|
+
relay.srv.close();
|
|
226
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("自建模式不取设备密钥(走 /_login,不经 public-config)", async () => {
|
|
231
|
+
const relay = await startFakeRelay();
|
|
232
|
+
// 自建:local_key 优先于账号(假企业端未实现 /_login → 降级失败,但绝不能去 device-login/public-config)
|
|
233
|
+
const { host, base, tempDir } = await bootRelay(relay.port, {
|
|
234
|
+
local_key: "lk-1",
|
|
235
|
+
tunnel_url: `ws://127.0.0.1:${relay.port}`,
|
|
236
|
+
});
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
239
|
+
assert.equal(res.status, 503, "自建且上游未实现 /_login → 可重试降级");
|
|
240
|
+
assert.ok(!relay.seen.some((s) => s.path === "/api/device-login" || s.path === "/api/public-config"),
|
|
241
|
+
"自建模式不得请求 device-login / public-config");
|
|
242
|
+
} finally {
|
|
243
|
+
host.close();
|
|
244
|
+
relay.srv.close();
|
|
245
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|