Add reauth flow to Litterrobot (#77459)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Robert Hillis 2022-08-29 00:40:28 -04:00 committed by GitHub
parent 4333d9a7d1
commit 7c27be230c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 175 additions and 36 deletions

View file

@ -611,8 +611,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/linux_battery/ @fabaff /homeassistant/components/linux_battery/ @fabaff
/homeassistant/components/litejet/ @joncar /homeassistant/components/litejet/ @joncar
/tests/components/litejet/ @joncar /tests/components/litejet/ @joncar
/homeassistant/components/litterrobot/ @natekspencer /homeassistant/components/litterrobot/ @natekspencer @tkdrob
/tests/components/litterrobot/ @natekspencer /tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/local_ip/ @issacg /homeassistant/components/local_ip/ @issacg
/tests/components/local_ip/ @issacg /tests/components/local_ip/ @issacg
/homeassistant/components/lock/ @home-assistant/core /homeassistant/components/lock/ @home-assistant/core

View file

@ -1,12 +1,9 @@
"""The Litter-Robot integration.""" """The Litter-Robot integration."""
from __future__ import annotations from __future__ import annotations
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN from .const import DOMAIN
from .hub import LitterRobotHub 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.""" """Set up Litter-Robot from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data) hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data)
try: await hub.login(load_robots=True)
await hub.login(load_robots=True)
except LitterRobotLoginException:
return False
except LitterRobotException as ex:
raise ConfigEntryNotReady from ex
if any(hub.litter_robots()): if any(hub.litter_robots()):
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View file

@ -5,15 +5,16 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from pylitterbot import Account
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
from .hub import LitterRobotHub
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -27,6 +28,38 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 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( async def async_step_user(
self, user_input: Mapping[str, Any] | None = None self, user_input: Mapping[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
@ -36,22 +69,30 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
hub = LitterRobotHub(self.hass, user_input) if not (error := await self._async_validate_input(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:
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input title=user_input[CONF_USERNAME], data=user_input
) )
errors["base"] = error
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 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 ""

View file

@ -11,6 +11,7 @@ from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginExcepti
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@ -52,11 +53,9 @@ class LitterRobotHub:
) )
return return
except LitterRobotLoginException as ex: except LitterRobotLoginException as ex:
_LOGGER.error("Invalid credentials") raise ConfigEntryAuthFailed("Invalid credentials") from ex
raise ex
except LitterRobotException as ex: except LitterRobotException as ex:
_LOGGER.error("Unable to connect to Litter-Robot API") raise ConfigEntryNotReady("Unable to connect to Litter-Robot API") from ex
raise ex
def litter_robots(self) -> Generator[LitterRobot, Any, Any]: def litter_robots(self) -> Generator[LitterRobot, Any, Any]:
"""Get Litter-Robots from the account.""" """Get Litter-Robots from the account."""

View file

@ -4,8 +4,8 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/litterrobot", "documentation": "https://www.home-assistant.io/integrations/litterrobot",
"requirements": ["pylitterbot==2022.8.0"], "requirements": ["pylitterbot==2022.8.0"],
"codeowners": ["@natekspencer", "@tkdrob"],
"dhcp": [{ "hostname": "litter-robot4" }], "dhcp": [{ "hostname": "litter-robot4" }],
"codeowners": ["@natekspencer"],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pylitterbot"] "loggers": ["pylitterbot"]
} }

View file

@ -6,6 +6,13 @@
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "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": { "error": {
@ -14,7 +21,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "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%]"
} }
} }
} }

View file

@ -1,7 +1,8 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Account is already configured" "already_configured": "Account is already configured",
"reauth_successful": "Re-authentication was successful"
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
@ -14,6 +15,13 @@
"password": "Password", "password": "Password",
"username": "Username" "username": "Username"
} }
},
"reauth_confirm": {
"data": {
"password": "Password"
},
"description": "Please update your password for {username}",
"title": "Reauthenticate Integration"
} }
} }
} }

View file

@ -1,10 +1,14 @@
"""Test the Litter-Robot config flow.""" """Test the Litter-Robot config flow."""
from unittest.mock import patch from unittest.mock import patch
from pylitterbot import Account
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import litterrobot 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 from .common import CONF_USERNAME, CONFIG, DOMAIN
@ -21,7 +25,7 @@ async def test_form(hass, mock_account):
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"homeassistant.components.litterrobot.hub.Account", "homeassistant.components.litterrobot.config_flow.Account.connect",
return_value=mock_account, return_value=mock_account,
), patch( ), patch(
"homeassistant.components.litterrobot.async_setup_entry", "homeassistant.components.litterrobot.async_setup_entry",
@ -62,7 +66,7 @@ async def test_form_invalid_auth(hass):
) )
with patch( with patch(
"pylitterbot.Account.connect", "homeassistant.components.litterrobot.config_flow.Account.connect",
side_effect=LitterRobotLoginException, side_effect=LitterRobotLoginException,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -80,7 +84,7 @@ async def test_form_cannot_connect(hass):
) )
with patch( with patch(
"pylitterbot.Account.connect", "homeassistant.components.litterrobot.config_flow.Account.connect",
side_effect=LitterRobotException, side_effect=LitterRobotException,
): ):
result2 = await hass.config_entries.flow.async_configure( 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( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
with patch( with patch(
"pylitterbot.Account.connect", "homeassistant.components.litterrobot.config_flow.Account.connect",
side_effect=Exception, side_effect=Exception,
): ):
result2 = await hass.config_entries.flow.async_configure( 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["type"] == "form"
assert result2["errors"] == {"base": "unknown"} 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