diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bed4e240e..867b37c29 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,32 +1,26 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "name": "Hass-NabuCasa Dev", - "context": "..", - "dockerFile": "Dockerfile", - "postCreateCommand": "pip3 install -e .", - "runArgs": [ - "-e", - "GIT_EDTIOR=code --wait" - ], - "extensions": [ - "ms-python.python", - "visualstudioexptteam.vscodeintellicode", - "ms-azure-devops.azure-pipelines", - "esbenp.prettier-vscode" - ], - "settings": { - "python.pythonPath": "/usr/local/bin/python", - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "python.formatting.blackArgs": [ - "--target-version", - "py36" - ], - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true, - "terminal.integrated.shell.linux": "/bin/bash" - } -} \ No newline at end of file + "name": "Hass-NabuCasa Dev", + "context": "..", + "dockerFile": "Dockerfile", + "postCreateCommand": "pip3 install -e .", + "runArgs": ["-e", "GIT_EDTIOR=code --wait"], + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", + "esbenp.prettier-vscode" + ], + "settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackArgs": ["--target-version", "py36"], + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.shell.linux": "/bin/bash" + } +} diff --git a/hass_nabucasa/iot.py b/hass_nabucasa/iot.py index 764215dc7..b9c6f62fa 100644 --- a/hass_nabucasa/iot.py +++ b/hass_nabucasa/iot.py @@ -3,6 +3,7 @@ import logging import pprint import uuid +import random from . import iot_base from .utils import Registry @@ -135,6 +136,26 @@ async def async_handle_cloud(cloud, payload): _LOGGER.error( "You have been logged out from Home Assistant cloud: %s", payload["reason"] ) + elif action == "disconnect_remote": + # Disconect Remote connection + await cloud.remote.disconnect(clear_snitun_token=True) + elif action == "evaluate_remote_security": + + async def _reconnect() -> None: + """Reconnect after a random timeout.""" + await asyncio.sleep(random.randint(60, 7200)) + await cloud.remote.disconnect(clear_snitun_token=True) + await cloud.remote.connect() + + # Reconnect to remote frontends + cloud.client.loop.create_task(_reconnect()) + elif action in ("user_notification", "critical_user_notification"): + # Send user Notification + cloud.client.user_message( + "homeassistant_cloud_notification", + payload["title"], + payload["message"], + ) else: _LOGGER.warning("Received unknown cloud action: %s", action) diff --git a/hass_nabucasa/remote.py b/hass_nabucasa/remote.py index 8c998eaff..3cf6d3e91 100644 --- a/hass_nabucasa/remote.py +++ b/hass_nabucasa/remote.py @@ -265,10 +265,10 @@ async def _refresh_snitun_token(self) -> None: try: async with async_timeout.timeout(30): resp = await cloud_api.async_remote_token(self.cloud, aes_key, aes_iv) - if resp.status == 409: - raise RemoteInsecureVersion() - if resp.status != 200: - raise RemoteBackendError() + if resp.status == 409: + raise RemoteInsecureVersion() + if resp.status != 200: + raise RemoteBackendError() except (asyncio.TimeoutError, aiohttp.ClientError): raise RemoteBackendError() from None @@ -326,7 +326,7 @@ async def connect(self) -> None: elif self._reconnect_task and insecure: self.cloud.run_task(self.disconnect()) - async def disconnect(self) -> None: + async def disconnect(self, clear_snitun_token=False) -> None: """Disconnect from snitun server.""" if not self._snitun: _LOGGER.error("Can't handle request-connection without backend") @@ -336,6 +336,9 @@ async def disconnect(self) -> None: if self._reconnect_task: self._reconnect_task.cancel() + if clear_snitun_token: + self._token = None + # Check if we already connected if not self._snitun.is_connected: return diff --git a/tests/test_iot.py b/tests/test_iot.py index 3dbe27227..d583302c0 100644 --- a/tests/test_iot.py +++ b/tests/test_iot.py @@ -195,3 +195,51 @@ async def test_send_message_answer(loop, cloud_mock_iot): cloud_iot._response_handler[uuid].set_result({"response": True}) response = await send_task assert response == {"response": True} + + +async def test_handling_core_messages_user_notifcation(cloud_mock_iot): + """Test handling core messages.""" + cloud_mock_iot.client.user_message = MagicMock() + + await iot.async_handle_cloud( + cloud_mock_iot, + {"action": "user_notification", "title": "Test", "message": "My message"}, + ) + assert len(cloud_mock_iot.client.user_message.mock_calls) == 1 + + +async def test_handling_core_messages_critical_user_notifcation(cloud_mock_iot): + """Test handling core messages.""" + cloud_mock_iot.client.user_message = MagicMock() + + await iot.async_handle_cloud( + cloud_mock_iot, + { + "action": "critical_user_notification", + "title": "Test", + "message": "My message", + }, + ) + assert len(cloud_mock_iot.client.user_message.mock_calls) == 1 + + +async def test_handling_core_messages_remote_disconnect(cloud_mock_iot): + """Test handling core messages.""" + cloud_mock_iot.remote.disconnect = AsyncMock() + + await iot.async_handle_cloud( + cloud_mock_iot, + {"action": "disconnect_remote"}, + ) + assert len(cloud_mock_iot.remote.disconnect.mock_calls) == 1 + + +async def test_handling_core_messages_evaluate_remote_security(cloud_mock_iot): + """Test handling core messages.""" + cloud_mock_iot.client = MagicMock() + + await iot.async_handle_cloud( + cloud_mock_iot, + {"action": "evaluate_remote_security"}, + ) + assert len(cloud_mock_iot.client.loop.mock_calls) == 1 diff --git a/tests/test_remote.py b/tests/test_remote.py index 4a13afaaa..19e3bcf0e 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -16,6 +16,8 @@ from .common import MockAcme, MockSnitun +# pylint: disable=protected-access + @pytest.fixture(autouse=True) def ignore_context(): @@ -309,6 +311,7 @@ async def test_call_disconnect( await remote.disconnect() assert snitun_mock.call_disconnect assert not remote.is_connected + assert remote._token assert auth_cloud_mock.client.mock_dispatcher[-1][0] == DISPATCH_REMOTE_DISCONNECT @@ -507,7 +510,7 @@ async def test_load_connect_insecure( "valid": valid.timestamp(), "throttling": 400, }, - status=409 + status=409, ) auth_cloud_mock.client.prop_remote_autostart = True @@ -523,3 +526,42 @@ async def test_load_connect_insecure( assert not snitun_mock.call_connect assert auth_cloud_mock.client.mock_dispatcher[-1][0] == DISPATCH_REMOTE_BACKEND_UP + + +async def test_call_disconnect_clean_token( + auth_cloud_mock, acme_mock, mock_cognito, aioclient_mock, snitun_mock +): + """Initialize backend.""" + valid = utcnow() + timedelta(days=1) + auth_cloud_mock.remote_api_url = "https://test.local/api" + remote = RemoteUI(auth_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(), + "throttling": 400, + }, + ) + + assert not remote.is_connected + await remote.load_backend() + await asyncio.sleep(0.1) + assert remote.is_connected + assert remote._token + + await remote.disconnect(clear_snitun_token=True) + assert snitun_mock.call_disconnect + assert not remote.is_connected + assert remote._token is None + assert auth_cloud_mock.client.mock_dispatcher[-1][0] == DISPATCH_REMOTE_DISCONNECT