Files
svfs/tests/test_client_auth.py
T

503 lines
20 KiB
Python
Raw Normal View History

#!/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()