diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index 78937c032670..aaebf2ba9e8e 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,10 +25,12 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", + "google.api_core.gapic_v1.client_utils", "google.api_core.gapic_v1.requests", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "requests", "routing_header"] +__all__ = ["client_info", "client_utils", "requests", "routing_header"] + if _has_grpc: __lazy_modules__.update( @@ -42,6 +44,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, + client_utils, requests, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/client_utils.py b/packages/google-api-core/google/api_core/gapic_v1/client_utils.py new file mode 100644 index 000000000000..b70f8ff65529 --- /dev/null +++ b/packages/google-api-core/google/api_core/gapic_v1/client_utils.py @@ -0,0 +1,133 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import Optional +from urllib.parse import urlparse, urlunparse + +from google.auth.exceptions import MutualTLSChannelError # type: ignore + + +def get_default_mtls_endpoint(api_endpoint: Optional[str]) -> Optional[str]: + """Converts api endpoint to mTLS endpoint. + + Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to + "*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively. + Other URLs (including those that do not match these domain suffixes or + already contain '.mtls.') are passed through as-is. + + Args: + api_endpoint (Optional[str]): the api endpoint to convert. + + Returns: + Optional[str]: converted mTLS api endpoint. + """ + if not api_endpoint or ".mtls." in api_endpoint.lower(): + return api_endpoint + + has_scheme = "://" in api_endpoint + if not has_scheme: + parsed = urlparse("//" + api_endpoint) + else: + parsed = urlparse(api_endpoint) + + host = parsed.hostname + if not host: + return api_endpoint + + port = f":{parsed.port}" if parsed.port else "" + + lowered_host = host.lower() + if lowered_host.endswith(".sandbox.googleapis.com"): + new_host = host[:-23] + ".mtls.sandbox.googleapis.com" + elif lowered_host.endswith(".googleapis.com"): + new_host = host[:-15] + ".mtls.googleapis.com" + else: + return api_endpoint + + netloc = new_host + port + new_parsed = parsed._replace(netloc=netloc) + + if not has_scheme: + return urlunparse(new_parsed)[2:] + else: + return urlunparse(new_parsed) + + +def get_api_endpoint( + api_override: Optional[str], + universe_domain: str, + default_universe: str, + default_mtls_endpoint: Optional[str], + default_endpoint_template: str, + use_mtls: bool, +) -> str: + """Return the API endpoint used by the client. + + Args: + api_override (Optional[str]): The API endpoint override. If specified, + this is always returned. + universe_domain (str): The universe domain used by the client. + default_universe (str): The default universe domain. + default_mtls_endpoint (Optional[str]): The default mTLS endpoint. + default_endpoint_template (str): The default endpoint template containing + a placeholder `{UNIVERSE_DOMAIN}`. + use_mtls (bool): Whether to use the mTLS endpoint. + + Returns: + str: The API endpoint to be used by the client. + + Raises: + google.auth.exceptions.MutualTLSChannelError: If mTLS is requested but + not supported in the configured universe domain. + ValueError: If mTLS is requested but no mTLS endpoint is available. + """ + if api_override is not None: + return api_override + + if use_mtls: + if universe_domain.lower() != default_universe.lower(): + raise MutualTLSChannelError( + f"mTLS is not supported in any universe other than {default_universe}." + ) + if not default_mtls_endpoint: + raise ValueError("mTLS endpoint is not available.") + return default_mtls_endpoint + else: + return default_endpoint_template.format(UNIVERSE_DOMAIN=universe_domain) + + +def get_universe_domain( + universe_domain: Optional[str], + default_universe: str = "googleapis.com", +) -> str: + """Return the universe domain used by the client. + + Args: + universe_domain (Optional[str]): The configured universe domain. + default_universe (str): The default universe domain. + + Returns: + str: The universe domain to be used by the client. + + Raises: + ValueError: If the resolved universe domain is an empty string. + """ + resolved = ( + universe_domain.strip() if universe_domain is not None else default_universe + ) + + if not resolved: + raise ValueError("Universe Domain cannot be an empty string.") + return resolved diff --git a/packages/google-api-core/google/api_core/gapic_v1/requests.py b/packages/google-api-core/google/api_core/gapic_v1/requests.py index f440ac69126c..8ce97c9cffa7 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/requests.py +++ b/packages/google-api-core/google/api_core/gapic_v1/requests.py @@ -21,8 +21,8 @@ if they are not already set. """ -from typing import Union import uuid +from typing import Union import google.protobuf.message diff --git a/packages/google-api-core/tests/conftest.py b/packages/google-api-core/tests/conftest.py new file mode 100644 index 000000000000..62a3c999f733 --- /dev/null +++ b/packages/google-api-core/tests/conftest.py @@ -0,0 +1,31 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from unittest import mock + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def mock_mtls_env(): + """Autouse session-scoped fixture to isolate unit tests from workstation mTLS environments.""" + with mock.patch.dict( + os.environ, + { + "GOOGLE_API_USE_CLIENT_CERTIFICATE": "false", + "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false", + }, + ): + yield diff --git a/packages/google-api-core/tests/unit/gapic/test_client_utils.py b/packages/google-api-core/tests/unit/gapic/test_client_utils.py new file mode 100644 index 000000000000..55d2619fa579 --- /dev/null +++ b/packages/google-api-core/tests/unit/gapic/test_client_utils.py @@ -0,0 +1,179 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from google.auth.exceptions import MutualTLSChannelError + +from google.api_core.gapic_v1.client_utils import ( + get_api_endpoint, + get_default_mtls_endpoint, + get_universe_domain, +) + + +def test_get_default_mtls_endpoint(): + # Test valid API endpoints + assert get_default_mtls_endpoint("foo.googleapis.com") == "foo.mtls.googleapis.com" + assert ( + get_default_mtls_endpoint("foo.sandbox.googleapis.com") + == "foo.mtls.sandbox.googleapis.com" + ) + # Test case-insensitivity + assert get_default_mtls_endpoint("foo.GoogleAPIs.com") == "foo.mtls.googleapis.com" + assert ( + get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com") + == "foo.mtls.sandbox.googleapis.com" + ) + + # Test valid API endpoints with schemes + assert ( + get_default_mtls_endpoint("https://foo.googleapis.com") + == "https://foo.mtls.googleapis.com" + ) + assert ( + get_default_mtls_endpoint("http://foo.googleapis.com:8080/v1") + == "http://foo.mtls.googleapis.com:8080/v1" + ) + + # Test valid API endpoints with ports + assert ( + get_default_mtls_endpoint("foo.googleapis.com:443") + == "foo.mtls.googleapis.com:443" + ) + assert ( + get_default_mtls_endpoint("foo.sandbox.googleapis.com:443") + == "foo.mtls.sandbox.googleapis.com:443" + ) + # Test case-insensitivity with ports + assert ( + get_default_mtls_endpoint("foo.GoogleAPIs.com:443") + == "foo.mtls.googleapis.com:443" + ) + assert ( + get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com:443") + == "foo.mtls.sandbox.googleapis.com:443" + ) + + # Test endpoints that shouldn't be converted + assert ( + get_default_mtls_endpoint("foo.mtls.googleapis.com") + == "foo.mtls.googleapis.com" + ) + assert get_default_mtls_endpoint("foo.com") == "foo.com" + assert get_default_mtls_endpoint("foo.com:8080") == "foo.com:8080" + + # Test empty/None endpoints + assert get_default_mtls_endpoint("") == "" + assert get_default_mtls_endpoint(None) is None + + +@pytest.mark.parametrize( + "api_override,universe_domain,default_universe,default_mtls_endpoint,default_endpoint_template,use_mtls,expected", + [ + ( + "foo.com", + "googleapis.com", + "googleapis.com", + "foo.mtls.googleapis.com", + "foo.{UNIVERSE_DOMAIN}", + True, + "foo.com", + ), + ( + None, + "googleapis.com", + "googleapis.com", + "foo.mtls.googleapis.com", + "foo.{UNIVERSE_DOMAIN}", + True, + "foo.mtls.googleapis.com", + ), + ( + None, + "googleapis.com", + "googleapis.com", + "foo.mtls.googleapis.com", + "foo.{UNIVERSE_DOMAIN}", + False, + "foo.googleapis.com", + ), + ( + None, + "bar.com", + "googleapis.com", + "foo.mtls.googleapis.com", + "foo.{UNIVERSE_DOMAIN}", + True, + MutualTLSChannelError, + ), + ( + None, + "googleapis.com", + "googleapis.com", + None, + "foo.{UNIVERSE_DOMAIN}", + True, + ValueError, + ), + ], +) +def test_get_api_endpoint( + api_override, + universe_domain, + default_universe, + default_mtls_endpoint, + default_endpoint_template, + use_mtls, + expected, +): + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + get_api_endpoint( + api_override, + universe_domain, + default_universe, + default_mtls_endpoint, + default_endpoint_template, + use_mtls, + ) + else: + assert ( + get_api_endpoint( + api_override, + universe_domain, + default_universe, + default_mtls_endpoint, + default_endpoint_template, + use_mtls, + ) + == expected + ) + + +def test_get_universe_domain(): + # When universe_domain is provided + assert get_universe_domain("foo.com", "default.com") == "foo.com" + assert get_universe_domain(" foo.com ", "default.com") == "foo.com" + + # When universe_domain is None, falls back to default_universe + assert get_universe_domain(None, "default.com") == "default.com" + + # ValueError raised when resolved value is empty string + with pytest.raises(ValueError) as excinfo: + get_universe_domain("", "default.com") + assert str(excinfo.value) == "Universe Domain cannot be an empty string." + + with pytest.raises(ValueError) as excinfo: + get_universe_domain(" ", "default.com") + assert str(excinfo.value) == "Universe Domain cannot be an empty string." diff --git a/packages/google-api-core/tests/unit/gapic/test_requests.py b/packages/google-api-core/tests/unit/gapic/test_requests.py index 1e921955d043..e046f31b828b 100644 --- a/packages/google-api-core/tests/unit/gapic/test_requests.py +++ b/packages/google-api-core/tests/unit/gapic/test_requests.py @@ -19,7 +19,6 @@ from google.api_core.gapic_v1.requests import setup_request_id - # --- Mock Request Helper Classes --- diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index eef0384652a6..12f4dda8e084 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -208,7 +208,7 @@ class _MutualTlsAdapter(requests.adapters.HTTPAdapter): google.auth.exceptions.MutualTLSChannelError: If the cert or key is invalid. """ - def __init__(self, cert, key): + def __init__(self, cert, key, **kwargs): import certifi import ssl @@ -250,7 +250,7 @@ def __init__(self, cert, key): self._ctx_poolmanager = ctx_poolmanager self._ctx_proxymanager = ctx_proxymanager - super(_MutualTlsAdapter, self).__init__() + super(_MutualTlsAdapter, self).__init__(**kwargs) def init_poolmanager(self, *args, **kwargs): kwargs["ssl_context"] = self._ctx_poolmanager @@ -457,6 +457,11 @@ def configure_mtls_channel(self, client_cert_callback=None): If the callback is None, application default SSL credentials will be used. + .. warning:: + Calling this method mutates the underlying `requests.Session` adapter + dictionary. It is not thread-safe to call this explicitly while other + threads are making requests. + Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel creation failed for any reason. The existing session state (such @@ -475,10 +480,58 @@ def configure_mtls_channel(self, client_cert_callback=None): client_cert_callback ) + old_adapter = self.adapters.get("https://") + + kwargs = {} + if old_adapter is not None: + kwargs["max_retries"] = getattr(old_adapter, "max_retries", 0) + kwargs["pool_connections"] = getattr( + old_adapter, "_pool_connections", requests.adapters.DEFAULT_POOLSIZE + ) + kwargs["pool_maxsize"] = getattr( + old_adapter, "_pool_maxsize", requests.adapters.DEFAULT_POOLSIZE + ) + kwargs["pool_block"] = getattr( + old_adapter, "_pool_block", requests.adapters.DEFAULT_POOLBLOCK + ) + + old_auth_adapter = None + auth_kwargs = {} + if self._auth_request_session is not None: + old_auth_adapter = self._auth_request_session.adapters.get("https://") + + if old_auth_adapter is not None: + auth_kwargs["max_retries"] = getattr( + old_auth_adapter, "max_retries", 0 + ) + auth_kwargs["pool_connections"] = getattr( + old_auth_adapter, + "_pool_connections", + requests.adapters.DEFAULT_POOLSIZE, + ) + auth_kwargs["pool_maxsize"] = getattr( + old_auth_adapter, + "_pool_maxsize", + requests.adapters.DEFAULT_POOLSIZE, + ) + auth_kwargs["pool_block"] = getattr( + old_auth_adapter, + "_pool_block", + requests.adapters.DEFAULT_POOLBLOCK, + ) + if is_mtls: - new_adapter = _MutualTlsAdapter(cert, key) + new_adapter = _MutualTlsAdapter(cert, key, **kwargs) + if self._auth_request_session is not None: + new_auth_adapter = _MutualTlsAdapter(cert, key, **auth_kwargs) + else: + new_auth_adapter = None else: - new_adapter = requests.adapters.HTTPAdapter() + new_adapter = requests.adapters.HTTPAdapter(**kwargs) + if self._auth_request_session is not None: + new_auth_adapter = requests.adapters.HTTPAdapter(**auth_kwargs) + else: + new_auth_adapter = None except ( exceptions.ClientCertError, ImportError, @@ -489,6 +542,19 @@ def configure_mtls_channel(self, client_cert_callback=None): raise new_exc from caught_exc self.mount("https://", new_adapter) + + if old_adapter is not None and old_adapter is not new_adapter: + old_adapter.close() + + if self._auth_request_session is not None and new_auth_adapter is not None: + self._auth_request_session.mount("https://", new_auth_adapter) + + if ( + old_auth_adapter is not None + and old_auth_adapter is not new_auth_adapter + ): + old_auth_adapter.close() + self._is_mtls = is_mtls if is_mtls: self._cached_cert = cert diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index 1b0fac7c342f..18e6128e03bd 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -335,6 +335,11 @@ def configure_mtls_channel(self, client_cert_callback=None): If the callback is None, application default SSL credentials will be used. + .. warning:: + Calling this method mutates the underlying `urllib3.PoolManager`. + It is not thread-safe to call this explicitly while other + threads are making requests. + Returns: True if the channel is mutual TLS and False otherwise. @@ -367,9 +372,15 @@ def configure_mtls_channel(self, client_cert_callback=None): new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc + old_http = self.http + self.http = new_http self._is_mtls = new_is_mtls self._request.http = new_http + + if old_http is not None and old_http is not new_http: + getattr(old_http, "clear", getattr(old_http, "close", lambda: None))() + if new_is_mtls: self._cached_cert = cert else: @@ -491,7 +502,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def __del__(self): if hasattr(self, "http") and self.http is not None: - self.http.clear() + getattr(self.http, "clear", getattr(self.http, "close", lambda: None))() @property def headers(self): diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index f14ccea58465..5aba3772132e 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -453,6 +453,141 @@ def test_configure_mtls_channel_with_metadata(self, mock_get_client_cert_and_key google.auth.transport.requests._MutualTlsAdapter, ) + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_closes_old_adapters( + self, mock_get_client_cert_and_key + ): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + old_main_adapter = mock.Mock(spec=requests.adapters.HTTPAdapter) + old_auth_adapter = mock.Mock(spec=requests.adapters.HTTPAdapter) + + auth_session.mount("https://", old_main_adapter) + auth_session._auth_request_session.mount("https://", old_auth_adapter) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + old_main_adapter.close.assert_called_once() + old_auth_adapter.close.assert_called_once() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_mounts_adapter_to_auth_request_session( + self, mock_get_client_cert_and_key + ): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + assert auth_session.is_mtls + # Main session gets the mTLS adapter + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + # _auth_request_session gets a separate adapter instance + assert isinstance( + auth_session._auth_request_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + assert ( + auth_session.adapters["https://"] + is not auth_session._auth_request_session.adapters["https://"] + ) + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_without_https_adapter( + self, mock_get_client_cert_and_key + ): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + + # Remove the 'https://' adapter to trigger InvalidSchema + auth_session.adapters.pop("https://", None) + auth_session._auth_request_session.adapters.pop("https://", None) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + assert auth_session.is_mtls + # Main session gets the mTLS adapter + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + # _auth_request_session gets the exact same adapter + assert isinstance( + auth_session._auth_request_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_without_auth_request_session( + self, mock_get_client_cert_and_key + ): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock(), auth_request=mock.Mock() + ) + assert auth_session._auth_request_session is None + + old_main_adapter = mock.Mock(spec=requests.adapters.HTTPAdapter) + auth_session.mount("https://", old_main_adapter) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + old_main_adapter.close.assert_called_once() + assert auth_session.is_mtls + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + @mock.patch.object(google.auth.transport.requests._MutualTlsAdapter, "__init__") @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 33674030aa8d..e1c92dbebc2c 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -243,6 +243,63 @@ def test_configure_mtls_channel_with_metadata( cert=pytest.public_cert_bytes, key=pytest.private_key_bytes ) + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_closes_old_poolmanager( + self, mock_get_client_cert_and_key, mock_make_mutual_tls_http + ): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + old_http = mock.create_autospec(urllib3.PoolManager) + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock(), http=old_http + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel() + + assert is_mtls + old_http.clear.assert_called_once() + mock_make_mutual_tls_http.assert_called_once_with( + cert=pytest.public_cert_bytes, key=pytest.private_key_bytes + ) + + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_with_none_http( + self, mock_get_client_cert_and_key, mock_make_mutual_tls_http + ): + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + authed_http.http = None # Force old_http to be None + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel() + + assert is_mtls + mock_make_mutual_tls_http.assert_called_once_with( + cert=pytest.public_cert_bytes, key=pytest.private_key_bytes + ) + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True