From 95a11b3fdcd7436bd3451476f53785a81eabc7f5 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 17 May 2026 09:20:52 -0500 Subject: [PATCH] test(config): cover trailing-slash stripping and extra_headers plumbing Adds tests/test_config.py with 10 tests closing the Wave 5 audit gaps: - F-Q-05: __post_init__ rstrips trailing slashes on base_url and ws_base_url, multiple-slash defense, validation still passes after stripping, and an end-to-end signed GET against a trailing-slash base that asserts no // in the wire URL (regression for the auth-signing failure mode the post-init prevents). - F-Q-06: extra_headers reach the wire via respx, coexist with per-request KALSHI-ACCESS-* auth headers (no overwrite either direction), persist across multiple requests, and the default_factory dict is per-instance. Closes #99 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_config.py | 163 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5d7f24e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,163 @@ +"""Tests for KalshiConfig normalization and request-layer plumbing. + +Covers two gaps flagged by Wave 5 audit (issue #99): + +* F-Q-05 — trailing-slash stripping on ``base_url`` / ``ws_base_url`` plus + end-to-end verification that signed requests don't end up with ``//`` paths. +* F-Q-06 — ``extra_headers`` is forwarded to the httpx client and coexists + with per-request auth headers (no overwrite either direction). +""" + +from __future__ import annotations + +import httpx +import respx + +from kalshi._base_client import SyncTransport +from kalshi.auth import KalshiAuth +from kalshi.config import ( + DEMO_BASE_URL, + DEMO_WS_URL, + PRODUCTION_BASE_URL, + PRODUCTION_WS_URL, + KalshiConfig, +) + + +class TestTrailingSlashStripping: + """F-Q-05: __post_init__ rstrips trailing slashes on base_url and ws_base_url.""" + + def test_trailing_slash_stripped_on_base_url(self) -> None: + config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/") + assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2" + + def test_trailing_slash_stripped_on_ws_base_url(self) -> None: + config = KalshiConfig(ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2/") + assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2" + + def test_multiple_trailing_slashes_all_stripped(self) -> None: + # Defensive: rstrip("/") removes all of them, not just one. + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2///", + ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2///", + ) + assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2" + assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2" + + def test_no_trailing_slash_left_unchanged(self) -> None: + config = KalshiConfig( + base_url=PRODUCTION_BASE_URL, + ws_base_url=PRODUCTION_WS_URL, + ) + assert config.base_url == PRODUCTION_BASE_URL + assert config.ws_base_url == PRODUCTION_WS_URL + + def test_demo_classmethod_url_has_no_trailing_slash(self) -> None: + config = KalshiConfig.demo() + assert not config.base_url.endswith("/") + assert not config.ws_base_url.endswith("/") + assert config.base_url == DEMO_BASE_URL + assert config.ws_base_url == DEMO_WS_URL + + def test_trailing_slash_base_url_still_passes_validation(self) -> None: + # Regression: stripping happens before _validate_url, so a known-host URL + # with a trailing slash must not trip the validator. + config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/") + assert config.base_url == DEMO_BASE_URL + + +class TestTrailingSlashSignedRequest: + """F-Q-05: signed GET against a trailing-slash base must hit /v2/markets, not //markets.""" + + @respx.mock + def test_signed_get_with_trailing_slash_base_no_double_slash( + self, test_auth: KalshiAuth + ) -> None: + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2/", + timeout=5.0, + max_retries=0, + ) + # Route asserts the URL has no // before "markets". If the trailing slash + # had leaked through, httpx would emit /trade-api/v2//markets and miss this route. + route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": []}) + ) + transport = SyncTransport(test_auth, config) + try: + resp = transport.request("GET", "/markets") + assert resp.status_code == 200 + assert route.call_count == 1 + request = route.calls[0].request + assert str(request.url) == "https://demo-api.kalshi.co/trade-api/v2/markets" + assert "//markets" not in str(request.url) + finally: + transport.close() + + +class TestExtraHeadersForwarding: + """F-Q-06: extra_headers reach the wire and coexist with auth headers.""" + + @respx.mock + def test_extra_headers_forwarded_to_request(self, test_auth: KalshiAuth) -> None: + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2", + timeout=5.0, + max_retries=0, + extra_headers={ + "X-Trace-Id": "trace-1", + "User-Agent": "kalshi-sdk/test", + }, + ) + route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": []}) + ) + transport = SyncTransport(test_auth, config) + try: + transport.request("GET", "/markets") + request = route.calls[0].request + # User extras land on the wire as-is. + assert request.headers["X-Trace-Id"] == "trace-1" + assert request.headers["User-Agent"] == "kalshi-sdk/test" + # And the per-request auth headers still ride alongside them + # (no overwrite either direction). + assert "KALSHI-ACCESS-KEY" in request.headers + assert "KALSHI-ACCESS-SIGNATURE" in request.headers + assert "KALSHI-ACCESS-TIMESTAMP" in request.headers + finally: + transport.close() + + @respx.mock + def test_extra_headers_persist_across_multiple_requests( + self, test_auth: KalshiAuth + ) -> None: + # Regression: extras are set on httpx.Client(headers=...), so every request + # carries them — not just the first one. + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2", + timeout=5.0, + max_retries=0, + extra_headers={"X-Trace-Id": "trace-1"}, + ) + route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": []}) + ) + transport = SyncTransport(test_auth, config) + try: + transport.request("GET", "/markets") + transport.request("GET", "/markets") + assert route.call_count == 2 + for call in route.calls: + assert call.request.headers["X-Trace-Id"] == "trace-1" + finally: + transport.close() + + def test_extra_headers_defaults_to_empty_dict(self) -> None: + # Each instance gets its own dict (default_factory), so mutating one + # config's extras must not bleed into another's. + config_a = KalshiConfig() + config_b = KalshiConfig() + assert config_a.extra_headers == {} + assert config_b.extra_headers == {} + config_a.extra_headers["X-Trace-Id"] = "a" + assert config_b.extra_headers == {}