fix(HEL-494): enforce datahub as sole website market boundary
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -10,7 +10,7 @@ from database import ReviewDatabase
|
||||
|
||||
|
||||
class BootstrapContainerTests(unittest.TestCase):
|
||||
def test_environment_credentials_preserve_legacy_model_fallbacks(self) -> None:
|
||||
def test_environment_credentials_exclude_provider_secrets_and_preserve_llm_fallbacks(self) -> None:
|
||||
result = environment_credentials(
|
||||
{
|
||||
"TUSHARE_TOKEN": " tushare ",
|
||||
@@ -20,8 +20,8 @@ class BootstrapContainerTests(unittest.TestCase):
|
||||
"LLM_MODEL": "legacy-model",
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["tushare_token"], "tushare")
|
||||
self.assertEqual(result["ifind_refresh_token"], "refresh")
|
||||
self.assertNotIn("tushare_token", result)
|
||||
self.assertNotIn("ifind_refresh_token", result)
|
||||
self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key")
|
||||
self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1")
|
||||
self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
|
||||
|
||||
@@ -12,6 +12,7 @@ from backend.data import (
|
||||
QualityEvidence,
|
||||
build_data_gateway,
|
||||
)
|
||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||
from backend.data.quality import market_timezone
|
||||
|
||||
|
||||
@@ -36,9 +37,14 @@ class DataGatewayTests(unittest.TestCase):
|
||||
policy.assert_allowed("market.level2", "unresolved", "display")
|
||||
|
||||
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
|
||||
settings = DatahubSettings(
|
||||
base_url="http://127.0.0.1:8766",
|
||||
token="hub-token",
|
||||
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
|
||||
)
|
||||
gateway = build_data_gateway(
|
||||
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
|
||||
lambda: "must-not-be-used",
|
||||
{},
|
||||
datahub_settings=settings,
|
||||
)
|
||||
client = gateway.tushare()
|
||||
self.assertEqual(client.token, "datahub")
|
||||
@@ -63,7 +69,7 @@ class DataGatewayTests(unittest.TestCase):
|
||||
/ "market"
|
||||
/ "service.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
||||
self.assertNotIn("TushareClient(self.token)", source)
|
||||
self.assertIn("return gateway.tushare()", source)
|
||||
|
||||
def test_provider_construction_has_unique_declared_owners(self) -> None:
|
||||
@@ -71,7 +77,7 @@ class DataGatewayTests(unittest.TestCase):
|
||||
owners = {
|
||||
"IfindProvider": {"backend/data/gateway.py"},
|
||||
"MarketChartClient": {"backend/data/gateway.py"},
|
||||
"TushareClient": {"backend/features/market/service.py"},
|
||||
"TushareClient": set(),
|
||||
"DatahubClient": {"backend/data/gateway.py"},
|
||||
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
||||
"DatahubBridge": {"backend/data/gateway.py"},
|
||||
|
||||
+72
-17
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -15,6 +16,7 @@ from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||
from backend.data.providers.tushare_transport import TushareError
|
||||
from backend.features.market.charts import MarketChartClient
|
||||
from backend.features.system.service import SystemServiceMixin
|
||||
from tests.test_datahub_bridge import FakeClient, FakeLegacy, flags
|
||||
|
||||
|
||||
@@ -220,6 +222,63 @@ def blocked_urlopen(request, timeout=None):
|
||||
|
||||
|
||||
class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
def test_website_availability_depends_on_hub_not_provider_credentials(self) -> None:
|
||||
service = SystemServiceMixin()
|
||||
service._system_credentials = {}
|
||||
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
|
||||
self.assertTrue(service.configured)
|
||||
self.assertEqual(service.token, "datahub")
|
||||
|
||||
def test_website_vault_scrubs_historical_provider_credentials(self) -> None:
|
||||
class Vault:
|
||||
@staticmethod
|
||||
def decrypt_json(payload):
|
||||
if isinstance(payload, str):
|
||||
return json.loads(payload)
|
||||
return dict(payload)
|
||||
|
||||
@staticmethod
|
||||
def encrypt_json(payload):
|
||||
return dict(payload)
|
||||
|
||||
class Database:
|
||||
system = {
|
||||
"tushare_token": "old-tushare",
|
||||
"ifind_refresh_token": "old-refresh",
|
||||
"ifind_access_token": "old-access",
|
||||
}
|
||||
personal = {"tushare_token": "old-personal", "llm_primary_model": "model"}
|
||||
|
||||
def get_system_setting(self, _name):
|
||||
return dict(self.system)
|
||||
|
||||
@staticmethod
|
||||
def first_user_id():
|
||||
return 1
|
||||
|
||||
def get_user_credentials(self, _user_id):
|
||||
return dict(self.personal)
|
||||
|
||||
@staticmethod
|
||||
def list_user_credentials():
|
||||
return [{"user_id": 1, "encrypted_payload": '{"tushare_token":"old-personal"}'}]
|
||||
|
||||
def save_system_setting(self, _name, payload):
|
||||
self.system = dict(payload)
|
||||
|
||||
def save_user_credentials(self, _user_id, payload):
|
||||
self.personal = dict(payload)
|
||||
|
||||
service = SystemServiceMixin()
|
||||
service.database = Database()
|
||||
service.vault = Vault()
|
||||
service.system_lock = threading.Lock()
|
||||
loaded = service._load_system_credentials({})
|
||||
for key in ("tushare_token", "ifind_refresh_token", "ifind_access_token"):
|
||||
self.assertNotIn(key, loaded)
|
||||
self.assertNotIn(key, service.database.system)
|
||||
self.assertNotIn(key, service.database.personal)
|
||||
|
||||
def test_query_never_calls_website_tushare_transport(self) -> None:
|
||||
client = FakeClient()
|
||||
legacy = FakeLegacy(TushareError("website tushare must stay dark"))
|
||||
@@ -340,7 +399,6 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
market = gateway.tushare()
|
||||
market_quotes = market.try_quotes(["600000.SH"])
|
||||
auction = market.query("stk_auction", {"trade_date": "20240902"}, "")
|
||||
gateway.ifind.set_credentials("refresh-token", "access-token")
|
||||
self.assertEqual(rows[0]["涨停原因"], "重组")
|
||||
self.assertEqual(len(quotes), 3)
|
||||
self.assertEqual(chart[-1]["close"], 10.2)
|
||||
@@ -349,23 +407,20 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
self.assertIsNone(market.realtime_aggregator)
|
||||
self.assertEqual(market.token, "datahub")
|
||||
|
||||
def test_set_credentials_posts_to_hub_not_ifind(self) -> None:
|
||||
seen: list[str] = []
|
||||
def test_website_does_not_accept_or_forward_provider_credentials(self) -> None:
|
||||
proxy = HubIfindProxy(DatahubBridge(_enabled_settings(), FakeClient()))
|
||||
self.assertFalse(hasattr(proxy, "set_credentials"))
|
||||
client_source = (ROOT / "backend" / "data" / "datahub" / "client.py").read_text(encoding="utf-8")
|
||||
application_source = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
|
||||
self.assertNotIn("/v1/credentials", client_source)
|
||||
self.assertNotIn("ifind_refresh_token", application_source)
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
url = str(getattr(request, "full_url", None) or request)
|
||||
seen.append(url)
|
||||
if any(host in url for host in BLOCKED_HOSTS):
|
||||
raise AssertionError(f"website opened blocked host: {url}")
|
||||
return _Resp(hub_payload(request))
|
||||
|
||||
settings = _enabled_settings()
|
||||
hub_client = DatahubClient(settings, urlopen=urlopen)
|
||||
proxy = HubIfindProxy(DatahubBridge(settings, hub_client))
|
||||
proxy.set_credentials("refresh-token", "access-token")
|
||||
self.assertTrue(any("/v1/credentials/ifind" in url for url in seen))
|
||||
self.assertFalse(any("51ifind.com" in url for url in seen))
|
||||
self.assertFalse(any("quantapi" in url for url in seen))
|
||||
def test_site_configuration_depends_on_hub_not_provider_credentials(self) -> None:
|
||||
service = SystemServiceMixin()
|
||||
service._system_credentials = {}
|
||||
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
|
||||
self.assertTrue(service.configured)
|
||||
self.assertEqual(service.token, "datahub")
|
||||
|
||||
def test_compose_passes_ifind_env_to_hub(self) -> None:
|
||||
overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8")
|
||||
|
||||
@@ -58,7 +58,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
||||
"data-system-edit-model",
|
||||
"data-system-open-member",
|
||||
"管理员专区",
|
||||
"保存密钥",
|
||||
"刷新状态",
|
||||
"保存分工",
|
||||
'location.assign("/login/")',
|
||||
):
|
||||
|
||||
@@ -53,6 +53,18 @@ class RealtimeClientStub:
|
||||
}
|
||||
|
||||
|
||||
class DataGatewayStub:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
def tushare(self):
|
||||
return self.client
|
||||
|
||||
@staticmethod
|
||||
def datahub_status():
|
||||
return {"configured": True}
|
||||
|
||||
|
||||
class FixedMarketDatetime(datetime):
|
||||
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
|
||||
|
||||
@@ -195,6 +207,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
self.service.ifind = None
|
||||
self.service.realtime_aggregator = None
|
||||
self.service.chart_data = None
|
||||
self.service._market_client_override = RealtimeClientStub("datahub")
|
||||
self.service.data_gateway = DataGatewayStub(self.service._market_client_override)
|
||||
RealtimeClientStub.quote_calls = 0
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
|
||||
@@ -218,9 +232,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
"moneyflow": {},
|
||||
}
|
||||
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(cached, "002141", today)
|
||||
|
||||
self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
|
||||
@@ -240,9 +252,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
"stock": {"code": "002141", "price": 10, "change": 1.2},
|
||||
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
||||
}
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
||||
|
||||
self.assertEqual(result["stock"]["change"], 1.2)
|
||||
@@ -279,9 +289,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedPreopenDatetime):
|
||||
result = self.service._prepare_stock_detail(payload, "002141", today)
|
||||
|
||||
self.assertEqual(result["meta"]["trade_date"], yesterday)
|
||||
@@ -294,11 +302,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
||||
aggregator = FreeQuoteAggregator(_free_quote())
|
||||
self.service.realtime_aggregator = aggregator
|
||||
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||
|
||||
bar = result["prices"][-1]
|
||||
@@ -327,11 +334,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
self.service.realtime_aggregator = aggregator
|
||||
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today)
|
||||
|
||||
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
|
||||
@@ -365,9 +371,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
)
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today)
|
||||
|
||||
bar = result["prices"][-1]
|
||||
@@ -384,11 +388,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
||||
self.service.realtime_aggregator = FreeQuoteAggregator(fail=True)
|
||||
self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30")
|
||||
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||
|
||||
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30")
|
||||
@@ -401,10 +404,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
self.service.realtime_aggregator = FreeQuoteAggregator(
|
||||
_free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp()))
|
||||
)
|
||||
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
with patch("backend.features.market.service.datetime", FixedLunchDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedLunchDatetime):
|
||||
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||
|
||||
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
|
||||
@@ -413,10 +415,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
def test_after_close_keeps_forming_bar_until_official_ready(self):
|
||||
today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d")
|
||||
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
|
||||
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
|
||||
forming = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||
self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31")
|
||||
self.assertTrue(forming["prices"][-1]["realtime"])
|
||||
@@ -435,9 +436,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
RealtimeClientStub.quote_calls = 0
|
||||
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||
):
|
||||
self.service._market_client_override = RealtimeClientStub("datahub")
|
||||
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
|
||||
replaced = self.service._prepare_stock_detail(official, "002141", today)
|
||||
|
||||
self.assertEqual(replaced["prices"][-1]["close"], 10.7)
|
||||
@@ -462,10 +462,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
|
||||
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||
DeniedRealtimeClientStub.quote_calls = 0
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
||||
):
|
||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||
result = self.service._prepare_stock_detail(payload, "002141", today)
|
||||
|
||||
self.assertEqual(len(result["prices"]), 2)
|
||||
|
||||
Reference in New Issue
Block a user