Files
inkwatch/tests/relay-route-e2e.js
PMon Dev b380c88b06 fix(relay): 单次上限收紧到 1 MiB(可配 RELAY_MAX_BYTES),把「卡死」变「明确拒绝」
背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。
缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook):
  ① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain')
  → 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done()
  依赖的 onDrain 被删导致 /relay/open 崩,已回滚。
已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、
TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。

因此在根因修好前收紧上限:
· MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整,
  2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。
· 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。
  文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。
· compose/.env.example 透传 RELAY_MAX_BYTES。
· relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字,
  不再绑死 GiB —— 否则改上限就假红)。

现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409;
健康检查五项全过。
2026-10-17 20:50:20 +08:00

205 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 中继路由端到端 —— 打**真网关的三条 HTTP 路由**(open / GET / PUT)
*
* 为什么单靠 relay-test.js 不够:那 30 项测的是 Relay 类,跑在进程内。
* 历史上有过一次真实事故:Relay 类全绿,但路由里把 `store.getDeviceToken`
* 写成了不存在的变量,被 catch 吞成 503,**中继永远开不出来**——
* 直到部署后 curl 一次 /relay/open 才发现。本套件把路由当被测对象。
*
* 覆盖:
* ① 开通:200 + id + 双令牌 + 限速声明
* ② 完整往返:GET 挂起 → PUT → 字节一致(不落盘由服务端测试保证)
* ③ 401:缺设备身份
* ④ 409 E_BAD_SIZE:size ≤ 0
* ⑤ 409 E_TOO_LARGE:超过单次上限(默认 1 MiB,RELAY_MAX_BYTES 可覆盖)
* ⑥ 409 E_TOO_MANY:每账号并发 1(第二条被拒)
* ⑦ 假 size 截断:声明多少只能传多少(多传的被丢弃)
* ⑧ 错向令牌:拿下载令牌走 PUT → 404(不区分原因,防爆破反馈)
*
* 前置:网关 @ :3000(compat 模式即可,devtoken 键不存在时放行)
* 运行:node tests/relay-route-e2e.js
*/
'use strict';
const http = require('http');
const crypto = require('crypto');
const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:3000';
const HOST = new URL(GATEWAY).hostname;
const PORT = Number(new URL(GATEWAY).port || 80);
let pass = 0;
let fail = 0;
function check(name, cond, extra = '') {
if (cond) {
pass++;
console.log(' \u2713 ' + name);
} else {
fail++;
console.log(' \u2717 ' + name + ' ' + extra);
}
}
function req(opts, body) {
return new Promise((resolve) => {
const r = http.request({ hostname: HOST, port: PORT, ...opts }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () =>
resolve({ status: res.statusCode, body: Buffer.concat(chunks), headers: res.headers }));
});
r.on('error', (e) =>
resolve({ status: 0, error: e.message, body: Buffer.alloc(0), headers: {} }));
if (body) r.write(body);
r.end();
});
}
const uniq = (tag) => `relay-e2e-${tag}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
function openRelay(deviceId, size, token = 'e2e-token') {
return req(
{
path: '/relay/open',
method: 'POST',
headers: {
'content-type': 'application/json',
'x-device-id': deviceId,
'x-device-token': token,
},
},
JSON.stringify({ size })
);
}
(async () => {
console.log('\n── ① 开通中继 ──');
const dev1 = uniq('open');
const opened = await openRelay(dev1, 1024);
let j = {};
try {
j = JSON.parse(opened.body.toString());
} catch (_) {}
check('open 200', opened.status === 200, `status=${opened.status} ${opened.body}`);
check('返回 id', typeof j.id === 'string' && j.id.length > 0);
check('双令牌各自独立', j.uploadToken && j.downloadToken && j.uploadToken !== j.downloadToken);
check('限速声明 8 MB/s', j.throttleBytesPerSec === 8 * 1024 * 1024, `bps=${j.throttleBytesPerSec}`);
console.log('\n── ② 完整往返(GET 挂起 → PUT → 字节一致)──');
{
const payload = crypto.randomBytes(1024);
const got = new Promise((resolve) => {
req({
path: `/relay/${j.id}?t=${encodeURIComponent(j.downloadToken)}`,
method: 'GET',
}).then(resolve);
});
await new Promise((r) => setTimeout(r, 120));
const put = await req(
{
path: `/relay/${j.id}?t=${encodeURIComponent(j.uploadToken)}`,
method: 'PUT',
headers: { 'content-type': 'application/octet-stream', 'content-length': payload.length },
},
payload
);
const down = await got;
check('PUT 200', put.status === 200, `status=${put.status}`);
check('GET 200 且字节一致', down.status === 200 && down.body.equals(payload),
`status=${down.status} bytes=${down.body.length}`);
}
console.log('\n── ③ 401 缺设备身份 ──');
{
const r = await req(
{ path: '/relay/open', method: 'POST', headers: { 'content-type': 'application/json' } },
JSON.stringify({ size: 10 })
);
check('缺头 → 401', r.status === 401, `status=${r.status}`);
}
console.log('\n── ④ E_BAD_SIZE ──');
{
const r = await openRelay(uniq('bad'), 0);
const b = (() => { try { return JSON.parse(r.body.toString()); } catch (_) { return {}; } })();
check('size=0 → 409 E_BAD_SIZE', r.status === 409 && b.code === 'E_BAD_SIZE',
`status=${r.status} code=${b.code}`);
}
console.log('\n── ⑤ E_TOO_LARGE ──');
{
const r = await openRelay(uniq('big'), 3 * 1024 * 1024 * 1024);
const b = (() => { try { return JSON.parse(r.body.toString()); } catch (_) { return {}; } })();
// 断言不绑定单位:上限是可配置的(RELAY_MAX_BYTES),文案单位会随值变
// (MiB/GiB)。绑死 "GiB" 会在改了上限之后变成假红。
check('3 GiB → 409 E_TOO_LARGE(原因含「上限」)',
r.status === 409 && b.code === 'E_TOO_LARGE' && /上限/.test(b.reason || ''),
`status=${r.status} code=${b.code}`);
}
console.log('\n── ⑥ E_TOO_MANY(每账号并发 1)──');
{
const dev = uniq('conc');
const a = await openRelay(dev, 10);
const b = await openRelay(dev, 10);
const bj = (() => { try { return JSON.parse(b.body.toString()); } catch (_) { return {}; } })();
check('第一条 200', a.status === 200, `status=${a.status}`);
check('第二条 409 E_TOO_MANY', b.status === 409 && bj.code === 'E_TOO_MANY',
`status=${b.status} code=${bj.code}`);
}
console.log('\n── ⑦ 假 size 截断(声明 100 却传 200 → 断连,且不超声明)──');
{
const dev = uniq('cut');
const o = await openRelay(dev, 100);
const oj = JSON.parse(o.body.toString());
const got = new Promise((resolve) => {
req({ path: `/relay/${oj.id}?t=${encodeURIComponent(oj.downloadToken)}`, method: 'GET' })
.then(resolve);
});
await new Promise((r) => setTimeout(r, 120));
const fat = crypto.randomBytes(200);
const put = await req(
{
path: `/relay/${oj.id}?t=${encodeURIComponent(oj.uploadToken)}`,
method: 'PUT',
headers: { 'content-type': 'application/octet-stream', 'content-length': fat.length },
},
fat
);
const down = await got;
// 服务端实现是 `req.destroy(E_OVER_LIMIT)` —— 超声明即断连。
// 安全检查的是「不超过声明」,不是「上传成功」。
check('超声明上传被断连(PUT 非 200)', put.status !== 200,
`status=${put.status}`);
check('接收端字节不超过声明(≤100)', down.body.length <= 100,
`bytes=${down.body.length}`);
}
console.log('\n── ⑧ 错向令牌 → 404 ──');
{
const dev = uniq('swap');
const o = await openRelay(dev, 10);
const oj = JSON.parse(o.body.toString());
const r = await req(
{
path: `/relay/${oj.id}?t=${encodeURIComponent(oj.downloadToken)}`,
method: 'PUT',
headers: { 'content-type': 'application/octet-stream', 'content-length': 10 },
},
Buffer.alloc(10)
);
check('下载令牌走 PUT → 404', r.status === 404, `status=${r.status}`);
}
console.log('\n========================================');
console.log(' 结果: ' + pass + ' 通过 / ' + fail + ' 失败');
console.log('========================================');
process.exit(fail ? 1 : 0);
})().catch((e) => {
console.error('测试异常:', e);
process.exit(1);
});