Files
xiaobai-review/xiaobai-datahub/tests/test_admin_observability_api.py
T
abd4d22a67 HEL-543 返工: 补充 observability 运行时可关闭开关 (DATAHUB_OBSERVABILITY)
总工复核 🔴:安全边界要求新增观测功能必须可关闭、关闭后现有功能完全照旧,
但此前实现没有任何运行时开关。修复:

- settings.py: 新增 Settings.observability_enabled 字段,沿用既有
  DATAHUB_SCHEDULER 的环境变量模式,读取 DATAHUB_OBSERVABILITY
  (0/false/off 关闭,默认开启)。
- hub.py: Hub.__init__ 把 settings.observability_enabled 挂到
  self.db 上,让 pipeline/realtime_serve/steward/admin_api 已经
  在传的 db 参数直接带上开关,零额外改造。
- observability.py: 新增 is_enabled(db),缺失该属性时默认按启用处理
  (向后兼容裸 HubDB 用例/测试)。关闭时 observe() 变成纯
  透传(不计时、不分类、不碰数据库),record_call() 直接 no-op。
- admin_api.py: 4 个新只读端点关闭时返回明确的
  {"enabled": false, ...空结构} 而不是静默返回旧数据。
- 新增 10 个测试:开关默认值/环境变量解析、关闭后 observe() 的透传语义
  (含异常原样重新抛出)、关闭后 record_call() 零写入、关闭后重新开启恢复
  记录、4 个 admin 端点在关闭态的响应结构。

全量测试 183/183 通过(新增 10 个,含此前 173 个零回归)。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 01:10:58 +08:00

181 lines
7.7 KiB
Python

from __future__ import annotations
import json
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
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.settings import Settings
from tests.fixtures import TRADE_DATE, fake_transport
class AdminObservabilityApiTests(unittest.TestCase):
"""HEL-543: new read-only admin endpoints for provider status, source
catalog and lineage. These must never require write access and must
never touch the existing routing/publish logic."""
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,
)
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]}"
_, 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
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 _get(self, path):
return self._json(path, cookie=self.cookie, csrf=self.csrf)
def test_providers_status_reflects_real_pipeline_activity(self) -> None:
pipeline = self.hub.pipeline
pipeline.ingest_reference(TRADE_DATE)
pipeline.run_dataset("daily", TRADE_DATE)
status, body, _ = self._get("/admin/api/providers/status")
self.assertEqual(status, 200)
health = body["health"]
self.assertTrue(any(item["provider"] == "tushare" and item["interface"] == "daily" for item in health))
row = next(item for item in health if item["provider"] == "tushare" and item["interface"] == "daily")
self.assertEqual(row["state"], "ok")
recent = body["recent_calls"]
self.assertTrue(any(item["provider"] == "tushare" and item["interface"] == "daily" for item in recent))
def test_providers_status_filters_by_provider(self) -> None:
pipeline = self.hub.pipeline
pipeline.ingest_reference(TRADE_DATE)
pipeline.run_dataset("daily", TRADE_DATE)
status, body, _ = self._get("/admin/api/providers/status?provider=tushare")
self.assertEqual(status, 200)
self.assertTrue(all(item["provider"] == "tushare" for item in body["health"]))
self.assertTrue(all(item["provider"] == "tushare" for item in body["recent_calls"]))
status, body, _ = self._get("/admin/api/providers/status?provider=eastmoney")
self.assertEqual(status, 200)
self.assertEqual(body["health"], [])
self.assertEqual(body["recent_calls"], [])
def test_source_catalog_lists_known_providers_without_leaking_secrets(self) -> None:
status, body, _ = self._get("/admin/api/source-catalog")
self.assertEqual(status, 200)
blob = json.dumps(body)
self.assertNotIn("real-tushare-token-abcdef", blob)
providers = {item["provider"] for item in body["items"]}
self.assertIn("tushare", providers)
self.assertIn("eastmoney", providers)
self.assertIn("tencent", providers)
self.assertIn("ifind", providers)
def test_lineage_snapshot_and_affected_query(self) -> None:
status, body, _ = self._get("/admin/api/lineage")
self.assertEqual(status, 200)
self.assertTrue(len(body["items"]) > 0)
datasets = {item["dataset"] for item in body["items"]}
self.assertIn("stocks", datasets)
status, body, _ = self._get("/admin/api/lineage/affected?provider=tushare&interface=daily")
self.assertEqual(status, 200)
self.assertEqual(body["provider"], "tushare")
self.assertEqual(body["interface"], "daily")
def test_disabled_kill_switch_reports_enabled_false_with_empty_structure(self) -> None:
# HEL-543 total-review 🔴: flip the runtime kill switch the same way
# Hub.__init__ wires Settings.observability_enabled onto the db
# handle, then confirm every new endpoint reports disabled with an
# explicit empty structure rather than silently going quiet.
self.hub.db.observability_enabled = False
try:
pipeline = self.hub.pipeline
pipeline.ingest_reference(TRADE_DATE)
pipeline.run_dataset("daily", TRADE_DATE) # must still fully succeed
status, body, _ = self._get("/admin/api/providers/status")
self.assertEqual(status, 200)
self.assertEqual(body, {"enabled": False, "health": [], "recent_calls": []})
status, body, _ = self._get("/admin/api/source-catalog")
self.assertEqual(status, 200)
self.assertEqual(body, {"enabled": False, "items": []})
status, body, _ = self._get("/admin/api/lineage")
self.assertEqual(status, 200)
self.assertFalse(body["enabled"])
self.assertEqual(body["items"], [])
status, body, _ = self._get("/admin/api/lineage/affected?provider=tushare")
self.assertEqual(status, 200)
self.assertEqual(
body, {"enabled": False, "provider": "tushare", "interface": "", "items": []}
)
# Nothing was ever written while disabled.
self.assertEqual(self.hub.db.fetchall("SELECT * FROM provider_call_log"), [])
finally:
self.hub.db.observability_enabled = True
def test_must_change_password_blocks_new_endpoints_too(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)
if __name__ == "__main__":
unittest.main()