@mrrisega/dsh-remote 0.7.3 → 0.7.4
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/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/clients/dsh-remote/test/bridge-file-transfer.test.mjs +281 -0
- package/clients/dsh-remote/test/e2ee-shim-retry.test.mjs +249 -0
- package/clients/dsh-remote/test/lib-e2ee-shim.mjs +1 -0
- package/clients/dsh-remote/test/mobile-adapter-resume.test.mjs +460 -0
- package/clients/dsh-remote/test/mobile-adapter-upload-hint.test.mjs +157 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/packages/dsh-remote-web/test/mobile-adapter-resume.test.mjs +125 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bridge-file-transfer.test.mjs — 文件上传/下载/预览这条线的 bridge 侧契约(2026-10-23):
|
|
4
|
+
*
|
|
5
|
+
* A. 上传健壮性(B):
|
|
6
|
+
* - `requestBodyBytes`:http 帧 base64 / 旧协议文本的字节数(日志与 502 回包用);
|
|
7
|
+
* - `classifyUpstreamError`:超时 / 连不上 / 其它三类 code(生产 502 里 24 字节的
|
|
8
|
+
* `{"error":"fetch failed"}` 退化成用户看不懂的"上传失败"的根因之一);
|
|
9
|
+
* - 502 回包改成可读 `{error:{code,message,...}}`(保留 error 键与顶层 code/message);
|
|
10
|
+
* - 上传成功日志必须含**上行字节 + 耗时**(客服排查"上传失败"的三要素之一);
|
|
11
|
+
* - 隧道 `maxPayload` 与 relay-router 的 WS_MAX_PAYLOAD(256MB) 对齐,且真的传给
|
|
12
|
+
* `new WebSocket`(否则 100MiB 默认值会被大上传帧 1009 打断 → 连续 502 device_offline)。
|
|
13
|
+
*
|
|
14
|
+
* B. 下载(C):
|
|
15
|
+
* - `/api/file` 的**导航**响应补 `Content-Disposition: attachment; filename*=UTF-8''…`;
|
|
16
|
+
* - 图片/子资源(sec-fetch-dest=image)、非导航、上游已有该头、非 /api/file 一律不动;
|
|
17
|
+
* - 走真实 handleHttpFrame → doHttp → 本地上游全链路,body 不被改动。
|
|
18
|
+
*
|
|
19
|
+
* 用法: node --test test/bridge-file-transfer.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { createServer } from "node:http";
|
|
23
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import test, { after } from "node:test";
|
|
27
|
+
|
|
28
|
+
const BRIDGE_SRC = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "dsh-bridge.mjs");
|
|
29
|
+
|
|
30
|
+
/** 假上游:dsh web 的最小替身。 */
|
|
31
|
+
const PDF_BYTES = Buffer.from("%PDF-1.4\n% test pdf body " + "x".repeat(2048) + "\n%%EOF\n", "utf8");
|
|
32
|
+
const upstream = createServer((req, res) => {
|
|
33
|
+
const u = new URL(req.url, "http://127.0.0.1");
|
|
34
|
+
if (u.pathname === "/api/file") {
|
|
35
|
+
if (u.searchParams.get("miss") === "1") {
|
|
36
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
37
|
+
res.end("not found");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (u.searchParams.get("withcd") === "1") {
|
|
41
|
+
res.writeHead(200, { "content-type": "application/pdf", "content-disposition": 'inline; filename="upstream.pdf"' });
|
|
42
|
+
res.end(PDF_BYTES);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(200, { "content-type": "application/pdf", "cache-control": "private, no-store" });
|
|
46
|
+
res.end(PDF_BYTES);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (u.pathname === "/api/session/uploadFileBinary") {
|
|
50
|
+
let n = 0;
|
|
51
|
+
req.on("data", (c) => { n += c.length; });
|
|
52
|
+
req.on("end", () => {
|
|
53
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
54
|
+
res.end(JSON.stringify({ ok: true, value: { receiptId: "r1", file: { attachmentId: "sha256:x", name: u.searchParams.get("name") || "", bytes: n } } }));
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (u.pathname === "/api/boom") {
|
|
59
|
+
// 模拟上游连接被重置(用户关掉 dsh web / Electron 退出)
|
|
60
|
+
try { req.socket.destroy(); } catch { /* ignore */ }
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
64
|
+
res.end("ok");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-bridge-filetransfer-"));
|
|
68
|
+
process.env.DSH_BRIDGE_DEVICE_ID = "dev-filetrf00001";
|
|
69
|
+
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
70
|
+
|
|
71
|
+
const bridge = await new Promise((resolve, reject) => {
|
|
72
|
+
upstream.listen(0, "127.0.0.1", () => {
|
|
73
|
+
process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
|
|
74
|
+
import("../dsh-bridge.mjs").then(resolve, reject);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
const { handleHttpFrame, requestBodyBytes, classifyUpstreamError, downloadDispositionOf, downloadFilenameOf, TUNNEL_WS_OPTIONS, TUNNEL_WS_MAX_PAYLOAD } = bridge;
|
|
78
|
+
|
|
79
|
+
after(() => {
|
|
80
|
+
try { upstream.close(); } catch { /* ignore */ }
|
|
81
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** 发一帧并取回 reply(与 bridge-gzip 用例同构)。 */
|
|
85
|
+
async function request(frame) {
|
|
86
|
+
let reply;
|
|
87
|
+
const sender = (obj) => { reply = obj; return true; };
|
|
88
|
+
await handleHttpFrame(sender, { type: "http", ...frame });
|
|
89
|
+
return reply;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================
|
|
93
|
+
// A. 上传健壮性(B)
|
|
94
|
+
// ============================================================
|
|
95
|
+
|
|
96
|
+
test("requestBodyBytes:base64 / 文本 / 空体", () => {
|
|
97
|
+
assert.equal(requestBodyBytes(undefined, true), 0);
|
|
98
|
+
assert.equal(requestBodyBytes("", true), 0);
|
|
99
|
+
// 1MB 字节 → base64 后按 3/4 反推回 1MB
|
|
100
|
+
const oneMb = Buffer.alloc(1024 * 1024, 7);
|
|
101
|
+
assert.equal(requestBodyBytes(oneMb.toString("base64"), true), oneMb.length);
|
|
102
|
+
// 带 padding 的短样本
|
|
103
|
+
assert.equal(requestBodyBytes(Buffer.from("abc").toString("base64"), true), 3); // YWJj
|
|
104
|
+
assert.equal(requestBodyBytes(Buffer.from("ab").toString("base64"), true), 2); // YWI=
|
|
105
|
+
assert.equal(requestBodyBytes(Buffer.from("a").toString("base64"), true), 1); // YQ==
|
|
106
|
+
// 旧协议:原始文本按 UTF-8
|
|
107
|
+
assert.equal(requestBodyBytes("你好", false), 6);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("classifyUpstreamError:超时 / 连不上 / 其它 → 稳定 code + 可读 message", () => {
|
|
111
|
+
const timeout = Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" });
|
|
112
|
+
const t = classifyUpstreamError(timeout);
|
|
113
|
+
assert.equal(t.code, "upstream_timeout");
|
|
114
|
+
assert.match(t.message, /没有响应|稍后重试/);
|
|
115
|
+
|
|
116
|
+
const refused = Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } });
|
|
117
|
+
const r = classifyUpstreamError(refused);
|
|
118
|
+
assert.equal(r.code, "upstream_unreachable");
|
|
119
|
+
assert.match(r.message, /连不上电脑端|重新发现/);
|
|
120
|
+
assert.equal(r.detail, "ECONNREFUSED");
|
|
121
|
+
|
|
122
|
+
const socketErr = Object.assign(new TypeError("terminated"), { cause: { code: "UND_ERR_SOCKET" } });
|
|
123
|
+
assert.equal(classifyUpstreamError(socketErr).code, "upstream_unreachable");
|
|
124
|
+
|
|
125
|
+
const other = new Error("boom");
|
|
126
|
+
const o = classifyUpstreamError(other);
|
|
127
|
+
assert.equal(o.code, "upstream_error");
|
|
128
|
+
assert.match(o.message, /boom/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("★ 上游 502 回包:可读 {error:{code,message}},不再是 {\"error\":\"fetch failed\"}", async () => {
|
|
132
|
+
const reply = await request({ id: "boom1", method: "GET", path: "/api/boom", headers: {} });
|
|
133
|
+
assert.equal(reply.status, 502);
|
|
134
|
+
assert.match(String(reply.headers["content-type"]), /application\/json/);
|
|
135
|
+
const body = JSON.parse(Buffer.from(reply.body, "base64").toString("utf8"));
|
|
136
|
+
assert.equal(typeof body.error, "object", "error 必须是对象(可读 code/message),不能是裸字符串");
|
|
137
|
+
assert.equal(typeof body.error.code, "string");
|
|
138
|
+
assert.ok(body.error.code.length > 0);
|
|
139
|
+
assert.equal(typeof body.error.message, "string");
|
|
140
|
+
assert.ok(body.error.message.length > 8, "message 必须是一句人话,不是 'fetch failed'");
|
|
141
|
+
// 兼容字段:顶层 code/message 与 error.code/message 一致
|
|
142
|
+
assert.equal(body.code, body.error.code);
|
|
143
|
+
assert.equal(body.message, body.error.message);
|
|
144
|
+
// 诊断字段:method/path/bytes/durationMs(客服据此定位是哪次上传、传了多大)
|
|
145
|
+
assert.equal(body.error.method, "GET");
|
|
146
|
+
assert.equal(body.error.path, "/api/boom");
|
|
147
|
+
assert.equal(typeof body.error.bytes, "number");
|
|
148
|
+
assert.equal(typeof body.error.durationMs, "number");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("★ 上传成功日志含上行字节与耗时(长请求可观测性)", async () => {
|
|
152
|
+
const bytes = Buffer.alloc(1024 * 1024, 3); // 1MB
|
|
153
|
+
const logs = [];
|
|
154
|
+
const orig = console.log;
|
|
155
|
+
console.log = (...args) => { logs.push(args.map(String).join(" ")); };
|
|
156
|
+
let reply;
|
|
157
|
+
try {
|
|
158
|
+
reply = await request({
|
|
159
|
+
id: "up1",
|
|
160
|
+
method: "POST",
|
|
161
|
+
path: "/api/session/uploadFileBinary?sessionId=session-test&name=big.bin",
|
|
162
|
+
headers: { "content-type": "application/octet-stream" },
|
|
163
|
+
body: bytes.toString("base64"),
|
|
164
|
+
bodyBase64: true
|
|
165
|
+
});
|
|
166
|
+
} finally {
|
|
167
|
+
console.log = orig;
|
|
168
|
+
}
|
|
169
|
+
assert.equal(reply.status, 200);
|
|
170
|
+
const line = logs.find((l) => l.includes("/api/session/uploadFileBinary"));
|
|
171
|
+
assert.ok(line, "上传必须留下一条日志:" + JSON.stringify(logs));
|
|
172
|
+
assert.match(line, /→ 200/);
|
|
173
|
+
assert.match(line, /上行 1\.00MB/, "日志必须含上行字节(排查 413/超时/断流都靠它):" + line);
|
|
174
|
+
assert.match(line, /\(\d+ms/, "日志必须含耗时:" + line);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("★ 隧道 ws maxPayload 与 router 对齐(256MB),且真的传给 new WebSocket", () => {
|
|
178
|
+
assert.equal(TUNNEL_WS_MAX_PAYLOAD, 256 * 1024 * 1024);
|
|
179
|
+
assert.equal(TUNNEL_WS_OPTIONS.maxPayload, 256 * 1024 * 1024);
|
|
180
|
+
assert.equal(TUNNEL_WS_OPTIONS.followRedirects, false);
|
|
181
|
+
// ws 默认 100MiB 挡不住 64MiB 请求体的 base64 帧(≈89.5MB),余量仅 ~15%;
|
|
182
|
+
// 代码级断言防止以后"顺手删掉"这个选项。
|
|
183
|
+
const src = readFileSync(BRIDGE_SRC, "utf8");
|
|
184
|
+
assert.match(src, /new WebSocket\(endpoint, TUNNEL_WS_OPTIONS\)/, "隧道连接必须显式传 TUNNEL_WS_OPTIONS");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ============================================================
|
|
188
|
+
// B. 下载(C)
|
|
189
|
+
// ============================================================
|
|
190
|
+
|
|
191
|
+
test("downloadFilenameOf:取最后一段;非法/空回落 download", () => {
|
|
192
|
+
assert.equal(downloadFilenameOf("/Users/mac/TalkWithMe/incidence_test.pdf"), "incidence_test.pdf");
|
|
193
|
+
assert.equal(downloadFilenameOf("/Users/mac/TalkWithMe/预览_技术SQL表_上.png"), "预览_技术SQL表_上.png");
|
|
194
|
+
assert.equal(downloadFilenameOf("C:\\Users\\a\\b.zip"), "b.zip");
|
|
195
|
+
assert.equal(downloadFilenameOf("/a/b/"), "b");
|
|
196
|
+
assert.equal(downloadFilenameOf(""), "download");
|
|
197
|
+
assert.equal(downloadFilenameOf("/"), "download");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("downloadDispositionOf:导航 → attachment(RFC 5987);图片/子资源/非导航/上游已有 → 不动", () => {
|
|
201
|
+
const nav = { "sec-fetch-dest": "document", accept: "text/html,application/xhtml+xml" };
|
|
202
|
+
const cd = downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2F%E6%8A%A5%E5%91%8A.pdf", reqHeaders: nav, resHeaders: {} });
|
|
203
|
+
assert.ok(cd && cd.startsWith("attachment;"), "导航必须给 attachment:" + cd);
|
|
204
|
+
assert.match(cd, /filename\*=UTF-8''%E6%8A%A5%E5%91%8A\.pdf/, "中文名必须 percent-encode 进 filename*:" + cd);
|
|
205
|
+
assert.match(cd, /filename="download\.pdf"/, "非 ASCII 名的 ASCII 回退用 download.<ext>(别丢扩展名):" + cd);
|
|
206
|
+
|
|
207
|
+
// 子资源:pic 的 <img src> 加载,不能变成下载
|
|
208
|
+
for (const dest of ["image", "empty", "iframe", "audio", "video"]) {
|
|
209
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.png", reqHeaders: { "sec-fetch-dest": dest }, resHeaders: {} }), null, dest);
|
|
210
|
+
}
|
|
211
|
+
// 老浏览器(无 sec-fetch-dest):靠 Accept 判导航
|
|
212
|
+
assert.ok(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf", reqHeaders: { accept: "text/html,application/xhtml+xml,*/*" }, resHeaders: {} }));
|
|
213
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.png", reqHeaders: { accept: "image/avif,image/webp,*/*" }, resHeaders: {} }), null);
|
|
214
|
+
// 上游已经给了(attachment 或 inline)→ 一律不覆盖
|
|
215
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf", reqHeaders: nav, resHeaders: { "content-disposition": 'inline; filename="x.pdf"' } }), null);
|
|
216
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf", reqHeaders: nav, resHeaders: { "Content-Disposition": 'attachment; filename="x.pdf"' } }), null);
|
|
217
|
+
// 非 /api/file、非 GET → 不动
|
|
218
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/workspaceFiles/readBytes", reqHeaders: nav, resHeaders: {} }), null);
|
|
219
|
+
assert.equal(downloadDispositionOf({ method: "POST", path: "/api/file?path=%2Ftmp%2Fa.pdf", reqHeaders: nav, resHeaders: {} }), null);
|
|
220
|
+
// 显式 ?download=1 强制(即使 Accept 是图片),?download=0 强制不加
|
|
221
|
+
assert.ok(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.png&download=1", reqHeaders: { "sec-fetch-dest": "image" }, resHeaders: {} }));
|
|
222
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf&download=0", reqHeaders: nav, resHeaders: {} }), null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("集成:/api/file 导航请求 → 补 Content-Disposition,body 与上游逐字节一致", async () => {
|
|
226
|
+
const reply = await request({
|
|
227
|
+
id: "dl1",
|
|
228
|
+
method: "GET",
|
|
229
|
+
path: "/api/file?path=%2FUsers%2Fmac%2FTalkWithMe%2Fincidence_test.pdf",
|
|
230
|
+
headers: { "sec-fetch-dest": "document", accept: "text/html,application/xhtml+xml", "user-agent": "iPhone" }
|
|
231
|
+
});
|
|
232
|
+
assert.equal(reply.status, 200);
|
|
233
|
+
const cd = reply.headers["content-disposition"];
|
|
234
|
+
assert.ok(cd, "导航下载必须带 content-disposition:" + JSON.stringify(reply.headers));
|
|
235
|
+
assert.match(cd, /^attachment;/);
|
|
236
|
+
assert.match(cd, /filename\*=UTF-8''incidence_test\.pdf/);
|
|
237
|
+
assert.deepEqual(Buffer.from(reply.body, "base64"), PDF_BYTES, "attachment 不能改动响应体");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("集成:图片子资源请求 → 不加 Content-Disposition(聊天里 <img src=/api/file> 不受影响)", async () => {
|
|
241
|
+
const reply = await request({
|
|
242
|
+
id: "dl2",
|
|
243
|
+
method: "GET",
|
|
244
|
+
path: "/api/file?path=%2FUsers%2Fmac%2FTalkWithMe%2Fa.png",
|
|
245
|
+
headers: { "sec-fetch-dest": "image", accept: "image/avif,image/webp,image/png" }
|
|
246
|
+
});
|
|
247
|
+
assert.equal(reply.status, 200);
|
|
248
|
+
assert.equal(reply.headers["content-disposition"], undefined);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("集成:上游已给 Content-Disposition → 保留上游值(不覆盖)", async () => {
|
|
252
|
+
const reply = await request({
|
|
253
|
+
id: "dl3",
|
|
254
|
+
method: "GET",
|
|
255
|
+
path: "/api/file?path=%2Ftmp%2Fa.pdf&withcd=1",
|
|
256
|
+
headers: { "sec-fetch-dest": "document", accept: "text/html" }
|
|
257
|
+
});
|
|
258
|
+
assert.equal(reply.status, 200);
|
|
259
|
+
assert.equal(reply.headers["content-disposition"], 'inline; filename="upstream.pdf"');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("集成:上游 4xx/5xx 不补 attachment(否则浏览器把报错正文下载成文件)", async () => {
|
|
263
|
+
const reply = await request({
|
|
264
|
+
id: "dl4",
|
|
265
|
+
method: "GET",
|
|
266
|
+
path: "/api/file?path=%2Ftmp%2Fa.pdf&miss=1",
|
|
267
|
+
headers: { "sec-fetch-dest": "document", accept: "text/html" }
|
|
268
|
+
});
|
|
269
|
+
assert.equal(reply.status, 404);
|
|
270
|
+
assert.equal(reply.headers["content-disposition"], undefined);
|
|
271
|
+
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), "not found");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("downloadDispositionOf:非 2xx 一律不附加", () => {
|
|
275
|
+
const nav = { "sec-fetch-dest": "document", accept: "text/html" };
|
|
276
|
+
for (const status of [204, 301, 401, 404, 413, 500, 502]) {
|
|
277
|
+
assert.equal(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf", status, reqHeaders: nav, resHeaders: {} }), null, String(status));
|
|
278
|
+
}
|
|
279
|
+
assert.ok(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf", status: 200, reqHeaders: nav, resHeaders: {} }));
|
|
280
|
+
assert.ok(downloadDispositionOf({ method: "GET", path: "/api/file?path=%2Ftmp%2Fa.pdf", status: 206, reqHeaders: nav, resHeaders: {} }));
|
|
281
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* e2ee-shim-retry.test.mjs — 镜像页 shim 的「网络层失败一次极短重试」(D,2026-10-23):
|
|
4
|
+
*
|
|
5
|
+
* 背景:文档预览/会话历史的数据面(`/api/workspaceFiles/*`、`/api/session/page`、
|
|
6
|
+
* `/api/officeToPdf/*`)在弱网或中继限速下经常"读到一半断"——浏览器只看到一次
|
|
7
|
+
* fetch reject 或空响应,上层直接落到"读取失败"(用户得手点「重新读取文件」)。
|
|
8
|
+
*
|
|
9
|
+
* 契约(全部用抽取出来的真实 CORE 代码 + mock 传输,不开真浏览器):
|
|
10
|
+
* A. 白名单:只认幂等读路径(目录式前缀对齐 /api/session/page 不误伤 /api/session/pages);
|
|
11
|
+
* B. fetch reject → 白名单读路径重试 1 次(共 2 次请求),第二次成功则用户无感;
|
|
12
|
+
* C. 重试必须换**新 nonce 的信封**(桥端 http nonce 防重放窗口 5 分钟,复用会被判 replay);
|
|
13
|
+
* D. 非白名单路径(fetch reject)→ 不重试,原异常原样抛出(零行为变化);
|
|
14
|
+
* E. 已有响应体的明文 4xx/5xx → 不重试;
|
|
15
|
+
* F. 信封响应体为空/非法(截断)→ 白名单读路径重试;
|
|
16
|
+
* G. 调用方已 abort → 不再重试,抛 AbortError;
|
|
17
|
+
* H. 白名单路径首次成功 → 只发 1 次(不能无脑双发)。
|
|
18
|
+
*
|
|
19
|
+
* 用法: node --test test/e2ee-shim-retry.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
import {
|
|
24
|
+
E2eeSession,
|
|
25
|
+
decodeHttpRequestPlain,
|
|
26
|
+
deriveMasterKey,
|
|
27
|
+
deriveShk,
|
|
28
|
+
encodeHttpResponsePlain,
|
|
29
|
+
newSessId,
|
|
30
|
+
randomB64
|
|
31
|
+
} from "../e2ee-client.mjs";
|
|
32
|
+
import { loadShimCore } from "./lib-e2ee-shim.mjs";
|
|
33
|
+
|
|
34
|
+
const shim = await loadShimCore();
|
|
35
|
+
const ORIGIN = "http://relay.local";
|
|
36
|
+
|
|
37
|
+
function makeSeSession(over = {}) {
|
|
38
|
+
const mk = deriveMasterKey("shim-retry-password", "AAECAwQFBgcICQoLDA0ODw");
|
|
39
|
+
const { shk, saltH } = deriveShk(mk, randomB64(32), randomB64(32));
|
|
40
|
+
return new shim.SeSession({ sessId: newSessId(), shk: new Uint8Array(shk), saltH: new Uint8Array(saltH), ...over });
|
|
41
|
+
}
|
|
42
|
+
/** 桥端视角的 node 对端(同一会话材料)。 */
|
|
43
|
+
function peerOf(seSess) {
|
|
44
|
+
return new E2eeSession({ sessId: seSess.sessId, shk: Buffer.from(seSess.shk), saltH: Buffer.from(seSess.saltH) });
|
|
45
|
+
}
|
|
46
|
+
/** 把响应明文封成信封响应(桥端在回包时做的事)。 */
|
|
47
|
+
function envelopeResponse(peer, envReq, { status = 200, headers = {}, body = Buffer.from("ok") } = {}) {
|
|
48
|
+
const plain = encodeHttpResponsePlain({ status, headers, bodyBuffer: body });
|
|
49
|
+
const envResp = peer.seal({ kind: "http-resp", dir: "b2p", counter: envReq.c ?? 0, data: plain, reqNonceB64: envReq.n });
|
|
50
|
+
return new Response(JSON.stringify(envResp), {
|
|
51
|
+
status: 200,
|
|
52
|
+
headers: { "content-type": "application/vnd.dsh.e2ee-v2", "x-dsh-e2ee": `v=2;s=${peer.sessId};k=http-resp` }
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============ A. 白名单 ============
|
|
57
|
+
|
|
58
|
+
test("A. 白名单:幂等读路径命中;写/上传/其它路径不命中", () => {
|
|
59
|
+
const yes = [
|
|
60
|
+
"/api/workspaceFiles/readBytes",
|
|
61
|
+
"/api/workspaceFiles/stat",
|
|
62
|
+
"/api/workspaceFiles/readBytes?x=1",
|
|
63
|
+
"/api/session/page",
|
|
64
|
+
"/api/session/page?sessionId=s1&offset=0",
|
|
65
|
+
"/api/officeToPdf/render",
|
|
66
|
+
"/api/officeToPdf/whatever"
|
|
67
|
+
];
|
|
68
|
+
for (const p of yes) assert.equal(shim.seRetryableReadPath(p), true, `应命中: ${p}`);
|
|
69
|
+
const no = [
|
|
70
|
+
"/api/session/pages", // 不能吃掉同名兄弟路径(精确匹配)
|
|
71
|
+
"/api/session/page/extra", // 子路径也不算(精确匹配)
|
|
72
|
+
"/api/session/pageX",
|
|
73
|
+
"/api/session/uploadFileBinary",
|
|
74
|
+
"/api/file",
|
|
75
|
+
"/api/session/list",
|
|
76
|
+
"/api/workspace",
|
|
77
|
+
"/plugins/app.js",
|
|
78
|
+
""
|
|
79
|
+
];
|
|
80
|
+
for (const p of no) assert.equal(shim.seRetryableReadPath(p), false, `不应命中: ${p}`);
|
|
81
|
+
assert.equal(shim.SE_RETRY_DELAY_MS, 300);
|
|
82
|
+
assert.ok(Array.isArray(shim.SE_RETRY_PATH_PREFIXES) && shim.SE_RETRY_PATH_PREFIXES.length >= 3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("A2. 白名单口径与加密策略一致:device 形态 /remote/<dev>/api/... 也要命中", () => {
|
|
86
|
+
// 客户端在 device 形态下会把路径改写成 /remote/<deviceId>/api/...
|
|
87
|
+
// (见 seUpstreamPathOf 的注释):重试白名单必须先用同一函数归一,否则这类请求漏重试。
|
|
88
|
+
const yes = [
|
|
89
|
+
"/remote/dev-x/api/workspaceFiles/readBytes",
|
|
90
|
+
"/remote/dev-x/api/workspaceFiles/stat?x=1",
|
|
91
|
+
"/remote/dev-x/api/session/page",
|
|
92
|
+
"/remote/dev-x/api/session/page?sessionId=s1",
|
|
93
|
+
"/remote/dev-x/api/officeToPdf/render"
|
|
94
|
+
];
|
|
95
|
+
for (const p of yes) assert.equal(shim.seRetryableReadPath(p), true, `device 形态应命中: ${p}`);
|
|
96
|
+
const no = [
|
|
97
|
+
"/remote/dev-x/api/session/pages",
|
|
98
|
+
"/remote/dev-x/api/session/page/extra",
|
|
99
|
+
"/remote/dev-x/api/session/uploadFileBinary",
|
|
100
|
+
"/remote/dev-x/api/session/list"
|
|
101
|
+
];
|
|
102
|
+
for (const p of no) assert.equal(shim.seRetryableReadPath(p), false, `device 形态不应命中: ${p}`);
|
|
103
|
+
// 与 seUpstreamPathOf 口径一致:归一后应当就是白名单里的上游路径
|
|
104
|
+
assert.equal(shim.seUpstreamPathOf("/remote/dev-x/api/workspaceFiles/readBytes"), "/api/workspaceFiles/readBytes");
|
|
105
|
+
assert.equal(shim.seUpstreamPathOf("/remote/dev-x/api/session/page?sessionId=s1&device=dev-x"), "/api/session/page?sessionId=s1");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ============ B/C. fetch reject → 重试一次 + 新 nonce ============
|
|
109
|
+
|
|
110
|
+
test("B/C. fetch reject(白名单读路径)→ 重试 1 次即成功;两次请求 nonce 不同(防 replay)", async () => {
|
|
111
|
+
const seS = makeSeSession();
|
|
112
|
+
const peer = peerOf(seS);
|
|
113
|
+
const nonces = [];
|
|
114
|
+
let calls = 0;
|
|
115
|
+
const transport = async (_input, init) => {
|
|
116
|
+
calls += 1;
|
|
117
|
+
const envReq = JSON.parse(init.body);
|
|
118
|
+
nonces.push(envReq.n);
|
|
119
|
+
const opened = peer.open({ kind: "http", dir: "p2b", env: envReq, counter: envReq.c ?? 0 });
|
|
120
|
+
const req = decodeHttpRequestPlain(opened.data);
|
|
121
|
+
assert.equal(req.path, "/api/workspaceFiles/readBytes");
|
|
122
|
+
if (calls === 1) throw new TypeError("Load failed"); // 第一次:网络层失败
|
|
123
|
+
return envelopeResponse(peer, envReq, { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from('{"ok":true,"value":"重试成功"}') });
|
|
124
|
+
};
|
|
125
|
+
const res = await shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/workspaceFiles/readBytes", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "content-type": "application/json" },
|
|
128
|
+
body: "{}"
|
|
129
|
+
});
|
|
130
|
+
assert.equal(calls, 2, "白名单读路径的网络层失败必须重试一次(共 2 次)");
|
|
131
|
+
assert.equal(res.status, 200);
|
|
132
|
+
assert.equal(await res.text(), '{"ok":true,"value":"重试成功"}');
|
|
133
|
+
assert.notEqual(nonces[0], nonces[1], "重试必须重新封新 nonce 的信封,复用会被桥端判 replay");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("B/C. 请求体同样重发(重试的是同一个幂等读请求)", async () => {
|
|
137
|
+
const seS = makeSeSession();
|
|
138
|
+
const peer = peerOf(seS);
|
|
139
|
+
const bodies = [];
|
|
140
|
+
let calls = 0;
|
|
141
|
+
const transport = async (_input, init) => {
|
|
142
|
+
calls += 1;
|
|
143
|
+
const envReq = JSON.parse(init.body);
|
|
144
|
+
const opened = peer.open({ kind: "http", dir: "p2b", env: envReq, counter: envReq.c ?? 0 });
|
|
145
|
+
const req = decodeHttpRequestPlain(opened.data);
|
|
146
|
+
bodies.push(Buffer.from(req.bodyB64, "base64").toString("utf8"));
|
|
147
|
+
if (calls === 1) throw new TypeError("Load failed");
|
|
148
|
+
return envelopeResponse(peer, envReq, { status: 200, headers: { "content-type": "text/plain" }, body: Buffer.from("done") });
|
|
149
|
+
};
|
|
150
|
+
await shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/session/page", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "content-type": "application/json" },
|
|
153
|
+
body: '{"sessionId":"s1","offset":40}'
|
|
154
|
+
});
|
|
155
|
+
assert.deepEqual(bodies, ['{"sessionId":"s1","offset":40}', '{"sessionId":"s1","offset":40}']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ============ D. 非白名单:不重试 ============
|
|
159
|
+
|
|
160
|
+
test("D. 非白名单路径 fetch reject → 不重试,原异常抛出(零行为变化)", async () => {
|
|
161
|
+
const seS = makeSeSession();
|
|
162
|
+
let calls = 0;
|
|
163
|
+
const boom = new TypeError("Load failed");
|
|
164
|
+
const transport = async () => { calls += 1; throw boom; };
|
|
165
|
+
await assert.rejects(
|
|
166
|
+
() => shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/session/uploadFileBinary?sessionId=s", { method: "POST", body: "x" }),
|
|
167
|
+
(e) => e === boom,
|
|
168
|
+
"非白名单必须原样抛原异常(不能换成别的错误)"
|
|
169
|
+
);
|
|
170
|
+
assert.equal(calls, 1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ============ E. 已有响应体的明文错误页:不重试 ============
|
|
174
|
+
|
|
175
|
+
test("E. 明文 4xx/5xx(有响应体)→ 不重试,原样透传", async () => {
|
|
176
|
+
const seS = makeSeSession();
|
|
177
|
+
let calls = 0;
|
|
178
|
+
const transport = async () => {
|
|
179
|
+
calls += 1;
|
|
180
|
+
return new Response('{"error":{"code":"upstream","message":"boom"}}', { status: 502, headers: { "content-type": "application/json" } });
|
|
181
|
+
};
|
|
182
|
+
const res = await shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/workspaceFiles/readBytes", { method: "POST", body: "{}" });
|
|
183
|
+
assert.equal(calls, 1, "已经有响应体(哪怕是 5xx)不重试");
|
|
184
|
+
assert.equal(res.status, 502);
|
|
185
|
+
assert.match(await res.text(), /boom/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ============ F. 信封响应为空/截断 → 重试 ============
|
|
189
|
+
|
|
190
|
+
test("F. 信封响应体为空(截断)→ 白名单读路径重试;第二次正常则成功", async () => {
|
|
191
|
+
const seS = makeSeSession();
|
|
192
|
+
const peer = peerOf(seS);
|
|
193
|
+
let calls = 0;
|
|
194
|
+
const transport = async (_input, init) => {
|
|
195
|
+
calls += 1;
|
|
196
|
+
const envReq = JSON.parse(init.body);
|
|
197
|
+
if (calls === 1) return new Response("", { status: 200, headers: { "content-type": "application/vnd.dsh.e2ee-v2", "x-dsh-e2ee": `v=2;s=${seS.sessId};k=http-resp` } });
|
|
198
|
+
return envelopeResponse(peer, envReq, { status: 200, headers: { "content-type": "text/plain" }, body: Buffer.from("第二次好了") });
|
|
199
|
+
};
|
|
200
|
+
const res = await shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/officeToPdf/render", { method: "POST", body: "{}" });
|
|
201
|
+
assert.equal(calls, 2);
|
|
202
|
+
assert.equal(res.status, 200);
|
|
203
|
+
assert.equal(await res.text(), "第二次好了");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("F2. 非白名单的空信封响应 → 不重试,仍回显式 502 bad_envelope(原行为)", async () => {
|
|
207
|
+
const seS = makeSeSession();
|
|
208
|
+
let calls = 0;
|
|
209
|
+
const transport = async () => {
|
|
210
|
+
calls += 1;
|
|
211
|
+
return new Response("", { status: 200, headers: { "content-type": "application/vnd.dsh.e2ee-v2", "x-dsh-e2ee": `v=2;s=${seS.sessId};k=http-resp` } });
|
|
212
|
+
};
|
|
213
|
+
const res = await shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/session/list", { method: "POST", body: "{}" });
|
|
214
|
+
assert.equal(calls, 1);
|
|
215
|
+
assert.equal(res.status, 502);
|
|
216
|
+
assert.equal(res.headers.get("x-dsh-e2ee-error"), "bad_envelope");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ============ G. 调用方 abort:不重试 ============
|
|
220
|
+
|
|
221
|
+
test("G. 调用方已 abort → 不重试,抛 AbortError", async () => {
|
|
222
|
+
const seS = makeSeSession();
|
|
223
|
+
let calls = 0;
|
|
224
|
+
const transport = async () => { calls += 1; throw new TypeError("Load failed"); };
|
|
225
|
+
const ac = new AbortController();
|
|
226
|
+
ac.abort();
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
() => shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/workspaceFiles/readBytes", { method: "POST", body: "{}", signal: ac.signal }),
|
|
229
|
+
(e) => e && e.name === "AbortError"
|
|
230
|
+
);
|
|
231
|
+
assert.equal(calls, 1, "已 abort 的请求不应再发第二次");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ============ H. 首次成功:不双发 ============
|
|
235
|
+
|
|
236
|
+
test("H. 白名单路径首次成功 → 只发 1 次(不做无脑双发)", async () => {
|
|
237
|
+
const seS = makeSeSession();
|
|
238
|
+
const peer = peerOf(seS);
|
|
239
|
+
let calls = 0;
|
|
240
|
+
const transport = async (_input, init) => {
|
|
241
|
+
calls += 1;
|
|
242
|
+
const envReq = JSON.parse(init.body);
|
|
243
|
+
return envelopeResponse(peer, envReq, { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from("[]") });
|
|
244
|
+
};
|
|
245
|
+
const res = await shim.seFetchWrapper(transport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/workspaceFiles/stat", { method: "POST", body: "{}" });
|
|
246
|
+
assert.equal(calls, 1);
|
|
247
|
+
assert.equal(res.status, 200);
|
|
248
|
+
assert.equal(await res.text(), "[]");
|
|
249
|
+
});
|
|
@@ -42,6 +42,7 @@ export const SHIM_EXPORTS = [
|
|
|
42
42
|
"seEncodeHttpReqPlain", "seDecodeHttpRespPlain", "seEnvelopeHeaders", "seIsEnvelopeResponse", "seGunzip",
|
|
43
43
|
"seUpstreamPathOf", "seShouldEncryptHttp", "seShouldEncryptWs", "seWsLabelParts", "seBuildWsTarget",
|
|
44
44
|
"seHandoverDecode", "seSessionOfHandover",
|
|
45
|
+
"SE_RETRY_PATH_PREFIXES", "SE_RETRY_DELAY_MS", "seRetryableReadPath",
|
|
45
46
|
"seFetchWrapper", "SeE2eeWs", "seMakeWsCtor", "seBuildResponse", "seErrorResponse"
|
|
46
47
|
];
|
|
47
48
|
|