Add reauthentication support for Rainbird (#131434)

* Add reauthentication support for Rainbird

* Add test coverage for getting the password wrong on reauth

* Improve the reauth test
This commit is contained in:
Allen Porter 2024-11-24 10:33:19 -08:00 committed by GitHub
parent b7e960f0bc
commit 1dc99ebc05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 196 additions and 12 deletions

View File

@ -7,7 +7,7 @@ from typing import Any
import aiohttp import aiohttp
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController 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.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -18,7 +18,7 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant 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 import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
@ -91,6 +91,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
model_info = await controller.get_model_and_version() model_info = await controller.get_model_and_version()
except RainbirdAuthException as err:
raise ConfigEntryAuthFailed from err
except RainbirdApiException as err: except RainbirdApiException as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err

View File

@ -3,15 +3,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from pyrainbird.async_client import ( from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
AsyncRainbirdClient,
AsyncRainbirdController,
RainbirdApiException,
)
from pyrainbird.data import WifiParams from pyrainbird.data import WifiParams
from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ( 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): class ConfigFlowError(Exception):
@ -59,6 +64,8 @@ class ConfigFlowError(Exception):
class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Rain Bird.""" """Handle a config flow for Rain Bird."""
host: str
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -67,6 +74,35 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define the config flow to handle options.""" """Define the config flow to handle options."""
return RainBirdOptionsFlowHandler() 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -123,6 +159,11 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
f"Timeout connecting to Rain Bird controller: {err!s}", f"Timeout connecting to Rain Bird controller: {err!s}",
"timeout_connect", "timeout_connect",
) from err ) 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: except RainbirdApiException as err:
raise ConfigFlowError( raise ConfigFlowError(
f"Error connecting to Rain Bird controller: {err!s}", f"Error connecting to Rain Bird controller: {err!s}",

View File

@ -45,7 +45,7 @@ rules:
# Silver # Silver
log-when-unavailable: todo log-when-unavailable: todo
config-entry-unloading: todo config-entry-unloading: todo
reauthentication-flow: todo reauthentication-flow: done
action-exceptions: todo action-exceptions: todo
docs-installation-parameters: todo docs-installation-parameters: todo
integration-owner: todo integration-owner: todo

View File

@ -12,14 +12,26 @@
"host": "The hostname or IP address of your Rain Bird device.", "host": "The hostname or IP address of your Rain Bird device.",
"password": "The password used to authenticate with the 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": { "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": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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": { "options": {

View File

@ -56,7 +56,7 @@ async def mock_setup() -> AsyncGenerator[AsyncMock]:
yield mock_setup 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.""" """Start the config flow and enter the host and password."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -268,6 +268,59 @@ async def test_controller_cannot_connect(
assert not mock_setup.mock_calls 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( async def test_controller_timeout(
hass: HomeAssistant, hass: HomeAssistant,
mock_setup: Mock, mock_setup: Mock,
@ -286,6 +339,67 @@ async def test_controller_timeout(
assert not mock_setup.mock_calls 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: async def test_options_flow(hass: HomeAssistant, mock_setup: Mock) -> None:
"""Test config flow options.""" """Test config flow options."""

View File

@ -45,17 +45,19 @@ async def test_init_success(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("config_entry_data", "responses", "config_entry_state"), ("config_entry_data", "responses", "config_entry_state", "config_flow_steps"),
[ [
( (
CONFIG_ENTRY_DATA, CONFIG_ENTRY_DATA,
[mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)],
ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_RETRY,
[],
), ),
( (
CONFIG_ENTRY_DATA, CONFIG_ENTRY_DATA,
[mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)],
ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_RETRY,
[],
), ),
( (
CONFIG_ENTRY_DATA, CONFIG_ENTRY_DATA,
@ -64,6 +66,7 @@ async def test_init_success(
mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE),
], ],
ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_RETRY,
[],
), ),
( (
CONFIG_ENTRY_DATA, CONFIG_ENTRY_DATA,
@ -72,6 +75,13 @@ async def test_init_success(
mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR),
], ],
ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_RETRY,
[],
),
(
CONFIG_ENTRY_DATA,
[mock_response_error(HTTPStatus.FORBIDDEN)],
ConfigEntryState.SETUP_ERROR,
["reauth_confirm"],
), ),
], ],
ids=[ ids=[
@ -79,17 +89,22 @@ async def test_init_success(
"server-error", "server-error",
"coordinator-unavailable", "coordinator-unavailable",
"coordinator-server-error", "coordinator-server-error",
"forbidden",
], ],
) )
async def test_communication_failure( async def test_communication_failure(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
config_entry_state: list[ConfigEntryState], config_entry_state: list[ConfigEntryState],
config_flow_steps: list[str],
) -> None: ) -> None:
"""Test unable to talk to device on startup, which fails setup.""" """Test unable to talk to device on startup, which fails setup."""
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.state == config_entry_state 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( @pytest.mark.parametrize(
("config_entry_unique_id", "config_entry_data"), ("config_entry_unique_id", "config_entry_data"),