From ad8af5fc7a52a66a584bc31c535f100fb7c71919 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jan 2022 11:02:41 -0800 Subject: [PATCH] Allow mobile app registrations only supporting websocket push (#63208) --- CODEOWNERS | 4 +- homeassistant/components/mobile_app/const.py | 16 +++++ .../components/mobile_app/http_api.py | 3 +- .../components/mobile_app/manifest.json | 2 +- homeassistant/components/mobile_app/notify.py | 35 ++++++---- homeassistant/components/mobile_app/util.py | 5 +- .../components/mobile_app/webhook.py | 3 +- tests/common.py | 7 +- tests/components/mobile_app/test_notify.py | 69 +++++++++++++++++++ 9 files changed, 124 insertions(+), 20 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f2c90a63746..000c21f388c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -551,8 +551,8 @@ homeassistant/components/minecraft_server/* @elmurato tests/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan tests/components/minio/* @tkislan -homeassistant/components/mobile_app/* @robbiet480 -tests/components/mobile_app/* @robbiet480 +homeassistant/components/mobile_app/* @home-assistant/core +tests/components/mobile_app/* @home-assistant/core homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik tests/components/modbus/* @adamchengtkc @janiversen @vzahradnik homeassistant/components/modem_callerid/* @tkdrob diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 2f5db21b815..a2a4e15ee72 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -1,4 +1,8 @@ """Constants for mobile_app.""" +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + DOMAIN = "mobile_app" STORAGE_KEY = DOMAIN @@ -26,6 +30,7 @@ ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" ATTR_OS_NAME = "os_name" ATTR_OS_VERSION = "os_version" +ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel" ATTR_PUSH_TOKEN = "push_token" ATTR_PUSH_URL = "push_url" ATTR_PUSH_RATE_LIMITS = "rateLimits" @@ -76,3 +81,14 @@ SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update" SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}" ATTR_CAMERA_ENTITY_ID = "camera_entity_id" + +SCHEMA_APP_DATA = vol.Schema( + { + vol.Inclusive(ATTR_PUSH_TOKEN, "push_cloud"): cv.string, + vol.Inclusive(ATTR_PUSH_URL, "push_cloud"): cv.url, + # Set to True to indicate that this registration will connect via websocket channel + # to receive push notifications. + vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, +) diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 05b370c711d..b817e99493f 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -32,6 +32,7 @@ from .const import ( CONF_SECRET, CONF_USER_ID, DOMAIN, + SCHEMA_APP_DATA, ) from .helpers import supports_encryption @@ -45,7 +46,7 @@ class RegistrationsView(HomeAssistantView): @RequestDataValidator( vol.Schema( { - vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Optional(ATTR_APP_DATA, default={}): SCHEMA_APP_DATA, vol.Required(ATTR_APP_ID): cv.string, vol.Required(ATTR_APP_NAME): cv.string, vol.Required(ATTR_APP_VERSION): cv.string, diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index 253ff16a34e..ea1a24f6643 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -6,7 +6,7 @@ "requirements": ["PyNaCl==1.4.0", "emoji==1.5.0"], "dependencies": ["http", "webhook", "person", "tag", "websocket_api"], "after_dependencies": ["cloud", "camera", "notify"], - "codeowners": ["@robbiet480"], + "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push" } diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index e1dada100f9..beca42aab7e 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -15,6 +15,7 @@ from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, BaseNotificationService, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.util.dt as dt_util @@ -118,20 +119,28 @@ class MobileAppNotificationService(BaseNotificationService): local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL] for target in targets: + registration = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target].data + if target in local_push_channels: local_push_channels[target].async_send_notification( - data, partial(self._async_send_remote_message_target, target) + data, + partial( + self._async_send_remote_message_target, target, registration + ), ) continue - await self._async_send_remote_message_target(target, data) + # Test if local push only. + if ATTR_PUSH_URL not in registration[ATTR_APP_DATA]: + raise HomeAssistantError( + "Device not connected to local push notifications" + ) - async def _async_send_remote_message_target(self, target, data): + await self._async_send_remote_message_target(target, registration, data) + + async def _async_send_remote_message_target(self, target, registration, data): """Send a message to a target.""" - entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] - entry_data = entry.data - - app_data = entry_data[ATTR_APP_DATA] + app_data = registration[ATTR_APP_DATA] push_token = app_data[ATTR_PUSH_TOKEN] push_url = app_data[ATTR_PUSH_URL] @@ -139,12 +148,12 @@ class MobileAppNotificationService(BaseNotificationService): target_data[ATTR_PUSH_TOKEN] = push_token reg_info = { - ATTR_APP_ID: entry_data[ATTR_APP_ID], - ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION], + ATTR_APP_ID: registration[ATTR_APP_ID], + ATTR_APP_VERSION: registration[ATTR_APP_VERSION], ATTR_WEBHOOK_ID: target, } - if ATTR_OS_VERSION in entry_data: - reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION] + if ATTR_OS_VERSION in registration: + reg_info[ATTR_OS_VERSION] = registration[ATTR_OS_VERSION] target_data["registration_info"] = reg_info @@ -160,7 +169,7 @@ class MobileAppNotificationService(BaseNotificationService): HTTPStatus.CREATED, HTTPStatus.ACCEPTED, ): - log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) + log_rate_limits(self.hass, registration[ATTR_DEVICE_NAME], result) return fallback_error = result.get("errorMessage", "Unknown error") @@ -177,7 +186,7 @@ class MobileAppNotificationService(BaseNotificationService): if response.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.warning(message) log_rate_limits( - self.hass, entry_data[ATTR_DEVICE_NAME], result, logging.WARNING + self.hass, registration[ATTR_DEVICE_NAME], result, logging.WARNING ) else: _LOGGER.error(message) diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py index cd4b7c22939..1f1715eab67 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -9,6 +9,7 @@ from .const import ( ATTR_APP_DATA, ATTR_PUSH_TOKEN, ATTR_PUSH_URL, + ATTR_PUSH_WEBSOCKET_CHANNEL, DATA_CONFIG_ENTRIES, DATA_DEVICES, DATA_NOTIFY, @@ -37,7 +38,9 @@ def supports_push(hass, webhook_id: str) -> bool: """Return if push notifications is supported.""" config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] app_data = config_entry.data[ATTR_APP_DATA] - return ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data + return ( + ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data + ) or ATTR_PUSH_WEBSOCKET_CHANNEL in app_data @callback diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index ebba383636b..81cc2fe8078 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -91,6 +91,7 @@ from .const import ( ERR_ENCRYPTION_REQUIRED, ERR_INVALID_FORMAT, ERR_SENSOR_NOT_REGISTERED, + SCHEMA_APP_DATA, SIGNAL_LOCATION_UPDATE, SIGNAL_SENSOR_UPDATE, ) @@ -332,7 +333,7 @@ async def webhook_update_location(hass, config_entry, data): @WEBHOOK_COMMANDS.register("update_registration") @validate_schema( { - vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Optional(ATTR_APP_DATA): SCHEMA_APP_DATA, vol.Required(ATTR_APP_VERSION): cv.string, vol.Required(ATTR_DEVICE_NAME): cv.string, vol.Required(ATTR_MANUFACTURER): cv.string, diff --git a/tests/common.py b/tests/common.py index 73b67a63ebc..b0cc0a3f282 100644 --- a/tests/common.py +++ b/tests/common.py @@ -286,7 +286,12 @@ async def async_test_home_assistant(loop, load_registries=True): hass.config.media_dirs = {"local": get_test_config_dir("media")} hass.config.skip_pip = True - hass.config_entries = config_entries.ConfigEntries(hass, {}) + hass.config_entries = config_entries.ConfigEntries( + hass, + { + "_": "Not empty or else some bad checks for hass config in discovery.py breaks" + }, + ) hass.config_entries._entries = {} hass.config_entries._store._async_ensure_stop_listener = lambda: None diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 86cc9f0ae67..001da68dfbf 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components.mobile_app.const import DOMAIN +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -102,6 +103,38 @@ async def setup_push_receiver(hass, aioclient_mock, hass_admin_user): assert hass.services.has_service("notify", "mobile_app_loaded_late") +@pytest.fixture +async def setup_websocket_channel_only_push(hass, hass_admin_user): + """Set up local push.""" + entry = MockConfigEntry( + data={ + "app_data": {"push_websocket_channel": True}, + "app_id": "io.homeassistant.mobile_app", + "app_name": "mobile_app tests", + "app_version": "1.0", + "device_id": "websocket-push-device-id", + "device_name": "Websocket Push Name", + "manufacturer": "Home Assistant", + "model": "mobile_app", + "os_name": "Linux", + "os_version": "5.0.6", + "secret": "123abc2", + "supports_encryption": False, + "user_id": hass_admin_user.id, + "webhook_id": "websocket-push-webhook-id", + }, + domain=DOMAIN, + source="registration", + title="websocket push test entry", + version=1, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "mobile_app_websocket_push_name") + + async def test_notify_works(hass, aioclient_mock, setup_push_receiver): """Test notify works.""" assert hass.services.has_service("notify", "mobile_app_test") is True @@ -333,3 +366,39 @@ async def test_notify_ws_not_confirming( ) assert len(aioclient_mock.mock_calls) == 3 + + +async def test_local_push_only(hass, hass_ws_client, setup_websocket_channel_only_push): + """Test a local only push registration.""" + with pytest.raises(HomeAssistantError) as e_info: + assert await hass.services.async_call( + "notify", + "mobile_app_websocket_push_name", + {"message": "Not connected"}, + blocking=True, + ) + + assert str(e_info.value) == "Device not connected to local push notifications" + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "mobile_app/push_notification_channel", + "webhook_id": "websocket-push-webhook-id", + } + ) + + sub_result = await client.receive_json() + assert sub_result["success"] + + assert await hass.services.async_call( + "notify", + "mobile_app_websocket_push_name", + {"message": "Hello world 1"}, + blocking=True, + ) + + msg = await client.receive_json() + assert msg == {"id": 5, "type": "event", "event": {"message": "Hello world 1"}}