@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2
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 +2 -1
- package/clients/dsh-remote/dsh-bridge.mjs +148 -9
- package/clients/dsh-remote/dsh-events.mjs +1981 -0
- package/clients/dsh-remote/src/lifecycle.mjs +42 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
- package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
- package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
- package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
- package/clients/dsh-remote/wechat-channel.mjs +2705 -0
- package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
- package/docs/telemetry.md +31 -2
- package/dsh-setup.mjs +134 -40
- package/package.json +6 -5
- package/packages/dsh-remote-web/lib/client.js +799 -65
- package/packages/dsh-remote-web/lib/index.js +460 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
- package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
- package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// 微信机器人通道 —— **插件宿主半边**(lib/index.js)代理契约回归。
|
|
2
|
+
//
|
|
3
|
+
// 背景(docs/wechat-bot-channel.md §3/§8/§10):微信通道跑在 bridge 里,只在 127.0.0.1 上
|
|
4
|
+
// 开一个控制面。面板不能直接碰它(跨端口 + 密钥 + 浏览器不该看见密钥),所以宿主半边做代理:
|
|
5
|
+
//
|
|
6
|
+
// 面板 /dsh-remote/wechat/* ──代理──▶ 控制面 127.0.0.1:<port>/wechat/*
|
|
7
|
+
// ↑ 头 x-dsh-bridge-secret: <config.bridge_secret>
|
|
8
|
+
//
|
|
9
|
+
// 这个文件锁死三件事,它们都是「出过事的那种」细节:
|
|
10
|
+
// ① 路由一一对应 + 密钥头真的被带上(少带 = 控制面 401,用户看到的是"绑定不了");
|
|
11
|
+
// ② 每一种失败(没发现文件 / 文件损坏 / 残留 pid / 连接被拒 / 401 / 403 / 404 / 超时 /
|
|
12
|
+
// 没密钥 / 非 JSON)都要变成**各不相同的人话** —— 一句「失败」会让用户去重启一个完全正常的服务;
|
|
13
|
+
// ③ 响应里**永远没有 token**(§8「面板 API 永不回显 bot_token」)。这里连 .wechat-account.json
|
|
14
|
+
// 都不许读 —— 用一条源码级断言钉死。
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import test from "node:test";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { apply } from "../lib/index.js";
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
|
|
28
|
+
|
|
29
|
+
const SECRET = "sec-wechat-abc123";
|
|
30
|
+
/** 控制面契约里唯一的密钥头(与 wechat-runtime.mjs 的 CONTROL_HEADER 同值)。 */
|
|
31
|
+
const HDR = "x-dsh-bridge-secret";
|
|
32
|
+
const QR_DATA_URL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
|
|
33
|
+
|
|
34
|
+
/** 面板路由 → 控制面路由(契约一一对应,测试与实现各自写一遍,改一边就会红)。 */
|
|
35
|
+
const ROUTE_MAP = [
|
|
36
|
+
{ panel: "/dsh-remote/wechat/status", method: "GET", control: "GET /wechat/status" },
|
|
37
|
+
{ panel: "/dsh-remote/wechat/bind/start", method: "POST", control: "POST /wechat/bind/start" },
|
|
38
|
+
{ panel: "/dsh-remote/wechat/bind/poll", method: "GET", control: "GET /wechat/bind/poll" },
|
|
39
|
+
{ panel: "/dsh-remote/wechat/bind/verify", method: "POST", control: "POST /wechat/bind/verify" },
|
|
40
|
+
{ panel: "/dsh-remote/wechat/bind/cancel", method: "POST", control: "POST /wechat/bind/cancel" },
|
|
41
|
+
{ panel: "/dsh-remote/wechat/unbind", method: "POST", control: "POST /wechat/unbind" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const UNBOUND_STATUS = {
|
|
45
|
+
ok: true, disabled: false, bound: false, bot_id: "", bound_at: 0, connected_at: 0,
|
|
46
|
+
last_push_ok_at: 0, last_error: "", cooldown_ms: 0, pending_replies: 0,
|
|
47
|
+
binding: { active: false }, channel_running: false, events_running: false
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 假控制面:行为对齐 clients/dsh-remote/wechat-runtime.mjs 的 #handleControl
|
|
52
|
+
* (同一条 401/403/404 语义、同一组路由)。opts 用来按需制造各种失败。
|
|
53
|
+
*/
|
|
54
|
+
function startFakeControl(opts = {}) {
|
|
55
|
+
const seen = [];
|
|
56
|
+
const srv = http.createServer((req, res) => {
|
|
57
|
+
const url = new URL(req.url, "http://x");
|
|
58
|
+
const chunks = [];
|
|
59
|
+
req.on("data", (c) => chunks.push(c));
|
|
60
|
+
req.on("end", async () => {
|
|
61
|
+
let body = null;
|
|
62
|
+
try { body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; } catch { body = null; }
|
|
63
|
+
seen.push({ method: req.method, path: url.pathname, header: req.headers[HDR] || "", body, accept: req.headers.accept || "", contentType: req.headers["content-type"] || "" });
|
|
64
|
+
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
|
|
65
|
+
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); };
|
|
66
|
+
if (opts.garbage) { res.writeHead(200, { "content-type": "text/html" }); res.end("<html>not json</html>"); return; }
|
|
67
|
+
if (opts.notFound) return send(404, { ok: false, error: "no such route" });
|
|
68
|
+
if (opts.noSecretAtBridge) return send(403, { ok: false, error: "bridge_secret 未配置,控制面已禁用" });
|
|
69
|
+
const expected = opts.secret === undefined ? SECRET : opts.secret;
|
|
70
|
+
if (!expected) return send(403, { ok: false, error: "bridge_secret 未配置,控制面已禁用" });
|
|
71
|
+
if ((req.headers[HDR] || "") !== expected) return send(401, { ok: false, error: "unauthorized" });
|
|
72
|
+
const route = `${req.method} ${url.pathname}`;
|
|
73
|
+
switch (route) {
|
|
74
|
+
case "GET /wechat/status": return send(200, opts.statusBody || UNBOUND_STATUS);
|
|
75
|
+
case "POST /wechat/bind/start":
|
|
76
|
+
// 真实契约:beginBind 失败 → 400 + {ok:false, error:<人话>}
|
|
77
|
+
if (opts.bindStartError) return send(400, { ok: false, error: opts.bindStartError });
|
|
78
|
+
return send(200, { ok: true, qrcode_svg: QR_DATA_URL, qrcode_url: "https://liteapp.weixin.qq.com/q/7GiQu1", message: "请用手机微信扫描二维码完成绑定。" });
|
|
79
|
+
case "GET /wechat/bind/poll": return send(200, { ok: true, state: "wait", bound: false });
|
|
80
|
+
case "POST /wechat/bind/verify": return send(200, { ok: true, code: body && body.code });
|
|
81
|
+
case "POST /wechat/bind/cancel": return send(200, { ok: true, cancelled: true });
|
|
82
|
+
case "POST /wechat/unbind": return send(200, { ok: true, notify_error: "" });
|
|
83
|
+
default: return send(404, { ok: false, error: `no such route: ${route}` });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 占一个端口再放掉 → 得到一个必定没有监听者的 loopback 端口(制造 ECONNREFUSED)。 */
|
|
91
|
+
function deadPort() {
|
|
92
|
+
const srv = http.createServer(() => {});
|
|
93
|
+
return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => {
|
|
94
|
+
const port = srv.address().port;
|
|
95
|
+
srv.close(() => resolve(port));
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 起一个真进程再等它退出 → 得到一个**确定已经不在**的 pid(不能用魔数,pid 可能真的存在)。 */
|
|
100
|
+
function deadPid() {
|
|
101
|
+
const r = spawnSync(process.execPath, ["-e", "0"]);
|
|
102
|
+
assert.ok(Number.isInteger(r.pid) && r.pid > 0, "应能拿到一个已退出进程的 pid");
|
|
103
|
+
return r.pid;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function tempRelayDir(cfg = {}) {
|
|
107
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-wx-proxy-"));
|
|
108
|
+
// api_url 指向本地死端口:插件的后台自愈/装机上报绝不允许打到生产中继(见 scripts/test-net-guard.cjs)
|
|
109
|
+
await writeFile(path.join(dir, ".dsh-config.json"), JSON.stringify({ device_id: "dev-wx", api_url: "http://127.0.0.1:9", ...cfg }));
|
|
110
|
+
return dir;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function writeControlFile(dir, obj) {
|
|
114
|
+
await writeFile(path.join(dir, ".wechat-control.json"), typeof obj === "string" ? obj : JSON.stringify(obj));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 装载插件路由(与 quota-absent.test.mjs 同一套 boot 方式),返回本地 http 面板入口。 */
|
|
118
|
+
async function bootHost(relayDir) {
|
|
119
|
+
const routes = new Map();
|
|
120
|
+
apply({
|
|
121
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
122
|
+
effect(register) { return register(); },
|
|
123
|
+
logger: { info() {}, warn() {} }
|
|
124
|
+
}, { relayDir });
|
|
125
|
+
const host = http.createServer((req, res) => {
|
|
126
|
+
const url = new URL(req.url, "http://x");
|
|
127
|
+
const handler = routes.get(url.pathname);
|
|
128
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
129
|
+
});
|
|
130
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
131
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function call(base, panelPath, method = "GET", body) {
|
|
135
|
+
const res = await fetch(`${base}${panelPath}`, {
|
|
136
|
+
method,
|
|
137
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
138
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
139
|
+
});
|
|
140
|
+
let parsed = null;
|
|
141
|
+
try { parsed = JSON.parse(await res.text()); } catch { parsed = null; }
|
|
142
|
+
return { status: res.status, body: parsed };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 起一个完整环境:假控制面 + 配置 + 发现文件 + 面板宿主。 */
|
|
146
|
+
async function boot(opts = {}) {
|
|
147
|
+
const control = await startFakeControl(opts.control || {});
|
|
148
|
+
const dir = await tempRelayDir(opts.config === undefined ? { bridge_secret: SECRET } : opts.config);
|
|
149
|
+
if (opts.noControlFile !== true) {
|
|
150
|
+
const pointer = opts.controlPointer === undefined ? { port: control.port, pid: process.pid, started_at: Date.now(), header: HDR } : opts.controlPointer;
|
|
151
|
+
await writeControlFile(dir, pointer);
|
|
152
|
+
}
|
|
153
|
+
const host = await bootHost(dir);
|
|
154
|
+
return {
|
|
155
|
+
control, dir, ...host,
|
|
156
|
+
async close() {
|
|
157
|
+
host.host.close();
|
|
158
|
+
control.srv.close();
|
|
159
|
+
await rm(dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
test("宿主代理:六条面板路由一一映射到控制面路由,并带上 x-dsh-bridge-secret", async () => {
|
|
167
|
+
const env = await boot();
|
|
168
|
+
try {
|
|
169
|
+
for (const r of ROUTE_MAP) {
|
|
170
|
+
const before = env.control.seen.length;
|
|
171
|
+
const out = await call(env.base, r.panel, r.method, r.method === "POST" ? { code: "123456" } : undefined);
|
|
172
|
+
assert.equal(out.status, 200, `${r.panel} 应 200(实际 ${out.status}: ${JSON.stringify(out.body)})`);
|
|
173
|
+
const hit = env.control.seen[before];
|
|
174
|
+
assert.ok(hit, `${r.panel} 必须打到控制面`);
|
|
175
|
+
assert.equal(`${hit.method} ${hit.path}`, r.control, `${r.panel} → 控制面路由必须一一对应`);
|
|
176
|
+
assert.equal(hit.header, SECRET, `每一次代理都必须带 ${HDR}(少了控制面会 401)`);
|
|
177
|
+
}
|
|
178
|
+
// 成功时上游响应体原样透传(面板据此渲染二维码/状态)
|
|
179
|
+
const st = await call(env.base, "/dsh-remote/wechat/status");
|
|
180
|
+
assert.equal(st.body.bound, false);
|
|
181
|
+
const start = await call(env.base, "/dsh-remote/wechat/bind/start", "POST");
|
|
182
|
+
assert.equal(start.body.qrcode_svg, QR_DATA_URL, "二维码 data URL 必须原样透传(面板直接 <img src>)");
|
|
183
|
+
} finally {
|
|
184
|
+
await env.close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("宿主代理:verify 只转发 {code},多余字段(含 token)一律不带过去", async () => {
|
|
189
|
+
const env = await boot();
|
|
190
|
+
try {
|
|
191
|
+
const out = await call(env.base, "/dsh-remote/wechat/bind/verify", "POST", {
|
|
192
|
+
code: "4321", bot_token: "SHOULD-NEVER-BE-FORWARDED", extra: { nested: true }
|
|
193
|
+
});
|
|
194
|
+
assert.equal(out.status, 200);
|
|
195
|
+
const hit = env.control.seen[env.control.seen.length - 1];
|
|
196
|
+
assert.equal(hit.path, "/wechat/bind/verify");
|
|
197
|
+
assert.deepEqual(hit.body, { code: "4321" }, "控制面只认 {code};白名单转发,绝不做原样透传");
|
|
198
|
+
assert.equal(hit.contentType, "application/json");
|
|
199
|
+
} finally {
|
|
200
|
+
await env.close();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("宿主代理:响应里**没有 token**(含嵌套字段),且从不读 .wechat-account.json", async () => {
|
|
205
|
+
const leaks = {
|
|
206
|
+
...UNBOUND_STATUS,
|
|
207
|
+
bound: true, bot_id: "bot_1", bound_at: 1700000000000,
|
|
208
|
+
bot_token: "BOT-TOKEN-LEAK-1",
|
|
209
|
+
account: { token: "NESTED-TOKEN-LEAK-2", botId: "bot_1" },
|
|
210
|
+
nested: [{ access_token: "NESTED-TOKEN-LEAK-3" }]
|
|
211
|
+
};
|
|
212
|
+
const env = await boot({ control: { statusBody: leaks } });
|
|
213
|
+
try {
|
|
214
|
+
// 埋一个"金丝雀":宿主半边若去读 .wechat-account.json,这里立刻会露出来
|
|
215
|
+
await writeFile(path.join(env.dir, ".wechat-account.json"), JSON.stringify({ token: "CANARY-FROM-ACCOUNT-FILE" }));
|
|
216
|
+
|
|
217
|
+
const st = await call(env.base, "/dsh-remote/wechat/status");
|
|
218
|
+
const serialized = JSON.stringify(st.body);
|
|
219
|
+
assert.doesNotMatch(serialized, /token/i, `响应体里不得出现任何 token 字段:${serialized}`);
|
|
220
|
+
assert.ok(!serialized.includes("BOT-TOKEN-LEAK-1"), "不得回显 bot_token");
|
|
221
|
+
assert.ok(!serialized.includes("NESTED-TOKEN-LEAK-2"), "嵌套对象里的 token 也要被剥掉");
|
|
222
|
+
assert.ok(!serialized.includes("NESTED-TOKEN-LEAK-3"), "数组里的 token 也要被剥掉");
|
|
223
|
+
assert.ok(!serialized.includes("CANARY-FROM-ACCOUNT-FILE"), "宿主半边不得读 .wechat-account.json");
|
|
224
|
+
// 该留的字段一个不能少(脱敏不能顺手把可用字段也删了)
|
|
225
|
+
assert.equal(st.body.bound, true);
|
|
226
|
+
assert.equal(st.body.bot_id, "bot_1");
|
|
227
|
+
assert.equal(st.body.bound_at, 1700000000000);
|
|
228
|
+
|
|
229
|
+
// bind/start 同样过滤
|
|
230
|
+
const start = await call(env.base, "/dsh-remote/wechat/bind/start", "POST");
|
|
231
|
+
assert.doesNotMatch(JSON.stringify(start.body), /token/i);
|
|
232
|
+
} finally {
|
|
233
|
+
await env.close();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("宿主代理:源码级铁律 —— 不碰 .wechat-account.json,且六条路由齐备", () => {
|
|
238
|
+
assert.doesNotMatch(INDEX_SRC, /wechat-account/, "宿主半边不得读取 .wechat-account.json(里面有明文 bot_token)");
|
|
239
|
+
for (const r of ROUTE_MAP) {
|
|
240
|
+
assert.ok(INDEX_SRC.includes(`"${r.panel}"`), `应注册路由 ${r.panel}`);
|
|
241
|
+
assert.ok(INDEX_SRC.includes(`"${r.control.split(" ")[1]}"`), `应代理到控制面 ${r.control}`);
|
|
242
|
+
}
|
|
243
|
+
assert.match(INDEX_SRC, /x-dsh-bridge-secret/, "密钥头必须来自同一个常量");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("宿主代理:每一种失败都给出**各不相同**的人话(绝不塌成一句「失败」)", async () => {
|
|
247
|
+
const seen = [];
|
|
248
|
+
const record = (label, status, body) => {
|
|
249
|
+
assert.equal(status >= 500 || status === 400, true, `${label} 失败应是 4xx/5xx(实际 ${status})`);
|
|
250
|
+
assert.equal(body && body.ok, false, `${label} 失败体必须 ok:false`);
|
|
251
|
+
assert.equal(typeof body.code, "string");
|
|
252
|
+
assert.equal(typeof body.error, "string");
|
|
253
|
+
seen.push({ label, ...body });
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// ① 没有发现文件(bridge 没跑 / 版本旧)
|
|
257
|
+
{
|
|
258
|
+
const env = await boot({ noControlFile: true });
|
|
259
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
260
|
+
record("no_control_file", r.status, r.body);
|
|
261
|
+
assert.equal(r.body.code, "no_control_file");
|
|
262
|
+
await env.close();
|
|
263
|
+
}
|
|
264
|
+
// ② 发现文件损坏
|
|
265
|
+
{
|
|
266
|
+
const env = await boot({ controlPointer: "{ this is not json" });
|
|
267
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
268
|
+
record("bad_control_file", r.status, r.body);
|
|
269
|
+
assert.equal(r.body.code, "bad_control_file");
|
|
270
|
+
await env.close();
|
|
271
|
+
}
|
|
272
|
+
// ③ 发现文件是上次进程留下的(pid 已不在)
|
|
273
|
+
{
|
|
274
|
+
const dead = await boot({ controlPointer: { port: 1, pid: deadPid(), started_at: 1, header: HDR } });
|
|
275
|
+
const r = await call(dead.base, "/dsh-remote/wechat/status");
|
|
276
|
+
record("stale_control_file", r.status, r.body);
|
|
277
|
+
assert.equal(r.body.code, "stale_control_file");
|
|
278
|
+
await dead.close();
|
|
279
|
+
}
|
|
280
|
+
// ④ 连接被拒绝(端口上没有监听者)
|
|
281
|
+
{
|
|
282
|
+
const port = await deadPort();
|
|
283
|
+
const env = await boot({ controlPointer: { port, pid: process.pid, started_at: Date.now(), header: HDR } });
|
|
284
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
285
|
+
record("refused", r.status, r.body);
|
|
286
|
+
assert.equal(r.body.code, "refused");
|
|
287
|
+
await env.close();
|
|
288
|
+
}
|
|
289
|
+
// ⑤ 401:本机密钥与控制面不匹配
|
|
290
|
+
{
|
|
291
|
+
const env = await boot({ control: { secret: "a-totally-different-secret" } });
|
|
292
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
293
|
+
record("unauthorized", r.status, r.body);
|
|
294
|
+
assert.equal(r.body.code, "unauthorized");
|
|
295
|
+
await env.close();
|
|
296
|
+
}
|
|
297
|
+
// ⑥ 403:控制面没配密钥(安全默认:拒绝一切)
|
|
298
|
+
{
|
|
299
|
+
const env = await boot({ control: { noSecretAtBridge: true } });
|
|
300
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
301
|
+
record("forbidden", r.status, r.body);
|
|
302
|
+
assert.equal(r.body.code, "forbidden");
|
|
303
|
+
await env.close();
|
|
304
|
+
}
|
|
305
|
+
// ⑦ 404:控制面版本旧,不认识该路由
|
|
306
|
+
{
|
|
307
|
+
const env = await boot({ control: { notFound: true } });
|
|
308
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
309
|
+
record("no_such_route", r.status, r.body);
|
|
310
|
+
assert.equal(r.body.code, "no_such_route");
|
|
311
|
+
await env.close();
|
|
312
|
+
}
|
|
313
|
+
// ⑧ 超时:控制面挂住不回
|
|
314
|
+
{
|
|
315
|
+
const env = await boot({ control: { delayMs: 800 } });
|
|
316
|
+
const prev = process.env.DSH_WECHAT_CONTROL_TIMEOUT_MS;
|
|
317
|
+
process.env.DSH_WECHAT_CONTROL_TIMEOUT_MS = "200";
|
|
318
|
+
try {
|
|
319
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
320
|
+
record("timeout", r.status, r.body);
|
|
321
|
+
assert.equal(r.body.code, "timeout");
|
|
322
|
+
} finally {
|
|
323
|
+
if (prev === undefined) delete process.env.DSH_WECHAT_CONTROL_TIMEOUT_MS;
|
|
324
|
+
else process.env.DSH_WECHAT_CONTROL_TIMEOUT_MS = prev;
|
|
325
|
+
await env.close();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ⑨ 本机还没有设备密钥(不能伪装成连接故障)
|
|
329
|
+
{
|
|
330
|
+
const env = await boot({ config: { device_id: "dev-no-secret" } });
|
|
331
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
332
|
+
record("no_secret", r.status, r.body);
|
|
333
|
+
assert.equal(r.body.code, "no_secret");
|
|
334
|
+
await env.close();
|
|
335
|
+
}
|
|
336
|
+
// ⑩ 上游返回的不是 JSON
|
|
337
|
+
{
|
|
338
|
+
const env = await boot({ control: { garbage: true } });
|
|
339
|
+
const r = await call(env.base, "/dsh-remote/wechat/status");
|
|
340
|
+
record("bad_response", r.status, r.body);
|
|
341
|
+
assert.equal(r.body.code, "bad_response");
|
|
342
|
+
await env.close();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 每条文案必须互不相同、且不是「失败」两个字了事
|
|
346
|
+
const messages = seen.map((s) => s.error);
|
|
347
|
+
assert.equal(new Set(messages).size, messages.length, `失败文案必须两两不同:\n${messages.join("\n")}`);
|
|
348
|
+
for (const s of seen) {
|
|
349
|
+
assert.ok(s.error.length >= 20, `${s.label} 的文案过于笼统:${s.error}`);
|
|
350
|
+
assert.notEqual(s.error.trim(), "失败");
|
|
351
|
+
assert.doesNotMatch(s.error, /^error$/i);
|
|
352
|
+
}
|
|
353
|
+
// 处置动作也要能分辨出来:有的要"启动/重启后台服务",有的要"一键更新",有的要"登录"
|
|
354
|
+
assert.match(seen.find((s) => s.code === "stale_control_file").error, /重启后台服务/);
|
|
355
|
+
assert.match(seen.find((s) => s.code === "unauthorized").error, /一键更新/);
|
|
356
|
+
assert.match(seen.find((s) => s.code === "no_secret").error, /登录/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("宿主代理:上游的业务错误(400 + error 文案)原样透传,不吞成通用错误", async () => {
|
|
360
|
+
const env = await boot({ control: { bindStartError: "已经绑定过了;如需更换请先解绑" } });
|
|
361
|
+
try {
|
|
362
|
+
const r = await call(env.base, "/dsh-remote/wechat/bind/start", "POST");
|
|
363
|
+
assert.equal(r.status, 502);
|
|
364
|
+
assert.equal(r.body.code, "upstream_400");
|
|
365
|
+
assert.equal(r.body.error, "已经绑定过了;如需更换请先解绑", "上游写给用户看的文案必须原样送达");
|
|
366
|
+
} finally {
|
|
367
|
+
await env.close();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import assert from "node:assert/strict";
|
|
24
24
|
import http from "node:http";
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
|
+
import * as nodeFs from "node:fs";
|
|
26
27
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
28
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
28
29
|
import os from "node:os";
|
|
@@ -476,9 +477,125 @@ test("测试隔离:套件不得访问任何外部地址(生产限流会被
|
|
|
476
477
|
const src = readFileSync(guard, "utf8");
|
|
477
478
|
assert.ok(/ENETUNREACH/.test(src), "护栏应阻断外部请求");
|
|
478
479
|
assert.ok(/LOCAL_HOST/.test(src), "护栏应只放行本地地址");
|
|
480
|
+
// 第二道闸:兜底默认地址也必须能被测试指向本地。
|
|
481
|
+
// 用例拆掉临时目录之后,仍在飞的后台请求(装机上报失败重试)会读到**空配置**并回落到
|
|
482
|
+
// 「默认云端地址」→ 打生产 /api/public-config。光靠逐用例写 api_url 挡不住(重试发生在拆卸之后),
|
|
483
|
+
// 所以常量本身必须认 DSH_RELAY_DEFAULT_API,且测试脚本统一指到本地死端口。
|
|
484
|
+
const loopback = /DSH_RELAY_DEFAULT_API=http:\/\/127\.0\.0\.1:\d+/;
|
|
485
|
+
for (const name of ["test:plugin", "test:bridge"]) {
|
|
486
|
+
assert.match(String(pkg.scripts[name]), loopback, `${name} 必须把兜底默认地址指向本地死端口`);
|
|
487
|
+
}
|
|
488
|
+
assert.match(INDEX_SRC, /process\.env\.DSH_RELAY_DEFAULT_API/,
|
|
489
|
+
"插件半的 DEFAULT_API 必须可被 DSH_RELAY_DEFAULT_API 覆盖(否则兜底路径会打生产)");
|
|
490
|
+
assert.match(SETUP_SRC, /process\.env\.DSH_RELAY_DEFAULT_API/,
|
|
491
|
+
"安装器的 DEFAULT_API 必须可被 DSH_RELAY_DEFAULT_API 覆盖(同一份语义)");
|
|
479
492
|
});
|
|
480
493
|
|
|
481
494
|
test("前端:平台文案不再默认 macOS(Windows 用户看不懂「自启动服务」)", () => {
|
|
482
495
|
assert.ok(/serviceManager === "detached"/.test(CLIENT_SRC), "面板应识别 Windows 的 detached 模式");
|
|
483
496
|
assert.match(CLIENT_SRC, /Windows 任务计划程序 dsh-remote-bridge/, "卸载说明要写明 Windows 侧会删掉什么");
|
|
484
497
|
});
|
|
498
|
+
|
|
499
|
+
// ─────────────── ⑧ Windows:不得弹出黑色空白命令行窗口 ───────────────
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* 复刻 VBScript 的字符串字面量求值:`"""a""b"` → `"a"b`。
|
|
503
|
+
* 规则:s[0] 是开引号;正文从 s[1] 起扫,`""` = 一个字面量引号,孤立 `"` = 结束。
|
|
504
|
+
* 用它把生成的 .vbs **反解回真正会被执行的命令行** —— 这样断言的是"转义可往返",
|
|
505
|
+
* 而不是"正则看着像"(正则对多一个少一个引号完全不敏感,那正是最容易犯的错)。
|
|
506
|
+
*/
|
|
507
|
+
function evalVbsLiteral(s) {
|
|
508
|
+
assert.equal(s[0], '"', "VBS 字符串字面量必须以双引号开头");
|
|
509
|
+
let i = 1;
|
|
510
|
+
let out = "";
|
|
511
|
+
while (i < s.length) {
|
|
512
|
+
if (s[i] === '"') {
|
|
513
|
+
if (s[i + 1] === '"') { out += '"'; i += 2; continue; }
|
|
514
|
+
return { value: out, end: i };
|
|
515
|
+
}
|
|
516
|
+
out += s[i];
|
|
517
|
+
i += 1;
|
|
518
|
+
}
|
|
519
|
+
throw new Error("VBS 字符串字面量未闭合");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
test("win32:登录任务经 wscript+VBS 隐藏启动(旧版会弹黑色空白命令行窗口)", async () => {
|
|
523
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-win-launcher-"));
|
|
524
|
+
try {
|
|
525
|
+
const src = SETUP_SRC.slice(
|
|
526
|
+
SETUP_SRC.indexOf("function winHiddenLauncherPath()"),
|
|
527
|
+
SETUP_SRC.indexOf("function writeWindowsTask()"),
|
|
528
|
+
);
|
|
529
|
+
const make = new Function("CONFIG_DIR", "fs", "path",
|
|
530
|
+
`${src}\nreturn { winHiddenLauncherPath, writeWinHiddenLauncher };`);
|
|
531
|
+
const { winHiddenLauncherPath, writeWinHiddenLauncher } = make(dir, nodeFs, path);
|
|
532
|
+
|
|
533
|
+
// 用「空格 + 中文 + 括号」的路径:Windows 上最常见的翻车形状
|
|
534
|
+
const node = "C:\\Program Files\\nodejs\\node.exe";
|
|
535
|
+
const setup = path.join(dir, "张三", "My App (x64)", "dsh-setup.mjs");
|
|
536
|
+
const vbsPath = writeWinHiddenLauncher(node, setup);
|
|
537
|
+
assert.equal(vbsPath, winHiddenLauncherPath(), "启动器应落在配置目录");
|
|
538
|
+
assert.ok(existsSync(vbsPath), "必须真的写出 .vbs");
|
|
539
|
+
|
|
540
|
+
const vbs = readFileSync(vbsPath, "utf8").replace(/\r\n/g, "\n");
|
|
541
|
+
const line = vbs.split("\n").find((l) => l.includes(".Run "));
|
|
542
|
+
assert.ok(line, "应调用 WScript.Shell.Run");
|
|
543
|
+
// 窗口样式 0 = 完全隐藏;第三参 False = 不等待(node 成为独立进程,任务计划程序不被拖住)
|
|
544
|
+
assert.ok(/,\s*0,\s*False\s*$/.test(line), `Run 必须是 (cmd, 0, False),实际:${line}`);
|
|
545
|
+
assert.ok(!/\bTrue\b/.test(vbs), "不得等待子进程");
|
|
546
|
+
|
|
547
|
+
// 转义可往返:反解出的命令行必须与目标命令行**逐字节相等**
|
|
548
|
+
const at = line.indexOf(".Run ");
|
|
549
|
+
const q = line.indexOf('"', at);
|
|
550
|
+
const { value } = evalVbsLiteral(line.slice(q));
|
|
551
|
+
assert.equal(value, `"${node}" "${setup}" run`, "VBS 字面量转义必须可往返(内嵌引号写成两个)");
|
|
552
|
+
assert.ok(value.includes("My App (x64)"), "含空格/括号的路径必须完整保留");
|
|
553
|
+
assert.ok(value.includes("张三"), "中文路径必须完整保留");
|
|
554
|
+
} finally {
|
|
555
|
+
await rm(dir, { recursive: true, force: true });
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("win32:注册的是 wscript,且隐藏方式不可用时回退直接启动(保住「能自启」底线)", () => {
|
|
560
|
+
const fn = SETUP_SRC.slice(SETUP_SRC.indexOf("function writeWindowsTask()"),
|
|
561
|
+
SETUP_SRC.indexOf("/** 查询 Windows 登录任务是否已注册"));
|
|
562
|
+
assert.ok(/tr = `wscript\.exe \/\/B "\$\{writeWinHiddenLauncher\(node, setup\)\}"`/.test(fn),
|
|
563
|
+
"任务命令行必须是 wscript.exe //B <vbs>");
|
|
564
|
+
assert.ok(/无法生成隐藏启动器/.test(fn), "写不了 .vbs 要如实告警,不能静默");
|
|
565
|
+
// 组策略禁用 Windows Script Host 等 → 注册失败必须回退,否则自启动整条失效
|
|
566
|
+
assert.ok(/if \(!created\.ok && hidden\)/.test(fn), "隐藏方式注册失败必须有回退分支");
|
|
567
|
+
assert.ok(/tr = direct;/.test(fn), "回退必须换回直接命令行");
|
|
568
|
+
assert.ok(/let direct = `"\$\{node\}" "\$\{setup\}" run`/.test(fn), "直接命令行形态应保留");
|
|
569
|
+
// 旧版本注册的可见任务:/Delete + /Create 必须仍然成对,否则升级后旧任务残留
|
|
570
|
+
assert.ok(/schtasks\(\["\/Delete", "\/TN", WIN_TASK_NAME, "\/F"\]\)/.test(fn), "必须先删旧任务(迁移可见任务)");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("win32:所有 detached 子进程都必须带 windowsHide(否则必弹新的黑色窗口)", () => {
|
|
574
|
+
// 根因:Windows 上 detached 即 DETACHED_PROCESS —— 子进程**不继承父控制台**,
|
|
575
|
+
// 控制台程序(node.exe)于是拿到一个全新的可见窗口。只有 windowsHide(CREATE_NO_WINDOW)
|
|
576
|
+
// 能压掉它。做成全仓不变量检查,防止以后新增 detached spawn 时再犯同一个错。
|
|
577
|
+
const files = { "dsh-setup.mjs": SETUP_SRC, "lib/index.js": INDEX_SRC };
|
|
578
|
+
let checked = 0;
|
|
579
|
+
for (const [name, src] of Object.entries(files)) {
|
|
580
|
+
const re = /\b(spawn|spawnSync)\s*\(/g;
|
|
581
|
+
let m;
|
|
582
|
+
while ((m = re.exec(src))) {
|
|
583
|
+
let i = re.lastIndex - 1;
|
|
584
|
+
let depth = 0;
|
|
585
|
+
let end = i;
|
|
586
|
+
for (; i < src.length; i += 1) {
|
|
587
|
+
if (src[i] === "(") depth += 1;
|
|
588
|
+
else if (src[i] === ")") { depth -= 1; if (!depth) { end = i; break; } }
|
|
589
|
+
}
|
|
590
|
+
const call = src.slice(m.index, end + 1);
|
|
591
|
+
if (!/detached:\s*true/.test(call)) continue;
|
|
592
|
+
// POSIX 专属(/bin/sh、launchctl…)在 Windows 上根本不会执行,不适用
|
|
593
|
+
if (/\/bin\/sh|\/bin\/bash|launchctl|systemctl/.test(call)) continue;
|
|
594
|
+
checked += 1;
|
|
595
|
+
const lineNo = src.slice(0, m.index).split("\n").length;
|
|
596
|
+
assert.ok(/windowsHide:\s*true/.test(call),
|
|
597
|
+
`${name}:${lineNo} 的 detached spawn 缺 windowsHide → Windows 上会弹黑色窗口`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
assert.ok(checked >= 3, `至少应检查到 3 处 detached spawn,实际 ${checked}(切片/匹配可能失效)`);
|
|
601
|
+
});
|