Update SmartThings config flow to be entirely UI based (#34163)

* bump pysmartthings 0.7.1

* Update config flow to use UI

* Code review comments and fix for resetting oauth client

* Replace html with markdown
This commit is contained in:
Andrew Sayre 2020-04-14 17:26:53 -05:00 committed by GitHub
parent bf33169627
commit 075030f15a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 543 additions and 382 deletions

View file

@ -1,28 +1,39 @@
{
"config": {
"error": {
"app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.",
"app_setup_error": "Unable to setup the SmartApp. Please try again.",
"base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.",
"token_already_setup": "The token has already been setup.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements."
},
"step": {
"user": {
"data": {
"access_token": "Access Token"
},
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).",
"title": "Enter Personal Access Token"
},
"wait_install": {
"description": "Please install the Home Assistant SmartApp in at least one location and click submit.",
"title": "Install SmartApp"
}
},
"title": "SmartThings"
"config": {
"title": "SmartThings",
"step": {
"user": {
"title": "Confirm Callback URL",
"description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again."
},
"pat": {
"title": "Enter Personal Access Token",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.",
"data": {
"access_token": "Access Token"
}
},
"select_location": {
"title": "Select Location",
"description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": {
"location_id": "Location"
}
},
"authorize": {
"title": "Authorize Home Assistant"
}
},
"abort": {
"invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
"no_available_locations": "There are no available SmartThings Locations to setup in Home Assistant."
},
"error": {
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"app_setup_error": "Unable to setup the SmartApp. Please try again.",
"webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
}
}
}
}

View file

@ -3,26 +3,30 @@ import logging
from aiohttp import ClientResponseError
from pysmartthings import APIResponseError, AppOAuth, SmartThings
from pysmartthings.installedapp import format_install_url
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
# pylint: disable=unused-import
from .const import (
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_INSTALLED_APPS,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_OAUTH_CLIENT_ID,
CONF_OAUTH_CLIENT_SECRET,
CONF_REFRESH_TOKEN,
DOMAIN,
VAL_UID_MATCHER,
)
from .smartapp import (
create_app,
find_app,
get_webhook_url,
setup_smartapp,
setup_smartapp_endpoint,
update_app,
@ -32,23 +36,8 @@ from .smartapp import (
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(DOMAIN)
class SmartThingsFlowHandler(config_entries.ConfigFlow):
"""
Handle configuration of SmartThings integrations.
Any number of integrations are supported. The high level flow follows:
1) Flow initiated
a) User initiates through the UI
b) Re-configuration of a failed entry setup
2) Enter access token
a) Check not already setup
b) Validate format
c) Setup SmartApp
3) Wait for Installation
a) Check user installed into one or more locations
b) Config entries setup for all installations
"""
class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle configuration of SmartThings integrations."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
@ -60,55 +49,84 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
self.api = None
self.oauth_client_secret = None
self.oauth_client_id = None
self.installed_app_id = None
self.refresh_token = None
self.location_id = None
async def async_step_import(self, user_input=None):
"""Occurs when a previously entry setup fails and is re-initiated."""
return await self.async_step_user(user_input)
async def async_step_user(self, user_input=None):
"""Get access token and validate it."""
"""Validate and confirm webhook setup."""
await setup_smartapp_endpoint(self.hass)
webhook_url = get_webhook_url(self.hass)
# Abort if the webhook is invalid
if not validate_webhook_requirements(self.hass):
return self.async_abort(
reason="invalid_webhook_url",
description_placeholders={
"webhook_url": webhook_url,
"component_url": "https://www.home-assistant.io/integrations/smartthings/",
},
)
# Show the confirmation
if user_input is None:
return self.async_show_form(
step_id="user", description_placeholders={"webhook_url": webhook_url},
)
# Show the next screen
return await self.async_step_pat()
async def async_step_pat(self, user_input=None):
"""Get the Personal Access Token and validate it."""
errors = {}
if user_input is None or CONF_ACCESS_TOKEN not in user_input:
return self._show_step_user(errors)
return self._show_step_pat(errors)
self.access_token = user_input.get(CONF_ACCESS_TOKEN, "")
self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
self.access_token = user_input[CONF_ACCESS_TOKEN]
# Ensure token is a UUID
if not VAL_UID_MATCHER.match(self.access_token):
errors[CONF_ACCESS_TOKEN] = "token_invalid_format"
return self._show_step_user(errors)
# Check not already setup in another entry
if any(
entry.data.get(CONF_ACCESS_TOKEN) == self.access_token
for entry in self.hass.config_entries.async_entries(DOMAIN)
):
errors[CONF_ACCESS_TOKEN] = "token_already_setup"
return self._show_step_user(errors)
return self._show_step_pat(errors)
# Setup end-point
await setup_smartapp_endpoint(self.hass)
if not validate_webhook_requirements(self.hass):
errors["base"] = "base_url_not_https"
return self._show_step_user(errors)
self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
try:
app = await find_app(self.hass, self.api)
if app:
await app.refresh() # load all attributes
await update_app(self.hass, app)
# Get oauth client id/secret by regenerating it
app_oauth = AppOAuth(app.app_id)
app_oauth.client_name = APP_OAUTH_CLIENT_NAME
app_oauth.scope.extend(APP_OAUTH_SCOPES)
client = await self.api.generate_app_oauth(app_oauth)
# Find an existing entry to copy the oauth client
existing = next(
(
entry
for entry in self._async_current_entries()
if entry.data[CONF_APP_ID] == app.app_id
),
None,
)
if existing:
self.oauth_client_id = existing.data[CONF_OAUTH_CLIENT_ID]
self.oauth_client_secret = existing.data[CONF_OAUTH_CLIENT_SECRET]
else:
# Get oauth client id/secret by regenerating it
app_oauth = AppOAuth(app.app_id)
app_oauth.client_name = APP_OAUTH_CLIENT_NAME
app_oauth.scope.extend(APP_OAUTH_SCOPES)
client = await self.api.generate_app_oauth(app_oauth)
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
else:
app, client = await create_app(self.hass, self.api)
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
setup_smartapp(self.hass, app)
self.app_id = app.app_id
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
except APIResponseError as ex:
if ex.is_target_error():
@ -118,58 +136,80 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
_LOGGER.exception(
"API error setting up the SmartApp: %s", ex.raw_error_response
)
return self._show_step_user(errors)
return self._show_step_pat(errors)
except ClientResponseError as ex:
if ex.status == 401:
errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
_LOGGER.debug(
"Unauthorized error received setting up SmartApp", exc_info=True
)
elif ex.status == HTTP_FORBIDDEN:
errors[CONF_ACCESS_TOKEN] = "token_forbidden"
_LOGGER.debug(
"Forbidden error received setting up SmartApp", exc_info=True
)
else:
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_user(errors)
return self._show_step_pat(errors)
except Exception: # pylint:disable=broad-except
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_user(errors)
return self._show_step_pat(errors)
return await self.async_step_wait_install()
return await self.async_step_select_location()
async def async_step_wait_install(self, user_input=None):
"""Wait for SmartApp installation."""
errors = {}
if user_input is None:
return self._show_step_wait_install(errors)
async def async_step_select_location(self, user_input=None):
"""Ask user to select the location to setup."""
if user_input is None or CONF_LOCATION_ID not in user_input:
# Get available locations
existing_locations = [
entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries()
]
locations = await self.api.locations()
locations_options = {
location.location_id: location.name
for location in locations
if location.location_id not in existing_locations
}
if not locations_options:
return self.async_abort(reason="no_available_locations")
# Find installed apps that were authorized
installed_apps = self.hass.data[DOMAIN][CONF_INSTALLED_APPS].copy()
if not installed_apps:
errors["base"] = "app_not_installed"
return self._show_step_wait_install(errors)
self.hass.data[DOMAIN][CONF_INSTALLED_APPS].clear()
# Enrich the data
for installed_app in installed_apps:
installed_app[CONF_APP_ID] = self.app_id
installed_app[CONF_ACCESS_TOKEN] = self.access_token
installed_app[CONF_OAUTH_CLIENT_ID] = self.oauth_client_id
installed_app[CONF_OAUTH_CLIENT_SECRET] = self.oauth_client_secret
# User may have installed the SmartApp in more than one SmartThings
# location. Config flows are created for the additional installations
for installed_app in installed_apps[1:]:
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
DOMAIN, context={"source": "install"}, data=installed_app
)
return self.async_show_form(
step_id="select_location",
data_schema=vol.Schema(
{vol.Required(CONF_LOCATION_ID): vol.In(locations_options)}
),
)
# Create config entity for the first one.
return await self.async_step_install(installed_apps[0])
self.location_id = user_input[CONF_LOCATION_ID]
return await self.async_step_authorize()
async def async_step_authorize(self, user_input=None):
"""Wait for the user to authorize the app installation."""
user_input = {} if user_input is None else user_input
self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID)
self.refresh_token = user_input.get(CONF_REFRESH_TOKEN)
if self.installed_app_id is None:
# Launch the external setup URL
url = format_install_url(self.app_id, self.location_id)
return self.async_external_step(step_id="authorize", url=url)
return self.async_external_step_done(next_step_id="install")
def _show_step_pat(self, errors):
if self.access_token is None:
# Get the token from an existing entry to make it easier to setup multiple locations.
self.access_token = next(
(
entry.data.get(CONF_ACCESS_TOKEN)
for entry in self._async_current_entries()
),
None,
)
def _show_step_user(self, errors):
return self.async_show_form(
step_id="user",
step_id="pat",
data_schema=vol.Schema(
{vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str}
),
@ -180,21 +220,18 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
},
)
def _show_step_wait_install(self, errors):
return self.async_show_form(step_id="wait_install", errors=errors)
async def async_step_install(self, data=None):
"""
Create a config entry at completion of a flow.
Launched when the user completes the flow or when the SmartApp
is installed into an additional location.
"""
if not self.api:
# Launched from the SmartApp install event handler
self.api = SmartThings(
async_get_clientsession(self.hass), data[CONF_ACCESS_TOKEN]
)
"""Create a config entry at completion of a flow and authorization of the app."""
data = {
CONF_ACCESS_TOKEN: self.access_token,
CONF_REFRESH_TOKEN: self.refresh_token,
CONF_OAUTH_CLIENT_ID: self.oauth_client_id,
CONF_OAUTH_CLIENT_SECRET: self.oauth_client_secret,
CONF_LOCATION_ID: self.location_id,
CONF_APP_ID: self.app_id,
CONF_INSTALLED_APP_ID: self.installed_app_id,
}
location = await self.api.location(data[CONF_LOCATION_ID])
return self.async_create_entry(title=location.name, data=data)

View file

@ -8,7 +8,6 @@ APP_NAME_PREFIX = "homeassistant."
CONF_APP_ID = "app_id"
CONF_CLOUDHOOK_URL = "cloudhook_url"
CONF_INSTALLED_APP_ID = "installed_app_id"
CONF_INSTALLED_APPS = "installed_apps"
CONF_INSTANCE_ID = "instance_id"
CONF_LOCATION_ID = "location_id"
CONF_OAUTH_CLIENT_ID = "client_id"

View file

@ -3,7 +3,7 @@
"name": "Smartthings",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.0"],
"requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.1"],
"dependencies": ["webhook"],
"after_dependencies": ["cloud"],
"codeowners": ["@andrewsayre"]

View file

@ -36,10 +36,8 @@ from .const import (
APP_NAME_PREFIX,
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_CLOUDHOOK_URL,
CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_INSTANCE_ID,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
@ -258,7 +256,6 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType):
CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
# Will not be present if not enabled
CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
CONF_INSTALLED_APPS: [],
}
_LOGGER.debug(
"Setup endpoint for %s",
@ -370,40 +367,30 @@ async def smartapp_sync_subscriptions(
async def smartapp_install(hass: HomeAssistantType, req, resp, app):
"""
Handle when a SmartApp is installed by the user into a location.
Create a config entry representing the installation if this is not
the first installation under the account, otherwise store the data
for the config flow.
"""
install_data = {
CONF_INSTALLED_APP_ID: req.installed_app_id,
CONF_LOCATION_ID: req.location_id,
CONF_REFRESH_TOKEN: req.refresh_token,
}
# App attributes (client id/secret, etc...) are copied from another entry
# with the same parent app_id. If one is not found, the install data is
# stored for the config flow to retrieve during the wait step.
entry = next(
"""Handle a SmartApp installation and continue the config flow."""
flow = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data[CONF_APP_ID] == app.app_id
flow
for flow in hass.config_entries.flow.async_progress()
if flow["handler"] == DOMAIN
),
None,
)
if entry:
data = entry.data.copy()
data.update(install_data)
# Add as job not needed because the current coroutine was invoked
# from the dispatcher and is not being awaited.
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "install"}, data=data
if flow is not None:
await hass.config_entries.flow.async_configure(
flow["flow_id"],
{
CONF_INSTALLED_APP_ID: req.installed_app_id,
CONF_LOCATION_ID: req.location_id,
CONF_REFRESH_TOKEN: req.refresh_token,
},
)
_LOGGER.debug(
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
req.installed_app_id,
app.app_id,
)
else:
# Store the data where the flow can find it
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data)
_LOGGER.debug(
"Installed SmartApp '%s' under parent app '%s'",
@ -413,12 +400,7 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app):
async def smartapp_update(hass: HomeAssistantType, req, resp, app):
"""
Handle when a SmartApp is updated (reconfigured) by the user.
Store the refresh token in the config entry.
"""
# Update refresh token in config entry
"""Handle a SmartApp update and either update the entry or continue the flow."""
entry = next(
(
entry
@ -431,6 +413,36 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app):
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
)
_LOGGER.debug(
"Updated config entry '%s' for SmartApp '%s' under parent app '%s'",
entry.entry_id,
req.installed_app_id,
app.app_id,
)
flow = next(
(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["handler"] == DOMAIN
),
None,
)
if flow is not None:
await hass.config_entries.flow.async_configure(
flow["flow_id"],
{
CONF_INSTALLED_APP_ID: req.installed_app_id,
CONF_LOCATION_ID: req.location_id,
CONF_REFRESH_TOKEN: req.refresh_token,
},
)
_LOGGER.debug(
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
req.installed_app_id,
app.app_id,
)
_LOGGER.debug(
"Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id

View file

@ -3,26 +3,37 @@
"title": "SmartThings",
"step": {
"user": {
"title": "Confirm Callback URL",
"description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again."
},
"pat": {
"title": "Enter Personal Access Token",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.",
"data": {
"access_token": "Access Token"
}
},
"wait_install": {
"title": "Install SmartApp",
"description": "Please install the Home Assistant SmartApp in at least one location and click submit."
"select_location": {
"title": "Select Location",
"description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": {
"location_id": "Location"
}
},
"authorize": {
"title": "Authorize Home Assistant"
}
},
"abort": {
"invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
"no_available_locations": "There are no available SmartThings Locations to setup in Home Assistant."
},
"error": {
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"token_already_setup": "The token has already been setup.",
"app_setup_error": "Unable to setup the SmartApp. Please try again.",
"app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.",
"base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.",
"webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements."
"webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
}
}
}

View file

@ -1551,7 +1551,7 @@ pysma==0.3.5
pysmartapp==0.3.2
# homeassistant.components.smartthings
pysmartthings==0.7.0
pysmartthings==0.7.1
# homeassistant.components.smarty
pysmarty==0.8

View file

@ -611,7 +611,7 @@ pysma==0.3.5
pysmartapp==0.3.2
# homeassistant.components.smartthings
pysmartthings==0.7.0
pysmartthings==0.7.1
# homeassistant.components.soma
pysoma==0.0.10

View file

@ -4,207 +4,266 @@ from uuid import uuid4
from aiohttp import ClientResponseError
from asynctest import Mock, patch
from pysmartthings import APIResponseError
from pysmartthings.installedapp import format_install_url
from homeassistant import data_entry_flow
from homeassistant.components.smartthings import smartapp
from homeassistant.components.smartthings.config_flow import SmartThingsFlowHandler
from homeassistant.components.smartthings.const import (
CONF_APP_ID,
CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_LOCATION_ID,
CONF_OAUTH_CLIENT_ID,
CONF_OAUTH_CLIENT_SECRET,
CONF_REFRESH_TOKEN,
DOMAIN,
)
from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND
from homeassistant.setup import async_setup_component
from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_NOT_FOUND
from tests.common import MockConfigEntry, mock_coro
async def test_step_user(hass):
"""Test the access token form is shown for a user initiated flow."""
async def test_step_import(hass):
"""Test import returns user."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_step_init(hass):
"""Test the access token form is shown for an init flow."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_import()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["description_placeholders"][
"webhook_url"
] == smartapp.get_webhook_url(hass)
async def test_base_url_not_https(hass):
"""Test the base_url parameter starts with https://."""
async def test_step_user(hass):
"""Test the webhook confirmation is shown."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["description_placeholders"][
"webhook_url"
] == smartapp.get_webhook_url(hass)
async def test_step_user_aborts_invalid_webhook(hass):
"""Test flow aborts if webhook is invalid."""
hass.config.api.base_url = "http://0.0.0.0"
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "invalid_webhook_url"
assert result["description_placeholders"][
"webhook_url"
] == smartapp.get_webhook_url(hass)
assert "component_url" in result["description_placeholders"]
async def test_step_user_advances_to_pat(hass):
"""Test user step advances to the pat step."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user({})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "base_url_not_https"}
assert result["step_id"] == "pat"
async def test_invalid_token_format(hass):
async def test_step_pat(hass):
"""Test pat step shows the input form."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_pat()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pat"
assert result["errors"] == {}
assert result["data_schema"]({CONF_ACCESS_TOKEN: ""}) == {CONF_ACCESS_TOKEN: ""}
assert "token_url" in result["description_placeholders"]
assert "component_url" in result["description_placeholders"]
async def test_step_pat_defaults_token(hass):
"""Test pat form defaults the token from another entry."""
token = str(uuid4())
entry = MockConfigEntry(domain=DOMAIN, data={CONF_ACCESS_TOKEN: token})
entry.add_to_hass(hass)
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_pat()
assert flow.access_token == token
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pat"
assert result["errors"] == {}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
assert "token_url" in result["description_placeholders"]
assert "component_url" in result["description_placeholders"]
async def test_step_pat_invalid_token(hass):
"""Test an error is shown for invalid token formats."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user({"access_token": "123456789"})
token = "123456789"
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["step_id"] == "pat"
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
assert result["errors"] == {"access_token": "token_invalid_format"}
assert "token_url" in result["description_placeholders"]
assert "component_url" in result["description_placeholders"]
async def test_token_already_setup(hass):
"""Test an error is shown when the token is already setup."""
flow = SmartThingsFlowHandler()
flow.hass = hass
token = str(uuid4())
entry = MockConfigEntry(domain=DOMAIN, data={"access_token": token})
entry.add_to_hass(hass)
result = await flow.async_step_user({"access_token": token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"access_token": "token_already_setup"}
async def test_token_unauthorized(hass, smartthings_mock):
async def test_step_pat_unauthorized(hass, smartthings_mock):
"""Test an error is shown when the token is not authorized."""
flow = SmartThingsFlowHandler()
flow.hass = hass
request_info = Mock(real_url="http://example.com")
smartthings_mock.apps.side_effect = ClientResponseError(
request_info=request_info, history=None, status=401
)
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"access_token": "token_unauthorized"}
assert result["step_id"] == "pat"
assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_token_forbidden(hass, smartthings_mock):
async def test_step_pat_forbidden(hass, smartthings_mock):
"""Test an error is shown when the token is forbidden."""
flow = SmartThingsFlowHandler()
flow.hass = hass
request_info = Mock(real_url="http://example.com")
smartthings_mock.apps.side_effect = ClientResponseError(
request_info=request_info, history=None, status=HTTP_FORBIDDEN
)
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"access_token": "token_forbidden"}
assert result["step_id"] == "pat"
assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_webhook_error(hass, smartthings_mock):
"""Test an error is when there's an error with the webhook endpoint."""
async def test_step_pat_webhook_error(hass, smartthings_mock):
"""Test an error is shown when there's an problem with the webhook endpoint."""
flow = SmartThingsFlowHandler()
flow.hass = hass
data = {"error": {}}
request_info = Mock(real_url="http://example.com")
error = APIResponseError(
request_info=request_info, history=None, data=data, status=422
)
error.is_target_error = Mock(return_value=True)
smartthings_mock.apps.side_effect = error
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["step_id"] == "pat"
assert result["errors"] == {"base": "webhook_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_api_error(hass, smartthings_mock):
async def test_step_pat_api_error(hass, smartthings_mock):
"""Test an error is shown when other API errors occur."""
flow = SmartThingsFlowHandler()
flow.hass = hass
data = {"error": {}}
request_info = Mock(real_url="http://example.com")
error = APIResponseError(
request_info=request_info, history=None, data=data, status=400
)
smartthings_mock.apps.side_effect = error
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["step_id"] == "pat"
assert result["errors"] == {"base": "app_setup_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_unknown_api_error(hass, smartthings_mock):
async def test_step_pat_unknown_api_error(hass, smartthings_mock):
"""Test an error is shown when there is an unknown API error."""
flow = SmartThingsFlowHandler()
flow.hass = hass
request_info = Mock(real_url="http://example.com")
smartthings_mock.apps.side_effect = ClientResponseError(
request_info=request_info, history=None, status=HTTP_NOT_FOUND
)
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["step_id"] == "pat"
assert result["errors"] == {"base": "app_setup_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_unknown_error(hass, smartthings_mock):
async def test_step_pat_unknown_error(hass, smartthings_mock):
"""Test an error is shown when there is an unknown API error."""
flow = SmartThingsFlowHandler()
flow.hass = hass
smartthings_mock.apps.side_effect = Exception("Unknown error")
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["step_id"] == "pat"
assert result["errors"] == {"base": "app_setup_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_app_created_then_show_wait_form(
hass, app, app_oauth_client, smartthings_mock
async def test_step_pat_app_created_webhook(
hass, app, app_oauth_client, location, smartthings_mock
):
"""Test SmartApp is created when one does not exist and shows wait form."""
"""Test SmartApp is created when one does not exist and shows location form."""
flow = SmartThingsFlowHandler()
flow.hass = hass
smartthings_mock.apps.return_value = []
smartthings_mock.create_app.return_value = (app, app_oauth_client)
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == app_oauth_client.client_secret
assert flow.oauth_client_id == app_oauth_client.client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
assert result["step_id"] == "select_location"
async def test_cloudhook_app_created_then_show_wait_form(
hass, app, app_oauth_client, smartthings_mock
async def test_step_pat_app_created_cloudhook(
hass, app, app_oauth_client, location, smartthings_mock
):
"""Test SmartApp is created with a cloudhoko and shows wait form."""
"""Test SmartApp is created with a cloudhook and shows location form."""
hass.config.components.add("cloud")
# Unload the endpoint so we can reload it under the cloud.
@ -224,128 +283,159 @@ async def test_cloudhook_app_created_then_show_wait_form(
flow.hass = hass
smartthings_mock.apps.return_value = []
smartthings_mock.create_app.return_value = (app, app_oauth_client)
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == app_oauth_client.client_secret
assert flow.oauth_client_id == app_oauth_client.client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
assert result["step_id"] == "select_location"
assert mock_create_cloudhook.call_count == 1
async def test_app_updated_then_show_wait_form(
hass, app, app_oauth_client, smartthings_mock
async def test_step_pat_app_updated_webhook(
hass, app, app_oauth_client, location, smartthings_mock
):
"""Test SmartApp is updated when an existing is already created."""
"""Test SmartApp is updated then show location form."""
flow = SmartThingsFlowHandler()
flow.hass = hass
smartthings_mock.apps.return_value = [app]
smartthings_mock.generate_app_oauth.return_value = app_oauth_client
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == app_oauth_client.client_secret
assert flow.oauth_client_id == app_oauth_client.client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
assert result["step_id"] == "select_location"
async def test_wait_form_displayed(hass):
"""Test the wait for installation form is displayed."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_wait_install(None)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
async def test_wait_form_displayed_after_checking(hass, smartthings_mock):
"""Test error is shown when the user has not installed the app."""
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.access_token = str(uuid4())
result = await flow.async_step_wait_install({})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
assert result["errors"] == {"base": "app_not_installed"}
async def test_config_entry_created_when_installed(
hass, location, installed_app, smartthings_mock
async def test_step_pat_app_updated_webhook_from_existing_oauth_client(
hass, app, location, smartthings_mock
):
"""Test SmartApp is updated from existing then show location form."""
oauth_client_id = str(uuid4())
oauth_client_secret = str(uuid4())
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_APP_ID: app.app_id,
CONF_OAUTH_CLIENT_ID: oauth_client_id,
CONF_OAUTH_CLIENT_SECRET: oauth_client_secret,
CONF_LOCATION_ID: str(uuid4()),
},
)
entry.add_to_hass(hass)
flow = SmartThingsFlowHandler()
flow.hass = hass
smartthings_mock.apps.return_value = [app]
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == oauth_client_secret
assert flow.oauth_client_id == oauth_client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "select_location"
async def test_step_select_location(hass, location, smartthings_mock):
"""Test select location shows form with available locations."""
smartthings_mock.locations.return_value = [location]
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.api = smartthings_mock
result = await flow.async_step_select_location()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "select_location"
assert result["data_schema"]({CONF_LOCATION_ID: location.location_id}) == {
CONF_LOCATION_ID: location.location_id
}
async def test_step_select_location_aborts(hass, location, smartthings_mock):
"""Test select location aborts if no available locations."""
smartthings_mock.locations.return_value = [location]
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id}
)
entry.add_to_hass(hass)
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.api = smartthings_mock
result = await flow.async_step_select_location()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "no_available_locations"
async def test_step_select_location_advances(hass):
"""Test select location aborts if no available locations."""
location_id = str(uuid4())
app_id = str(uuid4())
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.app_id = app_id
result = await flow.async_step_select_location({CONF_LOCATION_ID: location_id})
assert flow.location_id == location_id
assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
assert result["step_id"] == "authorize"
assert result["url"] == format_install_url(app_id, location_id)
async def test_step_authorize_advances(hass):
"""Test authorize step advances when completed."""
installed_app_id = str(uuid4())
refresh_token = str(uuid4())
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_authorize(
{CONF_INSTALLED_APP_ID: installed_app_id, CONF_REFRESH_TOKEN: refresh_token}
)
assert flow.installed_app_id == installed_app_id
assert flow.refresh_token == refresh_token
assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE
assert result["step_id"] == "install"
async def test_step_install_creates_entry(hass, location, smartthings_mock):
"""Test a config entry is created once the app is installed."""
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.access_token = str(uuid4())
flow.app_id = installed_app.app_id
flow.api = smartthings_mock
flow.access_token = str(uuid4())
flow.app_id = str(uuid4())
flow.installed_app_id = str(uuid4())
flow.location_id = location.location_id
flow.oauth_client_id = str(uuid4())
flow.oauth_client_secret = str(uuid4())
data = {
CONF_REFRESH_TOKEN: str(uuid4()),
CONF_LOCATION_ID: installed_app.location_id,
CONF_INSTALLED_APP_ID: installed_app.installed_app_id,
}
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data)
flow.refresh_token = str(uuid4())
result = await flow.async_step_wait_install({})
result = await flow.async_step_install()
assert not hass.data[DOMAIN][CONF_INSTALLED_APPS]
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"]["app_id"] == installed_app.app_id
assert result["data"]["installed_app_id"] == installed_app.installed_app_id
assert result["data"]["location_id"] == installed_app.location_id
assert result["data"]["app_id"] == flow.app_id
assert result["data"]["installed_app_id"] == flow.installed_app_id
assert result["data"]["location_id"] == flow.location_id
assert result["data"]["access_token"] == flow.access_token
assert result["data"]["refresh_token"] == data[CONF_REFRESH_TOKEN]
assert result["data"]["refresh_token"] == flow.refresh_token
assert result["data"]["client_secret"] == flow.oauth_client_secret
assert result["data"]["client_id"] == flow.oauth_client_id
assert result["title"] == location.name
async def test_multiple_config_entry_created_when_installed(
hass, app, locations, installed_apps, smartthings_mock
):
"""Test a config entries are created for multiple installs."""
assert await async_setup_component(hass, "persistent_notification", {})
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.access_token = str(uuid4())
flow.app_id = app.app_id
flow.api = smartthings_mock
flow.oauth_client_id = str(uuid4())
flow.oauth_client_secret = str(uuid4())
for installed_app in installed_apps:
data = {
CONF_REFRESH_TOKEN: str(uuid4()),
CONF_LOCATION_ID: installed_app.location_id,
CONF_INSTALLED_APP_ID: installed_app.installed_app_id,
}
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data)
install_data = hass.data[DOMAIN][CONF_INSTALLED_APPS].copy()
result = await flow.async_step_wait_install({})
assert not hass.data[DOMAIN][CONF_INSTALLED_APPS]
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"]["app_id"] == installed_apps[0].app_id
assert result["data"]["installed_app_id"] == installed_apps[0].installed_app_id
assert result["data"]["location_id"] == installed_apps[0].location_id
assert result["data"]["access_token"] == flow.access_token
assert result["data"]["refresh_token"] == install_data[0][CONF_REFRESH_TOKEN]
assert result["data"]["client_secret"] == flow.oauth_client_secret
assert result["data"]["client_id"] == flow.oauth_client_id
assert result["title"] == locations[0].name
await hass.async_block_till_done()
entries = hass.config_entries.async_entries("smartthings")
assert len(entries) == 1
assert entries[0].data["app_id"] == installed_apps[1].app_id
assert entries[0].data["installed_app_id"] == installed_apps[1].installed_app_id
assert entries[0].data["location_id"] == installed_apps[1].location_id
assert entries[0].data["access_token"] == flow.access_token
assert entries[0].data["client_secret"] == flow.oauth_client_secret
assert entries[0].data["client_id"] == flow.oauth_client_id
assert entries[0].title == locations[1].name

View file

@ -7,7 +7,6 @@ from pysmartthings import AppEntity, Capability
from homeassistant.components.smartthings import smartapp
from homeassistant.components.smartthings.const import (
CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DATA_MANAGER,
@ -40,11 +39,11 @@ async def test_update_app_updated_needed(hass, app):
assert mock_app.classifications == app.classifications
async def test_smartapp_install_store_if_no_other(
hass, smartthings_mock, device_factory
):
"""Test aborts if no other app was configured already."""
async def test_smartapp_install_configures_flow(hass):
"""Test install event continues an existing flow."""
# Arrange
flow_id = str(uuid4())
flows = [{"flow_id": flow_id, "handler": DOMAIN}]
app = Mock()
app.app_id = uuid4()
request = Mock()
@ -52,50 +51,22 @@ async def test_smartapp_install_store_if_no_other(
request.auth_token = str(uuid4())
request.location_id = str(uuid4())
request.refresh_token = str(uuid4())
# Act
await smartapp.smartapp_install(hass, request, None, app)
# Assert
entries = hass.config_entries.async_entries("smartthings")
assert not entries
data = hass.data[DOMAIN][CONF_INSTALLED_APPS][0]
assert data[CONF_REFRESH_TOKEN] == request.refresh_token
assert data[CONF_LOCATION_ID] == request.location_id
assert data[CONF_INSTALLED_APP_ID] == request.installed_app_id
async def test_smartapp_install_creates_flow(
hass, smartthings_mock, config_entry, location, device_factory
):
"""Test installation creates flow."""
# Arrange
config_entry.add_to_hass(hass)
app = Mock()
app.app_id = config_entry.data["app_id"]
request = Mock()
request.installed_app_id = str(uuid4())
request.auth_token = str(uuid4())
request.refresh_token = str(uuid4())
request.location_id = location.location_id
devices = [
device_factory("", [Capability.battery, "ping"]),
device_factory("", [Capability.switch, Capability.switch_level]),
device_factory("", [Capability.switch]),
]
smartthings_mock.devices.return_value = devices
# Act
await smartapp.smartapp_install(hass, request, None, app)
# Assert
await hass.async_block_till_done()
entries = hass.config_entries.async_entries("smartthings")
assert len(entries) == 2
assert entries[1].data["app_id"] == app.app_id
assert entries[1].data["installed_app_id"] == request.installed_app_id
assert entries[1].data["location_id"] == request.location_id
assert entries[1].data["access_token"] == config_entry.data["access_token"]
assert entries[1].data["refresh_token"] == request.refresh_token
assert entries[1].data["client_secret"] == config_entry.data["client_secret"]
assert entries[1].data["client_id"] == config_entry.data["client_id"]
assert entries[1].title == location.name
with patch.object(
hass.config_entries.flow, "async_progress", return_value=flows
), patch.object(hass.config_entries.flow, "async_configure") as configure_mock:
await smartapp.smartapp_install(hass, request, None, app)
configure_mock.assert_called_once_with(
flow_id,
{
CONF_INSTALLED_APP_ID: request.installed_app_id,
CONF_LOCATION_ID: request.location_id,
CONF_REFRESH_TOKEN: request.refresh_token,
},
)
async def test_smartapp_update_saves_token(
@ -121,6 +92,36 @@ async def test_smartapp_update_saves_token(
assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token
async def test_smartapp_update_configures_flow(hass):
"""Test update event continues an existing flow."""
# Arrange
flow_id = str(uuid4())
flows = [{"flow_id": flow_id, "handler": DOMAIN}]
app = Mock()
app.app_id = uuid4()
request = Mock()
request.installed_app_id = str(uuid4())
request.auth_token = str(uuid4())
request.location_id = str(uuid4())
request.refresh_token = str(uuid4())
# Act
with patch.object(
hass.config_entries.flow, "async_progress", return_value=flows
), patch.object(hass.config_entries.flow, "async_configure") as configure_mock:
await smartapp.smartapp_update(hass, request, None, app)
configure_mock.assert_called_once_with(
flow_id,
{
CONF_INSTALLED_APP_ID: request.installed_app_id,
CONF_LOCATION_ID: request.location_id,
CONF_REFRESH_TOKEN: request.refresh_token,
},
)
async def test_smartapp_uninstall(hass, config_entry):
"""Test the config entry is unloaded when the app is uninstalled."""
config_entry.add_to_hass(hass)