Files
svfs/tests/test_client_credentials.py
T
chakcy f62704ca0e feat: 插件化桌面客户端 svfs-client(含凭据/会话/网络/日志的脚手架补齐)
一个插件 = 一个完整的 .svfs 包:客户端把插件前端经 svfs://<插件id>/ 直接加载
进 WebView(不落盘、不起端口、不解包),每个插件一个独立 origin(浏览器按
origin 隔离 localStorage)与独立键值存储;快照即版本——升级 = 派生快照的增量
打包,回滚 = 把注册表指针切回旧快照,包文件一个字节不用动。svfs_client 复用
svfs_studio 的主题与基础部件,故两个包一并入库。

按评估结论补齐的 P0 三块:

* 会话:桥在"宿主注入的 token 被 401 拒绝"时通知壳层,提示一次并给出一键
  「重新登录…」;服务器状态灯把「在线但登录失效」(黄)与「离线」(红)分开,
  不再把凭据失效误报成网络不可达;
* 凭据:新增 credentials.py——token 不再明文进 settings.json。Windows 走 DPAPI
  (密文只有当前用户在本机解得开),其它平台走系统凭据库(可选依赖 keyring),
  都没有才明文兜底并在「设置 → 登录凭据」如实提示;令牌与签发它的 base_url
  绑定(换服务器不会把旧令牌发出去);老字段 auth_token 启动时自动迁移;
* 强登录链路保持:启动动画 → 静默校验 → 登录窗 → 主窗口,不可达时离线宽限。

P1/P2:net 超时与重试(只对幂等方法,避免重发 POST 变两单)可配、代理与
自签 CA 可配(loopback 永远直连);诊断日志滚动落盘(1MB×3,可关)——崩溃后
还能回看现场;客户端自更新检查(静态 <base_url>/client/manifest.json,缺失
即静默);插件清单可选 net_hosts 白名单;notify 改走系统托盘(无托盘降级状态栏);
设置界面新增网络 / 登录凭据 / 诊断日志三块。

examples/:示例服务端(FastAPI,含登录、上传即 publish、客户端版本接口)、
Vue 3 插件模板(带浏览器版宿主模拟器)、svfs-plugins-root/ 为模板配套的演示
插件实体。测试:tests/test_client_*.py(纯层 + Qt 层 + 一次真实 WebEngine 冒烟)、
tests/test_studio_*.py、tests/test_plugin_server.py。
2026-09-16 17:15:48 +08:00

218 lines
9.2 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
"""登录凭据的安全存储测试(纯标准库层)。
重点是三件事:
1. **不再明文落盘**:settings.json 里不出现 token,凭据文件里也不是原文;
2. **强度如实汇报**:DPAPI → 受保护;两个加密后端都不可用 → ``plain``
且 ``is_secure()`` 为 False(界面据此提示);
3. **换服务器不串凭据**:令牌只交还给签发它的那个 base_url。
"""
import base64
import json
import os
import sys
import unittest
from helpers import VFSTestCase
from svfs_client import credentials, paths as client_paths, settings
assert sys.modules.get("helpers") is not None
class _FakeKeyring:
"""keyring 的最小替身(够 credentials 用:后端名 + 三件套)。"""
def __init__(self):
self.store = {}
def get_keyring(self):
return "fake-keyring"
def set_password(self, service, username, password):
self.store[(service, username)] = password
def get_password(self, service, username):
return self.store.get((service, username))
def delete_password(self, service, username):
self.store.pop((service, username), None)
class CredentialsTest(VFSTestCase):
BASE_URL = "http://10.0.0.5:8000"
def setUp(self):
super().setUp()
self.base = client_paths.ensure_layout(self.path("data"))
# ---- 通用(任何后端都要满足) ------------------------------------
def test_roundtrip_and_base_url_gate(self):
credentials.save(self.base, "tok-1", base_url=self.BASE_URL,
username="admin")
self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-1")
# 换服务器:旧令牌不交还(它只对签发它的服务器有效)
self.assertEqual(credentials.load(self.base, "http://other:9000"), "")
# 不指定服务器时不设门槛(调用方明确表示"就是当前这个")
self.assertEqual(credentials.load(self.base), "tok-1")
def test_token_never_in_settings(self):
settings.save(self.base, {**settings.DEFAULTS,
"base_url": self.BASE_URL,
"auth_user": "admin"})
credentials.save(self.base, "tok-secret", base_url=self.BASE_URL)
raw = open(client_paths.settings_file(self.base), encoding="utf-8").read()
self.assertNotIn("tok-secret", raw)
def test_empty_token_clears(self):
credentials.save(self.base, "tok-1", base_url=self.BASE_URL)
credentials.save(self.base, "", base_url=self.BASE_URL)
self.assertEqual(credentials.load(self.base, self.BASE_URL), "")
self.assertFalse(os.path.exists(client_paths.credentials_file(self.base)))
def test_clear_removes_credential_file(self):
credentials.save(self.base, "tok-1", base_url=self.BASE_URL)
self.assertTrue(os.path.exists(client_paths.credentials_file(self.base)))
credentials.clear(self.base)
self.assertEqual(credentials.load(self.base, self.BASE_URL), "")
self.assertFalse(os.path.exists(client_paths.credentials_file(self.base)))
def test_corrupt_file_reads_as_logged_out(self):
credentials.save(self.base, "tok-1", base_url=self.BASE_URL)
with open(client_paths.credentials_file(self.base), "w",
encoding="utf-8") as handle:
handle.write("{ 不是 JSON")
self.assertEqual(credentials.load(self.base, self.BASE_URL), "")
def test_migrate_moves_settings_token(self):
settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL,
"auth_token": "tok-legacy",
"auth_user": "admin"})
self.assertEqual(credentials.migrate(self.base),
credentials.backend(self.base))
self.assertEqual(credentials.load(self.base, self.BASE_URL),
"tok-legacy")
# 原字段清空(落盘的内容里不再有 token)
self.assertEqual(settings.load(self.base)["auth_token"], "")
raw = open(client_paths.settings_file(self.base), encoding="utf-8").read()
self.assertNotIn("tok-legacy", raw)
# 幂等:再迁一次什么也不做
self.assertEqual(credentials.migrate(self.base), "")
def test_migrate_without_legacy_token_is_noop(self):
settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL})
self.assertEqual(credentials.migrate(self.base), "")
class PlainBackendTest(CredentialsTest):
"""两个加密后端都不可用时的兜底路径(仍是原来的通用断言)。"""
def setUp(self):
super().setUp()
# 关掉 DPAPI 与 keyring:只剩 plain
self._patch("_dpapi_usable", lambda: False)
self._patch("_keyring_usable", lambda: False)
self._patch("_keyring", lambda: None)
def _patch(self, name, value):
original = getattr(credentials, name)
setattr(credentials, name, value)
self.addCleanup(setattr, credentials, name, original)
def test_reports_insecure(self):
self.assertEqual(credentials.backend(self.base), "plain")
self.assertFalse(credentials.is_secure(self.base))
self.assertIn("明文", credentials.describe(self.base))
def test_encryption_failure_degrades_instead_of_losing_token(self):
"""加密后端当场失败(DPAPI 被策略禁、keyring 后端挂)时降级明文,
但不能把用户刚登录拿到的令牌弄丢。"""
def boom(data, protect):
raise OSError("DPAPI 被组策略禁用")
self._patch("_dpapi_usable", lambda: True)
self._patch("_dpapi_call", boom)
self.assertEqual(
credentials.save(self.base, "tok-x", base_url=self.BASE_URL),
"plain")
self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-x")
class KeyringBackendTest(CredentialsTest):
"""keyring 可用(非 Windows 的常规路径)——文件里不该有令牌。"""
def setUp(self):
super().setUp()
self.fake = _FakeKeyring()
self._patch("_dpapi_usable", lambda: False)
self._patch("_keyring", lambda: self.fake)
self._patch("_keyring_usable", lambda: True)
def _patch(self, name, value):
original = getattr(credentials, name)
setattr(credentials, name, value)
self.addCleanup(setattr, credentials, name, original)
def test_token_lives_in_keyring_not_in_file(self):
self.assertEqual(credentials.backend(self.base), "keyring")
self.assertTrue(credentials.is_secure(self.base))
credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL)
self.assertEqual(credentials.load(self.base, self.BASE_URL),
"tok-keyring")
raw = open(client_paths.credentials_file(self.base),
encoding="utf-8").read()
self.assertNotIn("tok-keyring", raw)
self.assertEqual(
self.fake.store[("svfs-client", self.BASE_URL)], "tok-keyring")
def test_load_is_empty_when_keyring_backend_disappears(self):
credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL)
self._patch("_keyring", lambda: None)
self.assertEqual(credentials.load(self.base, self.BASE_URL), "")
def test_clear_removes_keyring_entry(self):
credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL)
credentials.clear(self.base)
self.assertEqual(self.fake.store, {})
@unittest.skipUnless(os.name == "nt", "DPAPI 只在 Windows 上存在")
class DpapiBackendTest(CredentialsTest):
"""Windows 默认后端:文件里是密文,且只有本机本用户能解。"""
def test_default_backend_is_dpapi(self):
self.assertEqual(credentials.backend(self.base), "dpapi")
self.assertTrue(credentials.is_secure(self.base))
self.assertIn("DPAPI", credentials.describe(self.base))
def test_stored_bytes_are_ciphertext(self):
credentials.save(self.base, "tok-secret-value", base_url=self.BASE_URL)
with open(client_paths.credentials_file(self.base), "rb") as handle:
raw = handle.read()
self.assertNotIn(b"tok-secret-value", raw)
record = json.loads(raw.decode("utf-8"))
self.assertEqual(record["backend"], "dpapi")
self.assertTrue(record["token"])
# 存的是密文的 base64,不是令牌本身
self.assertNotEqual(base64.b64decode(record["token"]),
b"tok-secret-value")
def test_tampered_ciphertext_reads_as_logged_out(self):
credentials.save(self.base, "tok-secret-value", base_url=self.BASE_URL)
file = client_paths.credentials_file(self.base)
record = json.load(open(file, encoding="utf-8"))
blob = bytearray(base64.b64decode(record["token"]))
blob[len(blob) // 2] ^= 0xFF # 篡改一个字节
record["token"] = base64.b64encode(bytes(blob)).decode("ascii")
with open(file, "w", encoding="utf-8") as handle:
json.dump(record, handle)
# 解不开就当没登录(不能崩,也不能返回垃圾)
self.assertEqual(credentials.load(self.base, self.BASE_URL), "")
if __name__ == "__main__":
unittest.main()