test_library_folder_ownership_3201.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. """Library folder ownership and sharing (#3201).
  2. A library:read_own user sees their own folders, shared ones, folders holding
  3. their files and the parents leading there; they add only to their own and
  4. shared folders. Counts and activity times only reflect their own files.
  5. """
  6. import io
  7. import zipfile
  8. import pytest
  9. from httpx import AsyncClient
  10. from sqlalchemy import text
  11. from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
  12. def _h(token: str) -> dict:
  13. return {"Authorization": f"Bearer {token}"}
  14. def _ids(tree: list[dict]) -> set[int]:
  15. out: set[int] = set()
  16. for node in tree:
  17. out.add(node["id"])
  18. out |= _ids(node["children"])
  19. return out
  20. def _find(tree: list[dict], folder_id: int) -> dict | None:
  21. for node in tree:
  22. if node["id"] == folder_id:
  23. return node
  24. found = _find(node["children"], folder_id)
  25. if found:
  26. return found
  27. return None
  28. class TestFolderOwnership(TestOwnershipPermissionsSetup):
  29. @pytest.fixture
  30. async def folder(self, db_session):
  31. async def _create(**kwargs):
  32. from backend.app.models.library import LibraryFolder
  33. folder = LibraryFolder(**{"name": "F", **kwargs})
  34. db_session.add(folder)
  35. await db_session.commit()
  36. await db_session.refresh(folder)
  37. return folder
  38. return _create
  39. @pytest.fixture
  40. async def file(self, db_session):
  41. counter = [0]
  42. async def _create(**kwargs):
  43. from backend.app.models.library import LibraryFile
  44. counter[0] += 1
  45. row = LibraryFile(
  46. **{
  47. "filename": f"f{counter[0]}.3mf",
  48. "file_path": f"library/f{counter[0]}.3mf",
  49. "file_type": "3mf",
  50. "file_size": 1,
  51. **kwargs,
  52. }
  53. )
  54. db_session.add(row)
  55. await db_session.commit()
  56. await db_session.refresh(row)
  57. return row
  58. return _create
  59. # ---- visibility ---------------------------------------------------------
  60. @pytest.mark.asyncio
  61. @pytest.mark.integration
  62. async def test_tree_shows_only_what_the_user_may_see(self, async_client: AsyncClient, auth_setup, folder, file):
  63. me = auth_setup["operator_user"]["id"]
  64. other = auth_setup["operator2_user"]["id"]
  65. mine = await folder(name="Mine", created_by_id=me)
  66. theirs = await folder(name="Theirs", created_by_id=other)
  67. shared = await folder(name="Class", created_by_id=other, shared=True)
  68. # Their private folder holding one of my files: visible, and its
  69. # parent too, for navigation; a sibling of it is not.
  70. outer = await folder(name="Outer", created_by_id=other)
  71. inner = await folder(name="Inner", created_by_id=other, parent_id=outer.id)
  72. sibling = await folder(name="Sibling", created_by_id=other, parent_id=outer.id)
  73. await file(folder_id=inner.id, created_by_id=me)
  74. unowned_private = await folder(name="Unshared")
  75. tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
  76. assert _ids(tree) == {mine.id, shared.id, outer.id, inner.id}
  77. assert theirs.id not in _ids(tree) and sibling.id not in _ids(tree)
  78. assert unowned_private.id not in _ids(tree)
  79. node = _find(tree, mine.id)
  80. assert node["can_write"] and node["can_rename"] and node["can_delete"] and not node["shared"]
  81. assert _find(tree, shared.id)["can_write"] and not _find(tree, shared.id)["can_rename"]
  82. # Reachable, not writable.
  83. assert not _find(tree, outer.id)["can_write"] and not _find(tree, inner.id)["can_write"]
  84. admin_tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["admin_token"]))).json()
  85. assert {theirs.id, sibling.id, unowned_private.id} <= _ids(admin_tree)
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_counts_and_activity_reflect_own_files_only(
  89. self, async_client: AsyncClient, auth_setup, folder, file
  90. ):
  91. """lonix's point on #3201: counts and times leaked other users' files."""
  92. from datetime import datetime
  93. me = auth_setup["operator_user"]["id"]
  94. other = auth_setup["operator2_user"]["id"]
  95. shared = await folder(name="Class", shared=True, created_by_id=other)
  96. await file(folder_id=shared.id, created_by_id=me)
  97. recent = datetime(2030, 1, 1)
  98. await file(folder_id=shared.id, created_by_id=other, updated_at=recent)
  99. await file(folder_id=shared.id, created_by_id=other)
  100. tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
  101. node = _find(tree, shared.id)
  102. assert node["file_count"] == 1
  103. assert not node["latest_activity_at"].startswith("2030")
  104. single = await async_client.get(
  105. f"/api/v1/library/folders/{shared.id}", headers=_h(auth_setup["operator_token"])
  106. )
  107. assert single.json()["file_count"] == 1
  108. admin = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["admin_token"]))).json()
  109. assert _find(admin, shared.id)["file_count"] == 3
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_hidden_folder_answers_404(self, async_client: AsyncClient, auth_setup, folder):
  113. theirs = await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
  114. token = auth_setup["operator_token"]
  115. assert (await async_client.get(f"/api/v1/library/folders/{theirs.id}", headers=_h(token))).status_code == 404
  116. readme = await async_client.get(f"/api/v1/library/folders/{theirs.id}/readme", headers=_h(token))
  117. assert readme.status_code == 404 and readme.json()["detail"] == "Folder not found"
  118. assert (
  119. await async_client.get(f"/api/v1/library/folders/{theirs.id}", headers=_h(auth_setup["operator2_token"]))
  120. ).status_code == 200
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_stats_count_visible_folders(self, async_client: AsyncClient, auth_setup, folder):
  124. await folder(name="Mine", created_by_id=auth_setup["operator_user"]["id"])
  125. await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
  126. await folder(name="Class", shared=True)
  127. stats = await async_client.get("/api/v1/library/stats", headers=_h(auth_setup["operator_token"]))
  128. assert stats.json()["total_folders"] == 2
  129. admin = await async_client.get("/api/v1/library/stats", headers=_h(auth_setup["admin_token"]))
  130. assert admin.json()["total_folders"] == 3
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_scan_of_a_hidden_mount_answers_404(self, async_client: AsyncClient, auth_setup, folder):
  134. hidden = await folder(
  135. name="Mount", is_external=True, external_path="/nonexistent", created_by_id=auth_setup["admin_user"]["id"]
  136. )
  137. response = await async_client.post(
  138. f"/api/v1/library/folders/{hidden.id}/scan", headers=_h(auth_setup["operator_token"])
  139. )
  140. assert response.status_code == 404
  141. # ---- creating and adding ------------------------------------------------
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_new_folder_belongs_to_its_creator(self, async_client: AsyncClient, auth_setup):
  145. created = await async_client.post(
  146. "/api/v1/library/folders", json={"name": "Assignment 1"}, headers=_h(auth_setup["operator_token"])
  147. )
  148. assert created.status_code == 200
  149. body = created.json()
  150. assert body["created_by_id"] == auth_setup["operator_user"]["id"]
  151. assert body["shared"] is False and body["can_write"] is True
  152. other = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator2_token"]))).json()
  153. assert body["id"] not in _ids(other)
  154. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_folder_made_without_a_user_is_shared(self, async_client: AsyncClient):
  157. """Auth off: nobody to own it, so it stays everyone's, as before."""
  158. body = (await async_client.post("/api/v1/library/folders", json={"name": "Open"})).json()
  159. assert body["created_by_id"] is None and body["shared"] is True
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_subfolder_only_where_the_user_may_write(self, async_client: AsyncClient, auth_setup, folder, file):
  163. me = auth_setup["operator_user"]["id"]
  164. other = auth_setup["operator2_user"]["id"]
  165. theirs = await folder(name="Theirs", created_by_id=other)
  166. shared = await folder(name="Class", created_by_id=other, shared=True)
  167. passthrough = await folder(name="Outer", created_by_id=other)
  168. await file(folder_id=passthrough.id, created_by_id=me)
  169. token = auth_setup["operator_token"]
  170. async def create(parent_id):
  171. return await async_client.post(
  172. "/api/v1/library/folders", json={"name": "Sub", "parent_id": parent_id}, headers=_h(token)
  173. )
  174. assert (await create(theirs.id)).status_code == 404
  175. assert (await create(passthrough.id)).status_code == 403
  176. assert (await create(shared.id)).status_code == 200
  177. @pytest.mark.asyncio
  178. @pytest.mark.integration
  179. async def test_move_files_only_into_writable_folders(self, async_client: AsyncClient, auth_setup, folder, file):
  180. me = auth_setup["operator_user"]["id"]
  181. other = auth_setup["operator2_user"]["id"]
  182. theirs = await folder(name="Theirs", created_by_id=other)
  183. shared = await folder(name="Class", shared=True)
  184. mine = await file(created_by_id=me)
  185. token = auth_setup["operator_token"]
  186. moved = await async_client.post(
  187. "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": theirs.id}, headers=_h(token)
  188. )
  189. assert moved.status_code == 404
  190. update = await async_client.put(
  191. f"/api/v1/library/files/{mine.id}", json={"folder_id": theirs.id}, headers=_h(token)
  192. )
  193. assert update.status_code == 404
  194. moved = await async_client.post(
  195. "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": shared.id}, headers=_h(token)
  196. )
  197. assert moved.status_code == 200 and moved.json()["moved"] == 1
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_zip_never_reuses_another_users_folder(self, async_client: AsyncClient, auth_setup, folder):
  201. """Extracting "Alice.zip" must not land in Alice's own "Alice" folder."""
  202. alice_folder = await folder(name="Alice", created_by_id=auth_setup["operator2_user"]["id"])
  203. buf = io.BytesIO()
  204. with zipfile.ZipFile(buf, "w") as zf:
  205. zf.writestr("Alice/model.txt", "x")
  206. response = await async_client.post(
  207. "/api/v1/library/files/extract-zip",
  208. files={"file": ("Alice.zip", buf.getvalue(), "application/zip")},
  209. params={"preserve_structure": "true", "create_folder_from_zip": "true"},
  210. headers=_h(auth_setup["operator_token"]),
  211. )
  212. assert response.status_code == 200
  213. landed = response.json()["files"][0]["folder_id"]
  214. assert landed != alice_folder.id
  215. tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
  216. assert alice_folder.id not in _ids(tree) and landed in _ids(tree)
  217. # ---- renaming, sharing, deleting ---------------------------------------
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_owner_renames_admin_shares(self, async_client: AsyncClient, auth_setup, folder):
  221. me = auth_setup["operator_user"]["id"]
  222. mine = await folder(name="Mine", created_by_id=me)
  223. class_folder = await folder(name="Class", shared=True, created_by_id=auth_setup["admin_user"]["id"])
  224. token = auth_setup["operator_token"]
  225. renamed = await async_client.put(
  226. f"/api/v1/library/folders/{mine.id}", json={"name": "Mine2"}, headers=_h(token)
  227. )
  228. assert renamed.status_code == 200 and renamed.json()["name"] == "Mine2"
  229. assert (
  230. await async_client.put(f"/api/v1/library/folders/{class_folder.id}", json={"name": "X"}, headers=_h(token))
  231. ).status_code == 403
  232. assert (
  233. await async_client.put(f"/api/v1/library/folders/{mine.id}", json={"shared": True}, headers=_h(token))
  234. ).status_code == 403
  235. # The admin shares my folder: operator2 now sees it and may add to it.
  236. shared = await async_client.put(
  237. f"/api/v1/library/folders/{mine.id}", json={"shared": True}, headers=_h(auth_setup["admin_token"])
  238. )
  239. assert shared.status_code == 200 and shared.json()["shared"] is True
  240. other = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator2_token"]))).json()
  241. assert _find(other, mine.id)["can_write"] is True
  242. @pytest.mark.asyncio
  243. @pytest.mark.integration
  244. async def test_owner_moves_folder_only_into_writable_parent(self, async_client: AsyncClient, auth_setup, folder):
  245. me = auth_setup["operator_user"]["id"]
  246. mine = await folder(name="Mine", created_by_id=me)
  247. theirs = await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
  248. token = auth_setup["operator_token"]
  249. assert (
  250. await async_client.put(
  251. f"/api/v1/library/folders/{mine.id}", json={"parent_id": theirs.id}, headers=_h(token)
  252. )
  253. ).status_code == 404
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_owner_deletes_folder_only_when_everything_is_theirs(
  257. self, async_client: AsyncClient, auth_setup, folder, file
  258. ):
  259. me = auth_setup["operator_user"]["id"]
  260. other = auth_setup["operator2_user"]["id"]
  261. token = auth_setup["operator_token"]
  262. own_full = await folder(name="Full", created_by_id=me)
  263. await folder(name="Sub", created_by_id=me, parent_id=own_full.id)
  264. await file(folder_id=own_full.id, created_by_id=me)
  265. assert (
  266. await async_client.delete(f"/api/v1/library/folders/{own_full.id}", headers=_h(token))
  267. ).status_code == 200
  268. mixed = await folder(name="Mixed", created_by_id=me, shared=True)
  269. await file(folder_id=mixed.id, created_by_id=other)
  270. assert (await async_client.delete(f"/api/v1/library/folders/{mixed.id}", headers=_h(token))).status_code == 403
  271. their_shared = await folder(name="Class", created_by_id=other, shared=True)
  272. assert (
  273. await async_client.delete(f"/api/v1/library/folders/{their_shared.id}", headers=_h(token))
  274. ).status_code == 403
  275. theirs = await folder(name="Theirs", created_by_id=other)
  276. assert (await async_client.delete(f"/api/v1/library/folders/{theirs.id}", headers=_h(token))).status_code == 404
  277. bulk = await async_client.post(
  278. "/api/v1/library/bulk-delete",
  279. json={"file_ids": [], "folder_ids": [theirs.id, their_shared.id]},
  280. headers=_h(token),
  281. )
  282. assert bulk.json()["deleted_folders"] == 0
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_all_permissions_reach_folders_they_cannot_see(
  286. self, async_client: AsyncClient, auth_setup, folder, file
  287. ):
  288. """A group with update_all/delete_all but only read_own kept full reach before #3201."""
  289. admin = _h(auth_setup["admin_token"])
  290. groups = (await async_client.get("/api/v1/groups/", headers=admin)).json()
  291. operators = next(g for g in groups if g["name"] == "Operators")
  292. perms = [p for p in operators["permissions"] if p not in ("library:update_own", "library:delete_own")]
  293. perms += ["library:update_all", "library:delete_all"]
  294. group = await async_client.post(
  295. "/api/v1/groups/", json={"name": "Editors", "permissions": perms}, headers=admin
  296. )
  297. assert group.status_code in (200, 201), group.text
  298. user = await async_client.post(
  299. "/api/v1/users/",
  300. json={"username": "editor1", "password": "Editorpass1!", "group_ids": [group.json()["id"]]},
  301. headers=admin,
  302. )
  303. assert user.status_code in (200, 201), user.text
  304. login = await async_client.post("/api/v1/auth/login", json={"username": "editor1", "password": "Editorpass1!"})
  305. token = login.json()["access_token"]
  306. other = auth_setup["operator2_user"]["id"]
  307. theirs = await folder(name="Theirs", created_by_id=other)
  308. doomed = await folder(name="Doomed", created_by_id=other)
  309. mine = await file(created_by_id=user.json()["id"])
  310. # Still hidden from view: read_own decides what is listed.
  311. tree = (await async_client.get("/api/v1/library/folders", headers=_h(token))).json()
  312. assert theirs.id not in _ids(tree)
  313. # But the *_all permissions act on it as before.
  314. renamed = await async_client.put(f"/api/v1/library/folders/{theirs.id}", json={"name": "R"}, headers=_h(token))
  315. assert renamed.status_code == 200
  316. moved = await async_client.post(
  317. "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": theirs.id}, headers=_h(token)
  318. )
  319. assert moved.status_code == 200 and moved.json()["moved"] == 1
  320. assert (await async_client.delete(f"/api/v1/library/folders/{doomed.id}", headers=_h(token))).status_code == 200
  321. # ---- imports ------------------------------------------------------------
  322. @pytest.mark.asyncio
  323. @pytest.mark.integration
  324. async def test_default_import_folder(self, db_session, auth_setup):
  325. from sqlalchemy import select
  326. from sqlalchemy.orm import selectinload
  327. from backend.app.models.user import User
  328. from backend.app.services.library_folder_access import default_import_folder
  329. users = {
  330. u.id: u for u in (await db_session.execute(select(User).options(selectinload(User.groups)))).scalars().all()
  331. }
  332. op1 = users[auth_setup["operator_user"]["id"]]
  333. op2 = users[auth_setup["operator2_user"]["id"]]
  334. first = await default_import_folder(db_session, "MakerWorld", op1)
  335. assert first.shared is True
  336. assert (await default_import_folder(db_session, "MakerWorld", op2)).id == first.id
  337. # An admin made it private: op2 gets one of their own, not a refusal.
  338. first.shared = False
  339. await db_session.flush()
  340. own = await default_import_folder(db_session, "MakerWorld", op2)
  341. assert own.id != first.id and own.created_by_id == op2.id and own.shared is False
  342. class TestFolderOwnerBackfill:
  343. @pytest.mark.asyncio
  344. async def test_infers_owners_once(self, test_engine, db_session):
  345. from backend.app.core.database import _backfill_library_folder_owners
  346. from backend.app.models.library import LibraryFile, LibraryFolder
  347. from backend.app.models.project import Project
  348. from backend.app.models.user import User
  349. alice = User(username="alice", password_hash="x", role="user")
  350. bob = User(username="bob", password_hash="x", role="user")
  351. project = Project(name="P")
  352. db_session.add_all([alice, bob, project])
  353. await db_session.flush()
  354. def folder(name, parent=None, **kw):
  355. f = LibraryFolder(name=name, parent_id=parent.id if parent else None, **kw)
  356. db_session.add(f)
  357. return f
  358. students = folder("Students")
  359. await db_session.flush()
  360. a = folder("Alice", students)
  361. b = folder("Bob", students)
  362. empty = folder("Testing")
  363. maker = folder("MakerWorld")
  364. linked = folder("Linked")
  365. await db_session.flush()
  366. a_old = folder("Old", a) # empty, inside Alice's folder
  367. linked.project_id = project.id
  368. await db_session.flush()
  369. from datetime import datetime, timezone
  370. db_session.add_all(
  371. [
  372. LibraryFile(
  373. folder_id=a.id, created_by_id=alice.id, filename="1", file_path="1", file_type="3mf", file_size=1
  374. ),
  375. LibraryFile(
  376. folder_id=a.id,
  377. created_by_id=alice.id,
  378. filename="2",
  379. file_path="2",
  380. file_type="3mf",
  381. file_size=1,
  382. deleted_at=datetime.now(timezone.utc),
  383. ),
  384. LibraryFile(
  385. folder_id=b.id, created_by_id=bob.id, filename="3", file_path="3", file_type="3mf", file_size=1
  386. ),
  387. LibraryFile(
  388. folder_id=maker.id,
  389. created_by_id=alice.id,
  390. filename="4",
  391. file_path="4",
  392. file_type="3mf",
  393. file_size=1,
  394. ),
  395. LibraryFile(
  396. folder_id=linked.id, created_by_id=bob.id, filename="5", file_path="5", file_type="3mf", file_size=1
  397. ),
  398. ]
  399. )
  400. await db_session.commit()
  401. ids = {
  402. n: f.id
  403. for n, f in {
  404. "students": students,
  405. "a": a,
  406. "b": b,
  407. "empty": empty,
  408. "maker": maker,
  409. "linked": linked,
  410. "a_old": a_old,
  411. }.items()
  412. }
  413. alice_id, bob_id = alice.id, bob.id
  414. async with test_engine.begin() as conn:
  415. await _backfill_library_folder_owners(conn)
  416. async with test_engine.begin() as conn:
  417. rows = {
  418. r[0]: (r[1], bool(r[2]))
  419. for r in (await conn.execute(text("SELECT id, created_by_id, shared FROM library_folders"))).all()
  420. }
  421. assert rows[ids["a"]] == (alice_id, False)
  422. assert rows[ids["a_old"]] == (alice_id, False)
  423. assert rows[ids["b"]] == (bob_id, False)
  424. assert rows[ids["students"]] == (None, True) # two owners below it
  425. assert rows[ids["empty"]] == (None, True)
  426. assert rows[ids["maker"]] == (None, True) # import destination
  427. assert rows[ids["linked"]] == (None, True)
  428. # Gated: a second boot leaves an admin's later choice alone.
  429. async with test_engine.begin() as conn:
  430. await conn.execute(text("UPDATE library_folders SET shared = FALSE WHERE id = :i"), {"i": ids["empty"]})
  431. await _backfill_library_folder_owners(conn)
  432. again = (
  433. await conn.execute(text("SELECT shared FROM library_folders WHERE id = :i"), {"i": ids["empty"]})
  434. ).scalar_one()
  435. assert not again