mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 09:47:52 +00:00
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:
parent
33c906c20a
commit
0b58d5405e
49
homeassistant/components/cloud/__init__.py
Normal file
49
homeassistant/components/cloud/__init__.py
Normal 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
|
297
homeassistant/components/cloud/cloud_api.py
Normal file
297
homeassistant/components/cloud/cloud_api.py
Normal 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)
|
14
homeassistant/components/cloud/const.py
Normal file
14
homeassistant/components/cloud/const.py
Normal 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')
|
||||
}
|
||||
}
|
119
homeassistant/components/cloud/http_api.py
Normal file
119
homeassistant/components/cloud/http_api.py
Normal 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)
|
10
homeassistant/components/cloud/util.py
Normal file
10
homeassistant/components/cloud/util.py
Normal 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']
|
@ -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
|
||||
|
1
tests/components/cloud/__init__.py
Normal file
1
tests/components/cloud/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the cloud component."""
|
352
tests/components/cloud/test_cloud_api.py
Normal file
352
tests/components/cloud/test_cloud_api.py
Normal 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
|
157
tests/components/cloud/test_http_api.py
Normal file
157
tests/components/cloud/test_http_api.py
Normal 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]
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user