From f5ed6432ebee6e4ae7ae9d7016a93e1a0603a619 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Mar 2019 19:03:49 -0800 Subject: [PATCH] Expose create/delete cloudhook (#21606) * Expose create/delete cloudhook * Make sure we dont publish cloudhooks when not connected --- homeassistant/components/cloud/__init__.py | 48 +++++++++++++- homeassistant/components/cloud/cloudhooks.py | 3 + homeassistant/components/cloud/const.py | 4 ++ homeassistant/components/cloud/iot.py | 9 ++- tests/components/cloud/test_cloudhooks.py | 26 ++++++++ tests/components/cloud/test_init.py | 69 ++++++++++++++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c427657c76d..4b1a60133db 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -6,17 +6,21 @@ import os import voluptuous as vol +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION, CONF_MODE, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv +from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util +from homeassistant.util.aiohttp import MockRequest from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c from . import http_api, iot, auth_api, prefs, cloudhooks -from .const import CONFIG_DIR, DOMAIN, SERVERS +from .const import CONFIG_DIR, DOMAIN, SERVERS, STATE_CONNECTED REQUIREMENTS = ['warrant==0.6.1'] @@ -81,6 +85,43 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +class CloudNotAvailable(HomeAssistantError): + """Raised when an action requires the cloud but it's not available.""" + + +@bind_hass +@callback +def async_is_logged_in(hass): + """Test if user is logged in.""" + return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in + + +@bind_hass +async def async_create_cloudhook(hass, webhook_id): + """Create a cloudhook.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + return await hass.data[DOMAIN].cloudhooks.async_create(webhook_id) + + +@bind_hass +async def async_delete_cloudhook(hass, webhook_id): + """Delete a cloudhook.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + return await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) + + +def is_cloudhook_request(request): + """Test if a request came from a cloudhook. + + Async friendly. + """ + return isinstance(request, MockRequest) + + async def async_setup(hass, config): """Initialize the Home Assistant cloud.""" if DOMAIN in config: @@ -152,6 +193,11 @@ class Cloud: """Get if cloud is logged in.""" return self.id_token is not None + @property + def is_connected(self): + """Get if cloud is connected.""" + return self.iot.state == STATE_CONNECTED + @property def subscription_expired(self): """Return a boolean if the subscription has expired.""" diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py index 3c638d29166..1bec3cb4b01 100644 --- a/homeassistant/components/cloud/cloudhooks.py +++ b/homeassistant/components/cloud/cloudhooks.py @@ -14,6 +14,9 @@ class Cloudhooks: async def async_publish_cloudhooks(self): """Inform the Relayer of the cloudhooks that we support.""" + if not self.cloud.is_connected: + return + cloudhooks = self.cloud.prefs.cloudhooks await self.cloud.iot.async_send_message('webhook-register', { 'cloudhook_ids': [info['cloudhook_id'] for info diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index a5019efaa8e..192ccd8ac67 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -32,3 +32,7 @@ You have been logged out of Home Assistant Cloud because we have been unable to verify your credentials. Please [log in](/config/cloud) again to continue using the service. """ + +STATE_CONNECTING = 'connecting' +STATE_CONNECTED = 'connected' +STATE_DISCONNECTED = 'disconnected' diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 055c4dbaa64..4a7215305b2 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -16,15 +16,14 @@ from homeassistant.util.aiohttp import MockRequest from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api from . import utils -from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL +from .const import ( + MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL, STATE_CONNECTED, STATE_CONNECTING, + STATE_DISCONNECTED +) HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -STATE_CONNECTING = 'connecting' -STATE_CONNECTED = 'connected' -STATE_DISCONNECTED = 'disconnected' - class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py index 9306a6c6ef3..e98b697e6ab 100644 --- a/tests/components/cloud/test_cloudhooks.py +++ b/tests/components/cloud/test_cloudhooks.py @@ -68,3 +68,29 @@ async def test_disable(mock_cloudhooks): assert publish_calls[0][1][1] == { 'cloudhook_ids': [] } + + +async def test_create_without_connected(mock_cloudhooks, aioclient_mock): + """Test we don't publish a hook if not connected.""" + mock_cloudhooks.cloud.is_connected = False + # Make sure we fail test when we send a message. + mock_cloudhooks.cloud.iot.async_send_message.side_effect = ValueError + + aioclient_mock.post('https://webhook-create.url', json={ + 'cloudhook_id': 'mock-cloud-id', + 'url': 'https://hooks.nabu.casa/ZXCZCXZ', + }) + + hook = { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + + assert hook == await mock_cloudhooks.async_create('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == { + 'mock-webhook-id': hook + } + + assert len(mock_cloudhooks.cloud.iot.async_send_message.mock_calls) == 0 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index baf6747aead..2418e091740 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch, MagicMock, mock_open import pytest +from homeassistant.setup import async_setup_component from homeassistant.components import cloud from homeassistant.util.dt import utcnow @@ -175,3 +176,71 @@ def test_subscription_not_expired(hass): patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace(year=2017, month=11, day=9)): assert not cl.subscription_expired + + +async def test_create_cloudhook_no_login(hass): + """Test create cloudhook when not logged in.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_create', return_value=coro) as mock_create, \ + pytest.raises(cloud.CloudNotAvailable): + await hass.components.cloud.async_create_cloudhook('hello') + + assert len(mock_create.mock_calls) == 0 + + +async def test_delete_cloudhook_no_login(hass): + """Test delete cloudhook when not logged in.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ + pytest.raises(cloud.CloudNotAvailable): + await hass.components.cloud.async_delete_cloudhook('hello') + + assert len(mock_delete.mock_calls) == 0 + + +async def test_create_cloudhook(hass): + """Test create cloudhook.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_create', return_value=coro) as mock_create, \ + patch('homeassistant.components.cloud.async_is_logged_in', + return_value=True): + result = await hass.components.cloud.async_create_cloudhook('hello') + + assert result == {'yo': 'hey'} + assert len(mock_create.mock_calls) == 1 + + +async def test_delete_cloudhook(hass): + """Test delete cloudhook.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ + patch('homeassistant.components.cloud.async_is_logged_in', + return_value=True): + result = await hass.components.cloud.async_delete_cloudhook('hello') + + assert result == {'yo': 'hey'} + assert len(mock_delete.mock_calls) == 1 + + +async def test_async_logged_in(hass): + """Test if is_logged_in works.""" + # Cloud not loaded + assert hass.components.cloud.async_is_logged_in() is False + + assert await async_setup_component(hass, 'cloud', {}) + + # Cloud loaded, not logged in + assert hass.components.cloud.async_is_logged_in() is False + + hass.data['cloud'].id_token = "some token" + + # Cloud loaded, logged in + assert hass.components.cloud.async_is_logged_in() is True