diff --git a/homeassistant/components/imou/config_flow.py b/homeassistant/components/imou/config_flow.py index a00b7bf5ffe8..8eddc540357d 100644 --- a/homeassistant/components/imou/config_flow.py +++ b/homeassistant/components/imou/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Imou.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -17,12 +18,26 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_APP_SECRET): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + class ImouConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Imou integration.""" @@ -30,6 +45,27 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: + """Validate credentials and close the temporary client.""" + errors: dict[str, str] = {} + api_client = ImouOpenApiClient( + user_input[CONF_APP_ID], + user_input[CONF_APP_SECRET], + API_URLS[user_input[CONF_API_URL]], + ) + try: + await api_client.async_get_token() + except InvalidAppIdOrSecretException: + errors["base"] = "invalid_auth" + except ConnectFailedException, RequestFailedException: + errors["base"] = "cannot_connect" + except ImouException as exception: + _LOGGER.debug("Imou error during config flow: %s", exception) + errors["base"] = "unknown" + finally: + await api_client.async_close() + return errors + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -39,21 +75,7 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: await self.async_set_unique_id(user_input[CONF_APP_ID]) self._abort_if_unique_id_configured() - api_client = ImouOpenApiClient( - user_input[CONF_APP_ID], - user_input[CONF_APP_SECRET], - API_URLS[user_input[CONF_API_URL]], - ) - try: - await api_client.async_get_token() - except InvalidAppIdOrSecretException: - errors["base"] = "invalid_auth" - except ConnectFailedException, RequestFailedException: - errors["base"] = "cannot_connect" - except ImouException as exception: - _LOGGER.debug("Imou error during config flow: %s", exception) - errors["base"] = "unknown" - else: + if not (errors := await self._validate_input(user_input)): return self.async_create_entry( title="Imou", data={ @@ -79,3 +101,38 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication 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: + """Confirm reauthentication with a new App secret.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + if not ( + errors := await self._validate_input( + { + CONF_APP_ID: reauth_entry.data[CONF_APP_ID], + CONF_APP_SECRET: user_input[CONF_APP_SECRET], + CONF_API_URL: reauth_entry.data[CONF_API_URL], + } + ) + ): + await self.async_set_unique_id(reauth_entry.data[CONF_APP_ID]) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_APP_SECRET: user_input[CONF_APP_SECRET]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"app_id": reauth_entry.data[CONF_APP_ID]}, + errors=errors, + ) diff --git a/homeassistant/components/imou/quality_scale.yaml b/homeassistant/components/imou/quality_scale.yaml index 21e8faea2146..a9c86bee01c3 100644 --- a/homeassistant/components/imou/quality_scale.yaml +++ b/homeassistant/components/imou/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index 889a603f56b1..4c45bc362cd2 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The App ID does not match the previously configured account." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -10,6 +12,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "app_secret": "[%key:component::imou::config::step::user::data::app_secret%]" + }, + "data_description": { + "app_secret": "[%key:component::imou::config::step::user::data_description::app_secret%]" + }, + "description": "Enter a new App secret for {app_id}.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "api_url": "Server region", diff --git a/tests/components/imou/test_config_flow.py b/tests/components/imou/test_config_flow.py index d6e8eaad8917..700fe9df47fd 100644 --- a/tests/components/imou/test_config_flow.py +++ b/tests/components/imou/test_config_flow.py @@ -31,6 +31,8 @@ DHCP_DISCOVERY = DhcpServiceInfo( macaddress="1c4d895f7a29", ) +NEW_APP_SECRET = "new_app_secret" + async def test_user_flow_success( hass: HomeAssistant, @@ -262,3 +264,92 @@ async def test_dhcp_discovery_invalid_auth( assert result["data"] == USER_INPUT assert result["result"].unique_id == USER_INPUT[CONF_APP_ID] assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Reauth updates the App secret and reloads the entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_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"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_APP_SECRET] == NEW_APP_SECRET + mock_imou_openapi_client.async_close.assert_awaited_once() + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ConnectFailedException("fail"), "cannot_connect"), + (RequestFailedException("fail"), "cannot_connect"), + (InvalidAppIdOrSecretException("fail"), "invalid_auth"), + (ImouException("fail"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_exception_then_recover( + hass: HomeAssistant, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_error: str, +) -> None: + """Errors map to stable keys; clearing the failure allows completing reauth.""" + mock_config_entry.add_to_hass(hass) + mock_imou_openapi_client.async_get_token.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == expected_error + + mock_imou_openapi_client.async_get_token.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_APP_SECRET] == NEW_APP_SECRET + assert mock_imou_openapi_client.async_close.await_count == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_unique_id_mismatch( + hass: HomeAssistant, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Reauth aborts when the unique ID does not match the existing entry.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, unique_id="other-app-id") + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch"