diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index da2a0e4b475..737b8a0341d 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -7,7 +7,7 @@ from typing import Any import aiohttp from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController -from pyrainbird.exceptions import RainbirdApiException +from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import format_mac @@ -91,6 +91,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: model_info = await controller.get_model_and_version() + except RainbirdAuthException as err: + raise ConfigEntryAuthFailed from err except RainbirdApiException as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index abeb1b5da15..86a3c5d5d1c 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -3,15 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging from typing import Any -from pyrainbird.async_client import ( - AsyncRainbirdClient, - AsyncRainbirdController, - RainbirdApiException, -) +from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController from pyrainbird.data import WifiParams +from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException import voluptuous as vol from homeassistant.config_entries import ( @@ -45,6 +43,13 @@ DATA_SCHEMA = vol.Schema( ), } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } +) class ConfigFlowError(Exception): @@ -59,6 +64,8 @@ class ConfigFlowError(Exception): class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Rain Bird.""" + host: str + @staticmethod @callback def async_get_options_flow( @@ -67,6 +74,35 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return RainBirdOptionsFlowHandler() + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + self.host = entry_data[CONF_HOST] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + if user_input: + try: + await self._test_connection(self.host, user_input[CONF_PASSWORD]) + except ConfigFlowError as err: + _LOGGER.error("Error during config flow: %s", err) + errors["base"] = err.error_code + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -123,6 +159,11 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): f"Timeout connecting to Rain Bird controller: {err!s}", "timeout_connect", ) from err + except RainbirdAuthException as err: + raise ConfigFlowError( + f"Authentication error connecting from Rain Bird controller: {err!s}", + "invalid_auth", + ) from err except RainbirdApiException as err: raise ConfigFlowError( f"Error connecting to Rain Bird controller: {err!s}", diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index 63ea38d47bd..e918bf845ba 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -45,7 +45,7 @@ rules: # Silver log-when-unavailable: todo config-entry-unloading: todo - reauthentication-flow: todo + reauthentication-flow: done action-exceptions: todo docs-installation-parameters: todo integration-owner: todo diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 61498b36816..25d3a962b36 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -12,14 +12,26 @@ "host": "The hostname or IP address of your Rain Bird device.", "password": "The password used to authenticate with the Rain Bird device." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Rain Bird integration needs to re-authenticate with the device.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password to authenticate with your Rain Bird device." + } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, "options": { diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 87506ad656c..6e76943f202 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -56,7 +56,7 @@ async def mock_setup() -> AsyncGenerator[AsyncMock]: yield mock_setup -async def complete_flow(hass: HomeAssistant) -> FlowResult: +async def complete_flow(hass: HomeAssistant, password: str = PASSWORD) -> FlowResult: """Start the config flow and enter the host and password.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -268,6 +268,59 @@ async def test_controller_cannot_connect( assert not mock_setup.mock_calls +async def test_controller_invalid_auth( + hass: HomeAssistant, + mock_setup: Mock, + responses: list[AiohttpClientMockResponse], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test an invalid password.""" + + responses.clear() + responses.extend( + [ + # Incorrect password response + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + # Second attempt with the correct password + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") + assert "flow_id" in result + + # Simulate authentication error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_PASSWORD: "wrong-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "invalid_auth"} + + assert not mock_setup.mock_calls + + # Correct the form and enter the password again and setup completes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == HOST + assert "result" in result + assert dict(result["result"].data) == CONFIG_ENTRY_DATA + assert result["result"].unique_id == MAC_ADDRESS_UNIQUE_ID + + assert len(mock_setup.mock_calls) == 1 + + async def test_controller_timeout( hass: HomeAssistant, mock_setup: Mock, @@ -286,6 +339,67 @@ async def test_controller_timeout( assert not mock_setup.mock_calls +@pytest.mark.parametrize( + ("responses", "config_entry_data"), + [ + ( + [ + # First attempt simulate the wrong password + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + # Second attempt simulate the correct password + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], + { + **CONFIG_ENTRY_DATA, + CONF_PASSWORD: "old-password", + }, + ), + ], +) +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test the controller is setup correctly.""" + assert config_entry.data.get(CONF_PASSWORD) == "old-password" + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result.get("step_id") == "reauth_confirm" + assert not result.get("errors") + + # Simluate the wrong password + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "incorrect_password"}, + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + assert result.get("errors") == {"base": "invalid_auth"} + + # Enter the correct password and complete the flow + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: PASSWORD}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.unique_id == MAC_ADDRESS_UNIQUE_ID + assert entry.data.get(CONF_PASSWORD) == PASSWORD + + assert len(mock_setup.mock_calls) == 1 + + async def test_options_flow(hass: HomeAssistant, mock_setup: Mock) -> None: """Test config flow options.""" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 5b2e2ea6d1b..01e0c4458e4 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -45,17 +45,19 @@ async def test_init_success( @pytest.mark.parametrize( - ("config_entry_data", "responses", "config_entry_state"), + ("config_entry_data", "responses", "config_entry_state", "config_flow_steps"), [ ( CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], ConfigEntryState.SETUP_RETRY, + [], ), ( CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], ConfigEntryState.SETUP_RETRY, + [], ), ( CONFIG_ENTRY_DATA, @@ -64,6 +66,7 @@ async def test_init_success( mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), ], ConfigEntryState.SETUP_RETRY, + [], ), ( CONFIG_ENTRY_DATA, @@ -72,6 +75,13 @@ async def test_init_success( mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), ], ConfigEntryState.SETUP_RETRY, + [], + ), + ( + CONFIG_ENTRY_DATA, + [mock_response_error(HTTPStatus.FORBIDDEN)], + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], ), ], ids=[ @@ -79,17 +89,22 @@ async def test_init_success( "server-error", "coordinator-unavailable", "coordinator-server-error", + "forbidden", ], ) async def test_communication_failure( hass: HomeAssistant, config_entry: MockConfigEntry, config_entry_state: list[ConfigEntryState], + config_flow_steps: list[str], ) -> None: """Test unable to talk to device on startup, which fails setup.""" await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == config_entry_state + flows = hass.config_entries.flow.async_progress() + assert [flow["step_id"] for flow in flows] == config_flow_steps + @pytest.mark.parametrize( ("config_entry_unique_id", "config_entry_data"),