From 1fe2a928a2dada29194d7ccad8f12b0caff14e0d Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 4 Dec 2024 01:48:35 +0100 Subject: [PATCH] Add reauthentication flow for Powerfox integration (#132225) * Add reauthentication flow for Powerfox integration * Update quality scale --- .../components/powerfox/config_flow.py | 47 +++++++++++- .../components/powerfox/coordinator.py | 15 +++- .../components/powerfox/quality_scale.yaml | 2 +- .../components/powerfox/strings.json | 13 +++- tests/components/powerfox/test_config_flow.py | 75 ++++++++++++++++++- tests/components/powerfox/test_init.py | 19 ++++- 6 files changed, 163 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/powerfox/config_flow.py b/homeassistant/components/powerfox/config_flow.py index b4eddeb6fce..ca78b8eb874 100644 --- a/homeassistant/components/powerfox/config_flow.py +++ b/homeassistant/components/powerfox/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from powerfox import Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError @@ -20,6 +21,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Powerfox.""" @@ -28,7 +35,8 @@ class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} + errors = {} + if user_input is not None: self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) client = Powerfox( @@ -55,3 +63,40 @@ class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, data_schema=STEP_USER_DATA_SCHEMA, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication flow for Powerfox.""" + 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 flow for Powerfox.""" + errors = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + client = Powerfox( + username=reauth_entry.data[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, + data_schema=STEP_REAUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index 6fd9b2af189..f7ec5ab6716 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -2,10 +2,17 @@ from __future__ import annotations -from powerfox import Device, Powerfox, PowerfoxConnectionError, Poweropti +from powerfox import ( + Device, + Powerfox, + PowerfoxAuthenticationError, + PowerfoxConnectionError, + Poweropti, +) 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, SCAN_INTERVAL @@ -36,5 +43,7 @@ class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): """Fetch data from Powerfox API.""" try: return await self.client.device(device_id=self.device.id) - except PowerfoxConnectionError as error: - raise UpdateFailed(error) from error + except PowerfoxAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except PowerfoxConnectionError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/powerfox/quality_scale.yaml b/homeassistant/components/powerfox/quality_scale.yaml index 5b1fa9e6398..43172a2e84a 100644 --- a/homeassistant/components/powerfox/quality_scale.yaml +++ b/homeassistant/components/powerfox/quality_scale.yaml @@ -46,7 +46,7 @@ rules: status: exempt comment: | This integration uses a coordinator to handle updates. - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json index 451100f3b42..3eab77494d3 100644 --- a/homeassistant/components/powerfox/strings.json +++ b/homeassistant/components/powerfox/strings.json @@ -11,6 +11,16 @@ "email": "The email address of your Powerfox account.", "password": "The password of your Powerfox account." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The password for {email} is no longer valid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::powerfox::config::step::user::data_description::password%]" + } } }, "error": { @@ -18,7 +28,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "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%]" } }, "entity": { diff --git a/tests/components/powerfox/test_config_flow.py b/tests/components/powerfox/test_config_flow.py index b99470880a0..759092aee6e 100644 --- a/tests/components/powerfox/test_config_flow.py +++ b/tests/components/powerfox/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Powerfox config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError import pytest @@ -136,6 +136,7 @@ async def test_exceptions( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {"base": error} + # Recover from error mock_powerfox_client.all_devices.side_effect = None result = await hass.config_entries.flow.async_configure( @@ -143,3 +144,75 @@ async def test_exceptions( user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, ) assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_step_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test re-authentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + with patch( + "homeassistant.components.powerfox.config_flow.Powerfox", + autospec=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_step_reauth_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions during re-authentication flow.""" + mock_powerfox_client.all_devices.side_effect = exception + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/powerfox/test_init.py b/tests/components/powerfox/test_init.py index 900c7b60ae0..1ad60babc04 100644 --- a/tests/components/powerfox/test_init.py +++ b/tests/components/powerfox/test_init.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from powerfox import PowerfoxConnectionError +from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,3 +43,20 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_exception( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + mock_config_entry.add_to_hass(hass) + mock_powerfox_client.device.side_effect = PowerfoxAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm"