Merge branch 'master' of github.com:metallerok/Radicale into recurrence_all_day_comparsion

This commit is contained in:
Georgiy
2026-02-04 19:34:52 +03:00
78 changed files with 4807 additions and 974 deletions

View File

@@ -1,6 +1,7 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
# Copyright © 2024-2026 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
@@ -22,6 +23,8 @@ Tests for Radicale.
import base64
import logging
import os
import platform
import shutil
import sys
import tempfile
@@ -35,7 +38,7 @@ import defusedxml.ElementTree as DefusedET
import vobject
import radicale
from radicale import app, config, types, xmlutils
from radicale import app, config, types, utils, xmlutils
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]]
@@ -51,6 +54,11 @@ class BaseTest:
application: app.Application
def setup_method(self) -> None:
if os.environ.get("PYTHONPATH"):
info = "with PYTHONPATH=%r " % os.environ.get("PYTHONPATH")
else:
info = ""
logging.info("Testing Radicale %s(%s) as %s on %s", info, utils.packages_version(), utils.user_groups_as_string(), platform.platform())
self.configuration = config.load()
self.colpath = tempfile.mkdtemp()
self.configure({
@@ -75,6 +83,12 @@ class BaseTest:
if login is not None and not isinstance(login, str):
raise TypeError("login argument must be %r, not %r" %
(str, type(login)))
http_if_match = kwargs.pop("http_if_match", None)
if http_if_match is not None and not isinstance(http_if_match, str):
raise TypeError("http_if_match argument must be %r, not %r" %
(str, type(http_if_match)))
remote_useragent = kwargs.pop("remote_useragent", None)
remote_host = kwargs.pop("remote_host", None)
environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()}
for k, v in environ.items():
if not isinstance(v, str):
@@ -84,6 +98,12 @@ class BaseTest:
if login:
environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode(
login.encode(encoding)).decode()
if http_if_match:
environ["HTTP_IF_MATCH"] = http_if_match
if remote_useragent:
environ["HTTP_USER_AGENT"] = remote_useragent
if remote_host:
environ["REMOTE_ADDR"] = remote_host
environ["REQUEST_METHOD"] = method.upper()
environ["PATH_INFO"] = path
if data is not None:

View File

@@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.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
@@ -28,9 +29,9 @@ class Web(web.BaseWeb):
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse:
return client.OK, {"Content-Type": "text/plain"}, "custom"
return client.OK, {"Content-Type": "text/plain"}, "custom", None
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse:
content = httputils.read_request_body(self.configuration, environ)
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None

View File

@@ -0,0 +1,16 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
END:VCARD
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
UID:C68582D2-2E60-0001-C2C0-000000000001.vcf
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
END:VCARD

View File

@@ -0,0 +1,16 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
FN:Test Example
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
END:VCARD
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
END:VCARD

View File

@@ -0,0 +1,17 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
FN:Test Example
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
END:VCARD
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
UID:C68582D2-2E60-0001-C2C0-000000000001.vcf
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
END:VCARD

View File

@@ -0,0 +1,25 @@
BEGIN:VCALENDAR
PRODID:-//Radicale//NONSGML Radicale Server//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20160725T060147Z
LAST-MODIFIED:20160727T193435Z
DTSTAMP:20160727T193435Z
UID:040000008200E00074C5B7101A82E00800000000
SUMMARY:Good ICS
STATUS:CONFIRMED
X-MOZ-LASTACK:20160727T193435Z
DTSTART;TZID=Europe/Budapest:20160727T170000
DTEND;TZID=Europe/Budapest:20160727T223000
CLASS:PUBLIC
X-LIC-ERROR:No value for LOCATION property. Removing entire property:
END:VEVENT
BEGIN:VEVENT
CREATED:20160725T060147Z
LAST-MODIFIED:20160727T193435Z
DTSTAMP:20160727T193435Z
UID:040000008200E00074C5B7101A82E00800000001
CLASS:PUBLIC
X-LIC-ERROR:No value for LOCATION property. Removing entire property:
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,7 @@
BEGIN:VCARD
VERSION:4.0
UID:contact1
N:Contact;;;;
FN:Contact
NICKNAME:test
END:VCARD

View File

@@ -0,0 +1,12 @@
BEGIN:VCARD
VERSION:4.0
UID:contact1
N:Contact1;;;;
FN:Contact1
END:VCARD
BEGIN:VCARD
VERSION:4.0
UID:contact2
N:Contact2;;;;
FN:Contact2
END:VCARD

View File

@@ -0,0 +1,8 @@
BEGIN:VCARD
VERSION:4.0
UID:contact
N:Contact;;;;
FN:Contact
NICKNAME:test
PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC
END:VCARD

View File

@@ -0,0 +1,30 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
BEGIN:VTIMEZONE
TZID:Europe/Paris
LAST-MODIFIED:20250523T094234Z
BEGIN:STANDARD
DTSTART:19701025T030000Z
RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700329T020000Z
RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:070a3478-4411-4364-844d-26f3542fc364
DTSTART;TZID=Europe/Paris;VALUE=DATE:20250716
DTEND;TZID=Europe/Paris;VALUE=DATE:20250717
DTSTAMP;VALUE=DATE-TIME:20250723T080354Z
SEQUENCE:1
SUMMARY:Filtered event
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
BEGIN:VEVENT
CREATED:20250814T153429Z
LAST-MODIFIED:20250814T153503Z
DTSTAMP:20250814T153503Z
UID:f91964cb-53ca-4942-8811-c38f076f4328
SUMMARY:error
DTSTART:20250814T180000
DTEND;TZID=Europe/Brussels:20250814T190000
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
BEGIN:VEVENT
CREATED:20250814T153429Z
LAST-MODIFIED:20250814T153503Z
DTSTAMP:20250814T153503Z
UID:f91964cb-53ca-4942-8811-c38f076f4328
SUMMARY:error
DTSTART;TZID=Europe/Brussels:20250814T180000
DTEND:20250814T190000
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,29 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
BEGIN:VTIMEZONE
TZID:Europe/Paris
LAST-MODIFIED:20250523T094234Z
BEGIN:STANDARD
DTSTART:19701025T030000Z
RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700329T020000Z
RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:f5b69821-addc-4010-9ab8-891df1c33c01
DTSTART;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T093000
DTEND;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T140000
DTSTAMP:20250923T114003Z
SUMMARY:event from opencalendar
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,396 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
TZID:Europe/Paris
BEGIN:STANDARD
DTSTART:19110311T000000
RDATE:19110311T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+000921
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19161002T000000
RDATE:19161002T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19171008T000000
RDATE:19171008T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19181007T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1MO;UNTIL=19191006T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19201024T000000
RDATE:19201024T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19211026T000000
RDATE:19211026T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19221008T000000
RDATE:19221008T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19231007T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19331008T000000
RDATE:19331008T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19341007T000000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19391119T000000
RDATE:19391119T000000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+010000
TZOFFSETTO:+000000
END:STANDARD
BEGIN:STANDARD
DTSTART:19421102T030000
RDATE:19421102T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19431004T030000
RDATE:19431004T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19450916T030000
RDATE:19450916T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19760926T010000
RDATE:19760926T010000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19770925T030000
RDATE:19770925T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19781001T030000
RDATE:19781001T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19790930T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19961027T030000
RDATE:19961027T030000
TZNAME:Europe/Paris(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:STANDARD
DTSTART:19971026T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:(STD)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19160614T230000
RDATE:19160614T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19170324T230000
RDATE:19170324T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19180309T230000
RDATE:19180309T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19190301T230000
RDATE:19190301T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19200214T230000
RDATE:19200214T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19210314T230000
RDATE:19210314T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19220325T230000
RDATE:19220325T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19230526T230000
RDATE:19230526T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19240329T230000
RDATE:19240329T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19250404T230000
RDATE:19250404T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19260417T230000
RDATE:19260417T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19270409T230000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19290420T230000
RDATE:19290420T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19300412T230000
RDATE:19300412T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19310418T230000
RDATE:19310418T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19320402T230000
RDATE:19320402T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19330325T230000
RDATE:19330325T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19340407T230000
RDATE:19340407T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19350330T230000
RDATE:19350330T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19360418T230000
RDATE:19360418T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19370403T230000
RDATE:19370403T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19380326T230000
RDATE:19380326T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19390415T230000
RDATE:19390415T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19400225T020000
RDATE:19400225T020000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+000000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19400614T230000
RDATE:19400614T230000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19430329T020000
RDATE:19430329T020000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440403T020000
RDATE:19440403T020000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19440825T000000
RDATE:19440825T000000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19441008T010000
RDATE:19441008T010000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+020000
TZOFFSETTO:+010000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450402T020000
RDATE:19450402T020000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19760328T010000
RDATE:19760328T010000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19770403T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T020000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19960331T020000
TZNAME:Europe/Paris(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19970330T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:(DST)
TZOFFSETFROM:+010000
TZOFFSETTO:+020000
END:DAYLIGHT
X-TZINFO:Europe/Paris[2024a]
END:VTIMEZONE
BEGIN:VEVENT
UID:50c08af4-295c-4bea-9ea4-7402b8e82143
DTSTART;TZID=Europe/Paris:20250924T133000
DTEND;TZID=Europe/Paris:20250924T143000
CREATED:20250923T113902Z
DTSTAMP:20250923T113912Z
LAST-MODIFIED:20250923T113912Z
SUMMARY:event from thunderbird
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,38 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event1
SUMMARY:Event
CATEGORIES:some_category1,another_category2
DESCRIPTION:Line1
Line2
Line3
ORGANIZER:mailto:unclesam@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
DTSTART;TZID=Europe/Paris:20130901T180000
DTEND;TZID=Europe/Paris:20130901T190000
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,39 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event1
SUMMARY:Event having description with empty line
CATEGORIES:some_category1,another_category2
DESCRIPTION:Line1
Line2
Line4
ORGANIZER:mailto:unclesam@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
DTSTART;TZID=Europe/Paris:20130901T180000
DTEND;TZID=Europe/Paris:20130901T190000
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,40 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:event
SUMMARY:Event
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VTODO
UID:todo
DTSTART;TZID=Europe/Paris:20130901T220000
DURATION:PT1H
SUMMARY:Todo
END:VTODO
BEGIN:VEVENT
UID:event2
SUMMARY:Event-with-longer-description
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
END:VCALENDAR

View File

@@ -2,7 +2,7 @@
# Copyright © 2012-2016 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2026 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
@@ -30,7 +30,7 @@ from typing import Iterable, Tuple, Union
import pytest
from radicale import xmlutils
from radicale import utils, xmlutils
from radicale.tests import BaseTest
@@ -114,45 +114,60 @@ class TestBaseAuthRequests(BaseTest):
def test_htpasswd_sha256_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/")
def test_htpasswd_sha256_autodetect_with_rounds(self) -> None:
self._test_htpasswd("autodetect", "tmp:$5$rounds=2500$9QD/kpJlV71PCXWy$/AbUzxa6kjDWHJ8BLU1hyQUBN/8wsGEf.rNjuKDHA24")
def test_htpasswd_sha512(self) -> None:
self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
def test_htpasswd_sha512_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
def test_htpasswd_sha512_autodetect_with_rounds(self) -> None:
self._test_htpasswd("autodetect", "tmp:$6$rounds=2500$A1H/cZUl3CBnsplz$bSKYCDQ/YGR..YhxaZcM1eKmAi/jlnpbENKU8a.9kE95JBIpyUss3.cUyss0xQnhjD4PReN4sAzmdziWmoCsg/")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_2a(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed or incompatibe")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_2a_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_2b(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_2b_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_2y(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_2y_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_C10(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_C10_autodetect(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
def test_htpasswd_bcrypt_unicode(self) -> None:
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
@@ -263,6 +278,23 @@ class TestBaseAuthRequests(BaseTest):
href_element = prop.find(xmlutils.make_clark("D:href"))
assert href_element is not None and href_element.text == "/test/"
def test_http_remote_user(self) -> None:
self.configure({"auth": {"type": "http_remote_user"}})
_, responses = self.propfind("/", """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", HTTP_REMOTE_USER="test")
assert responses is not None
response = responses["/"]
assert not isinstance(response, int)
status, prop = response["D:current-user-principal"]
assert status == 200
href_element = prop.find(xmlutils.make_clark("D:href"))
assert href_element is not None and href_element.text == "/test/"
def test_http_x_remote_user(self) -> None:
self.configure({"auth": {"type": "http_x_remote_user"}})
_, responses = self.propfind("/", """\
@@ -282,13 +314,23 @@ class TestBaseAuthRequests(BaseTest):
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def _test_dovecot(
self, user, password, expected_status,
response=b'FAIL\n1\n', mech=[b'PLAIN'], broken=None):
self, user, password, expected_status, expected_rip=None,
response=b'FAIL\t1', mech=[b'PLAIN'], broken=None,
extra_config=None, extra_env=None):
import socket
from unittest.mock import DEFAULT, patch
self.configure({"auth": {"type": "dovecot",
"dovecot_socket": "./dovecot.sock"}})
if extra_env is None:
extra_env = {}
if extra_config is None:
extra_config = {}
config = {"auth": {"type": "dovecot",
"dovecot_socket": "./dovecot.sock"}}
for toplvl, entries in extra_config.items():
for key, val in entries.items():
config[toplvl][key] = val
self.configure(config)
if broken is None:
broken = []
@@ -311,10 +353,18 @@ class TestBaseAuthRequests(BaseTest):
if "done" not in broken:
handshake += b'DONE\n'
sent_rip = None
def record_sent_data(s, data, flags=None):
nonlocal sent_rip
if b'\trip=' in data:
sent_rip = data.split(b'\trip=')[1].split(b'\t')[0]
return len(data)
with patch.multiple(
'socket.socket',
connect=DEFAULT,
send=DEFAULT,
send=record_sent_data,
recv=DEFAULT
) as mock_socket:
if "socket" in broken:
@@ -325,7 +375,9 @@ class TestBaseAuthRequests(BaseTest):
status, _, answer = self.request(
"PROPFIND", "/",
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
("%s:%s" % (user, password)).encode()).decode())
("%s:%s" % (user, password)).encode()).decode(),
**extra_env)
assert sent_rip == expected_rip
assert status == expected_status
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
@@ -392,6 +444,36 @@ class TestBaseAuthRequests(BaseTest):
def test_dovecot_auth_id_mismatch(self):
self._test_dovecot("user", "password", 401, response=b'OK\t2')
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_remote_addr(self):
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15',
extra_env={
'REMOTE_ADDR': '172.17.16.15',
'HTTP_X_REMOTE_ADDR': '127.0.0.1',
})
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_x_remote_addr(self):
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15',
extra_env={
'REMOTE_ADDR': '127.0.0.1',
'HTTP_X_REMOTE_ADDR': '172.17.16.15',
},
extra_config={
'auth': {"remote_ip_source": "X-Remote-Addr"},
})
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_x_remote_addr_whitespace(self):
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15rip=127.0.0.1',
extra_env={
'REMOTE_ADDR': '127.0.0.1',
'HTTP_X_REMOTE_ADDR': '172.17.16.15\trip=127.0.0.1',
},
extra_config={
'auth': {"remote_ip_source": "X-Remote-Addr"},
})
def test_custom(self) -> None:
"""Custom authentication."""
self.configure({"auth": {"type": "radicale.tests.custom.auth"}})

View File

@@ -1,7 +1,7 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2026 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
@@ -24,12 +24,14 @@ Radicale tests with simple requests.
import logging
import os
import posixpath
import urllib
from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
import defusedxml.ElementTree as DefusedET
import pytest
import vobject
from radicale import storage, xmlutils
from radicale import storage, utils, xmlutils
from radicale.tests import RESPONSES, BaseTest
from radicale.tests.helpers import get_file_content
@@ -142,6 +144,64 @@ permissions: RrWw""")
assert "Event" in answer
assert "UID:event" in answer
def test_add_event_with_desc_ok(self) -> None:
"""Add an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event_issue1970_ok.ics")
path = "/calendar.ics/event_issue1970_ok.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 "DESCRIPTION" in answer
assert "VEVENT" in answer
assert "Event" in answer
assert "UID:event" in answer
def test_add_event_with_desc_problem(self) -> None:
"""Add an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event_issue1970_problem.ics")
path = "/calendar.ics/event_issue1970_problem.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 "DESCRIPTION" in answer
assert "VEVENT" in answer
assert "Event" in answer
assert "UID:event" in answer
def test_add_event_exceed_size(self) -> None:
"""Add an event which is exceeding max-resource-size."""
self.configure({"server": {"max_resource_size": 20}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=412)
def test_add_events_exceed_size(self) -> None:
"""Add multipe events where last is exceeding max-resource-size."""
self.configure({"server": {"max_resource_size": 603}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event_multiple3.ics")
path = "/calendar.ics/"
self.put(path, event, check=412)
def test_add_event_broken(self) -> None:
"""Add a broken event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("broken-vevent.ics")
path = "/calendar.ics/broken-vevent.ics"
self.put(path, event, check=400)
def test_add_events_broken2(self) -> None:
"""Add a broken event (2nd one is broken)."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("broken-vevents.ics")
path = "/calendar.ics/"
self.put(path, event, check=400)
def test_add_event_without_uid(self) -> None:
"""Add an event without UID."""
self.mkcalendar("/calendar.ics/")
@@ -201,6 +261,34 @@ permissions: RrWw""")
_, answer = self.get(path)
assert "UID:contact1" in answer
def test_add_contact_broken(self) -> None:
"""Add a broken contact."""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("broken-vcard.vcf")
path = "/contacts.vcf/broken-vcards.vcf"
self.put(path, contact, check=400)
def test_add_contacts_broken(self) -> None:
"""Add broken contacts."""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("broken-vcards.vcf")
path = "/contacts.vcf/"
self.put(path, contact, check=400)
def test_add_contacts_broken2(self) -> None:
"""Add broken contacts (only 2nd one is broken)."""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("broken-vcards2.vcf")
path = "/contacts.vcf/"
self.put(path, contact, check=400)
def test_add_contacts_broken2_no_uid(self) -> None:
"""Add broken contacts (only 2nd one is broken and has no UID)."""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("broken-vcards2-no_uid.vcf")
path = "/contacts.vcf/"
self.put(path, contact, check=400)
def test_add_contact_photo_with_data_uri(self) -> None:
"""Test workaround for broken PHOTO data from InfCloud"""
self.create_addressbook("/contacts.vcf/")
@@ -216,6 +304,48 @@ permissions: RrWw""")
path = "/contacts.vcf/contact.vcf"
self.put(path, contact, check=400)
def test_add_contact_v3(self) -> None:
"""Add a vCard 3.0 contact."""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("contact1.vcf")
path = "/contacts.vcf/contact.vcf"
self.put(path, contact)
_, headers, answer = self.request("GET", path, check=200)
assert "ETag" in headers
assert headers["Content-Type"] == "text/vcard; charset=utf-8"
assert "VCARD" in answer
assert "UID:contact1" in answer
assert "VERSION:3.0" in answer
@pytest.mark.skipif(not utils.vobject_supports_vcard4(),
reason="vobject < 1.0.0 does not support vCard 4.0")
def test_add_contact_v4(self) -> None:
"""Add a vCard 4.0 contact (requires vobject >= 1.0.0)."""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("contact1_v4.vcf")
path = "/contacts.vcf/contact.vcf"
self.put(path, contact)
_, headers, answer = self.request("GET", path, check=200)
assert "ETag" in headers
assert headers["Content-Type"] == "text/vcard; charset=utf-8"
assert "VCARD" in answer
assert "UID:contact1" in answer
assert "VERSION:4.0" in answer
def test_add_contact_photo_with_data_uri_v3(self) -> None:
"""Test vCard 3.0 PHOTO format"""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("contact_photo_with_data_uri.vcf")
self.put("/contacts.vcf/contact.vcf", contact)
@pytest.mark.skipif(not utils.vobject_supports_vcard4(),
reason="vobject < 1.0.0 does not support vCard 4.0")
def test_add_contact_photo_with_data_uri_v4(self) -> None:
"""Test vCard 4.0 PHOTO data URI format (requires vobject >= 1.0.0)"""
self.create_addressbook("/contacts.vcf/")
contact = get_file_content("contact_photo_with_data_uri_v4.vcf")
self.put("/contacts.vcf/contact.vcf", contact)
def test_update_event(self) -> None:
"""Update an event."""
self.mkcalendar("/calendar.ics/")
@@ -229,6 +359,56 @@ permissions: RrWw""")
_, answer = self.get(path)
assert "DTSTAMP:20130902T150159Z" in answer
def test_update_event_no_etag_strict_preconditions_true(self) -> None:
"""Update an event without serving etag having strict_preconditions enabled (Precondition Failed)."""
self.configure({"storage": {"strict_preconditions": True}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event_modified = get_file_content("event1_modified.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=201)
self.put(path, event_modified, check=412)
def test_update_event_with_etag_strict_preconditions_true(self) -> None:
"""Update an event with serving equal etag having strict_preconditions enabled (OK)."""
self.configure({"storage": {"strict_preconditions": True}})
self.configure({"logging": {"response_content_on_debug": True}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event_modified = get_file_content("event1_modified.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=201)
# get etag
_, responses = self.report("/calendar.ics/", """\
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop xmlns:D="DAV:">
<D:getetag/>
</D:prop>
</C:calendar-query>""")
assert len(responses) == 1
response = responses["/calendar.ics/event1.ics"]
assert not isinstance(response, int)
status, prop = response["D:getetag"]
assert status == 200 and prop.text
self.put(path, event_modified, check=204, http_if_match=prop.text)
def test_update_event_with_etag_mismatch(self) -> None:
"""Update an event with serving mismatch etag (Precondition Failed)."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event_modified = get_file_content("event1_modified.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=201)
self.put(path, event_modified, check=412, http_if_match="0000")
def test_add_event_with_etag(self) -> None:
"""Add an event with serving etag (Precondition Failed)."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=412, http_if_match="0000")
def test_update_event_uid_event(self) -> None:
"""Update an event with a different UID."""
self.mkcalendar("/calendar.ics/")
@@ -306,6 +486,22 @@ permissions: RrWw""")
for uid2 in uids[i + 1:]:
assert uid1 != uid2
def test_add_event_tz_dtend_only(self) -> None:
"""Add an event having TZ only on DTEND."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event_issue1847_1.ics")
path = "/calendar.ics/event_issue1847_1.ics"
self.put(path, event)
_, headers, answer = self.request("GET", path, check=200)
def test_add_event_tz_dtstart_only(self) -> None:
"""Add an event having TZ only on DTSTART."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event_issue1847_2.ics")
path = "/calendar.ics/event_issue1847_2.ics"
self.put(path, event)
_, headers, answer = self.request("GET", path, check=200)
def test_verify(self) -> None:
"""Verify the storage."""
contacts = get_file_content("contact_multiple.vcf")
@@ -401,6 +597,33 @@ permissions: RrWw""")
self.get(path1, check=404)
self.get(path2)
def test_move_between_collections_with_at_native(self) -> None:
"""Move a item."""
self.mkcalendar("/calendar1@domain.ics/")
self.mkcalendar("/calendar2@domain.ics/")
event = get_file_content("event1.ics")
path1 = "/calendar1@domain.ics/event1.ics"
path2 = "/calendar2@domain.ics/event2.ics"
self.put(path1, event)
self.request("MOVE", path1, check=201,
HTTP_DESTINATION="http://127.0.0.1/"+path2)
self.get(path1, check=404)
self.get(path2)
def test_move_between_collections_with_at_encoded(self) -> None:
"""Move a item."""
self.mkcalendar("/calendar1@domain.ics/")
self.mkcalendar("/calendar2@domain.ics/")
event = get_file_content("event1.ics")
path1 = "/calendar1@domain.ics/event1.ics"
path2 = "/calendar2@domain.ics/event2.ics"
path2_encoded = urllib.parse.quote(path2)
self.put(path1, event)
self.request("MOVE", path1, check=201,
HTTP_DESTINATION="http://127.0.0.1/"+path2_encoded)
self.get(path1, check=404)
self.get(path2)
def test_move_between_collections_duplicate_uid(self) -> None:
"""Move a item to a collection which already contains the UID."""
self.mkcalendar("/calendar1.ics/")
@@ -568,11 +791,13 @@ permissions: RrWw""")
assert not isinstance(response, int)
status, prop = response["D:sync-token"]
assert status == 200 and prop.text
assert "C:max-resource-size" not in response
_, responses = self.propfind("/calendar.ics/event.ics", propfind)
response = responses["/calendar.ics/event.ics"]
assert not isinstance(response, int)
status, prop = response["D:getetag"]
assert status == 200 and prop.text
assert "C:max-resource-size" not in response
def test_propfind_nonexistent(self) -> None:
"""Read a property that does not exist."""
@@ -584,6 +809,87 @@ permissions: RrWw""")
status, prop = response["ICAL:calendar-color"]
assert status == 404 and not prop.text
def test_propfind_max_resource_size(self) -> None:
"""Read property C:max-resource-size"""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
self.put("/calendar.ics/event.ics", event)
_, responses = self.propfind("/calendar.ics/", """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<prop>
<C:max-resource-size />
</prop>
</propfind>""")
response = responses["/calendar.ics/"]
assert not isinstance(response, int)
status, prop = response["C:max-resource-size"]
assert status == 200 and prop.text
def test_propfind_getctag(self) -> None:
"""Read property CS:getctag"""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
self.put("/calendar.ics/event.ics", event)
_, responses = self.propfind("/calendar.ics/", """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:CS="http://calendarserver.org/ns/">
<prop>
<CS:getctag />
</prop>
</propfind>""")
response = responses["/calendar.ics/"]
assert not isinstance(response, int)
status, prop = response["CS:getctag"]
assert status == 200 and prop.text
def test_propfind_supported_address_data(self) -> None:
"""Read property CR:supported-address-data on addressbook"""
self.create_addressbook("/addressbook.vcf/")
contact = get_file_content("contact1.vcf")
self.put("/addressbook.vcf/contact.vcf", contact)
_, responses = self.propfind("/addressbook.vcf/", """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
<prop>
<CR:supported-address-data />
</prop>
</propfind>""")
response = responses["/addressbook.vcf/"]
assert not isinstance(response, int)
status, prop = response["CR:supported-address-data"]
assert status == 200
# Should have at least one address-data-type element
address_data_types = prop.findall(
xmlutils.make_clark("CR:address-data-type"))
assert len(address_data_types) >= 1
# Check that 3.0 is always supported
versions = [e.get("version") for e in address_data_types]
assert "3.0" in versions
# Check content-type is text/vcard for all
for e in address_data_types:
assert e.get("content-type") == "text/vcard"
# If vobject >= 1.0.0, should also support 4.0
if utils.vobject_supports_vcard4():
assert "4.0" in versions
# vCard 4.0 should be listed first (preferred)
assert versions[0] == "4.0"
def test_propfind_supported_address_data_on_calendar(self) -> None:
"""Read property CR:supported-address-data on calendar (should 404)"""
self.mkcalendar("/calendar.ics/")
_, responses = self.propfind("/calendar.ics/", """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
<prop>
<CR:supported-address-data />
</prop>
</propfind>""")
response = responses["/calendar.ics/"]
assert not isinstance(response, int)
status, prop = response["CR:supported-address-data"]
assert status == 404
def test_proppatch(self) -> None:
"""Set/Remove a property and read it back."""
self.mkcalendar("/calendar.ics/")
@@ -1621,7 +1927,7 @@ permissions: RrWw""")
</C:free-busy-query>""", 400, is_xml=False)
def _report_sync_token(
self, calendar_path: str, sync_token: Optional[str] = None
self, calendar_path: str, sync_token: Optional[str] = None, **kwargs
) -> Tuple[str, RESPONSES]:
sync_token_xml = (
"<sync-token><![CDATA[%s]]></sync-token>" % sync_token
@@ -1633,7 +1939,7 @@ permissions: RrWw""")
<getetag />
</prop>
%s
</sync-collection>""" % sync_token_xml)
</sync-collection>""" % sync_token_xml, **kwargs)
xml = DefusedET.fromstring(answer)
if status in (403, 409):
assert xml.tag == xmlutils.make_clark("D:error")
@@ -1781,6 +2087,15 @@ permissions: RrWw""")
calendar_path, "http://radicale.org/ns/sync/INVALID")
assert not sync_token
def test_report_sync_collection_invalid_sync_token_with_user(self) -> None:
"""Test sync-collection report with an invalid sync token and user+host+useragent"""
self.configure({"auth": {"type": "none"}})
calendar_path = "/calendar.ics/"
self.mkcalendar(calendar_path)
sync_token, _ = self._report_sync_token(
calendar_path, "http://radicale.org/ns/sync/INVALID", login="testuser:", remote_host="192.0.2.1", remote_useragent="Testclient/1.0")
assert not sync_token
def test_propfind_sync_token(self) -> None:
"""Retrieve the sync-token with a propfind request"""
calendar_path = "/calendar.ics/"

View File

@@ -3,6 +3,7 @@
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
# Copyright © 2025 David Greaves <david@dgreaves.com>
# Copyright © 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
@@ -513,6 +514,191 @@ permissions: RrWw""")
assert event2_calendar_data.text
assert "UID:c6be8b2c-3d72-453c-b698-4f25cdf1569e" in event2_calendar_data.text
def test_report_getetag_expand_filter(self) -> None:
"""Test getetag with time-range filter and expand (example from #1880)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1880_1.ics", get_file_content("event_issue1880_1.ics"))
self.put("/test/event_issue1880_2.ics", get_file_content("event_issue1880_2.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag>
<C:expand start="20250921T220000Z" end="20250928T220000Z"/>
</D:getetag>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20250921T220000Z" end="20250928T220000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 2
assert isinstance(responses["/test/event_issue1880_1.ics"], dict)
assert isinstance(responses["/test/event_issue1880_2.ics"], dict)
assert "D:getetag" in responses["/test/event_issue1880_1.ics"]
assert "D:getetag" in responses["/test/event_issue1880_2.ics"]
def test_report_getetag_expand_filter_positive1(self) -> None:
"""Test getetag with time-range filter and expand (not applicable), should return as matching filter range (example from #1812)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag>
<C:expand start="20250706T220000Z" end="20250713T220000Z" />
</D:getetag>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20250716T220000Z" end="20250717T220000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 1
assert isinstance(responses["/test/event_issue1812_getetag.ics"], dict)
assert "D:getetag" in responses["/test/event_issue1812_getetag.ics"]
def test_report_getetag_expand_filter_positive2(self) -> None:
"""Test getetag with time-range filter and expand, should return as matching filter range (example from #1812)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1812.ics", get_file_content("event_issue1812.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag>
<C:expand start="20250706T220000Z" end="20250730T220000Z" />
</D:getetag>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20250716T220000Z" end="20250723T220000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 1
assert isinstance(responses["/test/event_issue1812.ics"], dict)
assert "D:getetag" in responses["/test/event_issue1812.ics"]
def test_report_getetag_expand_filter_negative1(self) -> None:
"""Test getetag with time-range filter and expand, should not return anything (example from #1812)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag>
<C:expand start="20250706T220000Z" end="20250713T220000Z" />
</D:getetag>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20250706T220000Z" end="20250713T220000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 0
def test_report_getetag_expand_filter_negative2(self) -> None:
"""Test getetag with time-range filter and expand, should not return anything (example from #1812)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag />
<C:calendar-data>
<C:expand start="20240706T220000Z" end="20240713T220000Z" />
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20250706T220000Z" end="20250713T220000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 0
def test_report_getetag_expand_filter_negative3(self) -> None:
"""Test getetag with time-range filter and expand, should not return anything (example from #1812)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="20240706T220000Z" end="20240713T220000Z" />
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20250706T220000Z" end="20250713T220000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 0
def test_report_getetag_expand_filter_negative4(self) -> None:
"""Test getetag with time-range filter and expand, nothing returned as filter is not matching (example from #1812)."""
self.mkcalendar("/test/")
self.put("/test/event_issue1812.ics", get_file_content("event_issue1812.ics"))
request = """
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag>
<C:expand start="20250706T220000Z" end="20250730T220000Z" />
</D:getetag>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20240716T220000Z" end="20240723T220000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
status, responses = self.report("/test", request)
assert status == 207
assert len(responses) == 0
def test_report_with_expand_property_all_day_event_overridden(self) -> None:
self._test_expand(
"event_full_day_rrule_overridden",

View File

@@ -21,6 +21,8 @@ Radicale tests related to hook 'email'
import logging
import os
import re
from datetime import datetime, timedelta
from radicale.tests import BaseTest
from radicale.tests.helpers import get_file_content
@@ -63,11 +65,26 @@ permissions: RrWw""")
self.configure({"hook": {"type": "email",
"dryrun": "True"}})
def test_add_event(self, caplog) -> None:
def _future_date_timestamp(self) -> str:
"""Return a date timestamp for a future date."""
future_date = datetime.now() + timedelta(days=1)
return future_date.strftime("%Y%m%dT%H%M%S")
def _past_date_timestamp(self) -> str:
past_date = datetime.now() - timedelta(days=1)
return past_date.strftime("%Y%m%dT%H%M%S")
def _replace_end_date_in_event(self, event: str, new_date: str) -> str:
"""Replace the end date in an event string."""
return re.sub(r"DTEND;TZID=Europe/Paris:\d{8}T\d{6}",
f"DTEND;TZID=Europe/Paris:{new_date}", event)
def test_add_event_with_future_end_date(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Add an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event = self._replace_end_date_in_event(event, self._future_date_timestamp())
path = "/calendar.ics/event1.ics"
self.put(path, event)
_, headers, answer = self.request("GET", path, check=200)
@@ -76,35 +93,68 @@ permissions: RrWw""")
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:
logs = caplog.messages
# Should have a log saying the notification item was received
assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1
# Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun)
assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0
def test_add_event_with_past_end_date(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Add an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event = self._replace_end_date_in_event(event, self._past_date_timestamp())
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
logs = caplog.messages
# Should have a log saying the notification item was received
assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1
# Should have a log saying that no email is sent due to past end date
assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1
def test_delete_event_with_future_end_date(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Delete an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event = self._replace_end_date_in_event(event, self._future_date_timestamp())
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)
logs = caplog.messages
# Should have a log saying the notification item was received
assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1
# Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun)
assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0
def test_delete_event_with_past_end_date(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Delete an event."""
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event = self._replace_end_date_in_event(event, self._past_date_timestamp())
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
logs = caplog.messages
# Should have a log saying the notification item was received
assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1
# Should have a log saying that no email is sent due to past end date
assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1

View File

@@ -22,6 +22,8 @@ Radicale tests related to hook 'rabbitmq'
import logging
import os
import pytest
from radicale.tests import BaseTest
from radicale.tests.helpers import get_file_content
@@ -29,6 +31,14 @@ from radicale.tests.helpers import get_file_content
class TestHooks(BaseTest):
"""Tests with hooks."""
# test for available pika module
try:
import pika
except ImportError:
has_pika = 0
else:
has_pika = 1
def setup_method(self) -> None:
BaseTest.setup_method(self)
rights_file_path = os.path.join(self.colpath, "rights")
@@ -63,6 +73,7 @@ permissions: RrWw""")
self.configure({"hook": {"type": "rabbitmq",
"dryrun": "True"}})
@pytest.mark.skipif(has_pika == 0, reason="No pika module installed")
def test_add_event(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Add an event."""
@@ -83,6 +94,7 @@ permissions: RrWw""")
if (found is False):
raise ValueError("Logging misses expected log line")
@pytest.mark.skipif(has_pika == 0, reason="No pika module installed")
def test_delete_event(self, caplog) -> None:
caplog.set_level(logging.WARNING)
"""Delete an event."""

View File

@@ -0,0 +1,91 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2025 Tobias Brox <tobias@tobix.eu>
#
# 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/>.
"""
Tests for pathutils module.
"""
import gc
import os
import tempfile
import pytest
from radicale import pathutils
class TestPathToFilesystem:
"""Tests for path_to_filesystem function."""
@pytest.mark.filterwarnings("error::ResourceWarning")
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
def test_scandir_iterator_closed(self) -> None:
"""Verify that os.scandir iterator is properly closed.
This test catches ResourceWarning: unclosed scandir iterator
which occurs when os.scandir() is used without a context manager.
See: https://github.com/Kozea/Radicale/issues/1972
The ResourceWarning is emitted during garbage collection when an
unclosed scandir iterator is finalized. We use pytest.mark.filterwarnings
to convert both ResourceWarning and PytestUnraisableExceptionWarning
to errors.
"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a subdirectory so path_to_filesystem has something
# to scan (the scandir check is for case-insensitive filesystems)
subdir = os.path.join(tmpdir, "testdir")
os.makedirs(subdir)
# Call path_to_filesystem - if scandir iterator is not closed,
# a ResourceWarning will be emitted during garbage collection
result = pathutils.path_to_filesystem(tmpdir, "testdir")
assert result == subdir
# Force garbage collection to trigger any ResourceWarning
# from unclosed iterators
gc.collect()
def test_path_to_filesystem_basic(self) -> None:
"""Test basic path_to_filesystem functionality."""
with tempfile.TemporaryDirectory() as tmpdir:
# Test empty path
result = pathutils.path_to_filesystem(tmpdir, "")
assert result == tmpdir
# Test single component
subdir = os.path.join(tmpdir, "test")
os.makedirs(subdir)
result = pathutils.path_to_filesystem(tmpdir, "test")
assert result == subdir
# Test nested path
nested = os.path.join(subdir, "nested")
os.makedirs(nested)
result = pathutils.path_to_filesystem(tmpdir, "test/nested")
assert result == nested
def test_unsafe_path_raises(self) -> None:
"""Test that unsafe path components raise UnsafePathError."""
with tempfile.TemporaryDirectory() as tmpdir:
# Hidden files (starting with .) are not safe
with pytest.raises(pathutils.UnsafePathError):
pathutils.path_to_filesystem(tmpdir, ".hidden")
# Backup files (ending with ~) are not safe
with pytest.raises(pathutils.UnsafePathError):
pathutils.path_to_filesystem(tmpdir, "backup~")