Add cloud auth support (#9208)

* Add initial cloud auth

* Move hass.data to a dict

* Move mode into helper

* Fix bugs afte refactor

* Add tests

* Clean up scripts file after test config

* Lint

* Update __init__.py
This commit is contained in:
Paulus Schoutsen 2017-08-29 13:40:08 -07:00 committed by GitHub
parent 33c906c20a
commit 0b58d5405e
11 changed files with 1007 additions and 1 deletions

View File

@ -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

View File

@ -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)

View File

@ -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')
}
}

View File

@ -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)

View File

@ -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']

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the cloud component."""

View File

@ -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

View File

@ -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]

View File

@ -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)

View File

@ -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,