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)