Offload Cloud component (#21937)

* Offload Cloud component & Remote support

* Make hound happy

* Address comments
pull/21945/head
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."""
from datetime import datetime, timedelta
import json
import logging
import os
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.exceptions import HomeAssistantError
from homeassistant.const import (
EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION,
CONF_MODE, CONF_NAME)
from homeassistant.helpers import entityfilter, config_validation as cv
from homeassistant.helpers import config_validation as cv, entityfilter
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
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 .const import CONFIG_DIR, DOMAIN, SERVERS, STATE_CONNECTED
from . import http_api
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__)
CONF_ALEXA = 'alexa'
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 = MODE_PROD
DEFAULT_MODE = 'production'
DEPENDENCIES = ['http']
SERVICE_REMOTE_CONNECT = 'remote_connect'
SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
MODE_DEV = 'development'
ALEXA_ENTITY_SCHEMA = vol.Schema({
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
@ -56,7 +47,7 @@ GOOGLE_ENTITY_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({
@ -67,18 +58,21 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
})
# pylint: disable=no-value-for-parameter
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
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
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
vol.Optional(CONF_USER_POOL_ID): str,
vol.Optional(CONF_REGION): str,
vol.Optional(CONF_RELAYER): str,
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str,
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str,
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(),
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(),
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_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
}),
@ -133,189 +127,48 @@ def is_cloudhook_request(request):
async def async_setup(hass, config):
"""Initialize the Home Assistant cloud."""
from hass_nabucasa import Cloud
from .client import CloudClient
# Process configs
if DOMAIN in config:
kwargs = dict(config[DOMAIN])
else:
kwargs = {CONF_MODE: DEFAULT_MODE}
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:
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({})
prefs = CloudPreferences(hass)
await prefs.async_initialize()
kwargs[CONF_ALEXA] = alexa_sh.Config(
endpoint=None,
async_get_access_token=None,
should_expose=alexa_conf[CONF_FILTER],
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
)
websession = hass.helpers.aiohttp_client.async_get_clientsession()
client = CloudClient(hass, prefs, websession, alexa_conf, google_conf)
cloud = hass.data[DOMAIN] = Cloud(client, **kwargs)
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)
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."""
DOMAIN = 'cloud'
CONFIG_DIR = '.cloud'
REQUEST_TIMEOUT = 10
PREF_ENABLE_ALEXA = 'alexa_enabled'
@ -8,31 +7,19 @@ PREF_ENABLE_GOOGLE = 'google_enabled'
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
PREF_CLOUDHOOKS = 'cloudhooks'
SERVERS = {
'production': {
'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u',
'user_pool_id': 'us-east-1_87ll5WOP8',
'region': 'us-east-1',
'relayer': 'wss://cloud.hass.io:8000/websocket',
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
'amazonaws.com/prod/smart_home_sync'),
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
'subscription_info'),
'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate'
}
}
CONF_ALEXA = 'alexa'
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'
CONF_REMOTE_API_URL = 'remote_api_url'
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
MESSAGE_EXPIRATION = """
It looks like your Home Assistant Cloud subscription has expired. Please check
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'
MODE_DEV = "development"
MODE_PROD = "production"

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.google_assistant import smart_home as google_sh
from . import auth_api
from .const import (
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_ALLOW_UNLOCK)
from .iot import STATE_DISCONNECTED, STATE_CONNECTED
_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):
"""Initialize the HTTP API."""
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(CloudForgotPasswordView)
from hass_nabucasa import auth
_CLOUD_ERRORS = {
auth_api.UserNotFound: (400, "User does not exist."),
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'),
auth_api.Unauthenticated: (401, 'Authentication failed.'),
auth_api.PasswordChangeRequired: (400, 'Password change required.'),
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.')
}
_CLOUD_ERRORS.update({
auth.UserNotFound:
(400, "User does not exist."),
auth.UserNotConfirmed:
(400, 'Email not confirmed.'),
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):
@ -135,7 +142,7 @@ class GoogleActionsSyncView(HomeAssistantView):
websession = hass.helpers.aiohttp_client.async_get_clientsession()
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):
req = await websession.post(
@ -163,7 +170,7 @@ class CloudLoginView(HomeAssistantView):
cloud = hass.data[DOMAIN]
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'])
hass.async_add_job(cloud.iot.connect)
@ -206,7 +213,7 @@ class CloudRegisterView(HomeAssistantView):
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
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')
@ -228,7 +235,7 @@ class CloudResendConfirmView(HomeAssistantView):
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
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')
@ -250,7 +257,7 @@ class CloudForgotPasswordView(HomeAssistantView):
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
await hass.async_add_job(
auth_api.forgot_password, cloud, data['email'])
cloud.auth.forgot_password, data['email'])
return self.json_message('ok')
@ -307,6 +314,7 @@ def _handle_aiohttp_errors(handler):
@websocket_api.async_response
async def websocket_subscription(hass, connection, msg):
"""Handle request for account info."""
from hass_nabucasa.const import STATE_DISCONNECTED
cloud = hass.data[DOMAIN]
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
# 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(
"Found disconnected account with valid subscriotion, connecting")
await hass.async_add_executor_job(
auth_api.renew_access_token, cloud)
await hass.async_add_executor_job(cloud.auth.renew_access_token)
# Cancel reconnect in progress
if cloud.iot.state != STATE_DISCONNECTED:
@ -344,7 +351,7 @@ async def websocket_update_prefs(hass, connection, msg):
changes = dict(msg)
changes.pop('id')
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']))
@ -370,6 +377,8 @@ async def websocket_hook_delete(hass, connection, msg):
def _account_data(cloud):
"""Generate the auth data JSON response."""
from hass_nabucasa.const import STATE_DISCONNECTED
if not cloud.is_logged_in:
return {
'logged_in': False,
@ -377,14 +386,15 @@ def _account_data(cloud):
}
claims = cloud.claims
client = cloud.client
return {
'logged_in': True,
'email': claims['email'],
'cloud': cloud.iot.state,
'prefs': cloud.prefs.as_dict(),
'google_entities': cloud.google_actions_user_conf['filter'].config,
'prefs': client.prefs.as_dict(),
'google_entities': client.google_user_config['filter'].config,
'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),
}

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
hangups==0.4.6
# homeassistant.components.cloud
hass-nabucasa==0.3
# homeassistant.components.mqtt.server
hbmqtt==0.9.4
@ -1763,9 +1766,6 @@ wakeonlan==1.1.6
# homeassistant.components.sensor.waqi
waqiasync==1.0.0
# homeassistant.components.cloud
warrant==0.6.1
# homeassistant.components.folder_watcher
watchdog==0.8.3

View File

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

View File

@ -62,6 +62,7 @@ TEST_REQUIREMENTS = (
'ha-ffmpeg',
'hangups',
'HAP-python',
'hass-nabucasa',
'haversine',
'hbmqtt',
'hdate',
@ -136,9 +137,10 @@ TEST_REQUIREMENTS = (
)
IGNORE_PACKAGES = (
'homeassistant.components.recorder.models',
'homeassistant.components.hangouts.hangups_utils',
'homeassistant.components.cloud.client',
'homeassistant.components.homekit.*',
'homeassistant.components.hangouts.hangups_utils'
'homeassistant.components.recorder.models',
)
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={}):
"""Mock cloud."""
with patch('homeassistant.components.cloud.Cloud.async_start',
return_value=mock_coro()):
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
assert hass.loop.run_until_complete(async_setup_component(
hass, cloud.DOMAIN, {
'cloud': config
@ -30,5 +29,5 @@ def mock_cloud_prefs(hass, prefs={}):
const.PREF_GOOGLE_ALLOW_UNLOCK: True,
}
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

View File

@ -1,9 +1,18 @@
"""Fixtures for cloud tests."""
import pytest
from unittest.mock import patch
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
def mock_cloud_fixture(hass):
"""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
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 (
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 tests.common import mock_coro
@ -22,12 +22,12 @@ SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info'
@pytest.fixture()
def mock_auth():
"""Mock check token."""
with patch('homeassistant.components.cloud.auth_api.check_token'):
with patch('hass_nabucasa.auth.CognitoAuth.check_token'):
yield
@pytest.fixture(autouse=True)
def setup_api(hass):
def setup_api(hass, aioclient_mock):
"""Initialize HTTP API."""
mock_cloud(hass, {
'mode': 'development',
@ -54,14 +54,14 @@ def setup_api(hass):
@pytest.fixture
def cloud_client(hass, hass_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())
@pytest.fixture
def mock_cognito():
"""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()
@ -80,8 +80,7 @@ async def test_google_actions_sync_fails(mock_cognito, cloud_client,
assert req.status == 403
@asyncio.coroutine
def test_login_view(hass, cloud_client, mock_cognito):
async def test_login_view(hass, cloud_client, mock_cognito):
"""Test logging in."""
mock_cognito.id_token = jwt.encode({
'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.refresh_token = 'refresh_token'
with patch('homeassistant.components.cloud.iot.CloudIoT.'
'connect') as mock_connect, \
patch('homeassistant.components.cloud.auth_api._authenticate',
with patch('hass_nabucasa.iot.CloudIoT.connect') as mock_connect, \
patch('hass_nabucasa.auth.CognitoAuth._authenticate',
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',
'password': 'my_password'
})
assert req.status == 200
result = yield from req.json()
result = await req.json()
assert result == {'success': True}
assert len(mock_connect.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_pass == 'my_password'
@ -123,32 +121,29 @@ async def test_login_view_random_exception(cloud_client):
assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'}
@asyncio.coroutine
def test_login_view_invalid_json(cloud_client):
async def test_login_view_invalid_json(cloud_client):
"""Try logging in with invalid JSON."""
with patch('homeassistant.components.cloud.auth_api.login') as mock_login:
req = yield from cloud_client.post('/api/cloud/login', data='Not JSON')
with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
req = await cloud_client.post('/api/cloud/login', data='Not JSON')
assert req.status == 400
assert len(mock_login.mock_calls) == 0
@asyncio.coroutine
def test_login_view_invalid_schema(cloud_client):
async def test_login_view_invalid_schema(cloud_client):
"""Try logging in with invalid schema."""
with patch('homeassistant.components.cloud.auth_api.login') as mock_login:
req = yield from cloud_client.post('/api/cloud/login', json={
with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
req = await cloud_client.post('/api/cloud/login', json={
'invalid': 'schema'
})
assert req.status == 400
assert len(mock_login.mock_calls) == 0
@asyncio.coroutine
def test_login_view_request_timeout(cloud_client):
async def test_login_view_request_timeout(cloud_client):
"""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):
req = yield from cloud_client.post('/api/cloud/login', json={
req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username',
'password': 'my_password'
})
@ -156,12 +151,11 @@ def test_login_view_request_timeout(cloud_client):
assert req.status == 502
@asyncio.coroutine
def test_login_view_invalid_credentials(cloud_client):
async def test_login_view_invalid_credentials(cloud_client):
"""Test logging in with invalid credentials."""
with patch('homeassistant.components.cloud.auth_api.login',
side_effect=auth_api.Unauthenticated):
req = yield from cloud_client.post('/api/cloud/login', json={
with patch('hass_nabucasa.auth.CognitoAuth.login',
side_effect=Unauthenticated):
req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username',
'password': 'my_password'
})
@ -169,12 +163,11 @@ def test_login_view_invalid_credentials(cloud_client):
assert req.status == 401
@asyncio.coroutine
def test_login_view_unknown_error(cloud_client):
async def test_login_view_unknown_error(cloud_client):
"""Test unknown error while logging in."""
with patch('homeassistant.components.cloud.auth_api.login',
side_effect=auth_api.UnknownError):
req = yield from cloud_client.post('/api/cloud/login', json={
with patch('hass_nabucasa.auth.CognitoAuth.login',
side_effect=UnknownError):
req = await cloud_client.post('/api/cloud/login', json={
'email': 'my_username',
'password': 'my_password'
})
@ -182,40 +175,36 @@ def test_login_view_unknown_error(cloud_client):
assert req.status == 502
@asyncio.coroutine
def test_logout_view(hass, cloud_client):
async def test_logout_view(hass, cloud_client):
"""Test logging out."""
cloud = hass.data['cloud'] = MagicMock()
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
data = yield from req.json()
data = await req.json()
assert data == {'message': 'ok'}
assert len(cloud.logout.mock_calls) == 1
@asyncio.coroutine
def test_logout_view_request_timeout(hass, cloud_client):
async def test_logout_view_request_timeout(hass, cloud_client):
"""Test timeout while logging out."""
cloud = hass.data['cloud'] = MagicMock()
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
@asyncio.coroutine
def test_logout_view_unknown_error(hass, cloud_client):
async def test_logout_view_unknown_error(hass, cloud_client):
"""Test unknown error while logging out."""
cloud = hass.data['cloud'] = MagicMock()
cloud.logout.side_effect = auth_api.UnknownError
req = yield from cloud_client.post('/api/cloud/logout')
cloud.logout.side_effect = UnknownError
req = await cloud_client.post('/api/cloud/logout')
assert req.status == 502
@asyncio.coroutine
def test_register_view(mock_cognito, cloud_client):
async def test_register_view(mock_cognito, cloud_client):
"""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',
'password': 'falcon42'
})
@ -226,10 +215,9 @@ def test_register_view(mock_cognito, cloud_client):
assert result_pass == 'falcon42'
@asyncio.coroutine
def test_register_view_bad_data(mock_cognito, cloud_client):
async def test_register_view_bad_data(mock_cognito, cloud_client):
"""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',
'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
@asyncio.coroutine
def test_register_view_request_timeout(mock_cognito, cloud_client):
async def test_register_view_request_timeout(mock_cognito, cloud_client):
"""Test timeout while logging out."""
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',
'password': 'falcon42'
})
assert req.status == 502
@asyncio.coroutine
def test_register_view_unknown_error(mock_cognito, cloud_client):
async def test_register_view_unknown_error(mock_cognito, cloud_client):
"""Test unknown error while logging out."""
mock_cognito.register.side_effect = auth_api.UnknownError
req = yield from cloud_client.post('/api/cloud/register', json={
mock_cognito.register.side_effect = UnknownError
req = await cloud_client.post('/api/cloud/register', json={
'email': 'hello@bla.com',
'password': 'falcon42'
})
assert req.status == 502
@asyncio.coroutine
def test_forgot_password_view(mock_cognito, cloud_client):
async def test_forgot_password_view(mock_cognito, cloud_client):
"""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',
})
assert req.status == 200
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
@asyncio.coroutine
def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
async def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
"""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',
})
assert req.status == 400
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0
@asyncio.coroutine
def test_forgot_password_view_request_timeout(mock_cognito, cloud_client):
async def test_forgot_password_view_request_timeout(mock_cognito,
cloud_client):
"""Test timeout while logging out."""
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',
})
assert req.status == 502
@asyncio.coroutine
def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
"""Test unknown error while logging out."""
mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError
req = yield from cloud_client.post('/api/cloud/forgot_password', json={
mock_cognito.initiate_forgot_password.side_effect = UnknownError
req = await cloud_client.post('/api/cloud/forgot_password', json={
'email': 'hello@bla.com',
})
assert req.status == 502
@asyncio.coroutine
def test_resend_confirm_view(mock_cognito, cloud_client):
async def test_resend_confirm_view(mock_cognito, cloud_client):
"""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',
})
assert req.status == 200
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1
@asyncio.coroutine
def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
"""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',
})
assert req.status == 400
assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0
@asyncio.coroutine
def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
"""Test timeout while logging out."""
mock_cognito.client.resend_confirmation_code.side_effect = \
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',
})
assert req.status == 502
@asyncio.coroutine
def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
"""Test unknown error while logging out."""
mock_cognito.client.resend_confirmation_code.side_effect = \
auth_api.UnknownError
req = yield from cloud_client.post('/api/cloud/resend_confirm', json={
mock_cognito.client.resend_confirmation_code.side_effect = UnknownError
req = await cloud_client.post('/api/cloud/resend_confirm', json={
'email': 'hello@bla.com',
})
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',
'custom:sub-exp': '2018-01-03'
}, 'test')
hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED
hass.data[DOMAIN].iot.state = STATE_CONNECTED
client = await hass_ws_client(hass)
with patch.dict(
@ -407,9 +385,9 @@ async def test_websocket_subscription_reconnect(
client = await hass_ws_client(hass)
with patch(
'homeassistant.components.cloud.auth_api.renew_access_token'
'hass_nabucasa.auth.CognitoAuth.renew_access_token'
) as mock_renew, patch(
'homeassistant.components.cloud.iot.CloudIoT.connect'
'hass_nabucasa.iot.CloudIoT.connect'
) as mock_connect:
await client.send_json({
'id': 5,
@ -428,7 +406,7 @@ async def test_websocket_subscription_no_reconnect_if_connected(
hass, hass_ws_client, aioclient_mock, mock_auth):
"""Test querying the status and not reconnecting because still expired."""
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({
'email': 'hello@home-assistant.io',
'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)
with patch(
'homeassistant.components.cloud.auth_api.renew_access_token'
'hass_nabucasa.auth.CognitoAuth.renew_access_token'
) as mock_renew, patch(
'homeassistant.components.cloud.iot.CloudIoT.connect'
'hass_nabucasa.iot.CloudIoT.connect'
) as mock_connect:
await client.send_json({
'id': 5,
@ -464,9 +442,9 @@ async def test_websocket_subscription_no_reconnect_if_expired(
client = await hass_ws_client(hass)
with patch(
'homeassistant.components.cloud.auth_api.renew_access_token'
'hass_nabucasa.auth.CognitoAuth.renew_access_token'
) as mock_renew, patch(
'homeassistant.components.cloud.iot.CloudIoT.connect'
'hass_nabucasa.iot.CloudIoT.connect'
) as mock_connect:
await client.send_json({
'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):
"""Test querying the status."""
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'})):
await client.send_json({
'id': 5,
@ -548,8 +526,10 @@ async def test_enabling_webhook(hass, hass_ws_client, setup_api):
'custom:sub-exp': '2018-01-03'
}, 'test')
client = await hass_ws_client(hass)
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks'
'.async_create', return_value=mock_coro()) as mock_enable:
with patch(
'hass_nabucasa.cloudhooks.Cloudhooks.async_create',
return_value=mock_coro()
) as mock_enable:
await client.send_json({
'id': 5,
'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'
}, 'test')
client = await hass_ws_client(hass)
with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks'
'.async_delete', return_value=mock_coro()) as mock_disable:
with patch(
'hass_nabucasa.cloudhooks.Cloudhooks.async_delete',
return_value=mock_coro()
) as mock_disable:
await client.send_json({
'id': 5,
'type': 'cloud/cloudhook/delete',

View File

@ -1,72 +1,34 @@
"""Test the cloud component."""
import asyncio
import json
from unittest.mock import patch, MagicMock, mock_open
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.setup import async_setup_component
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
from homeassistant.components import cloud
from homeassistant.util.dt import utcnow
from homeassistant.components.cloud.const import DOMAIN
from tests.common import mock_coro
@pytest.fixture
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():
async def test_constructor_loads_info_from_config():
"""Test non-dev mode loads info from SERVERS constant."""
hass = MagicMock(data={})
with patch.dict(cloud.SERVERS, {
'beer': {
'cognito_client_id': 'test-cognito_client_id',
'user_pool_id': 'test-user_pool_id',
'region': 'test-region',
'relayer': 'test-relayer',
'google_actions_sync_url': 'test-google_actions_sync_url',
'subscription_info_url': 'test-subscription-info-url',
'cloudhook_create_url': 'test-cloudhook_create_url',
}
}):
result = yield from cloud.async_setup(hass, {
'cloud': {cloud.CONF_MODE: 'beer'}
with patch(
"homeassistant.components.cloud.prefs.CloudPreferences."
"async_initialize",
return_value=mock_coro()
):
result = await 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']
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']
assert cl.mode == cloud.MODE_DEV
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'
async def test_initialize_loads_info(mock_os, hass):
"""Test initialize will load info from config file."""
mock_os.path.isfile.return_value = True
mopen = mock_open(read_data=json.dumps({
'id_token': 'test-id-token',
'access_token': 'test-access-token',
'refresh_token': 'test-refresh-token',
}))
async def test_remote_services(hass, mock_cloud_fixture):
"""Setup cloud component and test services."""
assert hass.services.has_service(DOMAIN, 'remote_connect')
assert hass.services.has_service(DOMAIN, 'remote_disconnect')
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
cl.iot = MagicMock()
cl.iot.connect.return_value = mock_coro()
with patch(
"hass_nabucasa.remote.RemoteUI.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), \
patch('homeassistant.components.cloud.Cloud._decode_claims'):
await cl.async_start(None)
assert mock_connect.called
assert cl.id_token == 'test-id-token'
assert cl.access_token == 'test-access-token'
assert cl.refresh_token == 'test-refresh-token'
assert len(cl.iot.connect.mock_calls) == 1
with patch(
"hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro()
) as mock_disconnect:
await hass.services.async_call(
DOMAIN, "remote_disconnect", blocking=True)
assert mock_disconnect.called
@asyncio.coroutine
def test_logout_clears_info(mock_os, hass):
"""Test logging out disconnects and removes info."""
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
cl.iot = MagicMock()
cl.iot.disconnect.return_value = mock_coro()
async def test_startup_shutdown_events(hass, mock_cloud_fixture):
"""Test if the cloud will start on startup event."""
with patch(
"hass_nabucasa.Cloud.start", return_value=mock_coro()
) as mock_start:
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
assert cl.id_token is None
assert cl.access_token is None
assert cl.refresh_token is None
assert len(mock_os.remove.mock_calls) == 1
with patch(
"hass_nabucasa.Cloud.stop", return_value=mock_coro()
) as mock_stop:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
@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
assert mock_stop.called

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}