test_storage.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2012-2017 Guillaume Ayoub
  3. # Copyright © 2017-2022 Unrud <unrud@outlook.com>
  4. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  5. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. Tests for storage backends.
  20. """
  21. import json
  22. import logging
  23. import os
  24. import re
  25. import shutil
  26. from typing import ClassVar, cast
  27. import pytest
  28. import radicale.tests.custom.storage_simple_sync
  29. from radicale.tests import BaseTest
  30. from radicale.tests.helpers import get_file_content
  31. from radicale.tests.test_base import TestBaseRequests as _TestBaseRequests
  32. class TestMultiFileSystem(BaseTest):
  33. """Tests for multifilesystem."""
  34. def setup_method(self) -> None:
  35. _TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
  36. self.configure({"storage": {"type": "multifilesystem"}})
  37. def test_folder_creation(self) -> None:
  38. """Verify that the folder is created."""
  39. folder = os.path.join(self.colpath, "subfolder")
  40. self.configure({"storage": {"filesystem_folder": folder}})
  41. assert os.path.isdir(folder)
  42. def test_folder_creation_with_umask(self) -> None:
  43. """Verify that the folder is created with umask."""
  44. folder = os.path.join(self.colpath, "subfolder")
  45. self.configure({"storage": {"filesystem_folder": folder, "folder_umask": "0077"}})
  46. assert os.path.isdir(folder)
  47. def test_fsync(self) -> None:
  48. """Create a directory and file with syncing enabled."""
  49. self.configure({"storage": {"_filesystem_fsync": "True"}})
  50. self.mkcalendar("/calendar.ics/")
  51. def test_hook(self) -> None:
  52. """Run hook."""
  53. self.configure({"storage": {"hook": "mkdir %s" % os.path.join(
  54. "collection-root", "created_by_hook")}})
  55. self.mkcalendar("/calendar.ics/")
  56. self.propfind("/created_by_hook/")
  57. def test_hook_read_access(self) -> None:
  58. """Verify that hook is not run for read accesses."""
  59. self.configure({"storage": {"hook": "mkdir %s" % os.path.join(
  60. "collection-root", "created_by_hook")}})
  61. self.propfind("/")
  62. self.propfind("/created_by_hook/", check=404)
  63. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  64. def test_hook_storage_locked(self) -> None:
  65. """Verify that the storage is locked when the hook runs."""
  66. self.configure({"storage": {"hook": (
  67. "flock -n .Radicale.lock || exit 0; exit 1")}})
  68. self.mkcalendar("/calendar.ics/")
  69. def test_hook_principal_collection_creation(self) -> None:
  70. """Verify that the hooks runs when a new user is created."""
  71. self.configure({"storage": {"hook": "mkdir %s" % os.path.join(
  72. "collection-root", "created_by_hook")}})
  73. self.configure({"auth": {"type": "none"}})
  74. self.propfind("/", login="user:")
  75. self.propfind("/created_by_hook/")
  76. def test_hook_fail(self) -> None:
  77. """Verify that a request succeeded if the hook still fails (anyhow no rollback implemented)."""
  78. self.configure({"storage": {"hook": "exit 1"}})
  79. self.mkcalendar("/calendar.ics/", check=201)
  80. def test_item_cache_rebuild(self) -> None:
  81. """Delete the item cache and verify that it is rebuild."""
  82. self.mkcalendar("/calendar.ics/")
  83. event = get_file_content("event1.ics")
  84. path = "/calendar.ics/event1.ics"
  85. self.put(path, event)
  86. _, answer1 = self.get(path)
  87. cache_folder = os.path.join(self.colpath, "collection-root",
  88. "calendar.ics", ".Radicale.cache", "item")
  89. assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
  90. shutil.rmtree(cache_folder)
  91. _, answer2 = self.get(path)
  92. assert answer1 == answer2
  93. assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
  94. def test_item_cache_rebuild_subfolder(self) -> None:
  95. """Delete the item cache and verify that it is rebuild."""
  96. self.configure({"storage": {"use_cache_subfolder_for_item": "True"}})
  97. self.mkcalendar("/calendar.ics/")
  98. event = get_file_content("event1.ics")
  99. path = "/calendar.ics/event1.ics"
  100. self.put(path, event)
  101. _, answer1 = self.get(path)
  102. cache_folder = os.path.join(self.colpath, "collection-cache",
  103. "calendar.ics", ".Radicale.cache", "item")
  104. assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
  105. shutil.rmtree(cache_folder)
  106. _, answer2 = self.get(path)
  107. assert answer1 == answer2
  108. assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
  109. def test_item_cache_rebuild_mtime_and_size(self) -> None:
  110. """Delete the item cache and verify that it is rebuild."""
  111. self.configure({"storage": {"use_mtime_and_size_for_item_cache": "True"}})
  112. self.mkcalendar("/calendar.ics/")
  113. event = get_file_content("event1.ics")
  114. path = "/calendar.ics/event1.ics"
  115. self.put(path, event)
  116. _, answer1 = self.get(path)
  117. cache_folder = os.path.join(self.colpath, "collection-root",
  118. "calendar.ics", ".Radicale.cache", "item")
  119. assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
  120. shutil.rmtree(cache_folder)
  121. _, answer2 = self.get(path)
  122. assert answer1 == answer2
  123. assert os.path.exists(os.path.join(cache_folder, "event1.ics"))
  124. def test_put_whole_calendar_uids_used_as_file_names(self) -> None:
  125. """Test if UIDs are used as file names."""
  126. _TestBaseRequests.test_put_whole_calendar(
  127. cast(_TestBaseRequests, self))
  128. for uid in ("todo", "event"):
  129. _, answer = self.get("/calendar.ics/%s.ics" % uid)
  130. assert "\r\nUID:%s\r\n" % uid in answer
  131. def test_put_whole_calendar_random_uids_used_as_file_names(self) -> None:
  132. """Test if UIDs are used as file names."""
  133. _TestBaseRequests.test_put_whole_calendar_without_uids(
  134. cast(_TestBaseRequests, self))
  135. _, answer = self.get("/calendar.ics")
  136. assert answer is not None
  137. uids = []
  138. for line in answer.split("\r\n"):
  139. if line.startswith("UID:"):
  140. uids.append(line[len("UID:"):])
  141. for uid in uids:
  142. _, answer = self.get("/calendar.ics/%s.ics" % uid)
  143. assert answer is not None
  144. assert "\r\nUID:%s\r\n" % uid in answer
  145. def test_put_whole_addressbook_uids_used_as_file_names(self) -> None:
  146. """Test if UIDs are used as file names."""
  147. _TestBaseRequests.test_put_whole_addressbook(
  148. cast(_TestBaseRequests, self))
  149. for uid in ("contact1", "contact2"):
  150. _, answer = self.get("/contacts.vcf/%s.vcf" % uid)
  151. assert "\r\nUID:%s\r\n" % uid in answer
  152. def test_put_whole_addressbook_random_uids_used_as_file_names(
  153. self) -> None:
  154. """Test if UIDs are used as file names."""
  155. _TestBaseRequests.test_put_whole_addressbook_without_uids(
  156. cast(_TestBaseRequests, self))
  157. _, answer = self.get("/contacts.vcf")
  158. assert answer is not None
  159. uids = []
  160. for line in answer.split("\r\n"):
  161. if line.startswith("UID:"):
  162. uids.append(line[len("UID:"):])
  163. for uid in uids:
  164. _, answer = self.get("/contacts.vcf/%s.vcf" % uid)
  165. assert answer is not None
  166. assert "\r\nUID:%s\r\n" % uid in answer
  167. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  168. def test_hook_placeholders_PUT(self, caplog) -> None:
  169. """Run hook and check placeholders: PUT"""
  170. self.configure({"storage": {"hook": "echo \"hook-json {'user':'%(user)s', 'cwd':'%(cwd)s', 'path':'%(path)s', 'request':'%(request)s', 'to_path':'%(to_path)s'}\""}})
  171. found = 0
  172. self.mkcalendar("/calendar.ics/")
  173. event = get_file_content("event1.ics")
  174. path = "/calendar.ics/event1.ics"
  175. self.put(path, event)
  176. for line in caplog.messages:
  177. if line.find("\"hook-json ") != -1:
  178. found = 1
  179. r = re.search('.*\"hook-json ({.*})".*', line)
  180. if r:
  181. s = r.group(1).replace("'", "\"")
  182. else:
  183. break
  184. d = json.loads(s)
  185. if d["user"] == "Anonymous":
  186. found = found | 2
  187. if d["cwd"]:
  188. found = found | 4
  189. if d["path"]:
  190. found = found | 8
  191. if d["path"] == d["cwd"] + "/collection-root/calendar.ics/event1.ics":
  192. found = found | 16
  193. if d["request"]:
  194. found = found | 64
  195. if d["request"] == "PUT":
  196. found = found | 128
  197. if d["to_path"]:
  198. found = found | 32
  199. if d["to_path"] == "":
  200. found = found | 256
  201. else:
  202. found = found | 256 | 32
  203. if (found != 511):
  204. raise ValueError("Logging misses expected hook log line, found=%d data=%r", found, d)
  205. else:
  206. logging.info("Logging contains expected hook line, found=%d data=%r", found, d)
  207. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  208. def test_hook_placeholders_DELETE(self, caplog) -> None:
  209. """Run hook and check placeholders: DELETE"""
  210. self.configure({"storage": {"hook": "echo \"hook-json {'user':'%(user)s', 'cwd':'%(cwd)s', 'path':'%(path)s', 'request':'%(request)s', 'to_path':'%(to_path)s'}\""}})
  211. found = 0
  212. self.mkcalendar("/calendar.ics/")
  213. event = get_file_content("event1.ics")
  214. path = "/calendar.ics/event1.ics"
  215. self.put(path, event)
  216. self.delete(path)
  217. for line in caplog.messages:
  218. if line.find("\"hook-json ") != -1:
  219. found = 1
  220. r = re.search('.*\"hook-json ({.*})".*', line)
  221. if r:
  222. s = r.group(1).replace("'", "\"")
  223. else:
  224. break
  225. d = json.loads(s)
  226. if d["user"] == "Anonymous":
  227. found = found | 2
  228. if d["cwd"]:
  229. found = found | 4
  230. if d["path"]:
  231. found = found | 8
  232. if d["path"] == d["cwd"] + "/collection-root/calendar.ics/event1.ics":
  233. found = found | 16
  234. if d["request"]:
  235. found = found | 64
  236. if d["request"] == "DELETE":
  237. found = found | 128
  238. if d["to_path"]:
  239. found = found | 32
  240. if d["to_path"] == "":
  241. found = found | 256
  242. else:
  243. found = found | 256 | 32
  244. if (found != 511):
  245. raise ValueError("Logging misses expected hook log line, found=%d data=%r", found, s)
  246. else:
  247. logging.info("Logging contains expected hook line, found=%d data=%r", found, d)
  248. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  249. def test_hook_placeholders_MKCALENDAR(self, caplog) -> None:
  250. """Run hook and check placeholders: MKCALENDAR"""
  251. self.configure({"storage": {"hook": "echo \"hook-json {'user':'%(user)s', 'cwd':'%(cwd)s', 'path':'%(path)s', 'request':'%(request)s', 'to_path':'%(to_path)s'}\""}})
  252. found = 0
  253. self.mkcalendar("/calendar.ics/")
  254. for line in caplog.messages:
  255. if line.find("\"hook-json ") != -1:
  256. found = 1
  257. r = re.search('.*\"hook-json ({.*})".*', line)
  258. if r:
  259. s = r.group(1).replace("'", "\"")
  260. else:
  261. break
  262. d = json.loads(s)
  263. if d["user"] == "Anonymous":
  264. found = found | 2
  265. if d["cwd"]:
  266. found = found | 4
  267. if d["path"]:
  268. found = found | 8
  269. if d["path"] == d["cwd"] + "/collection-root/calendar.ics/":
  270. found = found | 16
  271. if d["request"]:
  272. found = found | 64
  273. if d["request"] == "MKCALENDAR":
  274. found = found | 128
  275. if d["to_path"]:
  276. found = found | 32
  277. if d["to_path"] == "":
  278. found = found | 256
  279. else:
  280. found = found | 256 | 32
  281. if (found != 511):
  282. raise ValueError("Logging misses expected hook log line, found=%d data=%r", found, d)
  283. else:
  284. logging.info("Logging contains expected hook line, found=%d data=%r", found, d)
  285. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  286. def test_hook_placeholders_MKCOL(self, caplog) -> None:
  287. """Run hook and check placeholders: MKCOL"""
  288. self.configure({"storage": {"hook": "echo \"hook-json {'user':'%(user)s', 'cwd':'%(cwd)s', 'path':'%(path)s', 'request':'%(request)s', 'to_path':'%(to_path)s'}\""}})
  289. found = 0
  290. self.mkcol("/user1/")
  291. for line in caplog.messages:
  292. if line.find("\"hook-json ") != -1:
  293. found = 1
  294. r = re.search('.*\"hook-json ({.*})".*', line)
  295. if r:
  296. s = r.group(1).replace("'", "\"")
  297. else:
  298. break
  299. d = json.loads(s)
  300. if d["user"] == "Anonymous":
  301. found = found | 2
  302. if d["cwd"]:
  303. found = found | 4
  304. if d["path"]:
  305. found = found | 8
  306. if d["path"] == d["cwd"] + "/collection-root/user1/":
  307. found = found | 16
  308. if d["request"]:
  309. found = found | 64
  310. if d["request"] == "MKCOL":
  311. found = found | 128
  312. if d["to_path"]:
  313. found = found | 32
  314. if d["to_path"] == "":
  315. found = found | 256
  316. else:
  317. found = found | 256 | 32
  318. if (found != 511):
  319. raise ValueError("Logging misses expected hook log line, found=%d data=%r", found, d)
  320. else:
  321. logging.info("Logging contains expected hook line, found=%d data=%r", found, d)
  322. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  323. def test_hook_placeholders_PROPPATCH(self, caplog) -> None:
  324. """Run hook and check placeholders: PROPPATCH"""
  325. self.configure({"storage": {"hook": "echo \"hook-json {'user':'%(user)s', 'cwd':'%(cwd)s', 'path':'%(path)s', 'request':'%(request)s', 'to_path':'%(to_path)s'}\""}})
  326. found = 0
  327. self.mkcalendar("/calendar.ics/")
  328. proppatch = get_file_content("proppatch_set_calendar_color.xml")
  329. _, responses = self.proppatch("/calendar.ics/", proppatch)
  330. for line in caplog.messages:
  331. if line.find("\"hook-json ") != -1:
  332. found = 1
  333. r = re.search('.*\"hook-json ({.*})".*', line)
  334. if r:
  335. s = r.group(1).replace("'", "\"")
  336. else:
  337. break
  338. d = json.loads(s)
  339. if d["user"] == "Anonymous":
  340. found = found | 2
  341. if d["cwd"]:
  342. found = found | 4
  343. if d["path"]:
  344. found = found | 8
  345. if d["path"] == d["cwd"] + "/collection-root/calendar.ics/":
  346. found = found | 16
  347. if d["request"]:
  348. found = found | 64
  349. if d["request"] == "PROPPATCH":
  350. found = found | 128
  351. if d["to_path"]:
  352. found = found | 32
  353. if d["to_path"] == "":
  354. found = found | 256
  355. else:
  356. found = found | 256 | 32
  357. if (found != 511):
  358. raise ValueError("Logging misses expected hook log line, found=%d data=%r", found, d)
  359. else:
  360. logging.info("Logging contains expected hook line, found=%d data=%r", found, d)
  361. @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found")
  362. def test_hook_placeholders_MOVE(self, caplog) -> None:
  363. """Run hook and check placeholders: MOVE"""
  364. self.configure({"storage": {"hook": "echo \"hook-json {'user':'%(user)s', 'cwd':'%(cwd)s', 'path':'%(path)s', 'request':'%(request)s', 'to_path':'%(to_path)s'}\""}})
  365. found = 0
  366. self.mkcalendar("/calendar.ics/")
  367. event = get_file_content("event1.ics")
  368. path1 = "/calendar.ics/event1.ics"
  369. path2 = "/calendar.ics/event2.ics"
  370. self.put(path1, event)
  371. self.request("MOVE", path1, check=201,
  372. HTTP_DESTINATION="http://127.0.0.1/"+path2)
  373. for line in caplog.messages:
  374. if line.find("\"hook-json ") != -1:
  375. found = 1
  376. r = re.search('.*\"hook-json ({.*})".*', line)
  377. if r:
  378. s = r.group(1).replace("'", "\"")
  379. else:
  380. break
  381. d = json.loads(s)
  382. if d["user"] == "Anonymous":
  383. found = found | 2
  384. if d["cwd"]:
  385. found = found | 4
  386. if d["path"]:
  387. found = found | 8
  388. if d["path"] == d["cwd"] + "/collection-root/calendar.ics/event1.ics":
  389. found = found | 16
  390. if d["request"]:
  391. found = found | 64
  392. if d["request"] == "MOVE":
  393. found = found | 128
  394. if d["to_path"]:
  395. found = found | 32
  396. if d["to_path"] == d["cwd"] + "/collection-root/calendar.ics/event2.ics":
  397. found = found | 256
  398. if (found != 511):
  399. raise ValueError("Logging misses expected hook log line, found=%d data=%r", found, d)
  400. else:
  401. logging.info("Logging contains expected hook line, found=%d data=%r", found, d)
  402. class TestMultiFileSystemNoLock(BaseTest):
  403. """Tests for multifilesystem_nolock."""
  404. def setup_method(self) -> None:
  405. _TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
  406. self.configure({"storage": {"type": "multifilesystem_nolock"}})
  407. test_add_event = _TestBaseRequests.test_add_event
  408. test_item_cache_rebuild = TestMultiFileSystem.test_item_cache_rebuild
  409. class TestCustomStorageSystem(BaseTest):
  410. """Test custom backend loading."""
  411. def setup_method(self) -> None:
  412. _TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
  413. self.configure({"storage": {
  414. "type": "radicale.tests.custom.storage_simple_sync"}})
  415. full_sync_token_support: ClassVar[bool] = False
  416. test_add_event = _TestBaseRequests.test_add_event
  417. _report_sync_token = _TestBaseRequests._report_sync_token
  418. # include tests related to sync token
  419. s: str = ""
  420. for s in dir(_TestBaseRequests):
  421. if s.startswith("test_") and "sync" in s.split("_"):
  422. locals()[s] = getattr(_TestBaseRequests, s)
  423. del s
  424. class TestCustomStorageSystemCallable(BaseTest):
  425. """Test custom backend loading with ``callable``."""
  426. def setup_method(self) -> None:
  427. _TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
  428. self.configure({"storage": {
  429. "type": radicale.tests.custom.storage_simple_sync.Storage}})
  430. test_add_event = _TestBaseRequests.test_add_event