#!/usr/bin/env python3 """svfs_client 纯标准库层的登录认证测试(不 import Qt)。 用本地 http.server 起一个假服务端(行为可用类属性调整),覆盖 auth.login / verify_token / logout 的成功与各类失败,以及 settings 新增键(auth_token / auth_user)的持久化。 """ import json import os import threading import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from helpers import VFSTestCase from svfs_client import auth, netutil from svfs_client import paths as client_paths from svfs_client import settings as client_settings class _AuthApiHandler(BaseHTTPRequestHandler): """假服务端:/api/login、/api/me、/api/logout;``mode`` 可注入异常行为。""" users = {"admin": "admin123"} sessions = {} mode = {} def _bearer(self): header = self.headers.get("Authorization") or "" return header[7:].strip() if header.startswith("Bearer ") else "" def _reply(self, code, payload): body = json.dumps(payload).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json(self): length = int(self.headers.get("Content-Length") or 0) try: return json.loads(self.rfile.read(length) or b"{}") except ValueError: return {} def do_POST(self): # noqa: N802 data = self._read_json() if self.path == "/api/login": if self.mode.get("login") == "badjson": raw = b"this is not json" self.send_response(200) self.send_header("Content-Length", str(len(raw))) self.end_headers() self.wfile.write(raw) return username = str(data.get("username") or "") if username and self.users.get(username) == data.get("password"): token = f"tok-{username}-{len(self.sessions)}" self.sessions[token] = { "username": username, "login_time": "2026-09-15T10:00:00", } if self.mode.get("login") == "notoken": self._reply(200, {"ok": True, "username": username}) else: self._reply(200, {"ok": True, "token": token, "username": username, "login_time": "2026-09-15T10:00:00"}) else: self._reply(401, {"detail": "用户名或密码不正确"}) elif self.path == "/api/logout": self.sessions.pop(self._bearer(), None) self._reply(200, {"ok": True}) else: self._reply(404, {"detail": "not found"}) def do_GET(self): # noqa: N802 if self.path == "/api/me": session = self.sessions.get(self._bearer()) if session: self._reply(200, {"ok": True, **session}) else: self._reply(401, {"detail": "未登录或登录已过期"}) else: self._reply(404, {"detail": "not found"}) def log_message(self, *args): # 静音 pass class AuthApiTest(VFSTestCase): """auth 模块对真实 HTTP 的行为(经本地假服务端)。""" def setUp(self): super().setUp() _AuthApiHandler.sessions.clear() _AuthApiHandler.mode.clear() self.server = ThreadingHTTPServer(("127.0.0.1", 0), _AuthApiHandler) thread = threading.Thread(target=self.server.serve_forever, daemon=True) thread.start() self.addCleanup(self.server.shutdown) self.addCleanup(self.server.server_close) self.base = f"http://127.0.0.1:{self.server.server_address[1]}" def test_login_success_normalizes_base_url(self): session = auth.login(self.base + "/", "admin", "admin123") self.assertTrue(session["token"]) self.assertEqual(session["username"], "admin") self.assertEqual(session["base_url"], self.base) self.assertEqual(session["login_time"], "2026-09-15T10:00:00") def test_login_bad_password(self): with self.assertRaises(auth.AuthError) as caught: auth.login(self.base, "admin", "wrong") self.assertIn("不正确", str(caught.exception)) def test_login_requires_credentials(self): with self.assertRaises(auth.AuthError): auth.login(self.base, "", "admin123") with self.assertRaises(auth.AuthError): auth.login(self.base, "admin", "") def test_login_rejects_invalid_base_url(self): with self.assertRaises(auth.AuthError): auth.login("ftp://nope", "admin", "admin123") def test_login_invalid_response_body(self): _AuthApiHandler.mode["login"] = "badjson" with self.assertRaises(auth.AuthError) as caught: auth.login(self.base, "admin", "admin123") self.assertIn("响应无效", str(caught.exception)) def test_login_response_without_token(self): _AuthApiHandler.mode["login"] = "notoken" with self.assertRaises(auth.AuthError) as caught: auth.login(self.base, "admin", "admin123") self.assertIn("token", str(caught.exception)) def test_login_network_error(self): # 停掉服务器再请求:连接层失败 → 「无法连接」 base = self.base self.server.shutdown() with self.assertRaises(auth.AuthError) as caught: auth.login(base, "admin", "admin123") self.assertIn("无法连接服务器", str(caught.exception)) def test_verify_token_roundtrip(self): token = auth.login(self.base, "admin", "admin123")["token"] result = auth.verify_token(self.base, token) self.assertEqual(result["username"], "admin") self.assertEqual(result["base_url"], self.base) self.assertEqual(result["login_time"], "2026-09-15T10:00:00") def test_verify_token_rejects_garbage(self): with self.assertRaises(auth.AuthError): auth.verify_token(self.base, "not-a-token") with self.assertRaises(auth.AuthError) as caught: auth.verify_token(self.base, " ") self.assertIn("凭据", str(caught.exception)) def test_logout_invalidates_token(self): token = auth.login(self.base, "admin", "admin123")["token"] self.assertTrue(auth.verify_token(self.base, token)) auth.logout(self.base, token) with self.assertRaises(auth.AuthError): auth.verify_token(self.base, token) def test_logout_is_best_effort(self): # 服务器已停 / token 无效都不抛 self.server.shutdown() auth.logout(self.base, "whatever") auth.logout(self.base, "") class SettingsAuthTest(VFSTestCase): """settings 新增键的缺省与持久化。""" def test_defaults_include_auth_keys(self): base = client_paths.ensure_layout(self.path("data")) data = client_settings.load(base) self.assertEqual(data["auth_token"], "") self.assertEqual(data["auth_user"], "") def test_auth_keys_roundtrip(self): base = client_paths.ensure_layout(self.path("data")) data = client_settings.load(base) data.update({"base_url": "http://10.0.0.5:9000", "auth_token": "tok", "auth_user": "admin"}) client_settings.save(base, data) loaded = client_settings.load(base) self.assertEqual(loaded["auth_token"], "tok") self.assertEqual(loaded["auth_user"], "admin") class NetUtilTest(VFSTestCase): """loopback 请求的代理绕过:系统代理黑掉 localhost 是"接口一直在 请求中"的经典原因。""" class _OkHandler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 body = b'{"ok": true}' self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass def setUp(self): super().setUp() # 全局策略与 opener 缓存跨用例残留,每个用例复位 self.addCleanup(netutil.configure, "", "") netutil.configure("", "") def test_is_loopback(self): for url in ("http://localhost:8000/a", "http://127.0.0.1:8000/", "http://[::1]:8000/", "http://api.localhost/x", "HTTP://LOCALHOST/x"): self.assertTrue(netutil.is_loopback(url), url) for url in ("http://example.com/", "http://192.168.1.5:8000/", "not a url"): self.assertFalse(netutil.is_loopback(url), url) def test_loopback_bypasses_system_proxy(self): import urllib.error import urllib.request server = ThreadingHTTPServer(("127.0.0.1", 0), self._OkHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() self.addCleanup(server.shutdown) self.addCleanup(server.server_close) base = f"http://127.0.0.1:{server.server_address[1]}" # 模拟系统代理指向一个死端口:默认 urlopen 会失败(走代理), # netutil 对 loopback 强制直连 → 成功 old_http = os.environ.get("http_proxy") old_https = os.environ.get("https_proxy") os.environ["http_proxy"] = "http://127.0.0.1:9" os.environ["https_proxy"] = "http://127.0.0.1:9" try: # 代理环境变量已生效(默认 urllib 会把请求发往该代理)。 # 注意:这里只查 getproxies(),绝不能真用默认 urlopen 发请求—— # 那会把"含死代理的 opener"缓存进全局,殃及之后所有测试模块。 proxies = urllib.request.getproxies() self.assertEqual(proxies.get("http"), "http://127.0.0.1:9") # netutil 对 loopback 强制直连 → 不受代理影响 fresh = urllib.request.Request(base + "/ok") with netutil.urlopen(fresh, timeout=3) as response: self.assertEqual(response.status, 200) finally: if old_http is None: os.environ.pop("http_proxy", None) else: os.environ["http_proxy"] = old_http if old_https is None: os.environ.pop("https_proxy", None) else: os.environ["https_proxy"] = old_https class _FlakyHandler(BaseHTTPRequestHandler): """前 ``fail_times`` 次请求回 503,之后回 200;记录收到的每个请求。 模块级而不是塞在测试类里:嵌套类的方法体里看不到外层类作用域, 引用它得写全限定名,很别扭。 """ fail_times = 0 hits = [] def _handle(self): # 先把请求体读完再应答:不读就回错误码会让客户端在写 body 时 # 撞上连接被重置(表现为 ConnectionResetError 而不是 HTTPError) length = int(self.headers.get("Content-Length") or 0) if length: self.rfile.read(length) _FlakyHandler.hits.append((self.command, self.path)) if len(_FlakyHandler.hits) <= _FlakyHandler.fail_times: self.send_response(503) self.send_header("Content-Length", "0") self.end_headers() return body = b'{"ok": true}' self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) do_GET = _handle do_HEAD = _handle do_POST = _handle def log_message(self, *args): pass class NetPolicyTest(VFSTestCase): """代理/CA 配置、幂等重试与设置落点(P1-5 / P1-7)。""" def setUp(self): super().setUp() self.addCleanup(netutil.configure, "", "") netutil.configure("", "") _FlakyHandler.hits = [] _FlakyHandler.fail_times = 0 def _serve(self, handler=None): server = ThreadingHTTPServer(("127.0.0.1", 0), handler or _FlakyHandler) threading.Thread(target=server.serve_forever, daemon=True).start() self.addCleanup(server.shutdown) self.addCleanup(server.server_close) return server, f"http://127.0.0.1:{server.server_address[1]}" @staticmethod def _noop_sleep(_seconds): """不真等退避时间(真实退避从 0.3s 起,两次重试就该等 0.9s)。""" # ---- 重试策略 --------------------------------------------------- def test_idempotent_get_retries_until_success(self): import urllib.request _server, base = self._serve() _FlakyHandler.fail_times = 2 # 503、503、200 request = urllib.request.Request(base + "/api/x") with netutil.open_with_retry(request, timeout=5, retries=2, sleep=self._noop_sleep) as response: self.assertEqual(response.status, 200) self.assertEqual(len(_FlakyHandler.hits), 3) def test_post_is_never_retried(self): import urllib.error import urllib.request _server, base = self._serve() _FlakyHandler.fail_times = 99 request = urllib.request.Request(base + "/api/order", data=b"{}", method="POST") with self.assertRaises(urllib.error.HTTPError) as caught: netutil.open_with_retry(request, timeout=5, retries=3, sleep=self._noop_sleep) self.assertEqual(caught.exception.code, 503) # 自动重发 POST 可能把"下单"变成两单:只允许发一次 self.assertEqual(len(_FlakyHandler.hits), 1) def test_retries_exhausted_raises_last_error(self): import urllib.error import urllib.request _server, base = self._serve() _FlakyHandler.fail_times = 99 request = urllib.request.Request(base + "/api/x") with self.assertRaises(urllib.error.HTTPError): netutil.open_with_retry(request, timeout=5, retries=2, sleep=self._noop_sleep) self.assertEqual(len(_FlakyHandler.hits), 3) # 1 次 + 2 次重试 def test_non_retryable_status_is_not_retried(self): import urllib.error import urllib.request class _NotFound(BaseHTTPRequestHandler): hits = 0 def do_GET(self): # noqa: N802 _NotFound.hits += 1 self.send_response(404) self.send_header("Content-Length", "0") self.end_headers() def log_message(self, *args): pass _server, base = self._serve(_NotFound) request = urllib.request.Request(base + "/api/x") with self.assertRaises(urllib.error.HTTPError): netutil.open_with_retry(request, timeout=5, retries=3, sleep=self._noop_sleep) self.assertEqual(_NotFound.hits, 1) # 404 重试没有意义 def test_transport_error_is_retried(self): """连接层失败(拒绝连接/DNS/超时)也重试——服务器刚起来那几秒。""" import urllib.error import urllib.request attempts = [] class _Response: status = 200 def __enter__(self): return self def __exit__(self, *exc): return False def flaky(request, *, timeout): attempts.append(1) if len(attempts) < 3: raise urllib.error.URLError("connection refused") return _Response() original = netutil.urlopen netutil.urlopen = flaky self.addCleanup(setattr, netutil, "urlopen", original) request = urllib.request.Request("http://127.0.0.1:1/api/x") with netutil.open_with_retry(request, timeout=5, retries=2, sleep=self._noop_sleep) as response: self.assertEqual(response.status, 200) self.assertEqual(len(attempts), 3) # ---- 代理与 CA --------------------------------------------------- def test_explicit_proxy_for_remote_and_loopback_stays_direct(self): netutil.configure("http://proxy.corp:3128") remote = {type(h).__name__: h for h in netutil._opener(False).handlers} self.assertEqual( remote["ProxyHandler"].proxies, {"http": "http://proxy.corp:3128", "https": "http://proxy.corp:3128"}, ) # loopback 即使配了代理也直连:opener 里干脆没有代理handler # (ProxyHandler({}) 不生成 *_open 方法,build_opener 会丢掉它) local = {type(h).__name__ for h in netutil._opener(True).handlers} self.assertNotIn("ProxyHandler", local) def test_missing_ca_file_is_reported_and_not_applied(self): error = netutil.configure("", self.path("nope.pem")) self.assertIsNotNone(error) self.assertIn("不存在", error) self.assertEqual(netutil.current(), ("", "")) # 没生效(不假装配上了) def test_ca_bundle_valid_pem_installs_https_handler(self): import ssl source = ssl.get_default_verify_paths().cafile if not source or not os.path.isfile(source): self.skipTest("本机没有可复制的默认 CA 文件(Windows 无 cafile)") bundle = self.path("ca.pem") with open(source, "rb") as src, open(bundle, "wb") as dst: dst.write(src.read()) self.assertIsNone(netutil.configure("", bundle)) handlers = netutil._opener(False).handlers self.assertTrue(any(type(h).__name__ == "HTTPSHandler" for h in handlers)) def test_bad_ca_content_degrades_without_crashing(self): """CA 文件在但内容是垃圾:退回系统信任库,不能把客户端搞挂。""" from svfs_client import diagnostics bundle = self.path("junk.pem") with open(bundle, "w", encoding="utf-8") as handle: handle.write("这不是一个 PEM 证书\n") self.assertIsNone(netutil.configure("", bundle)) # 文件存在 → 配置接受 netutil._opener(False) # 构建 opener 不抛(退回系统信任库) self.assertIn("CA 证书无法加载", "\n".join(diagnostics.recent())) def test_apply_settings_reads_settings_json(self): base = client_paths.ensure_layout(self.path("data")) data = client_settings.load(base) data["proxy"] = "10.0.0.9:8080" # 只写 host:port 也认 client_settings.save(base, data) self.assertIsNone(netutil.apply_settings(base)) self.assertEqual(netutil.current()[0], "http://10.0.0.9:8080") def test_settings_net_options_fall_back_on_bad_values(self): base = client_paths.ensure_layout(self.path("data")) self.assertEqual(client_settings.net_options(base), (client_settings.NET_TIMEOUT_DEFAULT, client_settings.NET_RETRIES_DEFAULT)) data = client_settings.load(base) data.update({"net_timeout": "很快", "net_retries": -3}) client_settings.save(base, data) # 坏配置不该让网络不可用:一律落回默认值 self.assertEqual(client_settings.net_options(base), (client_settings.NET_TIMEOUT_DEFAULT, client_settings.NET_RETRIES_DEFAULT)) data.update({"net_timeout": 0, "net_retries": 0}) client_settings.save(base, data) # 超时 0 没意义(会把工作线程钉死)→ 用默认;重试 0 = 明确不重试 self.assertEqual(client_settings.net_options(base), (client_settings.NET_TIMEOUT_DEFAULT, 0)) def test_bridge_reads_net_options_from_provider(self): """桥现读设置(provider 抛错时退回默认值,坏回调不该让桥瘫痪)。""" from svfs_client.bridge import NET_TIMEOUT, SvfsBridge bridge = SvfsBridge("com.example.demo", ["net"], self.path("data/storage.json"), base_url_provider=lambda: "http://127.0.0.1:1", net_options_provider=lambda: (3, 2)) self.addCleanup(bridge.shutdown) self.assertEqual(bridge._net_options(), (3, 2)) bridge._net_options_provider = lambda: 1 / 0 self.assertEqual(bridge._net_options(), (NET_TIMEOUT, 0)) if __name__ == "__main__": unittest.main()