1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-08-07 18:30:54 +00:00

Merge pull request #1835 from pbiering/test-hooks

Test hooks for rabbitmq+email
This commit is contained in:
Peter Bieringer 2025-07-22 21:06:48 +02:00 committed by GitHub
commit d92e06e850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 289 additions and 2 deletions

View file

@ -12,6 +12,7 @@
* Fix: catch case where getpwuid is not returning a username
* Fix: add support for query without comp-type
* Fix: expanded event with dates are missing VALUE=DATE
* Add: [hook] dryrun: option to disable real hook action for testing, add tests for email+rabbitmq
## 3.5.4
* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)

View file

@ -1529,6 +1529,14 @@ Available types:
Default: `none`
##### dryrun
_(> 3.5.4)_
Dry-Run (do not really trigger hook action)
Default: `False`
##### rabbitmq_endpoint
_(>= 3.2.0)_

7
config
View file

@ -313,9 +313,16 @@
# Hook types
# Value: none | rabbitmq | email
#type = none
# dry-run (do not really trigger hook action)
#dryrun = False
# hook: rabbitmq
#rabbitmq_endpoint =
#rabbitmq_topic =
#rabbitmq_queue_type = classic
# hook: email
#smtp_server = localhost
#smtp_port = 25
#smtp_security = starttls

View file

@ -427,6 +427,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"help": "hook backend",
"type": str,
"internal": hook.INTERNAL_TYPES}),
("dryrun", {
"value": "False",
"help": "dry-run (do not really trigger hook action)",
"type": bool}),
("rabbitmq_endpoint", {
"value": "",
"help": "endpoint where rabbitmq server is running",

View file

@ -1,6 +1,20 @@
# This file is related to Radicale - CalDAV and CardDAV server
# for email notifications
# Copyright © 2025-2025 Nate Harris
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import enum
import re
import smtplib
@ -596,6 +610,7 @@ class EmailConfig:
password: str,
from_email: str,
send_mass_emails: bool,
dryrun: bool,
added_template: MessageTemplate,
removed_template: MessageTemplate):
self.host = host
@ -606,6 +621,7 @@ class EmailConfig:
self.password = password
self.from_email = from_email
self.send_mass_emails = send_mass_emails
self.dryrun = dryrun
self.added_template = added_template
self.removed_template = removed_template
self.updated_template = added_template # Reuse added template for updated events
@ -616,7 +632,7 @@ class EmailConfig:
Return a string representation of the EmailConfig.
"""
return f"EmailConfig(host={self.host}, port={self.port}, username={self.username}, " \
f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails})"
f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails}, dryrun={self.dryrun})"
def __repr__(self):
return self.__str__()
@ -733,6 +749,10 @@ class EmailConfig:
logger.warning("No valid email addresses found in attendees. Cannot send email.")
return False
if self.dryrun is True:
logger.warning("Hook 'email': DRY-RUN _send_email / to_addresses=%r", to_addresses)
return True
# Add headers
message = MIMEMultipart("mixed")
message["From"] = self.from_email
@ -805,6 +825,7 @@ def _read_event(vobject_data: str) -> EmailEvent:
class Hook(BaseHook):
def __init__(self, configuration):
super().__init__(configuration)
self.dryrun = self.configuration.get("hook", "dryrun")
self.email_config = EmailConfig(
host=self.configuration.get("hook", "smtp_server"),
port=self.configuration.get("hook", "smtp_port"),
@ -814,6 +835,7 @@ class Hook(BaseHook):
password=self.configuration.get("hook", "smtp_password"),
from_email=self.configuration.get("hook", "from_email"),
send_mass_emails=self.configuration.get("hook", "mass_email"),
dryrun=self.configuration.get("hook", "dryrun"),
added_template=MessageTemplate(
subject="You have been added to an event",
body=self.configuration.get("hook", "added_template")
@ -844,7 +866,10 @@ class Hook(BaseHook):
:type notification_item: HookNotificationItem
:return: None
"""
logger.debug("Received notification item: %s", notification_item)
if self.dryrun:
logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item))
else:
logger.debug("Received notification_item: %r", vars(notification_item))
try:
notification_type = HookNotificationItemTypes(value=notification_item.type)
except ValueError:

View file

@ -1,3 +1,20 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2020-2024 Tuna Celik <tuna@jakpark.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import pika
from pika.exceptions import ChannelWrongStateError, StreamLostError
@ -14,16 +31,26 @@ class Hook(hook.BaseHook):
self._topic = configuration.get("hook", "rabbitmq_topic")
self._queue_type = configuration.get("hook", "rabbitmq_queue_type")
self._encoding = configuration.get("encoding", "stock")
self._dryrun = configuration.get("hook", "dryrun")
logger.info("Hook 'rabbitmq': endpoint=%r topic=%r queue_type=%r dryrun=%s", self._endpoint, self._topic, self._queue_type, self._dryrun)
self._make_connection_synced()
self._make_declare_queue_synced()
def _make_connection_synced(self):
parameters = pika.URLParameters(self._endpoint)
if self._dryrun is True:
logger.warning("Hook 'rabbitmq': DRY-RUN _make_connection_synced / parameters=%r", parameters)
return
logger.debug("Hook 'rabbitmq': _make_connection_synced / parameters=%r", parameters)
connection = pika.BlockingConnection(parameters)
self._channel = connection.channel()
def _make_declare_queue_synced(self):
if self._dryrun is True:
logger.warning("Hook 'rabbitmq': DRY-RUN _make_declare_queue_synced")
return
logger.debug("Hook 'rabbitmq': _make_declare_queue_synced")
self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type})
def notify(self, notification_item):
@ -31,6 +58,9 @@ class Hook(hook.BaseHook):
self._notify(notification_item, True)
def _notify(self, notification_item, recall):
if self._dryrun is True:
logger.warning("Hook 'rabbitmq': DRY-RUN _notify / notification_item: %r", vars(notification_item))
return
try:
self._channel.basic_publish(
exchange='',

View file

@ -0,0 +1,110 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale tests related to hook 'email'
"""
import logging
import os
from radicale.tests import BaseTest
from radicale.tests.helpers import get_file_content
class TestHooks(BaseTest):
"""Tests with hooks."""
def setup_method(self) -> None:
BaseTest.setup_method(self)
rights_file_path = os.path.join(self.colpath, "rights")
with open(rights_file_path, "w") as f:
f.write("""\
[permit delete collection]
user: .*
collection: test-permit-delete
permissions: RrWwD
[forbid delete collection]
user: .*
collection: test-forbid-delete
permissions: RrWwd
[permit overwrite collection]
user: .*
collection: test-permit-overwrite
permissions: RrWwO
[forbid overwrite collection]
user: .*
collection: test-forbid-overwrite
permissions: RrWwo
[allow all]
user: .*
collection: .*
permissions: RrWw""")
self.configure({"rights": {"file": rights_file_path,
"type": "from_file"}})
self.configure({"hook": {"type": "email",
"dryrun": "True"}})
def test_add_event(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Add an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event)
_, headers, answer = self.request("GET", path, check=200)
assert "ETag" in headers
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
assert "VEVENT" in answer
assert "Event" in answer
assert "UID:event" in answer
found = 0
for line in caplog.messages:
if line.find("notification_item: {'type': 'upsert'") != -1:
found = found | 1
if line.find("to_addresses=['janedoe@example.com']") != -1:
found = found | 2
if line.find("to_addresses=['johndoe@example.com']") != -1:
found = found | 4
if (found != 7):
raise ValueError("Logging misses expected log lines, found=%d", found)
def test_delete_event(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Delete an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event)
_, responses = self.delete(path)
assert responses[path] == 200
_, answer = self.get("/calendar.ics/")
assert "VEVENT" not in answer
found = 0
for line in caplog.messages:
if line.find("notification_item: {'type': 'delete'") != -1:
found = found | 1
if line.find("to_addresses=['janedoe@example.com']") != -1:
found = found | 2
if line.find("to_addresses=['johndoe@example.com']") != -1:
found = found | 4
if (found != 7):
raise ValueError("Logging misses expected log lines, found=%d", found)

View file

@ -0,0 +1,102 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale tests related to hook 'rabbitmq'
"""
import logging
import os
from radicale.tests import BaseTest
from radicale.tests.helpers import get_file_content
class TestHooks(BaseTest):
"""Tests with hooks."""
def setup_method(self) -> None:
BaseTest.setup_method(self)
rights_file_path = os.path.join(self.colpath, "rights")
with open(rights_file_path, "w") as f:
f.write("""\
[permit delete collection]
user: .*
collection: test-permit-delete
permissions: RrWwD
[forbid delete collection]
user: .*
collection: test-forbid-delete
permissions: RrWwd
[permit overwrite collection]
user: .*
collection: test-permit-overwrite
permissions: RrWwO
[forbid overwrite collection]
user: .*
collection: test-forbid-overwrite
permissions: RrWwo
[allow all]
user: .*
collection: .*
permissions: RrWw""")
self.configure({"rights": {"file": rights_file_path,
"type": "from_file"}})
self.configure({"hook": {"type": "rabbitmq",
"dryrun": "True"}})
def test_add_event(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Add an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event)
_, headers, answer = self.request("GET", path, check=200)
assert "ETag" in headers
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
assert "VEVENT" in answer
assert "Event" in answer
assert "UID:event" in answer
found = False
for line in caplog.messages:
if line.find("notification_item: {'type': 'upsert'") != -1:
found = True
if (found is False):
raise ValueError("Logging misses expected log line")
def test_delete_event(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Delete an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event)
_, responses = self.delete(path)
assert responses[path] == 200
_, answer = self.get("/calendar.ics/")
assert "VEVENT" not in answer
found = False
for line in caplog.messages:
if line.find("notification_item: {'type': 'delete'") != -1:
found = True
if (found is False):
raise ValueError("Logging misses expected log line")