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

@ -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