Merge branch 'master' of github.com:metallerok/Radicale into recurrence_all_day_comparsion
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
16
radicale/tests/static/broken-vcards.vcf
Normal file
16
radicale/tests/static/broken-vcards.vcf
Normal 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
|
||||
16
radicale/tests/static/broken-vcards2-no_uid.vcf
Normal file
16
radicale/tests/static/broken-vcards2-no_uid.vcf
Normal 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
|
||||
17
radicale/tests/static/broken-vcards2.vcf
Normal file
17
radicale/tests/static/broken-vcards2.vcf
Normal 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
|
||||
25
radicale/tests/static/broken-vevents.ics
Normal file
25
radicale/tests/static/broken-vevents.ics
Normal 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
|
||||
7
radicale/tests/static/contact1_v4.vcf
Normal file
7
radicale/tests/static/contact1_v4.vcf
Normal file
@@ -0,0 +1,7 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:contact1
|
||||
N:Contact;;;;
|
||||
FN:Contact
|
||||
NICKNAME:test
|
||||
END:VCARD
|
||||
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
12
radicale/tests/static/contact_multiple_v4.vcf
Normal 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
|
||||
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal 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
|
||||
30
radicale/tests/static/event_issue1812_getetag.ics
Normal file
30
radicale/tests/static/event_issue1812_getetag.ics
Normal 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
|
||||
14
radicale/tests/static/event_issue1847_1.ics
Normal file
14
radicale/tests/static/event_issue1847_1.ics
Normal 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
|
||||
14
radicale/tests/static/event_issue1847_2.ics
Normal file
14
radicale/tests/static/event_issue1847_2.ics
Normal 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
|
||||
29
radicale/tests/static/event_issue1880_1.ics
Normal file
29
radicale/tests/static/event_issue1880_1.ics
Normal 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
|
||||
396
radicale/tests/static/event_issue1880_2.ics
Normal file
396
radicale/tests/static/event_issue1880_2.ics
Normal 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
|
||||
38
radicale/tests/static/event_issue1970_ok.ics
Normal file
38
radicale/tests/static/event_issue1970_ok.ics
Normal 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
|
||||
39
radicale/tests/static/event_issue1970_problem.ics
Normal file
39
radicale/tests/static/event_issue1970_problem.ics
Normal 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
|
||||
40
radicale/tests/static/event_multiple3.ics
Normal file
40
radicale/tests/static/event_multiple3.ics
Normal 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
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
91
radicale/tests/test_pathutils.py
Normal file
91
radicale/tests/test_pathutils.py
Normal 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~")
|
||||
Reference in New Issue
Block a user