Files
svfs/tests/test_client_mqtt.py
chakcy e0d5bad7b2 feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签
一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
  .../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
  按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
  一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
  数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
  ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
  插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
  线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
  自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
  总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
  全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
  宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
  /api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
  响应下发(/api/login 与 /api/me 都带),客户端不用手填。

二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
  登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
  不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。

三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
  管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。

四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
  QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
  不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
  崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
  一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
  静默吞掉):带身份快照,不匹配即丢弃。

五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
  端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
  (手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
  单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。

测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
  (装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
  消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
  messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
  崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
  os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
  README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
2026-09-17 01:06:02 +08:00

474 lines
18 KiB
Python
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.
#!/usr/bin/env python3
"""MQTT 推送通道的测试。
分两层:
* **装配层**:注入假客户端,确定性地验证"连接参数、订阅哪两条主题、状态机、
重连语义、缺 paho 时的降级"——不依赖网络与 broker;
* **端到端**:自建一个最小 MQTT broker(3.1.1、QoS 0,只实现 CONNECT /
SUBSCRIBE / PUBLISH / PINGREQ / DISCONNECT),用**真 paho 客户端**连上去,
让 broker 主动推消息,验证消息真的落进总线:主机消息进消息中心、插件消息
只进插件队列。没装 paho 时这一层自动跳过。
"""
import json
import socket
import struct
import sys
import threading
import time
import unittest
from helpers import VFSTestCase
from svfs_client import bus as bus_mod
from svfs_client import mqtt as mqtt_mod
from svfs_client import settings, topics
assert sys.modules.get("helpers") is not None
try:
import paho.mqtt.client as _paho # noqa: F401 - 端到端测试需要
HAVE_PAHO = True
except Exception:
HAVE_PAHO = False
def config(**overrides):
base = {"enabled": True, "host": "127.0.0.1", "port": 1883, "tls": False,
"prefix": "svfs", "org": "acme", "username": "alice",
"password": "tok", "keepalive": 30}
base.update(overrides)
return base
def wait_until(predicate, timeout=10.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
class _FakeClient:
"""paho 客户端的替身:记录调用,手动触发回调。"""
def __init__(self):
self.calls = []
self.subscriptions = []
self.on_connect = None
self.on_disconnect = None
self.on_message = None
self.username_pw = None
self.tls = None
self.reconnect_delay = None
# paho 的接口
def connect_async(self, host, port, keepalive):
self.calls.append(("connect_async", host, port, keepalive))
def loop_start(self):
self.calls.append(("loop_start",))
def loop_stop(self):
self.calls.append(("loop_stop",))
def disconnect(self):
self.calls.append(("disconnect",))
def subscribe(self, topic):
self.subscriptions.append(topic)
self.calls.append(("subscribe", topic))
def username_pw_set(self, username, password=None):
self.username_pw = (username, password)
def tls_set(self, **kwargs):
self.tls = kwargs
def reconnect_delay_set(self, min_delay, max_delay):
self.reconnect_delay = (min_delay, max_delay)
# 测试侧的触发点
def fire_connect(self, code=0):
self.on_connect(self, None, {}, code, None)
def fire_disconnect(self, code=0):
self.on_disconnect(self, None, None, code, None)
def fire_message(self, topic, payload):
self.on_message(self, None, _Message(topic, payload))
class _Message:
def __init__(self, topic, payload):
self.topic = topic
self.payload = payload if isinstance(payload, bytes) else payload.encode()
class TransportWiringTest(VFSTestCase):
"""装配层:参数、订阅、状态机(不需要 broker)。"""
def setUp(self):
super().setUp()
self.states = []
self.messages = []
self.client = _FakeClient()
def transport(self, **overrides):
return mqtt_mod.MqttTransport(
config(**overrides),
on_message=lambda topic, payload: self.messages.append((topic, payload)),
on_state=lambda state, detail: self.states.append((state, detail)),
client=self.client,
)
def test_disabled_config_does_not_connect(self):
transport = mqtt_mod.MqttTransport({"enabled": False},
client=self.client)
self.assertEqual(transport.start(), mqtt_mod.STATE_DISABLED)
self.assertEqual(self.client.calls, [])
def test_missing_paho_degrades_with_hint(self):
original = mqtt_mod._import_paho
mqtt_mod._import_paho = lambda: None
self.addCleanup(setattr, mqtt_mod, "_import_paho", original)
transport = mqtt_mod.MqttTransport(config(), on_state=lambda *a: None)
self.assertEqual(transport.start(), mqtt_mod.STATE_MISSING)
self.assertIn("paho", transport.detail)
def test_start_connects_subscribes_and_reports_state(self):
transport = self.transport()
self.assertEqual(transport.start(), mqtt_mod.STATE_CONNECTING)
self.assertIn(("connect_async", "127.0.0.1", 1883, 30), self.client.calls)
self.assertIn(("loop_start",), self.client.calls)
self.assertEqual(self.client.username_pw, ("alice", "tok"))
self.assertFalse(self.client.tls) # 非 TLS 不调 tls_set
self.assertEqual(self.client.reconnect_delay,
(mqtt_mod.RECONNECT_MIN_DELAY, mqtt_mod.RECONNECT_MAX_DELAY))
self.client.fire_connect(0)
self.assertTrue(transport.is_connected())
# 订阅的是"自己的 + 组织广播"两条,不是整个组织
self.assertEqual(self.client.subscriptions,
["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"])
self.assertEqual(self.states[-1][0], mqtt_mod.STATE_CONNECTED)
def test_reconnect_resubscribes(self):
transport = self.transport()
transport.start()
self.client.fire_connect(0)
self.client.subscriptions.clear()
# 掉线 → paho 自动重连 → 重新订阅(broker 不记得旧会话的订阅)
self.client.fire_disconnect(7)
self.assertEqual(transport.state, mqtt_mod.STATE_CONNECTING)
self.client.fire_connect(0)
self.assertEqual(len(self.client.subscriptions), 2)
self.assertEqual(transport.connected_count, 2)
def test_refused_connection_reports_error(self):
transport = self.transport()
transport.start()
self.client.fire_connect(5) # 5 = 未授权
self.assertEqual(transport.state, mqtt_mod.STATE_ERROR)
self.assertIn("拒绝", transport.detail)
def test_message_callback_hands_payload_to_bus(self):
transport = self.transport()
transport.start()
self.client.fire_connect(0)
self.client.fire_message("svfs/acme/user/alice/message", '{"body": "hi"}')
self.assertEqual(self.messages,
[("svfs/acme/user/alice/message", b'{"body": "hi"}')])
self.assertEqual(transport.received_count, 1)
def test_stop_is_idempotent_and_marks_stopped(self):
transport = self.transport()
transport.start()
self.client.fire_connect(0)
transport.stop()
self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED)
self.assertIn(("disconnect",), self.client.calls)
self.assertIn(("loop_stop",), self.client.calls)
# 主动断开后的 on_disconnect 不该被当成"掉线重连中"
self.client.fire_disconnect(0)
self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED)
def test_tls_config_sets_tls(self):
transport = self.transport(tls=True, port=8883)
transport.start()
self.assertIsNotNone(self.client.tls)
self.assertIn(("connect_async", "127.0.0.1", 8883, 30), self.client.calls)
class BrokerTest(VFSTestCase):
"""端到端:真 paho ↔ 自建最小 broker ↔ 统一总线。"""
ORG = "acme"
USER = "alice"
def setUp(self):
super().setUp()
if not HAVE_PAHO:
self.skipTest("没装 paho-mqtt,跳过真实 MQTT 端到端")
self.broker = _MiniBroker()
self.addCleanup(self.broker.close)
self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER)
self.transport = mqtt_mod.MqttTransport(
config(host="127.0.0.1", port=self.broker.port, keepalive=5),
on_message=self.bus.deliver)
self.addCleanup(self.transport.stop)
self.assertEqual(self.transport.start(), mqtt_mod.STATE_CONNECTING)
self.assertTrue(wait_until(lambda: self.transport.is_connected()),
"客户端没能连上自建 broker")
def test_server_push_lands_in_bus(self):
# broker 记录客户端订阅的主题(验证最小权限订阅)
self.assertTrue(wait_until(lambda: len(self.broker.subscriptions) >= 2))
self.assertEqual(sorted(self.broker.subscriptions),
["svfs/acme/broadcast/#", "svfs/acme/user/alice/#"])
# 服务端主动推一条主机消息 + 一条插件消息
self.broker.publish("svfs/acme/user/alice/message",
json.dumps({"id": "m1", "title": "备份完成",
"body": "3 个包"}))
self.broker.publish("svfs/acme/user/alice/plugin/com.example.dash",
json.dumps({"cpu": 0.42}))
self.assertTrue(wait_until(lambda: len(self.bus.log) == 1))
self.assertTrue(wait_until(lambda: self.bus.pending("com.example.dash") == 1))
message = self.bus.log.items()[0]
self.assertEqual((message["title"], message["body"]), ("备份完成", "3 个包"))
self.assertTrue(message["unread"])
# 插件消息没有混进消息中心
self.assertEqual(len(self.bus.log), 1)
self.assertEqual(self.bus.drain("com.example.dash")[0]["payload"],
json.dumps({"cpu": 0.42}))
def test_qos1_style_redelivery_is_deduped_end_to_end(self):
payload = json.dumps({"id": "m2", "body": "同一条"})
self.broker.publish("svfs/acme/user/alice/message", payload)
self.broker.publish("svfs/acme/user/alice/message", payload)
self.assertTrue(wait_until(lambda: len(self.bus.log) == 1))
time.sleep(0.2)
self.assertEqual(len(self.bus.log), 1, "重投的两条应该去重成一条")
self.assertEqual(self.bus.stats()["duplicate"], 1)
def test_other_users_message_is_dropped_by_bus(self):
"""即使 broker 的 ACL 配错、把别人的消息推过来,总线也挡掉。
正常情况根本到不了这一步:客户端只订阅 ``user/<自己>/#``,broker 不会
投别的用户的消息(上一条测试验证的正是这个)。这里用 publish_raw 模拟
配错的 broker,验证第二道防线。
"""
self.broker.publish_raw("svfs/acme/user/bob/message", '{"body": "别人的"}')
self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1))
self.assertEqual(len(self.bus.log), 0)
def test_other_orgs_message_is_dropped_by_bus(self):
self.broker.publish_raw("svfs/othercorp/user/alice/message",
'{"body": "别的租户"}')
self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1))
self.assertEqual(len(self.bus.log), 0)
# ----------------------------------------------------------------------
# 最小 MQTT broker(只为测试:MQTT 3.1.1、QoS 0、CONNECT/SUBSCRIBE/PUBLISH/
# PINGREQ/DISCONNECT)。故意不引第三方 broker —— 测试要能离线跑。
# ----------------------------------------------------------------------
class _MiniBroker:
"""够用的 MQTT broker:接连接、记订阅、按通配符投递 PUBLISH。"""
def __init__(self):
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._server.bind(("127.0.0.1", 0))
self._server.listen(4)
self.port = self._server.getsockname()[1]
self.subscriptions = [] # broker 收到的订阅(测试断言用)
self.published = [] # broker 收到的客户端 PUBLISH(本测试没用到)
self._clients = [] # [(sock, [topics])]
self._lock = threading.Lock()
self._closed = False
threading.Thread(target=self._accept_loop, daemon=True).start()
# ---- 对外 -------------------------------------------------------
def publish(self, topic, payload):
"""broker 主动推一条(QoS 0),按订阅投递。"""
self._send(topic, payload, ignore_subscriptions=False)
def publish_raw(self, topic, payload):
"""无视订阅、推给所有连接(模拟 ACL 配错的 broker)。
正常 broker 不会这么干(订阅本身就是 ``user/<自己>/#``),但"broker
配错"是真实存在的运维事故,总线的归属校验就是为它准备的兜底。
"""
self._send(topic, payload, ignore_subscriptions=True)
def _send(self, topic, payload, ignore_subscriptions):
body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
packet = _publish_packet(topic, body)
with self._lock:
if ignore_subscriptions:
targets = [sock for sock, _filters in self._clients]
else:
targets = [sock for sock, filters in self._clients
if any(_topic_matches(f, topic) for f in filters)]
for sock in targets:
try:
sock.sendall(packet)
except OSError:
pass
def close(self):
self._closed = True
with self._lock:
for sock, _filters in self._clients:
try:
sock.close()
except OSError:
pass
self._clients = []
try:
self._server.close()
except OSError:
pass
# ---- 内部 -------------------------------------------------------
def _accept_loop(self):
while not self._closed:
try:
sock, _addr = self._server.accept()
except OSError:
return
with self._lock:
self._clients.append((sock, []))
threading.Thread(target=self._client_loop, args=(sock,),
daemon=True).start()
def _client_loop(self, sock):
try:
while not self._closed:
header = sock.recv(1)
if not header:
return
packet_type = header[0] >> 4
length, _ = _read_length(sock)
body = _read_exact(sock, length) if length else b""
if packet_type == 1: # CONNECT
sock.sendall(b"\x20\x02\x00\x00")
elif packet_type == 8: # SUBSCRIBE
packet_id = body[:2]
filters = _parse_subscribe(body[2:])
with self._lock:
for entry in self._clients:
if entry[0] is sock:
entry[1].extend(filters)
self.subscriptions.extend(filters)
sock.sendall(b"\x90" + bytes([2 + len(filters)]) + packet_id
+ b"\x00" * len(filters))
elif packet_type == 3: # PUBLISH(客户端发的)→ 转投订阅者
topic, payload = _parse_publish(body)
self.published.append(topic)
self._send(topic, payload, ignore_subscriptions=False)
elif packet_type == 12: # PINGREQ
sock.sendall(b"\xd0\x00")
elif packet_type == 14: # DISCONNECT
return
except OSError:
return
finally:
with self._lock:
self._clients = [entry for entry in self._clients if entry[0] is not sock]
try:
sock.close()
except OSError:
pass
def _read_length(sock):
"""MQTT 的可变长度字段(剩下长度)。"""
multiplier = 1
value = 0
for _ in range(4):
byte = sock.recv(1)
if not byte:
raise OSError("连接已关闭")
digit = byte[0]
value += (digit & 0x7F) * multiplier
if not digit & 0x80:
break
multiplier *= 128
return value, multiplier
def _read_exact(sock, count):
chunks = []
remaining = count
while remaining > 0:
chunk = sock.recv(remaining)
if not chunk:
raise OSError("连接已关闭")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _parse_subscribe(body):
"""SUBSCRIBE 载荷:包 id(2) + 若干个 [主题长度(2) + 主题 + QoS(1)]。"""
filters = []
offset = 0
while offset + 2 <= len(body):
length = struct.unpack(">H", body[offset:offset + 2])[0]
start = offset + 2
filters.append(body[start:start + length].decode("utf-8"))
offset = start + length + 1 # 跳过 QoS 字节(别忘了,否则后面全错位)
return filters
def _parse_publish(body):
length = struct.unpack(">H", body[:2])[0]
topic = body[2:2 + length].decode("utf-8")
return topic, body[2 + length:]
def _publish_packet(topic, payload):
encoded = topic.encode("utf-8")
body = struct.pack(">H", len(encoded)) + encoded + payload
return b"\x30" + _encode_length(len(body)) + body
def _encode_length(length):
out = bytearray()
while True:
digit = length % 128
length //= 128
if length:
digit |= 0x80
out.append(digit)
if not length:
return bytes(out)
def _topic_matches(topic_filter, topic):
"""主题过滤器匹配(支持 ``+`` 与末尾的 ``#``)。"""
filter_parts = topic_filter.split("/")
topic_parts = topic.split("/")
for index, part in enumerate(filter_parts):
if part == "#":
return True
if index >= len(topic_parts):
return False
if part != "+" and part != topic_parts[index]:
return False
return len(filter_parts) == len(topic_parts)
if __name__ == "__main__":
unittest.main()