fastbee/yack-mini/tests/p0-regressions.test.js
2026-09-27 15:17:17 +08:00

248 lines
11 KiB
JavaScript
Raw 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.

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