diff --git a/tests/integration/test_env_routing.py b/tests/integration/test_env_routing.py new file mode 100644 index 0000000..1e5dd13 --- /dev/null +++ b/tests/integration/test_env_routing.py @@ -0,0 +1,252 @@ +"""Integration test: bearer token → corretto env URL per ogni exchange. + +Usa il constructor spy pattern: intercetta __init__ del client per catturare +i kwargs passati (testnet/paper) e verificare che il bearer token corrisponda +all'env atteso. + +Nota: la risposta HTTP può essere qualsiasi — non ci interessa. Ci interessa +solo che il costruttore venga chiamato con i parametri corretti. +""" +from __future__ import annotations + +import importlib + +import pytest +from fastapi.testclient import TestClient + + +# ── Fixtures ──────────────────────────────────────────────────────────────── + +@pytest.fixture +def app(monkeypatch): + from tests.unit.test_settings import _minimal_env + for k, v in _minimal_env().items(): + monkeypatch.setenv(k, v) + + # Re-import fresco per evitare Settings cached con env vuoto + import importlib + import sys + for mod_name in list(sys.modules.keys()): + if "cerbero_mcp" in mod_name: + sys.modules.pop(mod_name, None) + + from cerbero_mcp.__main__ import _make_app + from cerbero_mcp.settings import Settings + return _make_app(Settings()) + + +def _bearer_test(): + return {"Authorization": "Bearer t_test_123"} + + +def _bearer_live(): + return {"Authorization": "Bearer t_live_456"} + + +# ── Spy helpers ────────────────────────────────────────────────────────────── + +def _spy_constructor(monkeypatch, module_path: str, cls_name: str, capture: dict): + """Sostituisce __init__ con uno spy che cattura gli args/kwargs. + + Supporta sia dataclass (init generato) sia classi normali. + Dopo la cattura, chiama il vero __init__ (fallback no-op se fallisce). + """ + mod = importlib.import_module(module_path) + real_cls = getattr(mod, cls_name) + real_init = real_cls.__init__ + + def spy_init(self, *args, **kwargs): + capture["args"] = args + capture["kwargs"] = kwargs + try: + real_init(self, *args, **kwargs) + except Exception: + # Se il costruttore fallisce (network, SDK unavailable) non importa: + # la capture è già avvenuta. + pass + + monkeypatch.setattr(real_cls, "__init__", spy_init) + + +def _force_rebuild(app): + """Svuota la cache del registry per forzare rebuild al prossimo request.""" + app.state.registry._clients.clear() + + +# ── Deribit ────────────────────────────────────────────────────────────────── + +def test_deribit_testnet_bearer_constructs_testnet_client(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.deribit.client", "DeribitClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-deribit/tools/is_testnet", headers=_bearer_test()) + + assert capture, "DeribitClient constructor non chiamato" + assert capture["kwargs"].get("testnet") is True, ( + f"atteso testnet=True, kwargs={capture['kwargs']}" + ) + + +def test_deribit_mainnet_bearer_constructs_mainnet_client(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.deribit.client", "DeribitClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-deribit/tools/is_testnet", headers=_bearer_live()) + + assert capture, "DeribitClient constructor non chiamato" + assert capture["kwargs"].get("testnet") is False, ( + f"atteso testnet=False, kwargs={capture['kwargs']}" + ) + + +# ── Bybit ──────────────────────────────────────────────────────────────────── + +def test_bybit_testnet_bearer(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.bybit.client", "BybitClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-bybit/tools/environment_info", headers=_bearer_test()) + + assert capture, "BybitClient constructor non chiamato" + assert capture["kwargs"].get("testnet") is True, ( + f"atteso testnet=True, kwargs={capture['kwargs']}" + ) + + +def test_bybit_mainnet_bearer(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.bybit.client", "BybitClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-bybit/tools/environment_info", headers=_bearer_live()) + + assert capture, "BybitClient constructor non chiamato" + assert capture["kwargs"].get("testnet") is False, ( + f"atteso testnet=False, kwargs={capture['kwargs']}" + ) + + +# ── Hyperliquid ────────────────────────────────────────────────────────────── + +def test_hyperliquid_testnet_bearer(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.hyperliquid.client", "HyperliquidClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-hyperliquid/tools/environment_info", headers=_bearer_test()) + + assert capture, "HyperliquidClient constructor non chiamato" + assert capture["kwargs"].get("testnet") is True, ( + f"atteso testnet=True, kwargs={capture['kwargs']}" + ) + + +def test_hyperliquid_mainnet_bearer(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.hyperliquid.client", "HyperliquidClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-hyperliquid/tools/environment_info", headers=_bearer_live()) + + assert capture, "HyperliquidClient constructor non chiamato" + assert capture["kwargs"].get("testnet") is False, ( + f"atteso testnet=False, kwargs={capture['kwargs']}" + ) + + +# ── Alpaca ─────────────────────────────────────────────────────────────────── + +def test_alpaca_testnet_bearer_uses_paper(app, monkeypatch): + """Alpaca usa paper=True al posto di testnet=True.""" + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.alpaca.client", "AlpacaClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-alpaca/tools/environment_info", headers=_bearer_test()) + + assert capture, "AlpacaClient constructor non chiamato" + assert capture["kwargs"].get("paper") is True, ( + f"atteso paper=True, kwargs={capture['kwargs']}" + ) + + +def test_alpaca_mainnet_bearer_uses_paper_false(app, monkeypatch): + capture = {} + _spy_constructor(monkeypatch, + "cerbero_mcp.exchanges.alpaca.client", "AlpacaClient", + capture) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-alpaca/tools/environment_info", headers=_bearer_live()) + + assert capture, "AlpacaClient constructor non chiamato" + assert capture["kwargs"].get("paper") is False, ( + f"atteso paper=False, kwargs={capture['kwargs']}" + ) + + +# ── Auth sanity ────────────────────────────────────────────────────────────── + +def test_no_bearer_returns_401(app): + """Senza bearer header qualsiasi /mcp-*/tools/* deve ritornare 401.""" + c = TestClient(app, raise_server_exceptions=False) + r = c.post("/mcp-deribit/tools/is_testnet") + assert r.status_code == 401, f"atteso 401, got {r.status_code}" + + +def test_invalid_bearer_returns_401(app): + c = TestClient(app, raise_server_exceptions=False) + r = c.post( + "/mcp-deribit/tools/is_testnet", + headers={"Authorization": "Bearer wrong_token_xyz"}, + ) + assert r.status_code == 401, f"atteso 401, got {r.status_code}" + + +# ── Macro (read-only, env-agnostic) ───────────────────────────────────────── + +def test_macro_works_with_either_bearer(app, monkeypatch): + """Macro è env-agnostic. Entrambi i bearer devono costruire il client.""" + import cerbero_mcp.exchanges.macro.client as macro_mod + + captures: list[dict] = [] + real_init = macro_mod.MacroClient.__init__ + + def spy_init(self, *args, **kwargs): + captures.append({"args": args, "kwargs": kwargs}) + real_init(self, *args, **kwargs) + + monkeypatch.setattr(macro_mod.MacroClient, "__init__", spy_init) + _force_rebuild(app) + + c = TestClient(app, raise_server_exceptions=False) + c.post("/mcp-macro/tools/get_macro_calendar", headers=_bearer_test()) + c.post("/mcp-macro/tools/get_macro_calendar", headers=_bearer_live()) + + # Almeno una costruzione (registry può cachare dopo la prima) + assert len(captures) >= 1, f"MacroClient constructor non chiamato, captures={captures}"