diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 0a307894b..ef6fbfd80 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -133,7 +133,7 @@ jobs: - name: Run All Tests run: make test-client-python env: - OPEN_API_REF: f9709139a3693f6624efda12a001e242c5d506b6 + OPEN_API_REF: c0b62b28b14d0d164d37a1f6bf19dc9d39e5769b - name: Check for SDK changes run: | diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index d9ca067e2..9ea10c327 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -2,7 +2,12 @@ ## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD) -### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.4...{{packageVersion}}) (2025-07-09) +### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.5...{{packageVersion}}) (2025-09-15) + +- fix: reuse ssl context in the sync client (#222) - thanks @wadells! +- feat: add OAuth2 scopes parameter support to CredentialConfiguration (#213) - thanks @SoulPancake + +### [v0.9.5](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.9.4...v0.9.5) (2025-07-09) - fix: aiohttp.ClientResponse.data should be awaited (#197) - thanks @cmbernard333 diff --git a/config/clients/python/config.overrides.json b/config/clients/python/config.overrides.json index dcde9763f..096bb6ee9 100644 --- a/config/clients/python/config.overrides.json +++ b/config/clients/python/config.overrides.json @@ -2,7 +2,7 @@ "sdkId": "python", "gitRepoId": "python-sdk", "packageName": "openfga_sdk", - "packageVersion": "0.9.5", + "packageVersion": "0.9.6", "packageDescription": "Python SDK for OpenFGA", "packageDetailedDescription": "This is an autogenerated python SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).", "fossaComplianceNoticeId": "2f8a8629-b46c-435e-b8cd-1174a674fb4b", diff --git a/config/clients/python/template/test/sync/rest_test.py.mustache b/config/clients/python/template/test/sync/rest_test.py.mustache index 561e698a3..a0d680f48 100644 --- a/config/clients/python/template/test/sync/rest_test.py.mustache +++ b/config/clients/python/template/test/sync/rest_test.py.mustache @@ -1,7 +1,9 @@ {{>partial_header}} import json -from unittest.mock import MagicMock +import ssl + +from unittest.mock import MagicMock, patch import pytest @@ -520,3 +522,214 @@ def test_stream_exception_in_chunks(): # Exception is logged, we yield nothing assert results == [] mock_pool_manager.request.assert_called_once() + + +# Tests for SSL Context Reuse (fix for OpenSSL 3.0+ performance issues) +@patch("ssl.create_default_context") +@patch("urllib3.PoolManager") +def test_ssl_context_created_with_ca_cert(mock_pool_manager, mock_create_context): + """Test that SSL context is created with CA certificate file.""" + mock_ssl_context = MagicMock() + mock_create_context.return_value = mock_ssl_context + + mock_config = MagicMock() + mock_config.ssl_ca_cert = "/path/to/ca.pem" + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + + RESTClientObject(configuration=mock_config) + + # Verify SSL context was created with CA file + mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem") + + # Verify SSL context was passed to PoolManager + mock_pool_manager.assert_called_once() + call_kwargs = mock_pool_manager.call_args[1] + assert call_kwargs["ssl_context"] == mock_ssl_context + + +@patch("ssl.create_default_context") +@patch("urllib3.PoolManager") +def test_ssl_context_loads_client_certificate(mock_pool_manager, mock_create_context): + """Test that SSL context loads client certificate and key when provided.""" + mock_ssl_context = MagicMock() + mock_create_context.return_value = mock_ssl_context + + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = "/path/to/client.pem" + mock_config.key_file = "/path/to/client.key" + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + + RESTClientObject(configuration=mock_config) + + # Verify SSL context was created + mock_create_context.assert_called_once_with(cafile=None) + + # Verify client certificate was loaded + mock_ssl_context.load_cert_chain.assert_called_once_with( + "/path/to/client.pem", keyfile="/path/to/client.key" + ) + + # Verify SSL context was passed to PoolManager + mock_pool_manager.assert_called_once() + call_kwargs = mock_pool_manager.call_args[1] + assert call_kwargs["ssl_context"] == mock_ssl_context + + +@patch("ssl.create_default_context") +@patch("urllib3.PoolManager") +def test_ssl_context_disables_verification_when_verify_ssl_false( + mock_pool_manager, mock_create_context +): + """Test that SSL context disables verification when verify_ssl=False.""" + mock_ssl_context = MagicMock() + mock_create_context.return_value = mock_ssl_context + + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = False + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + + RESTClientObject(configuration=mock_config) + + # Verify SSL context was created + mock_create_context.assert_called_once_with(cafile=None) + + # Verify SSL verification was disabled + assert mock_ssl_context.check_hostname is False + assert mock_ssl_context.verify_mode == ssl.CERT_NONE + + # Verify SSL context was passed to PoolManager + mock_pool_manager.assert_called_once() + call_kwargs = mock_pool_manager.call_args[1] + assert call_kwargs["ssl_context"] == mock_ssl_context + + +@patch("ssl.create_default_context") +@patch("urllib3.ProxyManager") +def test_ssl_context_used_with_proxy_manager(mock_proxy_manager, mock_create_context): + """Test that SSL context is passed to ProxyManager when proxy is configured.""" + mock_ssl_context = MagicMock() + mock_create_context.return_value = mock_ssl_context + + mock_config = MagicMock() + mock_config.ssl_ca_cert = "/path/to/ca.pem" + mock_config.cert_file = "/path/to/client.pem" + mock_config.key_file = "/path/to/client.key" + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = "http://proxy:8080" + mock_config.proxy_headers = {"Proxy-Auth": "token"} + + RESTClientObject(configuration=mock_config) + + # Verify SSL context was created with CA file + mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem") + + # Verify client certificate was loaded + mock_ssl_context.load_cert_chain.assert_called_once_with( + "/path/to/client.pem", keyfile="/path/to/client.key" + ) + + # Verify SSL context was passed to ProxyManager + mock_proxy_manager.assert_called_once() + call_kwargs = mock_proxy_manager.call_args[1] + assert call_kwargs["ssl_context"] == mock_ssl_context + assert call_kwargs["proxy_url"] == "http://proxy:8080" + assert call_kwargs["proxy_headers"] == {"Proxy-Auth": "token"} + + +@patch("ssl.create_default_context") +@patch("urllib3.PoolManager") +def test_ssl_context_reuse_performance_optimization( + mock_pool_manager, mock_create_context +): + """Test that SSL context creation is called only once per client instance.""" + mock_ssl_context = MagicMock() + mock_create_context.return_value = mock_ssl_context + + mock_config = MagicMock() + mock_config.ssl_ca_cert = "/path/to/ca.pem" + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + + # Create client instance + client = RESTClientObject(configuration=mock_config) + + # Verify SSL context was created exactly once + mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem") + + # Verify the same SSL context instance is reused + mock_pool_manager.assert_called_once() + call_kwargs = mock_pool_manager.call_args[1] + assert call_kwargs["ssl_context"] is mock_ssl_context + + # Verify context was not created again during subsequent operations + mock_create_context.reset_mock() + + # Build a request (this should not trigger SSL context creation) + client.build_request("GET", "https://example.com") + + # SSL context should not be created again + mock_create_context.assert_not_called() + + +@patch("ssl.create_default_context") +@patch("urllib3.PoolManager") +def test_ssl_context_with_all_ssl_options(mock_pool_manager, mock_create_context): + """Test SSL context creation with all SSL configuration options set.""" + mock_ssl_context = MagicMock() + mock_create_context.return_value = mock_ssl_context + + mock_config = MagicMock() + mock_config.ssl_ca_cert = "/path/to/ca.pem" + mock_config.cert_file = "/path/to/client.pem" + mock_config.key_file = "/path/to/client.key" + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 8 + mock_config.timeout_millisec = 10000 + mock_config.proxy = None + + RESTClientObject(configuration=mock_config) + + # Verify SSL context was created with CA file + mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem") + + # Verify client certificate was loaded + mock_ssl_context.load_cert_chain.assert_called_once_with( + "/path/to/client.pem", keyfile="/path/to/client.key" + ) + + # Verify SSL verification settings were NOT modified (verify_ssl=True) + # check_hostname and verify_mode should remain at their default secure values + assert ( + not hasattr(mock_ssl_context, "check_hostname") + or mock_ssl_context.check_hostname + ) + assert ( + not hasattr(mock_ssl_context, "verify_mode") + or mock_ssl_context.verify_mode != ssl.CERT_NONE + ) + + # Verify SSL context was passed to PoolManager + mock_pool_manager.assert_called_once() + call_kwargs = mock_pool_manager.call_args[1] + assert call_kwargs["ssl_context"] == mock_ssl_context + assert call_kwargs["maxsize"] == 8