mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Add reauthentication flow to ouman_eh_800 (#178312)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Config flow for the Ouman EH-800 integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
@@ -14,6 +15,11 @@ from yarl import URL
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -27,6 +33,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): TextSelector(),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Reduce URL to scheme://host[:port], discarding any path, query, or fragment."""
|
||||
@@ -38,22 +53,16 @@ class OumanEh800ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _async_validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Normalize the URL, check for duplicates and test the connection.
|
||||
async def _async_try_login(self, data: Mapping[str, Any]) -> dict[str, str]:
|
||||
"""Test the connection by logging in to the device.
|
||||
|
||||
Mutates user_input to hold the normalized URL. Returns form errors,
|
||||
empty if validation succeeded.
|
||||
Returns form errors, empty if the login succeeded.
|
||||
"""
|
||||
try:
|
||||
user_input[CONF_URL] = _normalize_url(user_input[CONF_URL])
|
||||
except ValueError:
|
||||
return {CONF_URL: "invalid_url"}
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
client = OumanEh800Client(
|
||||
session=async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
address=user_input[CONF_URL],
|
||||
username=data[CONF_USERNAME],
|
||||
password=data[CONF_PASSWORD],
|
||||
address=data[CONF_URL],
|
||||
)
|
||||
try:
|
||||
await client.login()
|
||||
@@ -66,6 +75,19 @@ class OumanEh800ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return {"base": "unknown"}
|
||||
return {}
|
||||
|
||||
async def _async_validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Normalize the URL, check for duplicates and test the connection.
|
||||
|
||||
Mutates user_input to hold the normalized URL. Returns form errors,
|
||||
empty if validation succeeded.
|
||||
"""
|
||||
try:
|
||||
user_input[CONF_URL] = _normalize_url(user_input[CONF_URL])
|
||||
except ValueError:
|
||||
return {CONF_URL: "invalid_url"}
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
return await self._async_try_login(user_input)
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -84,6 +106,36 @@ class OumanEh800ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initiation of re-authentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication with the device."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if not (
|
||||
errors := await self._async_try_login(
|
||||
{CONF_URL: reauth_entry.data[CONF_URL], **user_input}
|
||||
)
|
||||
):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_REAUTH_DATA_SCHEMA, user_input or reauth_entry.data
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryError,
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
@@ -103,7 +103,7 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue
|
||||
await self.client.login()
|
||||
self._registry_set = await self.client.get_active_registries()
|
||||
except OumanClientAuthenticationError as err:
|
||||
raise ConfigEntryError("Invalid credentials") from err
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from err
|
||||
except OumanClientCommunicationError as err:
|
||||
raise ConfigEntryNotReady("Error communicating with API") from err
|
||||
|
||||
@@ -122,6 +122,9 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue
|
||||
try:
|
||||
result = await self.client.set_endpoint_value(endpoint, value)
|
||||
except OumanClientAuthenticationError as err:
|
||||
# Reload the config entry; setup re-validates the credentials and
|
||||
# starts a reauth flow if they are no longer valid.
|
||||
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
|
||||
raise HomeAssistantError("Authentication failed") from err
|
||||
except OumanClientCommunicationError as err:
|
||||
raise HomeAssistantError("Error communicating with API") from err
|
||||
|
||||
@@ -42,7 +42,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
@@ -11,6 +12,17 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::ouman_eh_800::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::ouman_eh_800::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Re-enter the credentials for the Ouman EH-800 web interface."
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -254,3 +254,68 @@ async def test_reconfigure_flow_already_configured(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ouman_client")
|
||||
async def test_reauth_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test a successful reauthentication updates the credentials."""
|
||||
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"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: "new-user", CONF_PASSWORD: "new-pass"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data == {
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: "new-user",
|
||||
CONF_PASSWORD: "new-pass",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_error"),
|
||||
[
|
||||
(OumanClientCommunicationError("Connection failed"), "cannot_connect"),
|
||||
(OumanClientAuthenticationError("Invalid credentials"), "invalid_auth"),
|
||||
(RuntimeError("Unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_flow_errors_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ouman_client: AsyncMock,
|
||||
error: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that reauthentication errors are surfaced and the flow can recover."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
mock_ouman_client.login.side_effect = error
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "new-pass"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
mock_ouman_client.login.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "new-pass"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
@@ -9,8 +9,15 @@ from ouman_eh_800_api import (
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ouman_eh_800.const import DOMAIN, OumanDevice
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.components.select import (
|
||||
ATTR_OPTION,
|
||||
DOMAIN as SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -59,15 +66,19 @@ async def test_setup_unload_entry(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_state"),
|
||||
("error", "expected_state", "expected_reauth_flows"),
|
||||
[
|
||||
(
|
||||
pytest.param(
|
||||
OumanClientCommunicationError("Connection failed"),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
0,
|
||||
id="communication_error",
|
||||
),
|
||||
(
|
||||
pytest.param(
|
||||
OumanClientAuthenticationError("Invalid credentials"),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
1,
|
||||
id="authentication_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -77,6 +88,7 @@ async def test_setup_error(
|
||||
mock_ouman_client: AsyncMock,
|
||||
error: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
expected_reauth_flows: int,
|
||||
) -> None:
|
||||
"""Test that setup raises the correct config-entry exception on client errors."""
|
||||
mock_ouman_client.login.side_effect = error
|
||||
@@ -85,3 +97,43 @@ async def test_setup_error(
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is expected_state
|
||||
|
||||
reauth_flows = hass.config_entries.flow.async_progress_by_handler(
|
||||
DOMAIN, match_context={"source": SOURCE_REAUTH}
|
||||
)
|
||||
assert len(reauth_flows) == expected_reauth_flows
|
||||
|
||||
|
||||
@pytest.mark.parametrize("init_integration", [Platform.SELECT], indirect=True)
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_reauth_started_on_action_auth_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_ouman_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that an auth failure when setting a value starts a reauth flow.
|
||||
|
||||
All platforms set values through the same coordinator method, which
|
||||
reloads the config entry on an authentication failure; setup then
|
||||
re-validates the credentials and starts the reauth flow. The select
|
||||
entity here is just one arbitrary way to trigger that shared path.
|
||||
"""
|
||||
error = OumanClientAuthenticationError("Wrong username or password")
|
||||
mock_ouman_client.set_endpoint_value.side_effect = error
|
||||
mock_ouman_client.login.side_effect = error
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Authentication failed"):
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: "select.ouman_eh_800_home_away_mode",
|
||||
ATTR_OPTION: "away",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
reauth_flows = hass.config_entries.flow.async_progress_by_handler(
|
||||
DOMAIN, match_context={"source": SOURCE_REAUTH}
|
||||
)
|
||||
assert len(reauth_flows) == 1
|
||||
|
||||
Reference in New Issue
Block a user