diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 46c315c1b07c..13a48ab13ec6 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -113,6 +113,12 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement """Domain that is providing the implementation.""" return DOMAIN + @property + @override + def service_domain(self) -> str: + """Domain of the service the tokens are for.""" + return self.service + @override async def async_generate_authorize_url(self, flow_id: str) -> str: """Generate a url for the user to authorize.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 5c21c20346ae..4682bbfa1178 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator, Sequence from typing import TYPE_CHECKING, Any, override -from aiohttp import ClientResponse, ClientResponseError, RequestInfo +from aiohttp import ClientError, ClientResponse, ClientResponseError, RequestInfo from multidict import MultiMapping from .util.event_type import EventType @@ -253,8 +253,20 @@ class ConfigEntryAuthFailed(IntegrationError): """Error to indicate that config entry could not authenticate.""" -class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError): - """Error to indicate that the OAuth 2.0 flow could not refresh token.""" +class OAuth2TokenRequestBaseError(ConfigEntryNotReady): + """Base class for the errors a failed OAuth 2.0 token request raises. + + Catch this to handle every token request failure; the subclasses differ in + whether a status was received and what should happen to the config entry. + """ + + +class OAuth2TokenRequestError(ClientResponseError, OAuth2TokenRequestBaseError): + """Error to indicate that the OAuth 2.0 flow could not refresh token. + + Inherits ConfigEntryNotReady so setup retries without the integration having to + map it. Catch it explicitly to handle it differently. + """ def __init__( self, @@ -275,7 +287,7 @@ class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError): message=message, headers=headers, ) - HomeAssistantError.__init__(self) + OAuth2TokenRequestBaseError.__init__(self) self.domain = domain self.translation_domain = "homeassistant" self.translation_key = "oauth2_helper_refresh_failed" @@ -283,7 +295,24 @@ class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError): self.generate_message = True -class OAuth2TokenRequestTransientError(OAuth2TokenRequestError, ConfigEntryNotReady): +class OAuth2TokenRequestConnectionError(ClientError, OAuth2TokenRequestBaseError): + """Recoverable error to indicate the token request yielded no usable token. + + Covers a request that never got a response and one whose response could not + be used, neither of which has a status to tell the causes apart. + """ + + def __init__(self, *, domain: str) -> None: + """Initialize OAuth2TokenRequestConnectionError.""" + OAuth2TokenRequestBaseError.__init__(self) + self.domain = domain + self.translation_domain = "homeassistant" + self.translation_key = "oauth2_helper_refresh_transient" + self.translation_placeholders = {"domain": domain} + self.generate_message = True + + +class OAuth2TokenRequestTransientError(OAuth2TokenRequestError): """Recoverable error to indicate flow could not refresh token. Inherits ConfigEntryNotReady so setup retries without the integration having to diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index ace26466c451..95926ea7b764 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -17,7 +17,7 @@ import json import logging import secrets import time -from typing import Any, cast, override +from typing import Any, NoReturn, cast, override from aiohttp import ClientError, ClientResponseError, client, hdrs, web from habluetooth import BluetoothServiceInfoBleak @@ -30,6 +30,7 @@ from homeassistant import config_entries from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import ( ImplementationUnavailableError, + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, @@ -105,6 +106,28 @@ _SHARED_ABORT_REASONS = frozenset( ) +def _raise_mapped_token_error(err: ClientError, domain: str) -> NoReturn: + """Re-raise a failed token request as the matching OAuth2 token error.""" + if not isinstance(err, ClientResponseError): + # Nothing was received, so there is no status to tell the causes apart. + _LOGGER.debug("Token request for %s got no response: %s", domain, err) + raise OAuth2TokenRequestConnectionError(domain=domain) from err + + kwargs: dict[str, Any] = { + "request_info": err.request_info, + "history": err.history, + "status": err.status, + "message": err.message, + "headers": err.headers, + "domain": domain, + } + if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599: + raise OAuth2TokenRequestTransientError(**kwargs) from err + if 400 <= err.status <= 499: + raise OAuth2TokenRequestReauthError(**kwargs) from err + raise OAuth2TokenRequestError(**kwargs) from err + + @callback def async_get_redirect_uri(hass: HomeAssistant) -> str: """Return the redirect uri.""" @@ -163,11 +186,30 @@ class AbstractOAuth2Implementation(ABC): config entry data. """ + @property + def service_domain(self) -> str: + """Domain of the service the tokens are for. + + Defaults to the implementation itself, but an implementation that obtains + tokens on behalf of other integrations has to name the one it serves. + """ + return self.domain + async def async_refresh_token(self, token: dict) -> dict: """Refresh a token and update expires info.""" - new_token = await self._async_refresh_token(token) + try: + new_token = await self._async_refresh_token(token) + except OAuth2TokenRequestError, OAuth2TokenRequestConnectionError: + raise + except ClientError as err: + # Implementations that issue their own token request may not map their + # failures, so callers would see a raw aiohttp error instead. + _raise_mapped_token_error(err, self.service_domain) # Force int for non-compliant oauth2 providers - new_token["expires_in"] = int(new_token["expires_in"]) + try: + new_token["expires_in"] = int(new_token["expires_in"]) + except (KeyError, TypeError, ValueError) as err: + raise OAuth2TokenRequestConnectionError(domain=self.service_domain) from err new_token["expires_at"] = time.time() + new_token["expires_in"] return new_token @@ -268,6 +310,11 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): } ) + # Merging a response without one would keep the stale access token while + # extending its expiry, so the session would never recover. + if not new_token.get("access_token"): + raise OAuth2TokenRequestConnectionError(domain=self.service_domain) + return {**token, **new_token} async def _token_request(self, data: dict) -> dict: @@ -306,38 +353,13 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): detail, ) resp.raise_for_status() + return cast(dict, await resp.json()) except ClientResponseError as err: - if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599: - # Recoverable error - raise OAuth2TokenRequestTransientError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self._domain, - ) from err - if 400 <= err.status <= 499: - # Non-recoverable error - raise OAuth2TokenRequestReauthError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self._domain, - ) from err - - raise OAuth2TokenRequestError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self._domain, - ) from err - - return cast(dict, await resp.json()) + _raise_mapped_token_error(err, self.service_domain) + except ClientError as err: + # Bare TimeoutError is left alone so an enclosing asyncio.timeout still + # aborts with oauth_timeout; aiohttp's own timeouts are ClientErrors. + _raise_mapped_token_error(err, self.service_domain) class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation): @@ -844,6 +866,15 @@ class OAuth2Session: self.config_entry.async_start_reauth_if_available(self.hass) raise + # Checked before storing, so reads can trust what is on the entry. + if any( + new_token.get(field) in (None, "") + for field in ("access_token", "expires_at") + ): + raise OAuth2TokenRequestConnectionError( + domain=self.implementation.service_domain + ) + self.hass.config_entries.async_update_entry( self.config_entry, data={**self.config_entry.data, "token": new_token} ) diff --git a/script/hassfest/quality_scale_validation/test_before_setup.py b/script/hassfest/quality_scale_validation/test_before_setup.py index 02eef8dc14b3..1e19c80a11cc 100644 --- a/script/hassfest/quality_scale_validation/test_before_setup.py +++ b/script/hassfest/quality_scale_validation/test_before_setup.py @@ -14,6 +14,13 @@ _VALID_EXCEPTIONS = { "ConfigEntryError", } +# Helpers that raise one of the above on the caller's behalf, so an integration +# awaiting them satisfies the rule without repeating the mapping itself. +_VALID_AWAITED_CALLS = { + "async_config_entry_first_refresh", + "async_ensure_token_valid", +} + def _get_exception_name(expression: ast.expr) -> str: """Get the name of the exception being raised.""" @@ -58,17 +65,20 @@ def _raises_exception(integration: Integration) -> bool: return False -def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool: - """Check that a async_config_entry_first_refresh within `async_setup_entry`.""" - for node in ast.walk(async_setup_entry_function): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "async_config_entry_first_refresh" - ): - return True +def _awaits_raising_helper(async_setup_entry_function: ast.AsyncFunctionDef) -> bool: + """Check that `async_setup_entry` awaits a helper that raises on its behalf. - return False + The call only has to sit somewhere inside an await, so gathering several of + them still counts, while an unawaited call does not. + """ + return any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in _VALID_AWAITED_CALLS + for await_node in ast.walk(async_setup_entry_function) + if isinstance(await_node, ast.Await) + for node in ast.walk(await_node) + ) def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None: @@ -90,6 +100,8 @@ def validate( if not (async_setup_entry := _get_setup_entry_function(init)): return [f"Could not find `async_setup_entry` in {init_file}"] - if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)): + if not ( + _awaits_raising_helper(async_setup_entry) or _raises_exception(integration) + ): return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"] return None diff --git a/tests/components/cloud/test_account_link.py b/tests/components/cloud/test_account_link.py index 56f1e4611958..43223228aef0 100644 --- a/tests/components/cloud/test_account_link.py +++ b/tests/components/cloud/test_account_link.py @@ -6,7 +6,7 @@ import logging from time import time from unittest.mock import AsyncMock, Mock, patch -from aiohttp import ClientResponseError, RequestInfo +from aiohttp import ClientError, ClientResponseError, RequestInfo import pytest from yarl import URL @@ -16,6 +16,7 @@ from homeassistant.components.cloud.const import DATA_CLOUD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ( + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, @@ -304,3 +305,24 @@ async def test_refresh_token_error( assert exc_info.value.status == status assert exc_info.value.domain == "test" + + +async def test_refresh_token_connection_error(hass: HomeAssistant) -> None: + """Test a failure without a response reports the service, not the cloud domain.""" + hass.data[DATA_CLOUD] = None + impl = account_link.CloudOAuth2Implementation(hass, "test") + + with ( + patch( + "hass_nabucasa.account_link.async_fetch_access_token", + side_effect=ClientError("Cannot connect"), + ), + pytest.raises(OAuth2TokenRequestConnectionError) as exc_info, + ): + await impl.async_refresh_token( + {"refresh_token": "mock-refresh", "access_token": "mock-access"} + ) + + assert impl.domain == "cloud" + assert exc_info.value.domain == "test" + assert exc_info.value.translation_placeholders == {"domain": "test"} diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index af0114ce5545..56754aad3975 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -7,15 +7,24 @@ import time from typing import Any from unittest.mock import AsyncMock, patch -from aiohttp import ClientError -from multidict import CIMultiDict +from aiohttp import ( + ClientError, + ClientPayloadError, + ClientResponseError, + ContentTypeError, + RequestInfo, + ServerTimeoutError, +) +from multidict import CIMultiDict, CIMultiDictProxy import pytest +from yarl import URL from homeassistant import config_entries, data_entry_flow, setup from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, @@ -24,7 +33,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.network import NoURLAvailableError from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import ClientSessionGenerator TEST_DOMAIN = "oauth2_test" @@ -35,6 +44,8 @@ ACCESS_TOKEN_1 = "mock-access-token-1" ACCESS_TOKEN_2 = "mock-access-token-2" AUTHORIZE_URL = "https://example.como/auth/authorize" TOKEN_URL = "https://example.como/auth/token" +# Far enough ahead that a token carrying it always counts as unexpired. +FUTURE_EXPIRES_AT = 2000000000 MOCK_SECRET_TOKEN_URLSAFE = ( "token-" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -1106,6 +1117,356 @@ async def test_oauth_session_refresh_failure_exceptions( assert f"Token request for {TEST_DOMAIN} failed" in caplog.text +@pytest.mark.parametrize( + "raised", + [ + pytest.param(ClientError("Cannot connect"), id="client_error"), + pytest.param(ServerTimeoutError("Timeout"), id="timeout"), + ], +) +async def test_oauth_session_refresh_connection_error_is_transient( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + raised: Exception, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a token request that never gets a response is mapped to a transient error.""" + mock_integration(hass, MockModule(domain=TEST_DOMAIN)) + + flow_handler.async_register_implementation(hass, local_impl) + + aioclient_mock.post(TOKEN_URL, exc=raised) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(OAuth2TokenRequestConnectionError) as err, + ): + await session.async_ensure_token_valid() + + # Integrations rely on this to retry setup without mapping the error themselves. + assert isinstance(err.value, ConfigEntryNotReady) + assert err.value.translation_domain == HOMEASSISTANT_DOMAIN + assert err.value.translation_key == "oauth2_helper_refresh_transient" + assert f"Token request for {TEST_DOMAIN} got no response" in caplog.text + assert str(raised) in caplog.text + + +@pytest.mark.parametrize( + "response", + [ + pytest.param({"access_token": ACCESS_TOKEN_2}, id="missing_expires_in"), + pytest.param( + {"access_token": ACCESS_TOKEN_2, "expires_in": "soon"}, + id="unparsable_expires_in", + ), + pytest.param( + {"access_token": ACCESS_TOKEN_2, "expires_in": None}, + id="null_expires_in", + ), + ], +) +async def test_oauth_session_malformed_refresh_response_is_not_reauth( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + response: dict[str, Any], +) -> None: + """Test an unusable token response retries instead of blaming stored credentials.""" + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(TOKEN_URL, json=response) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(config_entry, "async_start_reauth_if_available") as start_reauth, + pytest.raises(OAuth2TokenRequestConnectionError) as err, + ): + await session.async_ensure_token_valid() + + assert isinstance(err.value, ConfigEntryNotReady) + assert err.value.translation_domain == HOMEASSISTANT_DOMAIN + assert err.value.translation_key == "oauth2_helper_refresh_transient" + # Relinking the account cannot fix a bad response, so it must not ask for it. + start_reauth.assert_not_called() + + +@pytest.mark.parametrize( + "response", + [ + pytest.param({"expires_in": 100}, id="no_access_token"), + pytest.param({"access_token": None, "expires_in": 100}, id="null_access_token"), + pytest.param({"access_token": "", "expires_in": 100}, id="blank_access_token"), + ], +) +async def test_oauth_session_refresh_without_access_token_is_rejected( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + response: dict[str, Any], +) -> None: + """Test a response with no usable access token is not merged over the old one.""" + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(TOKEN_URL, json=response) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with pytest.raises(OAuth2TokenRequestConnectionError): + await session.async_ensure_token_valid() + + # The stale token must stay expired so the next attempt refreshes again. + assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1 + assert config_entry.data["token"]["expires_at"] == 0 + + +@pytest.mark.parametrize( + "refreshed", + [ + pytest.param({"expires_in": 100}, id="no_access_token"), + pytest.param({"access_token": None, "expires_in": 100}, id="null_access_token"), + ], +) +async def test_oauth_session_custom_implementation_without_access_token( + hass: HomeAssistant, + refreshed: dict[str, Any], +) -> None: + """Test an implementation returning no usable access token is rejected.""" + + class BadImplementation(MockOAuth2Implementation): + """Implementation whose refresh skips the local token request.""" + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh a token.""" + return refreshed + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, BadImplementation() + ) + with pytest.raises(OAuth2TokenRequestConnectionError): + await session.async_ensure_token_valid() + + assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1 + + +@pytest.mark.parametrize( + "refreshed", + [ + pytest.param({"expires_at": FUTURE_EXPIRES_AT}, id="no_access_token"), + pytest.param({"access_token": ACCESS_TOKEN_2}, id="no_expires_at"), + ], +) +async def test_oauth_session_never_stores_an_unusable_token( + hass: HomeAssistant, + refreshed: dict[str, Any], +) -> None: + """Test the session checks the new token even when the refresh skips its own.""" + + class UncheckedImplementation(MockOAuth2Implementation): + """Implementation overriding the public refresh, so nothing maps for it.""" + + async def async_refresh_token(self, token: dict) -> dict: + """Refresh a token without the base class validation.""" + return refreshed + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, UncheckedImplementation() + ) + with pytest.raises(OAuth2TokenRequestConnectionError): + await session.async_ensure_token_valid() + + assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1 + assert config_entry.data["token"]["expires_at"] == 0 + + +@pytest.mark.parametrize( + ("raised", "expected_exception"), + [ + pytest.param( + ClientPayloadError("Disconnected"), + OAuth2TokenRequestConnectionError, + id="payload_error", + ), + pytest.param( + ContentTypeError( + RequestInfo( + url=URL(TOKEN_URL), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + ), + (), + ), + OAuth2TokenRequestError, + id="content_type_error", + ), + ], +) +async def test_oauth_session_refresh_body_error_is_mapped( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + raised: Exception, + expected_exception: type[Exception], +) -> None: + """Test a failure reading the token response body does not leak an aiohttp error.""" + mock_integration(hass, MockModule(domain=TEST_DOMAIN)) + + flow_handler.async_register_implementation(hass, local_impl) + + aioclient_mock.post(TOKEN_URL, json={}) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(AiohttpClientMockResponse, "json", side_effect=raised), + pytest.raises(expected_exception) as err, + ): + await session.async_ensure_token_valid() + + assert type(err.value) is expected_exception + assert isinstance(err.value, ConfigEntryNotReady) + + +@pytest.mark.parametrize( + ("raised", "expected_exception", "expected_base"), + [ + pytest.param( + ClientResponseError( + RequestInfo( + url=URL(TOKEN_URL), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + ), + (), + status=HTTPStatus.UNAUTHORIZED, + ), + OAuth2TokenRequestReauthError, + ConfigEntryAuthFailed, + id="reauth", + ), + pytest.param( + ClientResponseError( + RequestInfo( + url=URL(TOKEN_URL), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + ), + (), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ), + OAuth2TokenRequestTransientError, + ConfigEntryNotReady, + id="transient", + ), + pytest.param( + ClientError("Cannot connect"), + OAuth2TokenRequestConnectionError, + ConfigEntryNotReady, + id="connection_error", + ), + ], +) +async def test_refresh_maps_errors_from_custom_implementation( + hass: HomeAssistant, + raised: Exception, + expected_exception: type[Exception], + expected_base: type[Exception], +) -> None: + """Test an implementation issuing its own token request still raises mapped errors.""" + + class UnmappedImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): + """Implementation that lets raw aiohttp errors escape, like a custom one.""" + + async def _async_refresh_token(self, token: dict) -> dict: + raise raised + + implementation = UnmappedImplementation( + hass, TEST_DOMAIN, CLIENT_ID, CLIENT_SECRET, AUTHORIZE_URL, TOKEN_URL + ) + + with pytest.raises(expected_exception) as err: + await implementation.async_refresh_token({"refresh_token": REFRESH_TOKEN}) + + assert type(err.value) is expected_exception + assert isinstance(err.value, expected_base) + assert err.value.__cause__ is raised + + @pytest.mark.parametrize( "entry_state", [ @@ -1534,8 +1895,8 @@ async def test_oauth2_request_replaces_caller_authorization_header( ), pytest.param( 600, - config_entries.ConfigEntryState.SETUP_ERROR, - None, + config_entries.ConfigEntryState.SETUP_RETRY, + "oauth2_helper_refresh_failed", id="generic", ), ], @@ -1551,8 +1912,8 @@ async def test_token_error_handled_without_integration_mapping( ) -> None: """Test setup maps token refresh errors when the integration does not. - Only the transient and reauth subclasses carry config entry semantics, the - base error is left to the integration. + Every subclass carries config entry semantics, so the reauth subclass fails + setup while the others retry it. """ aioclient_mock.post(TOKEN_URL, status=status_code, json={})