From ca7465299db577bbf4d593a6d2b5776ae9281796 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Tue, 4 Jan 2022 11:30:16 -0500 Subject: [PATCH] Custom form:// application/x-www-form-urlencoded Support (#512) --- README.md | 1 + apprise/plugins/NotifyForm.py | 391 ++++++++++++++++++++++++++++++++ apprise/plugins/NotifyJSON.py | 73 +++++- apprise/plugins/NotifyXML.py | 73 +++++- test/helpers/rest.py | 28 ++- test/test_plugin_custom_form.py | 226 ++++++++++++++++++ test/test_plugin_custom_json.py | 22 ++ test/test_plugin_custom_xml.py | 40 +++- 8 files changed, 832 insertions(+), 22 deletions(-) create mode 100644 apprise/plugins/NotifyForm.py create mode 100644 test/test_plugin_custom_form.py diff --git a/README.md b/README.md index 5cbda9a2..c4e4fcfb 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Apprise have some email services built right into it (such as yahoo, fastmail, h ### Custom Notifications | Post Method | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | +| [Form](https://github.com/caronc/apprise/wiki/Notify_Custom_Form) | form:// or form:// | (TCP) 80 or 443 | form://hostname
form://user@hostname
form://user:password@hostname:port
form://hostname/a/path/to/post/to | [JSON](https://github.com/caronc/apprise/wiki/Notify_Custom_JSON) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to | [XML](https://github.com/caronc/apprise/wiki/Notify_Custom_XML) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to diff --git a/apprise/plugins/NotifyForm.py b/apprise/plugins/NotifyForm.py new file mode 100644 index 00000000..43463016 --- /dev/null +++ b/apprise/plugins/NotifyForm.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import six +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ + + +# Defines the method to send the notification +METHODS = ( + 'POST', + 'GET', + 'DELETE', + 'PUT', + 'HEAD' +) + + +class NotifyForm(NotifyBase): + """ + A wrapper for Form Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Form' + + # The default protocol + protocol = 'form' + + # The default secure protocol + secure_protocol = 'forms' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_Form' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # Disable throttle rate for Form requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{user}@{host}', + '{schema}://{user}@{host}:{port}', + '{schema}://{user}:{password}@{host}', + '{schema}://{user}:{password}@{host}:{port}', + ) + + # Define our tokens; these are the minimum tokens required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'method': { + 'name': _('Fetch Method'), + 'type': 'choice:string', + 'values': METHODS, + 'default': METHODS[0], + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + 'payload': { + 'name': _('Payload Extras'), + 'prefix': ':', + }, + } + + def __init__(self, headers=None, method=None, payload=None, **kwargs): + """ + Initialize Form Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super(NotifyForm, self).__init__(**kwargs) + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, six.string_types): + self.fullpath = '/' + + self.method = self.template_args['method']['default'] \ + if not isinstance(method, six.string_types) else method.upper() + + if self.method not in METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.payload_extras = {} + if payload: + # Store our extra payload entries + self.payload_extras.update(payload) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our payload extra's into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyForm.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyForm.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyForm.quote(self.fullpath, safe='/'), + params=NotifyForm.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Form Notification + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + # Track our potential attachments + files = [] + if attach: + for no, attachment in enumerate(attach, start=1): + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + try: + files.append(( + 'file{:02d}'.format(no), ( + attachment.name, + open(attachment.path, 'rb'), + attachment.mimetype) + )) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while opening {}.'.format( + attachment.name if attachment else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + for file in files: + # Ensure all files are closed + if file[1][1]: + file[1][1].close() + + # prepare Form Object + payload = { + # Version: Major.Minor, Major is only updated if the entire + # schema is changed. If just adding new items (or removing + # old ones, only increment the Minor! + 'version': '1.0', + 'title': title, + 'message': body, + 'type': notify_type, + } + + # Apply any/all payload over-rides defined + payload.update(self.payload_extras) + + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('Form POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Form Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + if self.method == 'GET': + method = requests.get + + elif self.method == 'PUT': + method = requests.put + + elif self.method == 'DELETE': + method = requests.delete + + elif self.method == 'HEAD': + method = requests.head + + else: # POST + method = requests.post + + try: + r = method( + url, + files=None if not files else files, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyForm.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Form %s notification: %s%serror=%s.', + self.method, + status_str, + ', ' if status_str else '', + str(r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Form %s notification.', self.method) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Form ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading one of the ' + 'attached files.') + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + for file in files: + # Ensure all files are closed + file[1][1].close() + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + # store any additional payload extra's defined + results['payload'] = {NotifyForm.unquote(x): NotifyForm.unquote(y) + for x, y in results['qsd:'].items()} + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based Form header tokens are being " + " removed; use the plus (+) symbol instead.") + + # Tidy our header entries by unquoting them + results['headers'] = {NotifyForm.unquote(x): NotifyForm.unquote(y) + for x, y in results['headers'].items()} + + # Set method if not otherwise set + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = NotifyForm.unquote(results['qsd']['method']) + + return results diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index c0367033..99adfd9c 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -35,6 +35,16 @@ from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ +# Defines the method to send the notification +METHODS = ( + 'POST', + 'GET', + 'DELETE', + 'PUT', + 'HEAD' +) + + class NotifyJSON(NotifyBase): """ A wrapper for JSON Notifications @@ -93,6 +103,17 @@ class NotifyJSON(NotifyBase): 'type': 'string', 'private': True, }, + + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'method': { + 'name': _('Fetch Method'), + 'type': 'choice:string', + 'values': METHODS, + 'default': METHODS[0], + }, }) # Define any kwargs we're using @@ -103,7 +124,7 @@ class NotifyJSON(NotifyBase): }, } - def __init__(self, headers=None, **kwargs): + def __init__(self, headers=None, method=None, **kwargs): """ Initialize JSON Object @@ -117,6 +138,14 @@ class NotifyJSON(NotifyBase): if not isinstance(self.fullpath, six.string_types): self.fullpath = '/' + self.method = self.template_args['method']['default'] \ + if not isinstance(method, six.string_types) else method.upper() + + if self.method not in METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + self.headers = {} if headers: # Store our extra headers @@ -129,8 +158,13 @@ class NotifyJSON(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - # Our URL parameters - params = self.url_parameters(privacy=privacy, *args, **kwargs) + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({'+{}'.format(k): v for k, v in self.headers.items()}) @@ -238,8 +272,23 @@ class NotifyJSON(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() + if self.method == 'GET': + method = requests.get + + elif self.method == 'PUT': + method = requests.put + + elif self.method == 'DELETE': + method = requests.delete + + elif self.method == 'HEAD': + method = requests.head + + else: # POST + method = requests.post + try: - r = requests.post( + r = method( url, data=dumps(payload), headers=headers, @@ -253,11 +302,11 @@ class NotifyJSON(NotifyBase): NotifyJSON.http_response_code_lookup(r.status_code) self.logger.warning( - 'Failed to send JSON notification: ' - '{}{}error={}.'.format( - status_str, - ', ' if status_str else '', - r.status_code)) + 'Failed to send JSON %s notification: %s%serror=%s.', + self.method, + status_str, + ', ' if status_str else '', + str(r.status_code)) self.logger.debug('Response Details:\r\n{}'.format(r.content)) @@ -265,7 +314,7 @@ class NotifyJSON(NotifyBase): return False else: - self.logger.info('Sent JSON notification.') + self.logger.info('Sent JSON %s notification.', self.method) except requests.RequestException as e: self.logger.warning( @@ -303,4 +352,8 @@ class NotifyJSON(NotifyBase): results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results['headers'].items()} + # Set method if not otherwise set + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = NotifyJSON.unquote(results['qsd']['method']) + return results diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index 39438fad..f963b66b 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -35,6 +35,16 @@ from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ +# Defines the method to send the notification +METHODS = ( + 'POST', + 'GET', + 'DELETE', + 'PUT', + 'HEAD' +) + + class NotifyXML(NotifyBase): """ A wrapper for XML Notifications @@ -98,6 +108,17 @@ class NotifyXML(NotifyBase): 'type': 'string', 'private': True, }, + + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'method': { + 'name': _('Fetch Method'), + 'type': 'choice:string', + 'values': METHODS, + 'default': METHODS[0], + }, }) # Define any kwargs we're using @@ -108,7 +129,7 @@ class NotifyXML(NotifyBase): }, } - def __init__(self, headers=None, **kwargs): + def __init__(self, headers=None, method=None, **kwargs): """ Initialize XML Object @@ -138,6 +159,14 @@ class NotifyXML(NotifyBase): if not isinstance(self.fullpath, six.string_types): self.fullpath = '/' + self.method = self.template_args['method']['default'] \ + if not isinstance(method, six.string_types) else method.upper() + + if self.method not in METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + self.headers = {} if headers: # Store our extra headers @@ -150,12 +179,17 @@ class NotifyXML(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - # Store our defined headers into our URL parameters - params = {'+{}'.format(k): v for k, v in self.headers.items()} + # Define any URL parameters + params = { + 'method': self.method, + } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + # Determine Authentication auth = '' if self.user and self.password: @@ -277,8 +311,23 @@ class NotifyXML(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() + if self.method == 'GET': + method = requests.get + + elif self.method == 'PUT': + method = requests.put + + elif self.method == 'DELETE': + method = requests.delete + + elif self.method == 'HEAD': + method = requests.head + + else: # POST + method = requests.post + try: - r = requests.post( + r = method( url, data=payload, headers=headers, @@ -292,11 +341,11 @@ class NotifyXML(NotifyBase): NotifyXML.http_response_code_lookup(r.status_code) self.logger.warning( - 'Failed to send XML notification: ' - '{}{}error={}.'.format( - status_str, - ', ' if status_str else '', - r.status_code)) + 'Failed to send JSON %s notification: %s%serror=%s.', + self.method, + status_str, + ', ' if status_str else '', + str(r.status_code)) self.logger.debug('Response Details:\r\n{}'.format(r.content)) @@ -304,7 +353,7 @@ class NotifyXML(NotifyBase): return False else: - self.logger.info('Sent XML notification.') + self.logger.info('Sent XML %s notification.', self.method) except requests.RequestException as e: self.logger.warning( @@ -342,4 +391,8 @@ class NotifyXML(NotifyBase): results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results['headers'].items()} + # Set method if not otherwise set + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = NotifyXML.unquote(results['qsd']['method']) + return results diff --git a/test/helpers/rest.py b/test/helpers/rest.py index 6f039d8d..30eb891d 100644 --- a/test/helpers/rest.py +++ b/test/helpers/rest.py @@ -298,7 +298,11 @@ class AppriseURLTester(object): @mock.patch('requests.get') @mock.patch('requests.post') - def __notify(self, url, obj, meta, asset, mock_post, mock_get): + @mock.patch('requests.head') + @mock.patch('requests.put') + @mock.patch('requests.delete') + def __notify(self, url, obj, meta, asset, mock_del, mock_put, mock_head, + mock_post, mock_get): """ Perform notification testing against object specified """ @@ -344,20 +348,36 @@ class AppriseURLTester(object): robj.content = u'' mock_get.return_value = robj mock_post.return_value = robj + mock_head.return_value = robj + mock_del.return_value = robj + mock_put.return_value = robj if test_requests_exceptions is False: # Handle our default response + mock_put.return_value.status_code = requests_response_code + mock_head.return_value.status_code = requests_response_code + mock_del.return_value.status_code = requests_response_code mock_post.return_value.status_code = requests_response_code mock_get.return_value.status_code = requests_response_code # Handle our default text response mock_get.return_value.content = requests_response_text mock_post.return_value.content = requests_response_text + mock_del.return_value.content = requests_response_text + mock_put.return_value.content = requests_response_text + mock_head.return_value.content = requests_response_text + mock_get.return_value.text = requests_response_text mock_post.return_value.text = requests_response_text + mock_put.return_value.text = requests_response_text + mock_del.return_value.text = requests_response_text + mock_head.return_value.text = requests_response_text # Ensure there is no side effect set mock_post.side_effect = None + mock_del.side_effect = None + mock_put.side_effect = None + mock_head.side_effect = None mock_get.side_effect = None else: @@ -457,6 +477,9 @@ class AppriseURLTester(object): for _exception in self.req_exceptions: mock_post.side_effect = _exception + mock_head.side_effect = _exception + mock_del.side_effect = _exception + mock_put.side_effect = _exception mock_get.side_effect = _exception try: @@ -498,6 +521,9 @@ class AppriseURLTester(object): else: for _exception in self.req_exceptions: mock_post.side_effect = _exception + mock_del.side_effect = _exception + mock_put.side_effect = _exception + mock_head.side_effect = _exception mock_get.side_effect = _exception try: diff --git a/test/test_plugin_custom_form.py b/test/test_plugin_custom_form.py new file mode 100644 index 00000000..90a4a160 --- /dev/null +++ b/test/test_plugin_custom_form.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import os +import sys +import mock +import requests +from apprise import plugins +from helpers import AppriseURLTester +from apprise import Apprise +from apprise import NotifyType +from apprise import AppriseAttachment + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + +# Our Testing URLs +apprise_url_tests = ( + ('form://:@/', { + 'instance': None, + }), + ('form://', { + 'instance': None, + }), + ('forms://', { + 'instance': None, + }), + ('form://localhost', { + 'instance': plugins.NotifyForm, + }), + ('form://user@localhost?method=invalid', { + 'instance': TypeError, + }), + ('form://user:pass@localhost', { + 'instance': plugins.NotifyForm, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'form://user:****@localhost', + }), + ('form://user@localhost', { + 'instance': plugins.NotifyForm, + }), + + # Test method variations + ('form://user@localhost?method=put', { + 'instance': plugins.NotifyForm, + }), + ('form://user@localhost?method=get', { + 'instance': plugins.NotifyForm, + }), + ('form://user@localhost?method=post', { + 'instance': plugins.NotifyForm, + }), + ('form://user@localhost?method=head', { + 'instance': plugins.NotifyForm, + }), + ('form://user@localhost?method=delete', { + 'instance': plugins.NotifyForm, + }), + + # Custom payload options + ('form://localhost:8080?:key=value&:key2=value2', { + 'instance': plugins.NotifyForm, + }), + + # Continue testing other cases + ('form://localhost:8080', { + 'instance': plugins.NotifyForm, + }), + ('form://user:pass@localhost:8080', { + 'instance': plugins.NotifyForm, + }), + ('forms://localhost', { + 'instance': plugins.NotifyForm, + }), + ('forms://user:pass@localhost', { + 'instance': plugins.NotifyForm, + }), + ('forms://localhost:8080/path/', { + 'instance': plugins.NotifyForm, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'forms://localhost:8080/path/', + }), + ('forms://user:password@localhost:8080', { + 'instance': plugins.NotifyForm, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'forms://user:****@localhost:8080', + }), + ('form://localhost:8080/path?-HeaderKey=HeaderValue', { + 'instance': plugins.NotifyForm, + }), + ('form://user:pass@localhost:8081', { + 'instance': plugins.NotifyForm, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('form://user:pass@localhost:8082', { + 'instance': plugins.NotifyForm, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('form://user:pass@localhost:8083', { + 'instance': plugins.NotifyForm, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_custom_form_urls(): + """ + NotifyForm() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_custom_form_attachments(mock_post): + """ + NotifyForm() Attachments + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + okay_response = requests.Request() + okay_response.status_code = requests.codes.ok + okay_response.content = "" + + # Assign our mock object our return value + mock_post.return_value = okay_response + + obj = Apprise.instantiate( + 'form://user@localhost.localdomain/?method=post') + assert isinstance(obj, plugins.NotifyForm) + + # Test Valid Attachment + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test invalid attachment + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + mock_post.return_value = None + mock_post.side_effect = OSError() + # We can't send the message if we can't read the attachment + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # Get a appropriate "builtin" module name for pythons 2/3. + if sys.version_info.major >= 3: + builtin_open_function = 'builtins.open' + + else: + builtin_open_function = '__builtin__.open' + + # Test Valid Attachment (load 3) + path = ( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + ) + attach = AppriseAttachment(path) + + # Return our good configuration + mock_post.side_effect = None + mock_post.return_value = okay_response + with mock.patch(builtin_open_function, side_effect=OSError()): + # We can't send the message we can't open the attachment for reading + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # Fail on the 2nd attempt (but not the first) + with mock.patch(builtin_open_function, + side_effect=[None, OSError(), None]): + # We can't send the message we can't open the attachment for reading + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # Test file exception handling when performing post + mock_post.return_value = None + mock_post.side_effect = OSError() + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False diff --git a/test/test_plugin_custom_json.py b/test/test_plugin_custom_json.py index fcd94d65..c9824285 100644 --- a/test/test_plugin_custom_json.py +++ b/test/test_plugin_custom_json.py @@ -44,6 +44,9 @@ apprise_url_tests = ( ('json://localhost', { 'instance': plugins.NotifyJSON, }), + ('json://user@localhost?method=invalid', { + 'instance': TypeError, + }), ('json://user:pass@localhost', { 'instance': plugins.NotifyJSON, @@ -53,6 +56,25 @@ apprise_url_tests = ( ('json://user@localhost', { 'instance': plugins.NotifyJSON, }), + + # Test method variations + ('json://user@localhost?method=put', { + 'instance': plugins.NotifyJSON, + }), + ('json://user@localhost?method=get', { + 'instance': plugins.NotifyJSON, + }), + ('json://user@localhost?method=post', { + 'instance': plugins.NotifyJSON, + }), + ('json://user@localhost?method=head', { + 'instance': plugins.NotifyJSON, + }), + ('json://user@localhost?method=delete', { + 'instance': plugins.NotifyJSON, + }), + + # Continue testing other cases ('json://localhost:8080', { 'instance': plugins.NotifyJSON, }), diff --git a/test/test_plugin_custom_xml.py b/test/test_plugin_custom_xml.py index 237ffd61..86f8914f 100644 --- a/test/test_plugin_custom_xml.py +++ b/test/test_plugin_custom_xml.py @@ -47,12 +47,34 @@ apprise_url_tests = ( ('xml://user@localhost', { 'instance': plugins.NotifyXML, }), + ('xml://user@localhost?method=invalid', { + 'instance': TypeError, + }), ('xml://user:pass@localhost', { 'instance': plugins.NotifyXML, # Our expected url(privacy=True) startswith() response: 'privacy_url': 'xml://user:****@localhost', }), + + # Test method variations + ('xml://user@localhost?method=put', { + 'instance': plugins.NotifyXML, + }), + ('xml://user@localhost?method=get', { + 'instance': plugins.NotifyXML, + }), + ('xml://user@localhost?method=post', { + 'instance': plugins.NotifyXML, + }), + ('xml://user@localhost?method=head', { + 'instance': plugins.NotifyXML, + }), + ('xml://user@localhost?method=delete', { + 'instance': plugins.NotifyXML, + }), + + # Continue testing other cases ('xml://localhost:8080', { 'instance': plugins.NotifyXML, }), @@ -64,10 +86,26 @@ apprise_url_tests = ( }), ('xmls://user:pass@localhost', { 'instance': plugins.NotifyXML, - + }), + # Continue testing other cases + ('xml://localhost:8080', { + 'instance': plugins.NotifyXML, + }), + ('xml://user:pass@localhost:8080', { + 'instance': plugins.NotifyXML, + }), + ('xml://localhost', { + 'instance': plugins.NotifyXML, + }), + ('xmls://user:pass@localhost', { + 'instance': plugins.NotifyXML, # Our expected url(privacy=True) startswith() response: 'privacy_url': 'xmls://user:****@localhost', }), + ('xml://user@localhost:8080/path/', { + 'instance': plugins.NotifyXML, + 'privacy_url': 'xml://user@localhost:8080/path', + }), ('xmls://localhost:8080/path/', { 'instance': plugins.NotifyXML, # Our expected url(privacy=True) startswith() response: