Add reauthentication flow to ouman_eh_800 (#178312)

This commit is contained in:
Markus Tuominen
2026-08-06 11:34:26 +02:00
committed by GitHub
parent b56db84e74
commit 9553643bd5
6 changed files with 203 additions and 19 deletions
@@ -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"
+56 -4
View File
@@ -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