diff --git a/homeassistant/components/indevolt/coordinator.py b/homeassistant/components/indevolt/coordinator.py index 9298330d188a3b..12381aa519afbf 100644 --- a/homeassistant/components/indevolt/coordinator.py +++ b/homeassistant/components/indevolt/coordinator.py @@ -77,10 +77,8 @@ async def _async_setup(self) -> None: """Fetch device info once on boot.""" try: config_data = await self.api.get_config() - except TimeoutError as err: - raise ConfigEntryNotReady( - f"Device config retrieval timed out: {err}" - ) from err + except (ClientError, OSError) as err: + raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err # Cache device information device_data = config_data.get("device", {}) @@ -93,8 +91,8 @@ async def _async_update_data(self) -> dict[str, Any]: try: return await self.api.fetch_data(sensor_keys) - except TimeoutError as err: - raise UpdateFailed(f"Device update timed out: {err}") from err + except (ClientError, OSError) as err: + raise UpdateFailed(f"Device update failed: {err}") from err async def async_push_data(self, sensor_key: str, value: Any) -> bool: """Push/write data values to given key on the device.""" @@ -102,7 +100,7 @@ async def async_push_data(self, sensor_key: str, value: Any) -> bool: return await self.api.set_data(sensor_key, value) except TimeoutError as err: raise DeviceTimeoutError(f"Device push timed out: {err}") from err - except (ClientError, ConnectionError, OSError) as err: + except (ClientError, OSError) as err: raise DeviceConnectionError(f"Device push failed: {err}") from err async def async_switch_energy_mode( diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 01f4b89edfee3a..5afe1b4a12e701 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -1,10 +1,11 @@ """The OpenEVSE integration.""" from openevsehttp.__main__ import OpenEVSE +from openevsehttp.exceptions import AuthenticationError from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator @@ -25,6 +26,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> await charger.test_and_get() except TimeoutError as ex: raise ConfigEntryNotReady("Unable to connect to charger") from ex + except AuthenticationError as ex: + raise ConfigEntryAuthFailed("Invalid credentials for charger") from ex coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py index 264b306654c714..866501d8235cd1 100644 --- a/homeassistant/components/openevse/config_flow.py +++ b/homeassistant/components/openevse/config_flow.py @@ -1,5 +1,6 @@ """Config flow for OpenEVSE integration.""" +from collections.abc import Mapping from typing import Any from openevsehttp.__main__ import OpenEVSE @@ -170,3 +171,38 @@ async def async_step_auth( data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication on an 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.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + errors, _ = await self.check_status( + reauth_entry.data[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input), + description_placeholders={CONF_HOST: reauth_entry.data[CONF_HOST]}, + errors=errors, + ) diff --git a/homeassistant/components/openevse/coordinator.py b/homeassistant/components/openevse/coordinator.py index d4852d63dbeaa4..22d3a9bbeb131f 100644 --- a/homeassistant/components/openevse/coordinator.py +++ b/homeassistant/components/openevse/coordinator.py @@ -4,9 +4,11 @@ import logging from openevsehttp.__main__ import OpenEVSE +from openevsehttp.exceptions import AuthenticationError 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 @@ -63,3 +65,5 @@ async def _async_update_data(self) -> None: raise UpdateFailed( f"Timeout communicating with charger: {error}" ) from error + except AuthenticationError as error: + raise ConfigEntryAuthFailed("Invalid credentials for charger") from error diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index 3a76b2bb27f6d5..68bc31dbdc361b 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "This charger is already configured", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unavailable_host": "Unable to connect to host" }, "error": { @@ -19,6 +20,17 @@ "username": "The username to access your OpenEVSE charger" } }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::openevse::config::step::auth::data_description::password%]", + "username": "[%key:component::openevse::config::step::auth::data_description::username%]" + }, + "description": "The credentials for your OpenEVSE charger at {host} are no longer valid. Please enter your current username and password." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/tests/components/indevolt/test_services.py b/tests/components/indevolt/test_services.py index e32bf4bf30151b..d943fabc838041 100644 --- a/tests/components/indevolt/test_services.py +++ b/tests/components/indevolt/test_services.py @@ -371,11 +371,11 @@ async def test_single_device_execution_failure( mock_indevolt: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that the original exception is re-raised for a single device execution failure.""" + """Test that the exception is raised for a single device execution failure.""" await setup_integration(hass, mock_config_entry) # Simulate an API push failure - mock_indevolt.set_data.side_effect = HomeAssistantError("Device push failed") + mock_indevolt.set_data.side_effect = OSError("Device push failed") # Mock call to start charging with pytest.raises(HomeAssistantError) as exc_info: @@ -391,8 +391,7 @@ async def test_single_device_execution_failure( ) # Verify correct translation key is used for the error (for single coordinator) - assert str(exc_info.value) == "Device push failed" - assert exc_info.value.translation_key is None + assert exc_info.value.translation_key == "failed_to_switch_energy_mode" @pytest.mark.parametrize("generation", [2], indirect=True) @@ -410,7 +409,7 @@ async def test_multi_device_execution_failure( await setup_integration(hass, alt_mock_config_entry) # Simulate an API push failure (triggers for both coordinators) - mock_indevolt.set_data.side_effect = HomeAssistantError("Device push failed") + mock_indevolt.set_data.side_effect = OSError("Device push failed") # Mock call to start charging both devices with pytest.raises(HomeAssistantError) as exc_info: diff --git a/tests/components/openevse/test_config_flow.py b/tests/components/openevse/test_config_flow.py index 64406f5735657d..9514daa75dae25 100644 --- a/tests/components/openevse/test_config_flow.py +++ b/tests/components/openevse/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock from openevsehttp.exceptions import AuthenticationError, MissingSerial +import pytest from homeassistant.components.openevse.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF @@ -486,3 +487,80 @@ async def test_zeroconf_already_configured_host( # Should abort because the host matches an existing entry assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_charger: MagicMock, + mock_setup_entry: 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" + assert result["description_placeholders"][CONF_HOST] == "192.168.1.100" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "newuser", CONF_PASSWORD: "newpassword"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "newuser", + CONF_PASSWORD: "newpassword", + } + + +@pytest.mark.parametrize( + ("exception", "error_base"), + [ + (AuthenticationError, "invalid_auth"), + (TimeoutError, "cannot_connect"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_charger: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_base: str, +) -> None: + """Test reauthentication flow recovers from errors.""" + 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_charger.test_and_get.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "newuser", CONF_PASSWORD: "wrongpassword"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error_base} + + mock_charger.test_and_get.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "newuser", CONF_PASSWORD: "newpassword"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "192.168.1.100", + CONF_USERNAME: "newuser", + CONF_PASSWORD: "newpassword", + } diff --git a/tests/components/openevse/test_init.py b/tests/components/openevse/test_init.py index 5d99806abc079c..a0326dbf598fe7 100644 --- a/tests/components/openevse/test_init.py +++ b/tests/components/openevse/test_init.py @@ -2,7 +2,9 @@ from unittest.mock import MagicMock -from homeassistant.config_entries import ConfigEntryState +from openevsehttp.exceptions import AuthenticationError + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -23,6 +25,49 @@ async def test_setup_entry_timeout( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_entry_auth_error_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test setup entry triggers reauth on authentication error.""" + mock_charger.test_and_get.side_effect = AuthenticationError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +async def test_coordinator_update_auth_error_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test coordinator update triggers reauth on authentication error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + coordinator = mock_config_entry.runtime_data + mock_charger.update.side_effect = AuthenticationError + await coordinator.async_refresh() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry,