"""Test the cloud.iot module.""" import asyncio from unittest.mock import patch, MagicMock, PropertyMock from aiohttp import WSMsgType, client_exceptions, web import pytest from homeassistant.setup import async_setup_component from homeassistant.components.cloud import ( Cloud, iot, auth_api, MODE_DEV) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from homeassistant.util import dt as dt_util from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro, async_fire_time_changed from . import mock_cloud_prefs @pytest.fixture def mock_client(): """Mock the IoT client.""" client = MagicMock() type(client).closed = PropertyMock(side_effect=[False, True]) # Trigger cancelled error to avoid reconnect. with patch('asyncio.sleep', side_effect=asyncio.CancelledError), \ patch('homeassistant.components.cloud.iot' '.async_get_clientsession') as session: session().ws_connect.return_value = mock_coro(client) yield client @pytest.fixture def mock_handle_message(): """Mock handle message.""" with patch('homeassistant.components.cloud.iot' '.async_handle_message') as mock: yield mock @pytest.fixture def mock_cloud(): """Mock cloud class.""" return MagicMock(subscription_expired=False) @asyncio.coroutine def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): """Test we call handle message with correct info.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ 'msgid': 'test-msg-id', 'handler': 'test-handler', 'payload': 'test-payload' }) )) mock_handle_message.return_value = mock_coro('response') mock_client.send_json.return_value = mock_coro(None) yield from conn.connect() # Check that we sent message to handler correctly assert len(mock_handle_message.mock_calls) == 1 p_hass, p_cloud, handler_name, payload = \ mock_handle_message.mock_calls[0][1] assert p_hass is mock_cloud.hass assert p_cloud is mock_cloud assert handler_name == 'test-handler' assert payload == 'test-payload' # Check that we forwarded response from handler to cloud assert len(mock_client.send_json.mock_calls) == 1 assert mock_client.send_json.mock_calls[0][1][0] == { 'msgid': 'test-msg-id', 'payload': 'response' } @asyncio.coroutine def test_connection_msg_for_unknown_handler(mock_client, mock_cloud): """Test a msg for an unknown handler.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ 'msgid': 'test-msg-id', 'handler': 'non-existing-handler', 'payload': 'test-payload' }) )) mock_client.send_json.return_value = mock_coro(None) yield from conn.connect() # Check that we sent the correct error assert len(mock_client.send_json.mock_calls) == 1 assert mock_client.send_json.mock_calls[0][1][0] == { 'msgid': 'test-msg-id', 'error': 'unknown-handler', } @asyncio.coroutine def test_connection_msg_for_handler_raising(mock_client, mock_handle_message, mock_cloud): """Test we sent error when handler raises exception.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ 'msgid': 'test-msg-id', 'handler': 'test-handler', 'payload': 'test-payload' }) )) mock_handle_message.side_effect = Exception('Broken') mock_client.send_json.return_value = mock_coro(None) yield from conn.connect() # Check that we sent the correct error assert len(mock_client.send_json.mock_calls) == 1 assert mock_client.send_json.mock_calls[0][1][0] == { 'msgid': 'test-msg-id', 'error': 'exception', } @asyncio.coroutine def test_handler_forwarding(): """Test we forward messages to correct handler.""" handler = MagicMock() handler.return_value = mock_coro() hass = object() cloud = object() with patch.dict(iot.HANDLERS, {'test': handler}): yield from iot.async_handle_message( hass, cloud, 'test', 'payload') assert len(handler.mock_calls) == 1 r_hass, r_cloud, payload = handler.mock_calls[0][1] assert r_hass is hass assert r_cloud is cloud assert payload == 'payload' async def test_handling_core_messages_logout(hass, mock_cloud): """Test handling core messages.""" mock_cloud.logout.return_value = mock_coro() await iot.async_handle_cloud(hass, mock_cloud, { 'action': 'logout', 'reason': 'Logged in at two places.' }) assert len(mock_cloud.logout.mock_calls) == 1 async def test_handling_core_messages_refresh_auth(hass, mock_cloud): """Test handling core messages.""" mock_cloud.hass = hass with patch('random.randint', return_value=0) as mock_rand, patch( 'homeassistant.components.cloud.auth_api.check_token' ) as mock_check: await iot.async_handle_cloud(hass, mock_cloud, { 'action': 'refresh_auth', 'seconds': 230, }) async_fire_time_changed(hass, dt_util.utcnow()) await hass.async_block_till_done() assert len(mock_rand.mock_calls) == 1 assert mock_rand.mock_calls[0][1] == (0, 230) assert len(mock_check.mock_calls) == 1 assert mock_check.mock_calls[0][1][0] is mock_cloud @asyncio.coroutine def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.CLOSING, )) with patch('asyncio.sleep', side_effect=[None, asyncio.CancelledError]): yield from conn.connect() assert 'Connection closed' in caplog.text @asyncio.coroutine def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.BINARY, )) yield from conn.connect() assert 'Connection closed: Received non-Text message' in caplog.text @asyncio.coroutine def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): """Test cloud sending invalid JSON.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.TEXT, json=MagicMock(side_effect=ValueError) )) yield from conn.connect() assert 'Connection closed: Received invalid JSON.' in caplog.text @asyncio.coroutine def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): """Test cloud unable to check token.""" conn = iot.CloudIoT(mock_cloud) mock_cloud.hass.async_add_job.side_effect = auth_api.CloudError("BLA") yield from conn.connect() assert 'Unable to refresh token: BLA' in caplog.text @asyncio.coroutine def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud): """Test invalid auth detected by server.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = \ client_exceptions.WSServerHandshakeError(None, None, status=401) yield from conn.connect() assert 'Connection closed: Invalid auth.' in caplog.text @asyncio.coroutine def test_cloud_unable_to_connect(mock_client, caplog, mock_cloud): """Test unable to connect error.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = client_exceptions.ClientError(None, None) yield from conn.connect() assert 'Unable to connect:' in caplog.text @asyncio.coroutine def test_cloud_random_exception(mock_client, caplog, mock_cloud): """Test random exception.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = Exception yield from conn.connect() assert 'Unexpected error' in caplog.text @asyncio.coroutine def test_refresh_token_before_expiration_fails(hass, mock_cloud): """Test that we don't connect if token is expired.""" mock_cloud.subscription_expired = True mock_cloud.hass = hass conn = iot.CloudIoT(mock_cloud) with patch('homeassistant.components.cloud.auth_api.check_token', return_value=mock_coro()) as mock_check_token, \ patch.object(hass.components.persistent_notification, 'async_create') as mock_create: yield from conn.connect() assert len(mock_check_token.mock_calls) == 1 assert len(mock_create.mock_calls) == 1 @asyncio.coroutine def test_handler_alexa(hass): """Test handler Alexa.""" hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): setup = yield from async_setup_component(hass, 'cloud', { 'cloud': { 'alexa': { 'filter': { 'exclude_entities': 'switch.test2' }, 'entity_config': { 'switch.test': { 'name': 'Config name', 'description': 'Config description', 'display_categories': 'LIGHT' } } } } }) assert setup mock_cloud_prefs(hass) resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], test_alexa.get_new_request('Alexa.Discovery', 'Discover')) endpoints = resp['event']['payload']['endpoints'] assert len(endpoints) == 1 device = endpoints[0] assert device['description'] == 'Config description' assert device['friendlyName'] == 'Config name' assert device['displayCategories'] == ['LIGHT'] assert device['manufacturerName'] == 'Home Assistant' @asyncio.coroutine def test_handler_alexa_disabled(hass, mock_cloud_fixture): """Test handler Alexa when user has disabled it.""" mock_cloud_fixture[PREF_ENABLE_ALEXA] = False resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], test_alexa.get_new_request('Alexa.Discovery', 'Discover')) assert resp['event']['header']['namespace'] == 'Alexa' assert resp['event']['header']['name'] == 'ErrorResponse' assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' @asyncio.coroutine def test_handler_google_actions(hass): """Test handler Google Actions.""" hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) hass.states.async_set( 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): setup = yield from async_setup_component(hass, 'cloud', { 'cloud': { 'google_actions': { 'filter': { 'exclude_entities': 'switch.test2' }, 'entity_config': { 'switch.test': { 'name': 'Config name', 'aliases': 'Config alias', 'room': 'living room' } } } } }) assert setup mock_cloud_prefs(hass) reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} with patch('homeassistant.components.cloud.Cloud._decode_claims', return_value={'cognito:username': 'myUserName'}): resp = yield from iot.async_handle_google_actions( hass, hass.data['cloud'], data) assert resp['requestId'] == reqid payload = resp['payload'] assert payload['agentUserId'] == 'myUserName' devices = payload['devices'] assert len(devices) == 1 device = devices[0] assert device['id'] == 'switch.test' assert device['name']['name'] == 'Config name' assert device['name']['nicknames'] == ['Config alias'] assert device['type'] == 'action.devices.types.SWITCH' assert device['roomHint'] == 'living room' async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): assert await async_setup_component(hass, 'cloud', {}) reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} resp = await iot.async_handle_google_actions( hass, hass.data['cloud'], data) assert resp['requestId'] == reqid assert resp['payload']['errorCode'] == 'deviceTurnedOff' async def test_refresh_token_expired(hass): """Test handling Unauthenticated error raised if refresh token expired.""" cloud = Cloud(hass, MODE_DEV, None, None) with patch('homeassistant.components.cloud.auth_api.check_token', side_effect=auth_api.Unauthenticated) as mock_check_token, \ patch.object(hass.components.persistent_notification, 'async_create') as mock_create: await cloud.iot.connect() 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() 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' } async def test_send_message_not_connected(mock_cloud): """Test sending a message that expects no answer.""" cloud_iot = iot.CloudIoT(mock_cloud) with pytest.raises(iot.NotConnected): await cloud_iot.async_send_message('webhook', {'msg': 'yo'}) async def test_send_message_no_answer(mock_cloud): """Test sending a message that expects no answer.""" cloud_iot = iot.CloudIoT(mock_cloud) cloud_iot.state = iot.STATE_CONNECTED cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) await cloud_iot.async_send_message('webhook', {'msg': 'yo'}, expect_answer=False) assert not cloud_iot._response_handler assert len(cloud_iot.client.send_json.mock_calls) == 1 msg = cloud_iot.client.send_json.mock_calls[0][1][0] assert msg['handler'] == 'webhook' assert msg['payload'] == {'msg': 'yo'} async def test_send_message_answer(loop, mock_cloud): """Test sending a message that expects no answer.""" cloud_iot = iot.CloudIoT(mock_cloud) cloud_iot.state = iot.STATE_CONNECTED cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) uuid = 5 with patch('homeassistant.components.cloud.iot.uuid.uuid4', return_value=MagicMock(hex=uuid)): send_task = loop.create_task(cloud_iot.async_send_message( 'webhook', {'msg': 'yo'})) await asyncio.sleep(0) assert len(cloud_iot.client.send_json.mock_calls) == 1 assert len(cloud_iot._response_handler) == 1 msg = cloud_iot.client.send_json.mock_calls[0][1][0] assert msg['handler'] == 'webhook' assert msg['payload'] == {'msg': 'yo'} cloud_iot._response_handler[uuid].set_result({'response': True}) response = await send_task assert response == {'response': True}