/** * 中继路由端到端 —— 打**真网关的三条 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); });