Files
xiaobai-review/xiaobai-datahub/tests/test_admin.py
T
施工员andmultica-agent a4dbe2bcf8 fix(HEL-564): 数据中枢入口固定内网 IP,不再按主机名推导
- 主站桌面端/移动端「数据中枢」入口固定 http://192.168.200.11:8766/admin/
  (XIAOBAI_DATAHUB_URL 仍可覆盖):域名只反代 8765,推导出的 <域名>:8766 打不开,
  同时避免数据中枢被外网摸到
- 中枢 _review_url 不再从 Host 头推导,统一取 REVIEW_PUBLIC_URL,
  默认 http://192.168.200.11:8765;compose 与 .env.example 示例值同步
- 补回归测试:Host 为域名且未配 REVIEW_PUBLIC_URL 时登录链接仍为固定内网地址
- 文档写明 Cookie 按门牌区分的边界与部署说明

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 15:23:45 +08:00

225 lines
9.4 KiB
Python

from __future__ import annotations
import io
import json
import logging
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.httpapp import make_handler
from datahub.hub import Hub
from datahub.logutil import JsonFormatter
from datahub.settings import DEFAULT_REVIEW_PUBLIC_URL, Settings
from tests.fixtures import StubSiteAuth, fake_transport
class AdminTests(unittest.TestCase):
"""HEL-560: the console has no accounts — it rides the review site session.
Every case here drives the console the way a browser does: the review
site's `xiaobai_session` cookie plus the stateless CSRF token derived from
it. There is no console login endpoint left to exercise.
"""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="z" * 32,
tushare_token="real-tushare-token-abcdef",
db_path=Path(self.tmp.name) / "hub.db",
scheduler_enabled=False,
review_public_url="http://127.0.0.1:8765",
)
self.site_auth = StubSiteAuth()
self.hub = Hub(
settings,
adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport),
site_auth=self.site_auth,
)
handler = make_handler(self.hub)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
self.cookie = f"xiaobai_session={StubSiteAuth.SESSION}"
self.csrf = self.site_auth.csrf_token(StubSiteAuth.SESSION)
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.tmp.cleanup()
def _json(self, path, method="GET", body=None, cookie="", csrf=""):
data = None if body is None else json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if cookie:
headers["Cookie"] = cookie
if csrf:
headers["X-CSRF-Token"] = csrf
req = Request(self.base + path, data=data, headers=headers, method=method)
with urlopen(req, timeout=5) as resp:
set_cookie = resp.headers.get("Set-Cookie", "")
return resp.status, json.loads(resp.read().decode()), set_cookie
def _admin(self, path, method="GET", body=None):
return self._json(path, method, body, cookie=self.cookie, csrf=self.csrf)
def test_session_reports_the_site_account_and_a_csrf_token(self) -> None:
status, body, _ = self._admin("/admin/api/session")
self.assertEqual(status, 200)
self.assertTrue(body["authenticated"])
self.assertTrue(body["is_admin"])
self.assertEqual(body["username"], "admin")
self.assertEqual(body["csrf"], self.csrf)
def test_anonymous_session_probe_returns_the_site_login_url(self) -> None:
with self.assertRaises(HTTPError) as ctx:
self._json("/admin/api/session")
self.assertEqual(ctx.exception.code, 401)
payload = json.loads(ctx.exception.read().decode())
self.assertFalse(payload["authenticated"])
self.assertEqual(payload["login_url"], "http://127.0.0.1:8765/login/")
def test_login_url_ignores_the_browser_host_header(self) -> None:
"""HEL-564: the login link is the fixed intranet address.
Deriving it from Host sent admins to <域名>:8765 under a domain that
only proxies the review site, so the console now sticks to
REVIEW_PUBLIC_URL (default DEFAULT_REVIEW_PUBLIC_URL).
"""
self.hub.settings.review_public_url = ""
req = Request(self.base + "/admin/api/session", headers={"Host": "xiaobaifupan.com"})
with self.assertRaises(HTTPError) as ctx:
urlopen(req, timeout=5)
payload = json.loads(ctx.exception.read().decode())
self.assertEqual(payload["login_url"], f"{DEFAULT_REVIEW_PUBLIC_URL}/login/")
def test_non_admin_site_accounts_are_refused(self) -> None:
self.site_auth.add_session("member-session", StubSiteAuth.MEMBER)
with self.assertRaises(HTTPError) as ctx:
self._json("/admin/api/sources", cookie="xiaobai_session=member-session")
self.assertEqual(ctx.exception.code, 403)
payload = json.loads(ctx.exception.read().decode())
self.assertEqual(payload["error"]["code"], "PERMISSION_DENIED")
self.assertFalse(payload["is_admin"])
def test_writes_require_the_derived_csrf_token(self) -> None:
with self.assertRaises(HTTPError) as ctx:
self._json(
"/admin/api/credentials/tushare",
"POST",
{"tushare_token": "new-token-1234"},
cookie=self.cookie,
)
self.assertEqual(ctx.exception.code, 401)
def test_logout_ends_the_site_session(self) -> None:
status, body, _ = self._admin("/admin/api/logout", "POST", {})
self.assertEqual(status, 200)
self.assertEqual(body["login_url"], "http://127.0.0.1:8765/login/")
self.assertIn(StubSiteAuth.SESSION, self.site_auth.logged_out)
def test_stored_credentials_are_masked_in_the_sources_view(self) -> None:
_, sources, _ = self._admin("/admin/api/sources")
blob = json.dumps(sources)
self.assertNotIn("real-tushare-token-abcdef", blob)
credential = sources["items"][0]["credential"]
self.assertTrue(credential["configured"])
self.assertTrue("****" in str(credential["last4"]) or str(credential["last4"]).endswith("cdef"))
def test_tushare_credential_write_hot_swaps_the_live_adapter(self) -> None:
status, _, _ = self._admin(
"/admin/api/credentials/tushare", "POST", {"tushare_token": "rotated-token-9876"}
)
self.assertEqual(status, 200)
self.assertEqual(self.hub.adapter.token, "rotated-token-9876")
self.assertEqual(self.hub.auth.load_credential("tushare_token"), "rotated-token-9876")
_, sources, _ = self._admin("/admin/api/sources")
self.assertNotIn("rotated-token-9876", json.dumps(sources))
def test_ifind_credential_write_hot_swaps_the_live_adapter(self) -> None:
status, _, _ = self._admin(
"/admin/api/credentials/ifind",
"POST",
{"ifind_refresh_token": "refresh-abcd", "ifind_access_token": "access-efgh"},
)
self.assertEqual(status, 200)
self.assertEqual(self.hub.auth.load_credential("ifind_refresh_token"), "refresh-abcd")
self.assertEqual(self.hub.auth.load_credential("ifind_access_token"), "access-efgh")
def test_rollback_confirms_the_site_account_password(self) -> None:
with self.assertRaises(HTTPError) as ctx:
self._admin(
"/admin/api/rollback",
"POST",
{
"dataset": "daily",
"trade_date": "20240902",
"password": "wrong",
"confirm": "daily:20240902",
},
)
self.assertEqual(ctx.exception.code, 401)
def test_invalid_json_does_not_log_request_body_secrets(self) -> None:
secret = "SuperSecretPass1!"
token = "hub-token-should-not-leak"
raw = json.dumps({"password": secret, "token": token, "username": "admin"}) + "{not-json"
stream = io.StringIO()
logger = logging.getLogger("datahub")
handler = logging.StreamHandler(stream)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
previous_level = logger.level
logger.setLevel(logging.DEBUG)
try:
req = Request(
self.base + "/admin/api/credentials/tushare",
data=raw.encode("utf-8"),
headers={
"Content-Type": "application/json",
"Cookie": self.cookie,
"X-CSRF-Token": self.csrf,
},
method="POST",
)
with self.assertRaises(HTTPError) as ctx:
urlopen(req, timeout=5)
body = ctx.exception.read().decode("utf-8")
self.assertEqual(ctx.exception.code, 400)
self.assertNotIn(secret, body)
self.assertNotIn(token, body)
blob = stream.getvalue() + body
self.assertNotIn(secret, blob)
self.assertNotIn(token, blob)
self.assertNotIn(raw, blob)
finally:
logger.removeHandler(handler)
logger.setLevel(previous_level)
def test_json_formatter_drops_decode_error_document(self) -> None:
secret = "ParseSecretTokenXYZ"
formatter = JsonFormatter()
logger = logging.getLogger("datahub.test")
record = logger.makeRecord(
"datahub.test", logging.ERROR, __file__, 1, "parse failed", (), None
)
try:
json.loads('{"password": "%s"}{' % secret)
except json.JSONDecodeError as exc:
record.exc_info = (type(exc), exc, exc.__traceback__)
blob = formatter.format(record)
self.assertNotIn(secret, blob)
self.assertIn("invalid json", blob)
if __name__ == "__main__":
unittest.main()