test_webdav.py 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. # -*- coding: utf-8 -*-
  2. import io
  3. import pytest
  4. from sqlalchemy.exc import InvalidRequestError
  5. from wsgidav.wsgidav_app import DEFAULT_CONFIG
  6. from tracim_backend import WebdavAppFactory
  7. from tracim_backend.lib.core.user import UserApi
  8. from tracim_backend.lib.webdav import TracimDomainController
  9. from tracim_backend.tests import eq_
  10. from tracim_backend.lib.core.notifications import DummyNotifier
  11. from tracim_backend.lib.webdav.dav_provider import Provider
  12. from tracim_backend.lib.webdav.resources import RootResource
  13. from tracim_backend.models import Content
  14. from tracim_backend.models import ContentRevisionRO
  15. from tracim_backend.tests import StandardTest
  16. from tracim_backend.fixtures.content import Content as ContentFixtures
  17. from tracim_backend.fixtures.users_and_groups import Base as BaseFixture
  18. from wsgidav import util
  19. from unittest.mock import MagicMock
  20. class TestWebdavFactory(StandardTest):
  21. def test_unit__initConfig__ok__nominal_case(self):
  22. """
  23. Check if config is correctly modify for wsgidav using mocked
  24. wsgidav and tracim conf (as dict)
  25. :return:
  26. """
  27. tracim_settings = {
  28. 'sqlalchemy.url': 'sqlite:///:memory:',
  29. 'user.auth_token.validity': '604800',
  30. 'depot_storage_dir': '/tmp/test/depot',
  31. 'depot_storage_name': 'test',
  32. 'preview_cache_dir': '/tmp/test/preview_cache',
  33. 'wsgidav.config_path': 'development.ini'
  34. }
  35. wsgidav_setting = DEFAULT_CONFIG.copy()
  36. wsgidav_setting.update(
  37. {
  38. 'root_path': '',
  39. 'acceptbasic': True,
  40. 'acceptdigest': False,
  41. 'defaultdigest': False,
  42. }
  43. )
  44. mock = MagicMock()
  45. mock._initConfig = WebdavAppFactory._initConfig
  46. mock._readConfigFile.return_value = wsgidav_setting
  47. mock._get_tracim_settings.return_value = tracim_settings
  48. config = mock._initConfig(mock)
  49. assert config
  50. assert config['acceptbasic'] is True
  51. assert config['acceptdigest'] is False
  52. assert config['defaultdigest'] is False
  53. # TODO - G.M - 25-05-2018 - Better check for middleware stack config
  54. assert 'middleware_stack' in config
  55. assert len(config['middleware_stack']) == 7
  56. assert 'root_path' in config
  57. assert 'provider_mapping' in config
  58. assert config['root_path'] in config['provider_mapping']
  59. assert isinstance(config['provider_mapping'][config['root_path']], Provider) # nopep8
  60. assert 'domaincontroller' in config
  61. assert isinstance(config['domaincontroller'], TracimDomainController)
  62. class TestWebDav(StandardTest):
  63. fixtures = [BaseFixture, ContentFixtures]
  64. def _get_provider(self, config):
  65. return Provider(
  66. show_archived=False,
  67. show_deleted=False,
  68. show_history=False,
  69. app_config=config,
  70. )
  71. def _get_environ(
  72. self,
  73. provider: Provider,
  74. username: str,
  75. ) -> dict:
  76. return {
  77. 'http_authenticator.username': username,
  78. 'http_authenticator.realm': '/',
  79. 'wsgidav.provider': provider,
  80. 'tracim_user': self._get_user(username),
  81. 'tracim_dbsession': self.session,
  82. }
  83. def _get_user(self, email):
  84. return UserApi(None,
  85. self.session,
  86. self.app_config
  87. ).get_one_by_email(email)
  88. def _put_new_text_file(
  89. self,
  90. provider,
  91. environ,
  92. file_path,
  93. file_content,
  94. ):
  95. # This part id a reproduction of
  96. # wsgidav.request_server.RequestServer#doPUT
  97. # Grab parent folder where create file
  98. parentRes = provider.getResourceInst(
  99. util.getUriParent(file_path),
  100. environ,
  101. )
  102. assert parentRes, 'we should found folder for {0}'.format(file_path)
  103. new_resource = parentRes.createEmptyResource(
  104. util.getUriName(file_path),
  105. )
  106. write_object = new_resource.beginWrite(
  107. contentType='application/octet-stream',
  108. )
  109. write_object.write(file_content)
  110. write_object.close()
  111. new_resource.endWrite(withErrors=False)
  112. # Now file should exist
  113. return provider.getResourceInst(
  114. file_path,
  115. environ,
  116. )
  117. def test_unit__get_root__ok(self):
  118. provider = self._get_provider(self.app_config)
  119. root = provider.getResourceInst(
  120. '/',
  121. self._get_environ(
  122. provider,
  123. 'bob@fsf.local',
  124. )
  125. )
  126. assert root, 'Path / should return a RootResource instance'
  127. assert isinstance(root, RootResource)
  128. def test_unit__list_workspaces_with_user__ok(self):
  129. provider = self._get_provider(self.app_config)
  130. root = provider.getResourceInst(
  131. '/',
  132. self._get_environ(
  133. provider,
  134. 'bob@fsf.local',
  135. )
  136. )
  137. assert root, 'Path / should return a RootResource instance'
  138. assert isinstance(root, RootResource), 'Path / should return a RootResource instance'
  139. children = root.getMemberList()
  140. eq_(
  141. 2,
  142. len(children),
  143. msg='RootResource should return 2 workspaces instead {0}'.format(
  144. len(children),
  145. )
  146. )
  147. workspaces_names = [w.name for w in children]
  148. assert 'Recipes' in workspaces_names, \
  149. 'Recipes should be in names ({0})'.format(
  150. workspaces_names,
  151. )
  152. assert 'Others' in workspaces_names, 'Others should be in names ({0})'.format(
  153. workspaces_names,
  154. )
  155. def test_unit__list_workspaces_with_admin__ok(self):
  156. provider = self._get_provider(self.app_config)
  157. root = provider.getResourceInst(
  158. '/',
  159. self._get_environ(
  160. provider,
  161. 'admin@admin.admin',
  162. )
  163. )
  164. assert root, 'Path / should return a RootResource instance'
  165. assert isinstance(root, RootResource), 'Path / should return a RootResource instance'
  166. children = root.getMemberList()
  167. eq_(
  168. 2,
  169. len(children),
  170. msg='RootResource should return 3 workspaces instead {0}'.format(
  171. len(children),
  172. )
  173. )
  174. workspaces_names = [w.name for w in children]
  175. assert 'Recipes' in workspaces_names, 'Recipes should be in names ({0})'.format(
  176. workspaces_names,
  177. )
  178. assert 'Business' in workspaces_names, 'Business should be in names ({0})'.format(
  179. workspaces_names,
  180. )
  181. def test_unit__list_workspace_folders__ok(self):
  182. provider = self._get_provider(self.app_config)
  183. Recipes = provider.getResourceInst(
  184. '/Recipes/',
  185. self._get_environ(
  186. provider,
  187. 'bob@fsf.local',
  188. )
  189. )
  190. assert Recipes, 'Path /Recipes should return a Wrkspace instance'
  191. children = Recipes.getMemberList()
  192. eq_(
  193. 2,
  194. len(children),
  195. msg='Recipes should list 2 folders instead {0}'.format(
  196. len(children),
  197. ),
  198. )
  199. folders_names = [f.name for f in children]
  200. assert 'Salads' in folders_names, 'Salads should be in names ({0})'.format(
  201. folders_names,
  202. )
  203. assert 'Desserts' in folders_names, 'Desserts should be in names ({0})'.format(
  204. folders_names,
  205. )
  206. def test_unit__list_content__ok(self):
  207. provider = self._get_provider(self.app_config)
  208. Salads = provider.getResourceInst(
  209. '/Recipes/Desserts',
  210. self._get_environ(
  211. provider,
  212. 'bob@fsf.local',
  213. )
  214. )
  215. assert Salads, 'Path /Salads should return a Wrkspace instance'
  216. children = Salads.getMemberList()
  217. eq_(
  218. 5,
  219. len(children),
  220. msg='Salads should list 5 Files instead {0}'.format(
  221. len(children),
  222. ),
  223. )
  224. content_names = [c.name for c in children]
  225. assert 'Brownie Recipe.html' in content_names, \
  226. 'Brownie Recipe.html should be in names ({0})'.format(
  227. content_names,
  228. )
  229. assert 'Best Cakesʔ.html' in content_names,\
  230. 'Best Cakesʔ.html should be in names ({0})'.format(
  231. content_names,
  232. )
  233. assert 'Apple_Pie.txt' in content_names,\
  234. 'Apple_Pie.txt should be in names ({0})'.format(content_names,)
  235. assert 'Fruits Desserts' in content_names, \
  236. 'Fruits Desserts should be in names ({0})'.format(
  237. content_names,
  238. )
  239. assert 'Tiramisu Recipe.html' in content_names,\
  240. 'Tiramisu Recipe.html should be in names ({0})'.format(
  241. content_names,
  242. )
  243. def test_unit__get_content__ok(self):
  244. provider = self._get_provider(self.app_config)
  245. pie = provider.getResourceInst(
  246. '/Recipes/Desserts/Apple_Pie.txt',
  247. self._get_environ(
  248. provider,
  249. 'bob@fsf.local',
  250. )
  251. )
  252. assert pie, 'Apple_Pie should be found'
  253. eq_('Apple_Pie.txt', pie.name)
  254. def test_unit__delete_content__ok(self):
  255. provider = self._get_provider(self.app_config)
  256. pie = provider.getResourceInst(
  257. '/Recipes/Desserts/Apple_Pie.txt',
  258. self._get_environ(
  259. provider,
  260. 'bob@fsf.local',
  261. )
  262. )
  263. content_pie = self.session.query(ContentRevisionRO) \
  264. .filter(Content.label == 'Apple_Pie') \
  265. .one() # It must exist only one revision, cf fixtures
  266. eq_(
  267. False,
  268. content_pie.is_deleted,
  269. msg='Content should not be deleted !'
  270. )
  271. content_pie_id = content_pie.content_id
  272. pie.delete()
  273. self.session.flush()
  274. content_pie = self.session.query(ContentRevisionRO) \
  275. .filter(Content.content_id == content_pie_id) \
  276. .order_by(Content.revision_id.desc()) \
  277. .first()
  278. eq_(
  279. True,
  280. content_pie.is_deleted,
  281. msg='Content should be deleted!'
  282. )
  283. result = provider.getResourceInst(
  284. '/Recipes/Desserts/Apple_Pie.txt',
  285. self._get_environ(
  286. provider,
  287. 'bob@fsf.local',
  288. )
  289. )
  290. eq_(None, result, msg='Result should be None instead {0}'.format(
  291. result
  292. ))
  293. def test_unit__create_content__ok(self):
  294. provider = self._get_provider(self.app_config)
  295. environ = self._get_environ(
  296. provider,
  297. 'bob@fsf.local',
  298. )
  299. result = provider.getResourceInst(
  300. '/Recipes/Salads/greek_salad.txt',
  301. environ,
  302. )
  303. eq_(None, result, msg='Result should be None instead {0}'.format(
  304. result
  305. ))
  306. result = self._put_new_text_file(
  307. provider,
  308. environ,
  309. '/Recipes/Salads/greek_salad.txt',
  310. b'Greek Salad\n',
  311. )
  312. assert result, 'Result should not be None instead {0}'.format(
  313. result
  314. )
  315. eq_(
  316. b'Greek Salad\n',
  317. result.content.depot_file.file.read(),
  318. msg='fiel content should be "Greek Salad\n" but it is {0}'.format(
  319. result.content.depot_file.file.read()
  320. )
  321. )
  322. def test_unit__create_delete_and_create_file__ok(self):
  323. provider = self._get_provider(self.app_config)
  324. environ = self._get_environ(
  325. provider,
  326. 'bob@fsf.local',
  327. )
  328. new_file = provider.getResourceInst(
  329. '/Recipes/Salads/greek_salad.txt',
  330. environ,
  331. )
  332. eq_(None, new_file, msg='Result should be None instead {0}'.format(
  333. new_file
  334. ))
  335. # create it
  336. new_file = self._put_new_text_file(
  337. provider,
  338. environ,
  339. '/Recipes/Salads/greek_salad.txt',
  340. b'Greek Salad\n',
  341. )
  342. assert new_file, 'Result should not be None instead {0}'.format(
  343. new_file
  344. )
  345. content_new_file = self.session.query(ContentRevisionRO) \
  346. .filter(Content.label == 'greek_salad') \
  347. .one() # It must exist only one revision
  348. eq_(
  349. False,
  350. content_new_file.is_deleted,
  351. msg='Content should not be deleted!'
  352. )
  353. content_new_file_id = content_new_file.content_id
  354. # Delete if
  355. new_file.delete()
  356. self.session.flush()
  357. content_pie = self.session.query(ContentRevisionRO) \
  358. .filter(Content.content_id == content_new_file_id) \
  359. .order_by(Content.revision_id.desc()) \
  360. .first()
  361. eq_(
  362. True,
  363. content_pie.is_deleted,
  364. msg='Content should be deleted!'
  365. )
  366. result = provider.getResourceInst(
  367. '/Recipes/Salads/greek_salad.txt',
  368. self._get_environ(
  369. provider,
  370. 'bob@fsf.local',
  371. )
  372. )
  373. eq_(None, result, msg='Result should be None instead {0}'.format(
  374. result
  375. ))
  376. # Then create it again
  377. new_file = self._put_new_text_file(
  378. provider,
  379. environ,
  380. '/Recipes/Salads/greek_salad.txt',
  381. b'greek_salad\n',
  382. )
  383. assert new_file, 'Result should not be None instead {0}'.format(
  384. new_file
  385. )
  386. # Previous file is still dleeted
  387. self.session.flush()
  388. content_pie = self.session.query(ContentRevisionRO) \
  389. .filter(Content.content_id == content_new_file_id) \
  390. .order_by(Content.revision_id.desc()) \
  391. .first()
  392. eq_(
  393. True,
  394. content_pie.is_deleted,
  395. msg='Content should be deleted!'
  396. )
  397. # And an other file exist for this name
  398. content_new_new_file = self.session.query(ContentRevisionRO) \
  399. .filter(Content.label == 'greek_salad') \
  400. .order_by(Content.revision_id.desc()) \
  401. .first()
  402. assert content_new_new_file.content_id != content_new_file_id,\
  403. 'Contents ids should not be same!'
  404. eq_(
  405. False,
  406. content_new_new_file.is_deleted,
  407. msg='Content should not be deleted!'
  408. )
  409. def test_unit__rename_content__ok(self):
  410. provider = self._get_provider(self.app_config)
  411. environ = self._get_environ(
  412. provider,
  413. 'bob@fsf.local',
  414. )
  415. pie = provider.getResourceInst(
  416. '/Recipes/Desserts/Apple_Pie.txt',
  417. environ,
  418. )
  419. content_pie = self.session.query(ContentRevisionRO) \
  420. .filter(Content.label == 'Apple_Pie') \
  421. .one() # It must exist only one revision, cf fixtures
  422. assert content_pie, 'Apple_Pie should be exist'
  423. content_pie_id = content_pie.content_id
  424. pie.moveRecursive('/Recipes/Desserts/Apple_Pie_RENAMED.txt')
  425. # Database content is renamed
  426. content_pie = self.session.query(ContentRevisionRO) \
  427. .filter(ContentRevisionRO.content_id == content_pie_id) \
  428. .order_by(ContentRevisionRO.revision_id.desc()) \
  429. .first()
  430. eq_(
  431. 'Apple_Pie_RENAMED',
  432. content_pie.label,
  433. msg='File should be labeled Apple_Pie_RENAMED, not {0}'.format(
  434. content_pie.label
  435. )
  436. )
  437. def test_unit__move_content__ok(self):
  438. provider = self._get_provider(self.app_config)
  439. environ = self._get_environ(
  440. provider,
  441. 'bob@fsf.local',
  442. )
  443. pie = provider.getResourceInst(
  444. '/Recipes/Desserts/Apple_Pie.txt',
  445. environ,
  446. )
  447. content_pie = self.session.query(ContentRevisionRO) \
  448. .filter(Content.label == 'Apple_Pie') \
  449. .one() # It must exist only one revision, cf fixtures
  450. assert content_pie, 'Apple_Pie should be exist'
  451. content_pie_id = content_pie.content_id
  452. content_pie_parent = content_pie.parent
  453. eq_(
  454. content_pie_parent.label,
  455. 'Desserts',
  456. msg='field parent should be Desserts',
  457. )
  458. pie.moveRecursive('/Recipes/Salads/Apple_Pie.txt') # move in f2
  459. # Database content is moved
  460. content_pie = self.session.query(ContentRevisionRO) \
  461. .filter(ContentRevisionRO.content_id == content_pie_id) \
  462. .order_by(ContentRevisionRO.revision_id.desc()) \
  463. .first()
  464. assert content_pie.parent.label != content_pie_parent.label,\
  465. 'file should be moved in Salads but is in {0}'.format(
  466. content_pie.parent.label
  467. )
  468. def test_unit__move_and_rename_content__ok(self):
  469. provider = self._get_provider(self.app_config)
  470. environ = self._get_environ(
  471. provider,
  472. 'bob@fsf.local',
  473. )
  474. pie = provider.getResourceInst(
  475. '/Recipes/Desserts/Apple_Pie.txt',
  476. environ,
  477. )
  478. content_pie = self.session.query(ContentRevisionRO) \
  479. .filter(Content.label == 'Apple_Pie') \
  480. .one() # It must exist only one revision, cf fixtures
  481. assert content_pie, 'Apple_Pie should be exist'
  482. content_pie_id = content_pie.content_id
  483. content_pie_parent = content_pie.parent
  484. eq_(
  485. content_pie_parent.label,
  486. 'Desserts',
  487. msg='field parent should be Desserts',
  488. )
  489. pie.moveRecursive('/Others/Infos/Apple_Pie_RENAMED.txt')
  490. # Database content is moved
  491. content_pie = self.session.query(ContentRevisionRO) \
  492. .filter(ContentRevisionRO.content_id == content_pie_id) \
  493. .order_by(ContentRevisionRO.revision_id.desc()) \
  494. .first()
  495. assert content_pie.parent.label != content_pie_parent.label,\
  496. 'file should be moved in Recipesf2 but is in {0}'.format(
  497. content_pie.parent.label
  498. )
  499. eq_(
  500. 'Apple_Pie_RENAMED',
  501. content_pie.label,
  502. msg='File should be labeled Apple_Pie_RENAMED, not {0}'.format(
  503. content_pie.label
  504. )
  505. )
  506. def test_unit__move_content__ok__another_workspace(self):
  507. provider = self._get_provider(self.app_config)
  508. environ = self._get_environ(
  509. provider,
  510. 'bob@fsf.local',
  511. )
  512. content_to_move_res = provider.getResourceInst(
  513. '/Recipes/Desserts/Apple_Pie.txt',
  514. environ,
  515. )
  516. content_to_move = self.session.query(ContentRevisionRO) \
  517. .filter(Content.label == 'Apple_Pie') \
  518. .one() # It must exist only one revision, cf fixtures
  519. assert content_to_move, 'Apple_Pie should be exist'
  520. content_to_move_id = content_to_move.content_id
  521. content_to_move_parent = content_to_move.parent
  522. eq_(
  523. content_to_move_parent.label,
  524. 'Desserts',
  525. msg='field parent should be Desserts',
  526. )
  527. content_to_move_res.moveRecursive('/Others/Infos/Apple_Pie.txt') # move in Business, f1
  528. # Database content is moved
  529. content_to_move = self.session.query(ContentRevisionRO) \
  530. .filter(ContentRevisionRO.content_id == content_to_move_id) \
  531. .order_by(ContentRevisionRO.revision_id.desc()) \
  532. .first()
  533. assert content_to_move.parent, 'Content should have a parent'
  534. assert content_to_move.parent.label == 'Infos',\
  535. 'file should be moved in Infos but is in {0}'.format(
  536. content_to_move.parent.label
  537. )
  538. def test_unit__update_content__ok(self):
  539. provider = self._get_provider(self.app_config)
  540. environ = self._get_environ(
  541. provider,
  542. 'bob@fsf.local',
  543. )
  544. result = provider.getResourceInst(
  545. '/Recipes/Salads/greek_salad.txt',
  546. environ,
  547. )
  548. eq_(None, result, msg='Result should be None instead {0}'.format(
  549. result
  550. ))
  551. result = self._put_new_text_file(
  552. provider,
  553. environ,
  554. '/Recipes/Salads/greek_salad.txt',
  555. b'hello\n',
  556. )
  557. assert result, 'Result should not be None instead {0}'.format(
  558. result
  559. )
  560. eq_(
  561. b'hello\n',
  562. result.content.depot_file.file.read(),
  563. msg='fiel content should be "hello\n" but it is {0}'.format(
  564. result.content.depot_file.file.read()
  565. )
  566. )
  567. # ReInit DummyNotifier counter
  568. DummyNotifier.send_count = 0
  569. # Update file content
  570. write_object = result.beginWrite(
  571. contentType='application/octet-stream',
  572. )
  573. write_object.write(b'An other line')
  574. write_object.close()
  575. result.endWrite(withErrors=False)
  576. eq_(
  577. 1,
  578. DummyNotifier.send_count,
  579. msg='DummyNotifier should send 1 mail, not {}'.format(
  580. DummyNotifier.send_count
  581. ),
  582. )