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>
This commit is contained in:
@@ -113,3 +113,51 @@ def fake_transport(api_name: str, params: dict, fields: str):
|
||||
if limit_type:
|
||||
rows = [row for row in rows if str(row.get("limit_type") or "") == limit_type]
|
||||
return rows
|
||||
|
||||
|
||||
class StubSiteAuth:
|
||||
"""Stand-in for the review-site session bridge.
|
||||
|
||||
The console verifies operators against the review site over HTTP, which
|
||||
tests must not depend on. This stub answers from a fixed session -> user
|
||||
map and keeps the same stateless-HMAC CSRF contract as the real service, so
|
||||
tests exercise the console's own gate rather than the network hop.
|
||||
"""
|
||||
|
||||
SESSION = "site-session-token"
|
||||
ADMIN = {"id": 1, "username": "admin", "role": "admin", "is_admin": True}
|
||||
MEMBER = {"id": 2, "username": "member", "role": "user", "is_admin": False}
|
||||
|
||||
def __init__(self, password: str = "AdminPass1", secret: str = "stub-secret") -> None:
|
||||
self.password = password
|
||||
self.secret = secret
|
||||
self.sessions = {self.SESSION: dict(self.ADMIN)}
|
||||
self.logged_out: list[str] = []
|
||||
|
||||
def add_session(self, token: str, user: dict) -> None:
|
||||
self.sessions[token] = dict(user)
|
||||
|
||||
def verify(self, session_token: str):
|
||||
return self.sessions.get(session_token)
|
||||
|
||||
def csrf_token(self, session_token: str) -> str:
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
digest = hashlib.sha256(session_token.encode("utf-8")).digest()
|
||||
return hmac.new(self.secret.encode("utf-8"), digest, hashlib.sha256).hexdigest()
|
||||
|
||||
def check_csrf(self, session_token: str, supplied: str) -> bool:
|
||||
import hmac
|
||||
|
||||
return bool(supplied) and hmac.compare_digest(self.csrf_token(session_token), supplied)
|
||||
|
||||
def logout(self, session_token: str) -> None:
|
||||
self.logged_out.append(session_token)
|
||||
self.sessions.pop(session_token, None)
|
||||
|
||||
def confirm_password(self, user_id: int, password: str) -> bool:
|
||||
return bool(password) and password == self.password
|
||||
|
||||
def invalidate(self, session_token: str) -> None:
|
||||
pass
|
||||
|
||||
@@ -17,25 +17,39 @@ 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 fake_transport
|
||||
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,
|
||||
admin_password="StartPass1",
|
||||
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,
|
||||
)
|
||||
self.hub = Hub(settings, adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport))
|
||||
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()
|
||||
@@ -54,51 +68,96 @@ class AdminTests(unittest.TestCase):
|
||||
set_cookie = resp.headers.get("Set-Cookie", "")
|
||||
return resp.status, json.loads(resp.read().decode()), set_cookie
|
||||
|
||||
def test_login_change_password_and_secret_masking(self) -> None:
|
||||
status, body, cookie_header = self._json(
|
||||
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(body["must_change"])
|
||||
cookie = cookie_header.split(";")[0]
|
||||
csrf = body["csrf"]
|
||||
status, _, _ = self._json(
|
||||
"/admin/api/change-password",
|
||||
"POST",
|
||||
{"current": "StartPass1", "new_password": "NewPass123"},
|
||||
cookie=cookie,
|
||||
csrf=csrf,
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
_, sources, _ = self._json("/admin/api/sources", cookie=cookie, csrf=csrf)
|
||||
blob = json.dumps(sources)
|
||||
self.assertNotIn("real-tushare-token-abcdef", blob)
|
||||
self.assertTrue(sources["items"][0]["credential"]["configured"])
|
||||
self.assertTrue(str(sources["items"][0]["credential"]["last4"]).endswith("cdef") or "****" in str(sources["items"][0]["credential"]["last4"]))
|
||||
def _admin(self, path, method="GET", body=None):
|
||||
return self._json(path, method, body, cookie=self.cookie, csrf=self.csrf)
|
||||
|
||||
def test_rollback_requires_password_and_confirm(self) -> None:
|
||||
_, body, cookie_header = self._json(
|
||||
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
|
||||
)
|
||||
cookie = cookie_header.split(";")[0]
|
||||
csrf = body["csrf"]
|
||||
self._json("/admin/api/change-password", "POST", {"current": "StartPass1", "new_password": "NewPass123"}, cookie, csrf)
|
||||
from urllib.error import HTTPError
|
||||
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"},
|
||||
cookie,
|
||||
csrf,
|
||||
{
|
||||
"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": "hub_admin"}) + "{not-json"
|
||||
raw = json.dumps({"password": secret, "token": token, "username": "admin"}) + "{not-json"
|
||||
stream = io.StringIO()
|
||||
logger = logging.getLogger("datahub")
|
||||
handler = logging.StreamHandler(stream)
|
||||
@@ -108,9 +167,13 @@ class AdminTests(unittest.TestCase):
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
req = Request(
|
||||
self.base + "/admin/api/login",
|
||||
self.base + "/admin/api/credentials/tushare",
|
||||
data=raw.encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Cookie": self.cookie,
|
||||
"X-CSRF-Token": self.csrf,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
|
||||
@@ -13,7 +13,7 @@ from datahub.crypto import SecretVault
|
||||
from datahub.httpapp import make_handler
|
||||
from datahub.hub import Hub
|
||||
from datahub.settings import Settings
|
||||
from tests.fixtures import TRADE_DATE, fake_transport
|
||||
from tests.fixtures import TRADE_DATE, StubSiteAuth, fake_transport
|
||||
|
||||
|
||||
class AdminObservabilityApiTests(unittest.TestCase):
|
||||
@@ -26,31 +26,22 @@ class AdminObservabilityApiTests(unittest.TestCase):
|
||||
settings = Settings(
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="z" * 32,
|
||||
admin_password="StartPass1",
|
||||
tushare_token="real-tushare-token-abcdef",
|
||||
db_path=Path(self.tmp.name) / "hub.db",
|
||||
scheduler_enabled=False,
|
||||
)
|
||||
self.hub = Hub(settings, adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport))
|
||||
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]}"
|
||||
|
||||
_, body, cookie_header = self._json(
|
||||
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
|
||||
)
|
||||
cookie = cookie_header.split(";")[0]
|
||||
csrf = body["csrf"]
|
||||
self._json(
|
||||
"/admin/api/change-password",
|
||||
"POST",
|
||||
{"current": "StartPass1", "new_password": "NewPass123"},
|
||||
cookie=cookie,
|
||||
csrf=csrf,
|
||||
)
|
||||
self.cookie = cookie
|
||||
self.csrf = csrf
|
||||
self.cookie = f"xiaobai_session={StubSiteAuth.SESSION}"
|
||||
self.csrf = self.site_auth.csrf_token(StubSiteAuth.SESSION)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.server.shutdown()
|
||||
@@ -159,21 +150,17 @@ class AdminObservabilityApiTests(unittest.TestCase):
|
||||
finally:
|
||||
self.hub.db.observability_enabled = True
|
||||
|
||||
def test_must_change_password_blocks_new_endpoints_too(self) -> None:
|
||||
def test_non_admin_site_accounts_cannot_read_the_new_endpoints(self) -> None:
|
||||
from urllib.error import HTTPError
|
||||
|
||||
_, body, cookie_header = self._json(
|
||||
"/admin/api/login", "POST", {"username": "hub_admin", "password": "NewPass123"}
|
||||
)
|
||||
# Freshly logged-in user has already changed password in setUp, so
|
||||
# this login should not require a change; verify the endpoint is
|
||||
# reachable with a valid, non-must-change session (regression guard
|
||||
# against accidentally bypassing the must-change gate for these new
|
||||
# routes).
|
||||
cookie = cookie_header.split(";")[0]
|
||||
csrf = body["csrf"]
|
||||
status, _, _ = self._json("/admin/api/source-catalog", cookie=cookie, csrf=csrf)
|
||||
self.assertEqual(status, 200)
|
||||
self.site_auth.add_session("member-session", StubSiteAuth.MEMBER)
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._json("/admin/api/source-catalog", cookie="xiaobai_session=member-session")
|
||||
self.assertEqual(ctx.exception.code, 403)
|
||||
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._json("/admin/api/source-catalog")
|
||||
self.assertEqual(ctx.exception.code, 401)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -37,7 +37,6 @@ class ApiContractTests(unittest.TestCase):
|
||||
port=0,
|
||||
encryption_key=key,
|
||||
api_token=self.token,
|
||||
admin_password="StartPass1",
|
||||
tushare_token="tushare-secret-token-xyz",
|
||||
db_path=Path(self.tmp.name) / "hub.db",
|
||||
backup_dir=Path(self.tmp.name) / "backups",
|
||||
|
||||
@@ -52,7 +52,6 @@ def make_pipe(transport: GroupTransport, quality_extra: dict | None = None):
|
||||
settings = Settings(
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="t" * 32,
|
||||
admin_password="admin-pass",
|
||||
tushare_token="test-token",
|
||||
db_path=db.path,
|
||||
quality=quality,
|
||||
@@ -361,18 +360,22 @@ class ForceBoundaryEntryTests(unittest.TestCase):
|
||||
from datahub.scheduler import Scheduler
|
||||
from datahub.serving import ApiError
|
||||
|
||||
from tests.fixtures import StubSiteAuth
|
||||
|
||||
vault = SecretVault(self.pipe.settings.encryption_key)
|
||||
auth = AuthService(self.db, vault, self.pipe.settings.api_token, "StartPass1")
|
||||
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth)
|
||||
auth = AuthService(self.db, vault, self.pipe.settings.api_token)
|
||||
# 危险操作的第二因子现在是主站账号口令(HEL-560),不再有本地管理员密码
|
||||
site_auth = StubSiteAuth(password="StartPass1")
|
||||
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth, site_auth=site_auth)
|
||||
before = publications_map(self.db, TRADE_DATE)
|
||||
result = admin.backfill("moneyflow", TRADE_DATE, "StartPass1", f"moneyflow:{TRADE_DATE}", "tester")
|
||||
result = admin.backfill("moneyflow", TRADE_DATE, "StartPass1", f"moneyflow:{TRADE_DATE}", "tester", 1)
|
||||
self.assertEqual(result["moneyflow"]["state"], "published")
|
||||
after = publications_map(self.db, TRADE_DATE)
|
||||
for name in (*GROUP_A, "stocks"):
|
||||
self.assertNotEqual(after[name], before[name], name)
|
||||
# bad password / wrong confirm still rejected
|
||||
with self.assertRaises(ApiError):
|
||||
admin.backfill("daily", TRADE_DATE, "wrong", f"daily:{TRADE_DATE}", "tester")
|
||||
admin.backfill("daily", TRADE_DATE, "wrong", f"daily:{TRADE_DATE}", "tester", 1)
|
||||
|
||||
def test_admin_backfill_switch_crash_is_failed_precondition(self) -> None:
|
||||
from datahub.admin_api import AdminAPI
|
||||
@@ -381,9 +384,13 @@ class ForceBoundaryEntryTests(unittest.TestCase):
|
||||
from datahub.scheduler import Scheduler
|
||||
from datahub.serving import ApiError
|
||||
|
||||
from tests.fixtures import StubSiteAuth
|
||||
|
||||
vault = SecretVault(self.pipe.settings.encryption_key)
|
||||
auth = AuthService(self.db, vault, self.pipe.settings.api_token, "StartPass1")
|
||||
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth)
|
||||
auth = AuthService(self.db, vault, self.pipe.settings.api_token)
|
||||
# 危险操作的第二因子现在是主站账号口令(HEL-560),不再有本地管理员密码
|
||||
site_auth = StubSiteAuth(password="StartPass1")
|
||||
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth, site_auth=site_auth)
|
||||
before = publications_map(self.db, TRADE_DATE)
|
||||
|
||||
def explode() -> None:
|
||||
@@ -391,7 +398,7 @@ class ForceBoundaryEntryTests(unittest.TestCase):
|
||||
|
||||
self.pipe.before_commit = explode
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
admin.backfill("valuation", TRADE_DATE, "StartPass1", f"valuation:{TRADE_DATE}", "tester")
|
||||
admin.backfill("valuation", TRADE_DATE, "StartPass1", f"valuation:{TRADE_DATE}", "tester", 1)
|
||||
self.assertEqual(ctx.exception.code, "FAILED_PRECONDITION")
|
||||
self.assertIn("killed mid-switch", ctx.exception.message)
|
||||
# previous complete A/B versions keep serving
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX = (ROOT / "admin" / "index.html").read_text(encoding="utf-8")
|
||||
STYLES = (ROOT / "admin" / "styles.css").read_text(encoding="utf-8")
|
||||
APP = (ROOT / "admin" / "app.js").read_text(encoding="utf-8")
|
||||
HTTPAPP = (ROOT / "datahub" / "httpapp.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class ConsoleShellTests(unittest.TestCase):
|
||||
"""The console shell must carry the site-session gate, not its own login."""
|
||||
|
||||
def test_independent_login_and_password_change_are_gone(self) -> None:
|
||||
for removed in ("login-form", "change-form", "login-view", "change-view", 'value="hub_admin"'):
|
||||
self.assertNotIn(removed, INDEX, f"{removed} 属于已废弃的独立账号体系")
|
||||
self.assertNotIn("/admin/api/login", APP)
|
||||
self.assertNotIn("/admin/api/change-password", APP)
|
||||
|
||||
def test_gate_offers_a_way_back_to_the_review_site(self) -> None:
|
||||
for element in ("gate-view", "gate-title", "gate-desc", "gate-login", "gate-retry"):
|
||||
self.assertIn(element, INDEX)
|
||||
self.assertIn("/admin/api/session", APP)
|
||||
self.assertIn("login_url", APP)
|
||||
|
||||
def test_nav_exposes_the_pages_this_console_now_owns(self) -> None:
|
||||
for page in ("overview", "sources", "models", "members", "lineage"):
|
||||
self.assertIn(f'data-nav="{page}"', INDEX)
|
||||
# 路由白名单必须与导航一致,否则点了导航会回落到总览
|
||||
routed = re.search(r"return \[([^\]]+)\]\.includes\(h\)", APP)
|
||||
assert routed is not None
|
||||
for page in ("overview", "sources", "models", "members", "lineage"):
|
||||
self.assertIn(f"'{page}'", routed.group(1))
|
||||
|
||||
|
||||
class ThemeTokenTests(unittest.TestCase):
|
||||
"""Day/night is one token set with two value sets — never stacked overrides."""
|
||||
|
||||
def test_both_themes_define_the_same_tokens(self) -> None:
|
||||
night = _token_block(':root,\n:root[data-theme="night"]')
|
||||
day = _token_block(':root[data-theme="day"]')
|
||||
self.assertTrue(night)
|
||||
self.assertEqual(
|
||||
sorted(night),
|
||||
sorted(day),
|
||||
"日间主题必须覆盖同一组变量名,缺一个就会漏出夜间色",
|
||||
)
|
||||
|
||||
def test_no_hardcoded_colours_escape_the_token_set(self) -> None:
|
||||
# 颜色只要写死在 JS 或组件样式里,切主题就会有一块保持夜间色。
|
||||
self.assertEqual([], re.findall(r"#[0-9a-fA-F]{6}", APP))
|
||||
after_tokens = STYLES.split("/* ---------- ambient background", 1)[1]
|
||||
self.assertEqual([], re.findall(r"#[0-9a-fA-F]{6}", after_tokens))
|
||||
|
||||
def test_no_dark_literal_paint_survives_outside_the_token_blocks(self) -> None:
|
||||
"""A literal dark rgba() would stay dark in day mode — tokens only.
|
||||
|
||||
Accent tints are allowed: they are low-opacity washes of the four
|
||||
status hues and read correctly on either background.
|
||||
"""
|
||||
accent = {(34, 211, 238), (52, 211, 153), (251, 191, 36), (248, 113, 113)}
|
||||
offenders = []
|
||||
body = STYLES.split("/* ---------- ambient background", 1)[1]
|
||||
for line in body.splitlines():
|
||||
for match in re.finditer(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", line):
|
||||
rgb = tuple(int(match.group(index)) for index in (1, 2, 3))
|
||||
if rgb in accent or sum(rgb) >= 250:
|
||||
continue
|
||||
offenders.append(line.strip()[:80])
|
||||
self.assertEqual([], offenders, "深色字面值必须收敛成 token,否则日间主题会漏出夜间底色")
|
||||
|
||||
def test_theme_choice_survives_a_reload(self) -> None:
|
||||
self.assertIn("localStorage.getItem('datahub-theme')", APP)
|
||||
self.assertIn("localStorage.setItem('datahub-theme'", APP)
|
||||
self.assertIn('data-theme="night"', INDEX)
|
||||
|
||||
def test_svg_colours_go_through_style_so_tokens_apply(self) -> None:
|
||||
# var() 在 SVG 呈现属性里支持不稳,必须写进 style 才吃得到主题变量。
|
||||
for attribute in ('fill="var(', 'stroke="var(', 'stop-color="var('):
|
||||
self.assertNotIn(attribute, APP, f"{attribute} 应改写为 style 声明")
|
||||
|
||||
|
||||
class NarrowScreenTests(unittest.TestCase):
|
||||
def test_narrow_layout_stacks_inputs_and_buttons(self) -> None:
|
||||
self.assertIn("@media (max-width: 1100px)", STYLES)
|
||||
narrow = STYLES.split("@media (max-width: 1100px)", 1)[1]
|
||||
self.assertIn(".field-row { grid-template-columns: minmax(0, 1fr); }", narrow)
|
||||
# 凭证行与模型行在窄屏都要竖排,按钮才不会和输入框抢同一行
|
||||
self.assertIn(".cred-line { flex-direction: column;", narrow)
|
||||
self.assertIn(".model-row { flex-direction: column;", narrow)
|
||||
|
||||
|
||||
class ConsoleEndpointTests(unittest.TestCase):
|
||||
def test_every_endpoint_the_page_calls_is_routed(self) -> None:
|
||||
called = {
|
||||
path.split("?")[0]
|
||||
for path in re.findall(r"api\('(/admin/api/[^']+)'", APP)
|
||||
}
|
||||
self.assertTrue(called)
|
||||
for path in called:
|
||||
if path.startswith("/admin/api/sources/"):
|
||||
continue
|
||||
self.assertIn(f'"{path}"', HTTPAPP, f"{path} 前端在调,后端没路由")
|
||||
|
||||
def test_write_endpoints_are_reached_with_the_csrf_header(self) -> None:
|
||||
self.assertIn("X-CSRF-Token", APP)
|
||||
self.assertIn("check_csrf", HTTPAPP)
|
||||
|
||||
|
||||
def _token_block(selector: str) -> list[str]:
|
||||
start = STYLES.index(selector)
|
||||
body = STYLES[start:].split("}", 1)[0]
|
||||
return re.findall(r"(--[a-z0-9-]+):", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class StylesheetIntegrityTests(unittest.TestCase):
|
||||
"""HEL-560 改造中曾误把样式表尾部整段截断,抽屉/弹层/toast 全部失样,
|
||||
页面照样能跑、单测照样绿。这里把"每个仍在用的组件都得有样式"钉死。"""
|
||||
|
||||
def test_every_component_the_page_renders_still_has_its_own_rules(self) -> None:
|
||||
for selector in (
|
||||
".auth-wrap", ".drawer", ".drawer-mask", ".drawer-hd", ".drawer-tab",
|
||||
".modal-mask", ".modal-box", ".modal-actions", ".opbtn", ".empty-hint",
|
||||
".toast", ".row-fail", ".row-off", ".spark-end", ".cred-box", ".model-row",
|
||||
".vend-manual", ".gate-panel", ".dtable", ".invite-code", ".table-foot",
|
||||
".pbtn", ".field", ".form-hint",
|
||||
):
|
||||
self.assertIn(f"{selector} ", STYLES, f"{selector} 的样式丢了")
|
||||
|
||||
def test_stylesheet_braces_stay_balanced(self) -> None:
|
||||
body = STYLES[STYLES.index("/* ================= index.css"):]
|
||||
self.assertEqual(body.count("{"), body.count("}"))
|
||||
@@ -20,7 +20,6 @@ class ExtendedEodTests(unittest.TestCase):
|
||||
port=0,
|
||||
encryption_key=key,
|
||||
api_token="k" * 32,
|
||||
admin_password="StartPass1",
|
||||
tushare_token="tushare-secret",
|
||||
db_path=Path(self.tmp.name) / "hub.db",
|
||||
backup_dir=Path(self.tmp.name) / "backups",
|
||||
|
||||
@@ -60,7 +60,6 @@ class _Base(unittest.TestCase):
|
||||
settings = Settings(
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="z" * 32,
|
||||
admin_password="StartPass1",
|
||||
tushare_token="real-tushare-token-abcdef",
|
||||
db_path=Path(self.tmp.name) / "hub.db",
|
||||
scheduler_enabled=False,
|
||||
|
||||
@@ -53,7 +53,6 @@ def make_pipeline(before_commit=None, clock=None, quality=None) -> tuple[Pipelin
|
||||
settings = Settings(
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="t" * 32,
|
||||
admin_password="admin-pass",
|
||||
tushare_token="test-token",
|
||||
db_path=db.path,
|
||||
quality=quality_cfg,
|
||||
|
||||
@@ -76,7 +76,6 @@ def make_pipe(transport, quality_extra=None, clock=None):
|
||||
settings = Settings(
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="t" * 32,
|
||||
admin_password="admin-pass",
|
||||
tushare_token="test-token",
|
||||
db_path=db.path,
|
||||
quality=quality,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REVIEW_ROOT = ROOT.parent
|
||||
|
||||
from datahub.serving import ApiError
|
||||
from datahub.siteauth import SiteBridgeError
|
||||
from datahub.siteauth import SiteBridgeError
|
||||
from datahub.siteconsole import SiteConsole
|
||||
|
||||
|
||||
class RecordingBridge:
|
||||
"""Stands in for the review site so these tests exercise only the mapping."""
|
||||
|
||||
def __init__(self, replies: dict[str, dict[str, Any]] | None = None) -> None:
|
||||
self.replies = replies or {}
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.fail_with = ""
|
||||
|
||||
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
self.calls.append((path, dict(payload or {})))
|
||||
if self.fail_with:
|
||||
raise SiteBridgeError(self.fail_with)
|
||||
return self.replies.get(path, {})
|
||||
|
||||
def paths(self) -> list[str]:
|
||||
return [path for path, _ in self.calls]
|
||||
|
||||
|
||||
STATUS = {
|
||||
"llm": {
|
||||
"primary_model_id": "gpt-main",
|
||||
"fallback_model_id": "",
|
||||
"models": [
|
||||
{"id": "gpt-main", "name": "GPT 主力", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
|
||||
{"id": "gpt-mini", "name": "GPT 轻量", "model": "gpt-4o-mini", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
|
||||
{"id": "self-host", "name": "自建 Qwen", "model": "qwen2.5", "base_url": "https://llm.intra.example.com/v1", "configured": False, "api_key_last4": ""},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ModelPoolTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.bridge = RecordingBridge({"/api/hub-admin/status": STATUS})
|
||||
self.console = SiteConsole(self.bridge)
|
||||
|
||||
def test_models_are_grouped_per_vendor_so_one_vendor_can_hold_many(self) -> None:
|
||||
payload = self.console.models()
|
||||
groups = {group["base_url"]: group for group in payload["groups"]}
|
||||
self.assertEqual(2, len(groups))
|
||||
openai = groups["https://api.openai.com/v1"]
|
||||
self.assertEqual("OpenAI", openai["label"])
|
||||
self.assertEqual(2, len(openai["models"]))
|
||||
self.assertEqual("7f21", openai["key_last4"])
|
||||
self.assertTrue(openai["configured"])
|
||||
|
||||
def test_unknown_vendors_fall_back_to_their_host_as_a_label(self) -> None:
|
||||
groups = {group["base_url"]: group for group in self.console.models()["groups"]}
|
||||
self.assertEqual("llm.intra.example.com", groups["https://llm.intra.example.com/v1"]["label"])
|
||||
self.assertFalse(groups["https://llm.intra.example.com/v1"]["configured"])
|
||||
|
||||
def test_vendor_presets_are_offered_for_the_new_vendor_picker(self) -> None:
|
||||
vendors = self.console.models()["vendors"]
|
||||
self.assertIn("OpenAI", [vendor["label"] for vendor in vendors])
|
||||
self.assertTrue(all(vendor["base_url"].startswith("http") for vendor in vendors))
|
||||
|
||||
def test_saving_only_forwards_the_keys_the_caller_actually_sent(self) -> None:
|
||||
self.console.save_models({"primary_model_id": "gpt-mini"})
|
||||
path, payload = self.bridge.calls[0]
|
||||
self.assertEqual("/api/hub-admin/settings/save", path)
|
||||
self.assertEqual({"primary_model_id": "gpt-mini"}, payload)
|
||||
|
||||
def test_saving_nothing_is_refused_rather_than_wiping_the_pool(self) -> None:
|
||||
with self.assertRaises(ApiError):
|
||||
self.console.save_models({})
|
||||
self.assertEqual([], self.bridge.paths())
|
||||
|
||||
def test_fetching_a_model_list_needs_a_vendor_endpoint(self) -> None:
|
||||
with self.assertRaises(ApiError):
|
||||
self.console.fetch_models({"api_key": "sk-test"})
|
||||
|
||||
def test_fetch_passes_the_typed_key_through_for_first_time_vendors(self) -> None:
|
||||
self.bridge.replies["/api/hub-admin/models/fetch"] = {"models": ["gpt-4o", "gpt-4o-mini"]}
|
||||
result = self.console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"})
|
||||
self.assertEqual(["gpt-4o", "gpt-4o-mini"], result["models"])
|
||||
self.assertEqual({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"}, self.bridge.calls[0][1])
|
||||
|
||||
def test_a_site_outage_becomes_a_console_error_not_a_traceback(self) -> None:
|
||||
self.bridge.fail_with = "主站不可达"
|
||||
with self.assertRaises(ApiError) as caught:
|
||||
self.console.models()
|
||||
self.assertIn("主站不可达", str(caught.exception))
|
||||
|
||||
|
||||
class MemberAndQuotaTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.bridge = RecordingBridge({"/api/hub-admin/members": {"users": [{"id": 2}], "membership": {"member_daily_limit": 50}}})
|
||||
self.console = SiteConsole(self.bridge)
|
||||
|
||||
def test_saving_a_member_reads_the_roster_back_so_the_page_shows_truth(self) -> None:
|
||||
payload = self.console.save_member({"user_id": 2, "status": "active", "duration": "3_months"})
|
||||
self.assertEqual(["/api/hub-admin/membership/save", "/api/hub-admin/members"], self.bridge.paths())
|
||||
self.assertEqual([{"id": 2}], payload["users"])
|
||||
|
||||
def test_quota_is_a_system_setting_not_a_membership_row(self) -> None:
|
||||
self.console.save_quota({"member_daily_limit": 80})
|
||||
self.assertEqual("/api/hub-admin/settings/save", self.bridge.paths()[0])
|
||||
self.assertEqual({"member_daily_limit": 80}, self.bridge.calls[0][1])
|
||||
|
||||
def test_quota_below_one_is_rejected_before_it_reaches_the_site(self) -> None:
|
||||
for bad in (0, -5, "abc"):
|
||||
with self.subTest(bad=bad):
|
||||
with self.assertRaises(ApiError):
|
||||
self.console.save_quota({"member_daily_limit": bad})
|
||||
self.assertEqual([], self.bridge.paths())
|
||||
|
||||
|
||||
class InviteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.bridge = RecordingBridge(
|
||||
{
|
||||
"/api/hub-admin/invites": {"summary": {"unused": 1}, "codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}]},
|
||||
"/api/hub-admin/invites/create": {
|
||||
"created": [{"code_id": "abc", "code": "XB-9Q2F-7K3M-2P8T"}],
|
||||
"summary": {"unused": 1},
|
||||
"codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
self.console = SiteConsole(self.bridge)
|
||||
|
||||
def test_plaintext_codes_come_back_only_from_the_create_call(self) -> None:
|
||||
created = self.console.create_invites({"count": 1}, created_by=1)
|
||||
self.assertEqual("XB-9Q2F-7K3M-2P8T", created["created"][0]["code"])
|
||||
# 列表里永远只有掩码,完整码不会再出现第二次
|
||||
listed = self.console.invites()
|
||||
self.assertEqual("XB-9Q2F-••••", listed["codes"][0]["code_masked"])
|
||||
self.assertNotIn("code", listed["codes"][0])
|
||||
|
||||
def test_the_operator_is_recorded_as_the_issuer(self) -> None:
|
||||
self.console.create_invites({"count": 3, "note": "给张总"}, created_by=7)
|
||||
payload = self.bridge.calls[0][1]
|
||||
self.assertEqual(7, payload["created_by"])
|
||||
self.assertEqual(3, payload["count"])
|
||||
self.assertEqual("给张总", payload["note"])
|
||||
|
||||
def test_revoking_uses_the_public_handle_never_the_raw_code(self) -> None:
|
||||
self.console.revoke_invite({"code_id": "abc"})
|
||||
self.assertEqual("/api/hub-admin/invites/revoke", self.bridge.paths()[0])
|
||||
self.assertEqual({"code_id": "abc"}, self.bridge.calls[0][1])
|
||||
|
||||
def test_revoking_without_a_target_is_refused(self) -> None:
|
||||
with self.assertRaises(ApiError):
|
||||
self.console.revoke_invite({})
|
||||
self.assertEqual([], self.bridge.paths())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class BridgeContractTests(unittest.TestCase):
|
||||
"""Both halves of the bridge must agree on the path spelling.
|
||||
|
||||
A typo here fails only at runtime with a confusing 401 (the review site
|
||||
falls through to its browser-session guard), so it is worth a static check.
|
||||
"""
|
||||
|
||||
def test_every_path_the_console_calls_is_registered_on_the_review_site(self) -> None:
|
||||
console_paths = set()
|
||||
for module in ("siteauth.py", "siteconsole.py"):
|
||||
source = (ROOT / "datahub" / module).read_text(encoding="utf-8")
|
||||
console_paths.update(re.findall(r'"(/api/hub-admin/[a-z/-]+)"', source))
|
||||
self.assertTrue(console_paths)
|
||||
registry = (REVIEW_ROOT / "backend" / "http" / "dispatch.py").read_text(encoding="utf-8")
|
||||
registered = set(re.findall(r'"(/api/hub-admin/[a-z/-]+)":', registry))
|
||||
self.assertEqual(
|
||||
set(),
|
||||
console_paths - registered,
|
||||
"控制台在调、主站没注册的桥接路径会静默变成 401",
|
||||
)
|
||||
|
||||
|
||||
class BridgeErrorMappingTests(unittest.TestCase):
|
||||
"""主站拒绝入参(Key 不对)不能在中枢这边冒成 500。"""
|
||||
|
||||
def _console(self, error: SiteBridgeError) -> SiteConsole:
|
||||
class Failing:
|
||||
def call(self, path, payload=None):
|
||||
raise error
|
||||
|
||||
return SiteConsole(Failing())
|
||||
|
||||
def test_upstream_rejection_comes_back_as_a_bad_request(self) -> None:
|
||||
console = self._console(SiteBridgeError("模型列表拉取失败(HTTP 401)", 400))
|
||||
with self.assertRaises(ApiError) as caught:
|
||||
console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-bad"})
|
||||
self.assertEqual(caught.exception.code, "INVALID_ARGUMENT")
|
||||
self.assertEqual(caught.exception.status, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def test_unreachable_site_comes_back_as_service_unavailable(self) -> None:
|
||||
console = self._console(SiteBridgeError("主站不可达:connection refused"))
|
||||
with self.assertRaises(ApiError) as caught:
|
||||
console.models()
|
||||
self.assertEqual(caught.exception.code, "SOURCE_UNAVAILABLE")
|
||||
self.assertEqual(caught.exception.status, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
@@ -20,7 +20,6 @@ class StewardQueryTests(unittest.TestCase):
|
||||
port=0,
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="k" * 32,
|
||||
admin_password="StartPass1",
|
||||
tushare_token="tushare-secret-token-xyz",
|
||||
db_path=Path(self.tmp.name) / "hub.db",
|
||||
backup_dir=Path(self.tmp.name) / "backups",
|
||||
|
||||
@@ -45,7 +45,6 @@ def make_pipe(transport):
|
||||
settings = Settings(
|
||||
encryption_key=SecretVault.generate_key(),
|
||||
api_token="t" * 32,
|
||||
admin_password="admin-pass",
|
||||
tushare_token="test-token",
|
||||
db_path=db.path,
|
||||
quality={"max_publish_attempts": 3, "publication_generations": 3},
|
||||
|
||||
Reference in New Issue
Block a user