Offload Cloud component (#21937)

* Offload Cloud component & Remote support

* Make hound happy

* Address comments
This commit is contained in:
Pascal Vizeli 2019-03-11 20:21:20 +01:00 committed by Paulus Schoutsen
parent 8bfbe3e085
commit 92ff49212b
21 changed files with 646 additions and 2170 deletions

View File

@ -1,47 +1,38 @@
"""Component to integrate the Home Assistant cloud.""" """Component to integrate the Home Assistant cloud."""
from datetime import datetime, timedelta
import json
import logging import logging
import os
import voluptuous as vol import voluptuous as vol
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import const as ga_c
from homeassistant.const import (
CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP)
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.const import ( from homeassistant.helpers import config_validation as cv, entityfilter
EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION,
CONF_MODE, CONF_NAME)
from homeassistant.helpers import entityfilter, config_validation as cv
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from homeassistant.util.aiohttp import MockRequest from homeassistant.util.aiohttp import MockRequest
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import helpers as ga_h
from homeassistant.components.google_assistant import const as ga_c
from . import http_api, iot, auth_api, prefs, cloudhooks from . import http_api
from .const import CONFIG_DIR, DOMAIN, SERVERS, STATE_CONNECTED from .const import (
CONF_ACME_DIRECTORY_SERVER, CONF_ALEXA, CONF_ALIASES,
CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL,
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD)
from .prefs import CloudPreferences
REQUIREMENTS = ['warrant==0.6.1'] REQUIREMENTS = ['hass-nabucasa==0.3']
DEPENDENCIES = ['http']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_ALEXA = 'alexa' DEFAULT_MODE = MODE_PROD
CONF_ALIASES = 'aliases'
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
CONF_ENTITY_CONFIG = 'entity_config'
CONF_FILTER = 'filter'
CONF_GOOGLE_ACTIONS = 'google_actions'
CONF_RELAYER = 'relayer'
CONF_USER_POOL_ID = 'user_pool_id'
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
DEFAULT_MODE = 'production' SERVICE_REMOTE_CONNECT = 'remote_connect'
DEPENDENCIES = ['http'] SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
MODE_DEV = 'development'
ALEXA_ENTITY_SCHEMA = vol.Schema({ ALEXA_ENTITY_SCHEMA = vol.Schema({
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
@ -56,7 +47,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({
}) })
ASSISTANT_SCHEMA = vol.Schema({ ASSISTANT_SCHEMA = vol.Schema({
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, vol.Optional(CONF_FILTER, default=dict): entityfilter.FILTER_SCHEMA,
}) })
ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
@ -67,18 +58,21 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
}) })
# pylint: disable=no-value-for-parameter
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({ DOMAIN: vol.Schema({
vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.Optional(CONF_MODE, default=DEFAULT_MODE):
vol.In([MODE_DEV] + list(SERVERS)), vol.In([MODE_DEV, MODE_PROD]),
# Change to optional when we include real servers # Change to optional when we include real servers
vol.Optional(CONF_COGNITO_CLIENT_ID): str, vol.Optional(CONF_COGNITO_CLIENT_ID): str,
vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_USER_POOL_ID): str,
vol.Optional(CONF_REGION): str, vol.Optional(CONF_REGION): str,
vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_RELAYER): str,
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(),
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(),
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(),
vol.Optional(CONF_REMOTE_API_URL): vol.Url(),
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
}), }),
@ -133,189 +127,48 @@ def is_cloudhook_request(request):
async def async_setup(hass, config): async def async_setup(hass, config):
"""Initialize the Home Assistant cloud.""" """Initialize the Home Assistant cloud."""
from hass_nabucasa import Cloud
from .client import CloudClient
# Process configs
if DOMAIN in config: if DOMAIN in config:
kwargs = dict(config[DOMAIN]) kwargs = dict(config[DOMAIN])
else: else:
kwargs = {CONF_MODE: DEFAULT_MODE} kwargs = {CONF_MODE: DEFAULT_MODE}
alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({})
google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({})
if CONF_GOOGLE_ACTIONS not in kwargs: prefs = CloudPreferences(hass)
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) await prefs.async_initialize()
kwargs[CONF_ALEXA] = alexa_sh.Config( websession = hass.helpers.aiohttp_client.async_get_clientsession()
endpoint=None, client = CloudClient(hass, prefs, websession, alexa_conf, google_conf)
async_get_access_token=None, cloud = hass.data[DOMAIN] = Cloud(client, **kwargs)
should_expose=alexa_conf[CONF_FILTER],
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), async def _startup(event):
) """Startup event."""
await cloud.start()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup)
async def _shutdown(event):
"""Shutdown event."""
await cloud.stop()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
async def _service_handler(service):
"""Handle service for cloud."""
if service.service == SERVICE_REMOTE_CONNECT:
await cloud.remote.connect()
elif service.service == SERVICE_REMOTE_DISCONNECT:
await cloud.remote.disconnect()
hass.services.async_register(
DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler)
hass.services.async_register(
DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler)
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
await auth_api.async_setup(hass, cloud)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start)
await http_api.async_setup(hass) await http_api.async_setup(hass)
return True return True
class Cloud:
"""Store the configuration of the cloud connection."""
def __init__(self, hass, mode, alexa, google_actions,
cognito_client_id=None, user_pool_id=None, region=None,
relayer=None, google_actions_sync_url=None,
subscription_info_url=None, cloudhook_create_url=None):
"""Create an instance of Cloud."""
self.hass = hass
self.mode = mode
self.alexa_config = alexa
self.google_actions_user_conf = google_actions
self._gactions_config = None
self.prefs = prefs.CloudPreferences(hass)
self.id_token = None
self.access_token = None
self.refresh_token = None
self.iot = iot.CloudIoT(self)
self.cloudhooks = cloudhooks.Cloudhooks(self)
if mode == MODE_DEV:
self.cognito_client_id = cognito_client_id
self.user_pool_id = user_pool_id
self.region = region
self.relayer = relayer
self.google_actions_sync_url = google_actions_sync_url
self.subscription_info_url = subscription_info_url
self.cloudhook_create_url = cloudhook_create_url
else:
info = SERVERS[mode]
self.cognito_client_id = info['cognito_client_id']
self.user_pool_id = info['user_pool_id']
self.region = info['region']
self.relayer = info['relayer']
self.google_actions_sync_url = info['google_actions_sync_url']
self.subscription_info_url = info['subscription_info_url']
self.cloudhook_create_url = info['cloudhook_create_url']
@property
def is_logged_in(self):
"""Get if cloud is logged in."""
return self.id_token is not None
@property
def is_connected(self):
"""Get if cloud is connected."""
return self.iot.state == STATE_CONNECTED
@property
def subscription_expired(self):
"""Return a boolean if the subscription has expired."""
return dt_util.utcnow() > self.expiration_date + timedelta(days=7)
@property
def expiration_date(self):
"""Return the subscription expiration as a UTC datetime object."""
return datetime.combine(
dt_util.parse_date(self.claims['custom:sub-exp']),
datetime.min.time()).replace(tzinfo=dt_util.UTC)
@property
def claims(self):
"""Return the claims from the id token."""
return self._decode_claims(self.id_token)
@property
def user_info_path(self):
"""Get path to the stored auth."""
return self.path('{}_auth.json'.format(self.mode))
@property
def gactions_config(self):
"""Return the Google Assistant config."""
if self._gactions_config is None:
conf = self.google_actions_user_conf
def should_expose(entity):
"""If an entity should be exposed."""
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
return conf['filter'](entity.entity_id)
self._gactions_config = ga_h.Config(
should_expose=should_expose,
allow_unlock=self.prefs.google_allow_unlock,
entity_config=conf.get(CONF_ENTITY_CONFIG),
)
return self._gactions_config
def path(self, *parts):
"""Get config path inside cloud dir.
Async friendly.
"""
return self.hass.config.path(CONFIG_DIR, *parts)
async def fetch_subscription_info(self):
"""Fetch subscription info."""
await self.hass.async_add_executor_job(auth_api.check_token, self)
websession = self.hass.helpers.aiohttp_client.async_get_clientsession()
return await websession.get(
self.subscription_info_url, headers={
'authorization': self.id_token
})
async def logout(self):
"""Close connection and remove all credentials."""
await self.iot.disconnect()
self.id_token = None
self.access_token = None
self.refresh_token = None
self._gactions_config = None
await self.hass.async_add_job(
lambda: os.remove(self.user_info_path))
def write_user_info(self):
"""Write user info to a file."""
with open(self.user_info_path, 'wt') as file:
file.write(json.dumps({
'id_token': self.id_token,
'access_token': self.access_token,
'refresh_token': self.refresh_token,
}, indent=4))
async def async_start(self, _):
"""Start the cloud component."""
def load_config():
"""Load config."""
# Ensure config dir exists
path = self.hass.config.path(CONFIG_DIR)
if not os.path.isdir(path):
os.mkdir(path)
user_info = self.user_info_path
if not os.path.isfile(user_info):
return None
with open(user_info, 'rt') as file:
return json.loads(file.read())
info = await self.hass.async_add_job(load_config)
await self.prefs.async_initialize()
if info is None:
return
self.id_token = info['id_token']
self.access_token = info['access_token']
self.refresh_token = info['refresh_token']
self.hass.async_create_task(self.iot.connect())
def _decode_claims(self, token): # pylint: disable=no-self-use
"""Decode the claims in a token."""
from jose import jwt
return jwt.get_unverified_claims(token)

View File

@ -1,232 +0,0 @@
"""Package to communicate with the authentication API."""
import asyncio
import logging
import random
_LOGGER = logging.getLogger(__name__)
class CloudError(Exception):
"""Base class for cloud related errors."""
class Unauthenticated(CloudError):
"""Raised when authentication failed."""
class UserNotFound(CloudError):
"""Raised when a user is not found."""
class UserNotConfirmed(CloudError):
"""Raised when a user has not confirmed email yet."""
class PasswordChangeRequired(CloudError):
"""Raised when a password change is required."""
# https://github.com/PyCQA/pylint/issues/1085
# pylint: disable=useless-super-delegation
def __init__(self, message='Password change required.'):
"""Initialize a password change required error."""
super().__init__(message)
class UnknownError(CloudError):
"""Raised when an unknown error occurs."""
AWS_EXCEPTIONS = {
'UserNotFoundException': UserNotFound,
'NotAuthorizedException': Unauthenticated,
'UserNotConfirmedException': UserNotConfirmed,
'PasswordResetRequiredException': PasswordChangeRequired,
}
async def async_setup(hass, cloud):
"""Configure the auth api."""
refresh_task = None
async def handle_token_refresh():
"""Handle Cloud access token refresh."""
sleep_time = 5
sleep_time = random.randint(2400, 3600)
while True:
try:
await asyncio.sleep(sleep_time)
await hass.async_add_executor_job(renew_access_token, cloud)
except CloudError as err:
_LOGGER.error("Can't refresh cloud token: %s", err)
except asyncio.CancelledError:
# Task is canceled, stop it.
break
sleep_time = random.randint(3100, 3600)
async def on_connect():
"""When the instance is connected."""
nonlocal refresh_task
refresh_task = hass.async_create_task(handle_token_refresh())
async def on_disconnect():
"""When the instance is disconnected."""
nonlocal refresh_task
refresh_task.cancel()
cloud.iot.register_on_connect(on_connect)
cloud.iot.register_on_disconnect(on_disconnect)
def _map_aws_exception(err):
"""Map AWS exception to our exceptions."""
ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError)
return ex(err.response['Error']['Message'])
def register(cloud, email, password):
"""Register a new account."""
from botocore.exceptions import ClientError, EndpointConnectionError
cognito = _cognito(cloud)
# Workaround for bug in Warrant. PR with fix:
# https://github.com/capless/warrant/pull/82
cognito.add_base_attributes()
try:
cognito.register(email, password)
except ClientError as err:
raise _map_aws_exception(err)
except EndpointConnectionError:
raise UnknownError()
def resend_email_confirm(cloud, email):
"""Resend email confirmation."""
from botocore.exceptions import ClientError, EndpointConnectionError
cognito = _cognito(cloud, username=email)
try:
cognito.client.resend_confirmation_code(
Username=email,
ClientId=cognito.client_id
)
except ClientError as err:
raise _map_aws_exception(err)
except EndpointConnectionError:
raise UnknownError()
def forgot_password(cloud, email):
"""Initialize forgotten password flow."""
from botocore.exceptions import ClientError, EndpointConnectionError
cognito = _cognito(cloud, username=email)
try:
cognito.initiate_forgot_password()
except ClientError as err:
raise _map_aws_exception(err)
except EndpointConnectionError:
raise UnknownError()
def login(cloud, email, password):
"""Log user in and fetch certificate."""
cognito = _authenticate(cloud, email, password)
cloud.id_token = cognito.id_token
cloud.access_token = cognito.access_token
cloud.refresh_token = cognito.refresh_token
cloud.write_user_info()
def check_token(cloud):
"""Check that the token is valid and verify if needed."""
from botocore.exceptions import ClientError, EndpointConnectionError
cognito = _cognito(
cloud,
access_token=cloud.access_token,
refresh_token=cloud.refresh_token)
try:
if cognito.check_token():
cloud.id_token = cognito.id_token
cloud.access_token = cognito.access_token
cloud.write_user_info()
except ClientError as err:
raise _map_aws_exception(err)
except EndpointConnectionError:
raise UnknownError()
def renew_access_token(cloud):
"""Renew access token."""
from botocore.exceptions import ClientError, EndpointConnectionError
cognito = _cognito(
cloud,
access_token=cloud.access_token,
refresh_token=cloud.refresh_token)
try:
cognito.renew_access_token()
cloud.id_token = cognito.id_token
cloud.access_token = cognito.access_token
cloud.write_user_info()
except ClientError as err:
raise _map_aws_exception(err)
except EndpointConnectionError:
raise UnknownError()
def _authenticate(cloud, email, password):
"""Log in and return an authenticated Cognito instance."""
from botocore.exceptions import ClientError, EndpointConnectionError
from warrant.exceptions import ForceChangePasswordException
assert not cloud.is_logged_in, 'Cannot login if already logged in.'
cognito = _cognito(cloud, username=email)
try:
cognito.authenticate(password=password)
return cognito
except ForceChangePasswordException:
raise PasswordChangeRequired()
except ClientError as err:
raise _map_aws_exception(err)
except EndpointConnectionError:
raise UnknownError()
def _cognito(cloud, **kwargs):
"""Get the client credentials."""
import botocore
import boto3
from warrant import Cognito
cognito = Cognito(
user_pool_id=cloud.user_pool_id,
client_id=cloud.cognito_client_id,
user_pool_region=cloud.region,
**kwargs
)
cognito.client = boto3.client(
'cognito-idp',
region_name=cloud.region,
config=botocore.config.Config(
signature_version=botocore.UNSIGNED
)
)
return cognito

View File

@ -0,0 +1,180 @@
"""Interface implementation for cloud client."""
import asyncio
from pathlib import Path
from typing import Any, Dict
import aiohttp
from hass_nabucasa.client import CloudClient as Interface
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import (
helpers as ga_h, smart_home as ga)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.aiohttp import MockRequest
from . import utils
from .const import CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN
from .prefs import CloudPreferences
class CloudClient(Interface):
"""Interface class for Home Assistant Cloud."""
def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences,
websession: aiohttp.ClientSession,
alexa_config: Dict[str, Any], google_config: Dict[str, Any]):
"""Initialize client interface to Cloud."""
self._hass = hass
self._prefs = prefs
self._websession = websession
self._alexa_user_config = alexa_config
self._google_user_config = google_config
self._alexa_config = None
self._google_config = None
@property
def base_path(self) -> Path:
"""Return path to base dir."""
return Path(self._hass.config.config_dir)
@property
def prefs(self) -> CloudPreferences:
"""Return Cloud preferences."""
return self._prefs
@property
def loop(self) -> asyncio.BaseEventLoop:
"""Return client loop."""
return self._hass.loop
@property
def websession(self) -> aiohttp.ClientSession:
"""Return client session for aiohttp."""
return self._websession
@property
def aiohttp_runner(self) -> aiohttp.web.AppRunner:
"""Return client webinterface aiohttp application."""
return self._hass.http.runner
@property
def cloudhooks(self) -> Dict[str, Dict[str, str]]:
"""Return list of cloudhooks."""
return self._prefs.cloudhooks
@property
def alexa_config(self) -> alexa_sh.Config:
"""Return Alexa config."""
if not self._alexa_config:
alexa_conf = self._alexa_user_config
self._alexa_config = alexa_sh.Config(
endpoint=None,
async_get_access_token=None,
should_expose=alexa_conf[CONF_FILTER],
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
)
return self._alexa_config
@property
def google_config(self) -> ga_h.Config:
"""Return Google config."""
if not self._google_config:
google_conf = self._google_user_config
def should_expose(entity):
"""If an entity should be exposed."""
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
return google_conf['filter'](entity.entity_id)
self._google_config = ga_h.Config(
should_expose=should_expose,
allow_unlock=self._prefs.google_allow_unlock,
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
)
return self._google_config
@property
def google_user_config(self) -> Dict[str, Any]:
"""Return google action user config."""
return self._google_user_config
async def cleanups(self) -> None:
"""Cleanup some stuff after logout."""
self._alexa_config = None
self._google_config = None
async def async_user_message(
self, identifier: str, title: str, message: str) -> None:
"""Create a message for user to UI."""
self._hass.components.persistent_notification.async_create(
message, title, identifier
)
async def async_alexa_message(
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
"""Process cloud alexa message to client."""
return await alexa_sh.async_handle_message(
self._hass, self.alexa_config, payload,
enabled=self._prefs.alexa_enabled
)
async def async_google_message(
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
"""Process cloud google message to client."""
if not self._prefs.google_enabled:
return ga.turned_off_response(payload)
cloud = self._hass.data[DOMAIN]
return await ga.async_handle_message(
self._hass, self.google_config,
cloud.claims['cognito:username'], payload
)
async def async_webhook_message(
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
"""Process cloud webhook message to client."""
cloudhook_id = payload['cloudhook_id']
found = None
for cloudhook in self._prefs.cloudhooks.values():
if cloudhook['cloudhook_id'] == cloudhook_id:
found = cloudhook
break
if found is None:
return {
'status': 200
}
request = MockRequest(
content=payload['body'].encode('utf-8'),
headers=payload['headers'],
method=payload['method'],
query_string=payload['query'],
)
response = await self._hass.components.webhook.async_handle_webhook(
found['webhook_id'], request)
response_dict = utils.aiohttp_serialize_response(response)
body = response_dict.get('body')
return {
'body': body,
'status': response_dict['status'],
'headers': {
'Content-Type': response.content_type
}
}
async def async_cloudhooks_update(
self, data: Dict[str, Dict[str, str]]) -> None:
"""Update local list of cloudhooks."""
await self._prefs.async_update(cloudhooks=data)

View File

@ -1,42 +0,0 @@
"""Cloud APIs."""
from functools import wraps
import logging
from . import auth_api
_LOGGER = logging.getLogger(__name__)
def _check_token(func):
"""Decorate a function to verify valid token."""
@wraps(func)
async def check_token(cloud, *args):
"""Validate token, then call func."""
await cloud.hass.async_add_executor_job(auth_api.check_token, cloud)
return await func(cloud, *args)
return check_token
def _log_response(func):
"""Decorate a function to log bad responses."""
@wraps(func)
async def log_response(*args):
"""Log response if it's bad."""
resp = await func(*args)
meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning
meth('Fetched %s (%s)', resp.url, resp.status)
return resp
return log_response
@_check_token
@_log_response
async def async_create_cloudhook(cloud):
"""Create a cloudhook."""
websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession()
return await websession.post(
cloud.cloudhook_create_url, headers={
'authorization': cloud.id_token
})

View File

@ -1,69 +0,0 @@
"""Manage cloud cloudhooks."""
import async_timeout
from . import cloud_api
class Cloudhooks:
"""Class to help manage cloudhooks."""
def __init__(self, cloud):
"""Initialize cloudhooks."""
self.cloud = cloud
self.cloud.iot.register_on_connect(self.async_publish_cloudhooks)
async def async_publish_cloudhooks(self):
"""Inform the Relayer of the cloudhooks that we support."""
if not self.cloud.is_connected:
return
cloudhooks = self.cloud.prefs.cloudhooks
await self.cloud.iot.async_send_message('webhook-register', {
'cloudhook_ids': [info['cloudhook_id'] for info
in cloudhooks.values()]
}, expect_answer=False)
async def async_create(self, webhook_id):
"""Create a cloud webhook."""
cloudhooks = self.cloud.prefs.cloudhooks
if webhook_id in cloudhooks:
raise ValueError('Hook is already enabled for the cloud.')
if not self.cloud.iot.connected:
raise ValueError("Cloud is not connected")
# Create cloud hook
with async_timeout.timeout(10):
resp = await cloud_api.async_create_cloudhook(self.cloud)
data = await resp.json()
cloudhook_id = data['cloudhook_id']
cloudhook_url = data['url']
# Store hook
cloudhooks = dict(cloudhooks)
hook = cloudhooks[webhook_id] = {
'webhook_id': webhook_id,
'cloudhook_id': cloudhook_id,
'cloudhook_url': cloudhook_url
}
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
await self.async_publish_cloudhooks()
return hook
async def async_delete(self, webhook_id):
"""Delete a cloud webhook."""
cloudhooks = self.cloud.prefs.cloudhooks
if webhook_id not in cloudhooks:
raise ValueError('Hook is not enabled for the cloud.')
# Remove hook
cloudhooks = dict(cloudhooks)
cloudhooks.pop(webhook_id)
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
await self.async_publish_cloudhooks()

View File

@ -1,6 +1,5 @@
"""Constants for the cloud component.""" """Constants for the cloud component."""
DOMAIN = 'cloud' DOMAIN = 'cloud'
CONFIG_DIR = '.cloud'
REQUEST_TIMEOUT = 10 REQUEST_TIMEOUT = 10
PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_ALEXA = 'alexa_enabled'
@ -8,31 +7,19 @@ PREF_ENABLE_GOOGLE = 'google_enabled'
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUDHOOKS = 'cloudhooks'
SERVERS = { CONF_ALEXA = 'alexa'
'production': { CONF_ALIASES = 'aliases'
'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
'user_pool_id': 'us-east-1_87ll5WOP8', CONF_ENTITY_CONFIG = 'entity_config'
'region': 'us-east-1', CONF_FILTER = 'filter'
'relayer': 'wss://cloud.hass.io:8000/websocket', CONF_GOOGLE_ACTIONS = 'google_actions'
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' CONF_RELAYER = 'relayer'
'amazonaws.com/prod/smart_home_sync'), CONF_USER_POOL_ID = 'user_pool_id'
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
'subscription_info'), CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate' CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
} CONF_REMOTE_API_URL = 'remote_api_url'
} CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
MESSAGE_EXPIRATION = """ MODE_DEV = "development"
It looks like your Home Assistant Cloud subscription has expired. Please check MODE_PROD = "production"
your [account page](/config/cloud/account) to continue using the service.
"""
MESSAGE_AUTH_FAIL = """
You have been logged out of Home Assistant Cloud because we have been unable
to verify your credentials. Please [log in](/config/cloud) again to continue
using the service.
"""
STATE_CONNECTING = 'connecting'
STATE_CONNECTED = 'connected'
STATE_DISCONNECTED = 'disconnected'

View File

@ -15,11 +15,9 @@ from homeassistant.components import websocket_api
from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import smart_home as google_sh from homeassistant.components.google_assistant import smart_home as google_sh
from . import auth_api
from .const import ( from .const import (
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_ALLOW_UNLOCK) PREF_GOOGLE_ALLOW_UNLOCK)
from .iot import STATE_DISCONNECTED, STATE_CONNECTED
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -59,6 +57,9 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
}) })
_CLOUD_ERRORS = {}
async def async_setup(hass): async def async_setup(hass):
"""Initialize the HTTP API.""" """Initialize the HTTP API."""
hass.components.websocket_api.async_register_command( hass.components.websocket_api.async_register_command(
@ -88,14 +89,20 @@ async def async_setup(hass):
hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView) hass.http.register_view(CloudForgotPasswordView)
from hass_nabucasa import auth
_CLOUD_ERRORS = { _CLOUD_ERRORS.update({
auth_api.UserNotFound: (400, "User does not exist."), auth.UserNotFound:
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), (400, "User does not exist."),
auth_api.Unauthenticated: (401, 'Authentication failed.'), auth.UserNotConfirmed:
auth_api.PasswordChangeRequired: (400, 'Password change required.'), (400, 'Email not confirmed.'),
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') auth.Unauthenticated:
} (401, 'Authentication failed.'),
auth.PasswordChangeRequired:
(400, 'Password change required.'),
asyncio.TimeoutError:
(502, 'Unable to reach the Home Assistant cloud.')
})
def _handle_cloud_errors(handler): def _handle_cloud_errors(handler):
@ -135,7 +142,7 @@ class GoogleActionsSyncView(HomeAssistantView):
websession = hass.helpers.aiohttp_client.async_get_clientsession() websession = hass.helpers.aiohttp_client.async_get_clientsession()
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
await hass.async_add_job(auth_api.check_token, cloud) await hass.async_add_job(cloud.auth.check_token)
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
req = await websession.post( req = await websession.post(
@ -163,7 +170,7 @@ class CloudLoginView(HomeAssistantView):
cloud = hass.data[DOMAIN] cloud = hass.data[DOMAIN]
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
await hass.async_add_job(auth_api.login, cloud, data['email'], await hass.async_add_job(cloud.auth.login, data['email'],
data['password']) data['password'])
hass.async_add_job(cloud.iot.connect) hass.async_add_job(cloud.iot.connect)
@ -206,7 +213,7 @@ class CloudRegisterView(HomeAssistantView):
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
await hass.async_add_job( await hass.async_add_job(
auth_api.register, cloud, data['email'], data['password']) cloud.auth.register, data['email'], data['password'])
return self.json_message('ok') return self.json_message('ok')
@ -228,7 +235,7 @@ class CloudResendConfirmView(HomeAssistantView):
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
await hass.async_add_job( await hass.async_add_job(
auth_api.resend_email_confirm, cloud, data['email']) cloud.auth.resend_email_confirm, data['email'])
return self.json_message('ok') return self.json_message('ok')
@ -250,7 +257,7 @@ class CloudForgotPasswordView(HomeAssistantView):
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
await hass.async_add_job( await hass.async_add_job(
auth_api.forgot_password, cloud, data['email']) cloud.auth.forgot_password, data['email'])
return self.json_message('ok') return self.json_message('ok')
@ -307,6 +314,7 @@ def _handle_aiohttp_errors(handler):
@websocket_api.async_response @websocket_api.async_response
async def websocket_subscription(hass, connection, msg): async def websocket_subscription(hass, connection, msg):
"""Handle request for account info.""" """Handle request for account info."""
from hass_nabucasa.const import STATE_DISCONNECTED
cloud = hass.data[DOMAIN] cloud = hass.data[DOMAIN]
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
@ -320,11 +328,10 @@ async def websocket_subscription(hass, connection, msg):
# Check if a user is subscribed but local info is outdated # Check if a user is subscribed but local info is outdated
# In that case, let's refresh and reconnect # In that case, let's refresh and reconnect
if data.get('provider') and cloud.iot.state != STATE_CONNECTED: if data.get('provider') and not cloud.is_connected:
_LOGGER.debug( _LOGGER.debug(
"Found disconnected account with valid subscriotion, connecting") "Found disconnected account with valid subscriotion, connecting")
await hass.async_add_executor_job( await hass.async_add_executor_job(cloud.auth.renew_access_token)
auth_api.renew_access_token, cloud)
# Cancel reconnect in progress # Cancel reconnect in progress
if cloud.iot.state != STATE_DISCONNECTED: if cloud.iot.state != STATE_DISCONNECTED:
@ -344,7 +351,7 @@ async def websocket_update_prefs(hass, connection, msg):
changes = dict(msg) changes = dict(msg)
changes.pop('id') changes.pop('id')
changes.pop('type') changes.pop('type')
await cloud.prefs.async_update(**changes) await cloud.client.prefs.async_update(**changes)
connection.send_message(websocket_api.result_message(msg['id'])) connection.send_message(websocket_api.result_message(msg['id']))
@ -370,6 +377,8 @@ async def websocket_hook_delete(hass, connection, msg):
def _account_data(cloud): def _account_data(cloud):
"""Generate the auth data JSON response.""" """Generate the auth data JSON response."""
from hass_nabucasa.const import STATE_DISCONNECTED
if not cloud.is_logged_in: if not cloud.is_logged_in:
return { return {
'logged_in': False, 'logged_in': False,
@ -377,14 +386,15 @@ def _account_data(cloud):
} }
claims = cloud.claims claims = cloud.claims
client = cloud.client
return { return {
'logged_in': True, 'logged_in': True,
'email': claims['email'], 'email': claims['email'],
'cloud': cloud.iot.state, 'cloud': cloud.iot.state,
'prefs': cloud.prefs.as_dict(), 'prefs': client.prefs.as_dict(),
'google_entities': cloud.google_actions_user_conf['filter'].config, 'google_entities': client.google_user_config['filter'].config,
'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES),
'alexa_entities': cloud.alexa_config.should_expose.config, 'alexa_entities': client.alexa_config.should_expose.config,
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
} }

View File

@ -1,392 +0,0 @@
"""Module to handle messages from Home Assistant cloud."""
import asyncio
import logging
import pprint
import random
import uuid
from aiohttp import hdrs, client_exceptions, WSMsgType
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.components.alexa import smart_home as alexa
from homeassistant.components.google_assistant import smart_home as ga
from homeassistant.core import callback
from homeassistant.util.decorator import Registry
from homeassistant.util.aiohttp import MockRequest
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import auth_api
from . import utils
from .const import (
MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL, STATE_CONNECTED, STATE_CONNECTING,
STATE_DISCONNECTED
)
HANDLERS = Registry()
_LOGGER = logging.getLogger(__name__)
class UnknownHandler(Exception):
"""Exception raised when trying to handle unknown handler."""
class NotConnected(Exception):
"""Exception raised when trying to handle unknown handler."""
class ErrorMessage(Exception):
"""Exception raised when there was error handling message in the cloud."""
def __init__(self, error):
"""Initialize Error Message."""
super().__init__(self, "Error in Cloud")
self.error = error
class CloudIoT:
"""Class to manage the IoT connection."""
def __init__(self, cloud):
"""Initialize the CloudIoT class."""
self.cloud = cloud
# The WebSocket client
self.client = None
# Scheduled sleep task till next connection retry
self.retry_task = None
# Boolean to indicate if we wanted the connection to close
self.close_requested = False
# The current number of attempts to connect, impacts wait time
self.tries = 0
# Current state of the connection
self.state = STATE_DISCONNECTED
# Local code waiting for a response
self._response_handler = {}
self._on_connect = []
self._on_disconnect = []
@callback
def register_on_connect(self, on_connect_cb):
"""Register an async on_connect callback."""
self._on_connect.append(on_connect_cb)
@callback
def register_on_disconnect(self, on_disconnect_cb):
"""Register an async on_disconnect callback."""
self._on_disconnect.append(on_disconnect_cb)
@property
def connected(self):
"""Return if we're currently connected."""
return self.state == STATE_CONNECTED
@asyncio.coroutine
def connect(self):
"""Connect to the IoT broker."""
if self.state != STATE_DISCONNECTED:
raise RuntimeError('Connect called while not disconnected')
hass = self.cloud.hass
self.close_requested = False
self.state = STATE_CONNECTING
self.tries = 0
@asyncio.coroutine
def _handle_hass_stop(event):
"""Handle Home Assistant shutting down."""
nonlocal remove_hass_stop_listener
remove_hass_stop_listener = None
yield from self.disconnect()
remove_hass_stop_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _handle_hass_stop)
while True:
try:
yield from self._handle_connection()
except Exception: # pylint: disable=broad-except
# Safety net. This should never hit.
# Still adding it here to make sure we can always reconnect
_LOGGER.exception("Unexpected error")
if self.state == STATE_CONNECTED and self._on_disconnect:
try:
yield from asyncio.wait([
cb() for cb in self._on_disconnect
])
except Exception: # pylint: disable=broad-except
# Safety net. This should never hit.
# Still adding it here to make sure we don't break the flow
_LOGGER.exception(
"Unexpected error in on_disconnect callbacks")
if self.close_requested:
break
self.state = STATE_CONNECTING
self.tries += 1
try:
# Sleep 2^tries + 0…tries*3 seconds between retries
self.retry_task = hass.async_create_task(
asyncio.sleep(2**min(9, self.tries) +
random.randint(0, self.tries * 3),
loop=hass.loop))
yield from self.retry_task
self.retry_task = None
except asyncio.CancelledError:
# Happens if disconnect called
break
self.state = STATE_DISCONNECTED
if remove_hass_stop_listener is not None:
remove_hass_stop_listener()
async def async_send_message(self, handler, payload,
expect_answer=True):
"""Send a message."""
if self.state != STATE_CONNECTED:
raise NotConnected
msgid = uuid.uuid4().hex
if expect_answer:
fut = self._response_handler[msgid] = asyncio.Future()
message = {
'msgid': msgid,
'handler': handler,
'payload': payload,
}
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Publishing message:\n%s\n",
pprint.pformat(message))
await self.client.send_json(message)
if expect_answer:
return await fut
@asyncio.coroutine
def _handle_connection(self):
"""Connect to the IoT broker."""
hass = self.cloud.hass
try:
yield from hass.async_add_job(auth_api.check_token, self.cloud)
except auth_api.Unauthenticated as err:
_LOGGER.error('Unable to refresh token: %s', err)
hass.components.persistent_notification.async_create(
MESSAGE_AUTH_FAIL, 'Home Assistant Cloud',
'cloud_subscription_expired')
# Don't await it because it will cancel this task
hass.async_create_task(self.cloud.logout())
return
except auth_api.CloudError as err:
_LOGGER.warning("Unable to refresh token: %s", err)
return
if self.cloud.subscription_expired:
hass.components.persistent_notification.async_create(
MESSAGE_EXPIRATION, 'Home Assistant Cloud',
'cloud_subscription_expired')
self.close_requested = True
return
session = async_get_clientsession(self.cloud.hass)
client = None
disconnect_warn = None
try:
self.client = client = yield from session.ws_connect(
self.cloud.relayer, heartbeat=55, headers={
hdrs.AUTHORIZATION:
'Bearer {}'.format(self.cloud.id_token)
})
self.tries = 0
_LOGGER.info("Connected")
self.state = STATE_CONNECTED
if self._on_connect:
try:
yield from asyncio.wait([cb() for cb in self._on_connect])
except Exception: # pylint: disable=broad-except
# Safety net. This should never hit.
# Still adding it here to make sure we don't break the flow
_LOGGER.exception(
"Unexpected error in on_connect callbacks")
while not client.closed:
msg = yield from client.receive()
if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING):
break
elif msg.type == WSMsgType.ERROR:
disconnect_warn = 'Connection error'
break
elif msg.type != WSMsgType.TEXT:
disconnect_warn = 'Received non-Text message: {}'.format(
msg.type)
break
try:
msg = msg.json()
except ValueError:
disconnect_warn = 'Received invalid JSON.'
break
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Received message:\n%s\n",
pprint.pformat(msg))
response_handler = self._response_handler.pop(msg['msgid'],
None)
if response_handler is not None:
if 'payload' in msg:
response_handler.set_result(msg["payload"])
else:
response_handler.set_exception(
ErrorMessage(msg['error']))
continue
response = {
'msgid': msg['msgid'],
}
try:
result = yield from async_handle_message(
hass, self.cloud, msg['handler'], msg['payload'])
# No response from handler
if result is None:
continue
response['payload'] = result
except UnknownHandler:
response['error'] = 'unknown-handler'
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error handling message")
response['error'] = 'exception'
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Publishing message:\n%s\n",
pprint.pformat(response))
yield from client.send_json(response)
except client_exceptions.WSServerHandshakeError as err:
if err.status == 401:
disconnect_warn = 'Invalid auth.'
self.close_requested = True
# Should we notify user?
else:
_LOGGER.warning("Unable to connect: %s", err)
except client_exceptions.ClientError as err:
_LOGGER.warning("Unable to connect: %s", err)
finally:
if disconnect_warn is None:
_LOGGER.info("Connection closed")
else:
_LOGGER.warning("Connection closed: %s", disconnect_warn)
@asyncio.coroutine
def disconnect(self):
"""Disconnect the client."""
self.close_requested = True
if self.client is not None:
yield from self.client.close()
elif self.retry_task is not None:
self.retry_task.cancel()
@asyncio.coroutine
def async_handle_message(hass, cloud, handler_name, payload):
"""Handle incoming IoT message."""
handler = HANDLERS.get(handler_name)
if handler is None:
raise UnknownHandler()
return (yield from handler(hass, cloud, payload))
@HANDLERS.register('alexa')
@asyncio.coroutine
def async_handle_alexa(hass, cloud, payload):
"""Handle an incoming IoT message for Alexa."""
result = yield from alexa.async_handle_message(
hass, cloud.alexa_config, payload,
enabled=cloud.prefs.alexa_enabled)
return result
@HANDLERS.register('google_actions')
@asyncio.coroutine
def async_handle_google_actions(hass, cloud, payload):
"""Handle an incoming IoT message for Google Actions."""
if not cloud.prefs.google_enabled:
return ga.turned_off_response(payload)
result = yield from ga.async_handle_message(
hass, cloud.gactions_config,
cloud.claims['cognito:username'],
payload)
return result
@HANDLERS.register('cloud')
async def async_handle_cloud(hass, cloud, payload):
"""Handle an incoming IoT message for cloud component."""
action = payload['action']
if action == 'logout':
# Log out of Home Assistant Cloud
await cloud.logout()
_LOGGER.error("You have been logged out from Home Assistant cloud: %s",
payload['reason'])
else:
_LOGGER.warning("Received unknown cloud action: %s", action)
@HANDLERS.register('webhook')
async def async_handle_webhook(hass, cloud, payload):
"""Handle an incoming IoT message for cloud webhooks."""
cloudhook_id = payload['cloudhook_id']
found = None
for cloudhook in cloud.prefs.cloudhooks.values():
if cloudhook['cloudhook_id'] == cloudhook_id:
found = cloudhook
break
if found is None:
return {
'status': 200
}
request = MockRequest(
content=payload['body'].encode('utf-8'),
headers=payload['headers'],
method=payload['method'],
query_string=payload['query'],
)
response = await hass.components.webhook.async_handle_webhook(
found['webhook_id'], request)
response_dict = utils.aiohttp_serialize_response(response)
body = response_dict.get('body')
return {
'body': body,
'status': response_dict['status'],
'headers': {
'Content-Type': response.content_type
}
}

View File

@ -0,0 +1,7 @@
# Describes the format for available light services
remote_connect:
description: Make instance UI available outside over NabuCasa cloud.
remote_disconnect:
description: Disconnect UI from NabuCasa cloud.

View File

@ -520,6 +520,9 @@ habitipy==0.2.0
# homeassistant.components.hangouts # homeassistant.components.hangouts
hangups==0.4.6 hangups==0.4.6
# homeassistant.components.cloud
hass-nabucasa==0.3
# homeassistant.components.mqtt.server # homeassistant.components.mqtt.server
hbmqtt==0.9.4 hbmqtt==0.9.4
@ -1763,9 +1766,6 @@ wakeonlan==1.1.6
# homeassistant.components.sensor.waqi # homeassistant.components.sensor.waqi
waqiasync==1.0.0 waqiasync==1.0.0
# homeassistant.components.cloud
warrant==0.6.1
# homeassistant.components.folder_watcher # homeassistant.components.folder_watcher
watchdog==0.8.3 watchdog==0.8.3

View File

@ -110,6 +110,9 @@ ha-ffmpeg==1.11
# homeassistant.components.hangouts # homeassistant.components.hangouts
hangups==0.4.6 hangups==0.4.6
# homeassistant.components.cloud
hass-nabucasa==0.3
# homeassistant.components.mqtt.server # homeassistant.components.mqtt.server
hbmqtt==0.9.4 hbmqtt==0.9.4
@ -309,8 +312,5 @@ vultr==0.1.2
# homeassistant.components.switch.wake_on_lan # homeassistant.components.switch.wake_on_lan
wakeonlan==1.1.6 wakeonlan==1.1.6
# homeassistant.components.cloud
warrant==0.6.1
# homeassistant.components.zha # homeassistant.components.zha
zigpy-homeassistant==0.3.0 zigpy-homeassistant==0.3.0

View File

@ -62,6 +62,7 @@ TEST_REQUIREMENTS = (
'ha-ffmpeg', 'ha-ffmpeg',
'hangups', 'hangups',
'HAP-python', 'HAP-python',
'hass-nabucasa',
'haversine', 'haversine',
'hbmqtt', 'hbmqtt',
'hdate', 'hdate',
@ -136,9 +137,10 @@ TEST_REQUIREMENTS = (
) )
IGNORE_PACKAGES = ( IGNORE_PACKAGES = (
'homeassistant.components.recorder.models', 'homeassistant.components.hangouts.hangups_utils',
'homeassistant.components.cloud.client',
'homeassistant.components.homekit.*', 'homeassistant.components.homekit.*',
'homeassistant.components.hangouts.hangups_utils' 'homeassistant.components.recorder.models',
) )
IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3')

View File

@ -11,8 +11,7 @@ from tests.common import mock_coro
def mock_cloud(hass, config={}): def mock_cloud(hass, config={}):
"""Mock cloud.""" """Mock cloud."""
with patch('homeassistant.components.cloud.Cloud.async_start', with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
return_value=mock_coro()):
assert hass.loop.run_until_complete(async_setup_component( assert hass.loop.run_until_complete(async_setup_component(
hass, cloud.DOMAIN, { hass, cloud.DOMAIN, {
'cloud': config 'cloud': config
@ -30,5 +29,5 @@ def mock_cloud_prefs(hass, prefs={}):
const.PREF_GOOGLE_ALLOW_UNLOCK: True, const.PREF_GOOGLE_ALLOW_UNLOCK: True,
} }
prefs_to_set.update(prefs) prefs_to_set.update(prefs)
hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set
return prefs_to_set return prefs_to_set

View File

@ -1,9 +1,18 @@
"""Fixtures for cloud tests.""" """Fixtures for cloud tests."""
import pytest import pytest
from unittest.mock import patch
from . import mock_cloud, mock_cloud_prefs from . import mock_cloud, mock_cloud_prefs
@pytest.fixture(autouse=True)
def mock_user_data():
"""Mock os module."""
with patch('hass_nabucasa.Cloud.write_user_info') as writer:
yield writer
@pytest.fixture @pytest.fixture
def mock_cloud_fixture(hass): def mock_cloud_fixture(hass):
"""Fixture for cloud component.""" """Fixture for cloud component."""

View File

@ -1,196 +0,0 @@
"""Tests for the tools to communicate with the cloud."""
import asyncio
from unittest.mock import MagicMock, patch
from botocore.exceptions import ClientError
import pytest
from homeassistant.components.cloud import auth_api
@pytest.fixture
def mock_cognito():
"""Mock warrant."""
with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog:
yield mock_cog()
def aws_error(code, message='Unknown', operation_name='fake_operation_name'):
"""Generate AWS error response."""
response = {
'Error': {
'Code': code,
'Message': message
}
}
return ClientError(response, operation_name)
def test_login_invalid_auth(mock_cognito):
"""Test trying to login with invalid credentials."""
cloud = MagicMock(is_logged_in=False)
mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException')
with pytest.raises(auth_api.Unauthenticated):
auth_api.login(cloud, 'user', 'pass')
assert len(cloud.write_user_info.mock_calls) == 0
def test_login_user_not_found(mock_cognito):
"""Test trying to login with invalid credentials."""
cloud = MagicMock(is_logged_in=False)
mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException')
with pytest.raises(auth_api.UserNotFound):
auth_api.login(cloud, 'user', 'pass')
assert len(cloud.write_user_info.mock_calls) == 0
def test_login_user_not_confirmed(mock_cognito):
"""Test trying to login without confirming account."""
cloud = MagicMock(is_logged_in=False)
mock_cognito.authenticate.side_effect = \
aws_error('UserNotConfirmedException')
with pytest.raises(auth_api.UserNotConfirmed):
auth_api.login(cloud, 'user', 'pass')
assert len(cloud.write_user_info.mock_calls) == 0
def test_login(mock_cognito):
"""Test trying to login without confirming account."""
cloud = MagicMock(is_logged_in=False)
mock_cognito.id_token = 'test_id_token'
mock_cognito.access_token = 'test_access_token'
mock_cognito.refresh_token = 'test_refresh_token'
auth_api.login(cloud, 'user', 'pass')
assert len(mock_cognito.authenticate.mock_calls) == 1
assert cloud.id_token == 'test_id_token'
assert cloud.access_token == 'test_access_token'
assert cloud.refresh_token == 'test_refresh_token'
assert len(cloud.write_user_info.mock_calls) == 1
def test_register(mock_cognito):
"""Test registering an account."""
cloud = MagicMock()
cloud = MagicMock()
auth_api.register(cloud, 'email@home-assistant.io', 'password')
assert len(mock_cognito.register.mock_calls) == 1
result_user, result_password = mock_cognito.register.mock_calls[0][1]
assert result_user == 'email@home-assistant.io'
assert result_password == 'password'
def test_register_fails(mock_cognito):
"""Test registering an account."""
cloud = MagicMock()
mock_cognito.register.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.register(cloud, 'email@home-assistant.io', 'password')
def test_resend_email_confirm(mock_cognito):
"""Test starting forgot password flow."""
cloud = MagicMock()
auth_api.resend_email_confirm(cloud, 'email@home-assistant.io')
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1
def test_resend_email_confirm_fails(mock_cognito):
"""Test failure when starting forgot password flow."""
cloud = MagicMock()
mock_cognito.client.resend_confirmation_code.side_effect = \
aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.resend_email_confirm(cloud, 'email@home-assistant.io')
def test_forgot_password(mock_cognito):
"""Test starting forgot password flow."""
cloud = MagicMock()
auth_api.forgot_password(cloud, 'email@home-assistant.io')
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
def test_forgot_password_fails(mock_cognito):
"""Test failure when starting forgot password flow."""
cloud = MagicMock()
mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.forgot_password(cloud, 'email@home-assistant.io')
def test_check_token_writes_new_token_on_refresh(mock_cognito):
"""Test check_token writes new token if refreshed."""
cloud = MagicMock()
mock_cognito.check_token.return_value = True
mock_cognito.id_token = 'new id token'
mock_cognito.access_token = 'new access token'
auth_api.check_token(cloud)
assert len(mock_cognito.check_token.mock_calls) == 1
assert cloud.id_token == 'new id token'
assert cloud.access_token == 'new access token'
assert len(cloud.write_user_info.mock_calls) == 1
def test_check_token_does_not_write_existing_token(mock_cognito):
"""Test check_token won't write new token if still valid."""
cloud = MagicMock()
mock_cognito.check_token.return_value = False
auth_api.check_token(cloud)
assert len(mock_cognito.check_token.mock_calls) == 1
assert cloud.id_token != mock_cognito.id_token
assert cloud.access_token != mock_cognito.access_token
assert len(cloud.write_user_info.mock_calls) == 0
def test_check_token_raises(mock_cognito):
"""Test we raise correct error."""
cloud = MagicMock()
mock_cognito.check_token.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.check_token(cloud)
assert len(mock_cognito.check_token.mock_calls) == 1
assert cloud.id_token != mock_cognito.id_token
assert cloud.access_token != mock_cognito.access_token
assert len(cloud.write_user_info.mock_calls) == 0
async def test_async_setup(hass):
"""Test async setup."""
cloud = MagicMock()
await auth_api.async_setup(hass, cloud)
assert len(cloud.iot.mock_calls) == 2
on_connect = cloud.iot.mock_calls[0][1][0]
on_disconnect = cloud.iot.mock_calls[1][1][0]
with patch('random.randint', return_value=0), patch(
'homeassistant.components.cloud.auth_api.renew_access_token'
) as mock_renew:
await on_connect()
# Let handle token sleep once
await asyncio.sleep(0)
# Let handle token refresh token
await asyncio.sleep(0)
assert len(mock_renew.mock_calls) == 1
assert mock_renew.mock_calls[0][1][0] is cloud
await on_disconnect()
# Make sure task is no longer being called
await asyncio.sleep(0)
await asyncio.sleep(0)
assert len(mock_renew.mock_calls) == 1

View File

@ -0,0 +1,199 @@
"""Test the cloud.iot module."""
from unittest.mock import patch, MagicMock
from aiohttp import web
import pytest
from homeassistant.setup import async_setup_component
from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
from tests.components.alexa import test_smart_home as test_alexa
from tests.common import mock_coro
from . import mock_cloud_prefs
@pytest.fixture
def mock_cloud():
"""Mock cloud class."""
return MagicMock(subscription_expired=False)
async def test_handler_alexa(hass):
"""Test handler Alexa."""
hass.states.async_set(
'switch.test', 'on', {'friendly_name': "Test switch"})
hass.states.async_set(
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
setup = await async_setup_component(hass, 'cloud', {
'cloud': {
'alexa': {
'filter': {
'exclude_entities': 'switch.test2'
},
'entity_config': {
'switch.test': {
'name': 'Config name',
'description': 'Config description',
'display_categories': 'LIGHT'
}
}
}
}
})
assert setup
mock_cloud_prefs(hass)
cloud = hass.data['cloud']
resp = await cloud.client.async_alexa_message(
test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
endpoints = resp['event']['payload']['endpoints']
assert len(endpoints) == 1
device = endpoints[0]
assert device['description'] == 'Config description'
assert device['friendlyName'] == 'Config name'
assert device['displayCategories'] == ['LIGHT']
assert device['manufacturerName'] == 'Home Assistant'
async def test_handler_alexa_disabled(hass, mock_cloud_fixture):
"""Test handler Alexa when user has disabled it."""
mock_cloud_fixture[PREF_ENABLE_ALEXA] = False
cloud = hass.data['cloud']
resp = await cloud.client.async_alexa_message(
test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
assert resp['event']['header']['namespace'] == 'Alexa'
assert resp['event']['header']['name'] == 'ErrorResponse'
assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE'
async def test_handler_google_actions(hass):
"""Test handler Google Actions."""
hass.states.async_set(
'switch.test', 'on', {'friendly_name': "Test switch"})
hass.states.async_set(
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
hass.states.async_set(
'group.all_locks', 'on', {'friendly_name': "Evil locks"})
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
setup = await async_setup_component(hass, 'cloud', {
'cloud': {
'google_actions': {
'filter': {
'exclude_entities': 'switch.test2'
},
'entity_config': {
'switch.test': {
'name': 'Config name',
'aliases': 'Config alias',
'room': 'living room'
}
}
}
}
})
assert setup
mock_cloud_prefs(hass)
cloud = hass.data['cloud']
reqid = '5711642932632160983'
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
with patch(
'hass_nabucasa.Cloud._decode_claims',
return_value={'cognito:username': 'myUserName'}
):
resp = await cloud.client.async_google_message(data)
assert resp['requestId'] == reqid
payload = resp['payload']
assert payload['agentUserId'] == 'myUserName'
devices = payload['devices']
assert len(devices) == 1
device = devices[0]
assert device['id'] == 'switch.test'
assert device['name']['name'] == 'Config name'
assert device['name']['nicknames'] == ['Config alias']
assert device['type'] == 'action.devices.types.SWITCH'
assert device['roomHint'] == 'living room'
async def test_handler_google_actions_disabled(hass, mock_cloud_fixture):
"""Test handler Google Actions when user has disabled it."""
mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
assert await async_setup_component(hass, 'cloud', {})
reqid = '5711642932632160983'
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
cloud = hass.data['cloud']
resp = await cloud.client.async_google_message(data)
assert resp['requestId'] == reqid
assert resp['payload']['errorCode'] == 'deviceTurnedOff'
async def test_webhook_msg(hass):
"""Test webhook msg."""
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
setup = await async_setup_component(hass, 'cloud', {
'cloud': {}
})
assert setup
cloud = hass.data['cloud']
await cloud.client.prefs.async_initialize()
await cloud.client.prefs.async_update(cloudhooks={
'hello': {
'webhook_id': 'mock-webhook-id',
'cloudhook_id': 'mock-cloud-id'
}
})
received = []
async def handler(hass, webhook_id, request):
"""Handle a webhook."""
received.append(request)
return web.json_response({'from': 'handler'})
hass.components.webhook.async_register(
'test', 'Test', 'mock-webhook-id', handler)
response = await cloud.client.async_webhook_message({
'cloudhook_id': 'mock-cloud-id',
'body': '{"hello": "world"}',
'headers': {
'content-type': 'application/json'
},
'method': 'POST',
'query': None,
})
assert response == {
'status': 200,
'body': '{"from": "handler"}',
'headers': {
'Content-Type': 'application/json'
}
}
assert len(received) == 1
assert await received[0].json() == {
'hello': 'world'
}

View File

@ -1,33 +0,0 @@
"""Test cloud API."""
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.cloud import cloud_api
@pytest.fixture(autouse=True)
def mock_check_token():
"""Mock check token."""
with patch('homeassistant.components.cloud.auth_api.'
'check_token') as mock_check_token:
yield mock_check_token
async def test_create_cloudhook(hass, aioclient_mock):
"""Test creating a cloudhook."""
aioclient_mock.post('https://example.com/bla', json={
'cloudhook_id': 'mock-webhook',
'url': 'https://blabla'
})
cloud = Mock(
hass=hass,
id_token='mock-id-token',
cloudhook_create_url='https://example.com/bla',
)
resp = await cloud_api.async_create_cloudhook(cloud)
assert len(aioclient_mock.mock_calls) == 1
assert await resp.json() == {
'cloudhook_id': 'mock-webhook',
'url': 'https://blabla'
}

View File

@ -1,96 +0,0 @@
"""Test cloud cloudhooks."""
from unittest.mock import Mock
import pytest
from homeassistant.components.cloud import prefs, cloudhooks
from tests.common import mock_coro
@pytest.fixture
def mock_cloudhooks(hass):
"""Mock cloudhooks class."""
cloud = Mock()
cloud.hass = hass
cloud.hass.async_add_executor_job = Mock(return_value=mock_coro())
cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro()))
cloud.cloudhook_create_url = 'https://webhook-create.url'
cloud.prefs = prefs.CloudPreferences(hass)
hass.loop.run_until_complete(cloud.prefs.async_initialize())
return cloudhooks.Cloudhooks(cloud)
async def test_enable(mock_cloudhooks, aioclient_mock):
"""Test enabling cloudhooks."""
aioclient_mock.post('https://webhook-create.url', json={
'cloudhook_id': 'mock-cloud-id',
'url': 'https://hooks.nabu.casa/ZXCZCXZ',
})
hook = {
'webhook_id': 'mock-webhook-id',
'cloudhook_id': 'mock-cloud-id',
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
}
assert hook == await mock_cloudhooks.async_create('mock-webhook-id')
assert mock_cloudhooks.cloud.prefs.cloudhooks == {
'mock-webhook-id': hook
}
publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls
assert len(publish_calls) == 1
assert publish_calls[0][1][0] == 'webhook-register'
assert publish_calls[0][1][1] == {
'cloudhook_ids': ['mock-cloud-id']
}
async def test_disable(mock_cloudhooks):
"""Test disabling cloudhooks."""
mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = {
'mock-webhook-id': {
'webhook_id': 'mock-webhook-id',
'cloudhook_id': 'mock-cloud-id',
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
}
}
await mock_cloudhooks.async_delete('mock-webhook-id')
assert mock_cloudhooks.cloud.prefs.cloudhooks == {}
publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls
assert len(publish_calls) == 1
assert publish_calls[0][1][0] == 'webhook-register'
assert publish_calls[0][1][1] == {
'cloudhook_ids': []
}
async def test_create_without_connected(mock_cloudhooks, aioclient_mock):
"""Test we don't publish a hook if not connected."""
mock_cloudhooks.cloud.is_connected = False
# Make sure we fail test when we send a message.
mock_cloudhooks.cloud.iot.async_send_message.side_effect = ValueError
aioclient_mock.post('https://webhook-create.url', json={
'cloudhook_id': 'mock-cloud-id',
'url': 'https://hooks.nabu.casa/ZXCZCXZ',
})
hook = {
'webhook_id': 'mock-webhook-id',
'cloudhook_id': 'mock-cloud-id',
'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ',
}
assert hook == await mock_cloudhooks.async_create('mock-webhook-id')
assert mock_cloudhooks.cloud.prefs.cloudhooks == {
'mock-webhook-id': hook
}
assert len(mock_cloudhooks.cloud.iot.async_send_message.mock_calls) == 0

View File

@ -4,11 +4,11 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
from jose import jwt from jose import jwt
from hass_nabucasa.auth import Unauthenticated, UnknownError
from hass_nabucasa.const import STATE_CONNECTED
from homeassistant.components.cloud import (
DOMAIN, auth_api, iot)
from homeassistant.components.cloud.const import ( from homeassistant.components.cloud.const import (
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK) PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN)
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from tests.common import mock_coro from tests.common import mock_coro
@ -22,12 +22,12 @@ SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info'
@pytest.fixture() @pytest.fixture()
def mock_auth(): def mock_auth():
"""Mock check token.""" """Mock check token."""
with patch('homeassistant.components.cloud.auth_api.check_token'): with patch('hass_nabucasa.auth.CognitoAuth.check_token'):
yield yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_api(hass): def setup_api(hass, aioclient_mock):
"""Initialize HTTP API.""" """Initialize HTTP API."""
mock_cloud(hass, { mock_cloud(hass, {
'mode': 'development', 'mode': 'development',
@ -54,14 +54,14 @@ def setup_api(hass):
@pytest.fixture @pytest.fixture
def cloud_client(hass, hass_client): def cloud_client(hass, hass_client):
"""Fixture that can fetch from the cloud client.""" """Fixture that can fetch from the cloud client."""
with patch('homeassistant.components.cloud.Cloud.write_user_info'): with patch('hass_nabucasa.Cloud.write_user_info'):
yield hass.loop.run_until_complete(hass_client()) yield hass.loop.run_until_complete(hass_client())
@pytest.fixture @pytest.fixture
def mock_cognito(): def mock_cognito():
"""Mock warrant.""" """Mock warrant."""
with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: with patch('hass_nabucasa.auth.CognitoAuth._cognito') as mock_cog:
yield mock_cog() yield mock_cog()
@ -80,8 +80,7 @@ async def test_google_actions_sync_fails(mock_cognito, cloud_client,
assert req.status == 403 assert req.status == 403
@asyncio.coroutine async def test_login_view(hass, cloud_client, mock_cognito):
def test_login_view(hass, cloud_client, mock_cognito):
"""Test logging in.""" """Test logging in."""
mock_cognito.id_token = jwt.encode({ mock_cognito.id_token = jwt.encode({
'email': 'hello@home-assistant.io', 'email': 'hello@home-assistant.io',
@ -90,23 +89,22 @@ def test_login_view(hass, cloud_client, mock_cognito):
mock_cognito.access_token = 'access_token' mock_cognito.access_token = 'access_token'
mock_cognito.refresh_token = 'refresh_token' mock_cognito.refresh_token = 'refresh_token'
with patch('homeassistant.components.cloud.iot.CloudIoT.' with patch('hass_nabucasa.iot.CloudIoT.connect') as mock_connect, \
'connect') as mock_connect, \ patch('hass_nabucasa.auth.CognitoAuth._authenticate',
patch('homeassistant.components.cloud.auth_api._authenticate',
return_value=mock_cognito) as mock_auth: return_value=mock_cognito) as mock_auth:
req = yield from cloud_client.post('/api/cloud/login', json={ req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username', 'email': 'my_username',
'password': 'my_password' 'password': 'my_password'
}) })
assert req.status == 200 assert req.status == 200
result = yield from req.json() result = await req.json()
assert result == {'success': True} assert result == {'success': True}
assert len(mock_connect.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1
assert len(mock_auth.mock_calls) == 1 assert len(mock_auth.mock_calls) == 1
cloud, result_user, result_pass = mock_auth.mock_calls[0][1] result_user, result_pass = mock_auth.mock_calls[0][1]
assert result_user == 'my_username' assert result_user == 'my_username'
assert result_pass == 'my_password' assert result_pass == 'my_password'
@ -123,32 +121,29 @@ async def test_login_view_random_exception(cloud_client):
assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'} assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'}
@asyncio.coroutine async def test_login_view_invalid_json(cloud_client):
def test_login_view_invalid_json(cloud_client):
"""Try logging in with invalid JSON.""" """Try logging in with invalid JSON."""
with patch('homeassistant.components.cloud.auth_api.login') as mock_login: with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') req = await cloud_client.post('/api/cloud/login', data='Not JSON')
assert req.status == 400 assert req.status == 400
assert len(mock_login.mock_calls) == 0 assert len(mock_login.mock_calls) == 0
@asyncio.coroutine async def test_login_view_invalid_schema(cloud_client):
def test_login_view_invalid_schema(cloud_client):
"""Try logging in with invalid schema.""" """Try logging in with invalid schema."""
with patch('homeassistant.components.cloud.auth_api.login') as mock_login: with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
req = yield from cloud_client.post('/api/cloud/login', json={ req = await cloud_client.post('/api/cloud/login', json={
'invalid': 'schema' 'invalid': 'schema'
}) })
assert req.status == 400 assert req.status == 400
assert len(mock_login.mock_calls) == 0 assert len(mock_login.mock_calls) == 0
@asyncio.coroutine async def test_login_view_request_timeout(cloud_client):
def test_login_view_request_timeout(cloud_client):
"""Test request timeout while trying to log in.""" """Test request timeout while trying to log in."""
with patch('homeassistant.components.cloud.auth_api.login', with patch('hass_nabucasa.auth.CognitoAuth.login',
side_effect=asyncio.TimeoutError): side_effect=asyncio.TimeoutError):
req = yield from cloud_client.post('/api/cloud/login', json={ req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username', 'email': 'my_username',
'password': 'my_password' 'password': 'my_password'
}) })
@ -156,12 +151,11 @@ def test_login_view_request_timeout(cloud_client):
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_login_view_invalid_credentials(cloud_client):
def test_login_view_invalid_credentials(cloud_client):
"""Test logging in with invalid credentials.""" """Test logging in with invalid credentials."""
with patch('homeassistant.components.cloud.auth_api.login', with patch('hass_nabucasa.auth.CognitoAuth.login',
side_effect=auth_api.Unauthenticated): side_effect=Unauthenticated):
req = yield from cloud_client.post('/api/cloud/login', json={ req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username', 'email': 'my_username',
'password': 'my_password' 'password': 'my_password'
}) })
@ -169,12 +163,11 @@ def test_login_view_invalid_credentials(cloud_client):
assert req.status == 401 assert req.status == 401
@asyncio.coroutine async def test_login_view_unknown_error(cloud_client):
def test_login_view_unknown_error(cloud_client):
"""Test unknown error while logging in.""" """Test unknown error while logging in."""
with patch('homeassistant.components.cloud.auth_api.login', with patch('hass_nabucasa.auth.CognitoAuth.login',
side_effect=auth_api.UnknownError): side_effect=UnknownError):
req = yield from cloud_client.post('/api/cloud/login', json={ req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username', 'email': 'my_username',
'password': 'my_password' 'password': 'my_password'
}) })
@ -182,40 +175,36 @@ def test_login_view_unknown_error(cloud_client):
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_logout_view(hass, cloud_client):
def test_logout_view(hass, cloud_client):
"""Test logging out.""" """Test logging out."""
cloud = hass.data['cloud'] = MagicMock() cloud = hass.data['cloud'] = MagicMock()
cloud.logout.return_value = mock_coro() cloud.logout.return_value = mock_coro()
req = yield from cloud_client.post('/api/cloud/logout') req = await cloud_client.post('/api/cloud/logout')
assert req.status == 200 assert req.status == 200
data = yield from req.json() data = await req.json()
assert data == {'message': 'ok'} assert data == {'message': 'ok'}
assert len(cloud.logout.mock_calls) == 1 assert len(cloud.logout.mock_calls) == 1
@asyncio.coroutine async def test_logout_view_request_timeout(hass, cloud_client):
def test_logout_view_request_timeout(hass, cloud_client):
"""Test timeout while logging out.""" """Test timeout while logging out."""
cloud = hass.data['cloud'] = MagicMock() cloud = hass.data['cloud'] = MagicMock()
cloud.logout.side_effect = asyncio.TimeoutError cloud.logout.side_effect = asyncio.TimeoutError
req = yield from cloud_client.post('/api/cloud/logout') req = await cloud_client.post('/api/cloud/logout')
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_logout_view_unknown_error(hass, cloud_client):
def test_logout_view_unknown_error(hass, cloud_client):
"""Test unknown error while logging out.""" """Test unknown error while logging out."""
cloud = hass.data['cloud'] = MagicMock() cloud = hass.data['cloud'] = MagicMock()
cloud.logout.side_effect = auth_api.UnknownError cloud.logout.side_effect = UnknownError
req = yield from cloud_client.post('/api/cloud/logout') req = await cloud_client.post('/api/cloud/logout')
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_register_view(mock_cognito, cloud_client):
def test_register_view(mock_cognito, cloud_client):
"""Test logging out.""" """Test logging out."""
req = yield from cloud_client.post('/api/cloud/register', json={ req = await cloud_client.post('/api/cloud/register', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
'password': 'falcon42' 'password': 'falcon42'
}) })
@ -226,10 +215,9 @@ def test_register_view(mock_cognito, cloud_client):
assert result_pass == 'falcon42' assert result_pass == 'falcon42'
@asyncio.coroutine async def test_register_view_bad_data(mock_cognito, cloud_client):
def test_register_view_bad_data(mock_cognito, cloud_client):
"""Test logging out.""" """Test logging out."""
req = yield from cloud_client.post('/api/cloud/register', json={ req = await cloud_client.post('/api/cloud/register', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
'not_password': 'falcon' 'not_password': 'falcon'
}) })
@ -237,105 +225,95 @@ def test_register_view_bad_data(mock_cognito, cloud_client):
assert len(mock_cognito.logout.mock_calls) == 0 assert len(mock_cognito.logout.mock_calls) == 0
@asyncio.coroutine async def test_register_view_request_timeout(mock_cognito, cloud_client):
def test_register_view_request_timeout(mock_cognito, cloud_client):
"""Test timeout while logging out.""" """Test timeout while logging out."""
mock_cognito.register.side_effect = asyncio.TimeoutError mock_cognito.register.side_effect = asyncio.TimeoutError
req = yield from cloud_client.post('/api/cloud/register', json={ req = await cloud_client.post('/api/cloud/register', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
'password': 'falcon42' 'password': 'falcon42'
}) })
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_register_view_unknown_error(mock_cognito, cloud_client):
def test_register_view_unknown_error(mock_cognito, cloud_client):
"""Test unknown error while logging out.""" """Test unknown error while logging out."""
mock_cognito.register.side_effect = auth_api.UnknownError mock_cognito.register.side_effect = UnknownError
req = yield from cloud_client.post('/api/cloud/register', json={ req = await cloud_client.post('/api/cloud/register', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
'password': 'falcon42' 'password': 'falcon42'
}) })
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_forgot_password_view(mock_cognito, cloud_client):
def test_forgot_password_view(mock_cognito, cloud_client):
"""Test logging out.""" """Test logging out."""
req = yield from cloud_client.post('/api/cloud/forgot_password', json={ req = await cloud_client.post('/api/cloud/forgot_password', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
}) })
assert req.status == 200 assert req.status == 200
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
@asyncio.coroutine async def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
"""Test logging out.""" """Test logging out."""
req = yield from cloud_client.post('/api/cloud/forgot_password', json={ req = await cloud_client.post('/api/cloud/forgot_password', json={
'not_email': 'hello@bla.com', 'not_email': 'hello@bla.com',
}) })
assert req.status == 400 assert req.status == 400
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0
@asyncio.coroutine async def test_forgot_password_view_request_timeout(mock_cognito,
def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): cloud_client):
"""Test timeout while logging out.""" """Test timeout while logging out."""
mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError
req = yield from cloud_client.post('/api/cloud/forgot_password', json={ req = await cloud_client.post('/api/cloud/forgot_password', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
}) })
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
"""Test unknown error while logging out.""" """Test unknown error while logging out."""
mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError mock_cognito.initiate_forgot_password.side_effect = UnknownError
req = yield from cloud_client.post('/api/cloud/forgot_password', json={ req = await cloud_client.post('/api/cloud/forgot_password', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
}) })
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_resend_confirm_view(mock_cognito, cloud_client):
def test_resend_confirm_view(mock_cognito, cloud_client):
"""Test logging out.""" """Test logging out."""
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ req = await cloud_client.post('/api/cloud/resend_confirm', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
}) })
assert req.status == 200 assert req.status == 200
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1
@asyncio.coroutine async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
"""Test logging out.""" """Test logging out."""
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ req = await cloud_client.post('/api/cloud/resend_confirm', json={
'not_email': 'hello@bla.com', 'not_email': 'hello@bla.com',
}) })
assert req.status == 400 assert req.status == 400
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0
@asyncio.coroutine async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
"""Test timeout while logging out.""" """Test timeout while logging out."""
mock_cognito.client.resend_confirmation_code.side_effect = \ mock_cognito.client.resend_confirmation_code.side_effect = \
asyncio.TimeoutError asyncio.TimeoutError
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ req = await cloud_client.post('/api/cloud/resend_confirm', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
}) })
assert req.status == 502 assert req.status == 502
@asyncio.coroutine async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
"""Test unknown error while logging out.""" """Test unknown error while logging out."""
mock_cognito.client.resend_confirmation_code.side_effect = \ mock_cognito.client.resend_confirmation_code.side_effect = UnknownError
auth_api.UnknownError req = await cloud_client.post('/api/cloud/resend_confirm', json={
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={
'email': 'hello@bla.com', 'email': 'hello@bla.com',
}) })
assert req.status == 502 assert req.status == 502
@ -347,7 +325,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture):
'email': 'hello@home-assistant.io', 'email': 'hello@home-assistant.io',
'custom:sub-exp': '2018-01-03' 'custom:sub-exp': '2018-01-03'
}, 'test') }, 'test')
hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED hass.data[DOMAIN].iot.state = STATE_CONNECTED
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch.dict( with patch.dict(
@ -407,9 +385,9 @@ async def test_websocket_subscription_reconnect(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch( with patch(
'homeassistant.components.cloud.auth_api.renew_access_token' 'hass_nabucasa.auth.CognitoAuth.renew_access_token'
) as mock_renew, patch( ) as mock_renew, patch(
'homeassistant.components.cloud.iot.CloudIoT.connect' 'hass_nabucasa.iot.CloudIoT.connect'
) as mock_connect: ) as mock_connect:
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -428,7 +406,7 @@ async def test_websocket_subscription_no_reconnect_if_connected(
hass, hass_ws_client, aioclient_mock, mock_auth): hass, hass_ws_client, aioclient_mock, mock_auth):
"""Test querying the status and not reconnecting because still expired.""" """Test querying the status and not reconnecting because still expired."""
aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'})
hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED hass.data[DOMAIN].iot.state = STATE_CONNECTED
hass.data[DOMAIN].id_token = jwt.encode({ hass.data[DOMAIN].id_token = jwt.encode({
'email': 'hello@home-assistant.io', 'email': 'hello@home-assistant.io',
'custom:sub-exp': dt_util.utcnow().date().isoformat() 'custom:sub-exp': dt_util.utcnow().date().isoformat()
@ -436,9 +414,9 @@ async def test_websocket_subscription_no_reconnect_if_connected(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch( with patch(
'homeassistant.components.cloud.auth_api.renew_access_token' 'hass_nabucasa.auth.CognitoAuth.renew_access_token'
) as mock_renew, patch( ) as mock_renew, patch(
'homeassistant.components.cloud.iot.CloudIoT.connect' 'hass_nabucasa.iot.CloudIoT.connect'
) as mock_connect: ) as mock_connect:
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -464,9 +442,9 @@ async def test_websocket_subscription_no_reconnect_if_expired(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch( with patch(
'homeassistant.components.cloud.auth_api.renew_access_token' 'hass_nabucasa.auth.CognitoAuth.renew_access_token'
) as mock_renew, patch( ) as mock_renew, patch(
'homeassistant.components.cloud.iot.CloudIoT.connect' 'hass_nabucasa.iot.CloudIoT.connect'
) as mock_connect: ) as mock_connect:
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -503,7 +481,7 @@ async def test_websocket_subscription_fail(hass, hass_ws_client,
async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): async def test_websocket_subscription_not_logged_in(hass, hass_ws_client):
"""Test querying the status.""" """Test querying the status."""
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.cloud.Cloud.fetch_subscription_info', with patch('hass_nabucasa.Cloud.fetch_subscription_info',
return_value=mock_coro({'return': 'value'})): return_value=mock_coro({'return': 'value'})):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -548,8 +526,10 @@ async def test_enabling_webhook(hass, hass_ws_client, setup_api):
'custom:sub-exp': '2018-01-03' 'custom:sub-exp': '2018-01-03'
}, 'test') }, 'test')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' with patch(
'.async_create', return_value=mock_coro()) as mock_enable: 'hass_nabucasa.cloudhooks.Cloudhooks.async_create',
return_value=mock_coro()
) as mock_enable:
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
'type': 'cloud/cloudhook/create', 'type': 'cloud/cloudhook/create',
@ -569,8 +549,10 @@ async def test_disabling_webhook(hass, hass_ws_client, setup_api):
'custom:sub-exp': '2018-01-03' 'custom:sub-exp': '2018-01-03'
}, 'test') }, 'test')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' with patch(
'.async_delete', return_value=mock_coro()) as mock_disable: 'hass_nabucasa.cloudhooks.Cloudhooks.async_delete',
return_value=mock_coro()
) as mock_disable:
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
'type': 'cloud/cloudhook/delete', 'type': 'cloud/cloudhook/delete',

View File

@ -1,72 +1,34 @@
"""Test the cloud component.""" """Test the cloud component."""
import asyncio from unittest.mock import MagicMock, patch
import json
from unittest.mock import patch, MagicMock, mock_open
import pytest from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
from homeassistant.setup import async_setup_component
from homeassistant.components import cloud from homeassistant.components import cloud
from homeassistant.util.dt import utcnow from homeassistant.components.cloud.const import DOMAIN
from tests.common import mock_coro from tests.common import mock_coro
@pytest.fixture async def test_constructor_loads_info_from_config():
def mock_os():
"""Mock os module."""
with patch('homeassistant.components.cloud.os') as os:
os.path.isdir.return_value = True
yield os
@asyncio.coroutine
def test_constructor_loads_info_from_constant():
"""Test non-dev mode loads info from SERVERS constant.""" """Test non-dev mode loads info from SERVERS constant."""
hass = MagicMock(data={}) hass = MagicMock(data={})
with patch.dict(cloud.SERVERS, {
'beer': { with patch(
'cognito_client_id': 'test-cognito_client_id', "homeassistant.components.cloud.prefs.CloudPreferences."
'user_pool_id': 'test-user_pool_id', "async_initialize",
'region': 'test-region', return_value=mock_coro()
'relayer': 'test-relayer', ):
'google_actions_sync_url': 'test-google_actions_sync_url', result = await cloud.async_setup(hass, {
'subscription_info_url': 'test-subscription-info-url', 'cloud': {
'cloudhook_create_url': 'test-cloudhook_create_url', cloud.CONF_MODE: cloud.MODE_DEV,
} 'cognito_client_id': 'test-cognito_client_id',
}): 'user_pool_id': 'test-user_pool_id',
result = yield from cloud.async_setup(hass, { 'region': 'test-region',
'cloud': {cloud.CONF_MODE: 'beer'} 'relayer': 'test-relayer',
}
}) })
assert result assert result
cl = hass.data['cloud']
assert cl.mode == 'beer'
assert cl.cognito_client_id == 'test-cognito_client_id'
assert cl.user_pool_id == 'test-user_pool_id'
assert cl.region == 'test-region'
assert cl.relayer == 'test-relayer'
assert cl.google_actions_sync_url == 'test-google_actions_sync_url'
assert cl.subscription_info_url == 'test-subscription-info-url'
assert cl.cloudhook_create_url == 'test-cloudhook_create_url'
@asyncio.coroutine
def test_constructor_loads_info_from_config():
"""Test non-dev mode loads info from SERVERS constant."""
hass = MagicMock(data={})
result = yield from cloud.async_setup(hass, {
'cloud': {
cloud.CONF_MODE: cloud.MODE_DEV,
'cognito_client_id': 'test-cognito_client_id',
'user_pool_id': 'test-user_pool_id',
'region': 'test-region',
'relayer': 'test-relayer',
}
})
assert result
cl = hass.data['cloud'] cl = hass.data['cloud']
assert cl.mode == cloud.MODE_DEV assert cl.mode == cloud.MODE_DEV
assert cl.cognito_client_id == 'test-cognito_client_id' assert cl.cognito_client_id == 'test-cognito_client_id'
@ -75,195 +37,41 @@ def test_constructor_loads_info_from_config():
assert cl.relayer == 'test-relayer' assert cl.relayer == 'test-relayer'
async def test_initialize_loads_info(mock_os, hass): async def test_remote_services(hass, mock_cloud_fixture):
"""Test initialize will load info from config file.""" """Setup cloud component and test services."""
mock_os.path.isfile.return_value = True assert hass.services.has_service(DOMAIN, 'remote_connect')
mopen = mock_open(read_data=json.dumps({ assert hass.services.has_service(DOMAIN, 'remote_disconnect')
'id_token': 'test-id-token',
'access_token': 'test-access-token',
'refresh_token': 'test-refresh-token',
}))
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) with patch(
cl.iot = MagicMock() "hass_nabucasa.remote.RemoteUI.connect", return_value=mock_coro()
cl.iot.connect.return_value = mock_coro() ) as mock_connect:
await hass.services.async_call(DOMAIN, "remote_connect", blocking=True)
with patch('homeassistant.components.cloud.open', mopen, create=True), \ assert mock_connect.called
patch('homeassistant.components.cloud.Cloud._decode_claims'):
await cl.async_start(None)
assert cl.id_token == 'test-id-token' with patch(
assert cl.access_token == 'test-access-token' "hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro()
assert cl.refresh_token == 'test-refresh-token' ) as mock_disconnect:
assert len(cl.iot.connect.mock_calls) == 1 await hass.services.async_call(
DOMAIN, "remote_disconnect", blocking=True)
assert mock_disconnect.called
@asyncio.coroutine async def test_startup_shutdown_events(hass, mock_cloud_fixture):
def test_logout_clears_info(mock_os, hass): """Test if the cloud will start on startup event."""
"""Test logging out disconnects and removes info.""" with patch(
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) "hass_nabucasa.Cloud.start", return_value=mock_coro()
cl.iot = MagicMock() ) as mock_start:
cl.iot.disconnect.return_value = mock_coro() hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
yield from cl.logout() assert mock_start.called
assert len(cl.iot.disconnect.mock_calls) == 1 with patch(
assert cl.id_token is None "hass_nabucasa.Cloud.stop", return_value=mock_coro()
assert cl.access_token is None ) as mock_stop:
assert cl.refresh_token is None hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
assert len(mock_os.remove.mock_calls) == 1 await hass.async_block_till_done()
assert mock_stop.called
@asyncio.coroutine
def test_write_user_info():
"""Test writing user info works."""
mopen = mock_open()
cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV, None, None)
cl.id_token = 'test-id-token'
cl.access_token = 'test-access-token'
cl.refresh_token = 'test-refresh-token'
with patch('homeassistant.components.cloud.open', mopen, create=True):
cl.write_user_info()
handle = mopen()
assert len(handle.write.mock_calls) == 1
data = json.loads(handle.write.mock_calls[0][1][0])
assert data == {
'access_token': 'test-access-token',
'id_token': 'test-id-token',
'refresh_token': 'test-refresh-token',
}
@asyncio.coroutine
def test_subscription_expired(hass):
"""Test subscription being expired after 3 days of expiration."""
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
token_val = {
'custom:sub-exp': '2017-11-13'
}
with patch.object(cl, '_decode_claims', return_value=token_val), \
patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(year=2017, month=11, day=13)):
assert not cl.subscription_expired
with patch.object(cl, '_decode_claims', return_value=token_val), \
patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(
year=2017, month=11, day=19, hour=23, minute=59,
second=59)):
assert not cl.subscription_expired
with patch.object(cl, '_decode_claims', return_value=token_val), \
patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(
year=2017, month=11, day=20, hour=0, minute=0,
second=0)):
assert cl.subscription_expired
@asyncio.coroutine
def test_subscription_not_expired(hass):
"""Test subscription not being expired."""
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
token_val = {
'custom:sub-exp': '2017-11-13'
}
with patch.object(cl, '_decode_claims', return_value=token_val), \
patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(year=2017, month=11, day=9)):
assert not cl.subscription_expired
async def test_create_cloudhook_no_login(hass):
"""Test create cloudhook when not logged in."""
assert await async_setup_component(hass, 'cloud', {})
coro = mock_coro({'yo': 'hey'})
with patch('homeassistant.components.cloud.cloudhooks.'
'Cloudhooks.async_create', return_value=coro) as mock_create, \
pytest.raises(cloud.CloudNotAvailable):
await hass.components.cloud.async_create_cloudhook('hello')
assert len(mock_create.mock_calls) == 0
async def test_delete_cloudhook_no_setup(hass):
"""Test delete cloudhook when not logged in."""
coro = mock_coro()
with patch('homeassistant.components.cloud.cloudhooks.'
'Cloudhooks.async_delete', return_value=coro) as mock_delete, \
pytest.raises(cloud.CloudNotAvailable):
await hass.components.cloud.async_delete_cloudhook('hello')
assert len(mock_delete.mock_calls) == 0
async def test_create_cloudhook(hass):
"""Test create cloudhook."""
assert await async_setup_component(hass, 'cloud', {})
coro = mock_coro({'cloudhook_url': 'hello'})
with patch('homeassistant.components.cloud.cloudhooks.'
'Cloudhooks.async_create', return_value=coro) as mock_create, \
patch('homeassistant.components.cloud.async_is_logged_in',
return_value=True):
result = await hass.components.cloud.async_create_cloudhook('hello')
assert result == 'hello'
assert len(mock_create.mock_calls) == 1
async def test_delete_cloudhook(hass):
"""Test delete cloudhook."""
assert await async_setup_component(hass, 'cloud', {})
coro = mock_coro()
with patch('homeassistant.components.cloud.cloudhooks.'
'Cloudhooks.async_delete', return_value=coro) as mock_delete, \
patch('homeassistant.components.cloud.async_is_logged_in',
return_value=True):
await hass.components.cloud.async_delete_cloudhook('hello')
assert len(mock_delete.mock_calls) == 1
async def test_async_logged_in(hass):
"""Test if is_logged_in works."""
# Cloud not loaded
assert hass.components.cloud.async_is_logged_in() is False
assert await async_setup_component(hass, 'cloud', {})
# Cloud loaded, not logged in
assert hass.components.cloud.async_is_logged_in() is False
hass.data['cloud'].id_token = "some token"
# Cloud loaded, logged in
assert hass.components.cloud.async_is_logged_in() is True
async def test_async_active_subscription(hass):
"""Test if is_logged_in works."""
# Cloud not loaded
assert hass.components.cloud.async_active_subscription() is False
assert await async_setup_component(hass, 'cloud', {})
# Cloud loaded, not logged in
assert hass.components.cloud.async_active_subscription() is False
hass.data['cloud'].id_token = "some token"
# Cloud loaded, logged in, invalid sub
with patch('jose.jwt.get_unverified_claims', return_value={
'custom:sub-exp': '{}-12-31'.format(utcnow().year - 1)
}):
assert hass.components.cloud.async_active_subscription() is False
# Cloud loaded, logged in, valid sub
with patch('jose.jwt.get_unverified_claims', return_value={
'custom:sub-exp': '{}-01-01'.format(utcnow().year + 1)
}):
assert hass.components.cloud.async_active_subscription() is True

View File

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