# -*- coding: utf-8 -*- """只读 Web 服务测试(离线): uv run python tests/test_web_server.py""" import http.client import json import os import sys import tempfile import threading import urllib.parse from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.stdout.reconfigure(encoding="utf-8") from reaction_board.config import ConfigStore # noqa: E402 from reaction_board.database import Database # noqa: E402 from reaction_board.web.server import create_server # noqa: E402 from tests.web_fixtures import build_db, write_config # noqa: E402 class RunningServer: def __init__(self, tmp: str, *, token: str = "", preview: bool = False): self.config = write_config(tmp, token=token) build_db(tmp) self.db = Database(Path(tmp) / "app.db") self.db.initialize() if preview: self.db.write(lambda conn: conn.execute( "INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')" )) self.store = ConfigStore(self.config) self.server = create_server( self.store, self.db, port=0, preview=preview, clock=lambda: 1_793_459_000, ) self.port = self.server.server_address[1] self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() def request(self, path: str, *, method: str = "GET", headers=None, body=None): conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) conn.request(method, path, body=body, headers=headers or {}) response = conn.getresponse() payload = response.read() result = response.status, dict(response.getheaders()), payload conn.close() return result def json(self, path: str, **kwargs): status, headers, payload = self.request(path, **kwargs) return status, headers, json.loads(payload.decode("utf-8")) def close(self): self.server.shutdown() self.server.server_close() self.db.close() def test_readonly_routes_and_static(): with tempfile.TemporaryDirectory() as tmp: running = RunningServer(tmp) try: status, _, health = running.json("/api/health") assert status == 200 and health["ok"] is True assert health["data"]["preview"] is False status, _, payload = running.json("/api/session") assert status == 200 assert payload["data"]["auth_enabled"] is False assert payload["data"]["authenticated"] is True status, _, payload = running.json("/api/candidates") assert status == 200 and payload["ok"] is True status, _, payload = running.json("/api/system") assert status == 200 and payload["ok"] is True status, _, payload = running.json("/api/about") assert status == 200 assert payload["data"]["schema_version"] == 7 assert payload["data"]["analytics"]["min_duration_seconds"] == 600 status, _, overview = running.json("/api/overview") assert status == 200 and overview["ok"] is True assert overview["data"]["data_date"] == "2026-10-31" status, headers, payload = running.request("/") assert status == 200 assert "text/html" in headers["Content-Type"] assert b"= "2026-10-18" for point in fans["recent_points"] ) assert all("delta" in point for point in fans["recent_points"]) status, _, payload = running.request( "/api/fans?start=2026-10-29&end=2026-10-31" ) assert status == 200 data = json.loads(payload)["data"] assert data["mode"] == "daily" assert data["dates"] == ["2026-10-29", "2026-10-30", "2026-10-31"] running.db.write(lambda conn: conn.execute( "INSERT OR REPLACE INTO up_stat" "(stat_date, mid, follower, following, captured_at, ok) " "VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)" )) status, _, payload = running.request( "/api/fans?start=2025-01-01&end=2026-10-31" ) assert status == 200 assert json.loads(payload)["data"]["mode"] == "monthly" status, headers, payload = running.request("/api/videos", method="HEAD") assert status == 200 and payload == b"" assert "Content-Length" in headers # 只读:所有业务写方法都被拒绝,且数据库不变 before = running.db.read(lambda conn: conn.execute( "SELECT COUNT(*) FROM candidate" ).fetchone()[0]) for method in ("POST", "PUT", "PATCH", "DELETE"): status, _, _ = running.request("/api/videos/101", method=method) assert status == 405, method after = running.db.read(lambda conn: conn.execute( "SELECT COUNT(*) FROM candidate" ).fetchone()[0]) assert after == before status, _, error = running.json("/api/videos?sort=bad") assert status == 400 and error["ok"] is False status, _, error = running.json("/api/videos?page=0") assert status == 400 status, _, error = running.json("/api/videos/abc") assert status == 400 status, _, error = running.json("/api/nope") assert status == 404 status, _, payload = running.request("/api/export.csv?sort=view") assert status == 404 status, headers, _ = running.request("/api/overview") assert headers["X-Content-Type-Options"] == "nosniff" assert "default-src 'self'" in headers["Content-Security-Policy"] assert headers["X-Frame-Options"] == "DENY" finally: running.close() def test_visit_endpoint_and_stats(): with tempfile.TemporaryDirectory() as tmp: running = RunningServer(tmp) try: ua = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/155.0"} collector = running.server.context.visits # 上报:返回 204(无响应体),可信对端 + X-Real-IP → 记录真实 IP 与归一化路由 status, _, body = running.request( "/api/visit", method="POST", headers={**ua, "Content-Type": "application/json", "X-Real-IP": "203.0.113.7"}, body=json.dumps({"path": "/video/123"}), ) assert status == 204 and body == b"" status, _, body = running.request( "/api/visit", method="POST", headers={**ua, "Content-Type": "application/json", "X-Real-IP": "203.0.113.7"}, body=json.dumps({"path": "/videos"}), ) assert status == 204 and body == b"" assert collector.pending_keys == 2 routes = {(row["ip"], row["path"]) for row in collector.pending_rows()} assert routes == {("203.0.113.7", "/video/:aid"), ("203.0.113.7", "/videos")} # 机器人 UA 被过滤,不计数 status, _, body = running.request( "/api/visit", method="POST", headers={"User-Agent": "Googlebot/2.1", "Content-Type": "application/json"}, body=json.dumps({"path": "/home"}), ) assert status == 204 assert collector.pending_keys == 2 # 超大请求体被拒绝(JSON 错误仍返回) status, _, _ = running.request( "/api/visit", method="POST", headers={**ua, "Content-Type": "application/json"}, body=json.dumps({"path": "/" + "x" * 4000}), ) assert status == 400 # GET 埋点端点 → 405;其他业务 POST 仍 405 status, _, _ = running.request("/api/visit") assert status == 405 status, _, _ = running.request("/api/videos/101", method="POST") assert status == 405 # 未 flush 前库里没有行,但 /api/stats 合并内存缓冲可见 assert running.db.read( lambda c: c.execute("SELECT COUNT(*) FROM visit").fetchone()[0] ) == 0 status, _, payload = running.json("/api/stats?days=7") assert status == 200 data = payload["data"] assert data["pending"] is True assert data["today_pv"] == 2 assert data["today_uv"] == 1 pages = {page["path"]: page for page in data["top_pages"]} assert pages["/video/:aid"]["pv"] == 1 assert pages["/videos"]["pv"] == 1 # 手动 flush 后落库,且 IP 正确 assert collector.flush() == 2 assert running.db.read( lambda c: c.execute("SELECT COUNT(*) FROM visit").fetchone()[0] ) == 2 ips = running.db.read( lambda c: [row[0] for row in c.execute("SELECT DISTINCT ip FROM visit")] ) assert ips == ["203.0.113.7"] status, _, payload = running.json("/api/stats?days=7") data = payload["data"] assert data["pending"] is False assert data["today_pv"] == 2 assert data["range"]["end"] == "2026-10-31" finally: running.close() def test_visit_query_path_drains_body(): """path 也可来自查询串;请求体必须被读走,避免 keep-alive 连接错位。""" with tempfile.TemporaryDirectory() as tmp: running = RunningServer(tmp) try: ua = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/155.0"} conn = http.client.HTTPConnection("127.0.0.1", running.port, timeout=5) conn.request( "POST", "/api/visit?path=/about", body=json.dumps({"path": "/ignored"}), headers={**ua, "Content-Type": "application/json"}, ) first = conn.getresponse() assert first.status == 204 and first.read() == b"" # 路径来自查询串而非请求体 assert running.server.context.visits.pending_rows()[0]["path"] == "/about" # 同一连接继续发请求:若上一次请求体未被读走,这里会解析错位 conn.request("GET", "/api/health") second = conn.getresponse() health = json.loads(second.read()) conn.close() assert second.status == 200 and health["ok"] is True finally: running.close() def test_token_auth_and_login(): with tempfile.TemporaryDirectory() as tmp: running = RunningServer(tmp, token="test-token") try: # 公共页面与公共 API 访客可见 status, _, body = running.json("/api/overview") assert status == 200 and body["ok"] is True status, _, body = running.json("/api/videos") assert status == 200 and body["ok"] is True status, _, body = running.json("/api/fans?days=7") assert status == 200 and body["ok"] is True status, _, payload = running.request("/") assert status == 200 and b"