diff --git a/.coveragerc b/.coveragerc index 01c0a657f31..a8459a2cd74 100644 --- a/.coveragerc +++ b/.coveragerc @@ -567,6 +567,8 @@ omit = homeassistant/components/n26/* homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/light.py + homeassistant/components/neato/__init__.py + homeassistant/components/neato/api.py homeassistant/components/neato/camera.py homeassistant/components/neato/sensor.py homeassistant/components/neato/switch.py diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 9775dc592fd..1d9d3de4f89 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -3,26 +3,30 @@ import asyncio from datetime import timedelta import logging -from pybotvac import Account, Neato, Vorwerk -from pybotvac.exceptions import NeatoException, NeatoLoginException, NeatoRobotException +from pybotvac import Account, Neato +from pybotvac.exceptions import NeatoException import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_SOURCE, + CONF_TOKEN, +) from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util import Throttle -from .config_flow import NeatoConfigFlow +from . import api, config_flow from .const import ( - CONF_VENDOR, NEATO_CONFIG, NEATO_DOMAIN, NEATO_LOGIN, NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS, - VALID_VENDORS, ) _LOGGER = logging.getLogger(__name__) @@ -32,82 +36,74 @@ CONFIG_SCHEMA = vol.Schema( { NEATO_DOMAIN: vol.Schema( { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_VENDOR, default="neato"): vol.In(VALID_VENDORS), + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, } ) }, extra=vol.ALLOW_EXTRA, ) +PLATFORMS = ["camera", "vacuum", "switch", "sensor"] -async def async_setup(hass, config): + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the Neato component.""" + hass.data[NEATO_DOMAIN] = {} if NEATO_DOMAIN not in config: - # There is an entry and nothing in configuration.yaml return True - entries = hass.config_entries.async_entries(NEATO_DOMAIN) hass.data[NEATO_CONFIG] = config[NEATO_DOMAIN] - - if entries: - # There is an entry and something in the configuration.yaml - entry = entries[0] - conf = config[NEATO_DOMAIN] - if ( - entry.data[CONF_USERNAME] == conf[CONF_USERNAME] - and entry.data[CONF_PASSWORD] == conf[CONF_PASSWORD] - and entry.data[CONF_VENDOR] == conf[CONF_VENDOR] - ): - # The entry is not outdated - return True - - # The entry is outdated - error = await hass.async_add_executor_job( - NeatoConfigFlow.try_login, - conf[CONF_USERNAME], - conf[CONF_PASSWORD], - conf[CONF_VENDOR], - ) - if error is not None: - _LOGGER.error(error) - return False - - # Update the entry - hass.config_entries.async_update_entry(entry, data=config[NEATO_DOMAIN]) - else: - # Create the new entry - hass.async_create_task( - hass.config_entries.flow.async_init( - NEATO_DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[NEATO_DOMAIN], - ) - ) + vendor = Neato() + config_flow.OAuth2FlowHandler.async_register_implementation( + hass, + api.NeatoImplementation( + hass, + NEATO_DOMAIN, + config[NEATO_DOMAIN][CONF_CLIENT_ID], + config[NEATO_DOMAIN][CONF_CLIENT_SECRET], + vendor.auth_endpoint, + vendor.token_endpoint, + ), + ) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up config entry.""" - hub = NeatoHub(hass, entry.data, Account) - - await hass.async_add_executor_job(hub.login) - if not hub.logged_in: - _LOGGER.debug("Failed to login to Neato API") + if CONF_TOKEN not in entry.data: + # Init reauth flow + hass.async_create_task( + hass.config_entries.flow.async_init( + NEATO_DOMAIN, + context={CONF_SOURCE: SOURCE_REAUTH}, + ) + ) return False + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + neato_session = api.ConfigEntryAuth(hass, entry, session) + hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session + hub = NeatoHub(hass, Account(neato_session)) + try: await hass.async_add_executor_job(hub.update_robots) - except NeatoRobotException as ex: + except NeatoException as ex: _LOGGER.debug("Failed to connect to Neato API") raise ConfigEntryNotReady from ex hass.data[NEATO_LOGIN] = hub - for component in ("camera", "vacuum", "switch", "sensor"): + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) @@ -115,53 +111,27 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: """Unload config entry.""" - hass.data.pop(NEATO_LOGIN) - await asyncio.gather( - hass.config_entries.async_forward_entry_unload(entry, "camera"), - hass.config_entries.async_forward_entry_unload(entry, "vacuum"), - hass.config_entries.async_forward_entry_unload(entry, "switch"), - hass.config_entries.async_forward_entry_unload(entry, "sensor"), + unload_functions = ( + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS ) - return True + + unload_ok = all(await asyncio.gather(*unload_functions)) + if unload_ok: + hass.data[NEATO_DOMAIN].pop(entry.entry_id) + + return unload_ok class NeatoHub: """A My Neato hub wrapper class.""" - def __init__(self, hass, domain_config, neato): + def __init__(self, hass: HomeAssistantType, neato: Account): """Initialize the Neato hub.""" - self.config = domain_config - self._neato = neato - self._hass = hass - - if self.config[CONF_VENDOR] == "vorwerk": - self._vendor = Vorwerk() - else: # Neato - self._vendor = Neato() - - self.my_neato = None - self.logged_in = False - - def login(self): - """Login to My Neato.""" - _LOGGER.debug("Trying to connect to Neato API") - try: - self.my_neato = self._neato( - self.config[CONF_USERNAME], self.config[CONF_PASSWORD], self._vendor - ) - except NeatoException as ex: - if isinstance(ex, NeatoLoginException): - _LOGGER.error("Invalid credentials") - else: - _LOGGER.error("Unable to connect to Neato API") - raise ConfigEntryNotReady from ex - self.logged_in = False - return - - self.logged_in = True - _LOGGER.debug("Successfully connected to Neato API") + self._hass: HomeAssistantType = hass + self.my_neato: Account = neato @Throttle(timedelta(minutes=1)) def update_robots(self): diff --git a/homeassistant/components/neato/api.py b/homeassistant/components/neato/api.py new file mode 100644 index 00000000000..931d7cdb712 --- /dev/null +++ b/homeassistant/components/neato/api.py @@ -0,0 +1,55 @@ +"""API for Neato Botvac bound to Home Assistant OAuth.""" +from asyncio import run_coroutine_threadsafe +import logging + +import pybotvac + +from homeassistant import config_entries, core +from homeassistant.helpers import config_entry_oauth2_flow + +_LOGGER = logging.getLogger(__name__) + + +class ConfigEntryAuth(pybotvac.OAuthSession): + """Provide Neato Botvac authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + ): + """Initialize Neato Botvac Auth.""" + self.hass = hass + self.session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + super().__init__(self.session.token, vendor=pybotvac.Neato()) + + def refresh_tokens(self) -> str: + """Refresh and return new Neato Botvac tokens using Home Assistant OAuth2 session.""" + run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self.hass.loop + ).result() + + return self.session.token["access_token"] + + +class NeatoImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): + """Neato implementation of LocalOAuth2Implementation. + + We need this class because we have to add client_secret and scope to the authorization request. + """ + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return {"client_secret": self.client_secret} + + async def async_generate_authorize_url(self, flow_id: str) -> str: + """Generate a url for the user to authorize. + + We must make sure that the plus signs are not encoded. + """ + url = await super().async_generate_authorize_url(flow_id) + return f"{url}&scope=public_profile+control_robots+maps" diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 4d7c4129d81..1698a1d944a 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -45,7 +45,7 @@ class NeatoCleaningMap(Camera): self.robot = robot self.neato = neato self._mapdata = mapdata - self._available = self.neato.logged_in if self.neato is not None else False + self._available = neato is not None self._robot_name = f"{self.robot.name} Cleaning Map" self._robot_serial = self.robot.serial self._generated_at = None diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index f74364bc8bc..449de72b158 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -1,112 +1,65 @@ -"""Config flow to configure Neato integration.""" - +"""Config flow for Neato Botvac.""" import logging +from typing import Optional -from pybotvac import Account, Neato, Vorwerk -from pybotvac.exceptions import NeatoLoginException, NeatoRobotException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow # pylint: disable=unused-import -from .const import CONF_VENDOR, NEATO_DOMAIN, VALID_VENDORS - -DOCS_URL = "https://www.home-assistant.io/integrations/neato" -DEFAULT_VENDOR = "neato" +from .const import NEATO_DOMAIN _LOGGER = logging.getLogger(__name__) -class NeatoConfigFlow(config_entries.ConfigFlow, domain=NEATO_DOMAIN): - """Neato integration config flow.""" +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=NEATO_DOMAIN +): + """Config flow to handle Neato Botvac OAuth2 authentication.""" - VERSION = 1 + DOMAIN = NEATO_DOMAIN CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def __init__(self): - """Initialize flow.""" - self._username = vol.UNDEFINED - self._password = vol.UNDEFINED - self._vendor = vol.UNDEFINED + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - errors = {} - - if self._async_current_entries(): + async def async_step_user(self, user_input: Optional[dict] = None) -> dict: + """Create an entry for the flow.""" + current_entries = self._async_current_entries() + if current_entries and CONF_TOKEN in current_entries[0].data: + # Already configured return self.async_abort(reason="already_configured") - if user_input is not None: - self._username = user_input["username"] - self._password = user_input["password"] - self._vendor = user_input["vendor"] + return await super().async_step_user(user_input=user_input) - error = await self.hass.async_add_executor_job( - self.try_login, self._username, self._password, self._vendor + async def async_step_reauth(self, data) -> dict: + """Perform reauth upon migration of old entries.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Optional[dict] = None + ) -> dict: + """Confirm reauth upon migration of old entries.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}) ) - if error: - errors["base"] = error - else: - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=user_input, - description_placeholders={"docs_url": DOCS_URL}, - ) + return await self.async_step_user() - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_VENDOR, default="neato"): vol.In(VALID_VENDORS), - } - ), - description_placeholders={"docs_url": DOCS_URL}, - errors=errors, - ) - - async def async_step_import(self, user_input): - """Import a config flow from configuration.""" - - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - vendor = user_input[CONF_VENDOR] - - error = await self.hass.async_add_executor_job( - self.try_login, username, password, vendor - ) - if error is not None: - _LOGGER.error(error) - return self.async_abort(reason=error) - - return self.async_create_entry( - title=f"{username} (from configuration)", - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_VENDOR: vendor, - }, - ) - - @staticmethod - def try_login(username, password, vendor): - """Try logging in to device and return any errors.""" - this_vendor = None - if vendor == "vorwerk": - this_vendor = Vorwerk() - else: # Neato - this_vendor = Neato() - - try: - Account(username, password, this_vendor) - except NeatoLoginException: - return "invalid_auth" - except NeatoRobotException: - return "unknown" - - return None + async def async_oauth_create_entry(self, data: dict) -> dict: + """Create an entry for the flow. Update an entry if one already exist.""" + current_entries = self._async_current_entries() + if current_entries and CONF_TOKEN not in current_entries[0].data: + # Update entry + self.hass.config_entries.async_update_entry( + current_entries[0], title=self.flow_impl.name, data=data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(current_entries[0].entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=self.flow_impl.name, data=data) diff --git a/homeassistant/components/neato/const.py b/homeassistant/components/neato/const.py index 53948e2b19d..248e455b6da 100644 --- a/homeassistant/components/neato/const.py +++ b/homeassistant/components/neato/const.py @@ -11,8 +11,6 @@ NEATO_ROBOTS = "neato_robots" SCAN_INTERVAL_MINUTES = 1 -VALID_VENDORS = ["neato", "vorwerk"] - MODE = {1: "Eco", 2: "Turbo"} ACTION = { diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index d36e3fa503f..d3ea8a8525c 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -3,6 +3,14 @@ "name": "Neato Botvac", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/neato", - "requirements": ["pybotvac==0.0.17"], - "codeowners": ["@dshokouhi", "@Santobert"] -} + "requirements": [ + "pybotvac==0.0.19" + ], + "codeowners": [ + "@dshokouhi", + "@Santobert" + ], + "dependencies": [ + "http" + ] +} \ No newline at end of file diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index efcbfb8d54c..b083ec1d7df 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -37,7 +37,7 @@ class NeatoSensor(Entity): def __init__(self, neato, robot): """Initialize Neato sensor.""" self.robot = robot - self._available = neato.logged_in if neato is not None else False + self._available = neato is not None self._robot_name = f"{self.robot.name} {BATTERY}" self._robot_serial = self.robot.serial self._state = None diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 5d71d4889ac..21af0f91d17 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -1,26 +1,23 @@ { "config": { "step": { - "user": { - "title": "Neato Account Info", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "vendor": "Vendor" - }, - "description": "See [Neato documentation]({docs_url})." + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::description::confirm_setup%]" } }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "abort": { + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { - "default": "See [Neato documentation]({docs_url})." - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "default": "[%key:common::config_flow::create_entry::authenticated%]" } - } + }, + "title": "Neato Botvac" } \ No newline at end of file diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index a6aa19abe26..204adb108a8 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -40,7 +40,7 @@ class NeatoConnectedSwitch(ToggleEntity): """Initialize the Neato Connected switches.""" self.type = switch_type self.robot = robot - self._available = neato.logged_in if neato is not None else False + self._available = neato is not None self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}" self._state = None self._schedule_state = None diff --git a/homeassistant/components/neato/translations/de.json b/homeassistant/components/neato/translations/de.json index c41d4e6d93a..c8dcc93500b 100644 --- a/homeassistant/components/neato/translations/de.json +++ b/homeassistant/components/neato/translations/de.json @@ -1,21 +1,23 @@ { "config": { "abort": { - "already_configured": "Bereits konfiguriert" + "already_configured": "Konto ist bereits konfiguriert.", + "authorize_url_timeout": "Timeout beim Erzeugen der Autorisierungs-URL.", + "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte beachten Sie die Dokumentation.", + "no_url_available": "Keine URL verfügbar. Informationen zu diesem Fehler finden Sie [im Hilfebereich]({docs_url})", + "reauth_successful": "Re-Authentifizierung war erfolgreich" }, "create_entry": { - "default": "Siehe [Neato-Dokumentation]({docs_url})." + "default": "Erfolgreich authentifiziert" }, "step": { - "user": { - "data": { - "password": "Passwort", - "username": "Benutzername", - "vendor": "Hersteller" - }, - "description": "Siehe [Neato-Dokumentation]({docs_url}).", - "title": "Neato-Kontoinformationen" + "pick_implementation": { + "title": "Authentifizierungsmethode auswählen" + }, + "reauth_confirm": { + "title": "Einrichtung bestätigen?" } } - } + }, + "title": "Neato Botvac" } \ No newline at end of file diff --git a/homeassistant/components/neato/translations/en.json b/homeassistant/components/neato/translations/en.json index 61b6ad44dfd..333c8a980f0 100644 --- a/homeassistant/components/neato/translations/en.json +++ b/homeassistant/components/neato/translations/en.json @@ -1,26 +1,23 @@ { "config": { "abort": { - "already_configured": "Device is already configured", - "invalid_auth": "Invalid authentication" + "already_configured": "Account is already configured.", + "authorize_url_timeout": "Timeout generating authorize URL.", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "reauth_successful": "Re-authentication was successful" }, "create_entry": { - "default": "See [Neato documentation]({docs_url})." - }, - "error": { - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" + "default": "Successfully authenticated" }, "step": { - "user": { - "data": { - "password": "Password", - "username": "Username", - "vendor": "Vendor" - }, - "description": "See [Neato documentation]({docs_url}).", - "title": "Neato Account Info" + "pick_implementation": { + "title": "Pick Authentication Method" + }, + "reauth_confirm": { + "title": "Confirm setup?" } } - } + }, + "title": "Neato Botvac" } \ No newline at end of file diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 677bed1565b..ce4156244b7 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -24,7 +24,7 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.const import ATTR_MODE from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( @@ -93,7 +93,6 @@ async def async_setup_entry(hass, entry, async_add_entities): platform.async_register_entity_service( "custom_cleaning", { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_MODE, default=2): cv.positive_int, vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int, vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int, @@ -109,7 +108,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): def __init__(self, neato, robot, mapdata, persistent_maps): """Initialize the Neato Connected Vacuum.""" self.robot = robot - self._available = neato.logged_in if neato is not None else False + self._available = neato is not None self._mapdata = mapdata self._name = f"{self.robot.name}" self._robot_has_map = self.robot.has_persistent_maps diff --git a/requirements_all.txt b/requirements_all.txt index 80f685845ae..20e3c4c115c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1292,7 +1292,7 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato -pybotvac==0.0.17 +pybotvac==0.0.19 # homeassistant.components.nissan_leaf pycarwings2==2.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fef2cba80d0..27fe91d0eb4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -655,7 +655,7 @@ pyatv==0.7.5 pyblackbird==0.5 # homeassistant.components.neato -pybotvac==0.0.17 +pybotvac==0.0.19 # homeassistant.components.cloudflare pycfdns==1.2.1 diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py index 31c2cddd09d..6954eb1b7af 100644 --- a/tests/components/neato/test_config_flow.py +++ b/tests/components/neato/test_config_flow.py @@ -1,160 +1,156 @@ -"""Tests for the Neato config flow.""" -from pybotvac.exceptions import NeatoLoginException, NeatoRobotException -import pytest +"""Test the Neato Botvac config flow.""" +from pybotvac.neato import Neato -from homeassistant import data_entry_flow -from homeassistant.components.neato import config_flow -from homeassistant.components.neato.const import CONF_VENDOR, NEATO_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.neato.const import NEATO_DOMAIN +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.typing import HomeAssistantType from tests.async_mock import patch from tests.common import MockConfigEntry -USERNAME = "myUsername" -PASSWORD = "myPassword" -VENDOR_NEATO = "neato" -VENDOR_VORWERK = "vorwerk" -VENDOR_INVALID = "invalid" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +VENDOR = Neato() +OAUTH2_AUTHORIZE = VENDOR.auth_endpoint +OAUTH2_TOKEN = VENDOR.token_endpoint -@pytest.fixture(name="account") -def mock_controller_login(): - """Mock a successful login.""" - with patch("homeassistant.components.neato.config_flow.Account", return_value=True): - yield - - -def init_config_flow(hass): - """Init a configuration flow.""" - flow = config_flow.NeatoConfigFlow() - flow.hass = hass - return flow - - -async def test_user(hass, account): - """Test user config.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user() - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await flow.async_step_user( - {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_VENDOR] == VENDOR_NEATO - - result = await flow.async_step_user( - {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_VORWERK} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_VENDOR] == VENDOR_VORWERK - - -async def test_import(hass, account): - """Test import step.""" - flow = init_config_flow(hass) - - result = await flow.async_step_import( - {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == f"{USERNAME} (from configuration)" - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_VENDOR] == VENDOR_NEATO - - -async def test_abort_if_already_setup(hass, account): - """Test we abort if Neato is already setup.""" - flow = init_config_flow(hass) - MockConfigEntry( - domain=NEATO_DOMAIN, - data={ - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_NEATO, +async def test_full_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + "neato", + { + "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, + "http": {"base_url": "https://example.com"}, }, + ) + + result = await hass.config_entries.flow.async_init( + "neato", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&client_secret={CLIENT_SECRET}" + "&scope=public_profile+control_robots+maps" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.neato.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(NEATO_DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +async def test_abort_if_already_setup(hass: HomeAssistantType): + """Test we abort if Neato is already setup.""" + entry = MockConfigEntry( + domain=NEATO_DOMAIN, + data={"auth_implementation": "neato", "token": {"some": "data"}}, + ) + entry.add_to_hass(hass) + + # Should fail + result = await hass.config_entries.flow.async_init( + "neato", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistantType, aiohttp_client, aioclient_mock, current_request_with_host +): + """Test initialization of the reauth flow.""" + assert await setup.async_setup_component( + hass, + "neato", + { + "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, + "http": {"base_url": "https://example.com"}, + }, + ) + + MockConfigEntry( + entry_id="my_entry", + domain=NEATO_DOMAIN, + data={"username": "abcdef", "password": "123456", "vendor": "neato"}, ).add_to_hass(hass) - # Should fail, same USERNAME (import) - result = await flow.async_step_import( - {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO} + # Should show form + result = await hass.config_entries.flow.async_init( + "neato", context={"source": config_entries.SOURCE_REAUTH} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" - # Should fail, same USERNAME (flow) - result = await flow.async_step_user( - {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO} + # Confirm reauth flow + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 -async def test_abort_on_invalid_credentials(hass): - """Test when we have invalid credentials.""" - flow = init_config_flow(hass) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + # Update entry with patch( - "homeassistant.components.neato.config_flow.Account", - side_effect=NeatoLoginException(), - ): - result = await flow.async_step_user( - { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_NEATO, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} + "homeassistant.components.neato.async_setup_entry", return_value=True + ) as mock_setup: + result3 = await hass.config_entries.flow.async_configure(result2["flow_id"]) + await hass.async_block_till_done() - result = await flow.async_step_import( - { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_NEATO, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "invalid_auth" + new_entry = hass.config_entries.async_get_entry("my_entry") - -async def test_abort_on_unexpected_error(hass): - """Test when we have an unexpected error.""" - flow = init_config_flow(hass) - - with patch( - "homeassistant.components.neato.config_flow.Account", - side_effect=NeatoRobotException(), - ): - result = await flow.async_step_user( - { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_NEATO, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - - result = await flow.async_step_import( - { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_NEATO, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "unknown" + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" + assert new_entry.state == "loaded" + assert len(hass.config_entries.async_entries(NEATO_DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/neato/test_init.py b/tests/components/neato/test_init.py deleted file mode 100644 index 182ef98e529..00000000000 --- a/tests/components/neato/test_init.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for the Neato init file.""" -from pybotvac.exceptions import NeatoLoginException -import pytest - -from homeassistant.components.neato.const import CONF_VENDOR, NEATO_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.setup import async_setup_component - -from tests.async_mock import patch -from tests.common import MockConfigEntry - -USERNAME = "myUsername" -PASSWORD = "myPassword" -VENDOR_NEATO = "neato" -VENDOR_VORWERK = "vorwerk" -VENDOR_INVALID = "invalid" - -VALID_CONFIG = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_NEATO, -} - -DIFFERENT_CONFIG = { - CONF_USERNAME: "anotherUsername", - CONF_PASSWORD: "anotherPassword", - CONF_VENDOR: VENDOR_VORWERK, -} - -INVALID_CONFIG = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_VENDOR: VENDOR_INVALID, -} - - -@pytest.fixture(name="config_flow") -def mock_config_flow_login(): - """Mock a successful login.""" - with patch("homeassistant.components.neato.config_flow.Account", return_value=True): - yield - - -@pytest.fixture(name="hub") -def mock_controller_login(): - """Mock a successful login.""" - with patch("homeassistant.components.neato.Account", return_value=True): - yield - - -async def test_no_config_entry(hass): - """There is nothing in configuration.yaml.""" - res = await async_setup_component(hass, NEATO_DOMAIN, {}) - assert res is True - - -async def test_create_valid_config_entry(hass, config_flow, hub): - """There is something in configuration.yaml.""" - assert hass.config_entries.async_entries(NEATO_DOMAIN) == [] - assert await async_setup_component(hass, NEATO_DOMAIN, {NEATO_DOMAIN: VALID_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(NEATO_DOMAIN) - assert entries - assert entries[0].data[CONF_USERNAME] == USERNAME - assert entries[0].data[CONF_PASSWORD] == PASSWORD - assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO - - -async def test_config_entries_in_sync(hass, hub): - """The config entry and configuration.yaml are in sync.""" - MockConfigEntry(domain=NEATO_DOMAIN, data=VALID_CONFIG).add_to_hass(hass) - - assert hass.config_entries.async_entries(NEATO_DOMAIN) - assert await async_setup_component(hass, NEATO_DOMAIN, {NEATO_DOMAIN: VALID_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(NEATO_DOMAIN) - assert entries - assert entries[0].data[CONF_USERNAME] == USERNAME - assert entries[0].data[CONF_PASSWORD] == PASSWORD - assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO - - -async def test_config_entries_not_in_sync(hass, config_flow, hub): - """The config entry and configuration.yaml are not in sync.""" - MockConfigEntry(domain=NEATO_DOMAIN, data=DIFFERENT_CONFIG).add_to_hass(hass) - - assert hass.config_entries.async_entries(NEATO_DOMAIN) - assert await async_setup_component(hass, NEATO_DOMAIN, {NEATO_DOMAIN: VALID_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(NEATO_DOMAIN) - assert entries - assert entries[0].data[CONF_USERNAME] == USERNAME - assert entries[0].data[CONF_PASSWORD] == PASSWORD - assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO - - -async def test_config_entries_not_in_sync_error(hass): - """The config entry and configuration.yaml are not in sync, the new configuration is wrong.""" - MockConfigEntry(domain=NEATO_DOMAIN, data=VALID_CONFIG).add_to_hass(hass) - - assert hass.config_entries.async_entries(NEATO_DOMAIN) - with patch( - "homeassistant.components.neato.config_flow.Account", - side_effect=NeatoLoginException(), - ): - assert not await async_setup_component( - hass, NEATO_DOMAIN, {NEATO_DOMAIN: DIFFERENT_CONFIG} - ) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(NEATO_DOMAIN) - assert entries - assert entries[0].data[CONF_USERNAME] == USERNAME - assert entries[0].data[CONF_PASSWORD] == PASSWORD - assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO