| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- """Library folder ownership and sharing (#3201).
- A library:read_own user sees their own folders, shared ones, folders holding
- their files and the parents leading there; they add only to their own and
- shared folders. Counts and activity times only reflect their own files.
- """
- import io
- import zipfile
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import text
- from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
- def _h(token: str) -> dict:
- return {"Authorization": f"Bearer {token}"}
- def _ids(tree: list[dict]) -> set[int]:
- out: set[int] = set()
- for node in tree:
- out.add(node["id"])
- out |= _ids(node["children"])
- return out
- def _find(tree: list[dict], folder_id: int) -> dict | None:
- for node in tree:
- if node["id"] == folder_id:
- return node
- found = _find(node["children"], folder_id)
- if found:
- return found
- return None
- class TestFolderOwnership(TestOwnershipPermissionsSetup):
- @pytest.fixture
- async def folder(self, db_session):
- async def _create(**kwargs):
- from backend.app.models.library import LibraryFolder
- folder = LibraryFolder(**{"name": "F", **kwargs})
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- return folder
- return _create
- @pytest.fixture
- async def file(self, db_session):
- counter = [0]
- async def _create(**kwargs):
- from backend.app.models.library import LibraryFile
- counter[0] += 1
- row = LibraryFile(
- **{
- "filename": f"f{counter[0]}.3mf",
- "file_path": f"library/f{counter[0]}.3mf",
- "file_type": "3mf",
- "file_size": 1,
- **kwargs,
- }
- )
- db_session.add(row)
- await db_session.commit()
- await db_session.refresh(row)
- return row
- return _create
- # ---- visibility ---------------------------------------------------------
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tree_shows_only_what_the_user_may_see(self, async_client: AsyncClient, auth_setup, folder, file):
- me = auth_setup["operator_user"]["id"]
- other = auth_setup["operator2_user"]["id"]
- mine = await folder(name="Mine", created_by_id=me)
- theirs = await folder(name="Theirs", created_by_id=other)
- shared = await folder(name="Class", created_by_id=other, shared=True)
- # Their private folder holding one of my files: visible, and its
- # parent too, for navigation; a sibling of it is not.
- outer = await folder(name="Outer", created_by_id=other)
- inner = await folder(name="Inner", created_by_id=other, parent_id=outer.id)
- sibling = await folder(name="Sibling", created_by_id=other, parent_id=outer.id)
- await file(folder_id=inner.id, created_by_id=me)
- unowned_private = await folder(name="Unshared")
- tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
- assert _ids(tree) == {mine.id, shared.id, outer.id, inner.id}
- assert theirs.id not in _ids(tree) and sibling.id not in _ids(tree)
- assert unowned_private.id not in _ids(tree)
- node = _find(tree, mine.id)
- assert node["can_write"] and node["can_rename"] and node["can_delete"] and not node["shared"]
- assert _find(tree, shared.id)["can_write"] and not _find(tree, shared.id)["can_rename"]
- # Reachable, not writable.
- assert not _find(tree, outer.id)["can_write"] and not _find(tree, inner.id)["can_write"]
- admin_tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["admin_token"]))).json()
- assert {theirs.id, sibling.id, unowned_private.id} <= _ids(admin_tree)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_counts_and_activity_reflect_own_files_only(
- self, async_client: AsyncClient, auth_setup, folder, file
- ):
- """lonix's point on #3201: counts and times leaked other users' files."""
- from datetime import datetime
- me = auth_setup["operator_user"]["id"]
- other = auth_setup["operator2_user"]["id"]
- shared = await folder(name="Class", shared=True, created_by_id=other)
- await file(folder_id=shared.id, created_by_id=me)
- recent = datetime(2030, 1, 1)
- await file(folder_id=shared.id, created_by_id=other, updated_at=recent)
- await file(folder_id=shared.id, created_by_id=other)
- tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
- node = _find(tree, shared.id)
- assert node["file_count"] == 1
- assert not node["latest_activity_at"].startswith("2030")
- single = await async_client.get(
- f"/api/v1/library/folders/{shared.id}", headers=_h(auth_setup["operator_token"])
- )
- assert single.json()["file_count"] == 1
- admin = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["admin_token"]))).json()
- assert _find(admin, shared.id)["file_count"] == 3
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_hidden_folder_answers_404(self, async_client: AsyncClient, auth_setup, folder):
- theirs = await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
- token = auth_setup["operator_token"]
- assert (await async_client.get(f"/api/v1/library/folders/{theirs.id}", headers=_h(token))).status_code == 404
- readme = await async_client.get(f"/api/v1/library/folders/{theirs.id}/readme", headers=_h(token))
- assert readme.status_code == 404 and readme.json()["detail"] == "Folder not found"
- assert (
- await async_client.get(f"/api/v1/library/folders/{theirs.id}", headers=_h(auth_setup["operator2_token"]))
- ).status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_stats_count_visible_folders(self, async_client: AsyncClient, auth_setup, folder):
- await folder(name="Mine", created_by_id=auth_setup["operator_user"]["id"])
- await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
- await folder(name="Class", shared=True)
- stats = await async_client.get("/api/v1/library/stats", headers=_h(auth_setup["operator_token"]))
- assert stats.json()["total_folders"] == 2
- admin = await async_client.get("/api/v1/library/stats", headers=_h(auth_setup["admin_token"]))
- assert admin.json()["total_folders"] == 3
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_scan_of_a_hidden_mount_answers_404(self, async_client: AsyncClient, auth_setup, folder):
- hidden = await folder(
- name="Mount", is_external=True, external_path="/nonexistent", created_by_id=auth_setup["admin_user"]["id"]
- )
- response = await async_client.post(
- f"/api/v1/library/folders/{hidden.id}/scan", headers=_h(auth_setup["operator_token"])
- )
- assert response.status_code == 404
- # ---- creating and adding ------------------------------------------------
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_new_folder_belongs_to_its_creator(self, async_client: AsyncClient, auth_setup):
- created = await async_client.post(
- "/api/v1/library/folders", json={"name": "Assignment 1"}, headers=_h(auth_setup["operator_token"])
- )
- assert created.status_code == 200
- body = created.json()
- assert body["created_by_id"] == auth_setup["operator_user"]["id"]
- assert body["shared"] is False and body["can_write"] is True
- other = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator2_token"]))).json()
- assert body["id"] not in _ids(other)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_folder_made_without_a_user_is_shared(self, async_client: AsyncClient):
- """Auth off: nobody to own it, so it stays everyone's, as before."""
- body = (await async_client.post("/api/v1/library/folders", json={"name": "Open"})).json()
- assert body["created_by_id"] is None and body["shared"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_subfolder_only_where_the_user_may_write(self, async_client: AsyncClient, auth_setup, folder, file):
- me = auth_setup["operator_user"]["id"]
- other = auth_setup["operator2_user"]["id"]
- theirs = await folder(name="Theirs", created_by_id=other)
- shared = await folder(name="Class", created_by_id=other, shared=True)
- passthrough = await folder(name="Outer", created_by_id=other)
- await file(folder_id=passthrough.id, created_by_id=me)
- token = auth_setup["operator_token"]
- async def create(parent_id):
- return await async_client.post(
- "/api/v1/library/folders", json={"name": "Sub", "parent_id": parent_id}, headers=_h(token)
- )
- assert (await create(theirs.id)).status_code == 404
- assert (await create(passthrough.id)).status_code == 403
- assert (await create(shared.id)).status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_move_files_only_into_writable_folders(self, async_client: AsyncClient, auth_setup, folder, file):
- me = auth_setup["operator_user"]["id"]
- other = auth_setup["operator2_user"]["id"]
- theirs = await folder(name="Theirs", created_by_id=other)
- shared = await folder(name="Class", shared=True)
- mine = await file(created_by_id=me)
- token = auth_setup["operator_token"]
- moved = await async_client.post(
- "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": theirs.id}, headers=_h(token)
- )
- assert moved.status_code == 404
- update = await async_client.put(
- f"/api/v1/library/files/{mine.id}", json={"folder_id": theirs.id}, headers=_h(token)
- )
- assert update.status_code == 404
- moved = await async_client.post(
- "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": shared.id}, headers=_h(token)
- )
- assert moved.status_code == 200 and moved.json()["moved"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_zip_never_reuses_another_users_folder(self, async_client: AsyncClient, auth_setup, folder):
- """Extracting "Alice.zip" must not land in Alice's own "Alice" folder."""
- alice_folder = await folder(name="Alice", created_by_id=auth_setup["operator2_user"]["id"])
- buf = io.BytesIO()
- with zipfile.ZipFile(buf, "w") as zf:
- zf.writestr("Alice/model.txt", "x")
- response = await async_client.post(
- "/api/v1/library/files/extract-zip",
- files={"file": ("Alice.zip", buf.getvalue(), "application/zip")},
- params={"preserve_structure": "true", "create_folder_from_zip": "true"},
- headers=_h(auth_setup["operator_token"]),
- )
- assert response.status_code == 200
- landed = response.json()["files"][0]["folder_id"]
- assert landed != alice_folder.id
- tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
- assert alice_folder.id not in _ids(tree) and landed in _ids(tree)
- # ---- renaming, sharing, deleting ---------------------------------------
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_owner_renames_admin_shares(self, async_client: AsyncClient, auth_setup, folder):
- me = auth_setup["operator_user"]["id"]
- mine = await folder(name="Mine", created_by_id=me)
- class_folder = await folder(name="Class", shared=True, created_by_id=auth_setup["admin_user"]["id"])
- token = auth_setup["operator_token"]
- renamed = await async_client.put(
- f"/api/v1/library/folders/{mine.id}", json={"name": "Mine2"}, headers=_h(token)
- )
- assert renamed.status_code == 200 and renamed.json()["name"] == "Mine2"
- assert (
- await async_client.put(f"/api/v1/library/folders/{class_folder.id}", json={"name": "X"}, headers=_h(token))
- ).status_code == 403
- assert (
- await async_client.put(f"/api/v1/library/folders/{mine.id}", json={"shared": True}, headers=_h(token))
- ).status_code == 403
- # The admin shares my folder: operator2 now sees it and may add to it.
- shared = await async_client.put(
- f"/api/v1/library/folders/{mine.id}", json={"shared": True}, headers=_h(auth_setup["admin_token"])
- )
- assert shared.status_code == 200 and shared.json()["shared"] is True
- other = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator2_token"]))).json()
- assert _find(other, mine.id)["can_write"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_owner_moves_folder_only_into_writable_parent(self, async_client: AsyncClient, auth_setup, folder):
- me = auth_setup["operator_user"]["id"]
- mine = await folder(name="Mine", created_by_id=me)
- theirs = await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
- token = auth_setup["operator_token"]
- assert (
- await async_client.put(
- f"/api/v1/library/folders/{mine.id}", json={"parent_id": theirs.id}, headers=_h(token)
- )
- ).status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_owner_deletes_folder_only_when_everything_is_theirs(
- self, async_client: AsyncClient, auth_setup, folder, file
- ):
- me = auth_setup["operator_user"]["id"]
- other = auth_setup["operator2_user"]["id"]
- token = auth_setup["operator_token"]
- own_full = await folder(name="Full", created_by_id=me)
- await folder(name="Sub", created_by_id=me, parent_id=own_full.id)
- await file(folder_id=own_full.id, created_by_id=me)
- assert (
- await async_client.delete(f"/api/v1/library/folders/{own_full.id}", headers=_h(token))
- ).status_code == 200
- mixed = await folder(name="Mixed", created_by_id=me, shared=True)
- await file(folder_id=mixed.id, created_by_id=other)
- assert (await async_client.delete(f"/api/v1/library/folders/{mixed.id}", headers=_h(token))).status_code == 403
- their_shared = await folder(name="Class", created_by_id=other, shared=True)
- assert (
- await async_client.delete(f"/api/v1/library/folders/{their_shared.id}", headers=_h(token))
- ).status_code == 403
- theirs = await folder(name="Theirs", created_by_id=other)
- assert (await async_client.delete(f"/api/v1/library/folders/{theirs.id}", headers=_h(token))).status_code == 404
- bulk = await async_client.post(
- "/api/v1/library/bulk-delete",
- json={"file_ids": [], "folder_ids": [theirs.id, their_shared.id]},
- headers=_h(token),
- )
- assert bulk.json()["deleted_folders"] == 0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_all_permissions_reach_folders_they_cannot_see(
- self, async_client: AsyncClient, auth_setup, folder, file
- ):
- """A group with update_all/delete_all but only read_own kept full reach before #3201."""
- admin = _h(auth_setup["admin_token"])
- groups = (await async_client.get("/api/v1/groups/", headers=admin)).json()
- operators = next(g for g in groups if g["name"] == "Operators")
- perms = [p for p in operators["permissions"] if p not in ("library:update_own", "library:delete_own")]
- perms += ["library:update_all", "library:delete_all"]
- group = await async_client.post(
- "/api/v1/groups/", json={"name": "Editors", "permissions": perms}, headers=admin
- )
- assert group.status_code in (200, 201), group.text
- user = await async_client.post(
- "/api/v1/users/",
- json={"username": "editor1", "password": "Editorpass1!", "group_ids": [group.json()["id"]]},
- headers=admin,
- )
- assert user.status_code in (200, 201), user.text
- login = await async_client.post("/api/v1/auth/login", json={"username": "editor1", "password": "Editorpass1!"})
- token = login.json()["access_token"]
- other = auth_setup["operator2_user"]["id"]
- theirs = await folder(name="Theirs", created_by_id=other)
- doomed = await folder(name="Doomed", created_by_id=other)
- mine = await file(created_by_id=user.json()["id"])
- # Still hidden from view: read_own decides what is listed.
- tree = (await async_client.get("/api/v1/library/folders", headers=_h(token))).json()
- assert theirs.id not in _ids(tree)
- # But the *_all permissions act on it as before.
- renamed = await async_client.put(f"/api/v1/library/folders/{theirs.id}", json={"name": "R"}, headers=_h(token))
- assert renamed.status_code == 200
- moved = await async_client.post(
- "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": theirs.id}, headers=_h(token)
- )
- assert moved.status_code == 200 and moved.json()["moved"] == 1
- assert (await async_client.delete(f"/api/v1/library/folders/{doomed.id}", headers=_h(token))).status_code == 200
- # ---- imports ------------------------------------------------------------
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_import_folder(self, db_session, auth_setup):
- from sqlalchemy import select
- from sqlalchemy.orm import selectinload
- from backend.app.models.user import User
- from backend.app.services.library_folder_access import default_import_folder
- users = {
- u.id: u for u in (await db_session.execute(select(User).options(selectinload(User.groups)))).scalars().all()
- }
- op1 = users[auth_setup["operator_user"]["id"]]
- op2 = users[auth_setup["operator2_user"]["id"]]
- first = await default_import_folder(db_session, "MakerWorld", op1)
- assert first.shared is True
- assert (await default_import_folder(db_session, "MakerWorld", op2)).id == first.id
- # An admin made it private: op2 gets one of their own, not a refusal.
- first.shared = False
- await db_session.flush()
- own = await default_import_folder(db_session, "MakerWorld", op2)
- assert own.id != first.id and own.created_by_id == op2.id and own.shared is False
- class TestFolderOwnerBackfill:
- @pytest.mark.asyncio
- async def test_infers_owners_once(self, test_engine, db_session):
- from backend.app.core.database import _backfill_library_folder_owners
- from backend.app.models.library import LibraryFile, LibraryFolder
- from backend.app.models.project import Project
- from backend.app.models.user import User
- alice = User(username="alice", password_hash="x", role="user")
- bob = User(username="bob", password_hash="x", role="user")
- project = Project(name="P")
- db_session.add_all([alice, bob, project])
- await db_session.flush()
- def folder(name, parent=None, **kw):
- f = LibraryFolder(name=name, parent_id=parent.id if parent else None, **kw)
- db_session.add(f)
- return f
- students = folder("Students")
- await db_session.flush()
- a = folder("Alice", students)
- b = folder("Bob", students)
- empty = folder("Testing")
- maker = folder("MakerWorld")
- linked = folder("Linked")
- await db_session.flush()
- a_old = folder("Old", a) # empty, inside Alice's folder
- linked.project_id = project.id
- await db_session.flush()
- from datetime import datetime, timezone
- db_session.add_all(
- [
- LibraryFile(
- folder_id=a.id, created_by_id=alice.id, filename="1", file_path="1", file_type="3mf", file_size=1
- ),
- LibraryFile(
- folder_id=a.id,
- created_by_id=alice.id,
- filename="2",
- file_path="2",
- file_type="3mf",
- file_size=1,
- deleted_at=datetime.now(timezone.utc),
- ),
- LibraryFile(
- folder_id=b.id, created_by_id=bob.id, filename="3", file_path="3", file_type="3mf", file_size=1
- ),
- LibraryFile(
- folder_id=maker.id,
- created_by_id=alice.id,
- filename="4",
- file_path="4",
- file_type="3mf",
- file_size=1,
- ),
- LibraryFile(
- folder_id=linked.id, created_by_id=bob.id, filename="5", file_path="5", file_type="3mf", file_size=1
- ),
- ]
- )
- await db_session.commit()
- ids = {
- n: f.id
- for n, f in {
- "students": students,
- "a": a,
- "b": b,
- "empty": empty,
- "maker": maker,
- "linked": linked,
- "a_old": a_old,
- }.items()
- }
- alice_id, bob_id = alice.id, bob.id
- async with test_engine.begin() as conn:
- await _backfill_library_folder_owners(conn)
- async with test_engine.begin() as conn:
- rows = {
- r[0]: (r[1], bool(r[2]))
- for r in (await conn.execute(text("SELECT id, created_by_id, shared FROM library_folders"))).all()
- }
- assert rows[ids["a"]] == (alice_id, False)
- assert rows[ids["a_old"]] == (alice_id, False)
- assert rows[ids["b"]] == (bob_id, False)
- assert rows[ids["students"]] == (None, True) # two owners below it
- assert rows[ids["empty"]] == (None, True)
- assert rows[ids["maker"]] == (None, True) # import destination
- assert rows[ids["linked"]] == (None, True)
- # Gated: a second boot leaves an admin's later choice alone.
- async with test_engine.begin() as conn:
- await conn.execute(text("UPDATE library_folders SET shared = FALSE WHERE id = :i"), {"i": ids["empty"]})
- await _backfill_library_folder_owners(conn)
- again = (
- await conn.execute(text("SELECT shared FROM library_folders WHERE id = :i"), {"i": ids["empty"]})
- ).scalar_one()
- assert not again
|