Skip to content

Commit

Permalink
Added webhook support for sending Notification results (#139)
Browse files Browse the repository at this point in the history
  • Loading branch information
caronc committed Oct 15, 2023
1 parent cd2135b commit 6a8099d
Show file tree
Hide file tree
Showing 11 changed files with 665 additions and 82 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ omit =
*apps.py,
*/migrations/*,
*/core/settings/*,
*/*/tests/*,
lib/*,
lib64/*,
*urls.py,
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ The use of environment variables allow you to provide over-rides to default sett
| `ALLOWED_HOSTS` | A list of strings representing the host/domain names that this API can serve. This is a security measure to prevent HTTP Host header attacks, which are possible even under many seemingly-safe web server configurations. By default this is set to `*` allowing any host. Use space to delimit more than one host.
| `APPRISE_PLUGIN_PATHS` | Apprise supports the ability to define your own `schema://` definitions and load them. To read more about how you can create your own customizations, check out [this link here](https://github.com/caronc/apprise/wiki/decorator_notify). You may define one or more paths (separated by comma `,`) here. By default the `apprise_api/var/plugin` directory is scanned (which does not include anything). Feel free to set this to an empty string to disable any custom plugin loading.
| `APPRISE_RECURSION_MAX` | This defines the number of times one Apprise API Server can (recursively) call another. This is to both support and mitigate abuse through [the `apprise://` schema](https://github.com/caronc/apprise/wiki/Notify_apprise_api) for those who choose to use it. When leveraged properly, you can increase this (recursion max) value and successfully load balance the handling of many notification requests through many additional API Servers. By default this value is set to `1` (one).
| `APPRISE_WEBHOOK_URL` | Define a Webhook that Apprise should `POST` results to upon each notification call made. This must be in the format of an `http://` or `https://` URI. By default no URL is specified and no webhook is actioned.
| `APPRISE_WORKER_COUNT` | Over-ride the number of workers to run. by default this is `(2 * CPUS) + 1` as advised by Gunicorn's website.
| `APPRISE_WORKER_TIMEOUT` | Over-ride the worker timeout value; by default this is `300` (5 min) which should be more than enough time to send all pending notifications.
| `BASE_URL` | Those who are hosting the API behind a proxy that requires a subpath to gain access to this API should specify this path here as well. By default this is not set at all.
Expand Down
3 changes: 2 additions & 1 deletion apprise_api/api/tests/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ def test_key_lengths(self):
# However adding just 1 more character exceeds our limit and the save
# will fail
response = self.client.post(
'/add/{}'.format(key + 'x'), {'urls': 'mailto://user:[email protected]'})
'/add/{}'.format(key + 'x'),
{'urls': 'mailto://user:[email protected]'})
assert response.status_code == 404

@override_settings(APPRISE_CONFIG_LOCK=True)
Expand Down
63 changes: 63 additions & 0 deletions apprise_api/api/tests/test_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <[email protected]>
# 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.
from django.test import SimpleTestCase
from unittest import mock
from ..utils import Attachment
from django.test.utils import override_settings
from tempfile import TemporaryDirectory
from shutil import rmtree


class AttachmentTests(SimpleTestCase):
def setUp(self):
# Prepare a temporary directory
self.tmp_dir = TemporaryDirectory()

def tearDown(self):
# Clear directory
try:
rmtree(self.tmp_dir.name)
except FileNotFoundError:
# no worries
pass

self.tmp_dir = None

def test_attachment_initialization(self):
"""
Test attachment handling
"""

with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name):
with mock.patch('os.makedirs', side_effect=OSError):
with self.assertRaises(ValueError):
Attachment('file')

with mock.patch('tempfile.mkstemp', side_effect=FileNotFoundError):
with self.assertRaises(ValueError):
Attachment('file')

a = Attachment('file')
assert a.filename
160 changes: 150 additions & 10 deletions apprise_api/api/tests/test_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase, override_settings
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from unittest import mock
import requests
from ..forms import NotifyForm
import json
Expand All @@ -36,7 +37,7 @@ class NotifyTests(SimpleTestCase):
Test notifications
"""

@patch('apprise.Apprise.notify')
@mock.patch('apprise.Apprise.notify')
def test_notify_by_loaded_urls(self, mock_notify):
"""
Test adding a simple notification and notifying it
Expand Down Expand Up @@ -78,7 +79,146 @@ def test_notify_by_loaded_urls(self, mock_notify):
assert response.status_code == 200
assert mock_notify.call_count == 1

@patch('requests.post')
# Reset our mock object
mock_notify.reset_mock()

# Preare our form data
form_data = {
'body': 'test notifiction',
}
attach_data = {
'attachment': SimpleUploadedFile(
"attach.txt", b"content here", content_type="text/plain")
}

# At a minimum, just a body is required
form = NotifyForm(form_data, attach_data)
assert form.is_valid()

# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1

# Reset our mock object
mock_notify.reset_mock()

# Test Headers
for level in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG',
'TRACE', 'INVALID'):

# Preare our form data
form_data = {
'body': 'test notifiction',
}
attach_data = {
'attachment': SimpleUploadedFile(
"attach.txt", b"content here", content_type="text/plain")
}

# At a minimum, just a body is required
form = NotifyForm(form_data, attach_data)
assert form.is_valid()

# Prepare our header
headers = {
'HTTP_X-APPRISE-LOG-LEVEL': level,
}

# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1

# Reset our mock object
mock_notify.reset_mock()

# Long Filename
attach_data = {
'attachment': SimpleUploadedFile(
"{}.txt".format('a' * 2000),
b"content here", content_type="text/plain")
}

# At a minimum, just a body is required
form = NotifyForm(form_data, attach_data)
assert form.is_valid()

# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)

# We fail because the filename is too long
assert response.status_code == 400
assert mock_notify.call_count == 0

# Reset our mock object
mock_notify.reset_mock()

with override_settings(APPRISE_MAX_ATTACHMENTS=0):

# Preare our form data
form_data = {
'body': 'test notifiction',
}
attach_data = {
'attachment': SimpleUploadedFile(
"attach.txt", b"content here", content_type="text/plain")
}

# At a minimum, just a body is required
form = NotifyForm(form_data, attach_data)
assert form.is_valid()

# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)

# No attachments allowed
assert response.status_code == 400
assert mock_notify.call_count == 0

# Reset our mock object
mock_notify.reset_mock()

# Test Webhooks
with mock.patch('requests.post') as mock_post:
# Response object
response = mock.Mock()
response.status_code = requests.codes.ok
mock_post.return_value = response

with override_settings(
APPRISE_WEBHOOK_URL='http://localhost/webhook/'):

# Preare our form data
form_data = {
'body': 'test notifiction',
}

# At a minimum, just a body is required
form = NotifyForm(data=form_data)
assert form.is_valid()

# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']

# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)

# Test our results
assert response.status_code == 200
assert mock_notify.call_count == 1
assert mock_post.call_count == 1

# Reset our mock object
mock_notify.reset_mock()

@mock.patch('requests.post')
def test_notify_with_tags(self, mock_post):
"""
Test notification handling when setting tags
Expand Down Expand Up @@ -170,7 +310,7 @@ def test_notify_with_tags(self, mock_post):
assert response['message'] == form_data['body']
assert response['type'] == apprise.NotifyType.WARNING

@patch('requests.post')
@mock.patch('requests.post')
def test_notify_with_tags_via_apprise(self, mock_post):
"""
Test notification handling when setting tags via the Apprise CLI
Expand Down Expand Up @@ -272,7 +412,7 @@ def test_notify_with_tags_via_apprise(self, mock_post):
assert response['message'] == form_data['body']
assert response['type'] == apprise.NotifyType.WARNING

@patch('requests.post')
@mock.patch('requests.post')
def test_advanced_notify_with_tags(self, mock_post):
"""
Test advanced notification handling when setting tags
Expand Down Expand Up @@ -474,7 +614,7 @@ def test_advanced_notify_with_tags(self, mock_post):
# We'll trigger on 2 entries
assert mock_post.call_count == 0

@patch('apprise.NotifyBase.notify')
@mock.patch('apprise.NotifyBase.notify')
def test_partial_notify_by_loaded_urls(self, mock_notify):
"""
Test notification handling when one or more of the services
Expand Down Expand Up @@ -523,7 +663,7 @@ def test_partial_notify_by_loaded_urls(self, mock_notify):
assert response.status_code == 424
assert mock_notify.call_count == 2

@patch('apprise.Apprise.notify')
@mock.patch('apprise.Apprise.notify')
def test_notify_by_loaded_urls_with_json(self, mock_notify):
"""
Test adding a simple notification and notifying it using JSON
Expand Down Expand Up @@ -622,7 +762,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify):
}

# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
with mock.patch('gzip.open') as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
Expand Down Expand Up @@ -746,7 +886,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify):
assert mock_notify.call_count == 1
assert response['content-type'] == 'text/html'

@patch('apprise.plugins.NotifyEmail.NotifyEmail.send')
@mock.patch('apprise.plugins.NotifyEmail.NotifyEmail.send')
def test_notify_with_filters(self, mock_send):
"""
Test workings of APPRISE_DENY_SERVICES and APPRISE_ALLOW_SERVICES
Expand Down Expand Up @@ -904,7 +1044,7 @@ def test_notify_with_filters(self, mock_send):
apprise.common.NOTIFY_SCHEMA_MAP['mailto'].enabled is True

@override_settings(APPRISE_RECURSION_MAX=1)
@patch('apprise.Apprise.notify')
@mock.patch('apprise.Apprise.notify')
def test_stateful_notify_recursion(self, mock_notify):
"""
Test recursion an id header details as part of post
Expand Down
Loading

0 comments on commit 6a8099d

Please sign in to comment.