/** * 回归:本轮 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); })();