charging-cabinet/test/rssi_signal.test.mjs
12451 61adcdeaf9 fix(security)+fix(api)+docs: 越权面修复、登录门控与状态语义对齐、文档全面校对
- 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id
  参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、
  cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经
  charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、
  GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、
  POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、
  /api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2;
  /api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin
- TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则
- 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式)
- 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到")
  ⚠️ device-server 与 api-server 必须同批发布
- 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、
  行号引用改小节引用、修 3 处错引小节
- 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2
  与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、
  charge_orders.status 标注 2 保留未使用)
- 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红)
- RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs
- nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像
- 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级;
  docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正;
  权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
2026-09-20 03:23:00 +08:00

195 lines
8.1 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.

/**
* RSSI 信号强度分档(协议定点:`rssi` 是 **0–99 的信号等级**,非 dBm)
*
* 覆盖**两端**(PC `software/web/src/utils/deviceStatus.ts` 与
* 小程序 `software/h5-uniapp/src/utils/deviceStatus.ts`)的 `rssiSignal` / `rssiBarColors`,
* 并断言**两端口径完全一致**(同一输入 → 同一 `level` / `bars` / `color`)。
*
* 为什么需要它:2026-09 之前两端**本来就不一致**——
* - PC 是 5 档(≥70 绿 / ≥55 蓝 / ≥40 黄 / ≥30 橙 / <30 灰),格数随档位增删;
* - 小程序只有 4 档(**没有 ≥70**)、30–39 用**红色**、且**恒渲染 5 格只改颜色**。
* 本次收敛为统一 5 档后,用本用例把"两端一致"钉死:**只改一端就会红**。
*
* 另外断言**档位边界**与**颜色表**(防止有人"统一"成另一套阈值却没人发现)。
*
* 运行:node test/rssi_signal.test.mjs
*/
import assert from 'node:assert/strict'
import * as web from '../software/web/src/utils/deviceStatus.ts'
import * as mp from '../software/h5-uniapp/src/utils/deviceStatus.ts'
/** 极简用例收集器(保持仓库零依赖) */
const cases = []
function test(name, fn) {
cases.push([name, fn])
}
// ---------------------------------------------------------------------------
// 权威分档表(写死在这里 = 契约;与两个实现文件里的 RSSI_TIERS 必须一致)
// ---------------------------------------------------------------------------
const EXPECTED_TIERS = [
{ min: 70, level: 4, bars: 5, color: '#16a34a', key: 'strong' },
{ min: 55, level: 3, bars: 4, color: '#2563eb', key: 'good' },
{ min: 40, level: 2, bars: 3, color: '#f59e0b', key: 'fair' },
{ min: 30, level: 1, bars: 2, color: '#f97316', key: 'weak' },
]
const EXPECTED_NONE = { level: 0, bars: 1, color: '#9ca3af', key: 'none' }
/**
* 只取契约字段:`rssiSignal()` 返回 `{level,bars,color,key}`,
* **不含** `RSSI_TIERS` 里的 `min`(阈值是实现细节,不进返回值)。
*/
function contract({ level, bars, color, key }) {
return { level, bars, color, key }
}
/** 权威分档表的契约形态(供边界断言直接用) */
const T = EXPECTED_TIERS.map(contract)
/** 该值按权威契约应落在哪一档 */
function expectedFor(rssi) {
if (rssi === null || rssi === undefined) return EXPECTED_NONE
const value = Number(rssi)
if (!Number.isFinite(value)) return EXPECTED_NONE
for (const tier of EXPECTED_TIERS) {
if (value >= tier.min) return contract(tier)
}
return EXPECTED_NONE
}
// ---------------------------------------------------------------------------
// 1. 两端一致性:遍历 0–99(协议全量程)逐值断言
// ---------------------------------------------------------------------------
test('test_web_and_mp_rssi_signal_agree_over_0_99', () => {
for (let rssi = 0; rssi <= 99; rssi++) {
assert.deepEqual(
web.rssiSignal(rssi),
mp.rssiSignal(rssi),
`rssi=${rssi} 两端 rssiSignal 不一致`,
)
assert.deepEqual(
web.rssiBarColors(rssi),
mp.rssiBarColors(rssi),
`rssi=${rssi} 两端 rssiBarColors 不一致`,
)
}
})
test('test_web_and_mp_rssi_agree_on_missing_and_boundary_values', () => {
const values = [null, undefined, Number.NaN, Number.POSITIVE_INFINITY, -1, 29, 30, 39, 40, 54, 55, 69, 70, 99, 100, 255]
for (const value of values) {
assert.deepEqual(web.rssiSignal(value), mp.rssiSignal(value), `rssi=${String(value)} 两端不一致`)
assert.deepEqual(
web.rssiBarColors(value),
mp.rssiBarColors(value),
`rssi=${String(value)} 两端 rssiBarColors 不一致`,
)
}
})
test('test_web_and_mp_rssi_tier_tables_are_identical', () => {
assert.deepEqual(web.RSSI_TIERS, mp.RSSI_TIERS, '两端 RSSI_TIERS 必须逐字一致')
assert.deepEqual(web.RSSI_NONE, mp.RSSI_NONE, '两端 RSSI_NONE 必须一致')
assert.equal(web.RSSI_MAX_BARS, mp.RSSI_MAX_BARS, '两端 RSSI_MAX_BARS 必须一致')
assert.equal(web.RSSI_MAX_BARS, 5, '统一 5 格制')
})
// ---------------------------------------------------------------------------
// 2. 契约本身:档位边界 + 颜色表(防止"统一"成另一套阈值而无人发现)
// ---------------------------------------------------------------------------
test('test_rssi_tier_boundaries_match_agreed_contract', () => {
const boundaries = [
[-1, EXPECTED_NONE],
[0, EXPECTED_NONE],
[29, EXPECTED_NONE],
[30, T[3]],
[39, T[3]],
[40, T[2]],
[54, T[2]],
[55, T[1]],
[69, T[1]],
[70, T[0]],
[99, T[0]],
]
for (const [rssi, expected] of boundaries) {
assert.deepEqual(web.rssiSignal(rssi), expected, `rssi=${rssi} 档位不符契约`)
}
// 无数据 / 非数字一律落"无信号"档(灰色底格),不得猜测成"信号好"
for (const bad of [null, undefined, Number.NaN]) {
assert.deepEqual(web.rssiSignal(bad), EXPECTED_NONE, `rssi=${String(bad)} 必须落无信号档`)
}
})
test('test_rssi_colors_match_agreed_contract', () => {
// 收敛后的配色:绿 → 蓝 → 黄 → 橙 → 灰(**注意 ≥55 是蓝、30–39 是橙而非红**)
assert.deepEqual(
[99, 60, 45, 33, 10].map((v) => web.rssiSignal(v).color),
['#16a34a', '#2563eb', '#f59e0b', '#f97316', '#9ca3af'],
)
})
test('test_rssi_bars_increase_with_level_and_stay_within_max', () => {
let prev = 0
// 逐档递增,且 1..5 全部落在 5 格制内
for (const rssi of [10, 30, 40, 55, 70]) {
const { bars } = web.rssiSignal(rssi)
assert.ok(bars > prev, `rssi=${rssi} 的格数应比上一档多(bars=${bars})`)
assert.ok(bars >= 1 && bars <= web.RSSI_MAX_BARS, `rssi=${rssi} 格数越界:${bars}`)
prev = bars
}
// 满档 = 5 格;离线也是 1 格底格(不是 0 格 —— 0 格会让图标"消失")
assert.equal(web.rssiSignal(70).bars, 5)
assert.equal(web.rssiSignal(null).bars, 1)
})
test('test_rssi_bar_colors_length_equals_bars_and_single_color', () => {
for (let rssi = 0; rssi <= 99; rssi++) {
const signal = web.rssiSignal(rssi)
const colors = web.rssiBarColors(rssi)
assert.equal(colors.length, signal.bars, `rssi=${rssi} 颜色数组长度必须等于格数`)
assert.ok(colors.every((c) => c === signal.color), `rssi=${rssi} 每格必须同色`)
}
})
// ---------------------------------------------------------------------------
// 3. 反证:把"收敛前的两端不一致"作为反例 —— 若谁把某一端改回去,本组会红
// ---------------------------------------------------------------------------
test('test_mp_no_longer_uses_the_old_four_tier_red_30_39_scale', () => {
// 旧小程序口径:30–39 → 红;且没有 ≥70 档(55 就是最高)
// 收敛后:30–39 必须是**橙** `#f97316`,且 70 必须是**独立的一档**(绿,5 格)
assert.equal(mp.rssiSignal(35).color, '#f97316', '小程序 30–39 必须是橙色(旧实现是红 #ef4444)')
assert.notEqual(mp.rssiSignal(35).color, '#ef4444', '不得再出现旧的红色 30–39 档')
assert.notEqual(mp.rssiSignal(80).color, mp.rssiSignal(60).color, '≥70 必须是独立档(不与 ≥55 同色)')
assert.equal(mp.rssiSignal(80).bars, 5, '≥70 应为满格 5 格')
})
test('test_bars_are_not_constant_five_for_all_levels', () => {
// 收敛前的 bug:小程序恒渲染 5 格、只改颜色 ⇒ 所有档位 bars 都是 5。
// 现在格数必须随档位变化(否则本用例红)。
const barsSet = new Set([10, 30, 40, 55, 70].map((v) => mp.rssiSignal(v).bars))
assert.ok(barsSet.size >= 4, `格数必须随档位变化,实际集合:${[...barsSet].join(',')}`)
assert.notEqual(mp.rssiSignal(10).bars, mp.rssiSignal(70).bars, '离线档与满档格数必须不同')
})
// ---------------------------------------------------------------------------
// 运行
// ---------------------------------------------------------------------------
let failed = 0
for (const [name, fn] of cases) {
try {
fn()
console.log(`ok ${name}`)
} catch (err) {
failed += 1
console.error(`FAIL ${name}\n ${err.message}`)
}
}
console.log(`\n${cases.length - failed} passed, ${failed} failed (共 ${cases.length} 个用例)`)
process.exit(failed === 0 ? 0 : 1)