diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 01f4b89edfe..5afe1b4a12e 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 264b306654c..866501d8235 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 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): 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 d4852d63dbe..22d3a9bbeb1 100644 --- a/homeassistant/components/openevse/coordinator.py +++ b/homeassistant/components/openevse/coordinator.py @@ -4,9 +4,11 @@ from datetime import timedelta 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 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[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 3a76b2bb27f..68bc31dbdc3 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/openevse/test_config_flow.py b/tests/components/openevse/test_config_flow.py index 64406f57356..9514daa75da 100644 --- a/tests/components/openevse/test_config_flow.py +++ b/tests/components/openevse/test_config_flow.py @@ -4,6 +4,7 @@ from ipaddress import ip_address 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 5d99806abc0..a0326dbf598 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,