diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 97a7d32a9c198..e4d720c94e523 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -37,6 +37,7 @@ TOKEN_REFRESH_INTERVAL, ) from .smartapp import ( + format_unique_id, setup_smartapp, setup_smartapp_endpoint, smartapp_sync_subscriptions, @@ -76,6 +77,15 @@ async def async_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents an installed SmartApp.""" + # For backwards compat + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, + unique_id=format_unique_id( + entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] + ), + ) + if not validate_webhook_requirements(hass): _LOGGER.warning( "The 'base_url' of the 'http' integration must be configured and start with 'https://'" diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index cb4623cea1cb5..c03ade4d8b1a1 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED from homeassistant.helpers.aiohttp_client import async_get_clientsession # pylint: disable=unused-import @@ -26,6 +26,7 @@ from .smartapp import ( create_app, find_app, + format_unique_id, get_webhook_url, setup_smartapp, setup_smartapp_endpoint, @@ -138,7 +139,7 @@ async def async_step_pat(self, user_input=None): ) return self._show_step_pat(errors) except ClientResponseError as ex: - if ex.status == 401: + if ex.status == HTTP_UNAUTHORIZED: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" _LOGGER.debug( "Unauthorized error received setting up SmartApp", exc_info=True @@ -183,6 +184,7 @@ async def async_step_select_location(self, user_input=None): ) self.location_id = user_input[CONF_LOCATION_ID] + await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) return await self.async_step_authorize() async def async_step_authorize(self, user_input=None): diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 0b86a430d89e7..7d02a04d2ff9e 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -39,7 +39,6 @@ CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID, - CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DATA_MANAGER, @@ -53,6 +52,11 @@ _LOGGER = logging.getLogger(__name__) +def format_unique_id(app_id: str, location_id: str) -> str: + """Format the unique id for a config entry.""" + return f"{app_id}_{location_id}" + + async def find_app(hass: HomeAssistantType, api): """Find an existing SmartApp for this installation of hass.""" apps = await api.apps() @@ -366,13 +370,20 @@ async def delete_subscription(sub: SubscriptionEntity): _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def smartapp_install(hass: HomeAssistantType, req, resp, app): - """Handle a SmartApp installation and continue the config flow.""" +async def _continue_flow( + hass: HomeAssistantType, + app_id: str, + location_id: str, + installed_app_id: str, + refresh_token: str, +): + """Continue a config flow if one is in progress for the specific installed app.""" + unique_id = format_unique_id(app_id, location_id) flow = next( ( flow for flow in hass.config_entries.flow.async_progress() - if flow["handler"] == DOMAIN + if flow["handler"] == DOMAIN and flow["context"]["unique_id"] == unique_id ), None, ) @@ -380,18 +391,23 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): 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, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: 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, + installed_app_id, + app_id, ) + +async def smartapp_install(hass: HomeAssistantType, req, resp, app): + """Handle a SmartApp installation and continue the config flow.""" + await _continue_flow( + hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token + ) _LOGGER.debug( "Installed SmartApp '%s' under parent app '%s'", req.installed_app_id, @@ -420,30 +436,9 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app): app.app_id, ) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["handler"] == DOMAIN - ), - None, + await _continue_flow( + hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) - 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 ) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index dc046f718a8dd..81dbab917a371 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -8,166 +8,539 @@ 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_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET, - CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_NOT_FOUND +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + HTTP_FORBIDDEN, + HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, +) from tests.common import MockConfigEntry, mock_coro -async def test_step_import(hass): - """Test import returns user.""" - flow = SmartThingsFlowHandler() - flow.hass = hass +async def test_import_shows_user_step(hass): + """Test import source shows the user form.""" + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) - result = await flow.async_step_import() +async def test_entry_created(hass, app, app_oauth_client, location, smartthings_mock): + """Test local webhook, new app, install event creates entry.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" -async def test_step_user(hass): - """Test the webhook confirmation is shown.""" - flow = SmartThingsFlowHandler() - flow.hass = hass + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) - result = await flow.async_step_user() + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_from_update_event( + hass, app, app_oauth_client, location, smartthings_mock +): + """Test local webhook, new app, update event creates entry.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" -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 + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) - result = await flow.async_step_user() + # Complete external auth and advance to install + await smartapp.smartapp_update(hass, request, None, app) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "invalid_webhook_url" + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_existing_app_new_oauth_client( + hass, app, app_oauth_client, location, smartthings_mock +): + """Test entry is created with an existing app and generation of a new oauth client.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [app] + smartthings_mock.generate_app_oauth.return_value = app_oauth_client + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] assert "component_url" in result["description_placeholders"] + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" -async def test_step_user_advances_to_pat(hass): - """Test user step advances to the pat step.""" - flow = SmartThingsFlowHandler() - flow.hass = hass + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) - result = await flow.async_step_user({}) + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) -async def test_step_pat(hass): - """Test pat step shows the input form.""" - flow = SmartThingsFlowHandler() - flow.hass = hass +async def test_entry_created_existing_app_copies_oauth_client( + hass, app, location, smartthings_mock +): + """Test entry is created with an existing app and copies the oauth client from another entry.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + oauth_client_id = str(uuid4()) + oauth_client_secret = str(uuid4()) + smartthings_mock.apps.return_value = [app] + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + 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()), + CONF_INSTALLED_APP_ID: str(uuid4()), + CONF_ACCESS_TOKEN: token, + }, + ) + entry.add_to_hass(hass) - result = await flow.async_step_pat() + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) 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"] + # Assert access token is defaulted to an existing entry for convenience. + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) -async def test_step_pat_defaults_token(hass): - """Test pat form defaults the token from another entry.""" + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == oauth_client_secret + assert result["data"]["client_id"] == oauth_client_id + assert result["title"] == location.name + entry = next( + ( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_INSTALLED_APP_ID] == installed_app_id + ), + None, + ) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_with_cloudhook( + hass, app, app_oauth_client, location, smartthings_mock +): + """Test cloud, new app, install event creates entry.""" + hass.config.components.add("cloud") + # Unload the endpoint so we can reload it under the cloud. + await smartapp.unload_smartapp_endpoint(hass) token = str(uuid4()) - entry = MockConfigEntry(domain=DOMAIN, data={CONF_ACCESS_TOKEN: token}) - entry.add_to_hass(hass) - flow = SmartThingsFlowHandler() - flow.hass = hass + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token - result = await flow.async_step_pat() + with patch.object( + hass.components.cloud, "async_active_subscription", return_value=True + ), patch.object( + hass.components.cloud, + "async_create_cloudhook", + return_value=mock_coro("http://cloud.test"), + ) as mock_create_cloudhook: - 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"] + await smartapp.setup_smartapp_endpoint(hass) + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + assert mock_create_cloudhook.call_count == 1 + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) + + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next( + (entry for entry in hass.config_entries.async_entries(DOMAIN)), None, + ) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_invalid_webhook_aborts(hass): + """Test flow aborts if webhook is invalid.""" + hass.config.api.base_url = "http://0.0.0.0" + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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_pat_invalid_token(hass): +async def test_invalid_token_shows_error(hass): """Test an error is shown for invalid token formats.""" - flow = SmartThingsFlowHandler() - flow.hass = hass token = "123456789" - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"access_token": "token_invalid_format"} + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_invalid_format"} assert "token_url" in result["description_placeholders"] assert "component_url" in result["description_placeholders"] -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 +async def test_unauthorized_token_shows_error(hass, smartthings_mock): + """Test an error is shown for unauthorized token formats.""" + token = str(uuid4()) request_info = Mock(real_url="http://example.com") smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=401 + request_info=request_info, history=None, status=HTTP_UNAUTHORIZED ) - token = str(uuid4()) - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_forbidden(hass, smartthings_mock): - """Test an error is shown when the token is forbidden.""" - flow = SmartThingsFlowHandler() - flow.hass = hass +async def test_forbidden_token_shows_error(hass, smartthings_mock): + """Test an error is shown for forbidden token formats.""" + token = str(uuid4()) 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_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_webhook_error(hass, smartthings_mock): +async def test_webhook_problem_shows_error(hass, smartthings_mock): """Test an error is shown when there's an problem with the webhook endpoint.""" - flow = SmartThingsFlowHandler() - flow.hass = hass + token = str(uuid4()) data = {"error": {}} request_info = Mock(real_url="http://example.com") error = APIResponseError( @@ -175,267 +548,180 @@ async def test_step_pat_webhook_error(hass, smartthings_mock): ) error.is_target_error = Mock(return_value=True) smartthings_mock.apps.side_effect = error - token = str(uuid4()) - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" - assert result["errors"] == {"base": "webhook_error"} assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "webhook_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_api_error(hass, smartthings_mock): +async def test_api_error_shows_error(hass, smartthings_mock): """Test an error is shown when other API errors occur.""" - flow = SmartThingsFlowHandler() - flow.hass = hass + token = str(uuid4()) 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_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" - assert result["errors"] == {"base": "app_setup_error"} assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "app_setup_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_unknown_api_error(hass, smartthings_mock): +async def test_unknown_response_error_shows_error(hass, smartthings_mock): """Test an error is shown when there is an unknown API error.""" - flow = SmartThingsFlowHandler() - flow.hass = hass + token = str(uuid4()) request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( + error = ClientResponseError( request_info=request_info, history=None, status=HTTP_NOT_FOUND ) - token = str(uuid4()) + smartthings_mock.apps.side_effect = error - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" - assert result["errors"] == {"base": "app_setup_error"} assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "app_setup_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_unknown_error(hass, smartthings_mock): +async def test_unknown_error_shows_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()) + smartthings_mock.apps.side_effect = Exception("Unknown error") - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "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) + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pat" - assert result["errors"] == {"base": "app_setup_error"} assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "app_setup_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_app_created_webhook( +async def test_no_available_locations_aborts( hass, app, app_oauth_client, location, smartthings_mock ): - """Test SmartApp is created when one does not exist and shows location form.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - + """Test select location aborts if no available locations.""" + token = str(uuid4()) 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_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"] == "select_location" - - -async def test_step_pat_app_created_cloudhook( - hass, app, app_oauth_client, location, smartthings_mock -): - """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. - await smartapp.unload_smartapp_endpoint(hass) - - with patch.object( - hass.components.cloud, "async_active_subscription", return_value=True - ), patch.object( - hass.components.cloud, - "async_create_cloudhook", - return_value=mock_coro("http://cloud.test"), - ) as mock_create_cloudhook: - - await smartapp.setup_smartapp_endpoint(hass) - - 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_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"] == "select_location" - assert mock_create_cloudhook.call_count == 1 - - -async def test_step_pat_app_updated_webhook( - hass, app, app_oauth_client, location, smartthings_mock -): - """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_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"] == "select_location" - - -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()), - }, + domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} ) 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 + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) 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 + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) - result = await flow.async_step_select_location() + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) 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 - } - + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -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} + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} ) - 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.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()) - flow.refresh_token = str(uuid4()) - - result = await flow.async_step_install() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - 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"] == 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 diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py index 4d7280a6a9ecc..efc4844cef295 100644 --- a/tests/components/smartthings/test_smartapp.py +++ b/tests/components/smartthings/test_smartapp.py @@ -6,8 +6,6 @@ from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.const import ( - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_MANAGER, DOMAIN, @@ -39,36 +37,6 @@ async def test_update_app_updated_needed(hass, app): assert mock_app.classifications == app.classifications -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() - 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_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( hass, smartthings_mock, location, device_factory ): @@ -92,36 +60,6 @@ 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)