From 3526fd66df8301daef2c686cb991c63e2cb046e6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 15 Feb 2024 17:26:06 +0100 Subject: [PATCH] Add option to block remote enabling of HA Cloud remote (#109700) * Allow blocking remote enabling of HA Cloud remote * Fix test --- homeassistant/components/cloud/client.py | 5 +++- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 2 ++ homeassistant/components/cloud/prefs.py | 11 ++++++++ tests/components/cloud/test_client.py | 23 ++++++++++++--- tests/components/cloud/test_http_api.py | 33 ++++++++++++++++++++++ 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index ea85821a0a9..463d290d49c 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Literal import aiohttp -from hass_nabucasa.client import CloudClient as Interface +from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed from homeassistant.components import google_assistant, persistent_notification, webhook from homeassistant.components.alexa import ( @@ -234,6 +234,8 @@ class CloudClient(Interface): async def async_cloud_connect_update(self, connect: bool) -> None: """Process cloud remote message to client.""" + if not self._prefs.remote_allow_remote_enable: + raise RemoteActivationNotAllowed await self._prefs.async_update(remote_enabled=connect) async def async_cloud_connection_info( @@ -242,6 +244,7 @@ class CloudClient(Interface): """Process cloud connection info message to client.""" return { "remote": { + "can_enable": self._prefs.remote_allow_remote_enable, "connected": self.cloud.remote.is_connected, "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 3b8cfb08593..f704fb61f69 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -31,6 +31,7 @@ PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" +PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 7c7b1328408..4fd9d5c0301 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -44,6 +44,7 @@ from .const import ( PREF_ENABLE_GOOGLE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) @@ -408,6 +409,7 @@ async def websocket_subscription( vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) ), + vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, } ) @websocket_api.async_response diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 1664966f985..010a9697f26 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -39,6 +39,7 @@ from .const import ( PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, PREF_INSTANCE_ID, + PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, @@ -153,6 +154,7 @@ class CloudPreferences: alexa_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED, + remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -171,6 +173,7 @@ class CloudPreferences: (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), (PREF_GOOGLE_CONNECTED, google_connected), + (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), ): if value is not UNDEFINED: prefs[key] = value @@ -212,9 +215,16 @@ class CloudPreferences: PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, } + @property + def remote_allow_remote_enable(self) -> bool: + """Return if it's allowed to remotely activate remote.""" + allowed: bool = self._prefs.get(PREF_REMOTE_ALLOW_REMOTE_ENABLE, True) + return allowed + @property def remote_enabled(self) -> bool: """Return if remote is enabled on start.""" @@ -375,5 +385,6 @@ class CloudPreferences: PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, + PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, } diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 4284a11c94a..66a5ed8e4ad 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from aiohttp import web +from hass_nabucasa.client import RemoteActivationNotAllowed import pytest from homeassistant.components.cloud import DOMAIN @@ -376,14 +377,15 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: response = await cloud.client.async_cloud_connection_info({}) assert response == { + "instance_id": "12345678901234567890", "remote": { + "alias": None, + "can_enable": True, "connected": False, "enabled": False, "instance_domain": None, - "alias": None, }, "version": HA_VERSION, - "instance_id": "12345678901234567890", } @@ -481,6 +483,19 @@ async def test_remote_enable(hass: HomeAssistant) -> None: client = CloudClient(hass, prefs, None, {}, {}) client.cloud = MagicMock(is_logged_in=True, username="mock-username") - result = await client.async_cloud_connect_update(True) - assert result is None + await client.async_cloud_connect_update(True) prefs.async_update.assert_called_once_with(remote_enabled=True) + + +async def test_remote_enable_not_allowed(hass: HomeAssistant) -> None: + """Test enabling remote UI.""" + prefs = MagicMock( + async_update=AsyncMock(return_value=None), + remote_allow_remote_enable=False, + ) + client = CloudClient(hass, prefs, None, {}, {}) + client.cloud = MagicMock(is_logged_in=True, username="mock-username") + + with pytest.raises(RemoteActivationNotAllowed): + await client.async_cloud_connect_update(True) + prefs.async_update.assert_not_called() diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 78b06874d6d..8c93c1251d9 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -734,6 +734,7 @@ async def test_websocket_status( "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_report_state": True, "google_report_state": True, + "remote_allow_remote_enable": True, "remote_enabled": False, "tts_default_voice": ["en-US", "female"], }, @@ -853,6 +854,7 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.google_enabled assert cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin is None + assert cloud.client.prefs.remote_allow_remote_enable is True client = await hass_ws_client(hass) @@ -864,6 +866,7 @@ async def test_websocket_update_preferences( "google_enabled": False, "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "male"], + "remote_allow_remote_enable": False, } ) response = await client.receive_json() @@ -872,6 +875,7 @@ async def test_websocket_update_preferences( assert not cloud.client.prefs.google_enabled assert not cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin == "1234" + assert cloud.client.prefs.remote_allow_remote_enable is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "male") @@ -1032,6 +1036,35 @@ async def test_enabling_remote( assert mock_disconnect.call_count == 1 +async def test_enabling_remote_remote_activation_not_allowed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, +) -> None: + """Test we can enable remote UI locally when blocked remotely.""" + client = await hass_ws_client(hass) + mock_connect = cloud.remote.connect + assert not cloud.client.remote_autostart + cloud.client.prefs.async_update(remote_allow_remote_enable=False) + + await client.send_json({"id": 5, "type": "cloud/remote/connect"}) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.remote_autostart + assert mock_connect.call_count == 1 + + mock_disconnect = cloud.remote.disconnect + + await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) + response = await client.receive_json() + + assert response["success"] + assert not cloud.client.remote_autostart + assert mock_disconnect.call_count == 1 + + async def test_list_google_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry,