diff --git a/custom_components/tahoma/__init__.py b/custom_components/tahoma/__init__.py index c3377b2b2..69e7ebbf4 100644 --- a/custom_components/tahoma/__init__.py +++ b/custom_components/tahoma/__init__.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -117,18 +117,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): client.get_places(), ] devices, scenarios, gateways, places = await asyncio.gather(*tasks) - except BadCredentialsException: - _LOGGER.error("Invalid authentication.") - return False + except BadCredentialsException as exception: + raise ConfigEntryAuthFailed from exception except TooManyRequestsException as exception: - _LOGGER.error("Too many requests, try again later.") - raise ConfigEntryNotReady from exception + raise ConfigEntryNotReady("Too many requests, try again later") from exception except (TimeoutError, ClientError, ServerDisconnectedError) as exception: - _LOGGER.error("Failed to connect.") - raise ConfigEntryNotReady from exception + raise ConfigEntryNotReady("Failed to connect") from exception except MaintenanceException as exception: - _LOGGER.error("Server is down for maintenance.") - raise ConfigEntryNotReady from exception + raise ConfigEntryNotReady("Server is down for maintenance") from exception except Exception as exception: # pylint: disable=broad-except _LOGGER.exception(exception) return False diff --git a/custom_components/tahoma/config_flow.py b/custom_components/tahoma/config_flow.py index 6182cf58c..ce2a2b1b1 100644 --- a/custom_components/tahoma/config_flow.py +++ b/custom_components/tahoma/config_flow.py @@ -5,6 +5,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv from pyhoma.client import TahomaClient from pyhoma.exceptions import ( @@ -26,14 +27,6 @@ _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_HUB, default=DEFAULT_HUB): vol.In(SUPPORTED_ENDPOINTS.keys()), - } -) - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Somfy TaHoma.""" @@ -47,6 +40,12 @@ def async_get_options_flow(config_entry): """Handle the flow.""" return OptionsFlowHandler(config_entry) + def __init__(self): + """Start the Overkiz config flow.""" + self._reauth_entry = None + self._default_username = None + self._default_hub = DEFAULT_HUB + async def async_validate_input(self, user_input): """Validate user credentials.""" username = user_input.get(CONF_USERNAME) @@ -57,18 +56,37 @@ async def async_validate_input(self, user_input): async with TahomaClient(username, password, api_url=endpoint) as client: await client.login() - return self.async_create_entry( - title=username, - data=user_input, + + # Set first gateway as unique id + gateways = await client.get_gateways() + if gateways: + gateway_id = gateways[0].id + await self.async_set_unique_id(gateway_id) + + # Create new config entry + if ( + self._reauth_entry is None + or self._reauth_entry.unique_id != self.unique_id + ): + self._abort_if_unique_id_configured() + return self.async_create_entry(title=username, data=user_input) + + # Modify existing entry in reauth scenario + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + + return self.async_abort(reason="reauth_successful") + async def async_step_user(self, user_input=None): """Handle the initial step via config flow.""" errors = {} if user_input: - await self.async_set_unique_id(user_input.get(CONF_USERNAME)) - self._abort_if_unique_id_configured() + self._default_username = user_input[CONF_USERNAME] + self._default_hub = user_input[CONF_HUB] try: return await self.async_validate_input(user_input) @@ -80,14 +98,36 @@ async def async_step_user(self, user_input=None): errors["base"] = "cannot_connect" except MaintenanceException: errors["base"] = "server_in_maintenance" + except AbortFlow: + raise except Exception as exception: # pylint: disable=broad-except errors["base"] = "unknown" _LOGGER.exception(exception) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self._default_username): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_HUB, default=self._default_hub): vol.In( + SUPPORTED_ENDPOINTS.keys() + ), + } + ), + errors=errors, ) + async def async_step_reauth(self, user_input=None): + """Perform reauth if the user credentials have changed.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._default_username = user_input[CONF_USERNAME] + self._default_hub = user_input[CONF_HUB] + + return await self.async_step_user() + async def async_step_import(self, import_config: dict): """Handle the initial step via YAML configuration.""" if not import_config: diff --git a/custom_components/tahoma/coordinator.py b/custom_components/tahoma/coordinator.py index 57db96d1e..fe34fb776 100644 --- a/custom_components/tahoma/coordinator.py +++ b/custom_components/tahoma/coordinator.py @@ -6,6 +6,7 @@ from aiohttp import ServerDisconnectedError from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from pyhoma.client import TahomaClient @@ -66,7 +67,7 @@ async def _async_update_data(self) -> Dict[str, Device]: try: events = await self.client.fetch_events() except BadCredentialsException as exception: - raise UpdateFailed("Invalid authentication.") from exception + raise ConfigEntryAuthFailed() from exception except TooManyRequestsException as exception: raise UpdateFailed("Too many requests, try again later.") from exception except MaintenanceException as exception: diff --git a/custom_components/tahoma/strings.json b/custom_components/tahoma/strings.json index 0a661ef64..4e171bbf5 100644 --- a/custom_components/tahoma/strings.json +++ b/custom_components/tahoma/strings.json @@ -18,7 +18,7 @@ "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_account%]" } }, "options": { diff --git a/custom_components/tahoma/translations/en.json b/custom_components/tahoma/translations/en.json index 8dba0cb7e..987524a0a 100644 --- a/custom_components/tahoma/translations/en.json +++ b/custom_components/tahoma/translations/en.json @@ -11,14 +11,14 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "server_in_maintenance": "Server is down for maintenance.", "too_many_requests": "Too many requests, try again later.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "server_in_maintenance": "Server is down for maintenance", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Unexpected error" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Account is already configured" } }, "options": { diff --git a/custom_components/tahoma/translations/fr.json b/custom_components/tahoma/translations/fr.json index a25995c7b..8c8163ff6 100644 --- a/custom_components/tahoma/translations/fr.json +++ b/custom_components/tahoma/translations/fr.json @@ -12,10 +12,10 @@ }, "error": { "cannot_connect": "Connexion impossible", - "too_many_requests": "Trop de requêtes, veuillez réessayer plus tard.", + "too_many_requests": "Trop de requêtes, veuillez réessayer plus tard", "invalid_auth": "Mot de passe ou nom d'utilisateur incorrect", "server_in_maintenance": "Le serveur est en cours de maintenance", - "unknown": "Une erreur inconnue est survenue." + "unknown": "Une erreur inconnue est survenue" }, "abort": { "already_configured": "Votre compte a déjà été ajouté pour cette intégration." diff --git a/custom_components/tahoma/translations/nl.json b/custom_components/tahoma/translations/nl.json index 36739b42c..097116c07 100644 --- a/custom_components/tahoma/translations/nl.json +++ b/custom_components/tahoma/translations/nl.json @@ -11,14 +11,14 @@ } }, "error": { - "too_many_requests": "Te veel verzoeken, probeer het later opnieuw.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "too_many_requests": "Te veel verzoeken, probeer het later opnieuw", + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", "server_in_maintenance": "De server is offline voor onderhoud", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Onverwachte fout" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Account is al geconfigureerd" } }, "options": { diff --git a/requirements_dev.txt b/requirements_dev.txt index fbfec9a6e..f5e6d8b03 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,2 +1,2 @@ -r requirements.txt -homeassistant==2021.4.0b4 \ No newline at end of file +homeassistant==2021.7.0b0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index b89c87380..dc1e96479 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,2 +1,2 @@ -r requirements_dev.txt -pytest-homeassistant-custom-component==0.3.0 \ No newline at end of file +pytest-homeassistant-custom-component==0.4.2 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2be01f545..cc00dbcd0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,4 +30,9 @@ def skip_notifications_fixture(): with patch("homeassistant.components.persistent_notification.async_create"), patch( "homeassistant.components.persistent_notification.async_dismiss" ): - yield \ No newline at end of file + yield + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 968ebc6ea..54b2906d4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,32 +1,46 @@ -"""Test the Somfy TaHoma config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from aiohttp import ClientError -from homeassistant import config_entries, data_entry_flow -from pyhoma.exceptions import BadCredentialsException, TooManyRequestsException +from homeassistant import config_entries, data_entry_flow, setup +from pyhoma.exceptions import ( + BadCredentialsException, + MaintenanceException, + TooManyRequestsException, +) import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.tahoma.const import DOMAIN +from custom_components.tahoma import config_flow TEST_EMAIL = "test@testdomain.com" +TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" -DEFAULT_HUB = "Somfy TaHoma" +TEST_PASSWORD2 = "test-password2" +TEST_HUB = "Somfy TaHoma" +TEST_HUB2 = "Hi Kumo" +TEST_GATEWAY_ID = "1234-5678-9123" +TEST_GATEWAY_ID2 = "4321-5678-9123" + +MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] -async def test_form(hass, enable_custom_integrations): +async def test_form(hass): """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == "form" assert result["errors"] == {} - with patch("pyhoma.client.TahomaClient.login", return_value=True): + with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( + "pyhoma.client.TahomaClient.get_gateways", return_value=None + ), patch( + "custom_components.tahoma.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) assert result2["type"] == "create_entry" @@ -34,11 +48,13 @@ async def test_form(hass, enable_custom_integrations): assert result2["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, } await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + @pytest.mark.parametrize( "side_effect, error", @@ -47,75 +63,68 @@ async def test_form(hass, enable_custom_integrations): (TooManyRequestsException, "too_many_requests"), (TimeoutError, "cannot_connect"), (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), (Exception, "unknown"), ], ) -async def test_form_invalid(hass, side_effect, error, enable_custom_integrations): +async def test_form_invalid(hass, side_effect, error): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch("pyhoma.client.TahomaClient.login", side_effect=side_effect): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == "form" assert result2["errors"] == {"base": error} -async def test_abort_on_duplicate_entry(hass, enable_custom_integrations): +async def test_abort_on_duplicate_entry(hass): """Test config flow aborts Config Flow on duplicate entries.""" MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_EMAIL, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + domain=config_flow.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( - "custom_components.tahoma.async_setup", return_value=True - ) as mock_setup, patch( - "custom_components.tahoma.async_setup_entry", return_value=True - ) as mock_setup_entry: + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ), patch("custom_components.tahoma.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) assert result2["type"] == "abort" assert result2["reason"] == "already_configured" -async def test_allow_multiple_unique_entries(hass, enable_custom_integrations): +async def test_allow_multiple_unique_entries(hass): """Test config flow allows Config Flow unique entries.""" MockConfigEntry( - domain=DOMAIN, + domain=config_flow.DOMAIN, unique_id="test2@testdomain.com", - data={ - "username": "test2@testdomain.com", - "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, - }, + data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( - "custom_components.tahoma.async_setup", return_value=True - ) as mock_setup, patch( - "custom_components.tahoma.async_setup_entry", return_value=True - ) as mock_setup_entry: + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ), patch("custom_components.tahoma.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) assert result2["type"] == "create_entry" @@ -123,24 +132,69 @@ async def test_allow_multiple_unique_entries(hass, enable_custom_integrations): assert result2["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, } -async def test_import(hass, enable_custom_integrations): +async def test_reauth_success(hass): + """Test reauthentication flow.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch("pyhoma.client.TahomaClient.login", side_effect=BadCredentialsException): + mock_entry = MockConfigEntry( + domain=config_flow.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + "hub": TEST_HUB2, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_import(hass): """Test config flow using configuration.yaml.""" with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ), patch( "custom_components.tahoma.async_setup", return_value=True ) as mock_setup, patch( "custom_components.tahoma.async_setup_entry", return_value=True ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - DOMAIN, + config_flow.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, }, ) assert result["type"] == "create_entry" @@ -148,7 +202,7 @@ async def test_import(hass, enable_custom_integrations): assert result["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, } await hass.async_block_till_done() @@ -171,12 +225,12 @@ async def test_import_failing(hass, side_effect, error, enable_custom_integratio """Test failing config flow using configuration.yaml.""" with patch("pyhoma.client.TahomaClient.login", side_effect=side_effect): await hass.config_entries.flow.async_init( - DOMAIN, + config_flow.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, }, ) @@ -187,9 +241,9 @@ async def test_options_flow(hass, enable_custom_integrations): """Test options flow.""" entry = MockConfigEntry( - domain=DOMAIN, + domain=config_flow.DOMAIN, unique_id=TEST_EMAIL, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( @@ -199,8 +253,8 @@ async def test_options_flow(hass, enable_custom_integrations): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(hass.config_entries.async_entries(config_flow.DOMAIN)) == 1 + assert entry.state == config_entries.ConfigEntryState.LOADED result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None