HTML5 notify actions (#2855)
* Add action and callback support to html5 (#2855). Remove registrations from the callback view since we always get the latest anyway. We dont put an audience in the claims so we will never hit this error. Bring tests back up to where they were before callbacks. Only import jwt where necessary Fix bracket spacing errors Fix JWT decode check for loop Remove stale comment. Add tests for the callback system. Shorten line Disable pylint broad-except and change e to jwt_decode_error. Verify expiration Remove duplicate jwt.exceptions.DecodeError Catch no keys matched and return False * Switch to using registrations for callbackview instead of json_path * Only check for URL and such if the data object actually exists * raise instead of return * cleanup decode_jwt * Clean up JWT errors * Correctly set status_code to 401 * Improve JWT by adding target to claims and attempting to check the given target for a decode match first, as well as pass the target through in the event payload. * Add tag support and fix formatting issues * Pass through any keys that dont apply to the payload into the notification.data dictionary * Remove stale print * Pass back the data dictionary if it exists * Actually put the default url even if a notify payload dictionary doesnt exist * pylint, flake8 * Add subscription validation * Add validation for the callback event payload and use constants where possible * Use HTTP_UNAUTHORIZED instead of 401 * Change callback dictionary to dict instead of cv.match_all * Fix up tests and make subscription required * Whoops, that test was supposed to fail * Use the result of CALLBACK_EVENT_PAYLOAD_SCHEMA as event_payload * Add a test for html5 callback decode_jwt where the device has been renamed since notification has been sent. * Remove the loop through logic, assume that target is always in JWT * Always return something instead of possibly None. * Update frontend
This commit is contained in:
parent
49998272db
commit
a5f144cb7c
11 changed files with 306 additions and 44 deletions
|
@ -1,8 +1,8 @@
|
||||||
"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend."""
|
"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend."""
|
||||||
|
|
||||||
FINGERPRINTS = {
|
FINGERPRINTS = {
|
||||||
"core.js": "b4ee3a700ef5549a36b436611e27d3a9",
|
"core.js": "7a72f4dcf2a1d5e04094ef807478614d",
|
||||||
"frontend.html": "411fcc6c69b3cab0740ac3db4b9947c8",
|
"frontend.html": "77a461848fe2a94da4e26a103bd6f814",
|
||||||
"mdi.html": "b399b5d3798f5b68b0a4fbaae3432d48",
|
"mdi.html": "b399b5d3798f5b68b0a4fbaae3432d48",
|
||||||
"panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b",
|
"panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b",
|
||||||
"panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169",
|
"panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169",
|
||||||
|
|
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
@ -1 +1 @@
|
||||||
Subproject commit dd6ee9544e7129b1735fe2377311ff2e2b7449c0
|
Subproject commit 2c61fb2b72f422155dac4e7308181f7f7f845f44
|
File diff suppressed because one or more lines are too long
Binary file not shown.
|
@ -8,12 +8,14 @@ import os
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from voluptuous.humanize import humanize_error
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR,
|
||||||
HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR)
|
HTTP_UNAUTHORIZED, URL_ROOT)
|
||||||
from homeassistant.util import ensure_unique_string
|
from homeassistant.util import ensure_unique_string
|
||||||
from homeassistant.components.notify import (
|
from homeassistant.components.notify import (
|
||||||
ATTR_TARGET, ATTR_TITLE, ATTR_DATA, BaseNotificationService,
|
ATTR_TARGET, ATTR_TITLE, ATTR_DATA, BaseNotificationService,
|
||||||
|
@ -24,13 +26,13 @@ from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
REQUIREMENTS = ['https://github.com/web-push-libs/pywebpush/archive/'
|
REQUIREMENTS = ['https://github.com/web-push-libs/pywebpush/archive/'
|
||||||
'e743dc92558fc62178d255c0018920d74fa778ed.zip#'
|
'e743dc92558fc62178d255c0018920d74fa778ed.zip#'
|
||||||
'pywebpush==0.5.0']
|
'pywebpush==0.5.0', 'PyJWT==1.4.2']
|
||||||
|
|
||||||
DEPENDENCIES = ["frontend"]
|
DEPENDENCIES = ['frontend']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
REGISTRATIONS_FILE = "html5_push_registrations.conf"
|
REGISTRATIONS_FILE = 'html5_push_registrations.conf'
|
||||||
|
|
||||||
ATTR_GCM_SENDER_ID = 'gcm_sender_id'
|
ATTR_GCM_SENDER_ID = 'gcm_sender_id'
|
||||||
ATTR_GCM_API_KEY = 'gcm_api_key'
|
ATTR_GCM_API_KEY = 'gcm_api_key'
|
||||||
|
@ -43,11 +45,58 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
ATTR_SUBSCRIPTION = 'subscription'
|
ATTR_SUBSCRIPTION = 'subscription'
|
||||||
ATTR_BROWSER = 'browser'
|
ATTR_BROWSER = 'browser'
|
||||||
|
|
||||||
|
ATTR_ENDPOINT = 'endpoint'
|
||||||
|
ATTR_KEYS = 'keys'
|
||||||
|
ATTR_AUTH = 'auth'
|
||||||
|
ATTR_P256DH = 'p256dh'
|
||||||
|
|
||||||
|
ATTR_TAG = 'tag'
|
||||||
|
ATTR_ACTION = 'action'
|
||||||
|
ATTR_ACTIONS = 'actions'
|
||||||
|
ATTR_TYPE = 'type'
|
||||||
|
ATTR_URL = 'url'
|
||||||
|
|
||||||
|
ATTR_JWT = 'jwt'
|
||||||
|
|
||||||
|
# The number of days after the moment a notification is sent that a JWT
|
||||||
|
# is valid.
|
||||||
|
JWT_VALID_DAYS = 7
|
||||||
|
|
||||||
|
KEYS_SCHEMA = vol.All(dict,
|
||||||
|
vol.Schema({
|
||||||
|
vol.Required(ATTR_AUTH): cv.string,
|
||||||
|
vol.Required(ATTR_P256DH): cv.string
|
||||||
|
}))
|
||||||
|
|
||||||
|
SUBSCRIPTION_SCHEMA = vol.All(dict,
|
||||||
|
vol.Schema({
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
vol.Required(ATTR_ENDPOINT): vol.Url(),
|
||||||
|
vol.Required(ATTR_KEYS): KEYS_SCHEMA
|
||||||
|
}))
|
||||||
|
|
||||||
REGISTER_SCHEMA = vol.Schema({
|
REGISTER_SCHEMA = vol.Schema({
|
||||||
vol.Required(ATTR_SUBSCRIPTION): cv.match_all,
|
vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA,
|
||||||
vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox'])
|
vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({
|
||||||
|
vol.Required(ATTR_TAG): cv.string,
|
||||||
|
vol.Required(ATTR_TYPE): vol.In(['received', 'clicked', 'closed']),
|
||||||
|
vol.Required(ATTR_TARGET): cv.string,
|
||||||
|
vol.Optional(ATTR_ACTION): cv.string,
|
||||||
|
vol.Optional(ATTR_DATA): dict,
|
||||||
|
})
|
||||||
|
|
||||||
|
NOTIFY_CALLBACK_EVENT = 'html5_notification'
|
||||||
|
|
||||||
|
# badge and timestamp are Chrome specific (not in official spec)
|
||||||
|
|
||||||
|
HTML5_SHOWNOTIFICATION_PARAMETERS = ('actions', 'badge', 'body', 'dir',
|
||||||
|
'icon', 'lang', 'renotify',
|
||||||
|
'requireInteraction', 'tag', 'timestamp',
|
||||||
|
'vibrate')
|
||||||
|
|
||||||
|
|
||||||
def get_service(hass, config):
|
def get_service(hass, config):
|
||||||
"""Get the HTML5 push notification service."""
|
"""Get the HTML5 push notification service."""
|
||||||
|
@ -60,12 +109,14 @@ def get_service(hass, config):
|
||||||
|
|
||||||
hass.wsgi.register_view(
|
hass.wsgi.register_view(
|
||||||
HTML5PushRegistrationView(hass, registrations, json_path))
|
HTML5PushRegistrationView(hass, registrations, json_path))
|
||||||
|
hass.wsgi.register_view(HTML5PushCallbackView(hass, registrations))
|
||||||
|
|
||||||
gcm_api_key = config.get('gcm_api_key')
|
gcm_api_key = config.get(ATTR_GCM_API_KEY)
|
||||||
gcm_sender_id = config.get('gcm_sender_id')
|
gcm_sender_id = config.get(ATTR_GCM_SENDER_ID)
|
||||||
|
|
||||||
if gcm_sender_id is not None:
|
if gcm_sender_id is not None:
|
||||||
add_manifest_json_key('gcm_sender_id', config.get('gcm_sender_id'))
|
add_manifest_json_key(ATTR_GCM_SENDER_ID,
|
||||||
|
config.get(ATTR_GCM_SENDER_ID))
|
||||||
|
|
||||||
return HTML5NotificationService(gcm_api_key, registrations)
|
return HTML5NotificationService(gcm_api_key, registrations)
|
||||||
|
|
||||||
|
@ -76,7 +127,7 @@ def _load_config(filename):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filename, "r") as fdesc:
|
with open(filename, 'r') as fdesc:
|
||||||
inp = fdesc.read()
|
inp = fdesc.read()
|
||||||
|
|
||||||
# In case empty file
|
# In case empty file
|
||||||
|
@ -85,17 +136,17 @@ def _load_config(filename):
|
||||||
|
|
||||||
return json.loads(inp)
|
return json.loads(inp)
|
||||||
except (IOError, ValueError) as error:
|
except (IOError, ValueError) as error:
|
||||||
_LOGGER.error("Reading config file %s failed: %s", filename, error)
|
_LOGGER.error('Reading config file %s failed: %s', filename, error)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _save_config(filename, config):
|
def _save_config(filename, config):
|
||||||
"""Save configuration."""
|
"""Save configuration."""
|
||||||
try:
|
try:
|
||||||
with open(filename, "w") as fdesc:
|
with open(filename, 'w') as fdesc:
|
||||||
fdesc.write(json.dumps(config, indent=4, sort_keys=True))
|
fdesc.write(json.dumps(config, indent=4, sort_keys=True))
|
||||||
except (IOError, TypeError) as error:
|
except (IOError, TypeError) as error:
|
||||||
_LOGGER.error("Saving config file failed: %s", error)
|
_LOGGER.error('Saving config file failed: %s', error)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -103,8 +154,8 @@ def _save_config(filename, config):
|
||||||
class HTML5PushRegistrationView(HomeAssistantView):
|
class HTML5PushRegistrationView(HomeAssistantView):
|
||||||
"""Accepts push registrations from a browser."""
|
"""Accepts push registrations from a browser."""
|
||||||
|
|
||||||
url = "/api/notify.html5"
|
url = '/api/notify.html5'
|
||||||
name = "api:notify.html5"
|
name = 'api:notify.html5'
|
||||||
|
|
||||||
def __init__(self, hass, registrations, json_path):
|
def __init__(self, hass, registrations, json_path):
|
||||||
"""Init HTML5PushRegistrationView."""
|
"""Init HTML5PushRegistrationView."""
|
||||||
|
@ -129,7 +180,101 @@ class HTML5PushRegistrationView(HomeAssistantView):
|
||||||
return self.json_message('Error saving registration.',
|
return self.json_message('Error saving registration.',
|
||||||
HTTP_INTERNAL_SERVER_ERROR)
|
HTTP_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
return self.json_message("Push notification subscriber registered.")
|
return self.json_message('Push notification subscriber registered.')
|
||||||
|
|
||||||
|
|
||||||
|
class HTML5PushCallbackView(HomeAssistantView):
|
||||||
|
"""Accepts push registrations from a browser."""
|
||||||
|
|
||||||
|
requires_auth = False
|
||||||
|
url = '/api/notify.html5/callback'
|
||||||
|
name = 'api:notify.html5/callback'
|
||||||
|
|
||||||
|
def __init__(self, hass, registrations):
|
||||||
|
"""Init HTML5PushCallbackView."""
|
||||||
|
super().__init__(hass)
|
||||||
|
self.registrations = registrations
|
||||||
|
|
||||||
|
def decode_jwt(self, token):
|
||||||
|
"""Find the registration that signed this JWT and return it."""
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
# 1. Check claims w/o verifying to see if a target is in there.
|
||||||
|
# 2. If target in claims, attempt to verify against the given name.
|
||||||
|
# 2a. If decode is successful, return the payload.
|
||||||
|
# 2b. If decode is unsuccessful, return a 401.
|
||||||
|
|
||||||
|
target_check = jwt.decode(token, verify=False)
|
||||||
|
if target_check[ATTR_TARGET] in self.registrations:
|
||||||
|
possible_target = self.registrations[target_check[ATTR_TARGET]]
|
||||||
|
key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, key)
|
||||||
|
except jwt.exceptions.DecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.json_message('No target found in JWT',
|
||||||
|
status_code=HTTP_UNAUTHORIZED)
|
||||||
|
|
||||||
|
# The following is based on code from Auth0
|
||||||
|
# https://auth0.com/docs/quickstart/backend/python
|
||||||
|
# pylint: disable=too-many-return-statements
|
||||||
|
def check_authorization_header(self, request):
|
||||||
|
"""Check the authorization header."""
|
||||||
|
import jwt
|
||||||
|
auth = request.headers.get('Authorization', None)
|
||||||
|
if not auth:
|
||||||
|
return self.json_message('Authorization header is expected',
|
||||||
|
status_code=HTTP_UNAUTHORIZED)
|
||||||
|
|
||||||
|
parts = auth.split()
|
||||||
|
|
||||||
|
if parts[0].lower() != 'bearer':
|
||||||
|
return self.json_message('Authorization header must '
|
||||||
|
'start with Bearer',
|
||||||
|
status_code=HTTP_UNAUTHORIZED)
|
||||||
|
elif len(parts) != 2:
|
||||||
|
return self.json_message('Authorization header must '
|
||||||
|
'be Bearer token',
|
||||||
|
status_code=HTTP_UNAUTHORIZED)
|
||||||
|
|
||||||
|
token = parts[1]
|
||||||
|
try:
|
||||||
|
payload = self.decode_jwt(token)
|
||||||
|
except jwt.exceptions.InvalidTokenError:
|
||||||
|
return self.json_message('token is invalid',
|
||||||
|
status_code=HTTP_UNAUTHORIZED)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""Accept the POST request for push registrations event callback."""
|
||||||
|
auth_check = self.check_authorization_header(request)
|
||||||
|
if not isinstance(auth_check, dict):
|
||||||
|
return auth_check
|
||||||
|
|
||||||
|
event_payload = {
|
||||||
|
ATTR_TAG: request.json.get(ATTR_TAG),
|
||||||
|
ATTR_TYPE: request.json[ATTR_TYPE],
|
||||||
|
ATTR_TARGET: auth_check[ATTR_TARGET],
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.json.get(ATTR_ACTION) is not None:
|
||||||
|
event_payload[ATTR_ACTION] = request.json.get(ATTR_ACTION)
|
||||||
|
|
||||||
|
if request.json.get(ATTR_DATA) is not None:
|
||||||
|
event_payload[ATTR_DATA] = request.json.get(ATTR_DATA)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload)
|
||||||
|
except vol.Invalid as ex:
|
||||||
|
_LOGGER.warning('Callback event payload is not valid! %s',
|
||||||
|
humanize_error(event_payload, ex))
|
||||||
|
|
||||||
|
event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT,
|
||||||
|
event_payload[ATTR_TYPE])
|
||||||
|
self.hass.bus.fire(event_name, event_payload)
|
||||||
|
return self.json({'status': 'ok',
|
||||||
|
'event': event_payload[ATTR_TYPE]})
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-few-public-methods
|
# pylint: disable=too-few-public-methods
|
||||||
|
@ -147,31 +292,41 @@ class HTML5NotificationService(BaseNotificationService):
|
||||||
"""Return a dictionary of registered targets."""
|
"""Return a dictionary of registered targets."""
|
||||||
return self.registrations.keys()
|
return self.registrations.keys()
|
||||||
|
|
||||||
|
# pylint: disable=too-many-locals
|
||||||
def send_message(self, message="", **kwargs):
|
def send_message(self, message="", **kwargs):
|
||||||
"""Send a message to a user."""
|
"""Send a message to a user."""
|
||||||
|
import jwt
|
||||||
from pywebpush import WebPusher
|
from pywebpush import WebPusher
|
||||||
|
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
|
tag = str(uuid.uuid4())
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'body': message,
|
|
||||||
'data': {},
|
|
||||||
'icon': '/static/icons/favicon-192x192.png',
|
|
||||||
'badge': '/static/images/notification-badge.png',
|
'badge': '/static/images/notification-badge.png',
|
||||||
|
'body': message,
|
||||||
|
ATTR_DATA: {},
|
||||||
|
'icon': '/static/icons/favicon-192x192.png',
|
||||||
|
ATTR_TAG: tag,
|
||||||
'timestamp': (timestamp*1000), # Javascript ms since epoch
|
'timestamp': (timestamp*1000), # Javascript ms since epoch
|
||||||
'title': kwargs.get(ATTR_TITLE)
|
ATTR_TITLE: kwargs.get(ATTR_TITLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
data = kwargs.get(ATTR_DATA)
|
data = kwargs.get(ATTR_DATA)
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
payload.update(data)
|
# Pick out fields that should go into the notification directly vs
|
||||||
|
# into the notification data dictionary.
|
||||||
|
|
||||||
if data.get('url') is not None:
|
for key, val in data.copy().items():
|
||||||
payload['data']['url'] = data.get('url')
|
if key in HTML5_SHOWNOTIFICATION_PARAMETERS:
|
||||||
elif (payload['data'].get('url') is None and
|
payload[key] = val
|
||||||
payload.get('actions') is None):
|
del data[key]
|
||||||
payload['data']['url'] = '/'
|
|
||||||
|
payload[ATTR_DATA] = data
|
||||||
|
|
||||||
|
if (payload[ATTR_DATA].get(ATTR_URL) is None and
|
||||||
|
payload.get(ATTR_ACTIONS) is None):
|
||||||
|
payload[ATTR_DATA][ATTR_URL] = URL_ROOT
|
||||||
|
|
||||||
targets = kwargs.get(ATTR_TARGET)
|
targets = kwargs.get(ATTR_TARGET)
|
||||||
|
|
||||||
|
@ -183,9 +338,18 @@ class HTML5NotificationService(BaseNotificationService):
|
||||||
for target in targets:
|
for target in targets:
|
||||||
info = self.registrations.get(target)
|
info = self.registrations.get(target)
|
||||||
if info is None:
|
if info is None:
|
||||||
_LOGGER.error("%s is not a valid HTML5 push notification"
|
_LOGGER.error('%s is not a valid HTML5 push notification'
|
||||||
" target!", target)
|
' target!', target)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
jwt_exp = (datetime.datetime.fromtimestamp(timestamp) +
|
||||||
|
datetime.timedelta(days=JWT_VALID_DAYS))
|
||||||
|
jwt_secret = info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]
|
||||||
|
jwt_claims = {'exp': jwt_exp, 'nbf': timestamp,
|
||||||
|
'iat': timestamp, ATTR_TARGET: target,
|
||||||
|
ATTR_TAG: payload[ATTR_TAG]}
|
||||||
|
jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8')
|
||||||
|
payload[ATTR_DATA][ATTR_JWT] = jwt_token
|
||||||
|
|
||||||
WebPusher(info[ATTR_SUBSCRIPTION]).send(
|
WebPusher(info[ATTR_SUBSCRIPTION]).send(
|
||||||
json.dumps(payload), gcm_key=self._gcm_key, ttl='86400')
|
json.dumps(payload), gcm_key=self._gcm_key, ttl='86400')
|
||||||
|
|
|
@ -10,6 +10,9 @@ typing>=3,<4
|
||||||
# homeassistant.components.isy994
|
# homeassistant.components.isy994
|
||||||
PyISY==1.0.6
|
PyISY==1.0.6
|
||||||
|
|
||||||
|
# homeassistant.components.notify.html5
|
||||||
|
PyJWT==1.4.2
|
||||||
|
|
||||||
# homeassistant.components.arduino
|
# homeassistant.components.arduino
|
||||||
PyMata==2.12
|
PyMata==2.12
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,10 @@ class TestHtml5Notify(object):
|
||||||
data = {
|
data = {
|
||||||
'device': {
|
'device': {
|
||||||
'browser': 'chrome',
|
'browser': 'chrome',
|
||||||
'subscription': 'hello world',
|
'subscription': {
|
||||||
|
'endpoint': 'https://google.com',
|
||||||
|
'keys': {'auth': 'auth', 'p256dh': 'p256dh'}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +63,10 @@ class TestHtml5Notify(object):
|
||||||
assert len(mock_wp.mock_calls) == 2
|
assert len(mock_wp.mock_calls) == 2
|
||||||
|
|
||||||
# WebPusher constructor
|
# WebPusher constructor
|
||||||
assert mock_wp.mock_calls[0][1][0] == 'hello world'
|
assert mock_wp.mock_calls[0][1][0] == {'endpoint':
|
||||||
|
'https://google.com',
|
||||||
|
'keys': {'auth': 'auth',
|
||||||
|
'p256dh': 'p256dh'}}
|
||||||
|
|
||||||
# Call to send
|
# Call to send
|
||||||
payload = json.loads(mock_wp.mock_calls[1][1][0])
|
payload = json.loads(mock_wp.mock_calls[1][1][0])
|
||||||
|
@ -80,7 +86,7 @@ class TestHtml5Notify(object):
|
||||||
assert service is not None
|
assert service is not None
|
||||||
|
|
||||||
# assert hass.called
|
# assert hass.called
|
||||||
assert len(hass.mock_calls) == 2
|
assert len(hass.mock_calls) == 3
|
||||||
|
|
||||||
view = hass.mock_calls[1][1][0]
|
view = hass.mock_calls[1][1][0]
|
||||||
assert view.json_path == fp.name
|
assert view.json_path == fp.name
|
||||||
|
@ -88,7 +94,9 @@ class TestHtml5Notify(object):
|
||||||
|
|
||||||
builder = EnvironBuilder(method='POST', data=json.dumps({
|
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||||
'browser': 'chrome',
|
'browser': 'chrome',
|
||||||
'subscription': 'sub info',
|
'subscription': {'endpoint': 'https://google.com',
|
||||||
|
'keys': {'auth': 'auth',
|
||||||
|
'p256dh': 'p256dh'}},
|
||||||
}))
|
}))
|
||||||
Request = request_class()
|
Request = request_class()
|
||||||
resp = view.post(Request(builder.get_environ()))
|
resp = view.post(Request(builder.get_environ()))
|
||||||
|
@ -96,7 +104,9 @@ class TestHtml5Notify(object):
|
||||||
expected = {
|
expected = {
|
||||||
'unnamed device': {
|
'unnamed device': {
|
||||||
'browser': 'chrome',
|
'browser': 'chrome',
|
||||||
'subscription': 'sub info',
|
'subscription': {'endpoint': 'https://google.com',
|
||||||
|
'keys': {'auth': 'auth',
|
||||||
|
'p256dh': 'p256dh'}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +126,7 @@ class TestHtml5Notify(object):
|
||||||
assert service is not None
|
assert service is not None
|
||||||
|
|
||||||
# assert hass.called
|
# assert hass.called
|
||||||
assert len(hass.mock_calls) == 2
|
assert len(hass.mock_calls) == 3
|
||||||
|
|
||||||
view = hass.mock_calls[1][1][0]
|
view = hass.mock_calls[1][1][0]
|
||||||
|
|
||||||
|
@ -142,4 +152,89 @@ class TestHtml5Notify(object):
|
||||||
with patch('homeassistant.components.notify.html5._save_config',
|
with patch('homeassistant.components.notify.html5._save_config',
|
||||||
return_value=False):
|
return_value=False):
|
||||||
resp = view.post(Request(builder.get_environ()))
|
resp = view.post(Request(builder.get_environ()))
|
||||||
assert resp.status_code == 500, resp.response
|
assert resp.status_code == 400, resp.response
|
||||||
|
|
||||||
|
def test_callback_view_no_jwt(self):
|
||||||
|
"""Test that the notification callback view works without JWT."""
|
||||||
|
hass = MagicMock()
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as fp:
|
||||||
|
hass.config.path.return_value = fp.name
|
||||||
|
fp.close()
|
||||||
|
service = html5.get_service(hass, {})
|
||||||
|
|
||||||
|
assert service is not None
|
||||||
|
|
||||||
|
# assert hass.called
|
||||||
|
assert len(hass.mock_calls) == 3
|
||||||
|
|
||||||
|
view = hass.mock_calls[2][1][0]
|
||||||
|
|
||||||
|
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||||
|
'type': 'push',
|
||||||
|
'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72'
|
||||||
|
}))
|
||||||
|
Request = request_class()
|
||||||
|
resp = view.post(Request(builder.get_environ()))
|
||||||
|
|
||||||
|
assert resp.status_code == 401, resp.response
|
||||||
|
|
||||||
|
@patch('pywebpush.WebPusher')
|
||||||
|
def test_callback_view_with_jwt(self, mock_wp):
|
||||||
|
"""Test that the notification callback view works with JWT."""
|
||||||
|
hass = MagicMock()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'device': {
|
||||||
|
'browser': 'chrome',
|
||||||
|
'subscription': {
|
||||||
|
'endpoint': 'https://google.com',
|
||||||
|
'keys': {'auth': 'auth', 'p256dh': 'p256dh'}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as fp:
|
||||||
|
fp.write(json.dumps(data).encode('utf-8'))
|
||||||
|
fp.flush()
|
||||||
|
hass.config.path.return_value = fp.name
|
||||||
|
service = html5.get_service(hass, {'gcm_sender_id': '100'})
|
||||||
|
|
||||||
|
assert service is not None
|
||||||
|
|
||||||
|
# assert hass.called
|
||||||
|
assert len(hass.mock_calls) == 3
|
||||||
|
|
||||||
|
service.send_message('Hello', target=['device'],
|
||||||
|
data={'icon': 'beer.png'})
|
||||||
|
|
||||||
|
assert len(mock_wp.mock_calls) == 2
|
||||||
|
|
||||||
|
# WebPusher constructor
|
||||||
|
assert mock_wp.mock_calls[0][1][0] == {'endpoint':
|
||||||
|
'https://google.com',
|
||||||
|
'keys': {'auth': 'auth',
|
||||||
|
'p256dh':
|
||||||
|
'p256dh'}}
|
||||||
|
|
||||||
|
# Call to send
|
||||||
|
push_payload = json.loads(mock_wp.mock_calls[1][1][0])
|
||||||
|
|
||||||
|
assert push_payload['body'] == 'Hello'
|
||||||
|
assert push_payload['icon'] == 'beer.png'
|
||||||
|
|
||||||
|
view = hass.mock_calls[2][1][0]
|
||||||
|
view.registrations = data
|
||||||
|
|
||||||
|
bearer_token = "Bearer {}".format(push_payload['data']['jwt'])
|
||||||
|
|
||||||
|
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||||
|
'type': 'push',
|
||||||
|
}), headers={'Authorization': bearer_token})
|
||||||
|
Request = request_class()
|
||||||
|
resp = view.post(Request(builder.get_environ()))
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.response
|
||||||
|
returned = resp.response[0].decode('utf-8')
|
||||||
|
expected = '{"event": "push", "status": "ok"}'
|
||||||
|
assert json.loads(returned) == json.loads(expected)
|
||||||
|
|
Loading…
Add table
Reference in a new issue