diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 444d390d..30c02ccf 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -6,15 +6,18 @@ these: | Variable | Default | Effect | |---|---|---| | `KALSHI_KEY_ID` | unset | Key ID. If unset, `from_env()` returns an unauthenticated client. | -| `KALSHI_PRIVATE_KEY` | unset | PEM string. Takes precedence over `KALSHI_PRIVATE_KEY_PATH`. | -| `KALSHI_PRIVATE_KEY_PATH` | unset | Path to PEM file. `~` is expanded. | +| `KALSHI_PRIVATE_KEY` | unset | PEM string. **Conflicts** with `KALSHI_PRIVATE_KEY_PATH` — set exactly one. | +| `KALSHI_PRIVATE_KEY_PATH` | unset | Path to PEM file. `~` is expanded. Conflicts with `KALSHI_PRIVATE_KEY`. | | `KALSHI_DEMO` | `false` | Truthy values (`true`, `1`, `yes`, `on`, case-insensitive) flip the client to the demo URLs. | | `KALSHI_API_BASE_URL` | unset | Overrides `base_url` entirely. Wins over `KALSHI_DEMO`. | +| `KALSHI_ALLOW_UNKNOWN_HOST` | unset | Set to `1` to allow `base_url`/`ws_base_url` hosts outside `{api.elections.kalshi.com, demo-api.kalshi.co, localhost}`. Default-fail prevents typos like `kalsi.com` from silently signing to an attacker (#250). | ## Precedence -1. **Credentials**: `KALSHI_PRIVATE_KEY` (in-memory PEM) beats - `KALSHI_PRIVATE_KEY_PATH` (file). +1. **Credentials**: setting both `KALSHI_PRIVATE_KEY` (in-memory PEM) and + `KALSHI_PRIVATE_KEY_PATH` raises `KalshiAuthError`. Previously the inline + PEM silently won; under #249 the ambiguity is rejected up-front so + key-rotation mishaps surface as a clean error instead of a 401/403 mystery. 2. **Base URL**: an explicit `KALSHI_API_BASE_URL` wins over the demo/production pair selected by `KALSHI_DEMO`. 3. **Constructor overrides**: anything passed positionally / by kwarg to diff --git a/kalshi/async_client.py b/kalshi/async_client.py index 9ac422ec..3f43a259 100644 --- a/kalshi/async_client.py +++ b/kalshi/async_client.py @@ -5,7 +5,7 @@ import os from pathlib import Path from types import TracebackType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict, Unpack import httpx @@ -33,6 +33,27 @@ from kalshi.resources.structured_targets import AsyncStructuredTargetsResource from kalshi.resources.subaccounts import AsyncSubaccountsResource + +class ClientInitKwargs(TypedDict, total=False): + """Typed forwarder shape for :meth:`AsyncKalshiClient.from_env`. + + Mirrors the ``__init__`` keyword surface so ``mypy --strict`` catches typos + like ``from_env(time_out=10)`` instead of silently swallowing them under + ``**kwargs: object``. See #266. + """ + + key_id: str | None + private_key: str | bytes | None + private_key_path: str | Path | None + auth: KalshiAuth | None + config: KalshiConfig | None + demo: bool + base_url: str | None + timeout: float | None + max_retries: int | None + transport: httpx.AsyncBaseTransport | None + + if TYPE_CHECKING: from kalshi.ws.client import KalshiWebSocket @@ -65,6 +86,11 @@ def __init__( # Reject empty strings that look like misconfigured credentials if key_id is not None and not key_id.strip(): raise ValueError("key_id must not be empty. Omit it for unauthenticated access.") + # #249: refuse ambiguous credential bundle — caller must pick exactly one + # private-key source so a key-rotation mishap can't leave the SDK + # silently signing with the wrong key. + if private_key_path is not None and private_key is not None: + raise ValueError("Provide either private_key_path or private_key, not both.") # #210: only shut down auth on close() when WE built it. If the # caller passed in their own KalshiAuth, they keep ownership. self._auth_owned: bool = auth is None @@ -166,10 +192,11 @@ async def handle(msg): ... "Provide key_id + private_key_path, or use AsyncKalshiClient.from_env()." ) from kalshi.ws.client import KalshiWebSocket as _KalshiWebSocket + return _KalshiWebSocket(auth=self._auth, config=self._config) @classmethod - def from_env(cls, **kwargs: object) -> AsyncKalshiClient: + def from_env(cls, **kwargs: Unpack[ClientInitKwargs]) -> AsyncKalshiClient: """Create async client from environment variables. Reads: @@ -180,12 +207,16 @@ def from_env(cls, **kwargs: object) -> AsyncKalshiClient: Returns an unauthenticated client if no credentials are configured. """ - auth = KalshiAuth.try_from_env() - demo = os.environ.get("KALSHI_DEMO", "").lower() == "true" - base_url = os.environ.get("KALSHI_API_BASE_URL") - client = cls(auth=auth, demo=demo, base_url=base_url, **kwargs) # type: ignore[arg-type] + # Caller-supplied kwargs win over env-derived values (legacy behaviour + # via ``cls(auth=..., demo=..., base_url=..., **kwargs)`` would have + # TypeError'd on duplicates; setdefault preserves env semantics while + # giving the static signature a clean shape). See #266. + kwargs.setdefault("auth", KalshiAuth.try_from_env()) + kwargs.setdefault("demo", os.environ.get("KALSHI_DEMO", "").lower() == "true") + kwargs.setdefault("base_url", os.environ.get("KALSHI_API_BASE_URL")) + client = cls(**kwargs) # from_env constructs auth locally — caller never owned it. - client._auth_owned = auth is not None + client._auth_owned = kwargs["auth"] is not None return client async def close(self) -> None: diff --git a/kalshi/auth.py b/kalshi/auth.py index 1c595291..b5c64a2f 100644 --- a/kalshi/auth.py +++ b/kalshi/auth.py @@ -44,6 +44,10 @@ def _normalize_percent_encoding(path: str) -> str: /markets/ABC%2fDEF -> /markets/ABC%2FDEF /markets/ABC%2FDEF -> /markets/ABC%2FDEF (no change) """ + # #261: vast majority of signed paths contain no percent-encoding (e.g. + # /trade-api/v2/markets); skip the regex compile + scan in the hot path. + if "%" not in path: + return path return re.sub( r"%([0-9a-fA-F]{2})", lambda m: "%" + m.group(1).upper(), @@ -163,8 +167,7 @@ def from_env( key_id = os.environ.get("KALSHI_KEY_ID") if not key_id: raise KalshiAuthError( - "KALSHI_KEY_ID environment variable is not set. " - "Set it to your Kalshi API key ID." + "KALSHI_KEY_ID environment variable is not set. Set it to your Kalshi API key ID." ) resolved_password = ( @@ -172,10 +175,17 @@ def from_env( ) pem_string = os.environ.get("KALSHI_PRIVATE_KEY") + key_path = os.environ.get("KALSHI_PRIVATE_KEY_PATH") + # #249: refuse ambiguous credential bundle — naming both env vars makes + # the misconfiguration obvious in production logs. + if pem_string and key_path: + raise KalshiAuthError( + "Both KALSHI_PRIVATE_KEY and KALSHI_PRIVATE_KEY_PATH are set. " + "Provide exactly one — unset whichever you don't intend to use." + ) if pem_string: return cls.from_pem(key_id, pem_string, password=resolved_password) - key_path = os.environ.get("KALSHI_PRIVATE_KEY_PATH") if key_path: return cls.from_key_path(key_id, key_path, password=resolved_password) @@ -211,10 +221,17 @@ def try_from_env( ) pem_string = os.environ.get("KALSHI_PRIVATE_KEY") + key_path = os.environ.get("KALSHI_PRIVATE_KEY_PATH") + # #249: refuse ambiguous credential bundle — naming both env vars makes + # the misconfiguration obvious in production logs. + if pem_string and key_path: + raise KalshiAuthError( + "Both KALSHI_PRIVATE_KEY and KALSHI_PRIVATE_KEY_PATH are set. " + "Provide exactly one — unset whichever you don't intend to use." + ) if pem_string: return cls.from_pem(key_id, pem_string, password=resolved_password) - key_path = os.environ.get("KALSHI_PRIVATE_KEY_PATH") if key_path: return cls.from_key_path(key_id, key_path, password=resolved_password) @@ -289,8 +306,7 @@ def _get_sign_executor(self) -> ThreadPoolExecutor: """ if self._closed: raise RuntimeError( - "KalshiAuth has been closed; create a new instance to " - "sign further requests." + "KalshiAuth has been closed; create a new instance to sign further requests." ) if self._sign_executor is None: with self._sign_executor_lock: diff --git a/kalshi/client.py b/kalshi/client.py index f41076b7..31e65756 100644 --- a/kalshi/client.py +++ b/kalshi/client.py @@ -5,6 +5,7 @@ import os from pathlib import Path from types import TracebackType +from typing import TypedDict, Unpack import httpx @@ -32,6 +33,26 @@ from kalshi.resources.subaccounts import SubaccountsResource +class ClientInitKwargs(TypedDict, total=False): + """Typed forwarder shape for :meth:`KalshiClient.from_env`. + + Mirrors the ``__init__`` keyword surface so ``mypy --strict`` catches typos + like ``from_env(time_out=10)`` instead of silently swallowing them under + ``**kwargs: object``. See #266. + """ + + key_id: str | None + private_key: str | bytes | None + private_key_path: str | Path | None + auth: KalshiAuth | None + config: KalshiConfig | None + demo: bool + base_url: str | None + timeout: float | None + max_retries: int | None + transport: httpx.BaseTransport | None + + class KalshiClient: """Synchronous client for the Kalshi prediction markets API. @@ -67,6 +88,11 @@ def __init__( # Reject empty strings that look like misconfigured credentials if key_id is not None and not key_id.strip(): raise ValueError("key_id must not be empty. Omit it for unauthenticated access.") + # #249: refuse ambiguous credential bundle — caller must pick exactly one + # private-key source so a key-rotation mishap can't leave the SDK + # silently signing with the wrong key. + if private_key_path is not None and private_key is not None: + raise ValueError("Provide either private_key_path or private_key, not both.") # #210: only shut down auth on close() when WE built it. If the # caller passed in their own KalshiAuth, they keep ownership. self._auth_owned: bool = auth is None @@ -138,7 +164,7 @@ def is_authenticated(self) -> bool: return self._auth is not None @classmethod - def from_env(cls, **kwargs: object) -> KalshiClient: + def from_env(cls, **kwargs: Unpack[ClientInitKwargs]) -> KalshiClient: """Create client from environment variables. Reads: @@ -149,12 +175,16 @@ def from_env(cls, **kwargs: object) -> KalshiClient: Returns an unauthenticated client if no credentials are configured. """ - auth = KalshiAuth.try_from_env() - demo = os.environ.get("KALSHI_DEMO", "").lower() == "true" - base_url = os.environ.get("KALSHI_API_BASE_URL") - client = cls(auth=auth, demo=demo, base_url=base_url, **kwargs) # type: ignore[arg-type] + # Caller-supplied kwargs win over env-derived values (legacy behaviour + # via ``cls(auth=..., demo=..., base_url=..., **kwargs)`` would have + # TypeError'd on duplicates; setdefault preserves env semantics while + # giving the static signature a clean shape). See #266. + kwargs.setdefault("auth", KalshiAuth.try_from_env()) + kwargs.setdefault("demo", os.environ.get("KALSHI_DEMO", "").lower() == "true") + kwargs.setdefault("base_url", os.environ.get("KALSHI_API_BASE_URL")) + client = cls(**kwargs) # from_env constructs auth locally — caller never owned it. - client._auth_owned = auth is not None + client._auth_owned = kwargs["auth"] is not None return client def close(self) -> None: diff --git a/kalshi/config.py b/kalshi/config.py index 3f266574..f91ff0f7 100644 --- a/kalshi/config.py +++ b/kalshi/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -20,9 +21,7 @@ DEFAULT_MAX_RETRIES = 3 DEFAULT_WS_MAX_RETRIES = 10 -_KNOWN_HOSTS = frozenset( - {"api.elections.kalshi.com", "demo-api.kalshi.co"} -) +_KNOWN_HOSTS = frozenset({"api.elections.kalshi.com", "demo-api.kalshi.co"}) _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) logger = logging.getLogger("kalshi") @@ -71,6 +70,7 @@ class KalshiConfig: ws_close_timeout: float = 5.0 ws_json_loads: Callable[[bytes | str], Any] | None = None ws_json_dumps: Callable[[Any], bytes | str] | None = None + allow_unknown_host: bool = False def __post_init__(self) -> None: # Strip trailing slash to prevent double-slash in auth signing paths @@ -78,8 +78,26 @@ def __post_init__(self) -> None: object.__setattr__(self, "base_url", self.base_url.rstrip("/")) if self.ws_base_url.endswith("/"): object.__setattr__(self, "ws_base_url", self.ws_base_url.rstrip("/")) - KalshiConfig._validate_url(self.base_url, "base_url", secure="https", plaintext="http") - KalshiConfig._validate_url(self.ws_base_url, "ws_base_url", secure="wss", plaintext="ws") + # #250: ``KALSHI_ALLOW_UNKNOWN_HOST=1`` is a process-wide escape hatch + # for mock servers and proxies — it relaxes the host check the same + # way the per-instance ``allow_unknown_host=True`` flag does. + allow_unknown = self.allow_unknown_host or ( + os.environ.get("KALSHI_ALLOW_UNKNOWN_HOST", "").strip() == "1" + ) + KalshiConfig._validate_url( + self.base_url, + "base_url", + secure="https", + plaintext="http", + allow_unknown_host=allow_unknown, + ) + KalshiConfig._validate_url( + self.ws_base_url, + "ws_base_url", + secure="wss", + plaintext="ws", + allow_unknown_host=allow_unknown, + ) # #202: prevent silently calling /trade-api or /v1 by enforcing the # v2 path component. Trailing slashes are already stripped above. base_path = urlparse(self.base_url).path @@ -119,7 +137,14 @@ def __post_init__(self) -> None: ) @staticmethod - def _validate_url(url: str, field_name: str, *, secure: str, plaintext: str) -> None: + def _validate_url( + url: str, + field_name: str, + *, + secure: str, + plaintext: str, + allow_unknown_host: bool, + ) -> None: """Reject URLs that would expose credentials (bad scheme or plaintext-to-remote).""" parsed = urlparse(url) scheme = parsed.scheme.lower() @@ -131,9 +156,7 @@ def _validate_url(url: str, field_name: str, *, secure: str, plaintext: str) -> f"got scheme={scheme!r} (url={url!r})" ) if not host: - raise ValueError( - f"KalshiConfig.{field_name} is missing a host: {url!r}" - ) + raise ValueError(f"KalshiConfig.{field_name} is missing a host: {url!r}") if scheme == plaintext and host not in _LOCAL_HOSTS: raise ValueError( f"KalshiConfig.{field_name} must use {secure}:// for non-loopback " @@ -142,10 +165,28 @@ def _validate_url(url: str, field_name: str, *, secure: str, plaintext: str) -> f"expose the KALSHI-ACCESS-KEY header and request signature." ) if host not in _KNOWN_HOSTS and host not in _LOCAL_HOSTS: + # #250: a typo or hostile env value (e.g. KALSHI_API_BASE_URL= + # https://api.elections.kalsi.com/trade-api/v2 — note the missing + # 'h') used to slip through with only a logger.warning. Production + # log filters silently drop the warning while the SDK keeps signing + # every request — including the KALSHI-ACCESS-KEY and matching + # RSA-PSS signature — to the attacker. Default-fail; mock servers + # and proxies opt in via ``allow_unknown_host=True`` or the + # process-wide ``KALSHI_ALLOW_UNKNOWN_HOST=1`` env var. + if not allow_unknown_host: + raise ValueError( + f"KalshiConfig.{field_name} host {host!r} is not a known " + f"Kalshi endpoint. Known hosts: {sorted(_KNOWN_HOSTS)}. " + "If this is an intentional mock server, proxy, or " + "alternate Kalshi region, opt in with " + "KalshiConfig(allow_unknown_host=True) or set the " + "environment variable KALSHI_ALLOW_UNKNOWN_HOST=1." + ) logger.warning( - "KalshiConfig.%s host %r is not a known Kalshi " - "endpoint (%s). Requests will be signed and sent there " - "with your API key — verify this is intentional.", + "KalshiConfig.%s host %r is not a known Kalshi endpoint (%s). " + "Requests will be signed and sent there with your API key — " + "verify this is intentional. (allow_unknown_host=True or " + "KALSHI_ALLOW_UNKNOWN_HOST=1 silenced the default error.)", field_name, host, sorted(_KNOWN_HOSTS), diff --git a/tests/conftest.py b/tests/conftest.py index 4ed0cdab..8923471a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os + import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -9,6 +11,28 @@ from kalshi.auth import KalshiAuth from kalshi.config import KalshiConfig +# Tests assume a clean KALSHI_* environment. The user's shell may have these +# exported (real credentials, base URL overrides, etc.); strip them at import +# time so tests stay hermetic regardless of where they're run. +for _v in ( + "KALSHI_KEY_ID", + "KALSHI_PRIVATE_KEY", + "KALSHI_PRIVATE_KEY_PATH", + "KALSHI_PRIVATE_KEY_PASSPHRASE", + "KALSHI_API_BASE_URL", + "KALSHI_WS_BASE_URL", + "KALSHI_DEMO", + "KALSHI_ALLOW_UNKNOWN_HOST", +): + os.environ.pop(_v, None) + +# #250: tests use ``https://test.kalshi.com/trade-api/v2`` as a respx-mocked +# sentinel host; default-fail on unknown hosts would break ~25 fixture-based +# files. Enabling the escape hatch process-wide here mirrors how a real caller +# pointing at a mock server would do it. The new tests in test_config.py for +# the default-fail behaviour explicitly ``monkeypatch.delenv`` it. +os.environ["KALSHI_ALLOW_UNKNOWN_HOST"] = "1" + @pytest.fixture def rsa_private_key() -> rsa.RSAPrivateKey: diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 977a6f51..ccddea8c 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -49,9 +49,7 @@ class TestAsyncTransportRetry: @respx.mock @pytest.mark.asyncio async def test_get_retries_on_502(self, transport: AsyncTransport) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( side_effect=[ httpx.Response(502, text="Bad Gateway"), httpx.Response(200, json={"markets": []}), @@ -64,9 +62,7 @@ async def test_get_retries_on_502(self, transport: AsyncTransport) -> None: @respx.mock @pytest.mark.asyncio async def test_get_retries_on_429(self, transport: AsyncTransport) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( side_effect=[ httpx.Response(429, json={"message": "rate limited"}), httpx.Response(200, json={"markets": []}), @@ -120,59 +116,54 @@ async def test_get_retries_on_500(self, transport: AsyncTransport) -> None: @respx.mock @pytest.mark.asyncio async def test_post_not_retried_on_500( - self, transport: AsyncTransport, + self, + transport: AsyncTransport, ) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders", ).mock(return_value=httpx.Response(500, json={"message": "internal"})) with pytest.raises(KalshiServerError): await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"}, + "POST", + "/portfolio/orders", + json={"ticker": "T"}, ) assert route.call_count == 1 @respx.mock @pytest.mark.asyncio async def test_post_not_retried(self, transport: AsyncTransport) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(502, text="Bad Gateway")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(502, text="Bad Gateway") + ) with pytest.raises(KalshiServerError): - await transport.request( - "POST", "/portfolio/orders", json={"ticker": "TEST"} - ) + await transport.request("POST", "/portfolio/orders", json={"ticker": "TEST"}) assert route.call_count == 1 @respx.mock @pytest.mark.asyncio async def test_delete_not_retried(self, transport: AsyncTransport) -> None: - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/abc" - ).mock(return_value=httpx.Response(503, text="Unavailable")) + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/abc").mock( + return_value=httpx.Response(503, text="Unavailable") + ) with pytest.raises(KalshiServerError): await transport.request("DELETE", "/portfolio/orders/abc") assert route.call_count == 1 @respx.mock @pytest.mark.asyncio - async def test_max_retries_exhausted( - self, transport: AsyncTransport - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock(return_value=httpx.Response(502, text="Bad Gateway")) + async def test_max_retries_exhausted(self, transport: AsyncTransport) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(502, text="Bad Gateway") + ) with pytest.raises(KalshiServerError): await transport.request("GET", "/markets") @respx.mock @pytest.mark.asyncio async def test_400_not_retried(self, transport: AsyncTransport) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( - return_value=httpx.Response( - 400, json={"message": "bad request"} - ) + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(400, json={"message": "bad request"}) ) with pytest.raises(KalshiValidationError): await transport.request("GET", "/markets") @@ -181,12 +172,8 @@ async def test_400_not_retried(self, transport: AsyncTransport) -> None: @respx.mock @pytest.mark.asyncio async def test_401_not_retried(self, transport: AsyncTransport) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( - return_value=httpx.Response( - 401, json={"message": "unauthorized"} - ) + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(401, json={"message": "unauthorized"}) ) with pytest.raises(KalshiAuthError): await transport.request("GET", "/markets") @@ -194,15 +181,9 @@ async def test_401_not_retried(self, transport: AsyncTransport) -> None: @respx.mock @pytest.mark.asyncio - async def test_successful_request( - self, transport: AsyncTransport - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( - return_value=httpx.Response( - 200, json={"markets": [{"ticker": "TEST"}]} - ) + async def test_successful_request(self, transport: AsyncTransport) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [{"ticker": "TEST"}]}) ) resp = await transport.request("GET", "/markets") assert resp.status_code == 200 @@ -228,9 +209,7 @@ async def fake_sleep(d: float) -> None: ) resp = await transport.request("GET", "/markets") assert resp.status_code == 200 - assert sleeps == [0.1], ( - f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps!r}" - ) + assert sleeps == [0.1], f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps!r}" @respx.mock @pytest.mark.asyncio @@ -259,9 +238,7 @@ async def fake_sleep(d: float) -> None: assert route.call_count == 2 assert len(sleeps) == 1 # Date is far in the future; delta is huge, capped at retry_max_delay=0.1. - assert sleeps[0] == 0.1, ( - f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps[0]!r}" - ) + assert sleeps[0] == 0.1, f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps[0]!r}" @respx.mock @pytest.mark.asyncio @@ -291,25 +268,19 @@ async def fake_sleep(d: float) -> None: @respx.mock @pytest.mark.asyncio - async def test_post_not_retried_on_timeout( - self, transport: AsyncTransport - ) -> None: + async def test_post_not_retried_on_timeout(self, transport: AsyncTransport) -> None: """Async POST timeout raises immediately; no retry.""" - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.TimeoutException("read timed out")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.TimeoutException("read timed out") + ) with pytest.raises(KalshiError, match="timed out"): - await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 class TestAsyncTransportContextManager: @pytest.mark.asyncio - async def test_close( - self, test_auth: KalshiAuth, config: KalshiConfig - ) -> None: + async def test_close(self, test_auth: KalshiAuth, config: KalshiConfig) -> None: transport = AsyncTransport(test_auth, config) await transport.close() # should not raise @@ -355,23 +326,32 @@ def test_auth_passthrough(self, test_auth: KalshiAuth) -> None: assert client._auth is test_auth def test_key_id_and_pem(self, pem_string: str) -> None: - client = AsyncKalshiClient( - key_id="test-key", private_key=pem_string - ) + client = AsyncKalshiClient(key_id="test-key", private_key=pem_string) assert client._auth.key_id == "test-key" def test_key_id_and_path(self, pem_bytes: bytes) -> None: - with tempfile.NamedTemporaryFile( - suffix=".pem", delete=False - ) as f: + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: f.write(pem_bytes) f.flush() - client = AsyncKalshiClient( - key_id="test-key", private_key_path=f.name - ) + client = AsyncKalshiClient(key_id="test-key", private_key_path=f.name) assert client._auth.key_id == "test-key" os.unlink(f.name) + def test_both_private_key_and_path_rejected(self, pem_string: str) -> None: + """#249: passing both ``private_key_path=`` and ``private_key=`` is + ambiguous (which key actually signs?). The old elif chain silently + preferred the path; a key-rotation mishap could leave production + signing with a stale credential. Now: explicit ValueError.""" + with pytest.raises( + ValueError, + match=r"Provide either private_key_path or private_key, not both", + ): + AsyncKalshiClient( + key_id="test-key", + private_key_path="/tmp/nonexistent.pem", + private_key=pem_string, + ) + def test_no_auth_constructs_unauthenticated(self) -> None: client = AsyncKalshiClient() assert client._auth is None @@ -398,9 +378,7 @@ def test_default_production_url(self, test_auth: KalshiAuth) -> None: assert client._config.base_url == PRODUCTION_BASE_URL @pytest.mark.asyncio - async def test_async_context_manager( - self, test_auth: KalshiAuth - ) -> None: + async def test_async_context_manager(self, test_auth: KalshiAuth) -> None: async with AsyncKalshiClient(auth=test_auth) as client: assert client.markets is not None assert client.orders is not None @@ -429,9 +407,7 @@ def test_from_env_with_pem_string( assert client._auth.key_id == "env-key" assert client._config.base_url == PRODUCTION_BASE_URL - def test_from_env_demo_flag( - self, monkeypatch: pytest.MonkeyPatch, pem_string: str - ) -> None: + def test_from_env_demo_flag(self, monkeypatch: pytest.MonkeyPatch, pem_string: str) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) monkeypatch.setenv("KALSHI_DEMO", "true") @@ -472,6 +448,17 @@ def test_from_env_missing_keys_returns_unauthenticated( client = AsyncKalshiClient.from_env() assert client._auth is None + def test_from_env_typed_kwargs_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None: + """#266: typed kwargs in ``ClientInitKwargs`` reach ``__init__``. The + old ``**kwargs: object`` signature would have accepted ``time_out=10`` + silently; the Unpack[TypedDict] signature catches that statically via + mypy. Runtime check pins that the forwarder still wires through + ``timeout`` and ``max_retries`` from the kwargs path.""" + monkeypatch.delenv("KALSHI_KEY_ID", raising=False) + client = AsyncKalshiClient.from_env(timeout=7.5, max_retries=11) + assert client._config.timeout == 7.5 + assert client._config.max_retries == 11 + class TestAsyncUnauthenticatedResourceGuards: @pytest.mark.asyncio @@ -483,6 +470,7 @@ async def test_orders_create_raises_auth_required(self) -> None: ) transport = AsyncTransport(None, config) from kalshi.resources.orders import AsyncOrdersResource + resource = AsyncOrdersResource(transport) with pytest.raises(AuthRequiredError): await resource.create(ticker="TEST", side="yes") @@ -496,6 +484,7 @@ async def test_portfolio_balance_raises_auth_required(self) -> None: ) transport = AsyncTransport(None, config) from kalshi.resources.portfolio import AsyncPortfolioResource + resource = AsyncPortfolioResource(transport) with pytest.raises(AuthRequiredError): await resource.balance() @@ -529,6 +518,7 @@ def test_ws_raises_without_auth(self) -> None: with pytest.raises(AuthRequiredError): _ = client.ws + class TestAsyncWidenedRetrySet: """#192: async mirror of widened retryable status set.""" @@ -550,9 +540,9 @@ async def test_retry_includes_cloudflare_5xx_521_on_get( @respx.mock @pytest.mark.asyncio async def test_post_521_not_retried(self, transport: AsyncTransport) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(521, text="down")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(521, text="down") + ) with pytest.raises(KalshiServerError): await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 @@ -590,15 +580,11 @@ async def test_422_maps_to_KalshiValidationError( # noqa: N802 async def test_409_maps_to_KalshiConflictError( # noqa: N802 self, transport: AsyncTransport ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response(409, json={"message": "duplicate"}) ) with pytest.raises(KalshiConflictError): - await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) class TestAsyncPoolTimeout: @@ -613,17 +599,13 @@ async def fake_sleep(d: float) -> None: pass monkeypatch.setattr("asyncio.sleep", fake_sleep) - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( side_effect=[ httpx.PoolTimeout("pool full"), httpx.Response(200, json={"order_id": "abc"}), ] ) - resp = await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + resp = await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert resp.status_code == 200 assert route.call_count == 2 @@ -636,13 +618,11 @@ async def fake_sleep(d: float) -> None: pass monkeypatch.setattr("asyncio.sleep", fake_sleep) - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.PoolTimeout("pool full")) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.PoolTimeout("pool full") + ) with pytest.raises(KalshiPoolExhaustedError, match="pool exhausted"): - await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) class TestAsyncTypedTimeoutException: @@ -653,13 +633,11 @@ class TestAsyncTypedTimeoutException: async def test_read_timeout_on_post_raises_KalshiTimeoutError_no_retry( # noqa: N802 self, transport: AsyncTransport ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.ReadTimeout("read timed out")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.ReadTimeout("read timed out") + ) with pytest.raises(KalshiTimeoutError, match="timed out"): - await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 @@ -693,9 +671,7 @@ def fake_monotonic() -> float: clock["t"] += 1.0 return t - monkeypatch.setattr( - "kalshi._base_client.time.monotonic", fake_monotonic - ) + monkeypatch.setattr("kalshi._base_client.time.monotonic", fake_monotonic) route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( return_value=httpx.Response(502, text="bad gateway") ) @@ -750,9 +726,7 @@ async def test_close_does_not_shut_externally_provided_auth( await client_b.close() @pytest.mark.asyncio - async def test_close_shuts_locally_constructed_auth( - self, pem_string: str - ) -> None: + async def test_close_shuts_locally_constructed_auth(self, pem_string: str) -> None: client = AsyncKalshiClient(key_id="test-key", private_key=pem_string) assert client._auth_owned is True await client.close() @@ -821,17 +795,13 @@ async def fake_sleep(d: float) -> None: pass monkeypatch.setattr("asyncio.sleep", fake_sleep) - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( side_effect=[ httpx.ConnectError("connection refused"), httpx.Response(200, json={"order_id": "abc"}), ] ) - resp = await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + resp = await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert resp.status_code == 200 assert route.call_count == 2 @@ -844,12 +814,10 @@ async def fake_sleep(d: float) -> None: pass monkeypatch.setattr("asyncio.sleep", fake_sleep) - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.ReadError("socket read failed")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.ReadError("socket read failed") + ) with pytest.raises(KalshiNetworkError, match="Network error") as exc_info: - await transport.request( - "POST", "/portfolio/orders", json={"ticker": "T"} - ) + await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 assert isinstance(exc_info.value.__cause__, httpx.ReadError) diff --git a/tests/test_auth.py b/tests/test_auth.py index 68d78e50..54dd52d6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -8,14 +8,33 @@ import tempfile import pytest -from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa -from kalshi.auth import KalshiAuth +from kalshi.auth import KalshiAuth, _normalize_percent_encoding from kalshi.errors import KalshiAuthError +class TestNormalizePercentEncoding: + """#261: short-circuit when the path contains no ``%`` so the hot REST + signing path doesn't burn a regex compile + scan per request.""" + + def test_no_percent_returns_input_identity(self) -> None: + # Identity (``is``) is the contract — short-circuit must not allocate. + path = "/trade-api/v2/markets" + assert _normalize_percent_encoding(path) is path + + def test_uppercase_percent_passthrough(self) -> None: + path = "/trade-api/v2/markets/ABC%2FDEF" + assert _normalize_percent_encoding(path) == path + + def test_lowercase_percent_uppercased(self) -> None: + assert ( + _normalize_percent_encoding("/trade-api/v2/markets/ABC%2fDEF") + == "/trade-api/v2/markets/ABC%2FDEF" + ) + + class TestSignRequest: def test_returns_three_headers(self, test_auth: KalshiAuth) -> None: headers = test_auth.sign_request("GET", "/trade-api/v2/markets", timestamp_ms=1703123456789) @@ -69,7 +88,8 @@ def test_strips_query_params( # The signature should verify against the STRIPPED path (no query params) expected_msg = b"1000GET/trade-api/v2/markets" rsa_private_key.public_key().verify( - sig, expected_msg, + sig, + expected_msg, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH), hashes.SHA256(), ) @@ -82,7 +102,8 @@ def test_strips_trailing_slash( sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"]) expected_msg = b"1000GET/trade-api/v2/markets" rsa_private_key.public_key().verify( - sig, expected_msg, + sig, + expected_msg, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH), hashes.SHA256(), ) @@ -95,7 +116,8 @@ def test_method_case_insensitive( sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"]) expected_msg = b"1000GET/trade-api/v2/markets" rsa_private_key.public_key().verify( - sig, expected_msg, + sig, + expected_msg, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH), hashes.SHA256(), ) @@ -117,7 +139,8 @@ def test_percent_encoded_path_preserved_but_normalized( # Signature is against the raw (encoded) path expected_msg = b"1000GET/trade-api/v2/events/TICKER%2DNAME" rsa_private_key.public_key().verify( - sig, expected_msg, + sig, + expected_msg, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH), hashes.SHA256(), ) @@ -126,12 +149,8 @@ def test_encoded_and_decoded_paths_differ(self, test_auth: KalshiAuth) -> None: """Encoded and decoded paths produce different signatures. %2D is the encoding of '-', but the signing payload preserves the encoding rather than decoding it.""" - h1 = test_auth.sign_request( - "GET", "/trade-api/v2/events/TICKER%2DNAME", timestamp_ms=1000 - ) - h2 = test_auth.sign_request( - "GET", "/trade-api/v2/events/TICKER-NAME", timestamp_ms=1000 - ) + h1 = test_auth.sign_request("GET", "/trade-api/v2/events/TICKER%2DNAME", timestamp_ms=1000) + h2 = test_auth.sign_request("GET", "/trade-api/v2/events/TICKER-NAME", timestamp_ms=1000) assert h1["KALSHI-ACCESS-SIGNATURE"] != h2["KALSHI-ACCESS-SIGNATURE"] @pytest.mark.parametrize( @@ -205,12 +224,8 @@ def test_case_variants_produce_same_canonical_path( salt_length=padding.PSS.DIGEST_LENGTH, ) - h1 = test_auth.sign_request( - "GET", "/trade-api/v2/events/TICKER%2dNAME", timestamp_ms=1000 - ) - h2 = test_auth.sign_request( - "GET", "/trade-api/v2/events/TICKER%2DNAME", timestamp_ms=1000 - ) + h1 = test_auth.sign_request("GET", "/trade-api/v2/events/TICKER%2dNAME", timestamp_ms=1000) + h2 = test_auth.sign_request("GET", "/trade-api/v2/events/TICKER%2DNAME", timestamp_ms=1000) # Both signatures must verify against the same canonical message sig1 = base64.b64decode(h1["KALSHI-ACCESS-SIGNATURE"]) @@ -248,11 +263,15 @@ async def test_async_signature_verifies_for_same_message(self, test_auth: Kalshi ) pub.verify( base64.b64decode(sync_headers["KALSHI-ACCESS-SIGNATURE"]), - canonical, pss, hashes.SHA256(), + canonical, + pss, + hashes.SHA256(), ) pub.verify( base64.b64decode(async_headers["KALSHI-ACCESS-SIGNATURE"]), - canonical, pss, hashes.SHA256(), + canonical, + pss, + hashes.SHA256(), ) @pytest.mark.asyncio @@ -278,7 +297,8 @@ async def test_close_is_idempotent(self, test_auth: KalshiAuth) -> None: @pytest.mark.asyncio async def test_closed_auth_raises_on_sign_request_async( - self, test_auth: KalshiAuth, + self, + test_auth: KalshiAuth, ) -> None: """P4.6: ``close()`` is terminal — a subsequent ``sign_request_async`` raises ``RuntimeError`` instead of silently respawning the @@ -291,7 +311,8 @@ async def test_closed_auth_raises_on_sign_request_async( @pytest.mark.asyncio async def test_close_is_terminal_no_silent_respawn( - self, test_auth: KalshiAuth, + self, + test_auth: KalshiAuth, ) -> None: """P4.6: after ``close()``, the executor stays None — the lazy-init in ``_get_sign_executor`` never reinstates it. Pre-fix it would @@ -306,9 +327,7 @@ async def test_close_is_terminal_no_silent_respawn( assert test_auth._sign_executor is None # no silent respawn @pytest.mark.asyncio - async def test_concurrent_signs_do_not_stall_event_loop( - self, test_auth: KalshiAuth - ) -> None: + async def test_concurrent_signs_do_not_stall_event_loop(self, test_auth: KalshiAuth) -> None: """Microbench (#178). Run a real ``asyncio.sleep(0.01)`` ticker concurrently with a batch of signs and confirm the ticker's observed gaps stay tight. @@ -338,9 +357,7 @@ async def ticker() -> None: async def signs() -> None: # 200 signs covers a realistic batch_create burst. for _ in range(200): - await test_auth.sign_request_async( - "GET", "/trade-api/v2/markets", timestamp_ms=1 - ) + await test_auth.sign_request_async("GET", "/trade-api/v2/markets", timestamp_ms=1) await asyncio.gather(ticker(), signs()) test_auth.close() @@ -349,6 +366,7 @@ async def signs() -> None: # Pre-offload inline RSA-PSS would push the max gap well past this. assert max(gaps) < 0.030, f"max ticker gap {max(gaps) * 1000:.1f}ms exceeds 30ms budget" + class TestFromKeyPath: def test_loads_valid_pem_file(self, pem_bytes: bytes) -> None: with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: @@ -383,12 +401,8 @@ def test_invalid_pem_content(self) -> None: KalshiAuth.from_key_path("my-key", f.name) os.unlink(f.name) - @pytest.mark.skipif( - os.geteuid() == 0, reason="root bypasses file permission checks" - ) - def test_permission_denied_wraps_with_helpful_message( - self, pem_bytes: bytes - ) -> None: + @pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses file permission checks") + def test_permission_denied_wraps_with_helpful_message(self, pem_bytes: bytes) -> None: """A key file the user can't read raises KalshiAuthError, not PermissionError.""" with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: f.write(pem_bytes) @@ -464,17 +478,14 @@ def test_missing_both_key_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(KalshiAuthError, match="KALSHI_PRIVATE_KEY"): KalshiAuth.from_env() - def test_from_pem_env_var( - self, monkeypatch: pytest.MonkeyPatch, pem_string: str - ) -> None: + def test_from_pem_env_var(self, monkeypatch: pytest.MonkeyPatch, pem_string: str) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) auth = KalshiAuth.from_env() assert auth.key_id == "env-key" - def test_from_path_env_var( - self, monkeypatch: pytest.MonkeyPatch, pem_bytes: bytes - ) -> None: + def test_from_path_env_var(self, monkeypatch: pytest.MonkeyPatch, pem_bytes: bytes) -> None: with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: f.write(pem_bytes) f.flush() @@ -485,44 +496,25 @@ def test_from_path_env_var( assert auth.key_id == "path-key" os.unlink(f.name) - def test_from_env_pem_takes_precedence_over_path( - self, monkeypatch: pytest.MonkeyPatch, + def test_from_env_rejects_both_pem_and_path_set( + self, monkeypatch: pytest.MonkeyPatch, pem_string: str ) -> None: - """P4.4: when BOTH ``KALSHI_PRIVATE_KEY`` (inline PEM) and - ``KALSHI_PRIVATE_KEY_PATH`` (file) are set, ``from_env`` must - load the inline PEM. Pins the existing precedence so a future - refactor that flips the order doesn't silently swap which key - signs production requests.""" - key_a = rsa.generate_private_key(public_exponent=65537, key_size=2048) - key_b = rsa.generate_private_key(public_exponent=65537, key_size=2048) - pem_a = key_a.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - pem_b = key_b.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) + """#249: ``from_env`` must raise when both ``KALSHI_PRIVATE_KEY`` and + ``KALSHI_PRIVATE_KEY_PATH`` are populated. Silent precedence (PEM wins) + hid key-rotation mishaps; the message must name both env vars so the + misconfiguration is obvious from a single 401/403 incident.""" with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: - f.write(pem_b) + f.write(pem_string.encode()) f.flush() try: - monkeypatch.setenv("KALSHI_KEY_ID", "precedence-key") - monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_a) + monkeypatch.setenv("KALSHI_KEY_ID", "conflict-key") + monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) monkeypatch.setenv("KALSHI_PRIVATE_KEY_PATH", f.name) - auth = KalshiAuth.from_env() - headers = auth.sign_request("GET", "/x", timestamp_ms=1) - sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"]) - pss = padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.DIGEST_LENGTH, - ) - # Verifies with A; FAILS to verify with B (proves A signed). - key_a.public_key().verify(sig, b"1GET/x", pss, hashes.SHA256()) - with pytest.raises(InvalidSignature): - key_b.public_key().verify(sig, b"1GET/x", pss, hashes.SHA256()) + with pytest.raises( + KalshiAuthError, + match=r"KALSHI_PRIVATE_KEY.*KALSHI_PRIVATE_KEY_PATH", + ): + KalshiAuth.from_env() finally: os.unlink(f.name) @@ -538,60 +530,38 @@ def test_returns_auth_when_env_vars_set( assert auth is not None assert auth.key_id == "test-key" - def test_returns_none_when_key_id_missing( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_returns_none_when_key_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("KALSHI_KEY_ID", raising=False) monkeypatch.delenv("KALSHI_PRIVATE_KEY", raising=False) monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) auth = KalshiAuth.try_from_env() assert auth is None - def test_returns_none_when_key_id_set_but_no_key( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_returns_none_when_key_id_set_but_no_key(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "test-key") monkeypatch.delenv("KALSHI_PRIVATE_KEY", raising=False) monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) auth = KalshiAuth.try_from_env() assert auth is None - def test_try_from_env_pem_takes_precedence_over_path( - self, monkeypatch: pytest.MonkeyPatch, + def test_try_from_env_rejects_both_pem_and_path_set( + self, monkeypatch: pytest.MonkeyPatch, pem_string: str ) -> None: - """P4.4: mirror of - ``TestFromEnv.test_from_env_pem_takes_precedence_over_path`` - for ``try_from_env``. Same precedence contract.""" - key_a = rsa.generate_private_key(public_exponent=65537, key_size=2048) - key_b = rsa.generate_private_key(public_exponent=65537, key_size=2048) - pem_a = key_a.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - pem_b = key_b.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) + """#249: mirror of the ``from_env`` regression — ``try_from_env`` must + raise (not return ``None``, not silently pick PEM) when both env vars + are populated to non-empty values.""" with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: - f.write(pem_b) + f.write(pem_string.encode()) f.flush() try: - monkeypatch.setenv("KALSHI_KEY_ID", "precedence-key") - monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_a) + monkeypatch.setenv("KALSHI_KEY_ID", "conflict-key") + monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) monkeypatch.setenv("KALSHI_PRIVATE_KEY_PATH", f.name) - auth = KalshiAuth.try_from_env() - assert auth is not None - headers = auth.sign_request("GET", "/x", timestamp_ms=1) - sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"]) - pss = padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.DIGEST_LENGTH, - ) - key_a.public_key().verify(sig, b"1GET/x", pss, hashes.SHA256()) - with pytest.raises(InvalidSignature): - key_b.public_key().verify(sig, b"1GET/x", pss, hashes.SHA256()) + with pytest.raises( + KalshiAuthError, + match=r"KALSHI_PRIVATE_KEY.*KALSHI_PRIVATE_KEY_PATH", + ): + KalshiAuth.try_from_env() finally: os.unlink(f.name) @@ -609,9 +579,7 @@ def _encrypted_pem(key: rsa.RSAPrivateKey, passphrase: bytes) -> bytes: encryption_algorithm=serialization.BestAvailableEncryption(passphrase), ) - def test_from_pem_with_passphrase_succeeds( - self, rsa_private_key: rsa.RSAPrivateKey - ) -> None: + def test_from_pem_with_passphrase_succeeds(self, rsa_private_key: rsa.RSAPrivateKey) -> None: encrypted = self._encrypted_pem(rsa_private_key, b"correct-horse") auth = KalshiAuth.from_pem("key", encrypted, password="correct-horse") assert auth.key_id == "key" @@ -743,9 +711,7 @@ def handler(request: httpx.Request) -> httpx.Response: with respx.mock: # Match the canonicalized (no-trailing-slash) URL — if the transport # forwarded "/markets/" to httpx, respx would 404 this request. - respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( - side_effect=handler - ) + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(side_effect=handler) resp = transport.request("GET", "/markets/") assert resp.status_code == 200 sent = captured[0] diff --git a/tests/test_client.py b/tests/test_client.py index f53160c4..17982d07 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -71,9 +71,7 @@ def test_404_not_found(self) -> None: assert isinstance(err, KalshiNotFoundError) def test_429_rate_limit(self) -> None: - resp = httpx.Response( - 429, json={"message": "rate limited"}, headers={"Retry-After": "2.5"} - ) + resp = httpx.Response(429, json={"message": "rate limited"}, headers={"Retry-After": "2.5"}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) assert err.retry_after == 2.5 @@ -114,36 +112,28 @@ def test_validation_error_with_details(self) -> None: def test_429_retry_after_negative_falls_back(self) -> None: """Negative Retry-After is rejected so backoff isn't bypassed.""" - resp = httpx.Response( - 429, json={"message": "slow"}, headers={"Retry-After": "-1"} - ) + resp = httpx.Response(429, json={"message": "slow"}, headers={"Retry-After": "-1"}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) assert err.retry_after is None def test_429_retry_after_nan_falls_back(self) -> None: """NaN Retry-After is rejected so time.sleep never raises ValueError.""" - resp = httpx.Response( - 429, json={"message": "slow"}, headers={"Retry-After": "nan"} - ) + resp = httpx.Response(429, json={"message": "slow"}, headers={"Retry-After": "nan"}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) assert err.retry_after is None def test_429_retry_after_infinity_falls_back(self) -> None: """Infinity Retry-After is rejected (would survive min() cap math).""" - resp = httpx.Response( - 429, json={"message": "slow"}, headers={"Retry-After": "inf"} - ) + resp = httpx.Response(429, json={"message": "slow"}, headers={"Retry-After": "inf"}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) assert err.retry_after is None def test_429_retry_after_zero_is_kept(self) -> None: """Zero is a valid 'retry immediately' value; only <0 is rejected.""" - resp = httpx.Response( - 429, json={"message": "slow"}, headers={"Retry-After": "0"} - ) + resp = httpx.Response(429, json={"message": "slow"}, headers={"Retry-After": "0"}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) assert err.retry_after == 0.0 @@ -156,9 +146,7 @@ def test_429_retry_after_http_date_parsed(self) -> None: future = datetime.now(tz=UTC) + timedelta(days=5) http_date = _eu.format_datetime(future, usegmt=True) - resp = httpx.Response( - 429, json={"message": "slow"}, headers={"Retry-After": http_date} - ) + resp = httpx.Response(429, json={"message": "slow"}, headers={"Retry-After": http_date}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) # ~5 days ≈ 432000s; allow generous slack for the parse round-trip. @@ -178,9 +166,7 @@ def test_429_retry_after_past_http_date_clamped_to_zero(self) -> None: def test_429_retry_after_garbage_falls_back(self) -> None: """Neither delta-seconds nor a parseable HTTP-date — None, computed backoff.""" - resp = httpx.Response( - 429, json={"message": "slow"}, headers={"Retry-After": "not a date"} - ) + resp = httpx.Response(429, json={"message": "slow"}, headers={"Retry-After": "not a date"}) err = _map_error(resp) assert isinstance(err, KalshiRateLimitError) assert err.retry_after is None @@ -324,9 +310,7 @@ def test_429_retry_after_caps_at_retry_max_delay( resp = transport.request("GET", "/markets") assert resp.status_code == 200 # config fixture: retry_max_delay=0.1 — must clamp to that, NOT 9999. - assert sleeps == [0.1], ( - f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps!r}" - ) + assert sleeps == [0.1], f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps!r}" @respx.mock def test_retry_after_http_date_parsed_and_capped( @@ -408,9 +392,9 @@ def test_get_retries_on_timeout( @respx.mock def test_post_not_retried_on_timeout(self, transport: SyncTransport) -> None: """POST timeout raises immediately; no retry (duplicate-order risk).""" - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.TimeoutException("read timed out")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.TimeoutException("read timed out") + ) with pytest.raises(KalshiError, match="timed out"): transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 @@ -439,12 +423,12 @@ def test_timeout_message_does_not_contain_url( monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: None) # Use a sensitive-looking query param: if it shows up in the # KalshiError __str__, the leak is real. - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.TimeoutException( - "Request timed out for https://test.kalshi.com/trade-api/v2/" - "portfolio/orders?secret=SUPER_SECRET_TOKEN" - )) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.TimeoutException( + "Request timed out for https://test.kalshi.com/trade-api/v2/" + "portfolio/orders?secret=SUPER_SECRET_TOKEN" + ) + ) with pytest.raises(KalshiError) as exc_info: transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) msg = str(exc_info.value) @@ -459,12 +443,11 @@ def test_httperror_message_does_not_contain_url( self, transport: SyncTransport, ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock(side_effect=httpx.HTTPError( - "boom for https://test.kalshi.com/trade-api/v2/markets?" - "auth_token=LEAKY" - )) + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + side_effect=httpx.HTTPError( + "boom for https://test.kalshi.com/trade-api/v2/markets?auth_token=LEAKY" + ) + ) with pytest.raises(KalshiError) as exc_info: transport.request("GET", "/markets") msg = str(exc_info.value) @@ -497,6 +480,21 @@ def test_key_id_and_pem(self, pem_string: str) -> None: assert client._auth.key_id == "test-key" client.close() + def test_both_private_key_and_path_rejected(self, pem_string: str) -> None: + """#249: passing both ``private_key_path=`` and ``private_key=`` is + ambiguous (which key actually signs?). The old elif chain silently + preferred the path; a key-rotation mishap could leave production + signing with a stale credential. Now: explicit ValueError.""" + with pytest.raises( + ValueError, + match=r"Provide either private_key_path or private_key, not both", + ): + KalshiClient( + key_id="test-key", + private_key_path="/tmp/nonexistent.pem", + private_key=pem_string, + ) + def test_no_auth_constructs_unauthenticated(self) -> None: client = KalshiClient() assert client._auth is None @@ -709,9 +707,7 @@ def test_from_env_with_key_path( client.close() os.unlink(f.name) - def test_from_env_demo_flag( - self, monkeypatch: pytest.MonkeyPatch, pem_string: str - ) -> None: + def test_from_env_demo_flag(self, monkeypatch: pytest.MonkeyPatch, pem_string: str) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) monkeypatch.setenv("KALSHI_DEMO", "true") @@ -814,9 +810,7 @@ def test_positional_preserved(self) -> None: def test_carries_channel_op(self) -> None: from kalshi.errors import KalshiSubscriptionError - err = KalshiSubscriptionError( - "sub failed", channel="ticker", op="subscribe" - ) + err = KalshiSubscriptionError("sub failed", channel="ticker", op="subscribe") assert err.channel == "ticker" assert err.op == "subscribe" @@ -830,6 +824,7 @@ def test_orders_create_raises_auth_required(self) -> None: ) transport = SyncTransport(None, config) from kalshi.resources.orders import OrdersResource + resource = OrdersResource(transport) with pytest.raises(AuthRequiredError): resource.create(ticker="TEST", side="yes") @@ -842,6 +837,7 @@ def test_orders_list_raises_auth_required(self) -> None: ) transport = SyncTransport(None, config) from kalshi.resources.orders import OrdersResource + resource = OrdersResource(transport) with pytest.raises(AuthRequiredError): resource.list() @@ -854,6 +850,7 @@ def test_portfolio_balance_raises_auth_required(self) -> None: ) transport = SyncTransport(None, config) from kalshi.resources.portfolio import PortfolioResource + resource = PortfolioResource(transport) with pytest.raises(AuthRequiredError): resource.balance() @@ -866,7 +863,12 @@ def test_series_forecast_raises_auth_required(self) -> None: client = KalshiClient(config=config, demo=True) with pytest.raises(AuthRequiredError): client.series.forecast_percentile_history( - "SER", "EVT", percentiles=[5000], start_ts=0, end_ts=1, period_interval=60, + "SER", + "EVT", + percentiles=[5000], + start_ts=0, + end_ts=1, + period_interval=60, ) client.close() @@ -903,6 +905,7 @@ def test_markets_list_does_not_raise_auth_required(self) -> None: ) transport = SyncTransport(None, config) from kalshi.resources.markets import MarketsResource + resource = MarketsResource(transport) # Should succeed without raising AuthRequiredError page = resource.list() @@ -939,10 +942,13 @@ def test_has_all_resources(self) -> None: @respx.mock def test_public_endpoint_works(self) -> None: respx.get("https://demo-api.kalshi.co/trade-api/v2/exchange/status").mock( - return_value=httpx.Response(200, json={ - "exchange_active": True, - "trading_active": True, - }) + return_value=httpx.Response( + 200, + json={ + "exchange_active": True, + "trading_active": True, + }, + ) ) client = KalshiClient(demo=True) status = client.exchange.status() @@ -969,13 +975,24 @@ def test_from_env_no_credentials_returns_unauthenticated( assert client._auth is None client.close() + def test_from_env_typed_kwargs_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None: + """#266: typed kwargs in ``ClientInitKwargs`` reach ``__init__``. The + old ``**kwargs: object`` signature would have accepted ``time_out=10`` + silently; the Unpack[TypedDict] signature catches that statically via + mypy. Runtime check pins that the forwarder still wires through + ``timeout`` and ``max_retries`` from the kwargs path.""" + monkeypatch.delenv("KALSHI_KEY_ID", raising=False) + client = KalshiClient.from_env(timeout=7.5, max_retries=11) + assert client._config.timeout == 7.5 + assert client._config.max_retries == 11 + client.close() + + class TestWidenedRetrySet: """#192: Cloudflare 5xx (520-524) + 408 are retryable on safe methods only.""" @respx.mock - def test_retry_includes_cloudflare_5xx_521_on_get( - self, transport: SyncTransport - ) -> None: + def test_retry_includes_cloudflare_5xx_521_on_get(self, transport: SyncTransport) -> None: route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( side_effect=[ httpx.Response(521, text="Web Server Is Down"), @@ -988,9 +1005,9 @@ def test_retry_includes_cloudflare_5xx_521_on_get( @respx.mock def test_post_521_not_retried(self, transport: SyncTransport) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(521, text="down")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(521, text="down") + ) with pytest.raises(KalshiServerError): transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 @@ -1071,9 +1088,7 @@ def test_pool_timeout_retried_on_post( self, transport: SyncTransport, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: None) - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( side_effect=[ httpx.PoolTimeout("pool full"), httpx.Response(200, json={"order_id": "abc"}), @@ -1088,9 +1103,9 @@ def test_pool_timeout_raises_KalshiPoolExhaustedError( # noqa: N802 self, transport: SyncTransport, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: None) - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.PoolTimeout("pool full")) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.PoolTimeout("pool full") + ) with pytest.raises(KalshiPoolExhaustedError, match="pool exhausted"): transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) @@ -1102,9 +1117,9 @@ class TestTypedTimeoutException: def test_read_timeout_on_post_raises_KalshiTimeoutError_no_retry( # noqa: N802 self, transport: SyncTransport ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.ReadTimeout("read timed out")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.ReadTimeout("read timed out") + ) with pytest.raises(KalshiTimeoutError, match="timed out"): transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 @@ -1131,9 +1146,7 @@ def test_total_timeout_short_circuits_retries( ) transport = SyncTransport(test_auth, cfg) sleeps: list[float] = [] - monkeypatch.setattr( - "kalshi._base_client.time.sleep", lambda d: sleeps.append(d) - ) + monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: sleeps.append(d)) # Fake monotonic: start at 0, jump past the 0.05s budget on every # subsequent poll so the very first retry's budget check trips. clock = {"t": 0.0} @@ -1143,9 +1156,7 @@ def fake_monotonic() -> float: clock["t"] += 1.0 return t - monkeypatch.setattr( - "kalshi._base_client.time.monotonic", fake_monotonic - ) + monkeypatch.setattr("kalshi._base_client.time.monotonic", fake_monotonic) route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( return_value=httpx.Response(502, text="bad gateway") ) @@ -1183,9 +1194,7 @@ def test_total_timeout_None_preserves_legacy_behavior( # noqa: N802 class TestCloseOwnership: """#210: close() must not shut down a caller-owned KalshiAuth.""" - def test_close_does_not_shut_externally_provided_auth( - self, test_auth: KalshiAuth - ) -> None: + def test_close_does_not_shut_externally_provided_auth(self, test_auth: KalshiAuth) -> None: # Two clients share the same auth; closing one must not break the other. client_a = KalshiClient(auth=test_auth) client_b = KalshiClient(auth=test_auth) @@ -1206,9 +1215,7 @@ def test_close_shuts_locally_constructed_auth(self, pem_string: str) -> None: # Idempotent: close() is safe to call twice without raising. client.close() - def test_from_env_owns_auth( - self, monkeypatch: pytest.MonkeyPatch, pem_string: str - ) -> None: + def test_from_env_owns_auth(self, monkeypatch: pytest.MonkeyPatch, pem_string: str) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) @@ -1218,9 +1225,7 @@ def test_from_env_owns_auth( assert client._auth_owned is True client.close() - def test_from_env_no_credentials_owns_nothing( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_from_env_no_credentials_owns_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("KALSHI_KEY_ID", raising=False) monkeypatch.delenv("KALSHI_PRIVATE_KEY", raising=False) monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) @@ -1240,9 +1245,7 @@ class TestExtraHeadersPerRequest: """ @respx.mock - def test_extra_headers_per_request_overrides_config( - self, test_auth: KalshiAuth - ) -> None: + def test_extra_headers_per_request_overrides_config(self, test_auth: KalshiAuth) -> None: config = KalshiConfig( base_url="https://test.kalshi.com/trade-api/v2", timeout=5.0, @@ -1255,9 +1258,7 @@ def handler(request: httpx.Request) -> httpx.Response: captured.append(request) return httpx.Response(200, json={}) - route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( - side_effect=handler - ) + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(side_effect=handler) resp = transport.request( "GET", "/markets", @@ -1354,9 +1355,7 @@ def test_post_retries_on_connect_error( self, transport: SyncTransport, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: None) - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( side_effect=[ httpx.ConnectError("connection refused"), httpx.Response(200, json={"order_id": "abc"}), @@ -1373,9 +1372,9 @@ def test_post_does_not_retry_on_read_error( # ReadError on POST may indicate the server accepted a partial commit; # surface immediately so the caller can reconcile via client_order_id. monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: None) - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(side_effect=httpx.ReadError("socket read failed")) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + side_effect=httpx.ReadError("socket read failed") + ) with pytest.raises(KalshiNetworkError, match="Network error") as exc_info: transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 diff --git a/tests/test_config.py b/tests/test_config.py index 34764ca5..f2e40b35 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,8 @@ from __future__ import annotations +import logging + import httpx import pytest import respx @@ -141,9 +143,7 @@ def test_extra_headers_forwarded_to_request(self, test_auth: KalshiAuth) -> None transport.close() @respx.mock - def test_extra_headers_persist_across_multiple_requests( - self, test_auth: KalshiAuth - ) -> None: + 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( @@ -198,9 +198,7 @@ def test_http2_flag_forwarded_to_sync_client(self, test_auth: KalshiAuth) -> Non finally: transport.close() - def test_custom_limits_forwarded_to_sync_client( - self, test_auth: KalshiAuth - ) -> None: + def test_custom_limits_forwarded_to_sync_client(self, test_auth: KalshiAuth) -> None: limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) config = KalshiConfig(limits=limits) transport = SyncTransport(test_auth, config) @@ -281,6 +279,8 @@ def test_ws_json_loads_callable_stored(self) -> None: cfg = KalshiConfig(ws_json_loads=json.loads, ws_json_dumps=json.dumps) assert cfg.ws_json_loads is json.loads assert cfg.ws_json_dumps is json.dumps + + class TestHttp2ImportCheck: """P1.1: ``http2=True`` must fail-fast at construction if ``h2`` is missing, instead of deferring an opaque ImportError to the first request. @@ -337,3 +337,88 @@ def tracking_find_spec(name: str, *args: object, **kwargs: object) -> object: monkeypatch.setattr(_ilu, "find_spec", tracking_find_spec) KalshiConfig() assert "h2" not in called + + +_UNKNOWN_BASE = "https://attacker.example/trade-api/v2" +_UNKNOWN_WS = "wss://attacker.example/trade-api/ws/v2" + + +class TestUnknownHostDefaultFail: + """#250: a typo or hostile ``KALSHI_API_BASE_URL`` value used to slip + through with only ``logger.warning``. Production log filters silently + drop the warning while the SDK keeps signing every request — including + the KALSHI-ACCESS-KEY and matching RSA-PSS signature — to the attacker. + Now: default-fail, with explicit opt-in for mock servers and proxies.""" + + def test_known_host_ok(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) + config = KalshiConfig(base_url=PRODUCTION_BASE_URL, ws_base_url=PRODUCTION_WS_URL) + assert config.base_url == PRODUCTION_BASE_URL + + def test_unknown_host_raises_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError, match=r"not a known Kalshi endpoint"): + KalshiConfig(base_url=_UNKNOWN_BASE, ws_base_url=_UNKNOWN_WS) + + def test_unknown_host_error_message_lists_known_hosts( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError) as excinfo: + KalshiConfig(base_url=_UNKNOWN_BASE, ws_base_url=_UNKNOWN_WS) + msg = str(excinfo.value) + assert "api.elections.kalshi.com" in msg + assert "demo-api.kalshi.co" in msg + assert "allow_unknown_host=True" in msg + assert "KALSHI_ALLOW_UNKNOWN_HOST=1" in msg + + def test_unknown_host_with_flag_opt_in( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) + with caplog.at_level(logging.WARNING, logger="kalshi"): + config = KalshiConfig( + base_url=_UNKNOWN_BASE, + ws_base_url=_UNKNOWN_WS, + allow_unknown_host=True, + ) + assert config.base_url == _UNKNOWN_BASE + assert any("not a known Kalshi endpoint" in r.message for r in caplog.records) + + def test_unknown_host_with_env_opt_in( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("KALSHI_ALLOW_UNKNOWN_HOST", "1") + with caplog.at_level(logging.WARNING, logger="kalshi"): + config = KalshiConfig(base_url=_UNKNOWN_BASE, ws_base_url=_UNKNOWN_WS) + assert config.base_url == _UNKNOWN_BASE + assert any("not a known Kalshi endpoint" in r.message for r in caplog.records) + + def test_env_opt_in_only_honors_exact_one(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Any value other than literal "1" must NOT opt in — otherwise a stale + # ``KALSHI_ALLOW_UNKNOWN_HOST=0`` from a previous shell would silently + # disable the check. + monkeypatch.setenv("KALSHI_ALLOW_UNKNOWN_HOST", "0") + with pytest.raises(ValueError, match=r"not a known Kalshi endpoint"): + KalshiConfig(base_url=_UNKNOWN_BASE, ws_base_url=_UNKNOWN_WS) + monkeypatch.setenv("KALSHI_ALLOW_UNKNOWN_HOST", "true") + with pytest.raises(ValueError, match=r"not a known Kalshi endpoint"): + KalshiConfig(base_url=_UNKNOWN_BASE, ws_base_url=_UNKNOWN_WS) + + def test_localhost_ok_without_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) + config = KalshiConfig( + base_url="http://localhost:8080/trade-api/v2", + ws_base_url="ws://localhost:8080/trade-api/ws/v2", + ) + assert "localhost" in config.base_url + + def test_unknown_ws_host_also_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Unknown ws_base_url with a known base_url must also default-fail — + # the WS feed leaks the same KALSHI-ACCESS-KEY on connect. + monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError, match=r"not a known Kalshi endpoint"): + KalshiConfig( + base_url=PRODUCTION_BASE_URL, + ws_base_url="wss://attacker.example/trade-api/ws/v2", + )