diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index b968850668d..183dddf2c52 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,7 +20,7 @@ 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 +from . import http_api, iot, auth_api, prefs, cloudhooks from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] @@ -37,6 +37,7 @@ CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -78,6 +79,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -113,7 +115,7 @@ class Cloud: def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, relayer=None, google_actions_sync_url=None, - subscription_info_url=None): + subscription_info_url=None, cloudhook_create_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode @@ -125,6 +127,7 @@ class Cloud: self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) + self.cloudhooks = cloudhooks.Cloudhooks(self) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -133,6 +136,7 @@ class Cloud: self.relayer = relayer self.google_actions_sync_url = google_actions_sync_url self.subscription_info_url = subscription_info_url + self.cloudhook_create_url = cloudhook_create_url else: info = SERVERS[mode] @@ -143,6 +147,7 @@ class Cloud: self.relayer = info['relayer'] self.google_actions_sync_url = info['google_actions_sync_url'] self.subscription_info_url = info['subscription_info_url'] + self.cloudhook_create_url = info['cloudhook_create_url'] @property def is_logged_in(self): diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000..13575068a3e --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,25 @@ +"""Cloud APIs.""" +from functools import wraps + +from . import auth_api + + +def _check_token(func): + """Decorate a function to verify valid token.""" + @wraps(func) + async def check_token(cloud, *args): + """Validate token, then call func.""" + await cloud.hass.async_add_executor_job(auth_api.check_token, cloud) + return await func(cloud, *args) + + return check_token + + +@_check_token +async def async_create_cloudhook(cloud): + """Create a cloudhook.""" + websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() + return await websession.post( + cloud.cloudhook_create_url, headers={ + 'authorization': cloud.id_token + }) diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py new file mode 100644 index 00000000000..fdf7bb2a12e --- /dev/null +++ b/homeassistant/components/cloud/cloudhooks.py @@ -0,0 +1,66 @@ +"""Manage cloud cloudhooks.""" +import async_timeout + +from . import cloud_api + + +class Cloudhooks: + """Class to help manage cloudhooks.""" + + def __init__(self, cloud): + """Initialize cloudhooks.""" + self.cloud = cloud + self.cloud.iot.register_on_connect(self.async_publish_cloudhooks) + + async def async_publish_cloudhooks(self): + """Inform the Relayer of the cloudhooks that we support.""" + cloudhooks = self.cloud.prefs.cloudhooks + await self.cloud.iot.async_send_message('webhook-register', { + 'cloudhook_ids': [info['cloudhook_id'] for info + in cloudhooks.values()] + }) + + async def async_create(self, webhook_id): + """Create a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id in cloudhooks: + raise ValueError('Hook is already enabled for the cloud.') + + if not self.cloud.iot.connected: + raise ValueError("Cloud is not connected") + + # Create cloud hook + with async_timeout.timeout(10): + resp = await cloud_api.async_create_cloudhook(self.cloud) + + data = await resp.json() + cloudhook_id = data['cloudhook_id'] + cloudhook_url = data['url'] + + # Store hook + cloudhooks = dict(cloudhooks) + hook = cloudhooks[webhook_id] = { + 'webhook_id': webhook_id, + 'cloudhook_id': cloudhook_id, + 'cloudhook_url': cloudhook_url + } + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() + + return hook + + async def async_delete(self, webhook_id): + """Delete a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id not in cloudhooks: + raise ValueError('Hook is not enabled for the cloud.') + + # Remove hook + cloudhooks = dict(cloudhooks) + cloudhooks.pop(webhook_id) + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index abc72da796c..01d92c6f50f 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -6,6 +6,7 @@ REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' +PREF_CLOUDHOOKS = 'cloudhooks' SERVERS = { 'production': { @@ -16,7 +17,8 @@ SERVERS = { 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' 'amazonaws.com/prod/smart_home_sync'), 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' - 'subscription_info') + 'subscription_info'), + 'cloudhook_create_url': 'https://webhook-api.nabucasa.com/generate' } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 7b509f4eae2..03a77c08d4b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ import asyncio from functools import wraps import logging +import aiohttp import async_timeout import voluptuous as vol @@ -44,6 +45,20 @@ SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create' +SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_CREATE, + vol.Required('webhook_id'): str +}) + + +WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete' +SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_DELETE, + vol.Required('webhook_id'): str +}) + + async def async_setup(hass): """Initialize the HTTP API.""" hass.components.websocket_api.async_register_command( @@ -58,6 +73,14 @@ async def async_setup(hass): WS_TYPE_UPDATE_PREFS, websocket_update_prefs, SCHEMA_WS_UPDATE_PREFS ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_CREATE, websocket_hook_create, + SCHEMA_WS_HOOK_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_DELETE, websocket_hook_delete, + SCHEMA_WS_HOOK_DELETE + ) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -76,7 +99,7 @@ _CLOUD_ERRORS = { def _handle_cloud_errors(handler): - """Handle auth errors.""" + """Webview decorator to handle auth errors.""" @wraps(handler) async def error_handler(view, request, *args, **kwargs): """Handle exceptions that raise from the wrapped request handler.""" @@ -240,17 +263,49 @@ def websocket_cloud_status(hass, connection, msg): websocket_api.result_message(msg['id'], _account_data(cloud))) +def _require_cloud_login(handler): + """Websocket decorator that requires cloud to be logged in.""" + @wraps(handler) + def with_cloud_auth(hass, connection, msg): + """Require to be logged into the cloud.""" + cloud = hass.data[DOMAIN] + if not cloud.is_logged_in: + connection.send_message(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + handler(hass, connection, msg) + + return with_cloud_auth + + +def _handle_aiohttp_errors(handler): + """Websocket decorator that handlers aiohttp errors. + + Can only wrap async handlers. + """ + @wraps(handler) + async def with_error_handling(hass, connection, msg): + """Handle aiohttp errors.""" + try: + await handler(hass, connection, msg) + except asyncio.TimeoutError: + connection.send_message(websocket_api.error_message( + msg['id'], 'timeout', 'Command timed out.')) + except aiohttp.ClientError: + connection.send_message(websocket_api.error_message( + msg['id'], 'unknown', 'Error making request.')) + + return with_error_handling + + +@_require_cloud_login @websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - if not cloud.is_logged_in: - connection.send_message(websocket_api.error_message( - msg['id'], 'not_logged_in', - 'You need to be logged in to the cloud.')) - return - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): response = await cloud.fetch_subscription_info() @@ -277,24 +332,37 @@ async def websocket_subscription(hass, connection, msg): connection.send_message(websocket_api.result_message(msg['id'], data)) +@_require_cloud_login @websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - if not cloud.is_logged_in: - connection.send_message(websocket_api.error_message( - msg['id'], 'not_logged_in', - 'You need to be logged in to the cloud.')) - return - changes = dict(msg) changes.pop('id') changes.pop('type') await cloud.prefs.async_update(**changes) - connection.send_message(websocket_api.result_message( - msg['id'], {'success': True})) + connection.send_message(websocket_api.result_message(msg['id'])) + + +@_require_cloud_login +@websocket_api.async_response +@_handle_aiohttp_errors +async def websocket_hook_create(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'], hook)) + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_hook_delete(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + await cloud.cloudhooks.async_delete(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'])) def _account_data(cloud): diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index c5657ae9729..3c7275afa7a 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -2,13 +2,16 @@ import asyncio import logging import pprint +import uuid from aiohttp import hdrs, client_exceptions, WSMsgType from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.alexa import smart_home as alexa from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.core import callback from homeassistant.util.decorator import Registry +from homeassistant.util.aiohttp import MockRequest, serialize_response from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL @@ -25,6 +28,15 @@ class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" +class ErrorMessage(Exception): + """Exception raised when there was error handling message in the cloud.""" + + def __init__(self, error): + """Initialize Error Message.""" + super().__init__(self, "Error in Cloud") + self.error = error + + class CloudIoT: """Class to manage the IoT connection.""" @@ -41,6 +53,19 @@ class CloudIoT: self.tries = 0 # Current state of the connection self.state = STATE_DISCONNECTED + # Local code waiting for a response + self._response_handler = {} + self._on_connect = [] + + @callback + def register_on_connect(self, on_connect_cb): + """Register an async on_connect callback.""" + self._on_connect.append(on_connect_cb) + + @property + def connected(self): + """Return if we're currently connected.""" + return self.state == STATE_CONNECTED @asyncio.coroutine def connect(self): @@ -91,6 +116,20 @@ class CloudIoT: if remove_hass_stop_listener is not None: remove_hass_stop_listener() + async def async_send_message(self, handler, payload): + """Send a message.""" + msgid = uuid.uuid4().hex + self._response_handler[msgid] = asyncio.Future() + message = { + 'msgid': msgid, + 'handler': handler, + 'payload': payload, + } + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(message)) + await self.client.send_json(message) + @asyncio.coroutine def _handle_connection(self): """Connect to the IoT broker.""" @@ -134,6 +173,9 @@ class CloudIoT: _LOGGER.info("Connected") self.state = STATE_CONNECTED + if self._on_connect: + yield from asyncio.wait([cb() for cb in self._on_connect]) + while not client.closed: msg = yield from client.receive() @@ -159,6 +201,17 @@ class CloudIoT: _LOGGER.debug("Received message:\n%s\n", pprint.pformat(msg)) + response_handler = self._response_handler.pop(msg['msgid'], + None) + + if response_handler is not None: + if 'payload' in msg: + response_handler.set_result(msg["payload"]) + else: + response_handler.set_exception( + ErrorMessage(msg['error'])) + continue + response = { 'msgid': msg['msgid'], } @@ -257,3 +310,43 @@ def async_handle_cloud(hass, cloud, payload): payload['reason']) else: _LOGGER.warning("Received unknown cloud action: %s", action) + + +@HANDLERS.register('webhook') +async def async_handle_webhook(hass, cloud, payload): + """Handle an incoming IoT message for cloud webhooks.""" + cloudhook_id = payload['cloudhook_id'] + + found = None + for cloudhook in cloud.prefs.cloudhooks.values(): + if cloudhook['cloudhook_id'] == cloudhook_id: + found = cloudhook + break + + if found is None: + return { + 'status': 200 + } + + request = MockRequest( + content=payload['body'].encode('utf-8'), + headers=payload['headers'], + method=payload['method'], + query_string=payload['query'], + ) + + response = await hass.components.webhook.async_handle_webhook( + found['webhook_id'], request) + + response_dict = serialize_response(response) + body = response_dict.get('body') + if body: + body = body.decode('utf-8') + + return { + 'body': body, + 'status': response_dict['status'], + 'headers': { + 'Content-Type': response.content_type + } + } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index d29b356cfc0..b2ed83fc6b2 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,7 @@ """Preference management for cloud.""" from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK) + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -26,17 +26,20 @@ class CloudPreferences: PREF_ENABLE_ALEXA: logged_in, PREF_ENABLE_GOOGLE: logged_in, PREF_GOOGLE_ALLOW_UNLOCK: False, + PREF_CLOUDHOOKS: {} } self._prefs = prefs async def async_update(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF): + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF, + cloudhooks=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + (PREF_CLOUDHOOKS, cloudhooks), ): if value is not _UNDEF: self._prefs[key] = value @@ -61,3 +64,8 @@ class CloudPreferences: def google_allow_unlock(self): """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) + + @property + def cloudhooks(self): + """Return the published cloud webhooks.""" + return self._prefs.get(PREF_CLOUDHOOKS, {}) diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index ad23ba6f544..6742f33c72d 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -62,6 +62,28 @@ def async_generate_url(hass, webhook_id): return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) +@bind_hass +async def async_handle_webhook(hass, webhook_id, request): + """Handle a webhook.""" + handlers = hass.data.setdefault(DOMAIN, {}) + webhook = handlers.get(webhook_id) + + # Always respond successfully to not give away if a hook exists or not. + if webhook is None: + _LOGGER.warning( + 'Received message for unregistered webhook %s', webhook_id) + return Response(status=200) + + try: + response = await webhook['handler'](hass, webhook_id, request) + if response is None: + response = Response(status=200) + return response + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error processing webhook %s", webhook_id) + return Response(status=200) + + async def async_setup(hass, config): """Initialize the webhook component.""" hass.http.register_view(WebhookView) @@ -82,23 +104,7 @@ class WebhookView(HomeAssistantView): async def post(self, request, webhook_id): """Handle webhook call.""" hass = request.app['hass'] - handlers = hass.data.setdefault(DOMAIN, {}) - webhook = handlers.get(webhook_id) - - # Always respond successfully to not give away if a hook exists or not. - if webhook is None: - _LOGGER.warning( - 'Received message for unregistered webhook %s', webhook_id) - return Response(status=200) - - try: - response = await webhook['handler'](hass, webhook_id, request) - if response is None: - response = Response(status=200) - return response - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error processing webhook %s", webhook_id) - return Response(status=200) + return await async_handle_webhook(hass, webhook_id, request) @callback diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py new file mode 100644 index 00000000000..d648ed43110 --- /dev/null +++ b/homeassistant/util/aiohttp.py @@ -0,0 +1,53 @@ +"""Utilities to help with aiohttp.""" +import json +from urllib.parse import parse_qsl +from typing import Any, Dict, Optional + +from aiohttp import web +from multidict import CIMultiDict, MultiDict + + +class MockRequest: + """Mock an aiohttp request.""" + + def __init__(self, content: bytes, method: str = 'GET', + status: int = 200, headers: Optional[Dict[str, str]] = None, + query_string: Optional[str] = None, url: str = '') -> None: + """Initialize a request.""" + self.method = method + self.url = url + self.status = status + self.headers = CIMultiDict(headers or {}) # type: CIMultiDict[str] + self.query_string = query_string or '' + self._content = content + + @property + def query(self) -> 'MultiDict[str]': + """Return a dictionary with the query variables.""" + return MultiDict(parse_qsl(self.query_string, keep_blank_values=True)) + + @property + def _text(self) -> str: + """Return the body as text.""" + return self._content.decode('utf-8') + + async def json(self) -> Any: + """Return the body as JSON.""" + return json.loads(self._text) + + async def post(self) -> 'MultiDict[str]': + """Return POST parameters.""" + return MultiDict(parse_qsl(self._text, keep_blank_values=True)) + + async def text(self) -> str: + """Return the body as text.""" + return self._text + + +def serialize_response(response: web.Response) -> Dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + return { + 'status': response.status, + 'body': response.body, + 'headers': dict(response.headers), + } diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 00000000000..0ddb8ecce50 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,33 @@ +"""Test cloud API.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.cloud import cloud_api + + +@pytest.fixture(autouse=True) +def mock_check_token(): + """Mock check token.""" + with patch('homeassistant.components.cloud.auth_api.' + 'check_token') as mock_check_token: + yield mock_check_token + + +async def test_create_cloudhook(hass, aioclient_mock): + """Test creating a cloudhook.""" + aioclient_mock.post('https://example.com/bla', json={ + 'cloudhook_id': 'mock-webhook', + 'url': 'https://blabla' + }) + cloud = Mock( + hass=hass, + id_token='mock-id-token', + cloudhook_create_url='https://example.com/bla', + ) + resp = await cloud_api.async_create_cloudhook(cloud) + assert len(aioclient_mock.mock_calls) == 1 + assert await resp.json() == { + 'cloudhook_id': 'mock-webhook', + 'url': 'https://blabla' + } diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py new file mode 100644 index 00000000000..b65046331a7 --- /dev/null +++ b/tests/components/cloud/test_cloudhooks.py @@ -0,0 +1,70 @@ +"""Test cloud cloudhooks.""" +from unittest.mock import Mock + +import pytest + +from homeassistant.components.cloud import prefs, cloudhooks + +from tests.common import mock_coro + + +@pytest.fixture +def mock_cloudhooks(hass): + """Mock cloudhooks class.""" + cloud = Mock() + cloud.hass = hass + cloud.hass.async_add_executor_job = Mock(return_value=mock_coro()) + cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro())) + cloud.cloudhook_create_url = 'https://webhook-create.url' + cloud.prefs = prefs.CloudPreferences(hass) + hass.loop.run_until_complete(cloud.prefs.async_initialize(True)) + return cloudhooks.Cloudhooks(cloud) + + +async def test_enable(mock_cloudhooks, aioclient_mock): + """Test enabling cloudhooks.""" + 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 + } + + publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls + assert len(publish_calls) == 1 + assert publish_calls[0][1][0] == 'webhook-register' + assert publish_calls[0][1][1] == { + 'cloudhook_ids': ['mock-cloud-id'] + } + + +async def test_disable(mock_cloudhooks): + """Test disabling cloudhooks.""" + mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = { + 'mock-webhook-id': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + } + + await mock_cloudhooks.async_delete('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == {} + + publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls + assert len(publish_calls) == 1 + assert publish_calls[0][1][0] == 'webhook-register' + assert publish_calls[0][1][1] == { + 'cloudhook_ids': [] + } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 4abf5b8501d..57e92ba7628 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -527,3 +527,45 @@ async def test_websocket_update_preferences(hass, hass_ws_client, assert not setup_api[PREF_ENABLE_GOOGLE] assert not setup_api[PREF_ENABLE_ALEXA] assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + + +async def test_enabling_webhook(hass, hass_ws_client, setup_api): + """Test we call right code to enable webhooks.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' + '.async_create', return_value=mock_coro()) as mock_enable: + await client.send_json({ + 'id': 5, + 'type': 'cloud/cloudhook/create', + 'webhook_id': 'mock-webhook-id', + }) + response = await client.receive_json() + assert response['success'] + + assert len(mock_enable.mock_calls) == 1 + assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id' + + +async def test_disabling_webhook(hass, hass_ws_client, setup_api): + """Test we call right code to disable webhooks.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' + '.async_delete', return_value=mock_coro()) as mock_disable: + await client.send_json({ + 'id': 5, + 'type': 'cloud/cloudhook/delete', + 'webhook_id': 'mock-webhook-id', + }) + response = await client.receive_json() + assert response['success'] + + assert len(mock_disable.mock_calls) == 1 + assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 44d56566f75..baf6747aead 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -30,7 +30,8 @@ def test_constructor_loads_info_from_constant(): 'region': 'test-region', 'relayer': 'test-relayer', 'google_actions_sync_url': 'test-google_actions_sync_url', - 'subscription_info_url': 'test-subscription-info-url' + 'subscription_info_url': 'test-subscription-info-url', + 'cloudhook_create_url': 'test-cloudhook_create_url', } }): result = yield from cloud.async_setup(hass, { @@ -46,6 +47,7 @@ def test_constructor_loads_info_from_constant(): assert cl.relayer == 'test-relayer' assert cl.google_actions_sync_url == 'test-google_actions_sync_url' assert cl.subscription_info_url == 'test-subscription-info-url' + assert cl.cloudhook_create_url == 'test-cloudhook_create_url' @asyncio.coroutine diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index c900fc3a7a8..10488779dd8 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import patch, MagicMock, PropertyMock -from aiohttp import WSMsgType, client_exceptions +from aiohttp import WSMsgType, client_exceptions, web import pytest from homeassistant.setup import async_setup_component @@ -406,3 +406,48 @@ async def test_refresh_token_expired(hass): assert len(mock_check_token.mock_calls) == 1 assert len(mock_create.mock_calls) == 1 + + +async def test_webhook_msg(hass): + """Test webhook msg.""" + cloud = Cloud(hass, MODE_DEV, None, None) + await cloud.prefs.async_initialize(True) + await cloud.prefs.async_update(cloudhooks={ + 'hello': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id' + } + }) + + received = [] + + async def handler(hass, webhook_id, request): + """Handle a webhook.""" + received.append(request) + return web.json_response({'from': 'handler'}) + + hass.components.webhook.async_register( + 'test', 'Test', 'mock-webhook-id', handler) + + response = await iot.async_handle_webhook(hass, cloud, { + 'cloudhook_id': 'mock-cloud-id', + 'body': '{"hello": "world"}', + 'headers': { + 'content-type': 'application/json' + }, + 'method': 'POST', + 'query': None, + }) + + assert response == { + 'status': 200, + 'body': '{"from": "handler"}', + 'headers': { + 'Content-Type': 'application/json' + } + } + + assert len(received) == 1 + assert await received[0].json() == { + 'hello': 'world' + } diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py new file mode 100644 index 00000000000..8f528376cce --- /dev/null +++ b/tests/util/test_aiohttp.py @@ -0,0 +1,54 @@ +"""Test aiohttp request helper.""" +from aiohttp import web + +from homeassistant.util import aiohttp + + +async def test_request_json(): + """Test a JSON request.""" + request = aiohttp.MockRequest(b'{"hello": 2}') + assert request.status == 200 + assert await request.json() == { + 'hello': 2 + } + + +async def test_request_text(): + """Test a JSON request.""" + request = aiohttp.MockRequest(b'hello', status=201) + assert request.status == 201 + assert await request.text() == 'hello' + + +async def test_request_post_query(): + """Test a JSON request.""" + request = aiohttp.MockRequest( + b'hello=2&post=true', query_string='get=true', method='POST') + assert request.method == 'POST' + assert await request.post() == { + 'hello': '2', + 'post': 'true' + } + assert request.query == { + 'get': 'true' + } + + +def test_serialize_text(): + """Test serializing a text response.""" + response = web.Response(status=201, text='Hello') + assert aiohttp.serialize_response(response) == { + 'status': 201, + 'body': b'Hello', + 'headers': {'Content-Type': 'text/plain; charset=utf-8'}, + } + + +def test_serialize_json(): + """Test serializing a JSON response.""" + response = web.json_response({"how": "what"}) + assert aiohttp.serialize_response(response) == { + 'status': 200, + 'body': b'{"how": "what"}', + 'headers': {'Content-Type': 'application/json; charset=utf-8'}, + }