diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 78f1a13ce4..c82bad3f02 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -20,6 +20,7 @@ import logging import os import shutil import shlex +import ssl import stat import base64 import hashlib @@ -1663,7 +1664,7 @@ def _resolve_verify( insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, auth_state: Optional[Dict[str, Any]] = None, -) -> bool | str: +) -> bool | ssl.SSLContext: tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {} tls_state = tls_state if isinstance(tls_state, dict) else {} @@ -1683,13 +1684,12 @@ def _resolve_verify( if effective_ca: ca_path = str(effective_ca) if not os.path.isfile(ca_path): - import logging - logging.getLogger("hermes.auth").warning( + logger.warning( "CA bundle path does not exist: %s — falling back to default certificates", ca_path, ) return True - return ca_path + return ssl.create_default_context(cafile=ca_path) return True diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 89a2455041..3a58282ca2 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -27,15 +27,23 @@ class TestResolveVerifyFallback: }) assert result is True - def test_valid_ca_bundle_in_auth_state_is_returned(self, tmp_path): + def test_valid_ca_bundle_in_auth_state_is_returned(self, tmp_path, monkeypatch): + import ssl from hermes_cli.auth import _resolve_verify ca_file = tmp_path / "ca-bundle.pem" ca_file.write_text("fake cert") + + # Avoid loading actual PEM — just verify the return type + mock_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + monkeypatch.setattr(ssl, "create_default_context", lambda **kw: mock_ctx) + result = _resolve_verify(auth_state={ "tls": {"insecure": False, "ca_bundle": str(ca_file)}, }) - assert result == str(ca_file) + assert isinstance(result, ssl.SSLContext), ( + f"Expected ssl.SSLContext but got {type(result).__name__}: {result!r}" + ) def test_missing_ssl_cert_file_env_falls_back(self, monkeypatch): from hermes_cli.auth import _resolve_verify @@ -76,13 +84,21 @@ class TestResolveVerifyFallback: result = _resolve_verify(ca_bundle="/nonexistent/explicit-ca.pem") assert result is True - def test_explicit_ca_bundle_param_valid_is_returned(self, tmp_path): + def test_explicit_ca_bundle_param_valid_is_returned(self, tmp_path, monkeypatch): + import ssl from hermes_cli.auth import _resolve_verify ca_file = tmp_path / "explicit-ca.pem" ca_file.write_text("fake cert") + + # Avoid loading actual PEM — just verify the return type + mock_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + monkeypatch.setattr(ssl, "create_default_context", lambda **kw: mock_ctx) + result = _resolve_verify(ca_bundle=str(ca_file)) - assert result == str(ca_file) + assert isinstance(result, ssl.SSLContext), ( + f"Expected ssl.SSLContext but got {type(result).__name__}: {result!r}" + ) def _setup_nous_auth( diff --git a/tests/test_resolve_verify_ssl_context.py b/tests/test_resolve_verify_ssl_context.py new file mode 100644 index 0000000000..0b29378228 --- /dev/null +++ b/tests/test_resolve_verify_ssl_context.py @@ -0,0 +1,84 @@ +"""Tests for _resolve_verify returning ssl.SSLContext instead of str for CA bundles. + +This test verifies the fix for bug #12706: httpx deprecates verify= and +expects ssl.SSLContext when a custom CA bundle is configured. + +The test should: +- FAIL before the fix (returns str path) +- PASS after the fix (returns ssl.SSLContext) +""" + +import os +import ssl + +import pytest + +from hermes_cli.auth import _resolve_verify + + +# Use the system's default CA bundle for testing +DEFAULT_CA_FILE = ssl.get_default_verify_paths().cafile + + +class TestResolveVerifySslContext: + """Test that _resolve_verify returns ssl.SSLContext for CA bundles.""" + + def test_resolve_verify_returns_ssl_context_for_ca_bundle(self, monkeypatch): + """When a CA bundle path is provided, _resolve_verify returns an ssl.SSLContext.""" + # Clear any env vars that might interfere + monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + # Use the system's actual CA bundle which is a valid PEM file + result = _resolve_verify(ca_bundle=DEFAULT_CA_FILE) + + # The result should be an ssl.SSLContext, NOT a string + assert isinstance(result, ssl.SSLContext), ( + f"Expected ssl.SSLContext but got {type(result).__name__}: {result!r}. " + "httpx deprecates verify= and requires ssl.SSLContext." + ) + + def test_resolve_verify_returns_true_when_no_ca_bundle(self, monkeypatch): + """When no CA bundle is configured, _resolve_verify returns True (not a path).""" + # Clear any env vars that might interfere + monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + result = _resolve_verify() + assert result is True, f"Expected True but got {result!r}" + + def test_resolve_verify_returns_true_for_missing_ca_bundle_path(self, monkeypatch): + """When a CA bundle path is configured but doesn't exist, returns True.""" + monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + result = _resolve_verify(ca_bundle="/nonexistent/path/to/ca-bundle.crt") + assert result is True, f"Expected True for missing CA bundle but got {result!r}" + + def test_resolve_verify_returns_false_when_insecure_is_true(self, monkeypatch): + """When insecure=True, _resolve_verify returns False (skip SSL verification).""" + monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + result = _resolve_verify(insecure=True) + assert result is False, f"Expected False for insecure=True but got {result!r}" + + def test_resolve_verify_returns_ssl_context_from_hermes_ca_bundle_env(self, monkeypatch): + """SSLContext is returned when HERMES_CA_BUNDLE env var is set.""" + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.setenv("HERMES_CA_BUNDLE", DEFAULT_CA_FILE) + + result = _resolve_verify() + assert isinstance(result, ssl.SSLContext), ( + f"Expected ssl.SSLContext from HERMES_CA_BUNDLE env var, got {type(result).__name__}" + ) + + def test_resolve_verify_returns_ssl_context_from_ssl_cert_file_env(self, monkeypatch): + """SSLContext is returned when SSL_CERT_FILE env var is set.""" + monkeypatch.delenv("HERMES_CA_BUNDLE", raising=False) + monkeypatch.setenv("SSL_CERT_FILE", DEFAULT_CA_FILE) + + result = _resolve_verify() + assert isinstance(result, ssl.SSLContext), ( + f"Expected ssl.SSLContext from SSL_CERT_FILE env var, got {type(result).__name__}" + )