Raise ConfigEntryAuthFailed at Home Connect update auth error (#136953)

* Raise `ConfigEntryAuthFailed` on `UnauthorizedError` handling

* Implement reauth flow

* Add tests

* Remove unnecessary code from tests
This commit is contained in:
J. Diego Rodríguez Royo 2025-02-09 12:36:08 +01:00 committed by GitHub
parent 80cff85c14
commit 4a8c96471b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 4 deletions

View File

@ -1,7 +1,12 @@
"""Config flow for Home Connect.""" """Config flow for Home Connect."""
from collections.abc import Mapping
import logging import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN from .const import DOMAIN
@ -20,3 +25,29 @@ class OAuth2FlowHandler(
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
"""Return logger.""" """Return logger."""
return logging.getLogger(__name__) return logging.getLogger(__name__)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=data,
)
return await super().async_oauth_create_entry(data)

View File

@ -26,12 +26,14 @@ from aiohomeconnect.model.error import (
HomeConnectApiError, HomeConnectApiError,
HomeConnectError, HomeConnectError,
HomeConnectRequestError, HomeConnectRequestError,
UnauthorizedError,
) )
from aiohomeconnect.model.program import EnumerateProgram from aiohomeconnect.model.program import EnumerateProgram
from propcache.api import cached_property from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -270,6 +272,12 @@ class HomeConnectCoordinator(
"""Fetch data from Home Connect.""" """Fetch data from Home Connect."""
try: try:
appliances = await self.client.get_home_appliances() appliances = await self.client.get_home_appliances()
except UnauthorizedError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error: except HomeConnectError as error:
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,

View File

@ -7,9 +7,14 @@
"step": { "step": {
"pick_implementation": { "pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Home Connect integration needs to re-authenticate your account"
} }
}, },
"abort": { "abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
@ -22,6 +27,9 @@
} }
}, },
"exceptions": { "exceptions": {
"auth_error": {
"message": "Authentication error: {error}. Please, re-authenticate your account"
},
"appliance_not_found": { "appliance_not_found": {
"message": "Appliance for device ID {device_id} not found" "message": "Appliance for device ID {device_id} not found"
}, },

View File

@ -1,7 +1,8 @@
"""Test the Home Connect config flow.""" """Test the Home Connect config flow."""
from collections.abc import Awaitable, Callable
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch from unittest.mock import MagicMock, patch
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
import pytest import pytest
@ -93,3 +94,57 @@ async def test_prevent_multiple_config_entries(
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed" assert result["reason"] == "single_instance_allowed"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reauth_flow(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test reauth flow."""
result = await config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
_client = await hass_client_no_auth()
resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.home_connect.async_setup_entry", return_value=True
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"

View File

@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.const import OAUTH2_TOKEN
from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError
import pytest import pytest
import requests_mock import requests_mock
import respx import respx
@ -216,7 +216,16 @@ async def test_token_refresh_success(
) )
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(HomeConnectError(), ConfigEntryState.SETUP_RETRY),
(UnauthorizedError("error.key"), ConfigEntryState.SETUP_ERROR),
],
)
async def test_client_error( async def test_client_error(
exception: HomeConnectError,
expected_state: ConfigEntryState,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
@ -224,10 +233,10 @@ async def test_client_error(
) -> None: ) -> None:
"""Test client errors during setup integration.""" """Test client errors during setup integration."""
client_with_exception.get_home_appliances.return_value = None client_with_exception.get_home_appliances.return_value = None
client_with_exception.get_home_appliances.side_effect = HomeConnectError() client_with_exception.get_home_appliances.side_effect = exception
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert not await integration_setup(client_with_exception) assert not await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.SETUP_RETRY assert config_entry.state == expected_state
assert client_with_exception.get_home_appliances.call_count == 1 assert client_with_exception.get_home_appliances.call_count == 1