test_expand.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2012-2017 Guillaume Ayoub
  3. # Copyright © 2017-2019 Unrud <unrud@outlook.com>
  4. # Copyright © 2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
  5. # Copyright © 2025 David Greaves <david@dgreaves.com>
  6. #
  7. # This library is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  19. """
  20. Radicale tests with expand requests.
  21. """
  22. import os
  23. from typing import ClassVar, List, Optional
  24. from xml.etree import ElementTree
  25. from radicale.log import logger
  26. from radicale.tests import BaseTest
  27. from radicale.tests.helpers import get_file_content
  28. ONLY_DATES = True
  29. CONTAINS_TIMES = False
  30. class TestExpandRequests(BaseTest):
  31. """Tests with expand requests."""
  32. # Allow skipping sync-token tests, when not fully supported by the backend
  33. full_sync_token_support: ClassVar[bool] = True
  34. def setup_method(self) -> None:
  35. BaseTest.setup_method(self)
  36. rights_file_path = os.path.join(self.colpath, "rights")
  37. with open(rights_file_path, "w") as f:
  38. f.write("""\
  39. [permit delete collection]
  40. user: .*
  41. collection: test-permit-delete
  42. permissions: RrWwD
  43. [forbid delete collection]
  44. user: .*
  45. collection: test-forbid-delete
  46. permissions: RrWwd
  47. [permit overwrite collection]
  48. user: .*
  49. collection: test-permit-overwrite
  50. permissions: RrWwO
  51. [forbid overwrite collection]
  52. user: .*
  53. collection: test-forbid-overwrite
  54. permissions: RrWwo
  55. [allow all]
  56. user: .*
  57. collection: .*
  58. permissions: RrWw""")
  59. self.configure({"rights": {"file": rights_file_path,
  60. "type": "from_file"}})
  61. def _req_without_expand(self,
  62. expected_uid: str,
  63. start: str,
  64. end: str,
  65. ) -> str:
  66. self.put("/calendar.ics/", get_file_content(f"{expected_uid}.ics"))
  67. return \
  68. f"""<?xml version="1.0" encoding="utf-8" ?>
  69. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  70. <D:prop>
  71. <C:calendar-data>
  72. </C:calendar-data>
  73. </D:prop>
  74. <C:filter>
  75. <C:comp-filter name="VCALENDAR">
  76. <C:comp-filter name="VEVENT">
  77. <C:time-range start="{start}" end="{end}"/>
  78. </C:comp-filter>
  79. </C:comp-filter>
  80. </C:filter>
  81. </C:calendar-query>
  82. """
  83. def _req_with_expand(self,
  84. expected_uid: str,
  85. start: str,
  86. end: str,
  87. ) -> str:
  88. self.put("/calendar.ics/", get_file_content(f"{expected_uid}.ics"))
  89. return \
  90. f"""<?xml version="1.0" encoding="utf-8" ?>
  91. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  92. <D:prop>
  93. <C:calendar-data>
  94. <C:expand start="{start}" end="{end}"/>
  95. </C:calendar-data>
  96. </D:prop>
  97. <C:filter>
  98. <C:comp-filter name="VCALENDAR">
  99. <C:comp-filter name="VEVENT">
  100. <C:time-range start="{start}" end="{end}"/>
  101. </C:comp-filter>
  102. </C:comp-filter>
  103. </C:filter>
  104. </C:calendar-query>
  105. """
  106. def _test_expand(self,
  107. expected_uid: str,
  108. start: str,
  109. end: str,
  110. expected_recurrence_ids: List[str],
  111. expected_start_times: List[str],
  112. expected_end_times: List[str],
  113. only_dates: bool,
  114. nr_uids: int) -> None:
  115. _, responses = self.report("/calendar.ics/",
  116. self._req_without_expand(expected_uid, start, end))
  117. assert len(responses) == 1
  118. response_without_expand = responses[f'/calendar.ics/{expected_uid}.ics']
  119. assert isinstance(response_without_expand, dict)
  120. status, element = response_without_expand["C:calendar-data"]
  121. assert status == 200 and element.text
  122. assert "RRULE" in element.text
  123. if not only_dates:
  124. assert "BEGIN:VTIMEZONE" in element.text
  125. if nr_uids == 1:
  126. assert "RECURRENCE-ID" not in element.text
  127. uids: List[str] = []
  128. for line in element.text.split("\n"):
  129. if line.startswith("UID:"):
  130. uid = line[len("UID:"):]
  131. assert uid == expected_uid
  132. uids.append(uid)
  133. assert len(uids) == nr_uids
  134. _, responses = self.report("/calendar.ics/",
  135. self._req_with_expand(expected_uid, start, end))
  136. assert len(responses) == 1
  137. response_with_expand = responses[f'/calendar.ics/{expected_uid}.ics']
  138. assert isinstance(response_with_expand, dict)
  139. status, element = response_with_expand["C:calendar-data"]
  140. logger.debug("lbt: element is %s",
  141. ElementTree.tostring(element, encoding='unicode'))
  142. assert status == 200 and element.text
  143. assert "RRULE" not in element.text
  144. assert "BEGIN:VTIMEZONE" not in element.text
  145. uids = []
  146. recurrence_ids = []
  147. for line in element.text.split("\n"):
  148. if line.startswith("UID:"):
  149. assert line == f"UID:{expected_uid}"
  150. uids.append(line)
  151. if line.startswith("RECURRENCE-ID:"):
  152. assert line in expected_recurrence_ids
  153. recurrence_ids.append(line)
  154. if line.startswith("DTSTART:"):
  155. assert line in expected_start_times
  156. if line.startswith("DTEND:"):
  157. assert line in expected_end_times
  158. assert len(uids) == len(expected_recurrence_ids)
  159. assert len(set(recurrence_ids)) == len(expected_recurrence_ids)
  160. def _test_expand_max(self,
  161. expected_uid: str,
  162. start: str,
  163. end: str,
  164. check: Optional[int] = None) -> None:
  165. _, responses = self.report("/calendar.ics/",
  166. self._req_without_expand(expected_uid, start, end))
  167. assert len(responses) == 1
  168. response_without_expand = responses[f'/calendar.ics/{expected_uid}.ics']
  169. assert isinstance(response_without_expand, dict)
  170. status, element = response_without_expand["C:calendar-data"]
  171. assert status == 200 and element.text
  172. assert "RRULE" in element.text
  173. status, _, _ = self.request(
  174. "REPORT", "/calendar.ics/",
  175. self._req_with_expand(expected_uid, start, end),
  176. check=check)
  177. assert status == 400
  178. def test_report_with_expand_property(self) -> None:
  179. """Test report with expand property"""
  180. self._test_expand(
  181. "event_daily_rrule",
  182. "20060103T000000Z",
  183. "20060105T000000Z",
  184. ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"],
  185. ["DTSTART:20060103T170000Z", "DTSTART:20060104T170000Z"],
  186. [],
  187. CONTAINS_TIMES,
  188. 1
  189. )
  190. def test_report_with_expand_property_start_inside(self) -> None:
  191. """Test report with expand property start inside"""
  192. self._test_expand(
  193. "event_daily_rrule",
  194. "20060103T171500Z",
  195. "20060105T000000Z",
  196. ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"],
  197. ["DTSTART:20060103T170000Z", "DTSTART:20060104T170000Z"],
  198. [],
  199. CONTAINS_TIMES,
  200. 1
  201. )
  202. def test_report_with_expand_property_just_inside(self) -> None:
  203. """Test report with expand property start and end inside event"""
  204. self._test_expand(
  205. "event_daily_rrule",
  206. "20060103T171500Z",
  207. "20060103T171501Z",
  208. ["RECURRENCE-ID:20060103T170000Z"],
  209. ["DTSTART:20060103T170000Z"],
  210. [],
  211. CONTAINS_TIMES,
  212. 1
  213. )
  214. def test_report_with_expand_property_issue1812(self) -> None:
  215. """Test report with expand property for issue 1812"""
  216. self._test_expand(
  217. "event_issue1812",
  218. "20250127T183000Z",
  219. "20250127T183001Z",
  220. ["RECURRENCE-ID:20250127T180000Z"],
  221. ["DTSTART:20250127T180000Z"],
  222. ["DTEND:20250127T233000Z"],
  223. CONTAINS_TIMES,
  224. 11
  225. )
  226. def test_report_with_expand_property_issue1812_DS(self) -> None:
  227. """Test report with expand property for issue 1812 - DS active"""
  228. self._test_expand(
  229. "event_issue1812",
  230. "20250627T183000Z",
  231. "20250627T183001Z",
  232. ["RECURRENCE-ID:20250627T170000Z"],
  233. ["DTSTART:20250627T170000Z"],
  234. ["DTEND:20250627T223000Z"],
  235. CONTAINS_TIMES,
  236. 11
  237. )
  238. def test_report_with_expand_property_all_day_event(self) -> None:
  239. """Test report with expand property for all day events"""
  240. self._test_expand(
  241. "event_full_day_rrule",
  242. "20060103T000000Z",
  243. "20060105T000000Z",
  244. ["RECURRENCE-ID:20060103", "RECURRENCE-ID:20060104", "RECURRENCE-ID:20060105"],
  245. ["DTSTART:20060103", "DTSTART:20060104", "DTSTART:20060105"],
  246. ["DTEND:20060104", "DTEND:20060105", "DTEND:20060106"],
  247. ONLY_DATES,
  248. 1
  249. )
  250. def test_report_with_expand_property_overridden(self) -> None:
  251. """Test report with expand property with overridden events"""
  252. self._test_expand(
  253. "event_daily_rrule_overridden",
  254. "20060103T000000Z",
  255. "20060105T000000Z",
  256. ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"],
  257. ["DTSTART:20060103T170000Z", "DTSTART:20060104T190000Z"],
  258. [],
  259. CONTAINS_TIMES,
  260. 2
  261. )
  262. def test_report_with_expand_property_timezone(self):
  263. self._test_expand(
  264. "event_weekly_rrule",
  265. "20060320T000000Z",
  266. "20060414T000000Z",
  267. [
  268. "RECURRENCE-ID:20060321T200000Z",
  269. "RECURRENCE-ID:20060328T200000Z",
  270. "RECURRENCE-ID:20060404T190000Z",
  271. "RECURRENCE-ID:20060411T190000Z",
  272. ],
  273. [
  274. "DTSTART:20060321T200000Z",
  275. "DTSTART:20060328T200000Z",
  276. "DTSTART:20060404T190000Z",
  277. "DTSTART:20060411T190000Z",
  278. ],
  279. [],
  280. CONTAINS_TIMES,
  281. 1
  282. )
  283. def test_report_with_expand_property_max_occur(self) -> None:
  284. """Test report with expand property too many vevents"""
  285. self.configure({"reporting": {"max_freebusy_occurrence": 100}})
  286. self._test_expand_max(
  287. "event_daily_rrule_forever",
  288. "20060103T000000Z",
  289. "20060501T000000Z",
  290. check=400
  291. )
  292. def test_report_with_max_occur(self) -> None:
  293. """Test report with too many vevents"""
  294. self.configure({"reporting": {"max_freebusy_occurrence": 10}})
  295. uid = "event_multiple_too_many"
  296. start = "20130901T000000Z"
  297. end = "20130902T000000Z"
  298. check = 400
  299. status, responses = self.report("/calendar.ics/",
  300. self._req_without_expand(uid, start, end),
  301. check=check)
  302. assert len(responses) == 0
  303. assert status == check
  304. def test_report_vcalendar_all_components(self) -> None:
  305. """Test calendar-query with comp-filter VCALENDAR, returns all components."""
  306. self.mkcalendar("/test/")
  307. self.put("/test/calendar.ics", get_file_content("event_daily_rrule.ics"))
  308. self.put("/test/todo.ics", get_file_content("todo1.ics"))
  309. request = """
  310. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  311. <D:prop>
  312. <C:calendar-data/>
  313. </D:prop>
  314. <C:filter>
  315. <C:comp-filter name="VCALENDAR"/>
  316. </C:filter>
  317. </C:calendar-query>
  318. """
  319. status, responses = self.report("/test", request)
  320. assert status == 207
  321. assert len(responses) == 2
  322. assert "/test/calendar.ics" in responses
  323. assert "/test/todo.ics" in responses
  324. def test_report_vevent_only(self) -> None:
  325. """Test calendar-query with comp-filter VEVENT, returns only VEVENT."""
  326. self.mkcalendar("/test/")
  327. self.put("/test/calendar.ics", get_file_content("event_daily_rrule.ics"))
  328. self.put("/test/todo.ics", get_file_content("todo1.ics"))
  329. start = "20060101T000000Z"
  330. end = "20060104T000000Z"
  331. request = f"""
  332. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  333. <D:prop>
  334. <C:calendar-data>
  335. <C:expand start="{start}" end="{end}"/>
  336. </C:calendar-data>
  337. </D:prop>
  338. <C:filter>
  339. <C:comp-filter name="VCALENDAR">
  340. <C:comp-filter name="VEVENT">
  341. <C:time-range start="{start}" end="{end}"/>
  342. </C:comp-filter>
  343. </C:comp-filter>
  344. </C:filter>
  345. </C:calendar-query>
  346. """
  347. status, responses = self.report("/test", request)
  348. assert status == 207
  349. assert len(responses) == 1
  350. assert "/test/calendar.ics" in responses
  351. vevent_response = responses["/test/calendar.ics"]
  352. assert type(vevent_response) is dict
  353. status, element = vevent_response["C:calendar-data"]
  354. assert status == 200 and element.text
  355. assert "BEGIN:VEVENT" in element.text
  356. assert "UID:" in element.text
  357. assert "BEGIN:VTODO" not in element.text
  358. def test_report_time_range_no_vevent(self) -> None:
  359. """Test calendar-query with time-range filter, no matching VEVENT."""
  360. self.mkcalendar("/test/")
  361. self.put("/test/calendar.ics/", get_file_content("event_daily_rrule.ics"))
  362. request = """
  363. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  364. <D:prop>
  365. <C:calendar-data>
  366. <C:expand start="20000101T000000Z" end="20000105T000000Z"/>
  367. </C:calendar-data>
  368. </D:prop>
  369. <C:filter>
  370. <C:comp-filter name="VCALENDAR">
  371. <C:comp-filter name="VEVENT">
  372. <C:time-range start="20000101T000000Z" end="20000105T000000Z"/>
  373. </C:comp-filter>
  374. </C:comp-filter>
  375. </C:filter>
  376. </C:calendar-query>
  377. """
  378. status, responses = self.report("/test", request)
  379. assert status == 207
  380. assert len(responses) == 0
  381. def test_report_time_range_one_vevent(self) -> None:
  382. """Test calendar-query with time-range filter, matches one VEVENT."""
  383. self.mkcalendar("/test/")
  384. self.put("/test/calendar1.ics/", get_file_content("event_daily_rrule.ics"))
  385. self.put("/test/calendar2.ics/", get_file_content("event1.ics"))
  386. start = "20060101T000000Z"
  387. end = "20060104T000000Z"
  388. request = f"""
  389. <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  390. <D:prop>
  391. <C:calendar-data>
  392. <C:expand start="{start}" end="{end}"/>
  393. </C:calendar-data>
  394. </D:prop>
  395. <C:filter>
  396. <C:comp-filter name="VCALENDAR">
  397. <C:comp-filter name="VEVENT">
  398. <C:time-range start="{start}" end="{end}"/>
  399. </C:comp-filter>
  400. </C:comp-filter>
  401. </C:filter>
  402. </C:calendar-query>
  403. """
  404. status, responses = self.report("/test", request)
  405. assert status == 207
  406. assert len(responses) == 1
  407. response = responses["/test/calendar1.ics"]
  408. assert type(response) is dict
  409. status, element = response["C:calendar-data"]
  410. assert status == 200 and element.text
  411. assert "BEGIN:VEVENT" in element.text
  412. assert "RECURRENCE-ID:20060103T170000Z" in element.text
  413. assert "DTSTART:20060103T170000Z" in element.text
  414. def test_expand_report_for_recurring_and_non_recurring_events(self) -> None:
  415. """Test calendar-query with time-range filter, matches one VEVENT."""
  416. self.mkcalendar("/test/")
  417. self.put("/test/event.ics/", get_file_content("event_issue1812_2.ics"))
  418. self.put("/test/event2.ics/", get_file_content("event_issue1812_3.ics"))
  419. request = """
  420. <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
  421. <d:prop>
  422. <d:getetag/>
  423. <c:calendar-data>
  424. <c:expand start="20250629T220000Z" end="20250803T220000Z"/>
  425. </c:calendar-data>
  426. </d:prop>
  427. <c:filter>
  428. <c:comp-filter name="VCALENDAR">
  429. <c:comp-filter name="VEVENT">
  430. <c:time-range start="20250629T220000Z" end="20250803T220000Z"/>
  431. </c:comp-filter>
  432. </c:comp-filter>
  433. </c:filter>
  434. </c:calendar-query>
  435. """
  436. status, responses = self.report("/test", request)
  437. assert status == 207
  438. assert len(responses) == 2
  439. assert isinstance(responses, dict)
  440. assert "/test/event.ics" in responses
  441. assert "/test/event2.ics" in responses
  442. assert isinstance(responses["/test/event.ics"], dict)
  443. assert isinstance(responses["/test/event2.ics"], dict)
  444. assert "C:calendar-data" in responses["/test/event.ics"]
  445. status, event1_calendar_data = responses["/test/event.ics"]["C:calendar-data"]
  446. assert event1_calendar_data.text
  447. assert "UID:a07cfa8b-0ce6-4956-800d-c0bfe1f0730a" in event1_calendar_data.text
  448. assert "RECURRENCE-ID:20250716" in event1_calendar_data.text
  449. assert "RECURRENCE-ID:20250723" in event1_calendar_data.text
  450. assert "RECURRENCE-ID:20250730" in event1_calendar_data.text
  451. assert "C:calendar-data" in responses["/test/event2.ics"]
  452. status, event2_calendar_data = responses["/test/event2.ics"]["C:calendar-data"]
  453. assert event2_calendar_data.text
  454. assert "UID:c6be8b2c-3d72-453c-b698-4f25cdf1569e" in event2_calendar_data.text
  455. def test_report_with_expand_property_all_day_event_overridden(self) -> None:
  456. self._test_expand(
  457. "event_full_day_rrule_overridden",
  458. "20060101T000000Z",
  459. "20060111T235959Z",
  460. ["RECURRENCE-ID:20060102", "RECURRENCE-ID:20060104", "RECURRENCE-ID:20060103"],
  461. ["DTSTART:20060102", "DTSTART:20060104", "DTSTART:20060110"],
  462. ["DTEND:20060103", "DTEND:20060105", "DTEND:20060111"],
  463. ONLY_DATES,
  464. 2
  465. )