diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000..8804f6d113f --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,49 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, cloud_api +from .const import DOMAIN + + +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + cloud = yield from cloud_api.async_load_auth(hass) + + if cloud is not None: + data['cloud'] = cloud + + yield from http_api.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000..6429da14516 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,297 @@ +"""Package to offer tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +import json +import logging +import os +from urllib.parse import urljoin + +import aiohttp +import async_timeout + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.dt import utcnow + +from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +URL_CREATE_TOKEN = 'o/token/' +URL_REVOKE_TOKEN = 'o/revoke_token/' +URL_ACCOUNT = 'account.json' + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + def __init__(self, reason=None, status=None): + """Initialize a cloud error.""" + super().__init__(reason) + self.status = status + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UnknownError(CloudError): + """Raised when an unknown error occurred.""" + + +@asyncio.coroutine +def async_load_auth(hass): + """Load authentication from disk and verify it.""" + auth = yield from hass.async_add_job(_read_auth, hass) + + if not auth: + return None + + cloud = Cloud(hass, auth) + + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + auth_check = yield from cloud.async_refresh_account_info() + + if not auth_check: + _LOGGER.error('Unable to validate credentials.') + return None + + return cloud + + except asyncio.TimeoutError: + _LOGGER.error('Unable to reach server to validate credentials.') + return None + + +@asyncio.coroutine +def async_login(hass, username, password, scope=None): + """Get a token using a username and password. + + Returns a coroutine. + """ + data = { + 'grant_type': 'password', + 'username': username, + 'password': password + } + if scope is not None: + data['scope'] = scope + + auth = yield from _async_get_token(hass, data) + + yield from hass.async_add_job(_write_auth, hass, auth) + + return Cloud(hass, auth) + + +@asyncio.coroutine +def _async_get_token(hass, data): + """Get a new token and return it as a dictionary. + + Raises exceptions when errors occur: + - Unauthenticated + - UnknownError + """ + session = async_get_clientsession(hass) + auth = aiohttp.BasicAuth(*_client_credentials(hass)) + + try: + req = yield from session.post( + _url(hass, URL_CREATE_TOKEN), + data=data, + auth=auth + ) + + if req.status == 401: + _LOGGER.error('Cloud login failed: %d', req.status) + raise Unauthenticated(status=req.status) + elif req.status != 200: + _LOGGER.error('Cloud login failed: %d', req.status) + raise UnknownError(status=req.status) + + response = yield from req.json() + response['expires_at'] = \ + (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() + + return response + + except aiohttp.ClientError: + raise UnknownError() + + +class Cloud: + """Store Hass Cloud info.""" + + def __init__(self, hass, auth): + """Initialize Hass cloud info object.""" + self.hass = hass + self.auth = auth + self.account = None + + @property + def access_token(self): + """Return access token.""" + return self.auth['access_token'] + + @property + def refresh_token(self): + """Get refresh token.""" + return self.auth['refresh_token'] + + @asyncio.coroutine + def async_refresh_account_info(self): + """Refresh the account info.""" + req = yield from self.async_request('get', URL_ACCOUNT) + + if req.status != 200: + return False + + self.account = yield from req.json() + return True + + @asyncio.coroutine + def async_refresh_access_token(self): + """Get a token using a refresh token.""" + try: + self.auth = yield from _async_get_token(self.hass, { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + }) + + yield from self.hass.async_add_job( + _write_auth, self.hass, self.auth) + + return True + except CloudError: + return False + + @asyncio.coroutine + def async_revoke_access_token(self): + """Revoke active access token.""" + session = async_get_clientsession(self.hass) + client_id, client_secret = _client_credentials(self.hass) + data = { + 'token': self.access_token, + 'client_id': client_id, + 'client_secret': client_secret + } + try: + req = yield from session.post( + _url(self.hass, URL_REVOKE_TOKEN), + data=data, + ) + + if req.status != 200: + _LOGGER.error('Cloud logout failed: %d', req.status) + raise UnknownError(status=req.status) + + self.auth = None + yield from self.hass.async_add_job( + _write_auth, self.hass, None) + + except aiohttp.ClientError: + raise UnknownError() + + @asyncio.coroutine + def async_request(self, method, path, **kwargs): + """Make a request to Home Assistant cloud. + + Will refresh the token if necessary. + """ + session = async_get_clientsession(self.hass) + url = _url(self.hass, path) + + if 'headers' not in kwargs: + kwargs['headers'] = {} + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + request = yield from session.request(method, url, **kwargs) + + if request.status != 403: + return request + + # Maybe token expired. Try refreshing it. + reauth = yield from self.async_refresh_access_token() + + if not reauth: + return request + + # Release old connection back to the pool. + yield from request.release() + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + # If we are not already fetching the account info, + # refresh the account info. + + if path != URL_ACCOUNT: + yield from self.async_refresh_account_info() + + request = yield from session.request(method, url, **kwargs) + + return request + + +def _read_auth(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_auth(hass, data): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if data is None: + content.pop(mode, None) + else: + content[mode] = data + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _client_credentials(hass): + """Get the client credentials. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] + + +def _url(hass, path): + """Generate a url for the cloud. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000..f55a4be21a2 --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + 'host': 'http://localhost:8000', + 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', + 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' + 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' + 'VBJrRyfgTVd43kbrEQtuOiaUpK') + } +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py new file mode 100644 index 00000000000..661cc8a7ba1 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,119 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +import logging + +import voluptuous as vol +import async_timeout + +from homeassistant.components.http import HomeAssistantView + +from . import cloud_api +from .const import DOMAIN, REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudAccountView) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str, + }) + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Login with invalid JSON') + return self.json_message('Invalid JSON.', 400) + + try: + self.schema(data) + except vol.Invalid as err: + _LOGGER.error('Login with invalid formatted data') + return self.json_message( + 'Message format incorrect: {}'.format(err), 400) + + hass = request.app['hass'] + phase = 1 + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + cloud = yield from cloud_api.async_login( + hass, data['username'], data['password']) + + phase += 1 + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from cloud.async_refresh_account_info() + + except cloud_api.Unauthenticated: + return self.json_message( + 'Authentication failed (phase {}).'.format(phase), 401) + except cloud_api.UnknownError: + return self.json_message( + 'Unknown error occurred (phase {}).'.format(phase), 500) + except asyncio.TimeoutError: + return self.json_message( + 'Unable to reach Home Assistant cloud ' + '(phase {}).'.format(phase), 502) + + hass.data[DOMAIN]['cloud'] = cloud + return self.json(cloud.account) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from \ + hass.data[DOMAIN]['cloud'].async_revoke_access_token() + + hass.data[DOMAIN].pop('cloud') + + return self.json({ + 'result': 'ok', + }) + except asyncio.TimeoutError: + return self.json_message("Could not reach the server.", 502) + except cloud_api.UnknownError as err: + return self.json_message( + "Error communicating with the server ({}).".format(err.status), + 502) + + +class CloudAccountView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + + if 'cloud' not in hass.data[DOMAIN]: + return self.json_message('Not logged in', 400) + + return self.json(hass.data[DOMAIN]['cloud'].account) diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 00000000000..ec5445f0638 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/tests/common.py b/tests/common.py index 5fdec2fc411..f0d6a5bd057 100644 --- a/tests/common.py +++ b/tests/common.py @@ -119,7 +119,7 @@ def async_test_home_assistant(loop): def async_add_job(target, *args): """Add a magic mock.""" if isinstance(target, Mock): - return mock_coro(target()) + return mock_coro(target(*args)) return orig_async_add_job(target, *args) hass.async_add_job = async_add_job diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py new file mode 100644 index 00000000000..707e49f670f --- /dev/null +++ b/tests/components/cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the cloud component.""" diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 00000000000..11c396daf05 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,352 @@ +"""Tests for the tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch +from urllib.parse import urljoin + +import aiohttp +import pytest + +from homeassistant.components.cloud import DOMAIN, cloud_api, const +import homeassistant.util.dt as dt_util + +from tests.common import mock_coro + + +MOCK_AUTH = { + "access_token": "jvCHxpTu2nfORLBRgQY78bIAoK4RPa", + "expires_at": "2017-08-29T05:33:28.266048+00:00", + "expires_in": 86400, + "refresh_token": "C4wR1mgb03cs69EeiFgGOBC8mMQC5Q", + "scope": "", + "token_type": "Bearer" +} + + +def url(path): + """Create a url.""" + return urljoin(const.SERVERS['development']['host'], path) + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(cloud_api, '_write_auth') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(cloud_api, '_read_auth') as mock: + yield mock + + +@asyncio.coroutine +def test_async_login_invalid_auth(cloud_hass, aioclient_mock, mock_write): + """Test trying to login with invalid credentials.""" + aioclient_mock.post(url('o/token/'), status=401) + with pytest.raises(cloud_api.Unauthenticated): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_cloud_error(cloud_hass, aioclient_mock, mock_write): + """Test exception in cloud while logging in.""" + aioclient_mock.post(url('o/token/'), status=500) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_client_error(cloud_hass, aioclient_mock, mock_write): + """Test client error while logging in.""" + aioclient_mock.post(url('o/token/'), exc=aiohttp.ClientError) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login(cloud_hass, aioclient_mock, mock_write): + """Test logging in.""" + aioclient_mock.post(url('o/token/'), json={ + 'expires_in': 10 + }) + now = dt_util.utcnow() + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 1 + result_hass, result_data = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_data == { + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + + +@asyncio.coroutine +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_timeout_during_verification(cloud_hass, mock_read): + """Test loading authentication with timeout during verification.""" + mock_read.return_value = MOCK_AUTH + + with patch.object(cloud_api.Cloud, 'async_refresh_account_info', + side_effect=asyncio.TimeoutError): + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_verification_failed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with verify request getting 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_401(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 401.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=401) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_timeout(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), exc=asyncio.TimeoutError) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_succeeds(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh: + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_load_auth_token(cloud_hass, mock_read, aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is not None + assert result.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + assert result.auth == MOCK_AUTH + + +def test_cloud_properties(): + """Test Cloud class properties.""" + cloud = cloud_api.Cloud(None, MOCK_AUTH) + assert cloud.access_token == MOCK_AUTH['access_token'] + assert cloud.refresh_token == MOCK_AUTH['refresh_token'] + + +@asyncio.coroutine +def test_cloud_refresh_account_info(cloud_hass, aioclient_mock): + """Test refreshing account info.""" + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert result + assert cloud.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + + +@asyncio.coroutine +def test_cloud_refresh_account_info_500(cloud_hass, aioclient_mock): + """Test refreshing account info and getting 500.""" + aioclient_mock.get(url('account.json'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert not result + assert cloud.account is None + + +@asyncio.coroutine +def test_cloud_refresh_token(cloud_hass, aioclient_mock, mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), json={ + 'access_token': 'refreshed', + 'expires_in': 10 + }) + now = dt_util.utcnow() + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + result = yield from cloud.async_refresh_access_token() + assert result + assert cloud.auth == { + 'access_token': 'refreshed', + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data == cloud.auth + + +@asyncio.coroutine +def test_cloud_refresh_token_unknown_error(cloud_hass, aioclient_mock, + mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + result = yield from cloud.async_refresh_access_token() + assert not result + assert cloud.auth == MOCK_AUTH + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token(cloud_hass, aioclient_mock, mock_write): + """Test revoking access token.""" + aioclient_mock.post(url('o/revoke_token/')) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + yield from cloud.async_revoke_access_token() + assert cloud.auth is None + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data is None + + +@asyncio.coroutine +def test_cloud_revoke_token_invalid_client_creds(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), status=401) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token_request_error(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), exc=aiohttp.ClientError) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_request(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), json={'hello': 'world'}) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 200 + data = yield from request.json() + assert data == {'hello': 'world'} + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_fail(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(False)) as mock_refresh: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_success(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh, \ + patch.object(cloud_api.Cloud, 'async_refresh_account_info', + return_value=mock_coro()) as mock_account_info: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + assert len(mock_account_info.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py new file mode 100644 index 00000000000..99e73461bc1 --- /dev/null +++ b/tests/components/cloud/test_http_api.py @@ -0,0 +1,157 @@ +"""Tests for the HTTP API for the cloud component.""" +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.cloud import DOMAIN, cloud_api + +from tests.common import mock_coro + + +@pytest.fixture +def cloud_client(hass, test_client): + """Fixture that can fetch from the cloud client.""" + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { + 'cloud': { + 'mode': 'development' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_account_view_no_account(cloud_client): + """Test fetching account if no account available.""" + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 400 + + +@asyncio.coroutine +def test_account_view(hass, cloud_client): + """Test fetching account if no account available.""" + cloud = MagicMock(account={'test': 'account'}) + hass.data[DOMAIN]['cloud'] = cloud + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 200 + result = yield from req.json() + assert result == {'test': 'account'} + + +@asyncio.coroutine +def test_login_view(hass, cloud_client): + """Test logging in.""" + cloud = MagicMock(account={'test': 'account'}) + cloud.async_refresh_account_info.return_value = mock_coro(None) + + with patch.object(cloud_api, 'async_login', + MagicMock(return_value=mock_coro(cloud))): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 200 + + result = yield from req.json() + assert result == {'test': 'account'} + assert hass.data[DOMAIN]['cloud'] is cloud + + +@asyncio.coroutine +def test_login_view_invalid_json(hass, cloud_client): + """Try logging in with invalid JSON.""" + req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_schema(hass, cloud_client): + """Try logging in with invalid schema.""" + req = yield from cloud_client.post('/api/cloud/login', json={ + 'invalid': 'schema' + }) + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_request_timeout(hass, cloud_client): + """Test request timeout while trying to log in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=asyncio.TimeoutError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 502 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_credentials(hass, cloud_client): + """Test logging in with invalid credentials.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.Unauthenticated)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 401 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_unknown_error(hass, cloud_client): + """Test unknown error while logging in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.UnknownError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 500 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view(hass, cloud_client): + """Test logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.return_value = mock_coro(None) + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 200 + data = yield from req.json() + assert data == {'result': 'ok'} + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_request_timeout(hass, cloud_client): + """Test timeout while logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = asyncio.TimeoutError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_unknown_error(hass, cloud_client): + """Test unknown error while loggin out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = cloud_api.UnknownError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] diff --git a/tests/test_config.py b/tests/test_config.py index d1b9a052b72..1cb5e00bee9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,8 @@ from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) +from homeassistant.components.config.script import ( + CONFIG_PATH as SCRIPTS_CONFIG_PATH) from homeassistant.components.config.customize import ( CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) @@ -33,6 +35,7 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) +SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH) CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -68,6 +71,9 @@ class TestConfig(unittest.TestCase): if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) + if os.path.isfile(SCRIPTS_PATH): + os.remove(SCRIPTS_PATH) + if os.path.isfile(CUSTOMIZE_PATH): os.remove(CUSTOMIZE_PATH) diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0af5321c65f..ccd71e55d16 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -201,6 +201,7 @@ def mock_aiohttp_client(): with mock.patch('aiohttp.ClientSession') as mock_session: instance = mock_session() + instance.request = mocker.match_request for method in ('get', 'post', 'put', 'options', 'delete'): setattr(instance, method,