@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.
Files changed (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,357 @@
1
+ // 微信机器人通道「绑定 / 解绑」跳变的匿名遥测(客户端半)回归 —— 2026-09。
2
+ //
3
+ // 现场:微信通道是**两态**的(未绑定 / 已绑定,见 docs/wechat-bot-channel.md §9),
4
+ // 但「有多少人真的绑上了、绑上之后会不会掉」此前完全没有数据面。本用例锁死这件事的四个要点:
5
+ // ① **跳变才发**:unbound→bound = wechat_bound,bound→unbound = wechat_unbound;
6
+ // 连续读到同一个 bound(面板打开期间会反复轮询 status)**一条都不发** —— 按次计数会让
7
+ // "一台机器的一次绑定"变成几十条,指标直接失去意义;
8
+ // ② **首次观测不是跳变**:启动时状态文件已经是 bound:true,说明绑定可能发生在几天前,
9
+ // 只播种基线、不发事件(把它当新绑定会系统性虚高 —— 这是最容易写错的一条);
10
+ // ③ **读不出 = 没有观测**:状态文件缺失 / 损坏 / 半写既不抛进面板路由,也**不当作 unbound**;
11
+ // ④ **隐私**:只发事件名 + 版本/平台串;bot_id / bot_token / 绑定的微信用户标识 / 手机号
12
+ // 一个都不许出现在 payload 里(本文件对**序列化后的**事件断言,不看源码文字)。
13
+ //
14
+ // 手法:与 telemetry.test.mjs 一致 —— 假 HOME + PATH 上的假 launchctl/pgrep/ps/npx(绝不碰本机真实服务),
15
+ // 与 wechat-proxy.test.mjs 一致 —— 假控制面 + 真面板路由(观测点挂在唯一的代理出口上)。
16
+ import assert from "node:assert/strict";
17
+ import { chmodSync, existsSync, readdirSync } from "node:fs";
18
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
19
+ import http from "node:http";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import test from "node:test";
23
+ import { apply, __telemetryInternals as TELEMETRY } from "../lib/index.js";
24
+
25
+ const SECRET = "sec-wechat-tel-abc123";
26
+ /** 控制面契约里唯一的密钥头(与 wechat-runtime.mjs 的 CONTROL_HEADER 同值)。 */
27
+ const HDR = "x-dsh-bridge-secret";
28
+ /** 事件 payload 允许出现的字段(与 telemetry.test.mjs / telemetryEventOf() 同一份白名单)。 */
29
+ const ALLOWED_EVENT_KEYS = new Set(["name", "at", "version", "harness_version", "channel", "os", "arch", "node", "fail_code"]);
30
+ /** 绝不允许出现在 payload 里的字段名/取值(隐私边界)。 */
31
+ const FORBIDDEN = ["bot_id", "bot_token", "token", "phone", "password", "openid", "wxid", "bound_at", "last_error", "13800000000", "wxbot-secret-9f8e"];
32
+
33
+ const TMP_ROOTS = [];
34
+
35
+ const FAKE_LAUNCHCTL = `#!/bin/sh
36
+ printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
37
+ case "$1" in
38
+ print) printf 'state = %s\\n' "$DSH_TEST_STATE"; printf 'pid = %s\\n' "$DSH_TEST_PID"; ;;
39
+ list) printf '%s\\n' "$DSH_TEST_LIST"; ;;
40
+ esac
41
+ exit 0
42
+ `;
43
+ const FAKE_PGREP = `#!/bin/sh
44
+ if [ -n "$DSH_TEST_PGREP" ]; then printf '%s\\n' "$DSH_TEST_PGREP"; exit 0; fi
45
+ exit 1
46
+ `;
47
+ const FAKE_PS = `#!/bin/sh
48
+ printf '1\\n'
49
+ exit 0
50
+ `;
51
+ const FAKE_NPX = `#!/bin/sh
52
+ printf '%s\\n' "$*" >> "$DSH_TEST_NPX_LOG"
53
+ exit ${"${DSH_TEST_NPX_EXIT:-0}"}
54
+ `;
55
+
56
+ /**
57
+ * 隔离环境(沿用 telemetry.test.mjs 的 setup):假 HOME + 假 launchctl/pgrep/ps/npx + 临时 relayDir。
58
+ * 与那边唯一的差别:这里**必须走真实遥测分支**(删掉 DSH_RELAY_SKIP_SERVICE,否则 telemetryEnabled() 恒 false),
59
+ * 所以假二进制一个都不能少 —— 真实分支会去碰 launchctl/pgrep/npx。
60
+ * api_url 指向本地死端口 127.0.0.1:9:发送必然失败并留在本地队列(用例断言的就是队列),
61
+ * 且 scripts/test-net-guard.cjs 只放行 loopback,绝不会打到生产。
62
+ */
63
+ async function setup() {
64
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-wx-tel-"));
65
+ TMP_ROOTS.push(root);
66
+ const fakeHome = path.join(root, "home");
67
+ const fakeBin = path.join(root, "bin");
68
+ const relayDir = path.join(root, "relay");
69
+ await mkdir(fakeBin, { recursive: true });
70
+ await mkdir(fakeHome, { recursive: true });
71
+ await mkdir(relayDir, { recursive: true });
72
+ const launchLog = path.join(root, "launchctl.log");
73
+ const npxLog = path.join(root, "npx.log");
74
+ await writeFile(launchLog, "");
75
+ await writeFile(npxLog, "");
76
+ for (const [name, script] of [["launchctl", FAKE_LAUNCHCTL], ["pgrep", FAKE_PGREP], ["ps", FAKE_PS], ["npx", FAKE_NPX]]) {
77
+ await writeFile(path.join(fakeBin, name), script);
78
+ await chmodSync(path.join(fakeBin, name), 0o755);
79
+ }
80
+ // 运行环境已就绪(dsh-setup.mjs 在)→ ensureRuntime 是零副作用 no-op,不会触发任何真实安装
81
+ await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub");
82
+ // ⚠️ 只放 bridge_secret + 本地 api_url,**不放账号凭据**:本用例只走微信代理与匿名遥测,
83
+ // 不需要登录。放了 phone/password 会顺带触发装机上报链(失败后 350ms 重试一次),
84
+ // 而用例在几十毫秒内就拆掉了临时目录 → 重试时会读到空配置并回落到 DEFAULT_API 去打生产公开配置
85
+ // (被 scripts/test-net-guard.cjs 拦下,但那是一条不该出现的真实请求)。
86
+ await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
87
+ api_url: "http://127.0.0.1:9", bridge_secret: SECRET,
88
+ }));
89
+
90
+ const KEYS = ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_NPX_LOG", "DSH_TEST_STATE", "DSH_TEST_PID",
91
+ "DSH_TEST_LIST", "DSH_TEST_PGREP", "DSH_TEST_NPX_EXIT", "DSH_RELAY_SKIP_SERVICE", "DSH_SETUP_NPX_DIR",
92
+ "DSH_RELAY_SELFHEAL_MS", "DSH_REMOTE_TELEMETRY", "DSH_REMOTE_TELEMETRY_MS"];
93
+ const saved = {};
94
+ for (const k of KEYS) saved[k] = process.env[k];
95
+ process.env.HOME = fakeHome;
96
+ process.env.PATH = `${fakeBin}:${saved.PATH}`;
97
+ process.env.DSH_TEST_LAUNCH_LOG = launchLog;
98
+ process.env.DSH_TEST_NPX_LOG = npxLog;
99
+ process.env.DSH_TEST_STATE = "stopped";
100
+ process.env.DSH_TEST_PID = "0";
101
+ process.env.DSH_TEST_LIST = "";
102
+ process.env.DSH_TEST_NPX_EXIT = "0";
103
+ delete process.env.DSH_TEST_PGREP;
104
+ process.env.DSH_SETUP_NPX_DIR = fakeBin;
105
+ process.env.DSH_REMOTE_TELEMETRY = "1"; // 显式开启(关闭的分支另有专门用例)
106
+ delete process.env.DSH_REMOTE_TELEMETRY_MS; // 不加速心跳:本用例只观察队列,60s 心跳期内不会发送
107
+ process.env.DSH_RELAY_SELFHEAL_MS = "60000";
108
+ delete process.env.DSH_RELAY_SKIP_SERVICE; // ★ 必须删:否则 telemetryEnabled() 恒 false
109
+
110
+ return {
111
+ root, relayDir,
112
+ async restore() {
113
+ for (const [k, v] of Object.entries(saved)) {
114
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
115
+ }
116
+ TELEMETRY.reset(relayDir);
117
+ await rm(root, { recursive: true, force: true });
118
+ },
119
+ };
120
+ }
121
+
122
+ /** 假控制面(与 wechat-proxy.test.mjs 同一套语义):只认密钥头,status 回一个合法状态体。 */
123
+ function startFakeControl() {
124
+ const seen = [];
125
+ const srv = http.createServer((req, res) => {
126
+ const chunks = [];
127
+ req.on("data", (c) => chunks.push(c));
128
+ req.on("end", () => {
129
+ const url = new URL(req.url, "http://x");
130
+ seen.push({ method: req.method, path: url.pathname, header: req.headers[HDR] || "" });
131
+ const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); };
132
+ if ((req.headers[HDR] || "") !== SECRET) return send(401, { ok: false, error: "unauthorized" });
133
+ if (`${req.method} ${url.pathname}` === "GET /wechat/status") {
134
+ return send(200, { ok: true, bound: false, bot_id: "", bound_at: 0, connected_at: 0, last_push_ok_at: 0, last_error: "" });
135
+ }
136
+ return send(404, { ok: false, error: "no such route" });
137
+ });
138
+ });
139
+ return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
140
+ }
141
+
142
+ /** 装载插件路由(与 wechat-proxy.test.mjs / quota-absent.test.mjs 同一套 boot 方式)。 */
143
+ async function bootHost(relayDir) {
144
+ const routes = new Map();
145
+ const disposers = [];
146
+ apply({
147
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
148
+ effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
149
+ logger: { info() {}, warn() {} },
150
+ }, { relayDir });
151
+ const host = http.createServer((req, res) => {
152
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
153
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
154
+ });
155
+ await new Promise((r) => host.listen(0, "127.0.0.1", r));
156
+ return { host, base: `http://127.0.0.1:${host.address().port}`, dispose: () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } } };
157
+ }
158
+
159
+ /** 起一个完整环境:假控制面 + 配置 + 发现文件 + 面板宿主。 */
160
+ async function boot(opts = {}) {
161
+ const control = await startFakeControl();
162
+ const env = await setup();
163
+ // 关闭遥测的用例必须在**装载之前**关掉:apply() 本身会记 plugin_loaded,
164
+ // 装载后再关会留下一条"开启期间"的队列文件,断言"零文件"就变成假阳性/假阴性。
165
+ if (opts.telemetry === false) process.env.DSH_REMOTE_TELEMETRY = "0";
166
+ await writeFile(path.join(env.relayDir, ".wechat-control.json"),
167
+ JSON.stringify({ port: control.port, pid: process.pid, started_at: Date.now(), header: HDR }));
168
+ const host = await bootHost(env.relayDir);
169
+ return {
170
+ ...env, control, base: host.base, host: host.host,
171
+ async close() {
172
+ host.host.close();
173
+ host.dispose();
174
+ control.srv.close();
175
+ await env.restore();
176
+ },
177
+ };
178
+ }
179
+
180
+ /** 写 <relayDir>/.wechat-state.json(bridge 的财产;本用例只**写**它,插件只读它)。 */
181
+ async function writeState(relayDir, state) {
182
+ await writeFile(path.join(relayDir, ".wechat-state.json"),
183
+ typeof state === "string" ? state : JSON.stringify(state));
184
+ }
185
+
186
+ const UNBOUND = { bound: false, bot_id: "", bound_at: 0, connected_at: 0, last_push_ok_at: 0, last_error: "" };
187
+ const BOUND = {
188
+ bound: true, bot_id: "wxbot-secret-9f8e", bound_at: 1758000000000, connected_at: 1758000001000,
189
+ last_push_ok_at: 1758000002000, last_error: "",
190
+ };
191
+
192
+ async function status(base) {
193
+ const res = await fetch(`${base}/dsh-remote/wechat/status`);
194
+ let body = null;
195
+ try { body = JSON.parse(await res.text()); } catch { body = null; }
196
+ return { status: res.status, body };
197
+ }
198
+
199
+ /** 本机已产生的绑定类事件(只看队列:api_url 指向死端口,事件不会出队)。 */
200
+ function wechatEvents(relayDir) {
201
+ return TELEMETRY.queueOf(relayDir).filter((e) => e.name === "wechat_bound" || e.name === "wechat_unbound");
202
+ }
203
+ const namesOf = (relayDir) => wechatEvents(relayDir).map((e) => e.name).join(",");
204
+ const telemetryFilesIn = (dir) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.startsWith(".telemetry")) : []);
205
+
206
+ test("跳变:unbound→bound 恰好一条 wechat_bound;稳态 bound 反复读不再发", async () => {
207
+ const env = await boot();
208
+ try {
209
+ // ① 首次观测到未绑定:只播种基线,没有任何事件
210
+ await writeState(env.relayDir, UNBOUND);
211
+ let r = await status(env.base);
212
+ assert.equal(r.status, 200);
213
+ assert.equal(namesOf(env.relayDir), "", "首次观测(未绑定)不得发任何事件");
214
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), false, "首次观测必须播种基线 false");
215
+
216
+ // ② 用户扫码绑定完成 → 跳变,恰好一条 wechat_bound
217
+ await writeState(env.relayDir, BOUND);
218
+ await status(env.base);
219
+ assert.equal(namesOf(env.relayDir), "wechat_bound", "unbound→bound 必须发且只发一条 wechat_bound");
220
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), true, "基线必须前进到 true");
221
+
222
+ // ③ 稳态:面板会反复轮询 status,一条都不能再多(按次计数会让一次绑定刷成几十条)
223
+ await status(env.base);
224
+ await status(env.base);
225
+ await status(env.base);
226
+ assert.equal(wechatEvents(env.relayDir).length, 1, "稳态 bound 不得重复计数");
227
+ assert.equal(namesOf(env.relayDir), "wechat_bound");
228
+
229
+ // ④ 未观测(文件被删)不得被当成 unbound → 不得出现假跳变
230
+ await rm(path.join(env.relayDir, ".wechat-state.json"), { force: true });
231
+ await status(env.base);
232
+ assert.equal(namesOf(env.relayDir), "wechat_bound", "文件缺失 = 没有观测,不得伪造跳变");
233
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), true, "没有观测时基线必须原地不动");
234
+ } finally { await env.close(); }
235
+ });
236
+
237
+ test("跳变:bound→unbound 恰好一条 wechat_unbound;之后反复读不再发", async () => {
238
+ const env = await boot();
239
+ try {
240
+ // 启动时已经绑好(首次观测 = 播种,不发事件;这条规则另有专门用例)
241
+ await writeState(env.relayDir, BOUND);
242
+ await status(env.base);
243
+ assert.equal(namesOf(env.relayDir), "", "首次观测(已绑定)不得发 wechat_bound");
244
+
245
+ await writeState(env.relayDir, { ...UNBOUND, last_error: "用户主动解绑" });
246
+ await status(env.base);
247
+ assert.equal(namesOf(env.relayDir), "wechat_unbound", "bound→unbound 必须发且只发一条 wechat_unbound");
248
+
249
+ await status(env.base);
250
+ await status(env.base);
251
+ assert.equal(wechatEvents(env.relayDir).length, 1, "稳态 unbound 不得重复计数");
252
+
253
+ // 再次绑定 → 又一次 wechat_bound(同一条链路可反复绑定/解绑,每次都如实记一条)
254
+ await writeState(env.relayDir, BOUND);
255
+ await status(env.base);
256
+ assert.equal(namesOf(env.relayDir), "wechat_unbound,wechat_bound");
257
+ } finally { await env.close(); }
258
+ });
259
+
260
+ test("★ 首次观测不是跳变:进程启动时状态文件已经是 bound:true → 一条 wechat_bound 都不发", async () => {
261
+ const env = await boot();
262
+ try {
263
+ // bridge 在几天前就绑好了,宿主/面板现在才第一次读到它
264
+ await writeState(env.relayDir, BOUND);
265
+ await status(env.base);
266
+ await status(env.base);
267
+ await status(env.base);
268
+ assert.equal(namesOf(env.relayDir), "", "启动时已绑定必须只播种基线,绝不能计入新增绑定(否则存量被每天虚报一遍)");
269
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), true);
270
+
271
+ // 反向同理:启动时未绑定 → 也只是一次播种
272
+ await rm(path.join(env.relayDir, ".telemetry-once.json"), { force: true });
273
+ TELEMETRY.reset(env.relayDir);
274
+ await writeState(env.relayDir, UNBOUND);
275
+ await status(env.base);
276
+ assert.equal(namesOf(env.relayDir), "", "首次观测未绑定同样只是播种");
277
+ } finally { await env.close(); }
278
+ });
279
+
280
+ test("开关:DSH_REMOTE_TELEMETRY=0 → 零事件、零遥测文件;观测本身返回 null", async () => {
281
+ const env = await boot({ telemetry: false });
282
+ try {
283
+ await writeState(env.relayDir, UNBOUND);
284
+ let r = await status(env.base);
285
+ assert.equal(r.status, 200, "关闭遥测不得影响面板路由");
286
+ await writeState(env.relayDir, BOUND);
287
+ r = await status(env.base);
288
+ assert.equal(r.status, 200);
289
+ await status(env.base);
290
+
291
+ assert.equal(wechatEvents(env.relayDir).length, 0, "关闭后不得产生任何绑定/解绑事件");
292
+ assert.equal(TELEMETRY.observeWeChat(env.relayDir), null, "关闭后观测必须是 no-op(返回 null)");
293
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), null, "关闭后连基线都不许落盘");
294
+ assert.deepEqual(telemetryFilesIn(env.relayDir), [], "关闭后不得生成任何 .telemetry-* 文件");
295
+ } finally { await env.close(); }
296
+ });
297
+
298
+ test("读不出 = 没有观测:状态文件缺失 / 损坏 / 非对象 / bound 不是布尔,都不抛、都不算 unbound", async () => {
299
+ const env = await boot();
300
+ try {
301
+ // ① 从来没有过状态文件(bridge 还没装微信通道)
302
+ let r = await status(env.base);
303
+ assert.equal(r.status, 200, "状态文件缺失不得影响面板路由");
304
+ assert.equal(namesOf(env.relayDir), "");
305
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), null, "没观测过 → 基线必须仍是 null(不是 false)");
306
+
307
+ // ② 损坏(半写 / 手改坏)
308
+ await writeState(env.relayDir, "<<<broken>>>");
309
+ r = await status(env.base);
310
+ assert.equal(r.status, 200, "状态文件损坏不得抛进面板路由");
311
+ assert.equal(namesOf(env.relayDir), "", "损坏 = 没有观测,不得当作 unbound");
312
+ assert.equal(TELEMETRY.wechatBoundBaseline(env.relayDir), null);
313
+
314
+ // ③ 形状不对(null / 数组 / bound 是字符串)→ 同样视为没有观测
315
+ for (const bad of ["null", "[]", JSON.stringify({ bound: "true" }), JSON.stringify({})]) {
316
+ await writeState(env.relayDir, bad);
317
+ r = await status(env.base);
318
+ assert.equal(r.status, 200, `形状不合法(${bad})不得抛`);
319
+ assert.equal(namesOf(env.relayDir), "", `形状不合法(${bad})不得产生事件`);
320
+ }
321
+
322
+ // ④ 损坏之后第一次真正读到的值仍然是「首次观测」→ 只播种,不发
323
+ await writeState(env.relayDir, BOUND);
324
+ await status(env.base);
325
+ assert.equal(namesOf(env.relayDir), "", "损坏期不得留下任何基线痕迹,恢复后第一次读到只能是播种");
326
+ // ⑤ 之后真正的跳变照常记一条(且只记 unbound 这一条,不补发 bound)
327
+ await writeState(env.relayDir, UNBOUND);
328
+ await status(env.base);
329
+ assert.equal(namesOf(env.relayDir), "wechat_unbound");
330
+ } finally { await env.close(); }
331
+ });
332
+
333
+ test("隐私:序列化后的 payload 不含 bot_id / token / 绑定标识 / 手机号,且字段只有白名单那 9 个", async () => {
334
+ const env = await boot();
335
+ try {
336
+ await writeState(env.relayDir, UNBOUND);
337
+ await status(env.base);
338
+ await writeState(env.relayDir, BOUND);
339
+ await status(env.base);
340
+ const events = wechatEvents(env.relayDir);
341
+ assert.equal(events.length, 1);
342
+ const ev = events[0];
343
+ // 字段白名单:一条事件只允许这 9 个键(其余一律进不来)
344
+ for (const k of Object.keys(ev)) assert.ok(ALLOWED_EVENT_KEYS.has(k), "payload 出现契约外字段:" + k);
345
+ assert.equal(ev.name, "wechat_bound");
346
+ assert.equal(typeof ev.harness_version, "string");
347
+ assert.equal(typeof ev.channel, "string");
348
+ // 序列化后的整条事件里不得出现任何标识性字段名或取值
349
+ const raw = JSON.stringify(ev) + JSON.stringify(TELEMETRY.queueOf(env.relayDir));
350
+ for (const bad of FORBIDDEN) {
351
+ assert.ok(!raw.toLowerCase().includes(bad.toLowerCase()), "遥测 payload 不得包含:" + bad);
352
+ }
353
+ // 反向确认:状态文件里确实有这些标识(否则上面的断言是空转)
354
+ const stateRaw = await (await import("node:fs/promises")).readFile(path.join(env.relayDir, ".wechat-state.json"), "utf8");
355
+ assert.ok(stateRaw.includes("wxbot-secret-9f8e"), "夹具必须真的把 bot_id 写进状态文件,否则隐私断言是空转");
356
+ } finally { await env.close(); }
357
+ });