248 lines
11 KiB
JavaScript
248 lines
11 KiB
JavaScript
/**
|
||
* 回归:本轮 code review 修掉的 5 个 P0 缺陷
|
||
*
|
||
* 每条都锁死"修复后的语义",并尽可能给出**反向证明**(展示修复前的写法会产出什么),
|
||
* 防止将来有人"顺手简化"时把缺陷放回去。
|
||
*
|
||
* 红线:
|
||
* 1. crypto 必须按**字节**做 XOR(UTF-8),非 ASCII 无损往返;失败不 fail-open
|
||
* 2. addDevice 必须返回布尔,数量达上限时返回 false(调用方据此中断)
|
||
* 3. publish 必须有超时出口,底层永不回调时也能结算
|
||
* 4. subscriptions 的生命周期属于"业务意图",只有 disconnect() 能清
|
||
* 5. 产品ID 必须是正整数,NaN / 小数不得进入 topic
|
||
*
|
||
* 跑法:node tests/p0-regressions.test.js
|
||
*/
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { MINI_ROOT, loadRuntime, clearMiniCache } = require('./helpers/fake-wx.js');
|
||
|
||
/* ============================ 迷你断言器 ============================ */
|
||
|
||
let passed = 0;
|
||
let failed = 0;
|
||
|
||
function ok(cond, label, extra) {
|
||
if (cond) {
|
||
passed++;
|
||
process.stdout.write(` ✓ ${label}\n`);
|
||
} else {
|
||
failed++;
|
||
process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${JSON.stringify(extra)}` : ''}\n`);
|
||
}
|
||
}
|
||
|
||
function eq(actual, expected, label) {
|
||
ok(actual === expected, `${label} (期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)})`);
|
||
}
|
||
|
||
/* ============================ 加载真实 MQTT 模块 ============================ */
|
||
|
||
/**
|
||
* ⚠️ 必须在 loadRuntime() 之前调用:loadRuntime 会用假 mqtt-client 顶掉
|
||
* services/mqtt-client.js,之后 require 拿到的是桩而不是被测实现
|
||
*/
|
||
function loadRealMqtt() {
|
||
clearMiniCache();
|
||
global.wx = {
|
||
getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0),
|
||
arrayBufferToBase64: () => '',
|
||
base64ToArrayBuffer: () => new ArrayBuffer(0),
|
||
};
|
||
return require(path.join(MINI_ROOT, 'services/mqtt-client.js'));
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
/* ============================ 用例 ============================ */
|
||
|
||
/** P0-5:publish 超时出口 */
|
||
async function casePublishTimeout() {
|
||
process.stdout.write('\n[1] P0-5 publish 必须有超时出口(底层永不回调也能结算)\n');
|
||
|
||
const { mqttClient } = loadRealMqtt();
|
||
|
||
// 坏桩:publish 永不回调,模拟卡在在途队列里的 QoS1 消息
|
||
mqttClient.client = { connected: true, publish: () => {} };
|
||
const t0 = Date.now();
|
||
let msg = null;
|
||
try {
|
||
await mqttClient.publish('/1/BT_01/001/measure/post', 'AABB', { publishTimeout: 120 });
|
||
} catch (err) {
|
||
msg = err.message;
|
||
}
|
||
const cost = Date.now() - t0;
|
||
eq(msg, '发布超时', '超时后 reject');
|
||
ok(cost >= 110 && cost < 1000, `约 120ms 内结算,不是永久挂起 (实际 ${cost}ms)`);
|
||
|
||
// 未连接仍应立刻拒绝,不进超时路径
|
||
mqttClient.client = null;
|
||
let msg2 = null;
|
||
try { await mqttClient.publish('/t', 'x'); } catch (err) { msg2 = err.message; }
|
||
eq(msg2, 'MQTT 未连接', '未连接时立即 reject');
|
||
|
||
// 成功路径不受影响
|
||
mqttClient.client = { connected: true, publish: (t, m, o, cb) => cb && cb(null) };
|
||
let resolved = false;
|
||
await mqttClient.publish('/t', 'x').then(() => { resolved = true; }).catch(() => {});
|
||
ok(resolved, '正常回调时仍然 resolve');
|
||
}
|
||
|
||
/** P0-4:订阅表不得随连接被清空 */
|
||
async function caseSubscriptions() {
|
||
process.stdout.write('\n[2] P0-4 订阅表生命周期属于业务意图,只有 disconnect() 能清\n');
|
||
|
||
const { mqttClient } = loadRealMqtt();
|
||
|
||
mqttClient.subscriptions.set('/p/1/sn/function/get', () => {});
|
||
mqttClient.client = { end: () => {} };
|
||
mqttClient._teardownClient(mqttClient.client);
|
||
eq(mqttClient.subscriptions.size, 1, '拆掉客户端后订阅表仍在(供 _resubscribe 恢复)');
|
||
|
||
const SRC = fs.readFileSync(path.join(MINI_ROOT, 'services/mqtt-client.js'), 'utf8');
|
||
const clears = SRC.match(/this\.subscriptions\.clear\(\)/g) || [];
|
||
eq(clears.length, 1, 'subscriptions.clear() 全文件只允许 1 处');
|
||
ok(SRC.indexOf('this.subscriptions.clear()') > SRC.indexOf(' disconnect()'),
|
||
'唯一那处在 disconnect() 内(用户主动断开才清)');
|
||
ok(SRC.includes('_resubscribe'), 'connect 成功后仍有 _resubscribe 恢复订阅');
|
||
}
|
||
|
||
/** P0-3:crypto 字节级编解码 */
|
||
async function caseCrypto() {
|
||
process.stdout.write('\n[3] P0-3 crypto 按字节做 XOR,非 ASCII 无损往返\n');
|
||
|
||
const rt = loadRuntime();
|
||
const { encrypt, decrypt, encryptFields, decryptFields } = require(path.join(MINI_ROOT, 'utils/crypto.js'));
|
||
const KEY = 'fastbee-mini-2026';
|
||
|
||
// 修复前的写法(按 UTF-16 charCode XOR + setUint8 截断),用于兼容性与反向证明
|
||
const oldEncrypt = (text, key = KEY) => {
|
||
let s = '';
|
||
for (let i = 0; i < text.length; i++) s += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||
const bytes = [];
|
||
for (let i = 0; i < s.length; i++) bytes.push(s.charCodeAt(i) & 0xFF);
|
||
return rt.wx.arrayBufferToBase64(new Uint8Array(bytes).buffer);
|
||
};
|
||
|
||
// 存量兼容:纯 ASCII 的新密文必须与旧实现逐字节一致,老数据才能继续解开
|
||
for (const s of ['secret', 'fastbee', 'Abc123!@#', '0123456789abcdefXYZ']) {
|
||
eq(encrypt(s), oldEncrypt(s), `新密文与旧实现一致(存量可解): ${s}`);
|
||
eq(decrypt(oldEncrypt(s)), s, `新实现能解开旧密文: ${s}`);
|
||
}
|
||
|
||
// 非 ASCII:修复前会静默损坏,修复后必须无损
|
||
for (const s of ['密钥中文', '密码abc中文', '测点·2026', '🔧扳手', 'Ünïcödé']) {
|
||
eq(decrypt(encrypt(s)), s, `往返无损: ${s}`);
|
||
}
|
||
ok(decrypt(oldEncrypt('密钥中文')) !== '密钥中文',
|
||
'反向证明:旧实现对中文确实不可逆损坏', decrypt(oldEncrypt('密钥中文')));
|
||
|
||
// 不得 fail-open(旧实现异常时返回明文/密文本身)
|
||
eq(encrypt(''), '', 'encrypt("") 返回空串');
|
||
eq(decrypt(''), '', 'decrypt("") 返回空串');
|
||
const realDecode = global.wx.base64ToArrayBuffer;
|
||
global.wx.base64ToArrayBuffer = () => { throw new Error('decode boom'); };
|
||
eq(decrypt('anycipher'), '', '解码异常时返回空串,不拿密文冒充明文');
|
||
global.wx.base64ToArrayBuffer = realDecode;
|
||
|
||
// 字段级
|
||
const enc = encryptFields({ productSecret: '我的密钥123', serialNumber: 'BT_01' }, ['productSecret']);
|
||
ok(enc.productSecret !== '我的密钥123' && enc._productSecret_encrypted === true, '敏感字段已加密并打标记');
|
||
eq(enc.serialNumber, 'BT_01', '非敏感字段不受影响');
|
||
const dec = decryptFields(enc, ['productSecret']);
|
||
eq(dec.productSecret, '我的密钥123', '字段级往返无损');
|
||
eq(dec._productSecret_encrypted, undefined, '解密后清除标记');
|
||
}
|
||
|
||
/** P0-2:addDevice 返回值 */
|
||
async function caseAddDevice() {
|
||
process.stdout.write('\n[4] P0-2 addDevice 数量达上限必须返回 false(供调用方中断)\n');
|
||
|
||
const rt = loadRuntime();
|
||
let added = 0;
|
||
for (let i = 0; i < 60; i++) {
|
||
const sn = 'DEV_' + String(i).padStart(3, '0');
|
||
if (rt.storage.addDevice({ deviceId: 'ID_' + i, serialNumber: sn, name: sn, enabled: false }) === true) added++;
|
||
}
|
||
eq(added, 50, '恰好写入 50 台后开始返回 false');
|
||
eq(rt.storage.addDevice({ serialNumber: 'DEV_OVER', deviceId: 'X' }), false, '第 51 台返回 false');
|
||
eq(rt.storage.getDeviceList().length, 50, '列表未被超限写入污染');
|
||
eq(rt.storage.addDevice({ serialNumber: 'DEV_000', deviceId: 'ID_0', name: '改名' }), true, '覆盖既有编号仍返回 true');
|
||
|
||
// 调用方确实检查了返回值:scan.js 里 addDevice 之后不得无条件继续激活+启动
|
||
const SCAN = fs.readFileSync(path.join(MINI_ROOT, 'pages/device/scan/scan.js'), 'utf8');
|
||
ok(/if\s*\(\s*!storage\.addDevice\(/.test(SCAN), 'scan.js 对 addDevice 的返回值做了失败分支');
|
||
ok(!/^\s*storage\.addDevice\(deviceConfig\);\s*$/m.test(SCAN), '不再有「无视返回值直接调用」的写法');
|
||
}
|
||
|
||
/** P0-1:产品ID 必须是正整数 */
|
||
async function caseProductId() {
|
||
process.stdout.write('\n[5] P0-1 产品ID 必须校验为整数,NaN/小数不得进入 topic\n');
|
||
|
||
const rt = loadRuntime();
|
||
// 必须种进 storage:auth.getMqttCredentials 读的是存储里的用户信息,不是页面 data。
|
||
// 不种的话合法用例会走到"未登录"分支而失败,非法用例也会因提前 return 而假绿
|
||
rt.storage.setUserInfo({ userId: 1, userName: 'tester' });
|
||
|
||
const toasts = [];
|
||
global.wx.showToast = (o) => toasts.push(o.title);
|
||
global.wx.showModal = (o) => toasts.push('MODAL:' + o.title);
|
||
global.wx.showLoading = () => {};
|
||
global.wx.hideLoading = () => {};
|
||
|
||
global.getApp = () => ({ globalData: {} });
|
||
let cfg = null;
|
||
global.Page = (c) => { cfg = c; };
|
||
require(path.join(MINI_ROOT, 'pages/mine/mine.js'));
|
||
if (!cfg) throw new Error('mine.js 未调用 Page(),加载失败');
|
||
|
||
const page = Object.assign({}, cfg);
|
||
page.data = JSON.parse(JSON.stringify(cfg.data));
|
||
page.setData = function (patch) { Object.assign(this.data, patch); };
|
||
page.setData({ userInfo: { userId: 1 } });
|
||
|
||
const auth = require(path.join(MINI_ROOT, 'utils/auth.js'));
|
||
|
||
// 反向证明:修复前的 parseInt 会产出 NaN,而网关的判空两道都挡不住它
|
||
eq(auth.buildMeasurePostTopic(parseInt('abc'), 'BT_01', '001'), '/NaN/BT_01/001/measure/post',
|
||
'反向证明:parseInt("abc") 会造出 /NaN/ 脏 topic');
|
||
eq(auth.buildMeasurePostTopic(parseInt('12.5'), 'BT_01', '001'), '/12/BT_01/001/measure/post',
|
||
'反向证明:parseInt("12.5") 会静默截断成 12');
|
||
|
||
for (const bad of ['abc', '12.5', '0', '-3', '']) {
|
||
rt.storage.removeMqttConfig();
|
||
toasts.length = 0;
|
||
page.setData({ mqttConnectingLocal: false });
|
||
page.data.mqttForm = { serialNumber: 'BT_01', productId: bad, productSecret: 'sec' };
|
||
await page.handleMqttConnect();
|
||
eq(rt.storage.getMqttConfig(), null, `非法产品ID "${bad}" 不落盘`);
|
||
}
|
||
|
||
// 合法输入仍正常
|
||
rt.storage.removeMqttConfig();
|
||
toasts.length = 0;
|
||
page.setData({ mqttConnectingLocal: false });
|
||
page.data.mqttForm = { serialNumber: 'BT_01', productId: '12', productSecret: 'sec' };
|
||
await page.handleMqttConnect();
|
||
const saved = rt.storage.getMqttConfig();
|
||
ok(saved !== null, '合法产品ID 正常保存');
|
||
eq(saved && saved.productId, 12, '保存为数字 12(不是字符串)');
|
||
eq(auth.buildMeasurePostTopic(saved.productId, saved.serialNumber, '001'), '/12/BT_01/001/measure/post',
|
||
'构造出的 topic 正确');
|
||
}
|
||
|
||
/* ============================ 执行 ============================ */
|
||
|
||
(async () => {
|
||
process.stdout.write('\n===== P0 缺陷回归 =====\n');
|
||
// 顺序敏感:[1][2] 必须先加载真实 mqtt-client,之后 loadRuntime 才会注入假模块
|
||
await casePublishTimeout();
|
||
await caseSubscriptions();
|
||
await caseCrypto();
|
||
await caseAddDevice();
|
||
await caseProductId();
|
||
|
||
process.stdout.write(`\n===== 通过 ${passed} / 失败 ${failed} =====\n`);
|
||
process.exit(failed === 0 ? 0 : 1);
|
||
})();
|