From 864ec70a27496ece5cec1768cdbd610c2608c63f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 4 Mar 2019 17:01:03 +0100 Subject: [PATCH 1/6] Bump version 0.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6c4ba1d2d..53c106e7c 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup -VERSION = "0.2" +VERSION = "0.3" setup( name="hass-nabucasa", From 957a6877decde3d2c39fda490c7ed2554f559330 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 7 Mar 2019 16:21:54 +0100 Subject: [PATCH 2/6] Update snitun 0.12 (#12) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 53c106e7c..cd984f7c2 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ packages=["hass_nabucasa"], install_requires=[ "warrant==0.6.1", - "snitun==0.11", + "snitun==0.12", "acme==0.31.0", "cryptography>=2.5", "attrs>=18.2.0", From d053855e01337cd5f5f26c5b4f0d1882dbcf662f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 8 Mar 2019 16:10:10 +0100 Subject: [PATCH 3/6] Expose some details & fix tests p1 (#13) --- hass_nabucasa/__init__.py | 3 ++- hass_nabucasa/remote.py | 31 +++++++++++++++++++++++++------ tests/__init__.py | 33 --------------------------------- tests/test_init.py | 10 +++++----- tests/test_iot.py | 2 +- 5 files changed, 33 insertions(+), 46 deletions(-) diff --git a/hass_nabucasa/__init__.py b/hass_nabucasa/__init__.py index 5be53c2fa..d79946e66 100644 --- a/hass_nabucasa/__init__.py +++ b/hass_nabucasa/__init__.py @@ -45,6 +45,7 @@ def __init__( self.iot = CloudIoT(self) self.cloudhooks = Cloudhooks(self) self.remote = RemoteUI(self) + self.auth = auth_api if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -130,7 +131,7 @@ def run_executor(self, callback: Callable, *args) -> asyncio.Future: async def fetch_subscription_info(self): """Fetch subscription info.""" - await self.run_executor(auth_api.check_token, self) + await self.run_executor(self.auth.check_token, self) return await self.websession.get( self.subscription_info_url, headers={"authorization": self.id_token} ) diff --git a/hass_nabucasa/remote.py b/hass_nabucasa/remote.py index 87a09d0a5..f9b6e4a0c 100644 --- a/hass_nabucasa/remote.py +++ b/hass_nabucasa/remote.py @@ -50,6 +50,7 @@ def __init__(self, cloud): self._acme = None self._snitun = None self._snitun_server = None + self._instance_domain = None self._reconnect_task = None self._token = None @@ -62,6 +63,11 @@ def snitun_server(self) -> Optional[str]: """Return connected snitun server.""" return self._snitun_server + @property + def instance_domain(self) -> Optional[str]: + """Return instance domain.""" + return self._instance_domain + async def _create_context(self) -> ssl.SSLContext: """Create SSL context with acme certificate.""" context = server_context_modern() @@ -84,19 +90,28 @@ async def load_backend(self) -> None: resp = await cloud_api.async_remote_register(self.cloud) if resp.status != 200: - _LOGGER.error("Can't update remote details from Home Assistant cloud") + if resp.status == 423: + _LOGGER.error( + "Weekly certification rate-limit is reached, please try it again in few days" + ) + else: + _LOGGER.error("Can't update remote details from Home Assistant cloud") raise RemoteBackendError() data = await resp.json() + # Extract data _LOGGER.debug("Retrieve instance data: %s", data) + domain = data["domain"] + email = data["email"] + server = data["server"] # Set instance details for certificate - self._acme = AcmeHandler(self.cloud, data["domain"], data["email"]) + self._acme = AcmeHandler(self.cloud, domain, email) # Domain changed / revoke CA ca_domain = await self._acme.get_common_name() - if ca_domain is not None and ca_domain != data["domain"]: - _LOGGER.warning("Invalid certificate found") + if ca_domain is not None and ca_domain != domain: + _LOGGER.warning("Invalid certificate found: %s", ca_domain) await self._acme.reset_acme() # Issue a certificate @@ -106,16 +121,17 @@ async def load_backend(self) -> None: except AcmeClientError: _LOGGER.error("ACME certification fails. Please try later.") return + self._instance_domain = domain # Setup snitun / aiohttp wrapper context = await self._create_context() self._snitun = SniTunClientAioHttp( self.cloud.client.aiohttp_runner, context, - snitun_server=data["server"], + snitun_server=server, snitun_port=443, ) - self._snitun_server = data["server"] + self._snitun_server = server await self._snitun.start() self.cloud.run_task(self.connect()) @@ -129,8 +145,11 @@ async def close_backend(self) -> None: if self._snitun: await self._snitun.stop() + # Cleanup self._snitun = None self._acme = None + self._instance_domain = None + self._snitun_server = None async def handle_connection_requests(self, caller_ip): """Handle connection requests.""" diff --git a/tests/__init__.py b/tests/__init__.py index ba63e43d0..7a4e9f295 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,34 +1 @@ """Tests for the cloud component.""" -from unittest.mock import patch -from homeassistant.setup import async_setup_component -from homeassistant.components import cloud -from homeassistant.components.cloud import const - -from jose import jwt - -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()): - assert hass.loop.run_until_complete(async_setup_component( - hass, cloud.DOMAIN, { - 'cloud': config - })) - - hass.data[cloud.DOMAIN]._decode_claims = \ - lambda token: jwt.get_unverified_claims(token) - - -def mock_cloud_prefs(hass, prefs={}): - """Fixture for cloud component.""" - prefs_to_set = { - const.PREF_ENABLE_ALEXA: True, - const.PREF_ENABLE_GOOGLE: True, - const.PREF_GOOGLE_ALLOW_UNLOCK: True, - } - prefs_to_set.update(prefs) - hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set - return prefs_to_set diff --git a/tests/test_init.py b/tests/test_init.py index 2a09f6b7c..c42c5a5ec 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -6,7 +6,7 @@ import pytest import hass_nabucasa as cloud -from homeassistant.util.dt import utcnow +from hass_nabucasa.utils import utcnow from .common import mock_coro @@ -131,13 +131,13 @@ def test_subscription_expired(cloud_client): token_val = {"custom:sub-exp": "2017-11-13"} with patch.object(cl, "_decode_claims", return_value=token_val), patch( - "homeassistant.util.dt.utcnow", + "hass_nabucasa.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", + "hass_nabucasa.utcnow", return_value=utcnow().replace( year=2017, month=11, day=19, hour=23, minute=59, second=59 ), @@ -145,7 +145,7 @@ def test_subscription_expired(cloud_client): assert not cl.subscription_expired with patch.object(cl, "_decode_claims", return_value=token_val), patch( - "homeassistant.util.dt.utcnow", + "hass_nabucasa.utcnow", return_value=utcnow().replace( year=2017, month=11, day=20, hour=0, minute=0, second=0 ), @@ -159,7 +159,7 @@ def test_subscription_not_expired(cloud_client): token_val = {"custom:sub-exp": "2017-11-13"} with patch.object(cl, "_decode_claims", return_value=token_val), patch( - "homeassistant.util.dt.utcnow", + "hass_nabucasa.utcnow", return_value=utcnow().replace(year=2017, month=11, day=9), ): assert not cl.subscription_expired diff --git a/tests/test_iot.py b/tests/test_iot.py index 55f8b642f..69cee5885 100644 --- a/tests/test_iot.py +++ b/tests/test_iot.py @@ -318,7 +318,7 @@ async def test_send_message_answer(loop, cloud_mock_iot): uuid = 5 - with patch('homeassistant.components.cloud.iot.uuid.uuid4', + with patch('hass_nabucasa.iot.uuid.uuid4', return_value=MagicMock(hex=uuid)): send_task = loop.create_task(cloud_iot.async_send_message( 'webhook', {'msg': 'yo'})) From b217b321802869f557973070e7d91aa609755104 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 8 Mar 2019 17:14:54 +0100 Subject: [PATCH 4/6] Add more tests (#14) --- tests/common.py | 35 +++++++++++++ tests/test_remote.py | 121 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index 8065aedb2..4186efb36 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,6 +1,8 @@ """Test the helper method for writing tests.""" +import asyncio from pathlib import Path import tempfile +from typing import Optional from hass_nabucasa.client import CloudClient @@ -99,11 +101,17 @@ def __init__(self): """Initialize MockAcme.""" self.is_valid = True self.call_issue = False + self.call_reset = False self.init_args = None + self.common_name = None def set_false(self): self.is_valid = False + async def get_common_name(self) -> Optional[str]: + """Return common name.""" + return self.common_name + async def is_valid_certificate(self) -> bool: """Return valid certificate.""" return self.is_valid @@ -112,6 +120,10 @@ async def issue_certificate(self): """Issue a certificate.""" self.call_issue = True + async def reset_acme(self): + """Issue a certificate.""" + self.call_reset = True + def __call__(self, *args): """Init.""" self.init_args = args @@ -125,8 +137,21 @@ def __init__(self): """Initialize MockAcme.""" self.call_start = False self.call_stop = False + self.call_connect = False + self.call_disconnect = False self.init_args = None + self.connect_args = None self.init_kwarg = None + self.wait_task = asyncio.Event() + + @property + def is_connected(self): + """Return if it is connected.""" + return self.call_connect and not self.call_disconnect + + def wait(self): + """Return waitable object.""" + return self.wait_task.wait() async def start(self): """Start snitun.""" @@ -136,6 +161,16 @@ async def stop(self): """Stop snitun.""" self.call_stop = True + async def connect(self, token: bytes, aes_key: bytes, aes_iv: bytes): + """Connect snitun.""" + self.call_connect = True + self.connect_args = [token, aes_key, aes_iv] + + async def disconnect(self): + """Disconnect snitun.""" + self.wait_task.set() + self.call_disconnect = True + def __call__(self, *args, **kwarg): """Init.""" self.init_args = args diff --git a/tests/test_remote.py b/tests/test_remote.py index c2a8c541c..202da0ede 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -1,9 +1,12 @@ """Test remote sni handler.""" +import asyncio from unittest.mock import patch, MagicMock, Mock +from datetime import timedelta import pytest from hass_nabucasa.remote import RemoteUI +from hass_nabucasa.utils import utcnow from .common import mock_coro, MockAcme, MockSnitun @@ -42,6 +45,7 @@ async def test_load_backend_exists_cert( cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock ): """Initialize backend.""" + valid = utcnow() + timedelta(days=1) cloud_mock.remote_api_url = "https://test.local/api" remote = RemoteUI(cloud_mock) @@ -53,8 +57,17 @@ async def test_load_backend_exists_cert( "server": "rest-remote.nabu.casa", }, ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) await remote.load_backend() + await asyncio.sleep(0.1) assert remote.snitun_server == "rest-remote.nabu.casa" assert not acme_mock.call_issue @@ -70,11 +83,15 @@ async def test_load_backend_exists_cert( "snitun_port": 443, } + assert snitun_mock.call_connect + assert snitun_mock.connect_args[0] == b"test-token" + async def test_load_backend_not_exists_cert( cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock ): """Initialize backend.""" + valid = utcnow() + timedelta(days=1) cloud_mock.remote_api_url = "https://test.local/api" remote = RemoteUI(cloud_mock) @@ -86,9 +103,18 @@ async def test_load_backend_not_exists_cert( "server": "rest-remote.nabu.casa", }, ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) acme_mock.set_false() await remote.load_backend() + await asyncio.sleep(0.1) assert remote.snitun_server == "rest-remote.nabu.casa" assert acme_mock.call_issue @@ -104,11 +130,15 @@ async def test_load_backend_not_exists_cert( "snitun_port": 443, } + assert snitun_mock.call_connect + assert snitun_mock.connect_args[0] == b"test-token" -async def test_load_backend_exists_cert( + +async def test_load_and_unload_backend( cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock ): """Initialize backend.""" + valid = utcnow() + timedelta(days=1) cloud_mock.remote_api_url = "https://test.local/api" remote = RemoteUI(cloud_mock) @@ -120,8 +150,17 @@ async def test_load_backend_exists_cert( "server": "rest-remote.nabu.casa", }, ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) await remote.load_backend() + await asyncio.sleep(0.1) assert remote.snitun_server == "rest-remote.nabu.casa" assert not acme_mock.call_issue @@ -139,5 +178,85 @@ async def test_load_backend_exists_cert( } await remote.close_backend() + await asyncio.sleep(0.1) assert snitun_mock.call_stop + + +async def test_load_backend_exists_wrong_cert( + cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock +): + """Initialize backend.""" + valid = utcnow() + timedelta(days=1) + cloud_mock.remote_api_url = "https://test.local/api" + remote = RemoteUI(cloud_mock) + + aioclient_mock.post( + "https://test.local/api/register_instance", + json={ + "domain": "test.dui.nabu.casa", + "email": "test@nabucasa.inc", + "server": "rest-remote.nabu.casa", + }, + ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) + + acme_mock.common_name = "wrong.dui.nabu.casa" + await remote.load_backend() + await asyncio.sleep(0.1) + + assert remote.snitun_server == "rest-remote.nabu.casa" + assert acme_mock.call_reset + assert acme_mock.init_args == ( + cloud_mock, + "test.dui.nabu.casa", + "test@nabucasa.inc", + ) + assert snitun_mock.call_start + assert snitun_mock.init_args == (None, None) + assert snitun_mock.init_kwarg == { + "snitun_server": "rest-remote.nabu.casa", + "snitun_port": 443, + } + + assert snitun_mock.call_connect + assert snitun_mock.connect_args[0] == b"test-token" + + +async def test_call_disconnect( + cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock +): + """Initialize backend.""" + valid = utcnow() + timedelta(days=1) + cloud_mock.remote_api_url = "https://test.local/api" + remote = RemoteUI(cloud_mock) + + aioclient_mock.post( + "https://test.local/api/register_instance", + json={ + "domain": "test.dui.nabu.casa", + "email": "test@nabucasa.inc", + "server": "rest-remote.nabu.casa", + }, + ) + aioclient_mock.post( + "https://test.local/api/snitun_token", + json={ + "token": "test-token", + "server": "rest-remote.nabu.casa", + "valid": valid.timestamp(), + }, + ) + + await remote.load_backend() + await asyncio.sleep(0.1) + + await remote.disconnect() + assert snitun_mock.call_disconnect From 8ac79a16594e27d37ddd12ad0457e350091856d0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 10 Mar 2019 23:30:19 +0100 Subject: [PATCH 5/6] Fix auth (#15) --- hass_nabucasa/__init__.py | 6 +- hass_nabucasa/auth.py | 208 +++++++++++++++++++++ hass_nabucasa/auth_api.py | 225 ----------------------- hass_nabucasa/cloud_api.py | 4 +- hass_nabucasa/iot.py | 8 +- hass_nabucasa/remote.py | 9 +- tests/conftest.py | 2 +- tests/{test_auth_api.py => test_auth.py} | 47 +++-- tests/test_cloud_api.py | 2 +- tests/test_iot.py | 180 +++++++++--------- 10 files changed, 343 insertions(+), 348 deletions(-) create mode 100644 hass_nabucasa/auth.py delete mode 100644 hass_nabucasa/auth_api.py rename tests/{test_auth_api.py => test_auth.py} (81%) diff --git a/hass_nabucasa/__init__.py b/hass_nabucasa/__init__.py index d79946e66..6b8070396 100644 --- a/hass_nabucasa/__init__.py +++ b/hass_nabucasa/__init__.py @@ -8,9 +8,9 @@ import aiohttp -from . import auth_api from .client import CloudClient from .cloudhooks import Cloudhooks +from .auth import CognitoAuth from .const import CONFIG_DIR, MODE_DEV, SERVERS, STATE_CONNECTED from .iot import CloudIoT from .remote import RemoteUI @@ -45,7 +45,7 @@ def __init__( self.iot = CloudIoT(self) self.cloudhooks = Cloudhooks(self) self.remote = RemoteUI(self) - self.auth = auth_api + self.auth = CognitoAuth(self) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -131,7 +131,7 @@ def run_executor(self, callback: Callable, *args) -> asyncio.Future: async def fetch_subscription_info(self): """Fetch subscription info.""" - await self.run_executor(self.auth.check_token, self) + await self.run_executor(self.auth.check_token) return await self.websession.get( self.subscription_info_url, headers={"authorization": self.id_token} ) diff --git a/hass_nabucasa/auth.py b/hass_nabucasa/auth.py new file mode 100644 index 000000000..428e8beb3 --- /dev/null +++ b/hass_nabucasa/auth.py @@ -0,0 +1,208 @@ +"""Package to communicate with the authentication API.""" +import asyncio +import logging +import random + +import boto3 +import botocore +from botocore.exceptions import ClientError, EndpointConnectionError +import warrant +from warrant.exceptions import ForceChangePasswordException + +_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, +} + + +class CognitoAuth: + """Handle cloud auth.""" + + def __init__(self, cloud): + """Configure the auth api.""" + self.cloud = cloud + self._refresh_task = None + + cloud.iot.register_on_connect(self.on_connect) + cloud.iot.register_on_disconnect(self.on_disconnect) + + async def handle_token_refresh(self): + """Handle Cloud access token refresh.""" + sleep_time = random.randint(2400, 3600) + while True: + try: + await asyncio.sleep(sleep_time) + await self.cloud.run_executor(self.renew_access_token) + 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(self): + """When the instance is connected.""" + self._refresh_task = self.cloud.run_task(self.handle_token_refresh()) + + async def on_disconnect(self): + """When the instance is disconnected.""" + self._refresh_task.cancel() + + def register(self, email, password): + """Register a new account.""" + cognito = self._cognito() + + # 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(self, email): + """Resend email confirmation.""" + cognito = self._cognito(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(self, email): + """Initialize forgotten password flow.""" + cognito = self._cognito(username=email) + + try: + cognito.initiate_forgot_password() + + except ClientError as err: + raise _map_aws_exception(err) + except EndpointConnectionError: + raise UnknownError() + + def login(self, email, password): + """Log user in and fetch certificate.""" + cognito = self._authenticate(email, password) + self.cloud.id_token = cognito.id_token + self.cloud.access_token = cognito.access_token + self.cloud.refresh_token = cognito.refresh_token + self.cloud.write_user_info() + + def check_token(self): + """Check that the token is valid and verify if needed.""" + cognito = self._cognito( + access_token=self.cloud.access_token, refresh_token=self.cloud.refresh_token + ) + + try: + if cognito.check_token(): + self.cloud.id_token = cognito.id_token + self.cloud.access_token = cognito.access_token + self.cloud.write_user_info() + + except ClientError as err: + raise _map_aws_exception(err) + + except EndpointConnectionError: + raise UnknownError() + + def renew_access_token(self): + """Renew access token.""" + cognito = self._cognito( + access_token=self.cloud.access_token, refresh_token=self.cloud.refresh_token + ) + + try: + cognito.renew_access_token() + self.cloud.id_token = cognito.id_token + self.cloud.access_token = cognito.access_token + self.cloud.write_user_info() + + except ClientError as err: + raise _map_aws_exception(err) + + except EndpointConnectionError: + raise UnknownError() + + def _authenticate(self, email, password): + """Log in and return an authenticated Cognito instance.""" + assert not self.cloud.is_logged_in, "Cannot login if already logged in." + + cognito = self._cognito(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(self, **kwargs): + """Get the client credentials.""" + cognito = warrant.Cognito( + user_pool_id=self.cloud.user_pool_id, + client_id=self.cloud.cognito_client_id, + user_pool_region=self.cloud.region, + **kwargs + ) + cognito.client = boto3.client( + "cognito-idp", + region_name=self.cloud.region, + config=botocore.config.Config(signature_version=botocore.UNSIGNED), + ) + return cognito + + +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"]) diff --git a/hass_nabucasa/auth_api.py b/hass_nabucasa/auth_api.py deleted file mode 100644 index b01a1ea71..000000000 --- a/hass_nabucasa/auth_api.py +++ /dev/null @@ -1,225 +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(cloud): - """Configure the auth api.""" - refresh_task = None - - async def handle_token_refresh(): - """Handle Cloud access token refresh.""" - sleep_time = random.randint(2400, 3600) - while True: - try: - await asyncio.sleep(sleep_time) - await cloud.run_executor(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 = cloud.run_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 diff --git a/hass_nabucasa/cloud_api.py b/hass_nabucasa/cloud_api.py index 96da88c4f..e9a1fb8f9 100644 --- a/hass_nabucasa/cloud_api.py +++ b/hass_nabucasa/cloud_api.py @@ -4,8 +4,6 @@ from aiohttp.hdrs import AUTHORIZATION -from . import auth_api - _LOGGER = logging.getLogger(__name__) @@ -15,7 +13,7 @@ def _check_token(func): @wraps(func) async def check_token(cloud, *args): """Validate token, then call func.""" - await cloud.run_executor(auth_api.check_token, cloud) + await cloud.run_executor(cloud.auth.check_token) return await func(cloud, *args) return check_token diff --git a/hass_nabucasa/iot.py b/hass_nabucasa/iot.py index 39b0c3821..dfd78a08f 100644 --- a/hass_nabucasa/iot.py +++ b/hass_nabucasa/iot.py @@ -7,7 +7,7 @@ from aiohttp import WSMsgType, client_exceptions, hdrs -from . import auth_api +from .auth import Unauthenticated, CloudError from .const import ( MESSAGE_AUTH_FAIL, MESSAGE_EXPIRATION, @@ -139,8 +139,8 @@ async def async_send_message(self, handler, payload, expect_answer=True): async def _handle_connection(self): """Connect to the IoT broker.""" try: - await self.cloud.run_executor(auth_api.check_token, self.cloud) - except auth_api.Unauthenticated as err: + await self.cloud.run_executor(self.cloud.auth.check_token) + except Unauthenticated as err: _LOGGER.error("Unable to refresh token: %s", err) await self.cloud.client.async_user_message( @@ -150,7 +150,7 @@ async def _handle_connection(self): # Don't await it because it will cancel this task self.cloud.run_task(self.cloud.logout()) return - except auth_api.CloudError as err: + except CloudError as err: _LOGGER.warning("Unable to refresh token: %s", err) return diff --git a/hass_nabucasa/remote.py b/hass_nabucasa/remote.py index f9b6e4a0c..f7c0f5917 100644 --- a/hass_nabucasa/remote.py +++ b/hass_nabucasa/remote.py @@ -90,12 +90,7 @@ async def load_backend(self) -> None: resp = await cloud_api.async_remote_register(self.cloud) if resp.status != 200: - if resp.status == 423: - _LOGGER.error( - "Weekly certification rate-limit is reached, please try it again in few days" - ) - else: - _LOGGER.error("Can't update remote details from Home Assistant cloud") + _LOGGER.error("Can't update remote details from Home Assistant cloud") raise RemoteBackendError() data = await resp.json() @@ -120,7 +115,7 @@ async def load_backend(self) -> None: await self._acme.issue_certificate() except AcmeClientError: _LOGGER.error("ACME certification fails. Please try later.") - return + raise RemoteBackendError() self._instance_domain = domain # Setup snitun / aiohttp wrapper diff --git a/tests/conftest.py b/tests/conftest.py index c1e5e3c9a..85f73b3ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,5 +46,5 @@ def cloud_client(cloud_mock): @pytest.fixture def mock_cognito(): """Mock warrant.""" - with patch("hass_nabucasa.auth_api._cognito") as mock_cog: + with patch("hass_nabucasa.auth.CognitoAuth._cognito") as mock_cog: yield mock_cog() diff --git a/tests/test_auth_api.py b/tests/test_auth.py similarity index 81% rename from tests/test_auth_api.py rename to tests/test_auth.py index 930953f50..54de7edf2 100644 --- a/tests/test_auth_api.py +++ b/tests/test_auth.py @@ -5,7 +5,7 @@ from botocore.exceptions import ClientError import pytest -from hass_nabucasa import auth_api +from hass_nabucasa import auth as auth_api def aws_error(code, message="Unknown", operation_name="fake_operation_name"): @@ -17,10 +17,11 @@ def aws_error(code, message="Unknown", operation_name="fake_operation_name"): def test_login_invalid_auth(mock_cognito): """Test trying to login with invalid credentials.""" cloud = MagicMock(is_logged_in=False) + auth = auth_api.CognitoAuth(cloud) mock_cognito.authenticate.side_effect = aws_error("NotAuthorizedException") with pytest.raises(auth_api.Unauthenticated): - auth_api.login(cloud, "user", "pass") + auth.login("user", "pass") assert len(cloud.write_user_info.mock_calls) == 0 @@ -28,10 +29,11 @@ def test_login_invalid_auth(mock_cognito): def test_login_user_not_found(mock_cognito): """Test trying to login with invalid credentials.""" cloud = MagicMock(is_logged_in=False) + auth = auth_api.CognitoAuth(cloud) mock_cognito.authenticate.side_effect = aws_error("UserNotFoundException") with pytest.raises(auth_api.UserNotFound): - auth_api.login(cloud, "user", "pass") + auth.login("user", "pass") assert len(cloud.write_user_info.mock_calls) == 0 @@ -39,10 +41,11 @@ def test_login_user_not_found(mock_cognito): def test_login_user_not_confirmed(mock_cognito): """Test trying to login without confirming account.""" cloud = MagicMock(is_logged_in=False) + auth = auth_api.CognitoAuth(cloud) mock_cognito.authenticate.side_effect = aws_error("UserNotConfirmedException") with pytest.raises(auth_api.UserNotConfirmed): - auth_api.login(cloud, "user", "pass") + auth.login("user", "pass") assert len(cloud.write_user_info.mock_calls) == 0 @@ -50,11 +53,12 @@ def test_login_user_not_confirmed(mock_cognito): def test_login(mock_cognito): """Test trying to login without confirming account.""" cloud = MagicMock(is_logged_in=False) + auth = auth_api.CognitoAuth(cloud) 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") + auth.login("user", "pass") assert len(mock_cognito.authenticate.mock_calls) == 1 assert cloud.id_token == "test_id_token" @@ -65,7 +69,8 @@ def test_login(mock_cognito): def test_register(mock_cognito, cloud_mock): """Test registering an account.""" - auth_api.register(cloud_mock, "email@home-assistant.io", "password") + auth = auth_api.CognitoAuth(cloud_mock) + auth.register("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" @@ -75,44 +80,49 @@ def test_register(mock_cognito, cloud_mock): def test_register_fails(mock_cognito, cloud_mock): """Test registering an account.""" mock_cognito.register.side_effect = aws_error("SomeError") + auth = auth_api.CognitoAuth(cloud_mock) with pytest.raises(auth_api.CloudError): - auth_api.register(cloud_mock, "email@home-assistant.io", "password") + auth.register("email@home-assistant.io", "password") def test_resend_email_confirm(mock_cognito, cloud_mock): """Test starting forgot password flow.""" - auth_api.resend_email_confirm(cloud_mock, "email@home-assistant.io") + auth = auth_api.CognitoAuth(cloud_mock) + auth.resend_email_confirm("email@home-assistant.io") assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 def test_resend_email_confirm_fails(mock_cognito, cloud_mock): """Test failure when starting forgot password flow.""" + auth = auth_api.CognitoAuth(cloud_mock) mock_cognito.client.resend_confirmation_code.side_effect = aws_error("SomeError") with pytest.raises(auth_api.CloudError): - auth_api.resend_email_confirm(cloud_mock, "email@home-assistant.io") + auth.resend_email_confirm("email@home-assistant.io") def test_forgot_password(mock_cognito, cloud_mock): """Test starting forgot password flow.""" - auth_api.forgot_password(cloud_mock, "email@home-assistant.io") + auth = auth_api.CognitoAuth(cloud_mock) + auth.forgot_password("email@home-assistant.io") assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 def test_forgot_password_fails(mock_cognito, cloud_mock): """Test failure when starting forgot password flow.""" + auth = auth_api.CognitoAuth(cloud_mock) mock_cognito.initiate_forgot_password.side_effect = aws_error("SomeError") with pytest.raises(auth_api.CloudError): - auth_api.forgot_password(cloud_mock, "email@home-assistant.io") + auth.forgot_password("email@home-assistant.io") def test_check_token_writes_new_token_on_refresh(mock_cognito, cloud_mock): """Test check_token writes new token if refreshed.""" - cloud = MagicMock() + auth = auth_api.CognitoAuth(cloud_mock) 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_mock) + auth.check_token() assert len(mock_cognito.check_token.mock_calls) == 1 assert cloud_mock.id_token == "new id token" @@ -123,8 +133,9 @@ def test_check_token_writes_new_token_on_refresh(mock_cognito, cloud_mock): def test_check_token_does_not_write_existing_token(mock_cognito, cloud_mock): """Test check_token won't write new token if still valid.""" mock_cognito.check_token.return_value = False + auth = auth_api.CognitoAuth(cloud_mock) - auth_api.check_token(cloud_mock) + auth.check_token() assert len(mock_cognito.check_token.mock_calls) == 1 assert cloud_mock.id_token != mock_cognito.id_token @@ -135,9 +146,10 @@ def test_check_token_does_not_write_existing_token(mock_cognito, cloud_mock): def test_check_token_raises(mock_cognito, cloud_mock): """Test we raise correct error.""" mock_cognito.check_token.side_effect = aws_error("SomeError") + auth = auth_api.CognitoAuth(cloud_mock) with pytest.raises(auth_api.CloudError): - auth_api.check_token(cloud_mock) + auth.check_token() assert len(mock_cognito.check_token.mock_calls) == 1 assert cloud_mock.id_token != mock_cognito.id_token @@ -147,13 +159,13 @@ def test_check_token_raises(mock_cognito, cloud_mock): async def test_async_setup(cloud_mock): """Test async setup.""" - await auth_api.async_setup(cloud_mock) + auth = auth_api.CognitoAuth(cloud_mock) assert len(cloud_mock.iot.mock_calls) == 2 on_connect = cloud_mock.iot.mock_calls[0][1][0] on_disconnect = cloud_mock.iot.mock_calls[1][1][0] with patch("random.randint", return_value=0), patch( - "hass_nabucasa.auth_api.renew_access_token" + "hass_nabucasa.auth.CognitoAuth.renew_access_token" ) as mock_renew: await on_connect() # Let handle token sleep once @@ -162,7 +174,6 @@ async def test_async_setup(cloud_mock): await asyncio.sleep(0) assert len(mock_renew.mock_calls) == 1 - assert mock_renew.mock_calls[0][1][0] is cloud_mock await on_disconnect() diff --git a/tests/test_cloud_api.py b/tests/test_cloud_api.py index 058e4f088..e0fb957bf 100644 --- a/tests/test_cloud_api.py +++ b/tests/test_cloud_api.py @@ -9,7 +9,7 @@ @pytest.fixture(autouse=True) def mock_check_token(): """Mock check token.""" - with patch("hass_nabucasa.auth_api." "check_token"): + with patch("hass_nabucasa.auth.CognitoAuth.check_token"): yield diff --git a/tests/test_iot.py b/tests/test_iot.py index 69cee5885..900d3e348 100644 --- a/tests/test_iot.py +++ b/tests/test_iot.py @@ -5,7 +5,7 @@ from aiohttp import WSMsgType, client_exceptions, web import pytest -from hass_nabucasa import Cloud, iot, auth_api, MODE_DEV +from hass_nabucasa import Cloud, iot, auth as auth_api, MODE_DEV from .common import mock_coro, mock_coro_func @@ -19,7 +19,7 @@ def mock_client(cloud_mock): # Trigger cancelled error to avoid reconnect. org_websession = cloud_mock.websession - with patch('asyncio.sleep', side_effect=asyncio.CancelledError): + with patch("asyncio.sleep", side_effect=asyncio.CancelledError): websession.ws_connect.return_value = mock_coro(client) cloud_mock.websession = websession yield client @@ -30,7 +30,7 @@ def mock_client(cloud_mock): @pytest.fixture def mock_handle_message(): """Mock handle message.""" - with patch('hass_nabucasa.iot.async_handle_message') as mock: + with patch("hass_nabucasa.iot.async_handle_message") as mock: yield mock @@ -45,15 +45,19 @@ def cloud_mock_iot(cloud_mock): async def test_cloud_calling_handler(mock_client, mock_handle_message, cloud_mock_iot): """Test we call handle message with correct info.""" conn = iot.CloudIoT(cloud_mock_iot) - 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.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) await conn.connect() @@ -63,28 +67,32 @@ async def test_cloud_calling_handler(mock_client, mock_handle_message, cloud_moc cloud, handler_name, payload = mock_handle_message.mock_calls[0][1] assert cloud is cloud_mock_iot - assert handler_name == 'test-handler' - assert payload == 'test-payload' + 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' + "msgid": "test-msg-id", + "payload": "response", } async def test_connection_msg_for_unknown_handler(mock_client, cloud_mock_iot): """Test a msg for an unknown handler.""" conn = iot.CloudIoT(cloud_mock_iot) - 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.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) await conn.connect() @@ -92,24 +100,29 @@ async def test_connection_msg_for_unknown_handler(mock_client, cloud_mock_iot): # 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', + "msgid": "test-msg-id", + "error": "unknown-handler", } -async def test_connection_msg_for_handler_raising(mock_client, mock_handle_message, - cloud_mock_iot): +async def test_connection_msg_for_handler_raising( + mock_client, mock_handle_message, cloud_mock_iot +): """Test we sent error when handler raises exception.""" conn = iot.CloudIoT(cloud_mock_iot) - 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.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) await conn.connect() @@ -117,8 +130,8 @@ async def test_connection_msg_for_handler_raising(mock_client, mock_handle_messa # 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', + "msgid": "test-msg-id", + "error": "exception", } @@ -127,61 +140,57 @@ async def test_handler_forwarding(): handler = MagicMock() handler.return_value = mock_coro() cloud = object() - with patch.dict(iot.HANDLERS, {'test': handler}): - await iot.async_handle_message(cloud, 'test', 'payload') + with patch.dict(iot.HANDLERS, {"test": handler}): + await iot.async_handle_message(cloud, "test", "payload") assert len(handler.mock_calls) == 1 r_cloud, payload = handler.mock_calls[0][1] assert r_cloud is cloud - assert payload == 'payload' + assert payload == "payload" async def test_handling_core_messages_logout(cloud_mock_iot): """Test handling core messages.""" cloud_mock_iot.logout.return_value = mock_coro() - await iot.async_handle_cloud(cloud_mock_iot, { - 'action': 'logout', - 'reason': 'Logged in at two places.' - }) + await iot.async_handle_cloud( + cloud_mock_iot, {"action": "logout", "reason": "Logged in at two places."} + ) assert len(cloud_mock_iot.logout.mock_calls) == 1 -async def test_cloud_getting_disconnected_by_server(mock_client, caplog, cloud_mock_iot): +async def test_cloud_getting_disconnected_by_server( + mock_client, caplog, cloud_mock_iot +): """Test server disconnecting instance.""" conn = iot.CloudIoT(cloud_mock_iot) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.CLOSING, - )) + mock_client.receive.return_value = mock_coro(MagicMock(type=WSMsgType.CLOSING)) - with patch('asyncio.sleep', side_effect=[mock_coro(), asyncio.CancelledError]): + with patch("asyncio.sleep", side_effect=[mock_coro(), asyncio.CancelledError]): await conn.connect() - assert 'Connection closed' in caplog.text + assert "Connection closed" in caplog.text async def test_cloud_receiving_bytes(mock_client, caplog, cloud_mock_iot): """Test server disconnecting instance.""" conn = iot.CloudIoT(cloud_mock_iot) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.BINARY, - )) + mock_client.receive.return_value = mock_coro(MagicMock(type=WSMsgType.BINARY)) await conn.connect() - assert 'Connection closed: Received non-Text message' in caplog.text + assert "Connection closed: Received non-Text message" in caplog.text async def test_cloud_sending_invalid_json(mock_client, caplog, cloud_mock_iot): """Test cloud sending invalid JSON.""" conn = iot.CloudIoT(cloud_mock_iot) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.TEXT, - json=MagicMock(side_effect=ValueError) - )) + mock_client.receive.return_value = mock_coro( + MagicMock(type=WSMsgType.TEXT, json=MagicMock(side_effect=ValueError)) + ) await conn.connect() - assert 'Connection closed: Received invalid JSON.' in caplog.text + assert "Connection closed: Received invalid JSON." in caplog.text async def test_cloud_check_token_raising(mock_client, caplog, cloud_mock_iot): @@ -191,18 +200,19 @@ async def test_cloud_check_token_raising(mock_client, caplog, cloud_mock_iot): await conn.connect() - assert 'Unable to refresh token: BLA' in caplog.text + assert "Unable to refresh token: BLA" in caplog.text async def test_cloud_connect_invalid_auth(mock_client, caplog, cloud_mock_iot): """Test invalid auth detected by server.""" conn = iot.CloudIoT(cloud_mock_iot) - mock_client.receive.side_effect = \ - client_exceptions.WSServerHandshakeError(None, None, status=401) + mock_client.receive.side_effect = client_exceptions.WSServerHandshakeError( + None, None, status=401 + ) await conn.connect() - assert 'Connection closed: Invalid auth.' in caplog.text + assert "Connection closed: Invalid auth." in caplog.text async def test_cloud_unable_to_connect(mock_client, caplog, cloud_mock_iot): @@ -212,7 +222,7 @@ async def test_cloud_unable_to_connect(mock_client, caplog, cloud_mock_iot): await conn.connect() - assert 'Unable to connect:' in caplog.text + assert "Unable to connect:" in caplog.text async def test_cloud_random_exception(mock_client, caplog, cloud_mock_iot): @@ -222,7 +232,7 @@ async def test_cloud_random_exception(mock_client, caplog, cloud_mock_iot): await conn.connect() - assert 'Unexpected error' in caplog.text + assert "Unexpected error" in caplog.text async def test_refresh_token_before_expiration_fails(cloud_mock): @@ -230,10 +240,9 @@ async def test_refresh_token_before_expiration_fails(cloud_mock): cloud_mock.subscription_expired = True conn = iot.CloudIoT(cloud_mock) - with patch('hass_nabucasa.iot.auth_api.check_token', return_value=mock_coro()) as mock_check_token: - await conn.connect() + await conn.connect() - assert len(mock_check_token.mock_calls) == 1 + assert len(cloud_mock.auth.check_token.mock_calls) == 1 assert len(cloud_mock.client.mock_user) == 1 @@ -280,10 +289,9 @@ async def test_refresh_token_expired(cloud_mock): cloud_mock.subscription_expired = True conn = iot.CloudIoT(cloud_mock) - with patch('hass_nabucasa.iot.auth_api.check_token', return_value=mock_coro(exception=auth_api.Unauthenticated)) as mock_check_token: - await conn.connect() + await conn.connect() - assert len(mock_check_token.mock_calls) == 1 + assert len(cloud_mock.auth.check_token.mock_calls) == 1 assert len(cloud_mock.client.mock_user) == 1 @@ -292,7 +300,7 @@ async def test_send_message_not_connected(cloud_mock_iot): cloud_iot = iot.CloudIoT(cloud_mock_iot) with pytest.raises(iot.NotConnected): - await cloud_iot.async_send_message('webhook', {'msg': 'yo'}) + await cloud_iot.async_send_message("webhook", {"msg": "yo"}) async def test_send_message_no_answer(cloud_mock_iot): @@ -301,13 +309,12 @@ async def test_send_message_no_answer(cloud_mock_iot): 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) + 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'} + assert msg["handler"] == "webhook" + assert msg["payload"] == {"msg": "yo"} async def test_send_message_answer(loop, cloud_mock_iot): @@ -318,18 +325,19 @@ async def test_send_message_answer(loop, cloud_mock_iot): uuid = 5 - with patch('hass_nabucasa.iot.uuid.uuid4', - return_value=MagicMock(hex=uuid)): - send_task = loop.create_task(cloud_iot.async_send_message( - 'webhook', {'msg': 'yo'})) + with patch("hass_nabucasa.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'} + assert msg["handler"] == "webhook" + assert msg["payload"] == {"msg": "yo"} - cloud_iot._response_handler[uuid].set_result({'response': True}) + cloud_iot._response_handler[uuid].set_result({"response": True}) response = await send_task - assert response == {'response': True} + assert response == {"response": True} + From 82e6348aa00c4bbf785cf05c8dd1566638a0a6b7 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 10 Mar 2019 23:53:12 +0100 Subject: [PATCH 6/6] Cleanup token on logout (#16) --- hass_nabucasa/remote.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hass_nabucasa/remote.py b/hass_nabucasa/remote.py index f7c0f5917..22570a6e7 100644 --- a/hass_nabucasa/remote.py +++ b/hass_nabucasa/remote.py @@ -143,6 +143,7 @@ async def close_backend(self) -> None: # Cleanup self._snitun = None self._acme = None + self._token = None self._instance_domain = None self._snitun_server = None