@mrrisega/dsh-remote 0.7.5-beta.1 → 0.7.6-beta.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.
@@ -1,328 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 手机端「同一 deviceId 只允许一个镜像页」回归(2026-10-23)。
4
- *
5
- * 生产现场:手机端会出现同一设备的两个镜像页并发(重复打开 / 从历史里点回来),
6
- * 两边互相取消资源加载 → 都一直转圈。
7
- *
8
- * 注入体里的守卫(mobile-adapter 的 DSH-MA-SINGLEPAGE 段)契约:
9
- * ① 第一个页面:同步写租约(localStorage)+ BroadcastChannel claim,正常加载;
10
- * ② 第二个页面:同步读到"别人的新鲜租约"→ 立刻 blocked(window.stop + 拦 fetch/XHR/WS
11
- * + 全屏提示),**不写租约、不加载**;
12
- * ③ 「在本窗口继续」:广播 takeover → 旧页让路并释放租约 → 本页 400ms 后重载接管;
13
- * ④ 旧页关掉/失联(租约过期)→ blocked 页自动重载接管,不会永久卡在提示层;
14
- * ⑤ 非镜像页(设备列表/登录页)、老浏览器(无 localStorage/BroadcastChannel)→ 零行为。
15
- *
16
- * 本文件跑的是注入脚本里**真实的那一段**(vm + 假 DOM),断言的是行为不是文本。
17
- */
18
- import assert from "node:assert/strict";
19
- import test from "node:test";
20
- import vm from "node:vm";
21
- import { injectMobileAdapter } from "../mobile-adapter.mjs";
22
-
23
- /** 注入脚本里取出单页守卫那一段(含结尾的调用)。 */
24
- function guardSource() {
25
- const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
26
- const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
27
- assert.ok(m, "应能取出注入脚本");
28
- const js = m[1];
29
- const start = "/* ===DSH-MA-SINGLEPAGE-START===";
30
- const end = "/* ===DSH-MA-SINGLEPAGE-END=== */";
31
- const a = js.indexOf(start);
32
- const b = js.indexOf(end);
33
- assert.ok(a > 0 && b > a, "找不到单页守卫的段落标记(脚本结构变了,请同步更新本用例)");
34
- return js.slice(a, b + end.length);
35
- }
36
-
37
- const SRC = guardSource();
38
-
39
- // ── 假环境(同一"浏览器"的多个页面共享 storage 与 BroadcastChannel 总线)──────────
40
-
41
- const NOW0 = 1_700_000_000_000;
42
-
43
- function makeGroup() {
44
- return {
45
- store: new Map(),
46
- channels: new Set(),
47
- messages: [],
48
- clock: { now: NOW0 }
49
- };
50
- }
51
-
52
- function makeStorage(group) {
53
- return {
54
- getItem(k) { return group.store.has(k) ? group.store.get(k) : null; },
55
- setItem(k, v) { group.store.set(k, String(v)); },
56
- removeItem(k) { group.store.delete(k); }
57
- };
58
- }
59
-
60
- class FakeBC {
61
- constructor(group, name) {
62
- this.group = group;
63
- this.name = name;
64
- this.onmessage = null;
65
- group.channels.add(this);
66
- }
67
- postMessage(msg) {
68
- this.group.messages.push(msg);
69
- // 浏览器里是异步投递;用例里同步投递(同源多页面),断言更直接
70
- for (const peer of this.group.channels) {
71
- if (peer === this) continue;
72
- if (typeof peer.onmessage === "function") peer.onmessage({ data: msg });
73
- }
74
- }
75
- close() { this.group.channels.delete(this); }
76
- }
77
-
78
- /** 极简假 DOM:只支持守卫用到的那点能力(createElement/body.appendChild/getElementById)。 */
79
- function makeDoc() {
80
- const appended = [];
81
- const buttons = new Map();
82
- const makeEl = (tag) => {
83
- const el = {
84
- tagName: String(tag).toUpperCase(),
85
- id: "",
86
- attrs: {},
87
- style: { cssText: "" },
88
- innerHTML: "",
89
- children: [],
90
- _listeners: new Map(),
91
- setAttribute(n, v) { this.attrs[n] = String(v); },
92
- getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
93
- addEventListener(type, fn) { (this._listeners.get(type) || this._listeners.set(type, []).get(type)).push(fn); },
94
- appendChild(c) { this.children.push(c); return c; }
95
- };
96
- return el;
97
- };
98
- const body = makeEl("body");
99
- const doc = {
100
- body,
101
- _appended: appended,
102
- createElement: makeEl,
103
- addEventListener() {},
104
- getElementById(id) {
105
- const found = appended.find((el) => el.id === id || el.attrs.id === id);
106
- if (found) return found;
107
- // 提示层里的「在本窗口继续」按钮:innerHTML 里定义了 id,按需造一个可点击元素
108
- const overlay = appended.find((el) => el.id === "dsh-ma-singlepage");
109
- if (overlay && overlay.innerHTML.includes(`id="${id}"`)) {
110
- if (!buttons.has(id)) {
111
- const btn = makeEl("button");
112
- btn.id = id;
113
- buttons.set(id, btn);
114
- }
115
- return buttons.get(id);
116
- }
117
- return null;
118
- }
119
- };
120
- body.appendChild = (el) => { appended.push(el); return el; };
121
- return doc;
122
- }
123
-
124
- function makeEnv(group, { pathname = "/remote/dev-A/", withBC = true, withStorage = true } = {}) {
125
- const timers = { intervals: new Map(), timeouts: new Map(), seq: 0 };
126
- const doc = makeDoc();
127
- const storage = withStorage ? makeStorage(group) : null;
128
- const state = { stops: 0, reloads: 0, messages: [] };
129
- const loc = {
130
- pathname,
131
- href: `https://relay.example${pathname}`,
132
- reload() { state.reloads += 1; }
133
- };
134
- const FakeDate = class extends Date {
135
- static now() { return group.clock.now; }
136
- };
137
- const win = {
138
- localStorage: storage,
139
- // 真实 window.navigator 有 locks 就用(老 Safari 没有);这里默认不给,测租约路径
140
- navigator: {},
141
- location: loc,
142
- addEventListener(type, fn) { state.messages.push(type); (win._ls || (win._ls = [])).push({ type, fn }); },
143
- _ls: [],
144
- stop() { state.stops += 1; },
145
- fetch: () => Promise.resolve({ ok: true }),
146
- XMLHttpRequest: class { send() {} abort() {} },
147
- WebSocket: class { constructor() { this.readyState = 1; } },
148
- console
149
- };
150
- if (withBC) win.BroadcastChannel = class extends FakeBC { constructor(name) { super(group, name); } };
151
- const sandbox = {
152
- window: win,
153
- document: doc,
154
- location: loc,
155
- localStorage: storage,
156
- console,
157
- Promise,
158
- Date: FakeDate,
159
- Math,
160
- JSON,
161
- Object,
162
- Array,
163
- String,
164
- Number,
165
- Boolean,
166
- RegExp,
167
- Error,
168
- decodeURIComponent,
169
- isNaN,
170
- parseInt,
171
- parseFloat,
172
- setInterval: (fn, ms) => { const id = ++timers.seq; timers.intervals.set(id, { fn, ms, ticks: 0 }); return id; },
173
- clearInterval: (id) => { timers.intervals.delete(id); },
174
- setTimeout: (fn, ms) => { const id = ++timers.seq; timers.timeouts.set(id, { fn, ms }); return id; },
175
- clearTimeout: (id) => { timers.timeouts.delete(id); }
176
- };
177
- vm.runInNewContext(SRC, sandbox, { filename: "mobile-adapter#singlepage" });
178
- const api = {
179
- win,
180
- doc,
181
- loc,
182
- sandbox,
183
- timers,
184
- state,
185
- get appState() { return win.__dshSinglePage ? win.__dshSinglePage.state : ""; },
186
- /** 跑一轮 2s 轮询(blocked 页的租约复查)。 */
187
- tickPoll() {
188
- for (const t of timers.intervals.values()) { if (t.ms === 2000) t.fn(); }
189
- },
190
- runTimeouts() {
191
- for (const [id, t] of [...timers.timeouts]) { timers.timeouts.delete(id); t.fn(); }
192
- },
193
- clickTakeover() {
194
- const btn = doc.getElementById("dsh-ma-singlepage-takeover");
195
- assert.ok(btn, "提示层里必须有「在本窗口继续」按钮");
196
- const fns = btn._listeners.get("click") || [];
197
- assert.ok(fns.length > 0, "按钮必须绑定了点击处理");
198
- for (const fn of fns) fn({ type: "click" });
199
- },
200
- dispatchWindow(type, extra = {}) {
201
- for (const l of win._ls || []) if (l.type === type) l.fn({ type, ...extra });
202
- },
203
- lease() {
204
- const raw = storage && storage.getItem(`dsh-remote-mirror-page:${pathname.split("/")[2]}`);
205
- return raw ? JSON.parse(raw) : null;
206
- },
207
- storeGet(key) { return storage ? storage.getItem(key) : null; },
208
- storeSet(key, val) { if (storage) storage.setItem(key, val); }
209
- };
210
- return api;
211
- }
212
-
213
- // ── 用例 ──────────────────────────────────────────────────────────────────────
214
-
215
- test("① 第一个页面:同步写租约 + 广播 claim,正常加载", () => {
216
- const g = makeGroup();
217
- const a = makeEnv(g);
218
- assert.equal(a.appState, "active", "第一个页面必须是活跃页");
219
- const lease = a.lease();
220
- assert.ok(lease && typeof lease.id === "string", "必须写入租约");
221
- assert.equal(lease.at, NOW0);
222
- const claim = g.messages.find((m) => m.k === "claim");
223
- assert.ok(claim, "必须广播 claim(让后来的页面知道有人)");
224
- assert.equal(a.state.stops, 0, "活跃页不得停掉自己的加载");
225
- });
226
-
227
- test("② 第二个页面:同步识破别人的租约 → 立刻 blocked(stop + 拦网络 + 提示),且不写租约", async () => {
228
- const g = makeGroup();
229
- const first = makeEnv(g);
230
- const second = makeEnv(g);
231
- assert.equal(second.appState, "blocked", "第二页必须被按住");
232
- assert.equal(second.state.stops, 1, "必须 window.stop()(停止后续资源加载)");
233
- const overlay = second.doc.getElementById("dsh-ma-singlepage");
234
- assert.ok(overlay, "必须给出可见提示(不是静默卡住)");
235
- assert.equal(overlay.getAttribute("data-dsh-single-page"), "blocked");
236
- assert.match(overlay.innerHTML, /另一个窗口/, "提示文案要点明「已在另一个窗口打开」");
237
- assert.match(overlay.innerHTML, /在本窗口继续/, "必须给「在本窗口继续」出口");
238
- // 拦网络:fetch 拒绝、WebSocket 不可用
239
- await assert.rejects(() => second.win.fetch("/api/x"), /本页已停用/);
240
- assert.throws(() => new second.win.WebSocket("ws://x"), /本页已停用/);
241
- // 不许抢租约:租约仍属于第一页
242
- assert.equal(second.lease().id, first.lease().id, "第二页绝不能覆盖第一页的租约");
243
- });
244
-
245
- test("③ 「在本窗口继续」:广播 takeover → 旧页让路并释放租约 → 本页重载接管", () => {
246
- const g = makeGroup();
247
- const first = makeEnv(g);
248
- const second = makeEnv(g);
249
- const firstLeaseId = first.lease().id;
250
-
251
- second.clickTakeover();
252
- // 旧页收到 takeover:立刻让路(进入 blocked、释放租约)
253
- assert.equal(first.appState, "blocked", "旧页必须让路,不能两边都继续加载");
254
- assert.equal(first.lease(), null, "旧页必须释放租约");
255
- assert.ok(g.messages.some((m) => m.k === "takeover"), "必须广播 takeover");
256
- // 本页 400ms 后重载(重载后按 takeover 标记直接接管)
257
- assert.equal(second.state.reloads, 0, "还没到重载时间");
258
- second.runTimeouts();
259
- assert.equal(second.state.reloads, 1, "接管方必须重载一次,拿干净页面");
260
- // 重载后的路径:租约可能还在 + 有 takeover 标记 → 允许接管(否则自己把自己锁死)
261
- const reloaded = makeEnv(g);
262
- assert.equal(reloaded.appState, "active", "takeover 标记有效期内的重载必须能接管");
263
- assert.equal(reloaded.lease().id !== firstLeaseId, true, "接管后租约归属接管方");
264
- // 关键:takeover 标记必须"用掉即消费",否则有效期内第三个页面也会以为自己能接管
265
- const third = makeEnv(g);
266
- assert.equal(third.appState, "blocked", "接管完成后,再开的页面仍必须被按住(不允许并发第三个)");
267
- });
268
-
269
- test("④ 旧页关掉/失联(租约过期)→ blocked 页自动重载接管,不会永久卡住", () => {
270
- const g = makeGroup();
271
- makeEnv(g); // 第一页写完租约后"关掉了"(不再心跳)
272
- const second = makeEnv(g);
273
- assert.equal(second.appState, "blocked");
274
- // 时间前进到租约过期之后
275
- g.clock.now = NOW0 + 7000;
276
- second.tickPoll();
277
- assert.equal(second.state.reloads, 1, "租约过期后必须自动重载接管");
278
- assert.ok(second.storeGet("dsh-remote-mirror-page:dev-A:takeover"), "重载前要留 takeover 标记(避免重载后又被自己挡住)");
279
- });
280
-
281
- test("⑤ 活跃页 pagehide 时释放租约;bfcache 回来(pageshow persisted)重新判定", () => {
282
- const g = makeGroup();
283
- const a = makeEnv(g);
284
- assert.ok(a.lease());
285
- a.dispatchWindow("pagehide");
286
- assert.equal(a.lease(), null, "pagehide 必须释放租约");
287
- assert.equal(a.appState, "inactive", "pagehide 后不再是活跃页");
288
- // bfcache 回来:租约已释放 → 重新判定后重新成为活跃页
289
- a.dispatchWindow("pageshow", { persisted: true });
290
- assert.equal(a.appState, "active", "bfcache 恢复后必须重新判定并接管");
291
- assert.ok(a.lease(), "恢复后要重新写租约");
292
- // 另一个页面此刻打开 → 它看到租约并被按住(不会两个页面并发加载)
293
- const b = makeEnv(g);
294
- assert.equal(b.appState, "blocked");
295
- });
296
-
297
- test("⑥ 非镜像页 / 老浏览器 → 零行为(绝不把唯一页面锁死)", () => {
298
- // /app/ 设备列表页不参与单页约束
299
- const g1 = makeGroup();
300
- const app = makeEnv(g1, { pathname: "/app/" });
301
- assert.equal(app.appState, "", "非镜像页不得介入");
302
- assert.equal(g1.store.size, 0, "非镜像页不得写任何 localStorage");
303
- assert.equal(app.doc._appended.length, 0, "非镜像页不得往页面塞任何节点");
304
-
305
- // 没有 BroadcastChannel(部分老浏览器/隐私模式)→ 不启用
306
- const g2 = makeGroup();
307
- const noBc = makeEnv(g2, { withBC: false });
308
- assert.equal(noBc.appState, "", "没有 BroadcastChannel 时不得启用(不锁)");
309
- assert.equal(g2.store.size, 0);
310
- assert.equal(noBc.doc._appended.length, 0);
311
-
312
- // 没有 localStorage → 不启用
313
- const g3 = makeGroup();
314
- const noLs = makeEnv(g3, { withStorage: false });
315
- assert.equal(noLs.appState, "", "没有 localStorage 时不得启用");
316
- assert.equal(noLs.doc._appended.length, 0);
317
- });
318
-
319
- test("⑦ 同一时刻两个页面都从空租约启动(并发竞态)→ 后到的看到租约后被按住", () => {
320
- const g = makeGroup();
321
- // 用同一份 group 串行模拟"几乎同时":第一个先写入租约(JS 单线程,真实世界也是先到先得)
322
- const a = makeEnv(g);
323
- const b = makeEnv(g);
324
- assert.equal(a.appState, "active");
325
- assert.equal(b.appState, "blocked");
326
- // 两个页面不会互相写坏租约
327
- assert.equal(a.lease().id, b.lease().id, "租约归属第 1 页,第 2 页不覆盖");
328
- });
@@ -1,343 +0,0 @@
1
- // 🎉 企微交流群引导(使用满 2 小时弹一次)—— **宿主半边**(lib/index.js)代理路由回归。
2
- //
3
- // 背景(业主 2026-10-23 需求):用户实际使用满 2 小时后要「加入企业微信群」引导,
4
- // 每用户全局只弹一次;手机 Web 端已实现,桌面端插件面板复用**同一套企业端接口**:
5
- // GET /api/community-invite/status → { ok, show, reason, used_seconds, required_seconds, qr_url }
6
- // POST /api/community-invite/shown → { ok:true }(幂等)
7
- //
8
- // 桌面端浏览器半没有企业端用户 token 直连,所以本文件锁 node 半两条代理路由的契约:
9
- // GET /dsh-remote/community-invite/status → 上游 GET {api}/api/community-invite/status
10
- // POST /dsh-remote/community-invite/shown → 上游 POST {api}/api/community-invite/shown
11
- //
12
- // 最要紧的四件(都是"写错就出事故"的点):
13
- // ① 身份只认本机 device-login token,**不透传浏览器带来的 Authorization**;
14
- // ② 判定/记账全在服务端:show/reason/时长字段原样透传,node 半**不自己算**;
15
- // ③ qr_url 归一化成企业端绝对地址(相对路径在 dsh web 源上会解析到 127.0.0.1),
16
- // 非 http(s)/站内路径一律丢弃(前端降级),绝不把 data:/javascript: 交给 <img src>;
17
- // ④ 一切失败软着陆:未登录/自建 → 200 + ok:false(面板静默);上游 4xx/5xx 透传;
18
- // 不可达 → 502 JSON,**绝不抛回 webServer**(否则面板会看到 500 堆栈)。
19
- import assert from "node:assert/strict";
20
- import http from "node:http";
21
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
22
- import os from "node:os";
23
- import path from "node:path";
24
- import test from "node:test";
25
- import { apply } from "../lib/index.js";
26
-
27
- /** 假 relay:device-login 发 token;community-invite 两个端点按 opts 返回(并记录收到的请求)。 */
28
- async function startFakeRelay(opts = {}) {
29
- const seen = [];
30
- const srv = http.createServer((req, res) => {
31
- let raw = "";
32
- req.on("data", (c) => { raw += c; });
33
- req.on("end", () => {
34
- const entry = { method: req.method, url: req.url, headers: req.headers, body: raw };
35
- seen.push(entry);
36
- const send = (code, obj) => {
37
- res.writeHead(code, { "content-type": "application/json" });
38
- res.end(JSON.stringify(obj));
39
- };
40
- const url = new URL(req.url, "http://x");
41
- // 模拟"token 拿到了、但企业端 community-invite 端点不可达":直接掐连接(fetch 端表现为 status 0)
42
- if (opts.killCommunity && url.pathname.startsWith("/api/community-invite")) {
43
- try { req.socket.destroy(); } catch { /* 忽略 */ }
44
- return;
45
- }
46
- if (req.method === "POST" && url.pathname === "/api/device-login") {
47
- // device-login 失败场景:返回 401(插件 node 半应静默降级成 not_logged_in)
48
- if (opts.loginStatus && opts.loginStatus !== 200) return send(opts.loginStatus, { error: { code: "bad_credentials", message: "密码错误" } });
49
- return send(200, { token: "jwt-abc" });
50
- }
51
- if (url.pathname === "/api/community-invite/status") {
52
- if (opts.statusCode && opts.statusCode !== 200) {
53
- return send(opts.statusCode, { error: { code: "boom", message: "服务端炸了" } });
54
- }
55
- return send(200, opts.statusBody || {
56
- ok: true,
57
- show: true,
58
- reason: "ok",
59
- used_seconds: 7205,
60
- required_seconds: 7200,
61
- qr_url: "/qr/group-qr.png"
62
- });
63
- }
64
- if (req.method === "POST" && url.pathname === "/api/community-invite/shown") {
65
- return send(200, { ok: true });
66
- }
67
- send(404, { error: { code: "not_found" } });
68
- });
69
- });
70
- await new Promise((r) => srv.listen(0, "127.0.0.1", r));
71
- return { srv, seen, base: `http://127.0.0.1:${srv.address().port}` };
72
- }
73
-
74
- function boot(relayDir) {
75
- const routes = new Map();
76
- apply({
77
- webServer: { register(rt) { routes.set(rt.path, rt.handler); return () => {}; } },
78
- effect(reg) { return reg(); },
79
- logger: { info() {}, warn() {} },
80
- }, { relayDir });
81
- const host = http.createServer((req, res) => {
82
- const url = new URL(req.url, "http://x");
83
- // 与 announcements-proxy.test.mjs 对齐:注册对象只有 {kind,path,handler},
84
- // prefix 路由的 handler 自己判别子路径(没有 route.prefix 字段)。
85
- const handler = routes.get(url.pathname) || routes.get("/dsh-remote/community-invite");
86
- (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
87
- });
88
- return host;
89
- }
90
-
91
- const writeCfg = (relayDir, apiUrl, extra = {}) => writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
92
- device_id: "dev-ci-1", api_url: apiUrl, phone: "13800000000", password: "pw", ...extra,
93
- }));
94
-
95
- /** 只挑 community-invite 的上游请求(插件装载时还会打 public-config / device-login 等旁路请求)。 */
96
- const ciReqs = (up) => up.seen.filter((e) => String(e.url || "").startsWith("/api/community-invite"));
97
-
98
- async function withPlugin(fn) {
99
- const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ci-"));
100
- process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 套件级隔离:绝不真的去后台装运行环境
101
- let host = null;
102
- try {
103
- return await fn({ relayDir, startHost: () => { host = boot(relayDir); return host; } });
104
- } finally {
105
- try { if (host) host.close(); } catch { /* 忽略 */ }
106
- process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 恢复(不要 delete:套件本来就靠它隔离)
107
- await rm(relayDir, { recursive: true, force: true });
108
- }
109
- }
110
-
111
- async function listen(host) {
112
- await new Promise((r) => host.listen(0, "127.0.0.1", r));
113
- return `http://127.0.0.1:${host.address().port}`;
114
- }
115
-
116
- test("GET status:device-login token 打企业端,show/qr_url 原样透传(qr_url 归一化成绝对地址)", async () => {
117
- const up = await startFakeRelay();
118
- try {
119
- await withPlugin(async ({ relayDir, startHost }) => {
120
- await writeCfg(relayDir, up.base);
121
- const base = await listen(startHost());
122
- const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
123
- assert.equal(r.ok, true);
124
- assert.equal(r.show, true);
125
- assert.equal(r.reason, "ok");
126
- assert.equal(r.used_seconds, 7205, "时长口径由服务端算,node 半只透传");
127
- assert.equal(r.required_seconds, 7200);
128
- assert.equal(r.qr_url, `${up.base}/qr/group-qr.png`, "相对路径必须拼成企业端绝对地址(否则图片落在 127.0.0.1 上打不开)");
129
-
130
- const got = ciReqs(up).find((e) => e.method === "GET" && e.url === "/api/community-invite/status");
131
- assert.ok(got, "必须真的打到企业端 GET /api/community-invite/status");
132
- assert.equal(got.headers.authorization, "Bearer jwt-abc", "必须用 device-login 换来的 token(sub = user)");
133
- });
134
- } finally {
135
- up.srv.close();
136
- }
137
- });
138
-
139
- test("GET status:show=false(未满 2h / 已弹过 / 没配二维码)原样透传,node 半绝不自己算", async () => {
140
- const up = await startFakeRelay({
141
- statusBody: { ok: true, show: false, reason: "already_shown", used_seconds: 9000, required_seconds: 7200, qr_url: "/qr/group-qr.png" }
142
- });
143
- try {
144
- await withPlugin(async ({ relayDir, startHost }) => {
145
- await writeCfg(relayDir, up.base);
146
- const base = await listen(startHost());
147
- const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
148
- assert.equal(r.ok, true);
149
- assert.equal(r.show, false, "服务端说不弹就不弹");
150
- assert.equal(r.reason, "already_shown", "reason 要透传(面板/排障用)");
151
- assert.equal(r.used_seconds, 9000);
152
- });
153
- } finally {
154
- up.srv.close();
155
- }
156
- });
157
-
158
- test("GET status:qr_url 为空 / 非法协议(data:/javascript:/协议相对)→ 下发空串(前端降级)", async () => {
159
- for (const bad of ["", " ", "data:image/png;base64,AAA", "javascript:alert(1)", "//evil.example/qr.png"]) {
160
- const up = await startFakeRelay({
161
- statusBody: { ok: true, show: true, reason: "ok", used_seconds: 7300, required_seconds: 7200, qr_url: bad }
162
- });
163
- try {
164
- await withPlugin(async ({ relayDir, startHost }) => {
165
- await writeCfg(relayDir, up.base);
166
- const base = await listen(startHost());
167
- const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
168
- assert.equal(r.show, true, `show 仍然透传(${JSON.stringify(bad)})`);
169
- assert.equal(r.qr_url, "", `非法/空 qr_url 必须下发空串,交给前端降级(实际 ${JSON.stringify(r.qr_url)})`);
170
- });
171
- } finally {
172
- up.srv.close();
173
- }
174
- }
175
- });
176
-
177
- test("POST shown:幂等上报打到企业端 POST /api/community-invite/shown(Bearer)", async () => {
178
- const up = await startFakeRelay();
179
- try {
180
- await withPlugin(async ({ relayDir, startHost }) => {
181
- await writeCfg(relayDir, up.base);
182
- const base = await listen(startHost());
183
- const res = await fetch(`${base}/dsh-remote/community-invite/shown`, { method: "POST" });
184
- assert.equal(res.status, 200);
185
- const r = await res.json();
186
- assert.equal(r.ok, true);
187
- const got = ciReqs(up).find((e) => e.method === "POST" && e.url === "/api/community-invite/shown");
188
- assert.ok(got, "必须真的打到企业端 POST /api/community-invite/shown");
189
- assert.equal(got.headers.authorization, "Bearer jwt-abc");
190
- // 上报两次也必须都成功(幂等契约:展示时一次 + 关闭时一次)
191
- const again = await (await fetch(`${base}/dsh-remote/community-invite/shown`, { method: "POST" })).json();
192
- assert.equal(again.ok, true);
193
- assert.equal(ciReqs(up).filter((e) => e.method === "POST").length, 2, "两次上报都要转发(服务端幂等)");
194
- });
195
- } finally {
196
- up.srv.close();
197
- }
198
- });
199
-
200
- test("鉴权:没配账号 → 200 + ok:false/not_logged_in,且完全不碰企业端(不白跑 device-login)", async () => {
201
- const up = await startFakeRelay();
202
- try {
203
- await withPlugin(async ({ relayDir, startHost }) => {
204
- await writeCfg(relayDir, up.base, { phone: "", password: "" });
205
- const base = await listen(startHost());
206
- const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
207
- assert.equal(r.ok, false);
208
- assert.equal(r.show, false, "未登录绝不能让面板弹引导");
209
- assert.equal(r.reason, "not_logged_in");
210
- assert.equal(ciReqs(up).length, 0, "不得向企业端发任何 community-invite 请求");
211
- assert.equal(up.seen.filter((e) => e.url === "/api/device-login").length, 0, "没凭据就不该尝试 device-login");
212
- });
213
- } finally {
214
- up.srv.close();
215
- }
216
- });
217
-
218
- test("鉴权:自建(local_key)没有企业端账号体系 → local_mode,不发请求", async () => {
219
- const up = await startFakeRelay();
220
- try {
221
- await withPlugin(async ({ relayDir, startHost }) => {
222
- await writeCfg(relayDir, up.base, { phone: "", password: "", local_key: "selfhost-key" });
223
- const base = await listen(startHost());
224
- const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
225
- assert.equal(r.ok, false);
226
- assert.equal(r.show, false);
227
- assert.equal(r.reason, "local_mode");
228
- assert.equal(ciReqs(up).length, 0, "自建模式不得请求企业端");
229
- });
230
- } finally {
231
- up.srv.close();
232
- }
233
- });
234
-
235
- test("鉴权:device-login 换 token 失败 → 静默 not_logged_in(面板不弹、不报错)", async () => {
236
- const up = await startFakeRelay({ loginStatus: 401 });
237
- try {
238
- await withPlugin(async ({ relayDir, startHost }) => {
239
- await writeCfg(relayDir, up.base);
240
- const base = await listen(startHost());
241
- const res = await fetch(`${base}/dsh-remote/community-invite/status`);
242
- assert.equal(res.status, 200, "对面板而言是静默降级,不是 500");
243
- const r = await res.json();
244
- assert.equal(r.ok, false);
245
- assert.equal(r.show, false);
246
- assert.equal(r.reason, "not_logged_in");
247
- });
248
- } finally {
249
- up.srv.close();
250
- }
251
- });
252
-
253
- test("错误语义:企业端 401/500 → 状态码透传 + ok:false + 可读 error(面板依旧静默)", async () => {
254
- for (const code of [401, 500]) {
255
- const up = await startFakeRelay({ statusCode: code });
256
- try {
257
- await withPlugin(async ({ relayDir, startHost }) => {
258
- await writeCfg(relayDir, up.base);
259
- const base = await listen(startHost());
260
- const res = await fetch(`${base}/dsh-remote/community-invite/status`);
261
- assert.equal(res.status, code, "上游状态码透传(排障能看见,不伪装成 200)");
262
- const r = await res.json();
263
- assert.equal(r.ok, false);
264
- assert.equal(r.show, false);
265
- assert.equal(r.reason, "status_failed");
266
- assert.ok(String(r.error || "").length > 0, "必须给可读错误");
267
- });
268
- } finally {
269
- up.srv.close();
270
- }
271
- }
272
- });
273
-
274
- test("软着陆:整个企业端不可达(连 token 都拿不到)→ 200 + not_logged_in,面板不弹不报错", async () => {
275
- await withPlugin(async ({ relayDir, startHost }) => {
276
- // 指向一个一定没人监听的端口
277
- await writeCfg(relayDir, "http://127.0.0.1:1");
278
- const base = await listen(startHost());
279
- const res = await fetch(`${base}/dsh-remote/community-invite/status`);
280
- assert.equal(res.status, 200, "拿不到 token 属于'未登录',不是服务端错误(面板静默)");
281
- const r = await res.json();
282
- assert.equal(r.ok, false);
283
- assert.equal(r.show, false);
284
- assert.equal(r.reason, "not_logged_in");
285
- });
286
- });
287
-
288
- test("错误语义:token 拿到了但企业端 community-invite 端点不可达 → 502 JSON(路由自身绝不抛)", async () => {
289
- const up = await startFakeRelay({ killCommunity: true });
290
- try {
291
- await withPlugin(async ({ relayDir, startHost }) => {
292
- await writeCfg(relayDir, up.base);
293
- const base = await listen(startHost());
294
- const res = await fetch(`${base}/dsh-remote/community-invite/status`);
295
- assert.equal(res.status, 502, "已确认登录、失败发生在上游请求 → 502(排障可见,不是静默 200)");
296
- const r = await res.json();
297
- assert.equal(r.ok, false);
298
- assert.equal(r.show, false);
299
- assert.equal(r.reason, "status_failed");
300
- assert.ok(String(r.error || "").length > 0);
301
- // 路由本身绝不能把异常抛回 webServer(那会变成 500 + 堆栈)
302
- assert.notEqual(res.status, 500);
303
- });
304
- } finally {
305
- up.srv.close();
306
- }
307
- });
308
-
309
- test("未知子路径 / 错误方法 → 404,不代理到奇怪的上游路径", async () => {
310
- const up = await startFakeRelay();
311
- try {
312
- await withPlugin(async ({ relayDir, startHost }) => {
313
- await writeCfg(relayDir, up.base);
314
- const base = await listen(startHost());
315
- for (const [p, method] of [["/dsh-remote/community-invite/other", "GET"], ["/dsh-remote/community-invite/status", "POST"], ["/dsh-remote/community-invite/shown", "GET"]]) {
316
- const res = await fetch(`${base}${p}`, { method });
317
- assert.equal(res.status, 404, `${method} ${p} 必须 404`);
318
- const r = await res.json();
319
- assert.equal(r.reason, "unknown_route");
320
- }
321
- assert.equal(ciReqs(up).length, 0, "未知路由绝不请求企业端");
322
- });
323
- } finally {
324
- up.srv.close();
325
- }
326
- });
327
-
328
- test("身份纪律:浏览器带来的 Authorization **不透传**(用户级接口只认本机 node 半 token)", async () => {
329
- const up = await startFakeRelay();
330
- try {
331
- await withPlugin(async ({ relayDir, startHost }) => {
332
- await writeCfg(relayDir, up.base);
333
- const base = await listen(startHost());
334
- await fetch(`${base}/dsh-remote/community-invite/status`, { headers: { authorization: "Bearer browser-forged" } });
335
- const got = ciReqs(up).find((e) => e.method === "GET");
336
- assert.ok(got);
337
- assert.equal(got.headers.authorization, "Bearer jwt-abc",
338
- "身份必须是本机 device-login 换来的 token;浏览器带的 token 不能替代(否则'谁的身份'就含糊了)");
339
- });
340
- } finally {
341
- up.srv.close();
342
- }
343
- });