diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index c8e652fefd6..9bdc918be9c 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -1,7 +1,4 @@ """The flume integration.""" -from functools import partial -import logging - from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException @@ -14,7 +11,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( BASE_TOKEN_FILENAME, @@ -25,12 +22,9 @@ from .const import ( PLATFORMS, ) -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - """Set up flume from a config entry.""" +def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Config entry set up in executor.""" config = entry.data username = config[CONF_USERNAME] @@ -42,32 +36,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): http_session = Session() try: - flume_auth = await hass.async_add_executor_job( - partial( - FlumeAuth, - username, - password, - client_id, - client_secret, - flume_token_file=flume_token_full_path, - http_session=http_session, - ) - ) - flume_devices = await hass.async_add_executor_job( - partial( - FlumeDeviceList, - flume_auth, - http_session=http_session, - ) + flume_auth = FlumeAuth( + username, + password, + client_id, + client_secret, + flume_token_file=flume_token_full_path, + http_session=http_session, ) + flume_devices = FlumeDeviceList(flume_auth, http_session=http_session) except RequestException as ex: raise ConfigEntryNotReady from ex except Exception as ex: # pylint: disable=broad-except - _LOGGER.error("Invalid credentials for flume: %s", ex) - return False + raise ConfigEntryAuthFailed from ex - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { + return flume_auth, flume_devices, http_session + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up flume from a config entry.""" + + flume_auth, flume_devices, http_session = await hass.async_add_executor_job( + _setup_entry, hass, entry + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { FLUME_DEVICES: flume_devices, FLUME_AUTH: flume_auth, FLUME_HTTP_SESSION: http_session, diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index 49ae50d8912..1bab8817dbb 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -1,6 +1,6 @@ """Config flow for flume integration.""" -from functools import partial import logging +import os from pyflume import FlumeAuth, FlumeDeviceList from requests.exceptions import RequestException @@ -33,38 +33,46 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +def _validate_input(hass: core.HomeAssistant, data: dict, clear_token_file: bool): + """Validate in the executor.""" + flume_token_full_path = hass.config.path( + f"{BASE_TOKEN_FILENAME}-{data[CONF_USERNAME]}" + ) + if clear_token_file and os.path.exists(flume_token_full_path): + os.unlink(flume_token_full_path) + + return FlumeDeviceList( + FlumeAuth( + data[CONF_USERNAME], + data[CONF_PASSWORD], + data[CONF_CLIENT_ID], + data[CONF_CLIENT_SECRET], + flume_token_file=flume_token_full_path, + ) + ) + + +async def validate_input( + hass: core.HomeAssistant, data: dict, clear_token_file: bool = False +): """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ - username = data[CONF_USERNAME] - password = data[CONF_PASSWORD] - client_id = data[CONF_CLIENT_ID] - client_secret = data[CONF_CLIENT_SECRET] - flume_token_full_path = hass.config.path(f"{BASE_TOKEN_FILENAME}-{username}") - try: - flume_auth = await hass.async_add_executor_job( - partial( - FlumeAuth, - username, - password, - client_id, - client_secret, - flume_token_file=flume_token_full_path, - ) + flume_devices = await hass.async_add_executor_job( + _validate_input, hass, data, clear_token_file ) - flume_devices = await hass.async_add_executor_job(FlumeDeviceList, flume_auth) except RequestException as err: raise CannotConnect from err except Exception as err: + _LOGGER.exception("Auth exception") raise InvalidAuth from err if not flume_devices or not flume_devices.device_list: raise CannotConnect # Return info that you want to store in the config entry. - return {"title": username} + return {"title": data[CONF_USERNAME]} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -72,6 +80,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self): + """Init flume config flow.""" + self._reauth_unique_id = None + async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} @@ -85,10 +97,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors[CONF_PASSWORD] = "invalid_auth" return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors @@ -98,6 +107,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import.""" return await self.async_step_user(user_input) + async def async_step_reauth(self, user_input=None): + """Handle reauth.""" + self._reauth_unique_id = self.context["unique_id"] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle reauth input.""" + errors = {} + existing_entry = await self.async_set_unique_id(self._reauth_unique_id) + if user_input is not None: + new_data = {**existing_entry.data, CONF_PASSWORD: user_input[CONF_PASSWORD]} + try: + await validate_input(self.hass, new_data, clear_token_file=True) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors[CONF_PASSWORD] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + existing_entry, data=new_data + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: existing_entry.data[CONF_USERNAME] + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 67b4a95d069..5c95cfca22e 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -15,9 +15,17 @@ "client_id": "Client ID", "password": "[%key:common::config_flow::data::password%]" } - } + }, + "reauth_confirm": { + "description": "The password for {username} is no longer valid.", + "title": "Reauthenticate your Flume Account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } } diff --git a/homeassistant/components/flume/translations/en.json b/homeassistant/components/flume/translations/en.json index ac7d4335903..e70566f4315 100644 --- a/homeassistant/components/flume/translations/en.json +++ b/homeassistant/components/flume/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", @@ -9,6 +10,13 @@ "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "The password for {username} is no longer valid.", + "title": "Reauthenticate your Flume Account" + }, "user": { "data": { "client_id": "Client ID", diff --git a/tests/components/flume/test_config_flow.py b/tests/components/flume/test_config_flow.py index 3a9e3376f05..5c439933b0b 100644 --- a/tests/components/flume/test_config_flow.py +++ b/tests/components/flume/test_config_flow.py @@ -12,6 +12,8 @@ from homeassistant.const import ( CONF_USERNAME, ) +from tests.common import MockConfigEntry + def _get_mocked_flume_device_list(): flume_device_list_mock = MagicMock() @@ -124,7 +126,7 @@ async def test_form_invalid_auth(hass): ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {"password": "invalid_auth"} async def test_form_cannot_connect(hass): @@ -151,3 +153,82 @@ async def test_form_cannot_connect(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth(hass): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test@test.org", + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + }, + unique_id="test@test.org", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "unique_id": "test@test.org"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.flume.config_flow.FlumeAuth", + return_value=True, + ), patch( + "homeassistant.components.flume.config_flow.FlumeDeviceList", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"password": "invalid_auth"} + + with patch( + "homeassistant.components.flume.config_flow.FlumeAuth", + return_value=True, + ), patch( + "homeassistant.components.flume.config_flow.FlumeDeviceList", + side_effect=requests.exceptions.ConnectionError(), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + + mock_flume_device_list = _get_mocked_flume_device_list() + + with patch( + "homeassistant.components.flume.config_flow.FlumeAuth", + return_value=True, + ), patch( + "homeassistant.components.flume.config_flow.FlumeDeviceList", + return_value=mock_flume_device_list, + ), patch( + "homeassistant.components.flume.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert mock_setup_entry.called + assert result4["type"] == "abort" + assert result4["reason"] == "reauth_successful"