test_webdav.py 21KB

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