apprise/tests/test_plugin_msteams.py

848 lines
24 KiB
Python

# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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
from unittest import mock
from helpers import AppriseURLTester
import pytest
import requests
from apprise import Apprise, AppriseConfig, NotifyType
from apprise.plugins.msteams import NotifyMSTeams
logging.disable(logging.CRITICAL)
# a test UUID we can use
UUID4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752"
# Our Testing URLs
apprise_url_tests = (
##################################
# NotifyMSTeams
##################################
(
"msteams://",
{
# First API Token not specified
"instance": TypeError,
},
),
(
"msteams://:@/",
{
# We don't have strict host checking on for msteams, so this URL
# actually becomes parseable and :@ becomes a hostname.
# The below errors because a second token wasn't found
"instance": TypeError,
},
),
(
f"msteams://{UUID4}",
{
# Just half of one token 1 provided
"instance": TypeError,
},
),
(
f"msteams://{UUID4}@{UUID4}/",
{
# Just 1 tokens provided
"instance": TypeError,
},
),
(
"msteams://{}@{}/{}".format(UUID4, UUID4, "a" * 32),
{
# Just 2 tokens provided
"instance": TypeError,
},
),
(
"msteams://{}@{}/{}/{}?t1".format(UUID4, UUID4, "b" * 32, UUID4),
{
# All tokens provided - we're good
"instance": NotifyMSTeams,
},
),
# Support native URLs
(
"https://outlook.office.com/webhook/{}@{}/IncomingWebhook/{}/{}"
.format(UUID4, UUID4, "k" * 32, UUID4),
{
# All tokens provided - we're good
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response (v1 format)
"privacy_url": "msteams://8...2/k...k/8...2/",
},
),
# Support New Native URLs
(
"https://myteam.webhook.office.com/webhookb2/{}@{}/IncomingWebhook/{}/{}"
.format(UUID4, UUID4, "m" * 32, UUID4),
{
# All tokens provided - we're good
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response (v2 format):
"privacy_url": "msteams://myteam/8...2/m...m/8...2/",
},
),
# Support Newer Native URLs with 4 tokens, introduced in 2025
(
"https://myteam.webhook.office.com/webhookb2/{}@{}/IncomingWebhook/{}/{}"
"/{}".format(UUID4, UUID4, "m" * 32, UUID4, "V2-_" + "n" * 43),
{
# All tokens provided - we're good
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response (v2 format):
"privacy_url": "msteams://myteam/8...2/m...m/8...2/V...n",
},
),
# Legacy URL Formatting
(
"msteams://{}@{}/{}/{}?t2".format(UUID4, UUID4, "c" * 32, UUID4),
{
# All tokens provided - we're good
"instance": NotifyMSTeams,
# don't include an image by default
"include_image": False,
},
),
# Legacy URL Formatting
(
"msteams://{}@{}/{}/{}?image=No".format(UUID4, UUID4, "d" * 32, UUID4),
{
# All tokens provided - we're good no image
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response:
"privacy_url": "msteams://8...2/d...d/8...2/",
},
),
# New 2021 URL formatting
(
"msteams://apprise/{}@{}/{}/{}".format(UUID4, UUID4, "e" * 32, UUID4),
{
# All tokens provided - we're good no image
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response:
"privacy_url": "msteams://apprise/8...2/e...e/8...2/",
},
),
# New 2021 URL formatting; support team= argument
(
"msteams://{}@{}/{}/{}?team=teamname".format(
UUID4, UUID4, "f" * 32, UUID4
),
{
# All tokens provided - we're good no image
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response:
"privacy_url": "msteams://teamname/8...2/f...f/8...2/",
},
),
# New 2021 URL formatting (forcing v1)
(
"msteams://apprise/{}@{}/{}/{}?version=1".format(
UUID4, UUID4, "e" * 32, UUID4
),
{
# All tokens provided - we're good
"instance": NotifyMSTeams,
# Our expected url(privacy=True) startswith() response:
"privacy_url": "msteams://8...2/e...e/8...2/",
},
),
# Invalid versioning
(
"msteams://apprise/{}@{}/{}/{}?version=999".format(
UUID4, UUID4, "e" * 32, UUID4
),
{
# invalid version
"instance": TypeError,
},
),
(
"msteams://apprise/{}@{}/{}/{}?version=invalid".format(
UUID4, UUID4, "e" * 32, UUID4
),
{
# invalid version
"instance": TypeError,
},
),
(
"msteams://{}@{}/{}/{}?tx".format(UUID4, UUID4, "x" * 32, UUID4),
{
"instance": NotifyMSTeams,
# force a failure
"response": False,
"requests_response_code": requests.codes.internal_server_error,
},
),
(
"msteams://{}@{}/{}/{}?ty".format(UUID4, UUID4, "y" * 32, UUID4),
{
"instance": NotifyMSTeams,
# throw a bizzare code forcing us to fail to look it up
"response": False,
"requests_response_code": 999,
},
),
(
"msteams://{}@{}/{}/{}?tz".format(UUID4, UUID4, "z" * 32, UUID4),
{
"instance": NotifyMSTeams,
# Throws a series of i/o exceptions with this flag
# is set and tests that we gracfully handle them
"test_requests_exceptions": True,
},
),
)
def test_plugin_msteams_urls():
"""NotifyMSTeams() Apprise URLs."""
# Run our general tests
AppriseURLTester(tests=apprise_url_tests).run_all()
@pytest.fixture
def msteams_url():
return "msteams://{}@{}/{}/{}".format(UUID4, UUID4, "a" * 32, UUID4)
@pytest.fixture
def request_mock(mocker):
"""Prepare requests mock."""
mock_post = mocker.patch("requests.post")
mock_post.return_value = requests.Request()
mock_post.return_value.status_code = requests.codes.ok
return mock_post
@pytest.fixture
def simple_template(tmpdir):
template = tmpdir.join("simple.json")
template.write("""
{
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": "{{name}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{title}}",
"text": "{{body}}"
}
]
}
""")
return template
def test_plugin_msteams_templating_basic_success(
request_mock, msteams_url, tmpdir
):
"""
NotifyMSTeams() Templating - success.
Test cases where URL and JSON is valid.
"""
template = tmpdir.join("simple.json")
template.write("""
{
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": "{{app_id}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{app_title}}",
"text": "{{app_body}}"
}
]
}
""")
# Instantiate our URL
obj = Apprise.instantiate(
"{url}/?template={template}&{kwargs}".format(
url=msteams_url,
template=str(template),
kwargs=":key1=token&:key2=token",
)
)
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Apprise"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "title"
assert posted_json["sections"][0]["text"] == "body"
def test_plugin_msteams_templating_invalid_json(
request_mock, msteams_url, tmpdir
):
"""
NotifyMSTeams() Templating - invalid JSON.
"""
template = tmpdir.join("invalid.json")
template.write("}")
# Instantiate our URL
obj = Apprise.instantiate(
"{url}/?template={template}&{kwargs}".format(
url=msteams_url,
template=str(template),
kwargs=":key1=token&:key2=token",
)
)
assert isinstance(obj, NotifyMSTeams)
# We will fail to preform our notifcation because the JSON is bad
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is False
)
def test_plugin_msteams_templating_json_missing_type(
request_mock, msteams_url, tmpdir
):
"""
NotifyMSTeams() Templating - invalid JSON.
Test case where we're missing the @type part of the URL.
"""
template = tmpdir.join("missing_type.json")
template.write("""
{
"@context": "https://schema.org/extensions",
"summary": "{{app_id}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{app_title}}",
"text": "{{app_body}}"
}
]
}
""")
# Instantiate our URL
obj = Apprise.instantiate(
"{url}/?template={template}&{kwargs}".format(
url=msteams_url,
template=str(template),
kwargs=":key1=token&:key2=token",
)
)
assert isinstance(obj, NotifyMSTeams)
# We can not load the file because we're missing the @type entry
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is False
)
def test_plugin_msteams_templating_json_missing_context(
request_mock, msteams_url, tmpdir
):
"""
NotifyMSTeams() Templating - invalid JSON.
Test cases where we're missing the @context part of the URL.
"""
template = tmpdir.join("missing_context.json")
template.write("""
{
"@type": "MessageCard",
"summary": "{{app_id}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{app_title}}",
"text": "{{app_body}}"
}
]
}
""")
# Instantiate our URL
obj = Apprise.instantiate(
"{url}/?template={template}&{kwargs}".format(
url=msteams_url,
template=str(template),
kwargs=":key1=token&:key2=token",
)
)
assert isinstance(obj, NotifyMSTeams)
# We can not load the file because we're missing the @context entry
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is False
)
def test_plugin_msteams_templating_load_json_failure(
request_mock, msteams_url, tmpdir
):
"""
NotifyMSTeams() Templating - template loading failure.
Test a case where we can not access the file.
"""
template = tmpdir.join("empty.json")
template.write("")
obj = Apprise.instantiate(f"{msteams_url}/?template={template!s}")
with mock.patch("json.loads", side_effect=OSError):
# we fail, but this time it's because we couldn't
# access the cached file contents for reading
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is False
)
def test_plugin_msteams_templating_target_success(
request_mock, msteams_url, tmpdir
):
"""
NotifyMSTeams() Templating - success with target.
A more complicated example; uses a target.
"""
template = tmpdir.join("more_complicated_example.json")
template.write("""
{
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": "{{app_desc}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{app_title}}",
"text": "{{app_body}}"
}
],
"potentialAction": [{
"@type": "ActionCard",
"name": "Add a comment",
"inputs": [{
"@type": "TextInput",
"id": "comment",
"isMultiline": false,
"title": "Add a comment here for this task."
}],
"actions": [{
"@type": "HttpPOST",
"name": "Add Comment",
"target": "{{ target }}"
}]
}]
}
""")
# Instantiate our URL
obj = Apprise.instantiate(
"{url}/?template={template}&{kwargs}".format(
url=msteams_url,
template=str(template),
kwargs=":key1=token&:key2=token&:target=http://localhost",
)
)
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Apprise Notifications"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "title"
assert posted_json["sections"][0]["text"] == "body"
# We even parsed our entry out of the URL
assert (
posted_json["potentialAction"][0]["actions"][0]["target"]
== "http://localhost"
)
def test_msteams_yaml_config_invalid_template_filename(
request_mock, msteams_url, simple_template, tmpdir
):
"""
NotifyMSTeams() YAML Configuration Entries - invalid template filename.
"""
config = tmpdir.join("msteams01.yml")
config.write(f"""
urls:
- {msteams_url}:
- tag: 'msteams'
template: {simple_template!s}.missing
:name: 'Template.Missing'
:body: 'test body'
:title: 'test title'
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is False
)
assert request_mock.called is False
def test_msteams_yaml_config_token_identifiers(
request_mock, msteams_url, simple_template, tmpdir
):
"""
NotifyMSTeams() YAML Configuration Entries - test token identifiers.
"""
config = tmpdir.join("msteams01.yml")
config.write(f"""
urls:
- {msteams_url}:
- tag: 'msteams'
template: {simple_template!s}
:name: 'Testing'
:body: 'test body'
:title: 'test title'
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Testing"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "test title"
assert posted_json["sections"][0]["text"] == "test body"
def test_msteams_yaml_config_no_bullet_under_url_1(
request_mock, msteams_url, simple_template, tmpdir
):
"""
NotifyMSTeams() YAML Configuration Entries - no bullet 1.
Now again but without a bullet under the url definition.
"""
config = tmpdir.join("msteams02.yml")
config.write(f"""
urls:
- {msteams_url}:
tag: 'msteams'
template: {simple_template!s}
:name: 'Testing2'
:body: 'test body2'
:title: 'test title2'
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Testing2"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "test title2"
assert posted_json["sections"][0]["text"] == "test body2"
def test_msteams_yaml_config_dictionary_file(
request_mock, msteams_url, simple_template, tmpdir
):
"""NotifyMSTeams() YAML Configuration Entries.
Try again but store the content as a dictionary in the configuration file.
"""
config = tmpdir.join("msteams03.yml")
config.write(f"""
urls:
- {msteams_url}:
- tag: 'msteams'
template: {simple_template!s}
tokens:
name: 'Testing3'
body: 'test body3'
title: 'test title3'
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Testing3"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "test title3"
assert posted_json["sections"][0]["text"] == "test body3"
def test_msteams_yaml_config_no_bullet_under_url_2(
request_mock, msteams_url, simple_template, tmpdir
):
"""
NotifyMSTeams() YAML Configuration Entries - no bullet 2.
Now again but without a bullet under the url definition.
"""
config = tmpdir.join("msteams04.yml")
config.write(f"""
urls:
- {msteams_url}:
tag: 'msteams'
template: {simple_template!s}
tokens:
name: 'Testing4'
body: 'test body4'
title: 'test title4'
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Testing4"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "test title4"
assert posted_json["sections"][0]["text"] == "test body4"
def test_msteams_yaml_config_combined(
request_mock, msteams_url, simple_template, tmpdir
):
"""NotifyMSTeams() YAML Configuration Entries.
Now let's do a combination of the two.
"""
config = tmpdir.join("msteams05.yml")
config.write(f"""
urls:
- {msteams_url}:
- tag: 'msteams'
template: {simple_template!s}
tokens:
body: 'test body5'
title: 'test title5'
:name: 'Testing5'
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyMSTeams)
assert (
obj.notify(body="body", title="title", notify_type=NotifyType.INFO)
is True
)
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
"https://outlook.office.com/webhook/"
)
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]["data"])
assert "summary" in posted_json
assert posted_json["summary"] == "Testing5"
assert posted_json["themeColor"] == "#3AA3E3"
assert posted_json["sections"][0]["activityTitle"] == "test title5"
assert posted_json["sections"][0]["text"] == "test body5"
def test_msteams_yaml_config_token_mismatch(
request_mock, msteams_url, simple_template, tmpdir
):
"""NotifyMSTeams() YAML Configuration Entries.
Now let's do a test where our tokens is not the expected dictionary we want
to see.
"""
config = tmpdir.join("msteams06.yml")
config.write(f"""
urls:
- {msteams_url}:
- tag: 'msteams'
template: {simple_template!s}
# Not a dictionary
tokens:
body
""")
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
# It could not load because of invalid tokens
assert len(cfg[0]) == 0
def test_plugin_msteams_edge_cases():
"""NotifyMSTeams() Edge Cases."""
# Initializes the plugin with an invalid token
with pytest.raises(TypeError):
NotifyMSTeams(token_a=None, token_b="abcd", token_c="abcd")
# Whitespace also acts as an invalid token value
with pytest.raises(TypeError):
NotifyMSTeams(token_a=" ", token_b="abcd", token_c="abcd")
with pytest.raises(TypeError):
NotifyMSTeams(token_a="abcd", token_b=None, token_c="abcd")
# Whitespace also acts as an invalid token value
with pytest.raises(TypeError):
NotifyMSTeams(token_a="abcd", token_b=" ", token_c="abcd")
with pytest.raises(TypeError):
NotifyMSTeams(token_a="abcd", token_b="abcd", token_c=None)
# Whitespace also acts as an invalid token value
with pytest.raises(TypeError):
NotifyMSTeams(token_a="abcd", token_b="abcd", token_c=" ")
uuid4 = "8b799edf-6f98-4d3a-9be7-2862fb4e5752"
token_a = f"{uuid4}@{uuid4}"
token_b = "A" * 32
# test case where no tokens are specified
obj = NotifyMSTeams(token_a=token_a, token_b=token_b, token_c=uuid4)
assert isinstance(obj, NotifyMSTeams)