Files
xiaobai-review/xiaobai-datahub/tests/test_admin.py
T
施工员andmultica-agent 3eaa36a8d5 feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

数据中枢
- 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验
  主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站
- 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取
  /models,失败退回卡内手动录入)、会员管理与邀请码页
- 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量

自测
- 主站 verify_baseline 通过(498 项);数据中枢 235 项通过
- tools/verify_datahub_console.py 端到端跑通两服务真实对话;
  tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:44:09 +08:00

211 lines
8.7 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 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_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()