Files
svfs/tests/test_plugin_server.py
T
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

417 lines
19 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
"""examples/plugin_server.py 的最小验证(需要 fastapi + httpx,缺则整模块跳过)。
覆盖两条链路:
* API 本身:登录换 token、/api/ping(需 Bearer token)、上传 .svfs →
服务端 publish → 清单/对象可被 GET;
* **完整闭环**:经 HTTP 上传后,起一个真实 HTTP 服务吐插件目录,
用客户端的 ``installer.install_from_url`` 从服务端装回本地。
"""
import importlib.util
import json
import os
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import helpers
from helpers import VFSTestCase, quiet
try:
from fastapi.testclient import TestClient
HAVE_FASTAPI = True
except ImportError:
HAVE_FASTAPI = False
EXAMPLE_PATH = os.path.join(helpers.REPO_ROOT, "examples", "plugin_server.py")
if HAVE_FASTAPI:
from svfs_client import installer, registry
from svfs_client import paths as client_paths
from svfs_client import sample as client_sample
try:
# 自建最小 broker(见 test_client_mqtt)——端到端用例要一个真 broker,
# 但测试不应该依赖装 mosquitto
from test_client_mqtt import _MiniBroker, wait_until
HAVE_BROKER = True
except Exception:
HAVE_BROKER = False
_load_seq = [0]
def _load_example(plugins_dir):
"""以指定插件目录加载示例模块(StaticFiles 在 import 时建 mount)。"""
_load_seq[0] += 1
os.environ["SVFS_PLUGINS_DIR"] = plugins_dir
spec = importlib.util.spec_from_file_location(
f"plugin_server_example_{_load_seq[0]}", EXAMPLE_PATH
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
if HAVE_FASTAPI:
class PluginServerTest(VFSTestCase):
def setUp(self):
super().setUp()
self.plugins_dir = self.path("plugins-root")
self.mod = _load_example(self.plugins_dir)
self.client = TestClient(self.mod.app)
def _build_sample(self, name="sample.svfs"):
package = self.path(name)
with quiet():
client_sample.build(package)
return package
def _upload(self, plugin_id, pkg_path, token=None):
with open(pkg_path, "rb") as handle:
headers = {"X-SVFS-Token": token} if token is not None else {}
return self.client.post(
f"/plugins/{plugin_id}",
files={"file": ("plugin.svfs", handle.read())},
headers=headers,
)
def _login(self, username="admin", password="admin123"):
return self.client.post(
"/api/login", json={"username": username, "password": password}
)
def test_index_open_ping_requires_auth(self):
# /api/* 需要登录;服务索引保持开放
response = self.client.get("/api/ping")
self.assertEqual(response.status_code, 401)
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["plugins"], [])
def test_login_then_ping(self):
response = self._login()
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
self.assertEqual(body["username"], "admin")
self.assertTrue(body["token"])
headers = {"Authorization": f"Bearer {body['token']}"}
response = self.client.get("/api/ping", headers=headers)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["pong"])
self.assertEqual(response.json()["user"], "admin")
def test_login_rejects_bad_credentials(self):
response = self._login(password="wrong")
self.assertEqual(response.status_code, 401)
self.assertIn("不正确", response.json()["detail"])
def test_message_push_and_sync(self):
"""服务端主动推消息:进该用户的待收列表,客户端登录后能补齐。"""
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "message", "title": "备份完成", "body": "3 个包",
"level": "warn"})
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
# 主题按方案拼(组织位在第一段之后,多租户就靠它隔离)
self.assertEqual(body["topic"], "svfs/default/user/admin/message")
self.assertEqual(body["message"]["title"], "备份完成")
self.assertTrue(body["message"]["id"])
self.assertTrue(body["stored"])
# 没配 broker:如实说明是跳过了,不是假装发成功
self.assertIn("skipped", body["mqtt"])
# 补齐:默认全给;给了 since 只给更新的
response = self.client.get("/api/messages", headers=headers)
self.assertEqual(len(response.json()["messages"]), 1)
newest = response.json()["messages"][0]["time"]
response = self.client.get(f"/api/messages?since={newest}",
headers=headers)
self.assertEqual(response.json()["messages"], [])
# 发给别人的消息进别人的待收列表(自己的列表不受影响)
response = self.client.post("/api/push", headers=headers,
json={"title": "给别人的", "user": "bob"})
self.assertEqual(response.json()["topic"],
"svfs/default/user/bob/message")
self.assertEqual(
len(self.client.get("/api/messages", headers=headers)
.json()["messages"]), 1)
def test_plugin_push_goes_to_plugin_topic_and_not_backlog(self):
"""插件消息走插件主题、**不进**待收列表(仪表盘数据补历史没意义)。"""
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.42}})
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
self.assertEqual(body["topic"],
"svfs/default/user/admin/plugin/com.svfs.demo")
self.assertFalse(body["stored"])
self.assertEqual(
self.client.get("/api/messages", headers=headers).json()["messages"],
[])
# 少了 plugin 字段:400,别猜
response = self.client.post("/api/push", headers=headers,
json={"kind": "plugin"})
self.assertEqual(response.status_code, 400)
def test_broadcast_and_org_announcement(self):
"""广播走组织主题;登录响应下发组织标识(多租户预留位)。"""
body = self._login().json()
self.assertEqual(body["org"], "default")
self.assertNotIn("mqtt", body) # 没配 broker 就不下发
headers = {"Authorization": f"Bearer {body['token']}"}
response = self.client.post("/api/broadcast", headers=headers,
json={"title": "全员通知", "body": "维护"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["topic"],
"svfs/default/broadcast/message")
def test_org_and_broker_announcement_follows_env(self):
"""服务端配了组织/broker:登录与 /api/me 都下发(客户端据此订阅)。"""
keys = ("SVFS_ORG", "SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_MQTT_TLS")
old = {key: os.environ.get(key) for key in keys}
def restore():
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(restore)
os.environ.update({"SVFS_ORG": "acme", "SVFS_MQTT_HOST": "broker.acme",
"SVFS_MQTT_PORT": "8883", "SVFS_MQTT_TLS": "true"})
body = self._login().json()
self.assertEqual(body["org"], "acme")
self.assertEqual(body["mqtt"], {"host": "broker.acme", "tls": True,
"port": 8883, "prefix": "svfs",
"org": "acme"})
headers = {"Authorization": f"Bearer {body['token']}"}
self.assertEqual(
self.client.get("/api/me", headers=headers).json()["org"], "acme")
# 主题里的组织段跟着变(多租户隔离就靠它)
response = self.client.post("/api/push", headers=headers,
json={"title": "x"})
self.assertEqual(response.json()["topic"],
"svfs/acme/user/admin/message")
def test_push_requires_auth(self):
self.assertEqual(
self.client.post("/api/push", json={"title": "x"}).status_code, 401)
self.assertEqual(self.client.get("/api/messages").status_code, 401)
@unittest.skipUnless(HAVE_BROKER, "需要 paho-mqtt 与自建 broker 助手")
def test_server_push_reaches_client_over_mqtt(self):
"""端到端:``/api/push`` → broker → 客户端 MQTT 通道 → 消息中心。
这条链路把三方接在一起验证:**broker 地址由登录响应下发**(客户端
不用手配)、服务端按主题方案发布、客户端用同一套主题方案订阅并让
消息落进总线。
"""
from svfs_client import settings as client_settings
from svfs_client.bus import MessageBus
from svfs_client.mqtt import MqttTransport
broker = _MiniBroker()
self.addCleanup(broker.close)
keys = ("SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_ORG")
old = {key: os.environ.get(key) for key in keys}
def restore():
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(restore)
os.environ.update({"SVFS_MQTT_HOST": "127.0.0.1",
"SVFS_MQTT_PORT": str(broker.port),
"SVFS_ORG": "acme"})
session = self._login().json()
self.assertEqual(session["mqtt"]["host"], "127.0.0.1")
self.assertEqual(session["org"], "acme")
# 客户端侧:与服务端同一套主题方案 + 同一个解析器
base = client_paths.ensure_layout(self.path("data"))
config = client_settings.mqtt_config(base, session)
self.assertTrue(config["enabled"])
self.assertEqual(config["org"], "acme")
bus = MessageBus(org=config["org"], user=session["username"])
transport = MqttTransport(config, on_message=bus.deliver)
self.addCleanup(transport.stop)
transport.start()
self.assertTrue(wait_until(lambda: transport.is_connected()),
"客户端没有连上 broker")
# 服务端推一条 → 经 broker 到客户端
headers = {"Authorization": f"Bearer {session['token']}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "message", "title": "备份完成", "body": "3 个包"})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(wait_until(lambda: len(bus.log) == 1),
"推送没有到达客户端,消息中心是空的")
message = bus.log.items()[0]
self.assertEqual(message["title"], "备份完成")
self.assertEqual(message["scope"], "user")
# 插件消息走另一条通道:不进消息中心,只进该插件的队列
response = self.client.post("/api/push", headers=headers, json={
"kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.5}})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(
wait_until(lambda: bus.pending("com.svfs.demo") == 1),
"插件消息没有到达插件队列")
self.assertEqual(len(bus.log), 1)
# 组织广播:进消息中心并带 broadcast 标记
response = self.client.post("/api/broadcast", headers=headers,
json={"title": "全员通知"})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(wait_until(lambda: len(bus.log) == 2))
self.assertEqual(bus.log.items()[1]["scope"], "broadcast")
def test_me_and_logout(self):
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.get("/api/me", headers=headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["username"], "admin")
response = self.client.post("/api/logout", headers=headers)
self.assertEqual(response.status_code, 200)
# token 已作废
response = self.client.get("/api/me", headers=headers)
self.assertEqual(response.status_code, 401)
def test_custom_users_env(self):
os.environ["SVFS_USERS"] = "alice:pw1, bob: pw2"
try:
response = self._login() # 默认演示账号被关闭
self.assertEqual(response.status_code, 401)
response = self._login("alice", "pw1")
self.assertEqual(response.status_code, 200)
response = self._login("bob", "pw2") # 值两侧空白被容忍
self.assertEqual(response.status_code, 200)
finally:
del os.environ["SVFS_USERS"]
def test_upload_then_serve(self):
package = self._build_sample()
response = self._upload(client_sample.PLUGIN_ID, package)
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
self.assertTrue(body["ok"])
self.assertEqual(body["id"], client_sample.PLUGIN_ID)
self.assertEqual(body["version"], "1.0.0")
# 服务列表与 manifest 可取
self.assertEqual(self.client.get("/").json()["plugins"],
[client_sample.PLUGIN_ID])
response = self.client.get(f"/{client_sample.PLUGIN_ID}/manifest.json")
self.assertEqual(response.status_code, 200)
manifest = response.json()
self.assertEqual(manifest["format"], "svfs-objects")
# 任意一个 blob 也能按 hash 取到
blob_hash = next(
entry["hash"] for entry in manifest["entries"].values()
if "hash" in entry
)
response = self.client.get(
f"/{client_sample.PLUGIN_ID}/blobs/{blob_hash[:2]}/{blob_hash}"
)
self.assertEqual(response.status_code, 200)
self.assertTrue(len(response.content) > 0)
def test_upload_rejects_mismatched_id(self):
package = self._build_sample()
response = self._upload("com.other.app", package)
self.assertEqual(response.status_code, 400)
self.assertIn("不一致", response.json()["detail"])
def test_upload_rejects_corrupt_package(self):
junk = self.path("junk.svfs")
with open(junk, "wb") as handle:
handle.write(b"not a sqlite file")
response = self._upload("com.other.app", junk)
self.assertEqual(response.status_code, 400)
def test_upload_token(self):
package = self._build_sample()
os.environ["SVFS_UPLOAD_TOKEN"] = "secret"
try:
response = self._upload(client_sample.PLUGIN_ID, package)
self.assertEqual(response.status_code, 403)
response = self._upload(client_sample.PLUGIN_ID, package,
token="wrong")
self.assertEqual(response.status_code, 403)
response = self._upload(client_sample.PLUGIN_ID, package,
token="secret")
self.assertEqual(response.status_code, 200)
finally:
del os.environ["SVFS_UPLOAD_TOKEN"]
class PluginServerEndToEndTest(VFSTestCase):
"""上传 → 服务端 publish → 真实 HTTP 服务 → 客户端 install_from_url。"""
def test_full_circle(self):
plugins_dir = self.path("plugins-root")
mod = _load_example(plugins_dir)
client = TestClient(mod.app)
package = self.path("sample.svfs")
with quiet():
client_sample.build(package)
with open(package, "rb") as handle:
response = client.post(
f"/plugins/{client_sample.PLUGIN_ID}",
files={"file": ("plugin.svfs", handle.read())},
)
self.assertEqual(response.status_code, 200, response.text)
# 用真实 HTTP 服务吐出插件目录,走客户端完整的远程安装链路
server = ThreadingHTTPServer(
("127.0.0.1", 0), partial(SimpleHTTPRequestHandler,
directory=plugins_dir)
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
base = f"http://127.0.0.1:{server.server_address[1]}"
data_base = client_paths.ensure_layout(self.path("data"))
with quiet():
entry = installer.install_from_url(
f"{base}/{client_sample.PLUGIN_ID}/manifest.json",
base=data_base,
)
self.assertEqual(entry["version"], "1.0.0")
self.assertEqual(entry["source"]["type"], "url")
self.assertEqual(
registry.load(data_base)["plugins"][client_sample.PLUGIN_ID]["version"],
"1.0.0",
)
if __name__ == "__main__":
unittest.main()