diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 444ea24cb6b..02a3ca29335 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -1,7 +1,12 @@ """Config flow for Home Connect.""" +from collections.abc import Mapping 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 .const import DOMAIN @@ -20,3 +25,29 @@ class OAuth2FlowHandler( def logger(self) -> logging.Logger: """Return logger.""" 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) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 16584bfd586..da47d8ec91c 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -26,12 +26,14 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, HomeConnectRequestError, + UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -270,6 +272,12 @@ class HomeConnectCoordinator( """Fetch data from Home Connect.""" try: 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: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d163d04a6f7..d07cfcdf854 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -7,9 +7,14 @@ "step": { "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": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", @@ -22,6 +27,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication error: {error}. Please, re-authenticate your account" + }, "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 343d648e543..c35678e4e5f 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -1,7 +1,8 @@ """Test the Home Connect config flow.""" +from collections.abc import Awaitable, Callable from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest @@ -93,3 +94,57 @@ async def test_prevent_multiple_config_entries( assert result["type"] == "abort" 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" diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index b3d1c4e531f..009c40b662d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError import pytest import requests_mock 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( + exception: HomeConnectError, + expected_state: ConfigEntryState, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, @@ -224,10 +233,10 @@ async def test_client_error( ) -> None: """Test client errors during setup integration.""" 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 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