Add reauth support to flume (#49991)

pull/50121/head
J. Nick Koston 2021-05-03 07:30:22 -10:00 committed by GitHub
parent c49fa6f1ed
commit 302cab185d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 192 additions and 56 deletions

View File

@ -1,7 +1,4 @@
"""The flume integration.""" """The flume integration."""
from functools import partial
import logging
from pyflume import FlumeAuth, FlumeDeviceList from pyflume import FlumeAuth, FlumeDeviceList
from requests import Session from requests import Session
from requests.exceptions import RequestException from requests.exceptions import RequestException
@ -14,7 +11,7 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import ( from .const import (
BASE_TOKEN_FILENAME, BASE_TOKEN_FILENAME,
@ -25,12 +22,9 @@ from .const import (
PLATFORMS, 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 config = entry.data
username = config[CONF_USERNAME] username = config[CONF_USERNAME]
@ -42,32 +36,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
http_session = Session() http_session = Session()
try: try:
flume_auth = await hass.async_add_executor_job( flume_auth = FlumeAuth(
partial( username,
FlumeAuth, password,
username, client_id,
password, client_secret,
client_id, flume_token_file=flume_token_full_path,
client_secret, http_session=http_session,
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_devices = FlumeDeviceList(flume_auth, http_session=http_session)
except RequestException as ex: except RequestException as ex:
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady from ex
except Exception as ex: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
_LOGGER.error("Invalid credentials for flume: %s", ex) raise ConfigEntryAuthFailed from ex
return False
hass.data.setdefault(DOMAIN, {}) return flume_auth, flume_devices, http_session
hass.data[DOMAIN][entry.entry_id] = {
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_DEVICES: flume_devices,
FLUME_AUTH: flume_auth, FLUME_AUTH: flume_auth,
FLUME_HTTP_SESSION: http_session, FLUME_HTTP_SESSION: http_session,

View File

@ -1,6 +1,6 @@
"""Config flow for flume integration.""" """Config flow for flume integration."""
from functools import partial
import logging import logging
import os
from pyflume import FlumeAuth, FlumeDeviceList from pyflume import FlumeAuth, FlumeDeviceList
from requests.exceptions import RequestException 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. """Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user. 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: try:
flume_auth = await hass.async_add_executor_job( flume_devices = await hass.async_add_executor_job(
partial( _validate_input, hass, data, clear_token_file
FlumeAuth,
username,
password,
client_id,
client_secret,
flume_token_file=flume_token_full_path,
)
) )
flume_devices = await hass.async_add_executor_job(FlumeDeviceList, flume_auth)
except RequestException as err: except RequestException as err:
raise CannotConnect from err raise CannotConnect from err
except Exception as err: except Exception as err:
_LOGGER.exception("Auth exception")
raise InvalidAuth from err raise InvalidAuth from err
if not flume_devices or not flume_devices.device_list: if not flume_devices or not flume_devices.device_list:
raise CannotConnect raise CannotConnect
# Return info that you want to store in the config entry. # 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): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -72,6 +80,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def __init__(self):
"""Init flume config flow."""
self._reauth_unique_id = None
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors = {}
@ -85,10 +97,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors[CONF_PASSWORD] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors step_id="user", data_schema=DATA_SCHEMA, errors=errors
@ -98,6 +107,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle import.""" """Handle import."""
return await self.async_step_user(user_input) 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): class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot connect."""

View File

@ -15,9 +15,17 @@
"client_id": "Client ID", "client_id": "Client ID",
"password": "[%key:common::config_flow::data::password%]" "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": { "abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]" "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
} }
} }

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",
@ -9,6 +10,13 @@
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"step": { "step": {
"reauth_confirm": {
"data": {
"password": "Password"
},
"description": "The password for {username} is no longer valid.",
"title": "Reauthenticate your Flume Account"
},
"user": { "user": {
"data": { "data": {
"client_id": "Client ID", "client_id": "Client ID",

View File

@ -12,6 +12,8 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
) )
from tests.common import MockConfigEntry
def _get_mocked_flume_device_list(): def _get_mocked_flume_device_list():
flume_device_list_mock = MagicMock() flume_device_list_mock = MagicMock()
@ -124,7 +126,7 @@ async def test_form_invalid_auth(hass):
) )
assert result2["type"] == "form" assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {"password": "invalid_auth"}
async def test_form_cannot_connect(hass): async def test_form_cannot_connect(hass):
@ -151,3 +153,82 @@ async def test_form_cannot_connect(hass):
assert result2["type"] == "form" assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"} 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"