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
pull/131478/head
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
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

View File

@ -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}",

View File

@ -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

View File

@ -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": {

View File

@ -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."""

View File

@ -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"),