diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index b30b044e238..8bf065797e5 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PyLoadCoordinator @@ -56,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo except ParserError as e: raise ConfigEntryNotReady("Unable to parse data from pyLoad API") from e except InvalidAuth as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( f"Authentication failed for {entry.data[CONF_USERNAME]}, check your login credentials" ) from e coordinator = PyLoadCoordinator(hass, pyloadapi) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 7ebc4a501d4..7a2dfddeb5b 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import CookieJar from pyloadapi.api import PyLoadAPI @@ -23,7 +24,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from . import PyLoadConfigEntry from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -39,6 +46,23 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } +) + async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None: """Validate the user input and try to connect to PyLoad.""" @@ -67,8 +91,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for pyLoad.""" VERSION = 1 - # store values from yaml import so we can use them as - # suggested values when the configuration step is resumed + config_entry: PyLoadConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -118,3 +141,51 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): if errors := result.get("errors"): return self.async_abort(reason=errors["base"]) return result + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.config_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors = {} + + if TYPE_CHECKING: + assert self.config_entry + + if user_input is not None: + new_input = self.config_entry.data | user_input + try: + await validate_input(self.hass, new_input) + except (CannotConnect, ParserError): + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self.config_entry, data=new_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + REAUTH_SCHEMA, + { + CONF_USERNAME: user_input[CONF_USERNAME] + if user_input is not None + else self.config_entry.data[CONF_USERNAME] + }, + ), + description_placeholders={CONF_NAME: self.config_entry.data[CONF_USERNAME]}, + errors=errors, + ) diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 008375c3a34..b96a8d2ccbf 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -8,7 +8,7 @@ from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -63,12 +63,12 @@ class PyLoadCoordinator(DataUpdateCoordinator[pyLoadData]): try: await self.pyload.login() except InvalidAuth as exc: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( f"Authentication failed for {self.pyload.username}, check your login credentials", ) from exc raise UpdateFailed( - "Unable to retrieve data due to cookie expiration but re-authentication was successful." + "Unable to retrieve data due to cookie expiration" ) from e except CannotConnect as e: raise UpdateFailed( diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 94c0c29d286..6efdb23eaf4 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -16,6 +16,13 @@ "host": "The hostname or IP address of the device running your pyLoad instance.", "port": "pyLoad uses port 8000 by default." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -24,7 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 3c6f9fdb49a..1d7b11567c7 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -41,6 +41,19 @@ YAML_INPUT = { CONF_SSL: True, CONF_USERNAME: "test-username", } +REAUTH_INPUT = { + CONF_PASSWORD: "new-password", + CONF_USERNAME: "new-username", +} + +NEW_INPUT = { + CONF_HOST: "pyload.local", + CONF_PASSWORD: "new-password", + CONF_PORT: 8000, + CONF_SSL: True, + CONF_USERNAME: "new-username", + CONF_VERIFY_SSL: False, +} @pytest.fixture diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 70d324fd980..63297de7127 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,11 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import USER_INPUT, YAML_INPUT +from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT, YAML_INPUT from tests.common import MockConfigEntry @@ -164,3 +164,89 @@ async def test_flow_import_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason + + +async def test_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pyloadapi: AsyncMock, +) -> None: + """Test reauth flow.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + "unique_id": config_entry.unique_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data == NEW_INPUT + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pyloadapi: AsyncMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test reauth flow.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + "unique_id": config_entry.unique_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pyloadapi.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_pyloadapi.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + REAUTH_INPUT, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data == NEW_INPUT + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index a1ecf294523..12713ef2e54 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -61,3 +61,5 @@ async def test_config_entry_setup_invalid_auth( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_ERROR + + assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}))