Add option to block remote enabling of HA Cloud remote (#109700)

* Allow blocking remote enabling of HA Cloud remote

* Fix test
This commit is contained in:
Erik Montnemery 2024-02-15 17:26:06 +01:00 committed by GitHub
parent 619e7fbbce
commit 3526fd66df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 70 additions and 5 deletions

View file

@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
import aiohttp 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 import google_assistant, persistent_notification, webhook
from homeassistant.components.alexa import ( from homeassistant.components.alexa import (
@ -234,6 +234,8 @@ class CloudClient(Interface):
async def async_cloud_connect_update(self, connect: bool) -> None: async def async_cloud_connect_update(self, connect: bool) -> None:
"""Process cloud remote message to client.""" """Process cloud remote message to client."""
if not self._prefs.remote_allow_remote_enable:
raise RemoteActivationNotAllowed
await self._prefs.async_update(remote_enabled=connect) await self._prefs.async_update(remote_enabled=connect)
async def async_cloud_connection_info( async def async_cloud_connection_info(
@ -242,6 +244,7 @@ class CloudClient(Interface):
"""Process cloud connection info message to client.""" """Process cloud connection info message to client."""
return { return {
"remote": { "remote": {
"can_enable": self._prefs.remote_allow_remote_enable,
"connected": self.cloud.remote.is_connected, "connected": self.cloud.remote.is_connected,
"enabled": self._prefs.remote_enabled, "enabled": self._prefs.remote_enabled,
"instance_domain": self.cloud.remote.instance_domain, "instance_domain": self.cloud.remote.instance_domain,

View file

@ -31,6 +31,7 @@ PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version"
PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
PREF_GOOGLE_CONNECTED = "google_connected" PREF_GOOGLE_CONNECTED = "google_connected"
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True DEFAULT_ALEXA_REPORT_STATE = True

View file

@ -44,6 +44,7 @@ from .const import (
PREF_ENABLE_GOOGLE, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_TTS_DEFAULT_VOICE, PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
) )
@ -408,6 +409,7 @@ async def websocket_subscription(
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), vol.In(MAP_VOICE) vol.Coerce(tuple), vol.In(MAP_VOICE)
), ),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
} }
) )
@websocket_api.async_response @websocket_api.async_response

View file

@ -39,6 +39,7 @@ from .const import (
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_SETTINGS_VERSION,
PREF_INSTANCE_ID, PREF_INSTANCE_ID,
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_REMOTE_DOMAIN, PREF_REMOTE_DOMAIN,
PREF_TTS_DEFAULT_VOICE, PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME, PREF_USERNAME,
@ -153,6 +154,7 @@ class CloudPreferences:
alexa_settings_version: int | UndefinedType = UNDEFINED, alexa_settings_version: int | UndefinedType = UNDEFINED,
google_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
) -> None: ) -> None:
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs} prefs = {**self._prefs}
@ -171,6 +173,7 @@ class CloudPreferences:
(PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_TTS_DEFAULT_VOICE, tts_default_voice),
(PREF_REMOTE_DOMAIN, remote_domain), (PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected), (PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
): ):
if value is not UNDEFINED: if value is not UNDEFINED:
prefs[key] = value prefs[key] = value
@ -212,9 +215,16 @@ class CloudPreferences:
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_REPORT_STATE: self.google_report_state,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, 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, 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 @property
def remote_enabled(self) -> bool: def remote_enabled(self) -> bool:
"""Return if remote is enabled on start.""" """Return if remote is enabled on start."""
@ -375,5 +385,6 @@ class CloudPreferences:
PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_INSTANCE_ID: uuid.uuid4().hex,
PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_REMOTE_DOMAIN: None, PREF_REMOTE_DOMAIN: None,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
PREF_USERNAME: username, PREF_USERNAME: username,
} }

View file

@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
from hass_nabucasa.client import RemoteActivationNotAllowed
import pytest import pytest
from homeassistant.components.cloud import DOMAIN 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({}) response = await cloud.client.async_cloud_connection_info({})
assert response == { assert response == {
"instance_id": "12345678901234567890",
"remote": { "remote": {
"alias": None,
"can_enable": True,
"connected": False, "connected": False,
"enabled": False, "enabled": False,
"instance_domain": None, "instance_domain": None,
"alias": None,
}, },
"version": HA_VERSION, "version": HA_VERSION,
"instance_id": "12345678901234567890",
} }
@ -481,6 +483,19 @@ async def test_remote_enable(hass: HomeAssistant) -> None:
client = CloudClient(hass, prefs, None, {}, {}) client = CloudClient(hass, prefs, None, {}, {})
client.cloud = MagicMock(is_logged_in=True, username="mock-username") client.cloud = MagicMock(is_logged_in=True, username="mock-username")
result = await client.async_cloud_connect_update(True) await client.async_cloud_connect_update(True)
assert result is None
prefs.async_update.assert_called_once_with(remote_enabled=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()

View file

@ -734,6 +734,7 @@ async def test_websocket_status(
"alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS,
"alexa_report_state": True, "alexa_report_state": True,
"google_report_state": True, "google_report_state": True,
"remote_allow_remote_enable": True,
"remote_enabled": False, "remote_enabled": False,
"tts_default_voice": ["en-US", "female"], "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.google_enabled
assert cloud.client.prefs.alexa_enabled assert cloud.client.prefs.alexa_enabled
assert cloud.client.prefs.google_secure_devices_pin is None 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) client = await hass_ws_client(hass)
@ -864,6 +866,7 @@ async def test_websocket_update_preferences(
"google_enabled": False, "google_enabled": False,
"google_secure_devices_pin": "1234", "google_secure_devices_pin": "1234",
"tts_default_voice": ["en-GB", "male"], "tts_default_voice": ["en-GB", "male"],
"remote_allow_remote_enable": False,
} }
) )
response = await client.receive_json() 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.google_enabled
assert not cloud.client.prefs.alexa_enabled assert not cloud.client.prefs.alexa_enabled
assert cloud.client.prefs.google_secure_devices_pin == "1234" 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") 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 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( async def test_list_google_entities(
hass: HomeAssistant, hass: HomeAssistant,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,