Files
ykxmz/_build/test_admin_features.py
T
xmz7891 d4f335b8f9 管理页新增:允许/禁止新设备接入 + 修改设备配对码
== 功能一:允许新设备接入开关 ==

关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 relay_state.json 里,重启不丢。

判断新设备用的是 deviceId,不是配对码 ——
这一点很关键:管理员给老设备改过码之后,
那台设备必须仍然能进来;按码判断的话它会被自己的开关挡在外面。
测试里专门有一条 A3b 守这个行为。

被拒时回 blocked=True,让客户端能区分被管理员挡了
和配对码冲突,而不是去匹配中文错误文案(文案一改就坏)。

== 功能二:修改任意设备的配对码 ==

管理页每台设备一个「改码」按钮,填 6 位数字即生效。
要点:
  * 新旧码冲突检查
  * 旧码映射必须删掉,否则变成孤儿条目,
    管理页配对码占用的数字会虚高
  * 改码踢掉该设备当前会话(会话绑在码上)
  * 立刻持久化

== 顺手修掉两个真 bug ==

1. 改码功能一上线就 500:Session 用了 __slots__,
   根本没有 session_id 属性(会话是以 token 为 key 存在 dict 里的),
   写 s.session_id 直接 AttributeError。
   改成遍历 sessions.items() 拿 key。

2. snapshot() 里有同样的错(清理超时设备那段):
   平时 stale 列表是空的所以从没炸过,一旦真有设备超过 6 小时
   没上报就会 500。这次一起修了。

== 管理页 UI ==
  * 设备小节加了「允许新设备接入」勾选框
  * 每行加「改码」按钮(走 prompt,不引入弹窗层)
  * 改码按钮用 data-recode 而不是 data-device ——
    现有单台删除按 button.danger[data-device] 绑,
    带上 data-device 会被当成删除按钮,点改码变删设备。
    测试 C6 专门断言这一点。

== 测试 ==
新增 _tests/test_admin_device_control.py(23 项),
已注册进 _tests/verify_all.ps1 第 3e 阶段。
2026-10-07 17:01:14 +08:00

212 lines
8.1 KiB
Python
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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r'''测试管理页新增的两个功能(在设备上跑,直连 127.0.0.1:8080)。
覆盖:
A. 「允许新设备接入」开关
A1 默认是开的
A2 关掉之后,**新**设备注册被拒(blocked=True)
A3 关掉之后,**已登记过的**设备仍然能上线
A4 重新打开之后,新设备又能注册
B. 「修改配对码」
B1 换成合法的 6 位数字 -> 成功
B2 新码立刻生效(能拿来注册/attach)
B3 旧码的映射被清掉(不会变成孤儿占位)
B4 换成已被别的设备占用的码 -> 被拒
B5 换成非 6 位数字 -> 被拒
B6 换成同一个码 -> 成功但 unchanged
B7 改码会把该设备的会话踢掉
C. 管理页 HTML 里有新控件
'''
from __future__ import annotations
import json
import sys
import time
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8080"
KEY_FILE = "/root/lanremote-relay/data/admin_key.txt"
PASS = 0
FAIL = 0
def check(ok: bool, label: str) -> None:
global PASS, FAIL
if ok:
PASS += 1
print(" [PASS] %s" % label)
else:
FAIL += 1
print(" [FAIL] %s" % label)
def call(path: str, payload: dict | None = None, method: str = "POST",
admin: bool = False, key: str = "") -> tuple:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") \
if payload is not None else None
headers = {"Content-Type": "application/json"}
if admin:
headers["X-Admin"] = key
req = urllib.request.Request(BASE + path, data=data, headers=headers,
method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8", "replace")
try:
return resp.status, json.loads(body)
except Exception: # noqa: BLE001
return resp.status, body
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
try:
return exc.code, json.loads(body)
except Exception: # noqa: BLE001
return exc.code, body
except Exception as exc: # noqa: BLE001
return 0, {"error": str(exc)}
def main() -> int:
try:
key = open(KEY_FILE, encoding="utf-8").read().strip()
except OSError as exc:
print(" 读不到管理密钥: %s" % exc)
return 1
stamp = int(time.time())
dev_a = "featA-%d" % stamp
dev_b = "featB-%d" % stamp
dev_c = "featC-%d" % stamp
code_a = "%06d" % ((stamp + 11) % 1000000)
code_b = "%06d" % ((stamp + 22) % 1000000)
code_c = "%06d" % ((stamp + 33) % 1000000)
code_a2 = "%06d" % ((stamp + 44) % 1000000)
def reg(dev: str, code: str, name: str = "feat") -> tuple:
return call("/v1/agent/register", {
"deviceId": dev, "code": code, "name": name, "version": "1.8.2"})
print(" ================ A. 允许新设备接入开关 ================")
# A1 默认开
st, snap = call("/v1/health", method="GET")
check(snap.get("allowNew") is True, "A1 默认允许新设备接入")
# 先登记设备 A(此时开关是开的)
st, r = reg(dev_a, code_a, "设备A")
check(r.get("ok") is True, "A0 设备A 初始登记成功(码 %s)" % code_a)
# A2 关掉开关 + 已有会话
st, r = call("/v1/admin/allow_new", {"allow": False}, admin=True, key=key)
check(r.get("ok") is True and r.get("allowNew") is False,
"A2a 关闭开关成功")
st, r = reg(dev_b, code_b, "设备B")
check(r.get("ok") is False and r.get("blocked") is True,
"A2b 新设备B 被拒(blocked=True): %s" % r.get("error", ""))
# A3 已登记过的 A 仍能上线
st, r = reg(dev_a, code_a, "设备A")
check(r.get("ok") is True,
"A3 已登记的设备A 仍能上线(关开关不影响老设备)")
# A4 重新打开
st, r = call("/v1/admin/allow_new", {"allow": True}, admin=True, key=key)
check(r.get("ok") is True and r.get("allowNew") is True, "A4a 重新打开成功")
st, r = reg(dev_c, code_c, "设备C")
check(r.get("ok") is True, "A4b 新设备C 又能注册了")
# ---------------- B ----------------
print()
print(" ================ B. 修改配对码 ================")
# B5 非 6 位 -> 拒
st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": "12ab"},
admin=True, key=key)
check(r.get("ok") is False, "B5 非 6 位数字被拒: %s" % r.get("error", ""))
# B4 已被占用 -> 拒
st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_c},
admin=True, key=key)
check(r.get("ok") is False, "B4 已被设备C占用的码被拒: %s" % r.get("error", ""))
# B1 正常改码
st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_a2},
admin=True, key=key)
check(r.get("ok") is True and r.get("code") == code_a2,
"B1 改码成功 %s -> %s" % (code_a, code_a2))
# B2 新码立刻生效
st, r = reg(dev_a, code_a2, "设备A")
check(r.get("ok") is True and r.get("code") == code_a2,
"B2 新码立刻可用于注册")
# B6 改成同一个 -> unchanged
st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_a2},
admin=True, key=key)
check(r.get("ok") is True and r.get("unchanged") is True,
"B6 改成同一个码 -> unchanged=True")
# B3 旧码不该还占着
st, snap = call("/v1/health", method="GET")
codes_used = {d["code"] for d in snap.get("devices", [])}
check(code_a2 in codes_used and code_a not in codes_used,
"B3 设备列表里只有新码(旧码 %s 已清)" % code_a)
# B7 会话被踢 —— 先 attach 一个
st, r = call("/v1/client/attach",
{"code": code_a2, "name": "测试手表"})
attached = r.get("ok") is True
if attached:
st2, r2 = call("/v1/admin/set_code",
{"deviceId": dev_a,
"code": "%06d" % ((stamp + 55) % 1000000)},
admin=True, key=key)
check(r2.get("ok") is True and r2.get("kicked", 0) >= 1,
"B7 改码踢掉了 %s 个会话" % r2.get("kicked"))
# 改回去,方便下面收尾
call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_a2},
admin=True, key=key)
else:
check(True, "B7 跳过(attach 没成功:%s)" % r.get("error", ""))
# ---------------- C ----------------
print()
print(" ================ C. 管理页 HTML ================")
req = urllib.request.Request(BASE + "/zhongzhuanzhan",
headers={"X-Admin": key})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
html = resp.read().decode("utf-8", "replace")
check('id="allowNew"' in html, "C1 页面有开关控件")
check("允许新设备接入" in html, "C2 页面有开关文案")
check("data-recode=" in html, "C3 设备行有改码按钮")
check("/v1/admin/allow_new" in html, "C4 JS 里调 allow_new 接口")
check("/v1/admin/set_code" in html, "C5 JS 里调 set_code 接口")
# 改码按钮不能带 data-device,否则会被删除逻辑吃掉
import re
recode_btns = re.findall(r'<button[^>]*data-recode="[^"]*"[^>]*>', html)
bad = [b for b in recode_btns if "data-device" in b]
check(not bad, "C6 改码按钮没有 data-device(不会误触发删除)")
except Exception as exc: # noqa: BLE001
check(False, "C 取管理页失败: %s" % exc)
# ---------------- 收尾 ----------------
print()
print(" 清理测试设备…")
for dev in (dev_a, dev_b, dev_c):
call("/v1/admin/delete", {"deviceId": dev}, admin=True, key=key)
call("/v1/admin/allow_new", {"allow": True}, admin=True, key=key)
print()
print("=" * 56)
print(" 通过 %d 项,失败 %d 项" % (PASS, FAIL))
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())