# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2025, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import json # Disable logging for a cleaner testing output import logging import os import sys from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import asset, exception, url from apprise.common import PersistentStoreMode from apprise.plugins.vapid import NotifyVapid from apprise.plugins.vapid.subscription import ( WebPushSubscription, WebPushSubscriptionManager, ) from apprise.utils.pem import ApprisePEMController logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # a test UUID we can use SUBSCRIBER = "user@example.com" PLUGIN_ID = "vapid" # Our Testing URLs apprise_url_tests = ( ( "vapid://", { "instance": TypeError, }, ), ( "vapid://:@/", { "instance": TypeError, }, ), ( "vapid://invalid-subscriber", { # An invalid Subscriber "instance": TypeError, }, ), ( "vapid://user@example.com", { # bare bone requirements met, but we don't have our subscription # file or our private key (pem) "instance": NotifyVapid, # We'll fail to respond because we would not have found any # configuration to load "notify_response": False, }, ), ( "vapid://user@example.com?keyfile=invalid&subfile=invalid", { # Test passing keyfile and subfile on our path (even if invalid) "instance": NotifyVapid, # We'll fail to respond because we would not have found any # configuration to load "notify_response": False, }, ), ( "vapid://user@example.com/newuser@example.com", { # we don't have our subscription file or private key "instance": NotifyVapid, "notify_response": False, }, ), ( "vapid://user@example.ca/newuser@example.ca", { "instance": NotifyVapid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "vapid://user@example.uk/newuser@example.uk", { "instance": NotifyVapid, # throw a bizzare code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vapid://user@example.au/newuser@example.au", { "instance": NotifyVapid, # Throws a series of i/o exceptions with this flag # is set and tests that we gracfully handle them "test_requests_exceptions": True, }, ), ) @pytest.fixture def patch_persistent_store_namespace(tmpdir): """Force an easy to test environment.""" with ( mock.patch.object(url.URLBase, "url_id", return_value=PLUGIN_ID), mock.patch.object( asset.AppriseAsset, "storage_mode", PersistentStoreMode.AUTO ), mock.patch.object(asset.AppriseAsset, "storage_path", str(tmpdir)), ): tmp_dir = tmpdir.mkdir(PLUGIN_ID) # Return the directory name yield str(tmp_dir) @pytest.fixture def subscription_reference(): return { "user@example.com": { "endpoint": "https://fcm.googleapis.com/fcm/send/default", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, "user1": { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, "user2": { "endpoint": "https://fcm.googleapis.com/fcm/send/def456", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }, } @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_urls(): """ NotifyVapid() Apprise URLs - No Config """ # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_urls_with_required_assets( patch_persistent_store_namespace, subscription_reference ): """NotifyVapid() Apprise URLs With Config.""" # Determine our store pc = ApprisePEMController(path=patch_persistent_store_namespace) assert pc.keygen() is True # Write our subscriptions file to disk subscription_file = os.path.join( patch_persistent_store_namespace, NotifyVapid.vapid_subscription_file ) with open(subscription_file, "w") as f: f.write(json.dumps(subscription_reference)) tests = ( ( "vapid://user@example.com", { # user@example.com loaded (also used as subscriber id) "instance": NotifyVapid, }, ), ( "vapid://user@example.com/newuser@example.com", { # no newuser@example.com key entry "instance": NotifyVapid, "notify_response": False, }, ), ( "vapid://user@example.com/user1?to=user2", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://user1?to=user2&from=user@example.com", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://?to=user2&from=user@example.com", { # No host provided "instance": NotifyVapid, }, ), ( "vapid://user@example.com?to=user2&from=user@example.com", { # We'll succesfully notify 2 users "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=15", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=invalid", { # test ttl "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&ttl=-4000", { # bad ttl "instance": TypeError, }, ), ( "vapid://user@example.com/user1?to=user2&mode=edge", { # test mode "instance": NotifyVapid, }, ), ( "vapid://user@example.com/user1?to=user2&mode=", { # test mode "instance": TypeError, }, ), ( "vapid://user@example.com/user1?to=user2&mode=invalid", { # test mode more "instance": TypeError, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # throw a bizzare code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "vapid://user@example.com/user1", { "instance": NotifyVapid, # Throws a series of connection and transfer exceptions # when this flag is set and tests that we gracfully handle them "test_requests_exceptions": True, }, ), ) AppriseURLTester(tests=tests).run_all() @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscriptions(tmpdir): """NotifyVapid() Subscriptions.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") with pytest.raises(exception.AppriseInvalidData): # Integer not supported WebPushSubscription(42) with pytest.raises(exception.AppriseInvalidData): # Not the correct format WebPushSubscription("bad-content") with pytest.raises(exception.AppriseInvalidData): # Invalid JSON WebPushSubscription("{") with pytest.raises(exception.AppriseInvalidData): # Empty Dictionary WebPushSubscription({}) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=", "auth": 42, }, }) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": 42, "auth": "k9Xzm43nBGo=", }, }) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", }) with pytest.raises(exception.AppriseInvalidData): WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": {}, }) with pytest.raises(exception.AppriseInvalidData): # Invalid p256dh public key provided wps = WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=", "auth": "k9Xzm43nBGo=", }, }) # An empty object wps = WebPushSubscription() assert bool(wps) is False assert isinstance(wps.json(), str) assert json.loads(wps.json()) assert str(wps) == "" assert wps.auth is None assert wps.endpoint is None assert wps.p256dh is None assert wps.public_key is None # We can't write anything as there is nothing loaded assert wps.write(os.path.join(str(tmpdir0), "subscriptions.json")) is False # A valid key wps = WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }) assert bool(wps) is True assert isinstance(wps.json(), str) assert json.loads(wps.json()) assert str(wps) == "abc123" assert wps.auth == "k9Xzm43nBGo=" assert wps.endpoint == "https://fcm.googleapis.com/fcm/send/abc123" assert ( wps.p256dh == "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ) assert wps.public_key is not None # Currently no files here assert os.listdir(str(tmpdir0)) == [] # Bad content assert wps.write(object) is False assert wps.write(None) is False # Can't write to a name already taken by as a directory assert wps.write(str(tmpdir0)) is False # Can't write to a name already taken by as a directory assert wps.write(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_vapid_subscriptions_without_c(): """NotifyVapid() Subscriptions (no Cryptography)""" with pytest.raises(exception.AppriseInvalidData): # A valid key that can't be loaded because crytography is missing WebPushSubscription({ "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, }) @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) def test_plugin_vapid_subscription_manager(tmpdir): """NotifyVapid() Subscription Manager.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") with pytest.raises(exception.AppriseInvalidData): # An invalid object smgr = WebPushSubscriptionManager() smgr["abc"] = "invalid" with pytest.raises(exception.AppriseInvalidData): # An invalid object smgr = WebPushSubscriptionManager() smgr += "invalid" smgr = WebPushSubscriptionManager() assert bool(smgr) is False assert len(smgr) == 0 sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } assert smgr.add(sub) is True assert bool(smgr) is True assert len(smgr) == 1 # Same sub (overwrites same slot) smgr += sub assert bool(smgr) is True assert len(smgr) == 1 # This makes a copy smgr["abc"] = smgr["abc123"] assert bool(smgr) is True assert len(smgr) == 2 assert isinstance(smgr["abc123"], WebPushSubscription) # Currently no files here assert os.listdir(str(tmpdir0)) == [] # Write our content assert smgr.write(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] # Reset our object smgr.clear() assert bool(smgr) is False assert len(smgr) == 0 # Load our content back assert smgr.load(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert bool(smgr) is True assert len(smgr) == 2 # Write over our file using the standard Subscription format assert ( smgr["abc123"].write(os.path.join(str(tmpdir0), "subscriptions.json")) is True ) # We can still open this type as well assert smgr.load(os.path.join(str(tmpdir0), "subscriptions.json")) is True assert bool(smgr) is True assert len(smgr) == 1 smgr.clear() bad_entry = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": "invalid", "auth": "garbage", }, } subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: # A bad JSON file f.write("{") assert smgr.load(subscriptions) is False with open(subscriptions, "w", encoding="utf-8") as f: # not expected dictionary f.write("null") assert smgr.load(subscriptions) is False subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: json.dump(bad_entry, f) assert smgr.load(subscriptions) is False # Create bad data bad_data = { "bad1": bad_entry, "bad2": bad_entry, "bad3": bad_entry, "bad4": bad_entry, } subscriptions = os.path.join(str(tmpdir0), "subscriptions.json") with open(subscriptions, "w", encoding="utf-8") as f: json.dump(bad_data, f) assert smgr.load(subscriptions) is False assert smgr.load("invalid-file") is False @pytest.mark.skipif( "cryptography" not in sys.modules, reason="Requires cryptography" ) @mock.patch("requests.post") def test_plugin_vapid_initializations(mock_post, tmpdir): """NotifyVapid() Initializations.""" # Assign our mock object our return value okay_response = requests.Request() okay_response.status_code = requests.codes.ok okay_response.content = "" mock_post.return_value = okay_response # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") # Write our subfile smgr = WebPushSubscriptionManager() sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } subfile = os.path.join(str(tmpdir0), "subscriptions.json") assert smgr.add(sub) is True assert smgr.add(smgr["abc123"]) is True assert os.listdir(str(tmpdir0)) == [] with mock.patch("json.dump", side_effect=OSError): # We will fial to write assert smgr.write(subfile) is False assert smgr.write(subfile) is True assert os.listdir(str(tmpdir0)) == ["subscriptions.json"] assert isinstance(smgr.json(), str) _asset = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), # Auto-gen our private/public key pair pem_autogen=True, ) # Auto-Key Generation obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=_asset, ) assert isinstance(obj, NotifyVapid) # Our subscription directory + our # persistent store where our keys were generated assert len(os.listdir(str(tmpdir0))) == 2 # Second call re-references keys previously generated obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=_asset, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is True # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is True obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile="/a/bad/path", asset=_asset, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False # Detect our keyfile cache_dir = next( x for x in os.listdir(str(tmpdir0)) if not x.endswith("subscriptions.json") ) # Test fixed assignment to our keyfile keyfile = os.path.join(str(tmpdir0), cache_dir, "private_key.pem") assert os.path.exists(keyfile) obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=keyfile, subfile=subfile, asset=_asset, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is True # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is True # Invalid Keyfile obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=subfile, subfile=subfile, asset=_asset, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False # AutoGen Temporary directory tmpdir1 = tmpdir.mkdir("tmp01") _asset2 = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir1), # Auto-gen our private/public key pair pem_autogen=True, ) assert os.listdir(str(tmpdir1)) == [] obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile=keyfile, asset=_asset2, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) # We have a temporary subscription file we can use assert os.listdir(str(tmpdir1)) == ["00088ad3"] # We will have a dud configuration file, but at least it's something # to help the user with assert obj.send("test") is False # Second instance fails as well assert obj.send("test") is False # AutoGen Temporary directory tmpdir2 = tmpdir.mkdir("tmp02") _asset3 = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir2), # Auto-gen our private/public key pair pem_autogen=True, ) # Test invalid keyfile assert os.path.exists(keyfile) obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], keyfile="invalid-file", subfile=subfile, asset=_asset3, ) assert isinstance(obj, NotifyVapid) assert isinstance(obj.url(), str) assert obj.send("test") is False # A second message makes no difference; what is loaded into memory is used assert obj.send("test") is False @pytest.mark.skipif( "cryptography" in sys.modules, reason="Requires that cryptography NOT be installed", ) def test_plugin_vapid_initializations_without_c(tmpdir): """NotifyVapid() Initializations without cryptography.""" # Temporary directory tmpdir0 = tmpdir.mkdir("tmp00") # Write our subfile smgr = WebPushSubscriptionManager() sub = { "endpoint": "https://fcm.googleapis.com/fcm/send/abc123", "keys": { "p256dh": ( "BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO" "5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0" ), "auth": "k9Xzm43nBGo=", }, } subfile = os.path.join(str(tmpdir0), "subscriptions.json") assert smgr.add(sub) is False _asset = asset.AppriseAsset( storage_mode=PersistentStoreMode.FLUSH, storage_path=str(tmpdir0), # Auto-gen our private/public key pair pem_autogen=True, ) # Auto-Key Generation obj = NotifyVapid( "user@example.ca", targets=[ "abc123", ], subfile=subfile, asset=_asset, ) assert isinstance(obj, NotifyVapid)