From eccb425bc088dc5ff819311119aa98d5ec88f413 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 27 Aug 2017 22:35:02 -0700 Subject: [PATCH 1/8] Add initial cloud auth --- homeassistant/components/cloud/__init__.py | 36 +++ homeassistant/components/cloud/cloud_api.py | 277 ++++++++++++++++++++ homeassistant/components/cloud/const.py | 13 + homeassistant/components/cloud/http_api.py | 116 ++++++++ tests/components/cloud/__init__.py | 0 tests/components/cloud/test_http_api.py | 157 +++++++++++ 6 files changed, 599 insertions(+) create mode 100644 homeassistant/components/cloud/__init__.py create mode 100644 homeassistant/components/cloud/cloud_api.py create mode 100644 homeassistant/components/cloud/const.py create mode 100644 homeassistant/components/cloud/http_api.py create mode 100644 tests/components/cloud/__init__.py create mode 100644 tests/components/cloud/test_http_api.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 0000000000000..7e617db9b0fc6 --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,36 @@ +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from . import http_api, cloud_api +from .const import DOMAIN + + +DEPENDENCIES = ['http'] +CONF_DEVELOPMENT = 'development' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVELOPMENT, default=False): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + if not config[DOMAIN][CONF_DEVELOPMENT]: + _LOGGER.error('Production mode not available yet.') + return False + + cloud = yield from cloud_api.async_load_auth(hass, 'development') + + if cloud is not None: + hass.data[DOMAIN] = 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 0000000000000..71122ef2ff768 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,277 @@ +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 + +_LOGGER = logging.getLogger(__name__) + + +URL_CREATE_TOKEN = 'o/token/' +URL_REVOKE_TOKEN = 'o/revoke_token/' + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + def __init__(self, reason=None, status=None): + 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, mode): + """Load authentication from disk and verify it.""" + auth = yield from hass.async_add_job(_read_auth, hass, mode) + + if not auth: + return None + + cloud = Cloud(hass, mode, 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.TimeoutErrror: + _LOGGER.error('Unable to reach server to validate credentials.') + return None + + +@asyncio.coroutine +def async_login(hass, mode, 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) + return Cloud(hass, mode, auth) + + +@asyncio.coroutine +def _async_get_token(hass, mode, 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(mode)) + + try: + req = yield from session.post( + _url(mode, URL_CREATE_TOKEN), + data=data, + auth=auth + ) + + if req.status in (401, 403): + _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(content_type=None) + data['expires_at'] = \ + (utcnow() + timedelta(seconds=data['expires_in'])).isoformat() + + return response + + except aiohttp.ClientError: + raise UnknownError() + + +class Cloud: + """Store Hass Cloud info.""" + + def __init__(self, hass, mode, auth): + """Initialize Hass cloud info object.""" + self.hass = hass + self.mode = mode + 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): + req = yield from self.async_request( + self.hass, 'get', 'account.json') + + 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.mode) + data = { + 'token': self.access_token, + 'client_id': client_id, + 'client_secret': client_secret + } + try: + req = yield from session.post( + _url(self.mode, URL_REVOKE_TOKEN), + data=data, + ) + + if req.status != 200: + _LOGGER.error('Cloud logout failed: %d', req.status) + raise UnknownError(status=req.status) + + yield from self.hass.async_add_job( + _write_auth, self.hass, self.mode, None) + + except aiohttp.ClientError as err: + 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.mode, 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_CREATE_TOKEN: + yield from self.async_refresh_account_info() + + request = yield from session.request(method, url, **kwargs) + + return request + + +def _read_auth(hass, mode): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as fp: + return json.load(fp).get(mode) + + +def _write_auth(hass, mode, data): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + content = _read_auth(hass, mode) or {} + + if data is None: + content.pop(mode, None) + else: + content[mode] = data + + with open(hass.config.path(AUTH_FILE), 'wt') as file: + file.write(json.dumps(content)) + + +def _client_credentials(mode): + """Get the client credentials. + + Async friendly. + """ + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] + + +def _url(mode, path): + """Generate a url for the cloud. + + Async friendly. + """ + 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 0000000000000..4182cd9a9b35e --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,13 @@ +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 0000000000000..91889903364ae --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,116 @@ +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): + 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 + 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].async_revoke_access_token() + + hass.data.pop(DOMAIN) + + 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 DOMAIN not in hass.data: + return self.json_message('Not logged in', 400) + + return self.json(hass.data[DOMAIN].account) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py new file mode 100644 index 0000000000000..5f2fa087052b0 --- /dev/null +++ b/tests/components/cloud/test_http_api.py @@ -0,0 +1,157 @@ +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.cloud import DOMAIN, http_api, 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, 'http', { + 'cloud': { + 'development': True + } + })) + hass.loop.run_until_complete(http_api.async_setup(hass)) + 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 + 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] 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 DOMAIN not in hass.data + + +@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 DOMAIN not in hass.data + + +@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 DOMAIN not in hass.data + + +@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 DOMAIN not in hass.data + + +@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 DOMAIN not in hass.data + + +@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 + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 200 + data = yield from req.json() + assert data == {'result': 'ok'} + assert DOMAIN not in hass.data + + +@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 + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert DOMAIN in hass.data + + +@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 + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert DOMAIN in hass.data From 5fd978caf17f52c8770e4378978fb9f5903f545a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 27 Aug 2017 22:45:09 -0700 Subject: [PATCH 2/8] Move hass.data to a dict --- homeassistant/components/cloud/__init__.py | 25 ++++++++++------ homeassistant/components/cloud/http_api.py | 10 +++---- tests/components/cloud/test_http_api.py | 33 +++++++++++----------- 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 7e617db9b0fc6..e95f0669faa60 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -3,18 +3,18 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv - from . import http_api, cloud_api from .const import DOMAIN DEPENDENCIES = ['http'] -CONF_DEVELOPMENT = 'development' +CONF_MODE = 'mode' +MODE_DEV = 'development' +DEFAULT_MODE = MODE_DEV CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_DEVELOPMENT, default=False): cv.boolean, + vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV]), }), }, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) @@ -23,14 +23,23 @@ @asyncio.coroutine def async_setup(hass, config): """Initialize the Home Assistant cloud.""" - if not config[DOMAIN][CONF_DEVELOPMENT]: - _LOGGER.error('Production mode not available yet.') + 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 - cloud = yield from cloud_api.async_load_auth(hass, 'development') + data = hass.data[DOMAIN] = { + 'mode': mode + } + + cloud = yield from cloud_api.async_load_auth(hass, mode) if cloud is not None: - hass.data[DOMAIN] = cloud + data['cloud'] = cloud yield from http_api.async_setup(hass) return True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 91889903364ae..f85fbc6caf1e1 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -68,7 +68,7 @@ def post(self, request): 'Unable to reach Home Assistant cloud ' '(phase {}).'.format(phase), 502) - hass.data[DOMAIN] = cloud + hass.data[DOMAIN]['cloud'] = cloud return self.json(cloud.account) @@ -84,9 +84,9 @@ def post(self, request): hass = request.app['hass'] try: with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.data[DOMAIN].async_revoke_access_token() + yield from hass.data[DOMAIN]['cloud'].async_revoke_access_token() - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop('cloud') return self.json({ 'result': 'ok', @@ -110,7 +110,7 @@ def get(self, request): """Validate config and return results.""" hass = request.app['hass'] - if DOMAIN not in hass.data: + if 'cloud' not in hass.data[DOMAIN]: return self.json_message('Not logged in', 400) - return self.json(hass.data[DOMAIN].account) + return self.json(hass.data[DOMAIN]['cloud'].account) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5f2fa087052b0..85960ce1a2faa 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -4,7 +4,7 @@ import pytest from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, http_api, cloud_api +from homeassistant.components.cloud import DOMAIN, cloud_api from tests.common import mock_coro @@ -12,12 +12,11 @@ @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, 'http', { + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { 'cloud': { - 'development': True + 'mode': 'development' } })) - hass.loop.run_until_complete(http_api.async_setup(hass)) return hass.loop.run_until_complete(test_client(hass.http.app)) @@ -32,7 +31,7 @@ def test_account_view_no_account(cloud_client): def test_account_view(hass, cloud_client): """Test fetching account if no account available.""" cloud = MagicMock(account={'test': 'account'}) - hass.data[DOMAIN] = cloud + hass.data[DOMAIN]['cloud'] = cloud req = yield from cloud_client.get('/api/cloud/account') assert req.status == 200 result = yield from req.json() @@ -56,7 +55,7 @@ def test_login_view(hass, cloud_client): result = yield from req.json() assert result == {'test': 'account'} - assert hass.data[DOMAIN] is cloud + assert hass.data[DOMAIN]['cloud'] is cloud @asyncio.coroutine @@ -64,7 +63,7 @@ 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 DOMAIN not in hass.data + assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine @@ -74,7 +73,7 @@ def test_login_view_invalid_schema(hass, cloud_client): 'invalid': 'schema' }) assert req.status == 400 - assert DOMAIN not in hass.data + assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine @@ -88,7 +87,7 @@ def test_login_view_request_timeout(hass, cloud_client): }) assert req.status == 502 - assert DOMAIN not in hass.data + assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine @@ -102,7 +101,7 @@ def test_login_view_invalid_credentials(hass, cloud_client): }) assert req.status == 401 - assert DOMAIN not in hass.data + assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine @@ -116,7 +115,7 @@ def test_login_view_unknown_error(hass, cloud_client): }) assert req.status == 500 - assert DOMAIN not in hass.data + assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine @@ -124,13 +123,13 @@ 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 + 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 DOMAIN not in hass.data + assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine @@ -138,11 +137,11 @@ 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 + hass.data[DOMAIN]['cloud'] = cloud req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 - assert DOMAIN in hass.data + assert 'cloud' in hass.data[DOMAIN] @asyncio.coroutine @@ -150,8 +149,8 @@ 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 + hass.data[DOMAIN]['cloud'] = cloud req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 - assert DOMAIN in hass.data + assert 'cloud' in hass.data[DOMAIN] From 5734426e82efdf684e60bc41eec45cc8ec9e79c9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 27 Aug 2017 22:51:55 -0700 Subject: [PATCH 3/8] Move mode into helper --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/cloud_api.py | 43 ++++++++++++--------- homeassistant/components/cloud/util.py | 9 +++++ 3 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/cloud/util.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e95f0669faa60..3d4957f168b10 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -36,7 +36,7 @@ def async_setup(hass, config): 'mode': mode } - cloud = yield from cloud_api.async_load_auth(hass, mode) + cloud = yield from cloud_api.async_load_auth(hass) if cloud is not None: data['cloud'] = cloud diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py index 71122ef2ff768..25e7ccab6cd4f 100644 --- a/homeassistant/components/cloud/cloud_api.py +++ b/homeassistant/components/cloud/cloud_api.py @@ -12,6 +12,7 @@ from homeassistant.util.dt import utcnow from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS +from .util import get_mode _LOGGER = logging.getLogger(__name__) @@ -37,14 +38,14 @@ class UnknownError(CloudError): @asyncio.coroutine -def async_load_auth(hass, mode): +def async_load_auth(hass): """Load authentication from disk and verify it.""" - auth = yield from hass.async_add_job(_read_auth, hass, mode) + auth = yield from hass.async_add_job(_read_auth, hass) if not auth: return None - cloud = Cloud(hass, mode, auth) + cloud = Cloud(hass, auth) try: with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): @@ -62,7 +63,7 @@ def async_load_auth(hass, mode): @asyncio.coroutine -def async_login(hass, mode, username, password, scope=None): +def async_login(hass, username, password, scope=None): """Get a token using a username and password. Returns a coroutine. @@ -76,11 +77,11 @@ def async_login(hass, mode, username, password, scope=None): data['scope'] = scope auth = yield from _async_get_token(hass, data) - return Cloud(hass, mode, auth) + return Cloud(hass, auth) @asyncio.coroutine -def _async_get_token(hass, mode, data): +def _async_get_token(hass, data): """Get a new token and return it as a dictionary. Raises exceptions when errors occur: @@ -88,11 +89,11 @@ def _async_get_token(hass, mode, data): - UnknownError """ session = async_get_clientsession(hass) - auth = aiohttp.BasicAuth(*_client_credentials(mode)) + auth = aiohttp.BasicAuth(*_client_credentials(hass)) try: req = yield from session.post( - _url(mode, URL_CREATE_TOKEN), + _url(hass, URL_CREATE_TOKEN), data=data, auth=auth ) @@ -117,10 +118,9 @@ def _async_get_token(hass, mode, data): class Cloud: """Store Hass Cloud info.""" - def __init__(self, hass, mode, auth): + def __init__(self, hass, auth): """Initialize Hass cloud info object.""" self.hass = hass - self.mode = mode self.auth = auth self.account = None @@ -165,7 +165,7 @@ def async_refresh_access_token(self): def async_revoke_access_token(self): """Revoke active access token.""" session = async_get_clientsession(self.hass) - client_id, client_secret = _client_credentials(self.mode) + client_id, client_secret = _client_credentials(self.hass) data = { 'token': self.access_token, 'client_id': client_id, @@ -173,7 +173,7 @@ def async_revoke_access_token(self): } try: req = yield from session.post( - _url(self.mode, URL_REVOKE_TOKEN), + _url(self.hass, URL_REVOKE_TOKEN), data=data, ) @@ -182,7 +182,7 @@ def async_revoke_access_token(self): raise UnknownError(status=req.status) yield from self.hass.async_add_job( - _write_auth, self.hass, self.mode, None) + _write_auth, self.hass, None) except aiohttp.ClientError as err: raise UnknownError() @@ -193,7 +193,7 @@ def async_request(self, method, path, **kwargs): Will refresh the token if necessary.""" session = async_get_clientsession(self.hass) - url = _url(self.mode, path) + url = _url(self.hass, path) if 'headers' not in kwargs: kwargs['headers'] = {} @@ -228,7 +228,7 @@ def async_request(self, method, path, **kwargs): return request -def _read_auth(hass, mode): +def _read_auth(hass): """Read auth file.""" path = hass.config.path(AUTH_FILE) @@ -236,14 +236,15 @@ def _read_auth(hass, mode): return None with open(path) as fp: - return json.load(fp).get(mode) + return json.load(fp).get(get_mode(hass)) -def _write_auth(hass, mode, data): +def _write_auth(hass, data): """Write auth info for specified mode. Pass in None for data to remove authentication for that mode. """ + mode = get_mode(hass) content = _read_auth(hass, mode) or {} if data is None: @@ -255,22 +256,26 @@ def _write_auth(hass, mode, data): file.write(json.dumps(content)) -def _client_credentials(mode): +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(mode, path): +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)) diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 0000000000000..68cd8791912ac --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,9 @@ +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] From dd60ba34764724b3a2413d7c0065d8c83ddb6b40 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 27 Aug 2017 23:04:13 -0700 Subject: [PATCH 4/8] Fix bugs afte refactor --- homeassistant/components/cloud/cloud_api.py | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py index 25e7ccab6cd4f..a092b770945a7 100644 --- a/homeassistant/components/cloud/cloud_api.py +++ b/homeassistant/components/cloud/cloud_api.py @@ -19,6 +19,7 @@ URL_CREATE_TOKEN = 'o/token/' URL_REVOKE_TOKEN = 'o/revoke_token/' +URL_ACCOUNT = 'account.json' class CloudError(Exception): @@ -77,6 +78,9 @@ def async_login(hass, username, password, scope=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) @@ -106,8 +110,8 @@ def _async_get_token(hass, data): raise UnknownError(status=req.status) response = yield from req.json(content_type=None) - data['expires_at'] = \ - (utcnow() + timedelta(seconds=data['expires_in'])).isoformat() + response['expires_at'] = \ + (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() return response @@ -136,8 +140,7 @@ def refresh_token(self): @asyncio.coroutine def async_refresh_account_info(self): - req = yield from self.async_request( - self.hass, 'get', 'account.json') + req = yield from self.async_request('get', URL_ACCOUNT) if req.status != 200: return False @@ -220,7 +223,7 @@ def async_request(self, method, path, **kwargs): # If we are not already fetching the account info, # refresh the account info. - if path != URL_CREATE_TOKEN: + if path != URL_ACCOUNT: yield from self.async_refresh_account_info() request = yield from session.request(method, url, **kwargs) @@ -244,16 +247,22 @@ def _write_auth(hass, data): Pass in None for data to remove authentication for that mode. """ + path = hass.config.path(AUTH_FILE) mode = get_mode(hass) - content = _read_auth(hass, mode) or {} + + if os.path.isfile(path): + with open(path) as fp: + content = json.load(fp) + else: + content = {} if data is None: content.pop(mode, None) else: content[mode] = data - with open(hass.config.path(AUTH_FILE), 'wt') as file: - file.write(json.dumps(content)) + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) def _client_credentials(hass): From d9a68c3aca58628caab48b3cc22c4f187d544c1a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 28 Aug 2017 23:57:53 -0700 Subject: [PATCH 5/8] Add tests --- homeassistant/components/cloud/__init__.py | 7 +- homeassistant/components/cloud/cloud_api.py | 8 +- tests/common.py | 2 +- tests/components/cloud/test_cloud_api.py | 350 ++++++++++++++++++++ tests/test_util/aiohttp.py | 1 + 5 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 tests/components/cloud/test_cloud_api.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 3d4957f168b10..72be407e65268 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -10,11 +10,14 @@ 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]), + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), }), }, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) @@ -23,7 +26,7 @@ @asyncio.coroutine def async_setup(hass, config): """Initialize the Home Assistant cloud.""" - mode = 'production' + mode = MODE_PRODUCTION if DOMAIN in config: mode = config[DOMAIN].get(CONF_MODE) diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py index a092b770945a7..f30dfa1b194e9 100644 --- a/homeassistant/components/cloud/cloud_api.py +++ b/homeassistant/components/cloud/cloud_api.py @@ -58,7 +58,7 @@ def async_load_auth(hass): return cloud - except asyncio.TimeoutErrror: + except asyncio.TimeoutError: _LOGGER.error('Unable to reach server to validate credentials.') return None @@ -102,14 +102,14 @@ def _async_get_token(hass, data): auth=auth ) - if req.status in (401, 403): + 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(content_type=None) + response = yield from req.json() response['expires_at'] = \ (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() @@ -184,6 +184,7 @@ def async_revoke_access_token(self): _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) @@ -223,6 +224,7 @@ def async_request(self, method, path, **kwargs): # If we are not already fetching the account info, # refresh the account info. + if path != URL_ACCOUNT: yield from self.async_refresh_account_info() diff --git a/tests/common.py b/tests/common.py index 5fdec2fc41124..f0d6a5bd057d1 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/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 0000000000000..ff8254b96daa0 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,350 @@ +import asyncio +from datetime import timedelta +from unittest.mock import patch, MagicMock +from urllib.parse import urljoin + +import aiohttp +import pytest + +from homeassistant.bootstrap import async_setup_component +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(): + with patch.object(cloud_api, '_write_auth') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + 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/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0af5321c65f1a..ccd71e55d16d3 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, From 99421d22e2d2188f41e5460c9dc8db9e86446726 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 00:20:36 -0700 Subject: [PATCH 6/8] Clean up scripts file after test config --- tests/test_config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index d1b9a052b7261..1cb5e00bee92e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,8 @@ 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 @@ 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 @@ def tearDown(self): 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) From c0de058ae9f486d06d5b1db79b3408508fdd4299 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 08:08:02 -0700 Subject: [PATCH 7/8] Lint --- homeassistant/components/cloud/__init__.py | 1 + homeassistant/components/cloud/cloud_api.py | 16 ++++++++++------ homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 7 +++++-- homeassistant/components/cloud/util.py | 1 + tests/components/cloud/test_cloud_api.py | 6 ++++-- tests/components/cloud/test_http_api.py | 1 + 7 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 72be407e65268..8804f6d113fab 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,3 +1,4 @@ +"""Component to integrate the Home Assistant cloud.""" import asyncio import logging diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py index f30dfa1b194e9..6429da145167d 100644 --- a/homeassistant/components/cloud/cloud_api.py +++ b/homeassistant/components/cloud/cloud_api.py @@ -1,3 +1,4 @@ +"""Package to offer tools to communicate with the cloud.""" import asyncio from datetime import timedelta import json @@ -26,6 +27,7 @@ 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 @@ -140,6 +142,7 @@ def refresh_token(self): @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: @@ -188,14 +191,15 @@ def async_revoke_access_token(self): yield from self.hass.async_add_job( _write_auth, self.hass, None) - except aiohttp.ClientError as err: + 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.""" + Will refresh the token if necessary. + """ session = async_get_clientsession(self.hass) url = _url(self.hass, path) @@ -240,8 +244,8 @@ def _read_auth(hass): if not os.path.isfile(path): return None - with open(path) as fp: - return json.load(fp).get(get_mode(hass)) + with open(path) as file: + return json.load(file).get(get_mode(hass)) def _write_auth(hass, data): @@ -253,8 +257,8 @@ def _write_auth(hass, data): mode = get_mode(hass) if os.path.isfile(path): - with open(path) as fp: - content = json.load(fp) + with open(path) as file: + content = json.load(file) else: content = {} diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 4182cd9a9b35e..f55a4be21a2ff 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,3 +1,4 @@ +"""Constants for the cloud component.""" DOMAIN = 'cloud' REQUEST_TIMEOUT = 10 AUTH_FILE = '.cloud' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index f85fbc6caf1e1..661cc8a7ba1d7 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,3 +1,4 @@ +"""The HTTP api to control the cloud integration.""" import asyncio import logging @@ -14,6 +15,7 @@ @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) @@ -43,7 +45,7 @@ def post(self, request): except vol.Invalid as err: _LOGGER.error('Login with invalid formatted data') return self.json_message( - 'Message format incorrect: '.format(err), 400) + 'Message format incorrect: {}'.format(err), 400) hass = request.app['hass'] phase = 1 @@ -84,7 +86,8 @@ def post(self, request): hass = request.app['hass'] try: with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.data[DOMAIN]['cloud'].async_revoke_access_token() + yield from \ + hass.data[DOMAIN]['cloud'].async_revoke_access_token() hass.data[DOMAIN].pop('cloud') diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py index 68cd8791912ac..ec5445f0638c0 100644 --- a/homeassistant/components/cloud/util.py +++ b/homeassistant/components/cloud/util.py @@ -1,3 +1,4 @@ +"""Utilities for the cloud integration.""" from .const import DOMAIN diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py index ff8254b96daa0..11c396daf05b1 100644 --- a/tests/components/cloud/test_cloud_api.py +++ b/tests/components/cloud/test_cloud_api.py @@ -1,12 +1,12 @@ +"""Tests for the tools to communicate with the cloud.""" import asyncio from datetime import timedelta -from unittest.mock import patch, MagicMock +from unittest.mock import patch from urllib.parse import urljoin import aiohttp import pytest -from homeassistant.bootstrap import async_setup_component from homeassistant.components.cloud import DOMAIN, cloud_api, const import homeassistant.util.dt as dt_util @@ -37,12 +37,14 @@ def cloud_hass(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 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 85960ce1a2faa..99e73461bc153 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,3 +1,4 @@ +"""Tests for the HTTP API for the cloud component.""" import asyncio from unittest.mock import patch, MagicMock From 080b35593ed7ff02683d5747dc479670fab94c17 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 09:41:40 -0700 Subject: [PATCH 8/8] Update __init__.py --- tests/components/cloud/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index e69de29bb2d1d..707e49f670fb3 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the cloud component."""