diff --git a/CODEOWNERS b/CODEOWNERS index e22d2468f25..004bc365d89 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -611,8 +611,8 @@ build.json @home-assistant/supervisor /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar -/homeassistant/components/litterrobot/ @natekspencer -/tests/components/litterrobot/ @natekspencer +/homeassistant/components/litterrobot/ @natekspencer @tkdrob +/tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/local_ip/ @issacg /tests/components/local_ip/ @issacg /homeassistant/components/lock/ @home-assistant/core diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 334773c6f86..5aa186d0171 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -1,12 +1,9 @@ """The Litter-Robot integration.""" from __future__ import annotations -from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .hub import LitterRobotHub @@ -24,12 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Litter-Robot from a config entry.""" hass.data.setdefault(DOMAIN, {}) hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data) - try: - await hub.login(load_robots=True) - except LitterRobotLoginException: - return False - except LitterRobotException as ex: - raise ConfigEntryNotReady from ex + await hub.login(load_robots=True) if any(hub.litter_robots()): await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py index fbe32fa9749..558945ca1db 100644 --- a/homeassistant/components/litterrobot/config_flow.py +++ b/homeassistant/components/litterrobot/config_flow.py @@ -5,15 +5,16 @@ from collections.abc import Mapping import logging from typing import Any +from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .hub import LitterRobotHub _LOGGER = logging.getLogger(__name__) @@ -27,6 +28,38 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + username: str + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle a reauthorization flow request.""" + self.username = entry_data[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle user's reauth credentials.""" + errors = {} + if user_input: + entry_id = self.context["entry_id"] + if entry := self.hass.config_entries.async_get_entry(entry_id): + user_input = user_input | {CONF_USERNAME: self.username} + if not (error := await self._async_validate_input(user_input)): + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | user_input, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + errors["base"] = error + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_USERNAME: self.username}, + errors=errors, + ) + async def async_step_user( self, user_input: Mapping[str, Any] | None = None ) -> FlowResult: @@ -36,22 +69,30 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) - hub = LitterRobotHub(self.hass, user_input) - try: - await hub.login() - except LitterRobotLoginException: - errors["base"] = "invalid_auth" - except LitterRobotException: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: + if not (error := await self._async_validate_input(user_input)): return self.async_create_entry( title=user_input[CONF_USERNAME], data=user_input ) + errors["base"] = error return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def _async_validate_input(self, user_input: Mapping[str, Any]) -> str: + """Validate login credentials.""" + account = Account(websession=async_get_clientsession(self.hass)) + try: + await account.connect( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + await account.disconnect() + except LitterRobotLoginException: + return "invalid_auth" + except LitterRobotException: + return "cannot_connect" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", ex) + return "unknown" + return "" diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/hub.py index 40ba9e74a7a..627075208cc 100644 --- a/homeassistant/components/litterrobot/hub.py +++ b/homeassistant/components/litterrobot/hub.py @@ -11,6 +11,7 @@ from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginExcepti from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -52,11 +53,9 @@ class LitterRobotHub: ) return except LitterRobotLoginException as ex: - _LOGGER.error("Invalid credentials") - raise ex + raise ConfigEntryAuthFailed("Invalid credentials") from ex except LitterRobotException as ex: - _LOGGER.error("Unable to connect to Litter-Robot API") - raise ex + raise ConfigEntryNotReady("Unable to connect to Litter-Robot API") from ex def litter_robots(self) -> Generator[LitterRobot, Any, Any]: """Get Litter-Robots from the account.""" diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 32619979270..5b2f0f106b9 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -4,8 +4,8 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", "requirements": ["pylitterbot==2022.8.0"], + "codeowners": ["@natekspencer", "@tkdrob"], "dhcp": [{ "hostname": "litter-robot4" }], - "codeowners": ["@natekspencer"], "iot_class": "cloud_polling", "loggers": ["pylitterbot"] } diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index f7a539fe0e6..140a0308188 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -6,6 +6,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Please update your password for {username}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +21,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "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%]" } } } diff --git a/homeassistant/components/litterrobot/translations/en.json b/homeassistant/components/litterrobot/translations/en.json index a6c0889765f..2ca3d2f0dc2 100644 --- a/homeassistant/components/litterrobot/translations/en.json +++ b/homeassistant/components/litterrobot/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -14,6 +15,13 @@ "password": "Password", "username": "Username" } + }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please update your password for {username}", + "title": "Reauthenticate Integration" } } } diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 7bfb1321d9e..ee5b718fa60 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -1,10 +1,14 @@ """Test the Litter-Robot config flow.""" from unittest.mock import patch +from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from homeassistant import config_entries from homeassistant.components import litterrobot +from homeassistant.const import CONF_PASSWORD, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .common import CONF_USERNAME, CONFIG, DOMAIN @@ -21,7 +25,7 @@ async def test_form(hass, mock_account): assert result["errors"] == {} with patch( - "homeassistant.components.litterrobot.hub.Account", + "homeassistant.components.litterrobot.config_flow.Account.connect", return_value=mock_account, ), patch( "homeassistant.components.litterrobot.async_setup_entry", @@ -62,7 +66,7 @@ async def test_form_invalid_auth(hass): ) with patch( - "pylitterbot.Account.connect", + "homeassistant.components.litterrobot.config_flow.Account.connect", side_effect=LitterRobotLoginException, ): result2 = await hass.config_entries.flow.async_configure( @@ -80,7 +84,7 @@ async def test_form_cannot_connect(hass): ) with patch( - "pylitterbot.Account.connect", + "homeassistant.components.litterrobot.config_flow.Account.connect", side_effect=LitterRobotException, ): result2 = await hass.config_entries.flow.async_configure( @@ -96,9 +100,8 @@ async def test_form_unknown_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "pylitterbot.Account.connect", + "homeassistant.components.litterrobot.config_flow.Account.connect", side_effect=Exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -107,3 +110,91 @@ async def test_form_unknown_error(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} + + +async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: + """Test the reauth flow.""" + entry = MockConfigEntry( + domain=litterrobot.DOMAIN, + data=CONFIG[litterrobot.DOMAIN], + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.litterrobot.config_flow.Account.connect", + return_value=mock_account, + ), patch( + "homeassistant.components.litterrobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: + """Test the reauth flow fails and recovers.""" + entry = MockConfigEntry( + domain=litterrobot.DOMAIN, + data=CONFIG[litterrobot.DOMAIN], + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.litterrobot.config_flow.Account.connect", + side_effect=LitterRobotLoginException, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.litterrobot.config_flow.Account.connect", + return_value=mock_account, + ), patch( + "homeassistant.components.litterrobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1