diff options
| author | robot-piglet <[email protected]> | 2025-05-20 09:26:45 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2025-05-20 09:42:10 +0300 |
| commit | 68cf18d5ea6913da54fcd36e31e2b6a178900729 (patch) | |
| tree | 08d557f92be11f90b786840c6757719ccb080aaf | |
| parent | 9de99a9451af6f728edc321e802add05499b4bc9 (diff) | |
Intermediate changes
commit_hash:4518e1fc835bd128306da321c9c8a7578cfde09c
15 files changed, 870 insertions, 13 deletions
diff --git a/contrib/python/google-auth/py3/.dist-info/METADATA b/contrib/python/google-auth/py3/.dist-info/METADATA index 53da88bdec8..ddba2bddaed 100644 --- a/contrib/python/google-auth/py3/.dist-info/METADATA +++ b/contrib/python/google-auth/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: google-auth -Version: 2.39.0 +Version: 2.40.0 Summary: Google Authentication Library Home-page: https://github.com/googleapis/google-auth-library-python Author: Google Cloud Platform diff --git a/contrib/python/google-auth/py3/google/auth/_helpers.py b/contrib/python/google-auth/py3/google/auth/_helpers.py index a6c07f7d829..78fe22f7264 100644 --- a/contrib/python/google-auth/py3/google/auth/_helpers.py +++ b/contrib/python/google-auth/py3/google/auth/_helpers.py @@ -18,15 +18,38 @@ import base64 import calendar import datetime from email.message import Message +import hashlib +import json +import logging import sys +from typing import Any, Dict, Mapping, Optional, Union import urllib from google.auth import exceptions + +# _BASE_LOGGER_NAME is the base logger for all google-based loggers. +_BASE_LOGGER_NAME = "google" + +# _LOGGING_INITIALIZED ensures that base logger is only configured once +# (unless already configured by the end-user). +_LOGGING_INITIALIZED = False + + # The smallest MDS cache used by this library stores tokens until 4 minutes from # expiry. REFRESH_THRESHOLD = datetime.timedelta(minutes=3, seconds=45) +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1684): Audit and update the list below. +_SENSITIVE_FIELDS = { + "accessToken", + "access_token", + "id_token", + "client_id", + "refresh_token", + "client_secret", +} + def copy_docstring(source_class): """Decorator that copies a method's docstring from another class. @@ -271,3 +294,220 @@ def is_python_3(): bool: True if the Python interpreter is Python 3 and False otherwise. """ return sys.version_info > (3, 0) + + +def _hash_sensitive_info(data: Union[dict, list]) -> Union[dict, list, str]: + """ + Hashes sensitive information within a dictionary. + + Args: + data: The dictionary containing data to be processed. + + Returns: + A new dictionary with sensitive values replaced by their SHA512 hashes. + If the input is a list, returns a list with each element recursively processed. + If the input is neither a dict nor a list, returns the type of the input as a string. + + """ + if isinstance(data, dict): + hashed_data: Dict[Any, Union[Optional[str], dict, list]] = {} + for key, value in data.items(): + if key in _SENSITIVE_FIELDS and not isinstance(value, (dict, list)): + hashed_data[key] = _hash_value(value, key) + elif isinstance(value, (dict, list)): + hashed_data[key] = _hash_sensitive_info(value) + else: + hashed_data[key] = value + return hashed_data + elif isinstance(data, list): + hashed_list = [] + for val in data: + hashed_list.append(_hash_sensitive_info(val)) + return hashed_list + else: + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1701): + # Investigate and hash sensitive info before logging when the data type is + # not a dict or a list. + return str(type(data)) + + +def _hash_value(value, field_name: str) -> Optional[str]: + """Hashes a value and returns a formatted hash string.""" + if value is None: + return None + encoded_value = str(value).encode("utf-8") + hash_object = hashlib.sha512() + hash_object.update(encoded_value) + hex_digest = hash_object.hexdigest() + return f"hashed_{field_name}-{hex_digest}" + + +def _logger_configured(logger: logging.Logger) -> bool: + """Determines whether `logger` has non-default configuration + + Args: + logger: The logger to check. + + Returns: + bool: Whether the logger has any non-default configuration. + """ + return ( + logger.handlers != [] or logger.level != logging.NOTSET or not logger.propagate + ) + + +def is_logging_enabled(logger: logging.Logger) -> bool: + """ + Checks if debug logging is enabled for the given logger. + + Args: + logger: The logging.Logger instance to check. + + Returns: + True if debug logging is enabled, False otherwise. + """ + # NOTE: Log propagation to the root logger is disabled unless + # the base logger i.e. logging.getLogger("google") is + # explicitly configured by the end user. Ideally this + # needs to happen in the client layer (already does for GAPICs). + # However, this is implemented here to avoid logging + # (if a root logger is configured) when a version of google-auth + # which supports logging is used with: + # - an older version of a GAPIC which does not support logging. + # - Apiary client which does not support logging. + global _LOGGING_INITIALIZED + if not _LOGGING_INITIALIZED: + base_logger = logging.getLogger(_BASE_LOGGER_NAME) + if not _logger_configured(base_logger): + base_logger.propagate = False + _LOGGING_INITIALIZED = True + + return logger.isEnabledFor(logging.DEBUG) + + +def request_log( + logger: logging.Logger, + method: str, + url: str, + body: Optional[bytes], + headers: Optional[Mapping[str, str]], +) -> None: + """ + Logs an HTTP request at the DEBUG level if logging is enabled. + + Args: + logger: The logging.Logger instance to use. + method: The HTTP method (e.g., "GET", "POST"). + url: The URL of the request. + body: The request body (can be None). + headers: The request headers (can be None). + """ + if is_logging_enabled(logger): + content_type = ( + headers["Content-Type"] if headers and "Content-Type" in headers else "" + ) + json_body = _parse_request_body(body, content_type=content_type) + logged_body = _hash_sensitive_info(json_body) + logger.debug( + "Making request...", + extra={ + "httpRequest": { + "method": method, + "url": url, + "body": logged_body, + "headers": headers, + } + }, + ) + + +def _parse_request_body(body: Optional[bytes], content_type: str = "") -> Any: + """ + Parses a request body, handling bytes and string types, and different content types. + + Args: + body (Optional[bytes]): The request body. + content_type (str): The content type of the request body, e.g., "application/json", + "application/x-www-form-urlencoded", or "text/plain". If empty, attempts + to parse as JSON. + + Returns: + Parsed body (dict, str, or None). + - JSON: Decodes if content_type is "application/json" or None (fallback). + - URL-encoded: Parses if content_type is "application/x-www-form-urlencoded". + - Plain text: Returns string if content_type is "text/plain". + - None: Returns if body is None, UTF-8 decode fails, or content_type is unknown. + """ + if body is None: + return None + try: + body_str = body.decode("utf-8") + except (UnicodeDecodeError, AttributeError): + return None + content_type = content_type.lower() + if not content_type or "application/json" in content_type: + try: + return json.loads(body_str) + except (json.JSONDecodeError, TypeError): + return body_str + if "application/x-www-form-urlencoded" in content_type: + parsed_query = urllib.parse.parse_qs(body_str) + result = {k: v[0] for k, v in parsed_query.items()} + return result + if "text/plain" in content_type: + return body_str + return None + + +def _parse_response(response: Any) -> Any: + """ + Parses a response, attempting to decode JSON. + + Args: + response: The response object to parse. This can be any type, but + it is expected to have a `json()` method if it contains JSON. + + Returns: + The parsed response. If the response contains valid JSON, the + decoded JSON object (e.g., a dictionary or list) is returned. + If the response does not have a `json()` method or if the JSON + decoding fails, None is returned. + """ + try: + json_response = response.json() + return json_response + except Exception: + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1744): + # Parse and return response payload as json based on different content types. + return None + + +def _response_log_base(logger: logging.Logger, parsed_response: Any) -> None: + """ + Logs a parsed HTTP response at the DEBUG level. + + This internal helper function takes a parsed response and logs it + using the provided logger. It also applies a hashing function to + potentially sensitive information before logging. + + Args: + logger: The logging.Logger instance to use for logging. + parsed_response: The parsed HTTP response object (e.g., a dictionary, + list, or the original response if parsing failed). + """ + + logged_response = _hash_sensitive_info(parsed_response) + logger.debug("Response received...", extra={"httpResponse": logged_response}) + + +def response_log(logger: logging.Logger, response: Any) -> None: + """ + Logs an HTTP response at the DEBUG level if logging is enabled. + + Args: + logger: The logging.Logger instance to use. + response: The HTTP response object to log. + """ + if is_logging_enabled(logger): + json_response = _parse_response(response) + _response_log_base(logger, json_response) diff --git a/contrib/python/google-auth/py3/google/auth/aio/_helpers.py b/contrib/python/google-auth/py3/google/auth/aio/_helpers.py new file mode 100644 index 00000000000..fd7d37a2f7b --- /dev/null +++ b/contrib/python/google-auth/py3/google/auth/aio/_helpers.py @@ -0,0 +1,57 @@ +# Copyright 2025 Google Inc. +# +# 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. + +"""Helper functions for commonly used utilities.""" + + +import logging +from typing import Any + +from google.auth import _helpers + + +async def _parse_response_async(response: Any) -> Any: + """ + Parses an async response, attempting to decode JSON. + + Args: + response: The response object to parse. This can be any type, but + it is expected to have a `json()` method if it contains JSON. + + Returns: + The parsed response. If the response contains valid JSON, the + decoded JSON object (e.g., a dictionary) is returned. + If the response does not have a `json()` method or if the JSON + decoding fails, None is returned. + """ + try: + json_response = await response.json() + return json_response + except Exception: + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1745): + # Parse and return response payload as json based on different content types. + return None + + +async def response_log_async(logger: logging.Logger, response: Any) -> None: + """ + Logs an Async HTTP response at the DEBUG level if logging is enabled. + + Args: + logger: The logging.Logger instance to use. + response: The HTTP response object to log. + """ + if _helpers.is_logging_enabled(logger): + json_response = await _parse_response_async(response) + _helpers._response_log_base(logger, json_response) diff --git a/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py b/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py index 074d1491c70..67a19f952d2 100644 --- a/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py +++ b/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py @@ -16,6 +16,7 @@ """ import asyncio +import logging from typing import AsyncGenerator, Mapping, Optional try: @@ -27,8 +28,11 @@ except ImportError as caught_exc: # pragma: NO COVER from google.auth import _helpers from google.auth import exceptions +from google.auth.aio import _helpers as _helpers_async from google.auth.aio import transport +_LOGGER = logging.getLogger(__name__) + class Response(transport.Response): """ @@ -155,6 +159,7 @@ class Request(transport.Request): self._session = aiohttp.ClientSession() client_timeout = aiohttp.ClientTimeout(total=timeout) + _helpers.request_log(_LOGGER, method, url, body, headers) response = await self._session.request( method, url, @@ -163,6 +168,7 @@ class Request(transport.Request): timeout=client_timeout, **kwargs, ) + await _helpers_async.response_log_async(_LOGGER, response) return Response(response) except aiohttp.ClientError as caught_exc: diff --git a/contrib/python/google-auth/py3/google/auth/transport/_aiohttp_requests.py b/contrib/python/google-auth/py3/google/auth/transport/_aiohttp_requests.py index bc4d9dc69af..36366be5108 100644 --- a/contrib/python/google-auth/py3/google/auth/transport/_aiohttp_requests.py +++ b/contrib/python/google-auth/py3/google/auth/transport/_aiohttp_requests.py @@ -22,14 +22,20 @@ from __future__ import absolute_import import asyncio import functools +import logging import aiohttp # type: ignore import urllib3 # type: ignore +from google.auth import _helpers from google.auth import exceptions from google.auth import transport +from google.auth.aio import _helpers as _helpers_async from google.auth.transport import requests + +_LOGGER = logging.getLogger(__name__) + # Timeout can be re-defined depending on async requirement. Currently made 60s more than # sync timeout. _DEFAULT_TIMEOUT = 180 # in seconds @@ -182,10 +188,11 @@ class Request(transport.Request): self.session = aiohttp.ClientSession( auto_decompress=False ) # pragma: NO COVER - requests._LOGGER.debug("Making request: %s %s", method, url) + _helpers.request_log(_LOGGER, method, url, body, headers) response = await self.session.request( method, url, data=body, headers=headers, timeout=timeout, **kwargs ) + await _helpers_async.response_log_async(_LOGGER, response) return _CombinedResponse(response) except aiohttp.ClientError as caught_exc: diff --git a/contrib/python/google-auth/py3/google/auth/transport/_http_client.py b/contrib/python/google-auth/py3/google/auth/transport/_http_client.py index cec0ab73fb3..898a86519b7 100644 --- a/contrib/python/google-auth/py3/google/auth/transport/_http_client.py +++ b/contrib/python/google-auth/py3/google/auth/transport/_http_client.py @@ -19,6 +19,7 @@ import logging import socket import urllib +from google.auth import _helpers from google.auth import exceptions from google.auth import transport @@ -99,10 +100,11 @@ class Request(transport.Request): connection = http_client.HTTPConnection(parts.netloc, timeout=timeout) try: - _LOGGER.debug("Making request: %s %s", method, url) + _helpers.request_log(_LOGGER, method, url, body, headers) connection.request(method, path, body=body, headers=headers, **kwargs) response = connection.getresponse() + _helpers.response_log(_LOGGER, response) return Response(response) except (http_client.HTTPException, socket.error) as caught_exc: diff --git a/contrib/python/google-auth/py3/google/auth/transport/requests.py b/contrib/python/google-auth/py3/google/auth/transport/requests.py index 23a69783dc3..0540746f894 100644 --- a/contrib/python/google-auth/py3/google/auth/transport/requests.py +++ b/contrib/python/google-auth/py3/google/auth/transport/requests.py @@ -34,6 +34,7 @@ from requests.packages.urllib3.util.ssl_ import ( # type: ignore create_urllib3_context, ) # pylint: disable=ungrouped-imports +from google.auth import _helpers from google.auth import environment_vars from google.auth import exceptions from google.auth import transport @@ -182,10 +183,11 @@ class Request(transport.Request): google.auth.exceptions.TransportError: If any exception occurred. """ try: - _LOGGER.debug("Making request: %s %s", method, url) + _helpers.request_log(_LOGGER, method, url, body, headers) response = self.session.request( method, url, data=body, headers=headers, timeout=timeout, **kwargs ) + _helpers.response_log(_LOGGER, response) return _Response(response) except requests.exceptions.RequestException as caught_exc: new_exc = exceptions.TransportError(caught_exc) @@ -534,6 +536,7 @@ class AuthorizedSession(requests.Session): remaining_time = guard.remaining_timeout with TimeoutGuard(remaining_time) as guard: + _helpers.request_log(_LOGGER, method, url, data, headers) response = super(AuthorizedSession, self).request( method, url, @@ -542,6 +545,7 @@ class AuthorizedSession(requests.Session): timeout=timeout, **kwargs ) + _helpers.response_log(_LOGGER, response) remaining_time = guard.remaining_timeout # If the response indicated that the credentials needed to be diff --git a/contrib/python/google-auth/py3/google/auth/transport/urllib3.py b/contrib/python/google-auth/py3/google/auth/transport/urllib3.py index db4fa93ff11..03ed75aa2f5 100644 --- a/contrib/python/google-auth/py3/google/auth/transport/urllib3.py +++ b/contrib/python/google-auth/py3/google/auth/transport/urllib3.py @@ -50,6 +50,7 @@ except ImportError as caught_exc: # pragma: NO COVER ) from caught_exc +from google.auth import _helpers from google.auth import environment_vars from google.auth import exceptions from google.auth import transport @@ -144,10 +145,11 @@ class Request(transport.Request): kwargs["timeout"] = timeout try: - _LOGGER.debug("Making request: %s %s", method, url) + _helpers.request_log(_LOGGER, method, url, body, headers) response = self.http.request( method, url, body=body, headers=headers, **kwargs ) + _helpers.response_log(_LOGGER, response) return _Response(response) except urllib3.exceptions.HTTPError as caught_exc: new_exc = exceptions.TransportError(caught_exc) diff --git a/contrib/python/google-auth/py3/google/auth/version.py b/contrib/python/google-auth/py3/google/auth/version.py index 393caa8ad44..d1363c1ef0e 100644 --- a/contrib/python/google-auth/py3/google/auth/version.py +++ b/contrib/python/google-auth/py3/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.39.0" +__version__ = "2.40.0" diff --git a/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py b/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py index 7784e83d0b9..24e984f3d33 100644 --- a/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py +++ b/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py @@ -67,7 +67,7 @@ class GetRequest: extensions: Optional[AuthenticationExtensionsClientInputs] = None def to_json(self) -> str: - req_options: Dict[str, Any] = {"rpid": self.rpid, "challenge": self.challenge} + req_options: Dict[str, Any] = {"rpId": self.rpid, "challenge": self.challenge} if self.timeout_ms: req_options["timeout"] = self.timeout_ms if self.allow_credentials: diff --git a/contrib/python/google-auth/py3/tests/aio/test__helpers.py b/contrib/python/google-auth/py3/tests/aio/test__helpers.py new file mode 100644 index 00000000000..7642431caec --- /dev/null +++ b/contrib/python/google-auth/py3/tests/aio/test__helpers.py @@ -0,0 +1,110 @@ +# Copyright 2025 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 json +import logging + +import pytest # type: ignore + +from google.auth.aio import _helpers + +# _MOCK_BASE_LOGGER_NAME is the base logger namespace used for testing. +_MOCK_BASE_LOGGER_NAME = "foogle" + +# _MOCK_CHILD_LOGGER_NAME is the child logger namespace used for testing. +_MOCK_CHILD_LOGGER_NAME = "foogle.bar" + + +def logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_CHILD_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +def base_logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_BASE_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +async def test_response_log_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + await _helpers.response_log_async(logger, {"payload": None}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == "<class 'NoneType'>" + + +async def test_response_log_debug_disabled(logger, caplog, base_logger): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + await _helpers.response_log_async(logger, "another_response") + assert "Response received..." not in caplog.text + + +async def test_response_log_debug_enabled_response_json(logger, caplog, base_logger): + class MockResponse: + async def json(self): + return {"key1": "value1", "key2": "value2", "key3": "value3"} + + response = MockResponse() + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + await _helpers.response_log_async(logger, response) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == {"key1": "value1", "key2": "value2", "key3": "value3"} + + +async def test_parse_response_async_json_valid(): + class MockResponse: + async def json(self): + return {"data": "test"} + + response = MockResponse() + expected = {"data": "test"} + assert await _helpers._parse_response_async(response) == expected + + +async def test_parse_response_async_json_invalid(): + class MockResponse: + def json(self): + raise json.JSONDecodeError("msg", "doc", 0) + + response = MockResponse() + assert await _helpers._parse_response_async(response) is None + + +async def test_parse_response_async_no_json_method(): + response = "plain text" + assert await _helpers._parse_response_async(response) is None + + +async def test_parse_response_async_none(): + assert await _helpers._parse_response_async(None) is None diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py index 454e97cb61d..9fba266da9f 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py @@ -118,7 +118,7 @@ def test_success_get_assertion(os_get_stub, subprocess_run_stub): "type": "get", "origin": "fake_origin", "requestData": { - "rpid": "fake_rpid", + "rpId": "fake_rpid", "challenge": "fake_challenge", "allowCredentials": [{"type": "public-key", "id": "fake_id_1"}], }, diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py index 5231d21896a..bafe5b05060 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py @@ -82,7 +82,7 @@ def test_GetRequest(has_allow_credentials): "type": "get", "origin": "fake_origin", "requestData": { - "rpid": "fake_rpid", + "rpId": "fake_rpid", "timeout": 123, "challenge": "fake_challenge", "userVerification": "preferred", diff --git a/contrib/python/google-auth/py3/tests/test__helpers.py b/contrib/python/google-auth/py3/tests/test__helpers.py index c9a3847ac48..a4337c01608 100644 --- a/contrib/python/google-auth/py3/tests/test__helpers.py +++ b/contrib/python/google-auth/py3/tests/test__helpers.py @@ -13,12 +13,50 @@ # limitations under the License. import datetime +import json +import logging +from unittest import mock import urllib import pytest # type: ignore from google.auth import _helpers +# _MOCK_BASE_LOGGER_NAME is the base logger namespace used for testing. +_MOCK_BASE_LOGGER_NAME = "foogle" + +# _MOCK_CHILD_LOGGER_NAME is the child logger namespace used for testing. +_MOCK_CHILD_LOGGER_NAME = "foogle.bar" + + +def logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_CHILD_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + +def base_logger(): + """Returns a child logger for testing.""" + logger = logging.getLogger(_MOCK_BASE_LOGGER_NAME) + logger.level = logging.NOTSET + logger.handlers = [] + logger.propagate = True + return logger + + [email protected](autouse=True) +def reset_logging_initialized(): + """Resets the global _LOGGING_INITIALIZED variable before each test.""" + original_state = _helpers._LOGGING_INITIALIZED + _helpers._LOGGING_INITIALIZED = False + yield + _helpers._LOGGING_INITIALIZED = original_state + class SourceClass(object): def func(self): # pragma: NO COVER @@ -92,7 +130,7 @@ def test_to_bytes_with_bytes(): def test_to_bytes_with_unicode(): - value = u"string-val" + value = "string-val" encoded_value = b"string-val" assert _helpers.to_bytes(value) == encoded_value @@ -103,13 +141,13 @@ def test_to_bytes_with_nonstring_type(): def test_from_bytes_with_unicode(): - value = u"bytes-val" + value = "bytes-val" assert _helpers.from_bytes(value) == value def test_from_bytes_with_bytes(): value = b"string-val" - decoded_value = u"string-val" + decoded_value = "string-val" assert _helpers.from_bytes(value) == decoded_value @@ -194,3 +232,393 @@ def test_unpadded_urlsafe_b64encode(): for case, expected in cases: assert _helpers.unpadded_urlsafe_b64encode(case) == expected + + +def test_hash_sensitive_info_basic(): + test_data = { + "expires_in": 3599, + "access_token": "access-123", + "scope": "https://www.googleapis.com/auth/test-api", + "token_type": "Bearer", + } + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["expires_in"] == 3599 + assert hashed_data["scope"] == "https://www.googleapis.com/auth/test-api" + assert hashed_data["access_token"].startswith("hashed_access_token-") + assert hashed_data["token_type"] == "Bearer" + + +def test_hash_sensitive_info_multiple_sensitive(): + test_data = { + "access_token": "some_long_token", + "id_token": "1234-5678-9012-3456", + "expires_in": 3599, + "token_type": "Bearer", + } + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["expires_in"] == 3599 + assert hashed_data["token_type"] == "Bearer" + assert hashed_data["access_token"].startswith("hashed_access_token-") + assert hashed_data["id_token"].startswith("hashed_id_token-") + + +def test_hash_sensitive_info_none_value(): + test_data = {"username": "user3", "secret": None, "normal_data": "abc"} + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["secret"] is None + assert hashed_data["normal_data"] == "abc" + + +def test_hash_sensitive_info_non_string_value(): + test_data = {"username": "user4", "access_token": 12345, "normal_data": "def"} + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data["access_token"].startswith("hashed_access_token-") + assert hashed_data["normal_data"] == "def" + + +def test_hash_sensitive_info_list_value(): + test_data = [ + {"name": "Alice", "access_token": "12345"}, + {"name": "Bob", "client_id": "1141"}, + ] + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data[0]["access_token"].startswith("hashed_access_token-") + assert hashed_data[1]["client_id"].startswith("hashed_client_id-") + + +def test_hash_sensitive_info_nested_list_value(): + test_data = [{"names": ["Alice", "Bob"], "tokens": [{"access_token": "1234"}]}] + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data[0]["tokens"][0]["access_token"].startswith( + "hashed_access_token-" + ) + + +def test_hash_sensitive_info_int_value(): + test_data = 123 + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == "<class 'int'>" + + +def test_hash_sensitive_info_bool_value(): + test_data = True + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == "<class 'bool'>" + + +def test_hash_sensitive_info_byte_value(): + test_data = b"1243" + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == "<class 'bytes'>" + + +def test_hash_sensitive_info_empty_dict(): + test_data = {} + hashed_data = _helpers._hash_sensitive_info(test_data) + assert hashed_data == {} + + +def test_hash_value_consistent_hashing(): + value = "test_value" + field_name = "test_field" + hash1 = _helpers._hash_value(value, field_name) + hash2 = _helpers._hash_value(value, field_name) + assert hash1 == hash2 + + +def test_hash_value_different_hashing(): + value1 = "test_value1" + value2 = "test_value2" + field_name = "test_field" + hash1 = _helpers._hash_value(value1, field_name) + hash2 = _helpers._hash_value(value2, field_name) + assert hash1 != hash2 + + +def test_hash_value_none(): + assert _helpers._hash_value(None, "test") is None + + +def test_logger_configured_default(logger): + assert not _helpers._logger_configured(logger) + + +def test_logger_configured_with_handler(logger): + mock_handler = logging.NullHandler() + logger.addHandler(mock_handler) + assert _helpers._logger_configured(logger) + + # Cleanup + logger.removeHandler(mock_handler) + + +def test_logger_configured_with_custom_level(logger): + original_level = logger.level + logger.level = logging.INFO + assert _helpers._logger_configured(logger) + + # Cleanup + logging.level = original_level + + +def test_logger_configured_with_propagate(logger): + original_propagate = logger.propagate + logger.propagate = False + assert _helpers._logger_configured(logger) + + # Cleanup + logger.propagate = original_propagate + + +def test_is_logging_enabled_with_no_level_set(logger, base_logger): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", "foogle"): + assert _helpers.is_logging_enabled(logger) is False + + +def test_is_logging_enabled_with_debug_disabled(caplog, logger, base_logger): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + assert _helpers.is_logging_enabled(logger) is False + + +def test_is_logging_enabled_with_debug_enabled(caplog, logger, base_logger): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + assert _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_configured_with_info( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.INFO, logger=_MOCK_BASE_LOGGER_NAME) + + base_logger = logging.getLogger(_MOCK_BASE_LOGGER_NAME) + assert not _helpers.is_logging_enabled(base_logger) + assert not _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_configured_with_debug( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.DEBUG, logger=_MOCK_BASE_LOGGER_NAME) + + assert _helpers.is_logging_enabled(base_logger) + assert _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_info_child_logger_debug( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.INFO, logger=_MOCK_BASE_LOGGER_NAME) + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + + assert not _helpers.is_logging_enabled(base_logger) + assert _helpers.is_logging_enabled(logger) + + +def test_is_logging_enabled_with_base_logger_debug_child_logger_info( + caplog, logger, base_logger +): + with mock.patch("google.auth._helpers._BASE_LOGGER_NAME", _MOCK_BASE_LOGGER_NAME): + caplog.set_level(logging.DEBUG, logger=_MOCK_BASE_LOGGER_NAME) + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + + assert _helpers.is_logging_enabled(base_logger) + assert not _helpers.is_logging_enabled(logger) + + +def test_request_log_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.request_log( + logger, + "GET", + "http://example.com", + b'{"key": "value"}', + {"Authorization": "Bearer token"}, + ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Making request..." + assert record.httpRequest == { + "method": "GET", + "url": "http://example.com", + "body": {"key": "value"}, + "headers": {"Authorization": "Bearer token"}, + } + + +def test_request_log_plain_text_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.request_log( + logger, + "GET", + "http://example.com", + b"This is plain text.", + {"Authorization": "Bearer token", "Content-Type": "text/plain"}, + ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Making request..." + assert record.httpRequest == { + "method": "GET", + "url": "http://example.com", + "body": "<class 'str'>", + "headers": {"Authorization": "Bearer token", "Content-Type": "text/plain"}, + } + + +def test_request_log_debug_disabled(logger, caplog, base_logger): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.request_log( + logger, + "POST", + "https://api.example.com", + "data", + {"Content-Type": "application/json"}, + ) + assert "Making request: POST https://api.example.com" not in caplog.text + + +def test_response_log_debug_enabled(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.response_log(logger, {"payload": None}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == "<class 'NoneType'>" + + +def test_response_log_debug_disabled(logger, caplog): + caplog.set_level(logging.INFO, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.response_log(logger, "another_response") + assert "Response received..." not in caplog.text + + +def test_response_log_base_logger_configured(logger, caplog, base_logger): + caplog.set_level(logging.DEBUG, logger=_MOCK_BASE_LOGGER_NAME) + _helpers.response_log(logger, "another_response") + assert "Response received..." in caplog.text + + +def test_response_log_debug_enabled_response_list(logger, caplog, base_logger): + # NOTE: test the response log when response.json() returns a list as per + # https://requests.readthedocs.io/en/latest/api/#requests.Response.json. + class MockResponse: + def json(self): + return ["item1", "item2", "item3"] + + response = MockResponse() + caplog.set_level(logging.DEBUG, logger=_MOCK_CHILD_LOGGER_NAME) + _helpers.response_log(logger, response) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.message == "Response received..." + assert record.httpResponse == ["<class 'str'>", "<class 'str'>", "<class 'str'>"] + + +def test_parse_request_body_bytes_valid(): + body = b"key1=value1&key2=value2" + expected = {"key1": "value1", "key2": "value2"} + assert ( + _helpers._parse_request_body( + body, content_type="application/x-www-form-urlencoded" + ) + == expected + ) + + +def test_parse_request_body_bytes_empty(): + body = b"" + assert _helpers._parse_request_body(body) == "" + + +def test_parse_request_body_bytes_invalid_encoding(): + body = b"\xff\xfe\xfd" # Invalid UTF-8 sequence + assert _helpers._parse_request_body(body) is None + + +def test_parse_request_body_bytes_malformed_query(): + body = b"key1=value1&key2=value2" # missing equals + expected = {"key1": "value1", "key2": "value2"} + assert ( + _helpers._parse_request_body( + body, content_type="application/x-www-form-urlencoded" + ) + == expected + ) + + +def test_parse_request_body_none(): + assert _helpers._parse_request_body(None) is None + + +def test_parse_request_body_bytes_no_content_type(): + body = b'{"key": "value"}' + expected = {"key": "value"} + assert _helpers._parse_request_body(body) == expected + + +def test_parse_request_body_bytes_content_type_json(): + body = b'{"key": "value"}' + expected = {"key": "value"} + assert ( + _helpers._parse_request_body(body, content_type="application/json") == expected + ) + + +def test_parse_request_body_content_type_urlencoded(): + body = b"key=value" + expected = {"key": "value"} + assert ( + _helpers._parse_request_body( + body, content_type="application/x-www-form-urlencoded" + ) + == expected + ) + + +def test_parse_request_body_bytes_content_type_text(): + body = b"This is plain text." + expected = "This is plain text." + assert _helpers._parse_request_body(body, content_type="text/plain") == expected + + +def test_parse_request_body_content_type_invalid(): + body = b'{"key": "value"}' + assert _helpers._parse_request_body(body, content_type="invalid") is None + + +def test_parse_request_body_other_type(): + assert _helpers._parse_request_body(123) is None + assert _helpers._parse_request_body("string") is None + + +def test_parse_response_json_valid(): + class MockResponse: + def json(self): + return {"data": "test"} + + response = MockResponse() + expected = {"data": "test"} + assert _helpers._parse_response(response) == expected + + +def test_parse_response_json_invalid(): + class MockResponse: + def json(self): + raise json.JSONDecodeError("msg", "doc", 0) + + response = MockResponse() + assert _helpers._parse_response(response) is None + + +def test_parse_response_no_json_method(): + response = "plain text" + assert _helpers._parse_response(response) is None + + +def test_parse_response_none(): + assert _helpers._parse_response(None) is None diff --git a/contrib/python/google-auth/py3/ya.make b/contrib/python/google-auth/py3/ya.make index eddecb079e5..7ceec35306e 100644 --- a/contrib/python/google-auth/py3/ya.make +++ b/contrib/python/google-auth/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(2.39.0) +VERSION(2.40.0) LICENSE(Apache-2.0) @@ -39,6 +39,7 @@ PY_SRCS( google/auth/_refresh_worker.py google/auth/_service_account_info.py google/auth/aio/__init__.py + google/auth/aio/_helpers.py google/auth/aio/credentials.py google/auth/aio/transport/__init__.py google/auth/aio/transport/aiohttp.py |
