mobile_app component (#21475)

* Initial pass of a mobile_app component

* Fully support encryption, validation for the webhook payloads, and other general improvements

* Return same format as original API calls

* Minor encryption fixes, logging improvements

* Migrate Owntracks to use the superior PyNaCl instead of libnacl, mark it as a requirement in mobile_app

* Add mobile_app to .coveragerc

* Dont manually b64decode on OT

* Initial requested changes

* Round two of fixes

* Initial mobile_app tests

* Dont allow making registration requests for same/existing device

* Test formatting fixes

* Add mobile_app to default_config

* Add some more keys allowed in registration payloads

* Add support for getting a single device, updating a device, getting all devices. Also change from /api/mobile_app/register to /api/mobile_app/devices

* Change device_id to fingerprint

* Next round of changes

* Add keyword args and pass context on all relevant calls

* Remove SingleDeviceView in favor of webhook type to update registration

* Only allow some properties to be updated on registrations, rename integration_data to app_data

* Add call service test, ensure events actually fire, only run the encryption tests if sodium is installed

* pylint

* Fix OwnTracks test

* Fix iteration of devices and remove device_for_webhook_id
This commit is contained in:
Robbie Trencheny 2019-03-01 23:08:20 -08:00 committed by Paulus Schoutsen
parent 43f85f7053
commit 73675d5a48
12 changed files with 666 additions and 21 deletions

View File

@ -320,6 +320,7 @@ omit =
homeassistant/components/media_player/yamaha.py homeassistant/components/media_player/yamaha.py
homeassistant/components/media_player/ziggo_mediabox_xl.py homeassistant/components/media_player/ziggo_mediabox_xl.py
homeassistant/components/meteo_france/* homeassistant/components/meteo_france/*
homeassistant/components/mobile_app/*
homeassistant/components/mochad/* homeassistant/components/mochad/*
homeassistant/components/modbus/* homeassistant/components/modbus/*
homeassistant/components/mychevy/* homeassistant/components/mychevy/*

View File

@ -11,6 +11,7 @@ DEPENDENCIES = (
'history', 'history',
'logbook', 'logbook',
'map', 'map',
'mobile_app',
'person', 'person',
'script', 'script',
'sun', 'sun',

View File

@ -0,0 +1,355 @@
"""Support for native mobile apps."""
import logging
import json
from functools import partial
import voluptuous as vol
from aiohttp.web import json_response, Response
from aiohttp.web_exceptions import HTTPBadRequest
from homeassistant import config_entries
from homeassistant.auth.util import generate_secret
import homeassistant.core as ha
from homeassistant.core import Context
from homeassistant.components import webhook
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN, SERVICE_SEE as DEVICE_TRACKER_SEE,
SERVICE_SEE_PAYLOAD_SCHEMA as SEE_SCHEMA)
from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA,
HTTP_BAD_REQUEST, HTTP_CREATED,
HTTP_INTERNAL_SERVER_ERROR, CONF_WEBHOOK_ID)
from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound,
TemplateError)
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.typing import HomeAssistantType
REQUIREMENTS = ['PyNaCl==1.3.0']
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'mobile_app'
DEPENDENCIES = ['device_tracker', 'http', 'webhook']
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
CONF_SECRET = 'secret'
CONF_USER_ID = 'user_id'
ATTR_APP_DATA = 'app_data'
ATTR_APP_ID = 'app_id'
ATTR_APP_NAME = 'app_name'
ATTR_APP_VERSION = 'app_version'
ATTR_DEVICE_NAME = 'device_name'
ATTR_MANUFACTURER = 'manufacturer'
ATTR_MODEL = 'model'
ATTR_OS_VERSION = 'os_version'
ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption'
ATTR_EVENT_DATA = 'event_data'
ATTR_EVENT_TYPE = 'event_type'
ATTR_TEMPLATE = 'template'
ATTR_TEMPLATE_VARIABLES = 'variables'
ATTR_WEBHOOK_DATA = 'data'
ATTR_WEBHOOK_ENCRYPTED = 'encrypted'
ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data'
ATTR_WEBHOOK_TYPE = 'type'
WEBHOOK_TYPE_CALL_SERVICE = 'call_service'
WEBHOOK_TYPE_FIRE_EVENT = 'fire_event'
WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template'
WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location'
WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration'
WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT,
WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION,
WEBHOOK_TYPE_UPDATE_REGISTRATION]
REGISTER_DEVICE_SCHEMA = vol.Schema({
vol.Optional(ATTR_APP_DATA, default={}): dict,
vol.Required(ATTR_APP_ID): cv.string,
vol.Optional(ATTR_APP_NAME): cv.string,
vol.Required(ATTR_APP_VERSION): cv.string,
vol.Required(ATTR_DEVICE_NAME): cv.string,
vol.Required(ATTR_MANUFACTURER): cv.string,
vol.Required(ATTR_MODEL): cv.string,
vol.Optional(ATTR_OS_VERSION): cv.string,
vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean,
})
UPDATE_DEVICE_SCHEMA = vol.Schema({
vol.Optional(ATTR_APP_DATA, default={}): dict,
vol.Required(ATTR_APP_VERSION): cv.string,
vol.Required(ATTR_DEVICE_NAME): cv.string,
vol.Required(ATTR_MANUFACTURER): cv.string,
vol.Required(ATTR_MODEL): cv.string,
vol.Optional(ATTR_OS_VERSION): cv.string,
})
WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({
vol.Required(ATTR_WEBHOOK_TYPE): vol.In(WEBHOOK_TYPES),
vol.Required(ATTR_WEBHOOK_DATA, default={}): dict,
vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean,
vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string,
})
CALL_SERVICE_SCHEMA = vol.Schema({
vol.Required(ATTR_DOMAIN): cv.string,
vol.Required(ATTR_SERVICE): cv.string,
vol.Optional(ATTR_SERVICE_DATA, default={}): dict,
})
FIRE_EVENT_SCHEMA = vol.Schema({
vol.Required(ATTR_EVENT_TYPE): cv.string,
vol.Optional(ATTR_EVENT_DATA, default={}): dict,
})
RENDER_TEMPLATE_SCHEMA = vol.Schema({
vol.Required(ATTR_TEMPLATE): cv.string,
vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict,
})
WEBHOOK_SCHEMAS = {
WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA,
WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA,
WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA,
WEBHOOK_TYPE_UPDATE_LOCATION: SEE_SCHEMA,
WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_DEVICE_SCHEMA,
}
def get_cipher():
"""Return decryption function and length of key.
Async friendly.
"""
from nacl.secret import SecretBox
from nacl.encoding import Base64Encoder
def decrypt(ciphertext, key):
"""Decrypt ciphertext using key."""
return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
return (SecretBox.KEY_SIZE, decrypt)
def _decrypt_payload(key, ciphertext):
"""Decrypt encrypted payload."""
try:
keylen, decrypt = get_cipher()
except OSError:
_LOGGER.warning(
"Ignoring encrypted payload because libsodium not installed")
return None
if key is None:
_LOGGER.warning(
"Ignoring encrypted payload because no decryption key known")
return None
key = key.encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b'\0')
try:
message = decrypt(ciphertext, key)
message = json.loads(message.decode("utf-8"))
_LOGGER.debug("Successfully decrypted mobile_app payload")
return message
except ValueError:
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
return None
def context(device):
"""Generate a context from a request."""
return Context(user_id=device[CONF_USER_ID])
async def handle_webhook(store, hass: HomeAssistantType, webhook_id: str,
request):
"""Handle webhook callback."""
device = hass.data[DOMAIN][webhook_id]
try:
req_data = await request.json()
except ValueError:
_LOGGER.warning('Received invalid JSON from mobile_app')
return json_response([], status=HTTP_BAD_REQUEST)
try:
req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data)
except vol.Invalid as ex:
err = vol.humanize.humanize_error(req_data, ex)
_LOGGER.error('Received invalid webhook payload: %s', err)
return Response(status=200)
webhook_type = req_data[ATTR_WEBHOOK_TYPE]
webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {})
if req_data[ATTR_WEBHOOK_ENCRYPTED]:
enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA]
webhook_payload = _decrypt_payload(device[CONF_SECRET], enc_data)
try:
data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload)
except vol.Invalid as ex:
err = vol.humanize.humanize_error(webhook_payload, ex)
_LOGGER.error('Received invalid webhook payload: %s', err)
return Response(status=200)
if webhook_type == WEBHOOK_TYPE_CALL_SERVICE:
try:
await hass.services.async_call(data[ATTR_DOMAIN],
data[ATTR_SERVICE],
data[ATTR_SERVICE_DATA],
blocking=True,
context=context(device))
except (vol.Invalid, ServiceNotFound):
raise HTTPBadRequest()
return Response(status=200)
if webhook_type == WEBHOOK_TYPE_FIRE_EVENT:
event_type = data[ATTR_EVENT_TYPE]
hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA],
ha.EventOrigin.remote, context=context(device))
return Response(status=200)
if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE:
try:
tpl = template.Template(data[ATTR_TEMPLATE], hass)
rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES))
return json_response({"rendered": rendered})
except (ValueError, TemplateError) as ex:
return json_response(({"error": ex}), status=HTTP_BAD_REQUEST)
if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION:
await hass.services.async_call(DEVICE_TRACKER_DOMAIN,
DEVICE_TRACKER_SEE, data,
blocking=True, context=context(device))
return Response(status=200)
if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION:
data[ATTR_APP_ID] = device[ATTR_APP_ID]
data[ATTR_APP_NAME] = device[ATTR_APP_NAME]
data[ATTR_SUPPORTS_ENCRYPTION] = device[ATTR_SUPPORTS_ENCRYPTION]
data[CONF_SECRET] = device[CONF_SECRET]
data[CONF_USER_ID] = device[CONF_USER_ID]
data[CONF_WEBHOOK_ID] = device[CONF_WEBHOOK_ID]
hass.data[DOMAIN][webhook_id] = data
try:
await store.async_save(hass.data[DOMAIN])
except HomeAssistantError as ex:
_LOGGER.error("Error updating mobile_app registration: %s", ex)
return Response(status=200)
return json_response(safe_device(data))
def supports_encryption():
"""Test if we support encryption."""
try:
import nacl # noqa pylint: disable=unused-import
return True
except OSError:
return False
def safe_device(device: dict):
"""Return a device without webhook_id or secret."""
return {
ATTR_APP_DATA: device[ATTR_APP_DATA],
ATTR_APP_ID: device[ATTR_APP_ID],
ATTR_APP_NAME: device[ATTR_APP_NAME],
ATTR_APP_VERSION: device[ATTR_APP_VERSION],
ATTR_DEVICE_NAME: device[ATTR_DEVICE_NAME],
ATTR_MANUFACTURER: device[ATTR_MANUFACTURER],
ATTR_MODEL: device[ATTR_MODEL],
ATTR_OS_VERSION: device[ATTR_OS_VERSION],
ATTR_SUPPORTS_ENCRYPTION: device[ATTR_SUPPORTS_ENCRYPTION],
}
def register_device_webhook(hass: HomeAssistantType, store, device):
"""Register the webhook for a device."""
device_name = 'Mobile App: {}'.format(device[ATTR_DEVICE_NAME])
webhook_id = device[CONF_WEBHOOK_ID]
webhook.async_register(hass, DOMAIN, device_name, webhook_id,
partial(handle_webhook, store))
async def async_setup(hass, config):
"""Set up the mobile app component."""
conf = config.get(DOMAIN)
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
app_config = await store.async_load()
if app_config is None:
app_config = {}
hass.data[DOMAIN] = app_config
for device in app_config.values():
register_device_webhook(hass, store, device)
if conf is not None:
hass.async_create_task(hass.config_entries.flow.async_init(
DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
hass.http.register_view(DevicesView(store))
return True
async def async_setup_entry(hass, entry):
"""Set up an mobile_app entry."""
return True
class DevicesView(HomeAssistantView):
"""A view that accepts device registration requests."""
url = '/api/mobile_app/devices'
name = 'api:mobile_app:register-device'
def __init__(self, store):
"""Initialize the view."""
self._store = store
@RequestDataValidator(REGISTER_DEVICE_SCHEMA)
async def post(self, request, data):
"""Handle the POST request for device registration."""
hass = request.app['hass']
resp = {}
webhook_id = generate_secret()
data[CONF_WEBHOOK_ID] = resp[CONF_WEBHOOK_ID] = webhook_id
if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption():
secret = generate_secret(16)
data[CONF_SECRET] = resp[CONF_SECRET] = secret
data[CONF_USER_ID] = request['hass_user'].id
hass.data[DOMAIN][webhook_id] = data
try:
await self._store.async_save(hass.data[DOMAIN])
except HomeAssistantError:
return self.json_message("Error saving device.",
HTTP_INTERNAL_SERVER_ERROR)
register_device_webhook(hass, self._store, data)
return self.json(resp, status_code=HTTP_CREATED)

View File

@ -16,7 +16,7 @@ from homeassistant.setup import async_when_setup
from .config_flow import CONF_SECRET from .config_flow import CONF_SECRET
REQUIREMENTS = ['libnacl==1.6.1'] REQUIREMENTS = ['PyNaCl==1.3.0']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -9,7 +9,7 @@ CONF_SECRET = 'secret'
def supports_encryption(): def supports_encryption():
"""Test if we support encryption.""" """Test if we support encryption."""
try: try:
import libnacl # noqa pylint: disable=unused-import import nacl # noqa pylint: disable=unused-import
return True return True
except OSError: except OSError:
return False return False

View File

@ -4,7 +4,6 @@ Device tracker platform that adds support for OwnTracks over MQTT.
For more details about this platform, please refer to the documentation at For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/device_tracker.owntracks/ https://home-assistant.io/components/device_tracker.owntracks/
""" """
import base64
import json import json
import logging import logging
@ -37,13 +36,13 @@ def get_cipher():
Async friendly. Async friendly.
""" """
from libnacl import crypto_secretbox_KEYBYTES as KEYLEN from nacl.secret import SecretBox
from libnacl.secret import SecretBox from nacl.encoding import Base64Encoder
def decrypt(ciphertext, key): def decrypt(ciphertext, key):
"""Decrypt ciphertext using key.""" """Decrypt ciphertext using key."""
return SecretBox(key).decrypt(ciphertext) return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
return (KEYLEN, decrypt) return (SecretBox.KEY_SIZE, decrypt)
def _parse_topic(topic, subscribe_topic): def _parse_topic(topic, subscribe_topic):
@ -141,7 +140,6 @@ def _decrypt_payload(secret, topic, ciphertext):
key = key.ljust(keylen, b'\0') key = key.ljust(keylen, b'\0')
try: try:
ciphertext = base64.b64decode(ciphertext)
message = decrypt(ciphertext, key) message = decrypt(ciphertext, key)
message = message.decode("utf-8") message = message.decode("utf-8")
_LOGGER.debug("Decrypted payload: %s", message) _LOGGER.debug("Decrypted payload: %s", message)

View File

@ -50,6 +50,10 @@ PyMVGLive==1.1.4
# homeassistant.components.arduino # homeassistant.components.arduino
PyMata==2.14 PyMata==2.14
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
PyNaCl==1.3.0
# homeassistant.auth.mfa_modules.totp # homeassistant.auth.mfa_modules.totp
PyQRCode==1.2.1 PyQRCode==1.2.1
@ -608,9 +612,6 @@ konnected==0.1.4
# homeassistant.components.eufy # homeassistant.components.eufy
lakeside==0.12 lakeside==0.12
# homeassistant.components.owntracks
libnacl==1.6.1
# homeassistant.components.dyson # homeassistant.components.dyson
libpurecoollink==0.4.2 libpurecoollink==0.4.2

View File

@ -21,6 +21,10 @@ requests_mock==1.5.2
# homeassistant.components.homekit # homeassistant.components.homekit
HAP-python==2.4.2 HAP-python==2.4.2
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
PyNaCl==1.3.0
# homeassistant.components.sensor.rmvtransport # homeassistant.components.sensor.rmvtransport
PyRMVtransport==0.1.3 PyRMVtransport==0.1.3

View File

@ -108,6 +108,7 @@ TEST_REQUIREMENTS = (
'pyupnp-async', 'pyupnp-async',
'pywebpush', 'pywebpush',
'pyHS100', 'pyHS100',
'PyNaCl',
'regenmaschine', 'regenmaschine',
'restrictedpython', 'restrictedpython',
'rflink', 'rflink',

View File

@ -1295,18 +1295,25 @@ async def test_unsupported_message(hass, context):
def generate_ciphers(secret): def generate_ciphers(secret):
"""Generate test ciphers for the DEFAULT_LOCATION_MESSAGE.""" """Generate test ciphers for the DEFAULT_LOCATION_MESSAGE."""
# libnacl ciphertext generation will fail if the module # PyNaCl ciphertext generation will fail if the module
# cannot be imported. However, the test for decryption # cannot be imported. However, the test for decryption
# also relies on this library and won't be run without it. # also relies on this library and won't be run without it.
import pickle import pickle
import base64 import base64
try: try:
from libnacl import crypto_secretbox_KEYBYTES as KEYLEN from nacl.secret import SecretBox
from libnacl.secret import SecretBox from nacl.encoding import Base64Encoder
key = secret.encode("utf-8")[:KEYLEN].ljust(KEYLEN, b'\0')
ctxt = base64.b64encode(SecretBox(key).encrypt(json.dumps( keylen = SecretBox.KEY_SIZE
DEFAULT_LOCATION_MESSAGE).encode("utf-8"))).decode("utf-8") key = secret.encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b'\0')
msg = json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")
ctxt = SecretBox(key).encrypt(msg,
encoder=Base64Encoder).decode("utf-8")
except (ImportError, OSError): except (ImportError, OSError):
ctxt = '' ctxt = ''
@ -1341,7 +1348,8 @@ def mock_cipher():
def mock_decrypt(ciphertext, key): def mock_decrypt(ciphertext, key):
"""Decrypt/unpickle.""" """Decrypt/unpickle."""
import pickle import pickle
(mkey, plaintext) = pickle.loads(ciphertext) import base64
(mkey, plaintext) = pickle.loads(base64.b64decode(ciphertext))
if key != mkey: if key != mkey:
raise ValueError() raise ValueError()
return plaintext return plaintext
@ -1443,9 +1451,9 @@ async def test_encrypted_payload_libsodium(hass, setup_comp):
"""Test sending encrypted message payload.""" """Test sending encrypted message payload."""
try: try:
# pylint: disable=unused-import # pylint: disable=unused-import
import libnacl # noqa: F401 import nacl # noqa: F401
except (ImportError, OSError): except (ImportError, OSError):
pytest.skip("libnacl/libsodium is not installed") pytest.skip("PyNaCl/libsodium is not installed")
return return
await setup_owntracks(hass, { await setup_owntracks(hass, {

View File

@ -0,0 +1 @@
"""Tests for mobile_app component."""

View File

@ -0,0 +1,275 @@
"""Test the mobile_app_http platform."""
import pytest
from homeassistant.setup import async_setup_component
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.components.mobile_app import (DOMAIN, STORAGE_KEY,
STORAGE_VERSION,
CONF_SECRET, CONF_USER_ID)
from homeassistant.core import callback
from tests.common import async_mock_service
FIRE_EVENT = {
'type': 'fire_event',
'data': {
'event_type': 'test_event',
'event_data': {
'hello': 'yo world'
}
}
}
RENDER_TEMPLATE = {
'type': 'render_template',
'data': {
'template': 'Hello world'
}
}
CALL_SERVICE = {
'type': 'call_service',
'data': {
'domain': 'test',
'service': 'mobile_app',
'service_data': {
'foo': 'bar'
}
}
}
REGISTER = {
'app_data': {'foo': 'bar'},
'app_id': 'io.homeassistant.mobile_app_test',
'app_name': 'Mobile App Tests',
'app_version': '1.0.0',
'device_name': 'Test 1',
'manufacturer': 'mobile_app',
'model': 'Test',
'os_version': '1.0',
'supports_encryption': True
}
UPDATE = {
'app_data': {'foo': 'bar'},
'app_version': '2.0.0',
'device_name': 'Test 1',
'manufacturer': 'mobile_app',
'model': 'Test',
'os_version': '1.0'
}
# pylint: disable=redefined-outer-name
@pytest.fixture
def mobile_app_client(hass, aiohttp_client, hass_storage, hass_admin_user):
"""mobile_app mock client."""
hass_storage[STORAGE_KEY] = {
'version': STORAGE_VERSION,
'data': {
'mobile_app_test': {
CONF_SECRET: '58eb127991594dad934d1584bdee5f27',
'supports_encryption': True,
CONF_WEBHOOK_ID: 'mobile_app_test',
'device_name': 'Test Device',
CONF_USER_ID: hass_admin_user.id,
}
}
}
assert hass.loop.run_until_complete(async_setup_component(
hass, DOMAIN, {
DOMAIN: {}
}))
return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
@pytest.fixture
async def mock_api_client(hass, hass_client):
"""Provide an authenticated client for mobile_app to use."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
return await hass_client()
async def test_handle_render_template(mobile_app_client):
"""Test that we render templates properly."""
resp = await mobile_app_client.post(
'/api/webhook/mobile_app_test',
json=RENDER_TEMPLATE
)
assert resp.status == 200
json = await resp.json()
assert json == {'rendered': 'Hello world'}
async def test_handle_call_services(hass, mobile_app_client):
"""Test that we call services properly."""
calls = async_mock_service(hass, 'test', 'mobile_app')
resp = await mobile_app_client.post(
'/api/webhook/mobile_app_test',
json=CALL_SERVICE
)
assert resp.status == 200
assert len(calls) == 1
async def test_handle_fire_event(hass, mobile_app_client):
"""Test that we can fire events."""
events = []
@callback
def store_event(event):
"""Helepr to store events."""
events.append(event)
hass.bus.async_listen('test_event', store_event)
resp = await mobile_app_client.post(
'/api/webhook/mobile_app_test',
json=FIRE_EVENT
)
assert resp.status == 200
text = await resp.text()
assert text == ""
assert len(events) == 1
assert events[0].data['hello'] == 'yo world'
async def test_update_registration(mobile_app_client, hass_client):
"""Test that a we can update an existing registration via webhook."""
mock_api_client = await hass_client()
register_resp = await mock_api_client.post(
'/api/mobile_app/devices', json=REGISTER
)
assert register_resp.status == 201
register_json = await register_resp.json()
webhook_id = register_json[CONF_WEBHOOK_ID]
update_container = {
'type': 'update_registration',
'data': UPDATE
}
update_resp = await mobile_app_client.post(
'/api/webhook/{}'.format(webhook_id), json=update_container
)
assert update_resp.status == 200
update_json = await update_resp.json()
assert update_json['app_version'] == '2.0.0'
assert CONF_WEBHOOK_ID not in update_json
assert CONF_SECRET not in update_json
async def test_returns_error_incorrect_json(mobile_app_client, caplog):
"""Test that an error is returned when JSON is invalid."""
resp = await mobile_app_client.post(
'/api/webhook/mobile_app_test',
data='not json'
)
assert resp.status == 400
json = await resp.json()
assert json == []
assert 'invalid JSON' in caplog.text
async def test_handle_decryption(mobile_app_client):
"""Test that we can encrypt/decrypt properly."""
try:
# pylint: disable=unused-import
from nacl.secret import SecretBox # noqa: F401
from nacl.encoding import Base64Encoder # noqa: F401
except (ImportError, OSError):
pytest.skip("libnacl/libsodium is not installed")
return
import json
keylen = SecretBox.KEY_SIZE
key = "58eb127991594dad934d1584bdee5f27".encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b'\0')
payload = json.dumps({'template': 'Hello world'}).encode("utf-8")
data = SecretBox(key).encrypt(payload,
encoder=Base64Encoder).decode("utf-8")
container = {
'type': 'render_template',
'encrypted': True,
'encrypted_data': data,
}
resp = await mobile_app_client.post(
'/api/webhook/mobile_app_test',
json=container
)
assert resp.status == 200
json = await resp.json()
assert json == {'rendered': 'Hello world'}
async def test_register_device(hass_client, mock_api_client):
"""Test that a device can be registered."""
try:
# pylint: disable=unused-import
from nacl.secret import SecretBox # noqa: F401
from nacl.encoding import Base64Encoder # noqa: F401
except (ImportError, OSError):
pytest.skip("libnacl/libsodium is not installed")
return
import json
resp = await mock_api_client.post(
'/api/mobile_app/devices', json=REGISTER
)
assert resp.status == 201
register_json = await resp.json()
assert CONF_WEBHOOK_ID in register_json
assert CONF_SECRET in register_json
keylen = SecretBox.KEY_SIZE
key = register_json[CONF_SECRET].encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b'\0')
payload = json.dumps({'template': 'Hello world'}).encode("utf-8")
data = SecretBox(key).encrypt(payload,
encoder=Base64Encoder).decode("utf-8")
container = {
'type': 'render_template',
'encrypted': True,
'encrypted_data': data,
}
mobile_app_client = await hass_client()
resp = await mobile_app_client.post(
'/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]),
json=container
)
assert resp.status == 200
webhook_json = await resp.json()
assert webhook_json == {'rendered': 'Hello world'}