From e5365779fe596750cbd402d34c2123a5688d7bfc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Jan 2020 09:57:47 -0800 Subject: [PATCH] Allow unloading mobile app (#30995) --- .../components/mobile_app/__init__.py | 43 ++++++- .../components/mobile_app/websocket_api.py | 121 ------------------ tests/components/mobile_app/conftest.py | 12 +- tests/components/mobile_app/test_init.py | 37 ++++++ tests/components/mobile_app/test_webhook.py | 3 +- .../mobile_app/test_websocket_api.py | 76 ----------- 6 files changed, 86 insertions(+), 206 deletions(-) delete mode 100644 homeassistant/components/mobile_app/websocket_api.py create mode 100644 tests/components/mobile_app/test_init.py delete mode 100644 tests/components/mobile_app/test_websocket_api.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 56594f3e2c3..fcf95da586e 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,5 +1,11 @@ """Integrates Native Apps to Home Assistant.""" -from homeassistant.components.webhook import async_register as webhook_register +import asyncio + +from homeassistant.components import cloud +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.helpers import device_registry as dr, discovery from homeassistant.helpers.typing import ConfigType, HomeAssistantType @@ -10,6 +16,7 @@ from .const import ( ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + CONF_CLOUDHOOK_URL, DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, @@ -20,9 +27,9 @@ from .const import ( STORAGE_KEY, STORAGE_VERSION, ) +from .helpers import savable_state from .http_api import RegistrationsView from .webhook import handle_webhook -from .websocket_api import register_websocket_handlers PLATFORMS = "sensor", "binary_sensor", "device_tracker" @@ -49,7 +56,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): } hass.http.register_view(RegistrationsView()) - register_websocket_handlers(hass) for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]: try: @@ -96,3 +102,34 @@ async def async_setup_entry(hass, entry): ) return True + + +async def async_unload_entry(hass, entry): + """Unload a mobile app entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if not unload_ok: + return False + + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + return True + + +async def async_remove_entry(hass, entry): + """Cleanup when entry is removed.""" + hass.data[DOMAIN][DATA_DELETED_IDS].append(entry.data[CONF_WEBHOOK_ID]) + store = hass.data[DOMAIN][DATA_STORE] + await store.async_save(savable_state(hass)) + + if CONF_CLOUDHOOK_URL in entry.data: + try: + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py deleted file mode 100644 index a18e5247bfa..00000000000 --- a/homeassistant/components/mobile_app/websocket_api.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Websocket API for mobile_app.""" -import voluptuous as vol - -from homeassistant.components.websocket_api import ( - ActiveConnection, - async_register_command, - async_response, - error_message, - result_message, - websocket_command, - ws_require_user, -) -from homeassistant.components.websocket_api.const import ( - ERR_INVALID_FORMAT, - ERR_NOT_FOUND, - ERR_UNAUTHORIZED, -) -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import HomeAssistantType - -from .const import ( - CONF_CLOUDHOOK_URL, - CONF_USER_ID, - DATA_CONFIG_ENTRIES, - DATA_DELETED_IDS, - DATA_STORE, - DOMAIN, -) -from .helpers import safe_registration, savable_state - - -def register_websocket_handlers(hass: HomeAssistantType) -> bool: - """Register the websocket handlers.""" - async_register_command(hass, websocket_get_user_registrations) - - async_register_command(hass, websocket_delete_registration) - - return True - - -@ws_require_user() -@async_response -@websocket_command( - { - vol.Required("type"): "mobile_app/get_user_registrations", - vol.Optional(CONF_USER_ID): cv.string, - } -) -async def websocket_get_user_registrations( - hass: HomeAssistantType, connection: ActiveConnection, msg: dict -) -> None: - """Return all registrations or just registrations for given user ID.""" - user_id = msg.get(CONF_USER_ID, connection.user.id) - - if user_id != connection.user.id and not connection.user.is_admin: - # If user ID is provided and is not current user ID and current user - # isn't an admin user - connection.send_error(msg["id"], ERR_UNAUTHORIZED, "Unauthorized") - return - - user_registrations = [] - - for config_entry in hass.config_entries.async_entries(domain=DOMAIN): - registration = config_entry.data - if connection.user.is_admin or registration[CONF_USER_ID] is user_id: - user_registrations.append(safe_registration(registration)) - - connection.send_message(result_message(msg["id"], user_registrations)) - - -@ws_require_user() -@async_response -@websocket_command( - { - vol.Required("type"): "mobile_app/delete_registration", - vol.Required(CONF_WEBHOOK_ID): cv.string, - } -) -async def websocket_delete_registration( - hass: HomeAssistantType, connection: ActiveConnection, msg: dict -) -> None: - """Delete the registration for the given webhook_id.""" - user = connection.user - - webhook_id = msg.get(CONF_WEBHOOK_ID) - if webhook_id is None: - connection.send_error(msg["id"], ERR_INVALID_FORMAT, "Webhook ID not provided") - return - - config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] - - registration = config_entry.data - - if registration is None: - connection.send_error( - msg["id"], ERR_NOT_FOUND, "Webhook ID not found in storage" - ) - return - - if registration[CONF_USER_ID] != user.id and not user.is_admin: - return error_message( - msg["id"], ERR_UNAUTHORIZED, "User is not registration owner" - ) - - await hass.config_entries.async_remove(config_entry.entry_id) - - hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id) - - store = hass.data[DOMAIN][DATA_STORE] - - try: - await store.async_save(savable_state(hass)) - except HomeAssistantError: - return error_message(msg["id"], "internal_error", "Error deleting registration") - - if CONF_CLOUDHOOK_URL in registration: - await hass.components.cloud.async_delete_cloudhook(webhook_id) - - connection.send_message(result_message(msg["id"], "ok")) diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index cd819a9891c..e15c5732ac4 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -19,6 +19,8 @@ def registry(hass): @pytest.fixture async def create_registrations(hass, authed_api_client): """Return two new registrations.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + enc_reg = await authed_api_client.post( "/api/mobile_app/registrations", json=REGISTER ) @@ -39,11 +41,13 @@ async def create_registrations(hass, authed_api_client): @pytest.fixture -async def webhook_client(hass, aiohttp_client): +async def webhook_client(hass, authed_api_client, aiohttp_client): """mobile_app mock client.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - return await aiohttp_client(hass.http.app) + # We pass in the authed_api_client server instance because + # it is used inside create_registrations and just passing in + # the app instance would cause the server to start twice, + # which caused deprecation warnings to be printed. + return await aiohttp_client(authed_api_client.server) @pytest.fixture diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py new file mode 100644 index 00000000000..fe956796a96 --- /dev/null +++ b/tests/components/mobile_app/test_init.py @@ -0,0 +1,37 @@ +"""Tests for the mobile app integration.""" +from homeassistant.components.mobile_app.const import DATA_DELETED_IDS, DOMAIN + +from .const import CALL_SERVICE + +from tests.common import async_mock_service + + +async def test_unload_unloads(hass, create_registrations, webhook_client): + """Test we clean up when we unload.""" + # Second config entry is the one without encryption + config_entry = hass.config_entries.async_entries("mobile_app")[1] + webhook_id = config_entry.data["webhook_id"] + calls = async_mock_service(hass, "test", "mobile_app") + + # Test it works + await webhook_client.post(f"/api/webhook/{webhook_id}", json=CALL_SERVICE) + assert len(calls) == 1 + + await hass.config_entries.async_unload(config_entry.entry_id) + + # Test it no longer works + await webhook_client.post(f"/api/webhook/{webhook_id}", json=CALL_SERVICE) + assert len(calls) == 1 + + +async def test_remove_entry(hass, create_registrations): + """Test we clean up when we remove entry.""" + for config_entry in hass.config_entries.async_entries("mobile_app"): + await hass.config_entries.async_remove(config_entry.entry_id) + assert config_entry.data["webhook_id"] in hass.data[DOMAIN][DATA_DELETED_IDS] + + dev_reg = await hass.helpers.device_registry.async_get_registry() + assert len(dev_reg.devices) == 0 + + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 0 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 6a41b5f054d..3df71c34781 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -67,9 +67,8 @@ async def test_webhook_handle_fire_event(hass, create_registrations, webhook_cli assert events[0].data["hello"] == "yo world" -async def test_webhook_update_registration(webhook_client, hass_client): +async def test_webhook_update_registration(webhook_client, authed_api_client): """Test that a we can update an existing registration via webhook.""" - authed_api_client = await hass_client() register_resp = await authed_api_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py deleted file mode 100644 index bad956bf2db..00000000000 --- a/tests/components/mobile_app/test_websocket_api.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Test the mobile_app websocket API.""" -# pylint: disable=redefined-outer-name,unused-import -from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN -from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.setup import async_setup_component - -from .const import CALL_SERVICE, REGISTER - - -async def test_webocket_get_user_registrations( - hass, aiohttp_client, hass_ws_client, hass_read_only_access_token -): - """Test get_user_registrations websocket command from admin perspective.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - user_api_client = await aiohttp_client( - hass.http.app, - headers={"Authorization": "Bearer {}".format(hass_read_only_access_token)}, - ) - - # First a read only user registers. - register_resp = await user_api_client.post( - "/api/mobile_app/registrations", json=REGISTER - ) - - assert register_resp.status == 201 - register_json = await register_resp.json() - assert CONF_WEBHOOK_ID in register_json - assert CONF_SECRET in register_json - - # Then the admin user attempts to access it. - client = await hass_ws_client(hass) - await client.send_json({"id": 5, "type": "mobile_app/get_user_registrations"}) - - msg = await client.receive_json() - - assert msg["id"] == 5 - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert len(msg["result"]) == 1 - - -async def test_webocket_delete_registration( - hass, hass_client, hass_ws_client, webhook_client -): - """Test delete_registration websocket command.""" - authed_api_client = await hass_client() # noqa: F811 - register_resp = await authed_api_client.post( - "/api/mobile_app/registrations", json=REGISTER - ) - - assert register_resp.status == 201 - register_json = await register_resp.json() - assert CONF_WEBHOOK_ID in register_json - assert CONF_SECRET in register_json - - webhook_id = register_json[CONF_WEBHOOK_ID] - - client = await hass_ws_client(hass) - await client.send_json( - {"id": 5, "type": "mobile_app/delete_registration", CONF_WEBHOOK_ID: webhook_id} - ) - - msg = await client.receive_json() - - assert msg["id"] == 5 - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert msg["result"] == "ok" - - ensure_four_ten_gone = await webhook_client.post( - "/api/webhook/{}".format(webhook_id), json=CALL_SERVICE - ) - - assert ensure_four_ten_gone.status == 410