From 91a43873a2f3475d6cd2eb666bce561a384daee3 Mon Sep 17 00:00:00 2001 From: Joshua Monta <42532812+joshsmonta@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:03:01 +0800 Subject: [PATCH] feat: implement reauthentication requirement (#165641) --- homeassistant/components/uhoo/__init__.py | 8 +-- homeassistant/components/uhoo/config_flow.py | 41 ++++++++++- homeassistant/components/uhoo/coordinator.py | 5 +- .../components/uhoo/quality_scale.yaml | 2 +- homeassistant/components/uhoo/strings.json | 11 ++- tests/components/uhoo/test_config_flow.py | 71 ++++++++++++++++++- 6 files changed, 128 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/uhoo/__init__.py b/homeassistant/components/uhoo/__init__.py index 1b9a223efb5..898d1a8583a 100644 --- a/homeassistant/components/uhoo/__init__.py +++ b/homeassistant/components/uhoo/__init__.py @@ -3,11 +3,11 @@ from aiodns.error import DNSError from aiohttp.client_exceptions import ClientConnectionError from uhooapi import Client -from uhooapi.errors import UhooError, UnauthorizedError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import PLATFORMS @@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: UhooConfigEntry) await client.setup_devices() except (ClientConnectionError, DNSError) as err: raise ConfigEntryNotReady(f"Cannot connect to uHoo servers: {err}") from err - except UnauthorizedError as err: - raise ConfigEntryError(f"Invalid API credentials: {err}") from err + except (UnauthorizedError, ForbiddenError) as err: + raise ConfigEntryAuthFailed(f"Invalid API credentials: {err}") from err except UhooError as err: raise ConfigEntryNotReady(err) from err diff --git a/homeassistant/components/uhoo/config_flow.py b/homeassistant/components/uhoo/config_flow.py index dbaa8d1c6ad..348f22e7069 100644 --- a/homeassistant/components/uhoo/config_flow.py +++ b/homeassistant/components/uhoo/config_flow.py @@ -1,9 +1,10 @@ """Custom uhoo config flow setup.""" +from collections.abc import Mapping from typing import Any from uhooapi import Client -from uhooapi.errors import UhooError, UnauthorizedError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -45,7 +46,7 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN): client = Client(user_input[CONF_API_KEY], session, debug=True) try: await client.login() - except UnauthorizedError: + except UnauthorizedError, ForbiddenError: errors["base"] = "invalid_auth" except UhooError: errors["base"] = "cannot_connect" @@ -65,3 +66,39 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + 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 is not None: + session = async_create_clientsession(self.hass) + client = Client(user_input[CONF_API_KEY], session, debug=True) + try: + await client.login() + except UnauthorizedError, ForbiddenError: + errors["base"] = "invalid_auth" + except UhooError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uhoo/coordinator.py b/homeassistant/components/uhoo/coordinator.py index da42fb2c88a..004299239d0 100644 --- a/homeassistant/components/uhoo/coordinator.py +++ b/homeassistant/components/uhoo/coordinator.py @@ -1,10 +1,11 @@ """Custom uhoo data update coordinator.""" from uhooapi import Client, Device -from uhooapi.errors import UhooError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -34,6 +35,8 @@ class UhooDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): if self.client.devices: for device_id in self.client.devices: await self.client.get_latest_data(device_id) + except (UnauthorizedError, ForbiddenError) as error: + raise ConfigEntryAuthFailed(f"Invalid API credentials: {error}") from error except UhooError as error: raise UpdateFailed(f"The device is unavailable: {error}") from error else: diff --git a/homeassistant/components/uhoo/quality_scale.yaml b/homeassistant/components/uhoo/quality_scale.yaml index 5a63545c164..3f762740e67 100644 --- a/homeassistant/components/uhoo/quality_scale.yaml +++ b/homeassistant/components/uhoo/quality_scale.yaml @@ -28,7 +28,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/uhoo/strings.json b/homeassistant/components/uhoo/strings.json index aab2414e837..56086e9d46b 100644 --- a/homeassistant/components/uhoo/strings.json +++ b/homeassistant/components/uhoo/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +10,14 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your uHoo API key. You can find this in your uHoo account settings." + } + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/tests/components/uhoo/test_config_flow.py b/tests/components/uhoo/test_config_flow.py index 69855e193f1..689ee5a58a2 100644 --- a/tests/components/uhoo/test_config_flow.py +++ b/tests/components/uhoo/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from uhooapi.errors import UhooError, UnauthorizedError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError from homeassistant.components.uhoo.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -57,6 +57,7 @@ async def test_user_duplicate_entry( [ (UhooError("asd"), "cannot_connect"), (UnauthorizedError("Invalid credentials"), "invalid_auth"), + (ForbiddenError("Forbidden"), "invalid_auth"), (Exception(), "unknown"), ], ) @@ -94,3 +95,71 @@ async def test_user_flow_exceptions( ) assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication flow.""" + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key-67890"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key-67890" + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + (UnauthorizedError("Invalid credentials"), "invalid_auth"), + (ForbiddenError("Forbidden"), "invalid_auth"), + (UhooError("asd"), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_type: str, +) -> None: + """Test reauthentication flow with errors and recovery.""" + 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" + + mock_uhoo_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key-67890"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error_type} + + mock_uhoo_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key-67890"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key-67890"