From 6aae4bde887f123c8a7ea18acc94202c14e06d2c Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Mon, 27 Jul 2026 17:18:40 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20hide=20reachable=20restric?= =?UTF-8?q?ted=20roots=20from=20the=20top-level=20listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restricted folder lives at the tree root but its members reach it through the shortcut when they can open the containing folder. The listing hides the root in that case so the folder shows up in a single location, and keeps it for members without container access. --- src/backend/core/api/viewsets.py | 26 +++ .../items/test_api_items_list_restricted.py | 182 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/backend/core/tests/items/test_api_items_list_restricted.py diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c1957765..559e6f1d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -759,6 +759,32 @@ class ItemViewSet( ) queryset = queryset.filter(path__in=root_paths) + # Hide restricted roots the user already reaches through a live + # shortcut, so the folder shows up in a single location + if user.is_authenticated: + reachable_shortcuts = models.Item.objects.filter( + type=models.ItemTypeChoices.SHORTCUT, + target_id=db.OuterRef("pk"), + ancestors_deleted_at__isnull=True, + ).filter( + db.Exists( + models.ItemAccess.objects.filter( + db.Q(user=user) | db.Q(team__in=user.teams), + item__path__ancestors=db.OuterRef("path"), + ) + ) + | db.Exists( + models.Item.objects.filter( + path__ancestors=db.OuterRef("path"), + link_reach__in=[ + LinkReachChoices.PUBLIC, + LinkReachChoices.AUTHENTICATED, + ], + ) + ) + ) + queryset = queryset.exclude(db.Q(is_restricted=True) & db.Exists(reachable_shortcuts)) + # Annotate the queryset with an attribute marking instances as highest ancestor # in order to save some time while computing abilities in the instance queryset = queryset.annotate( diff --git a/src/backend/core/tests/items/test_api_items_list_restricted.py b/src/backend/core/tests/items/test_api_items_list_restricted.py new file mode 100644 index 00000000..b5085af9 --- /dev/null +++ b/src/backend/core/tests/items/test_api_items_list_restricted.py @@ -0,0 +1,182 @@ +"""Tests for restricted roots visibility in the items list API endpoint.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def _create_restricted_folder(parent, user): + """Create a folder under parent and restrict it as user.""" + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + return folder.restrict(user) + + +def _listed_ids(client): + """Return the ids listed on the user's root listing.""" + response = client.get("/api/v1.0/items/") + assert response.status_code == 200 + return {result["id"] for result in response.json()["results"]} + + +def test_api_items_list_restricted_hidden_when_shortcut_reachable(): + """A member reaching the live shortcut does not see the restricted root.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + listed = _listed_ids(client) + assert str(parent.id) in listed + assert str(folder.id) not in listed + + +def test_api_items_list_restricted_visible_without_container_access(): + """A member without access to the container sees the restricted root.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + listed = _listed_ids(client) + assert str(folder.id) in listed + assert str(parent.id) not in listed + + +def test_api_items_list_restricted_hidden_via_team_access(mock_user_teams): + """A team access on the container hides the restricted root.""" + user = factories.UserFactory() + mock_user_teams.return_value = ["lasuite"] + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + factories.TeamItemAccessFactory(item=parent, team="lasuite", role="reader") + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + assert str(folder.id) not in _listed_ids(client) + + +def test_api_items_list_restricted_follows_moved_shortcut(): + """Moving the shortcut out of reach makes the restricted root visible again.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + other = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder.shortcut.move(other) + + client = APIClient() + client.force_login(user) + + assert str(folder.id) in _listed_ids(client) + + +def test_api_items_list_restricted_visible_when_shortcut_deleted(): + """Without a live shortcut the restricted root shows up for its members.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + models.Item.objects.filter(target=folder).delete() + + client = APIClient() + client.force_login(user) + + assert str(folder.id) in _listed_ids(client) + + +def test_api_items_list_restricted_visible_when_shortcut_trashed(): + """A shortcut under a soft deleted ancestor does not hide the restricted root.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + parent.soft_delete() + + client = APIClient() + client.force_login(user) + + assert str(folder.id) in _listed_ids(client) + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_items_list_restricted_hidden_via_link_on_container(reach): + """An open link on the container makes the shortcut reachable and hides the root.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach=reach, + link_role="reader", + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + assert str(folder.id) not in _listed_ids(client) + + +def test_api_items_list_restricted_hidden_via_inherited_link(): + """A link inherited from a grandparent counts to reach the shortcut.""" + user = factories.UserFactory() + grandparent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + link_reach="public", + link_role="reader", + ) + parent = factories.ItemFactory( + parent=grandparent, + type=models.ItemTypeChoices.FOLDER, + link_reach=None, + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=user, role="reader") + + client = APIClient() + client.force_login(user) + + assert str(folder.id) not in _listed_ids(client) + + +def test_api_items_list_restricted_differs_per_user(): + """The same restricted root is hidden or visible depending on the user.""" + insider = factories.UserFactory() + outsider = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(insider, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + factories.UserItemAccessFactory(item=folder, user=insider, role="reader") + factories.UserItemAccessFactory(item=folder, user=outsider, role="reader") + + insider_client = APIClient() + insider_client.force_login(insider) + outsider_client = APIClient() + outsider_client.force_login(outsider) + + assert str(folder.id) not in _listed_ids(insider_client) + assert str(folder.id) in _listed_ids(outsider_client)