From a8292776d47a60e518741bde70d4e84d95d681b3 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 31 Jul 2026 15:48:34 +0000 Subject: [PATCH 1/2] fix(oauth2): log database token failures --- superset/commands/database/oauth2.py | 23 +++++-- superset/databases/api.py | 3 +- superset/utils/oauth2.py | 20 +++--- .../commands/databases/oauth2_test.py | 30 +++++++++ tests/unit_tests/databases/oauth2_api_test.py | 65 +++++++++++++++++++ tests/unit_tests/utils/oauth2_tests.py | 12 +++- 6 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 tests/unit_tests/databases/oauth2_api_test.py diff --git a/superset/commands/database/oauth2.py b/superset/commands/database/oauth2.py index 8355bc0098ec..0dc88615a8b5 100644 --- a/superset/commands/database/oauth2.py +++ b/superset/commands/database/oauth2.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +import logging from datetime import datetime, timedelta from functools import partial from typing import cast @@ -32,6 +33,8 @@ from superset.utils.decorators import on_error, transaction from superset.utils.oauth2 import decode_oauth2_state +logger = logging.getLogger(__name__) + class OAuth2StoreTokenCommand(BaseCommand): """ @@ -71,11 +74,21 @@ def run(self) -> DatabaseUserOAuth2Tokens: code_verifier = kv_value.get("code_verifier") KeyValueDAO.delete_entry(KeyValueResource.PKCE_CODE_VERIFIER, tab_uuid) - token_response = self._database.db_engine_spec.get_oauth2_token( - oauth2_config, - self._parameters["code"], - code_verifier=code_verifier, - ) + try: + token_response = self._database.db_engine_spec.get_oauth2_token( + oauth2_config, + self._parameters["code"], + code_verifier=code_verifier, + ) + except Exception as ex: + logger.error( + "OAuth2 token exchange failed: database_id=%s engine=%s " + "error_type=%s", + self._state["database_id"], + self._database.backend, + type(ex).__name__, + ) + raise # delete old tokens if existing := DatabaseUserOAuth2TokensDAO.find_one_or_none( diff --git a/superset/databases/api.py b/superset/databases/api.py index 7791adaae8ac..16046b69467a 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -1454,10 +1454,11 @@ def validate_sql(self, pk: int) -> FlaskResponse: return self.response_404() @expose("/oauth2/", methods=["GET"]) + @statsd_metrics @transaction() @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.oauth2", - log_to_statsd=True, + log_to_statsd=False, ) def oauth2(self) -> FlaskResponse: """ diff --git a/superset/utils/oauth2.py b/superset/utils/oauth2.py index 020f5397b3c4..055d0c489670 100644 --- a/superset/utils/oauth2.py +++ b/superset/utils/oauth2.py @@ -164,21 +164,23 @@ def refresh_oauth2_token( except db_engine_spec.oauth2_exception as ex: # OAuth token is no longer valid, delete it and start OAuth2 dance logger.warning( - "OAuth2 token refresh failed for user=%s db=%s, " - "deleting token. Error: %s", - user_id, + "OAuth2 token refresh failed: database_id=%s engine=%s " + "error_type=%s; deleting token", database_id, - ex, + db_engine_spec.engine, + type(ex).__name__, ) db.session.delete(token) db.session.flush() raise - except Exception: - # non-OAuth related failure, log the exception - logger.warning( - "OAuth2 token refresh failed for user=%s db=%s", - user_id, + except Exception as ex: + # Non-OAuth failure: preserve the token and log structured context + logger.error( + "OAuth2 token refresh failed: database_id=%s engine=%s " + "error_type=%s", database_id, + db_engine_spec.engine, + type(ex).__name__, ) raise diff --git a/tests/unit_tests/commands/databases/oauth2_test.py b/tests/unit_tests/commands/databases/oauth2_test.py index 0fbe2035d295..9f79ee2f920c 100644 --- a/tests/unit_tests/commands/databases/oauth2_test.py +++ b/tests/unit_tests/commands/databases/oauth2_test.py @@ -15,11 +15,13 @@ # specific language governing permissions and limitations # under the License. +import logging from typing import Any from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture +from requests.exceptions import HTTPError from superset.commands.database.exceptions import DatabaseNotFoundError from superset.commands.database.oauth2 import OAuth2StoreTokenCommand @@ -33,6 +35,7 @@ @pytest.fixture def mock_database(mocker: MockerFixture) -> MagicMock: database = mocker.MagicMock(spec=Database) + database.backend = "postgresql" database.get_oauth2_config.return_value = { "client_id": "test", "client_secret": "secret", @@ -135,6 +138,33 @@ def test_run_success( mock_create.assert_called_once() +def test_run_logs_token_exchange_failure( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + mock_database: MagicMock, + mock_parameters: OAuth2ProviderResponseSchema, +) -> None: + mocker.patch.object( + DatabaseUserOAuth2TokensDAO, + "get_database", + return_value=mock_database, + ) + mock_database.db_engine_spec.get_oauth2_token.side_effect = HTTPError( + "token endpoint unavailable" + ) + + with ( + caplog.at_level(logging.ERROR, logger="superset.commands.database.oauth2"), + pytest.raises(HTTPError), + ): + OAuth2StoreTokenCommand(mock_parameters).run() + + assert ( + "OAuth2 token exchange failed: database_id=123 engine=postgresql " + "error_type=HTTPError" + ) in caplog.messages + + def test_run_existing_token( mocker: MockerFixture, mock_database: MagicMock, diff --git a/tests/unit_tests/databases/oauth2_api_test.py b/tests/unit_tests/databases/oauth2_api_test.py new file mode 100644 index 000000000000..8690c1f73094 --- /dev/null +++ b/tests/unit_tests/databases/oauth2_api_test.py @@ -0,0 +1,65 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 Any + +import pytest +from pytest_mock import MockerFixture +from requests.exceptions import HTTPError + +from superset.superset_typing import OAuth2State +from superset.utils.oauth2 import encode_oauth2_state + + +@pytest.mark.parametrize( + ("exchange_error", "expected_status", "expected_outcome"), + [ + (None, 200, "success"), + (HTTPError("token endpoint unavailable"), 500, "error"), + ], +) +def test_oauth2_callback_emits_outcome_metric( + mocker: MockerFixture, + client: Any, + full_api_access: None, + exchange_error: Exception | None, + expected_status: int, + expected_outcome: str, +) -> None: + from superset.databases.api import DatabaseRestApi + + command = mocker.patch("superset.databases.api.OAuth2StoreTokenCommand") + command.return_value.run.side_effect = exchange_error + mocker.patch("superset.databases.api.render_template", return_value="OK") + incr_stats = mocker.patch.object(DatabaseRestApi, "incr_stats") + + state: OAuth2State = { + "user_id": 1, + "database_id": 1, + "tab_id": "42", + "default_redirect_uri": "http://localhost:8088/api/v1/oauth2/", + } + response = client.get( + "/api/v1/database/oauth2/", + query_string={ + "state": encode_oauth2_state(state), + "code": "XXX", + }, + ) + + assert response.status_code == expected_status + incr_stats.assert_called_once_with(expected_outcome, "oauth2") diff --git a/tests/unit_tests/utils/oauth2_tests.py b/tests/unit_tests/utils/oauth2_tests.py index ac788ce66e59..c6936eed1c93 100644 --- a/tests/unit_tests/utils/oauth2_tests.py +++ b/tests/unit_tests/utils/oauth2_tests.py @@ -19,6 +19,7 @@ import base64 import hashlib +import logging from datetime import datetime from typing import cast @@ -144,6 +145,7 @@ class OAuth2ExceptionError(Exception): def test_refresh_oauth2_token_keeps_token_on_other_exception( mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, ) -> None: """ Test that refresh_oauth2_token keeps the token on non-OAuth2 exceptions. @@ -159,6 +161,7 @@ class OAuth2ExceptionError(Exception): pass db_engine_spec = mocker.MagicMock() + db_engine_spec.engine = "postgresql" db_engine_spec.oauth2_exception = OAuth2ExceptionError db_engine_spec.get_oauth2_fresh_token.side_effect = Exception("Network error") token = mocker.MagicMock() @@ -166,10 +169,17 @@ class OAuth2ExceptionError(Exception): token.refresh_token = "refresh-token" # noqa: S105 db.session.query().filter_by().one_or_none.return_value = token - with pytest.raises(Exception, match="Network error"): + with ( + caplog.at_level(logging.ERROR, logger="superset.utils.oauth2"), + pytest.raises(Exception, match="Network error"), + ): refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec) db.session.delete.assert_not_called() + assert ( + "OAuth2 token refresh failed: database_id=1 engine=postgresql " + "error_type=Exception" + ) in caplog.messages def test_refresh_oauth2_token_no_access_token_in_response( From 3f50169b1f26af29f9d2f4f16df07209503d8a2b Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 1 Aug 2026 00:51:29 +0000 Subject: [PATCH 2/2] fix(oauth2): exclude sensitive callback data --- UPDATING.md | 7 ++ superset/commands/database/oauth2.py | 10 +-- superset/databases/api.py | 1 + superset/utils/log.py | 36 +++++--- superset/utils/oauth2.py | 7 +- .../commands/databases/oauth2_test.py | 12 ++- tests/unit_tests/databases/oauth2_api_test.py | 86 +++++++++++++++---- tests/unit_tests/utils/oauth2_tests.py | 25 ++++-- 8 files changed, 137 insertions(+), 47 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 75300f245686..463d5b2349cd 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,13 @@ assists people when migrating to a new version. ## Next +### OAuth2 database callback metrics include their outcome + +The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with +`DatabaseRestApi.oauth2.success`, `DatabaseRestApi.oauth2.warning`, and +`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume +the old counter to use the outcome-specific replacements. + ### Principal listing APIs now honour related-field filters Two authorization-related listing behaviors changed for API clients. Neither diff --git a/superset/commands/database/oauth2.py b/superset/commands/database/oauth2.py index 0dc88615a8b5..a748d8253dd4 100644 --- a/superset/commands/database/oauth2.py +++ b/superset/commands/database/oauth2.py @@ -74,18 +74,18 @@ def run(self) -> DatabaseUserOAuth2Tokens: code_verifier = kv_value.get("code_verifier") KeyValueDAO.delete_entry(KeyValueResource.PKCE_CODE_VERIFIER, tab_uuid) + engine_spec = self._database.db_engine_spec try: - token_response = self._database.db_engine_spec.get_oauth2_token( + token_response = engine_spec.get_oauth2_token( oauth2_config, self._parameters["code"], code_verifier=code_verifier, ) except Exception as ex: logger.error( - "OAuth2 token exchange failed: database_id=%s engine=%s " - "error_type=%s", - self._state["database_id"], - self._database.backend, + "OAuth2 token exchange failed: database_id=%s engine=%s error_type=%s", + self._database.id, + engine_spec.engine, type(ex).__name__, ) raise diff --git a/superset/databases/api.py b/superset/databases/api.py index 16046b69467a..2c3994991911 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -1459,6 +1459,7 @@ def validate_sql(self, pk: int) -> FlaskResponse: @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.oauth2", log_to_statsd=False, + include_request_data=False, ) def oauth2(self) -> FlaskResponse: """ diff --git a/superset/utils/log.py b/superset/utils/log.py index c5ae88f33669..8658a041ed4a 100644 --- a/superset/utils/log.py +++ b/superset/utils/log.py @@ -38,21 +38,21 @@ logger = logging.getLogger(__name__) -def collect_request_payload() -> dict[str, Any]: +def collect_request_payload(include_request_data: bool = True) -> dict[str, Any]: """Collect log payload identifiable from request context""" if not request: return {} - payload: dict[str, Any] = { - "path": request.path, - **request.form.to_dict(), - # url search params can overwrite POST body - **request.args.to_dict(), - } + payload: dict[str, Any] = {"path": request.path} + + if include_request_data: + payload.update(request.form.to_dict()) + # URL search params can overwrite POST body. + payload.update(request.args.to_dict()) - if request.is_json: - json_payload = request.get_json(cache=True, silent=True) or {} - payload.update(json_payload) + if request.is_json: + json_payload = request.get_json(cache=True, silent=True) or {} + payload.update(json_payload) # save URL match pattern in addition to the request path url_rule = str(request.url_rule) @@ -120,7 +120,7 @@ def __call__( object_ref: str | None = None, log_to_statsd: bool = True, duration: timedelta | None = None, - **payload_override: dict[str, Any], + **payload_override: Any, ) -> object: # pylint: disable=W0201 self.action = action @@ -176,7 +176,8 @@ def log_with_context( # pylint: disable=too-many-locals,too-many-arguments object_ref: str | None = None, log_to_statsd: bool = True, database: Any | None = None, - **payload_override: dict[str, Any] | None, + include_request_data: bool = True, + **payload_override: Any, ) -> None: # pylint: disable=import-outside-toplevel from superset import db @@ -203,7 +204,7 @@ def log_with_context( # pylint: disable=too-many-locals,too-many-arguments except Exception as ex: logger.debug("Failed to add user to db session: %s", ex) user_id = None - payload = collect_request_payload() + payload = collect_request_payload(include_request_data) if object_ref: payload["object_ref"] = object_ref if payload_override: @@ -258,6 +259,7 @@ def log_context( action: str, object_ref: str | None = None, log_to_statsd: bool = True, + include_request_data: bool = True, **kwargs: Any, ) -> Iterator[Callable[..., None]]: """ @@ -265,6 +267,7 @@ def log_context( :param action: a name to identify the event :param object_ref: reference to the Python object that triggered this action :param log_to_statsd: whether to update statsd counter for the action + :param include_request_data: whether to include form, query, and JSON fields """ payload_override = kwargs.copy() start = datetime.now() @@ -275,7 +278,12 @@ def log_context( # take the action from payload_override else take the function param action action_str = payload_override.pop("action", action) self.log_with_context( - action_str, duration, object_ref, log_to_statsd, **payload_override + action_str, + duration, + object_ref, + log_to_statsd, + include_request_data=include_request_data, + **payload_override, ) def _wrapper( diff --git a/superset/utils/oauth2.py b/superset/utils/oauth2.py index 055d0c489670..3c6f9e4bfac2 100644 --- a/superset/utils/oauth2.py +++ b/superset/utils/oauth2.py @@ -164,8 +164,8 @@ def refresh_oauth2_token( except db_engine_spec.oauth2_exception as ex: # OAuth token is no longer valid, delete it and start OAuth2 dance logger.warning( - "OAuth2 token refresh failed: database_id=%s engine=%s " - "error_type=%s; deleting token", + "OAuth2 token refresh failed: database_id=%s engine=%s error_type=%s; " + "deleting token", database_id, db_engine_spec.engine, type(ex).__name__, @@ -176,8 +176,7 @@ def refresh_oauth2_token( except Exception as ex: # Non-OAuth failure: preserve the token and log structured context logger.error( - "OAuth2 token refresh failed: database_id=%s engine=%s " - "error_type=%s", + "OAuth2 token refresh failed: database_id=%s engine=%s error_type=%s", database_id, db_engine_spec.engine, type(ex).__name__, diff --git a/tests/unit_tests/commands/databases/oauth2_test.py b/tests/unit_tests/commands/databases/oauth2_test.py index 9f79ee2f920c..2385f5177b2f 100644 --- a/tests/unit_tests/commands/databases/oauth2_test.py +++ b/tests/unit_tests/commands/databases/oauth2_test.py @@ -35,7 +35,8 @@ @pytest.fixture def mock_database(mocker: MockerFixture) -> MagicMock: database = mocker.MagicMock(spec=Database) - database.backend = "postgresql" + database.id = 123 + database.db_engine_spec.engine = "postgresql" database.get_oauth2_config.return_value = { "client_id": "test", "client_secret": "secret", @@ -144,13 +145,17 @@ def test_run_logs_token_exchange_failure( mock_database: MagicMock, mock_parameters: OAuth2ProviderResponseSchema, ) -> None: + mock_parameters["code"] = "oauth-code-sentinel" + mock_database.get_oauth2_config.return_value["client_secret"] = ( + "client-secret-sentinel" # noqa: S105 + ) mocker.patch.object( DatabaseUserOAuth2TokensDAO, "get_database", return_value=mock_database, ) mock_database.db_engine_spec.get_oauth2_token.side_effect = HTTPError( - "token endpoint unavailable" + "provider-payload-sentinel" ) with ( @@ -163,6 +168,9 @@ def test_run_logs_token_exchange_failure( "OAuth2 token exchange failed: database_id=123 engine=postgresql " "error_type=HTTPError" ) in caplog.messages + assert "oauth-code-sentinel" not in caplog.text + assert "client-secret-sentinel" not in caplog.text + assert "provider-payload-sentinel" not in caplog.text def test_run_existing_token( diff --git a/tests/unit_tests/databases/oauth2_api_test.py b/tests/unit_tests/databases/oauth2_api_test.py index 8690c1f73094..1116bb8403c1 100644 --- a/tests/unit_tests/databases/oauth2_api_test.py +++ b/tests/unit_tests/databases/oauth2_api_test.py @@ -16,50 +16,102 @@ # under the License. from typing import Any +from unittest.mock import call, MagicMock import pytest from pytest_mock import MockerFixture from requests.exceptions import HTTPError +from superset import db +from superset.extensions import event_logger, stats_logger_manager from superset.superset_typing import OAuth2State from superset.utils.oauth2 import encode_oauth2_state +@pytest.fixture +def oauth2_command(mocker: MockerFixture) -> MagicMock: + command = mocker.patch("superset.databases.api.OAuth2StoreTokenCommand") + mocker.patch("superset.databases.api.render_template", return_value="OK") + return command + + +def callback_state() -> str: + state: OAuth2State = { + "user_id": 1, + "database_id": 1, + "tab_id": "42", + "default_redirect_uri": "http://localhost:8088/api/v1/oauth2/", + } + return encode_oauth2_state(state) + + @pytest.mark.parametrize( - ("exchange_error", "expected_status", "expected_outcome"), + ("exchange_error", "expected_status", "expected_outcome", "transaction_method"), [ - (None, 200, "success"), - (HTTPError("token endpoint unavailable"), 500, "error"), + (None, 200, "success", "commit"), + (HTTPError("token endpoint unavailable"), 500, "error", "rollback"), ], ) -def test_oauth2_callback_emits_outcome_metric( +def test_oauth2_callback_emits_one_outcome_metric_after_transaction( mocker: MockerFixture, client: Any, full_api_access: None, + oauth2_command: MagicMock, exchange_error: Exception | None, expected_status: int, expected_outcome: str, + transaction_method: str, ) -> None: - from superset.databases.api import DatabaseRestApi + oauth2_command.return_value.run.side_effect = exchange_error + mocker.patch.object(event_logger, "log") - command = mocker.patch("superset.databases.api.OAuth2StoreTokenCommand") - command.return_value.run.side_effect = exchange_error - mocker.patch("superset.databases.api.render_template", return_value="OK") - incr_stats = mocker.patch.object(DatabaseRestApi, "incr_stats") + calls = mocker.MagicMock() + transaction_complete = mocker.patch.object(db.session, transaction_method) + metric = mocker.patch.object(stats_logger_manager.instance, "incr") + calls.attach_mock(transaction_complete, "transaction_complete") + calls.attach_mock(metric, "metric") - state: OAuth2State = { - "user_id": 1, - "database_id": 1, - "tab_id": "42", - "default_redirect_uri": "http://localhost:8088/api/v1/oauth2/", - } response = client.get( "/api/v1/database/oauth2/", query_string={ - "state": encode_oauth2_state(state), + "state": callback_state(), "code": "XXX", }, ) assert response.status_code == expected_status - incr_stats.assert_called_once_with(expected_outcome, "oauth2") + assert calls.mock_calls == [ + call.transaction_complete(), + call.metric(f"DatabaseRestApi.oauth2.{expected_outcome}"), + ] + + +def test_oauth2_callback_excludes_provider_data_from_event_log( + mocker: MockerFixture, + client: Any, + full_api_access: None, + oauth2_command: MagicMock, +) -> None: + event_log = mocker.patch.object(event_logger, "log") + + response = client.get( + "/api/v1/database/oauth2/", + query_string={ + "state": callback_state(), + "code": "oauth-code-sentinel", + "scope": "oauth-scope-sentinel", + "error_description": "provider-error-sentinel", + "provider_payload": "provider-payload-sentinel", + }, + ) + + assert response.status_code == 200 + record = event_log.call_args.kwargs["records"][0] + assert record["path"] == "/api/v1/database/oauth2/" + assert { + "state", + "code", + "scope", + "error_description", + "provider_payload", + }.isdisjoint(record) diff --git a/tests/unit_tests/utils/oauth2_tests.py b/tests/unit_tests/utils/oauth2_tests.py index c6936eed1c93..fcf153ea8f82 100644 --- a/tests/unit_tests/utils/oauth2_tests.py +++ b/tests/unit_tests/utils/oauth2_tests.py @@ -113,6 +113,7 @@ def test_get_oauth2_access_token_base_no_refresh(mocker: MockerFixture) -> None: def test_refresh_oauth2_token_deletes_token_on_oauth2_exception( mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, ) -> None: """ Test that refresh_oauth2_token deletes the token on OAuth2-specific exception. @@ -127,20 +128,30 @@ class OAuth2ExceptionError(Exception): pass db_engine_spec = mocker.MagicMock() + db_engine_spec.engine = "postgresql" db_engine_spec.oauth2_exception = OAuth2ExceptionError db_engine_spec.get_oauth2_fresh_token.side_effect = OAuth2ExceptionError( - "Token revoked" + "provider-error-sentinel" ) token = mocker.MagicMock() token.access_token = None - token.refresh_token = "refresh-token" # noqa: S105 + token.refresh_token = "refresh-token-sentinel" # noqa: S105 db.session.query().filter_by().one_or_none.return_value = token - with pytest.raises(OAuth2ExceptionError): + with ( + caplog.at_level(logging.WARNING, logger="superset.utils.oauth2"), + pytest.raises(OAuth2ExceptionError), + ): refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec) db.session.delete.assert_called_with(token) db.session.flush.assert_called_once() + assert ( + "OAuth2 token refresh failed: database_id=1 engine=postgresql " + "error_type=OAuth2ExceptionError; deleting token" + ) in caplog.messages + assert "refresh-token-sentinel" not in caplog.text + assert "provider-error-sentinel" not in caplog.text def test_refresh_oauth2_token_keeps_token_on_other_exception( @@ -163,10 +174,12 @@ class OAuth2ExceptionError(Exception): db_engine_spec = mocker.MagicMock() db_engine_spec.engine = "postgresql" db_engine_spec.oauth2_exception = OAuth2ExceptionError - db_engine_spec.get_oauth2_fresh_token.side_effect = Exception("Network error") + db_engine_spec.get_oauth2_fresh_token.side_effect = Exception( + "Network error: provider-payload-sentinel" + ) token = mocker.MagicMock() token.access_token = None - token.refresh_token = "refresh-token" # noqa: S105 + token.refresh_token = "refresh-token-sentinel" # noqa: S105 db.session.query().filter_by().one_or_none.return_value = token with ( @@ -180,6 +193,8 @@ class OAuth2ExceptionError(Exception): "OAuth2 token refresh failed: database_id=1 engine=postgresql " "error_type=Exception" ) in caplog.messages + assert "refresh-token-sentinel" not in caplog.text + assert "provider-payload-sentinel" not in caplog.text def test_refresh_oauth2_token_no_access_token_in_response(