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()