From d4c69a08ca18d27e48c93b6f83c8f03d6910f4be Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 09:54:43 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20detach=20restricted=20fold?= =?UTF-8?q?ers=20by=20deleting=20their=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a shortcut removes the entry from the containing folder without trashing anything. An owner excluded from the target acts on the container only and can neither destroy, declassify nor read it: the folder keeps its accesses, stays restricted, and surfaces in its members' top-level listing. --- src/backend/core/api/viewsets.py | 5 ++- src/backend/core/models.py | 14 ++++++ .../tests/items/test_api_items_shortcuts.py | 43 +++++++++++++++++++ .../core/tests/test_models_items_shortcuts.py | 25 +++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index fe9091b3..ae167dd8 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -689,7 +689,10 @@ class ItemViewSet( def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" - instance.soft_delete() + if instance.type == models.ItemTypeChoices.SHORTCUT: + instance.detach() + else: + instance.soft_delete() def perform_update(self, serializer): """Override to check if a file is renamed in order to rename file on storage.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index c201b0a3..f835ec83 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1683,6 +1683,20 @@ class Item(TreeModel, BaseModel): self.link_reach = None self.save(update_fields=["link_reach"]) + def detach(self): + """Delete this shortcut row, leaving its restricted target untouched.""" + if self.type != ItemTypeChoices.SHORTCUT: + raise ValidationError( + { + "type": ValidationError( + _("Only shortcuts can be detached"), + code="item_detach_not_a_shortcut", + ) + } + ) + + self._meta.model.objects.filter(pk=self.pk).delete() + @transaction.atomic def unrestrict(self): """Lift restriction and reattach the folder at its shortcut location.""" diff --git a/src/backend/core/tests/items/test_api_items_shortcuts.py b/src/backend/core/tests/items/test_api_items_shortcuts.py index 0b8e371d..f03fa959 100644 --- a/src/backend/core/tests/items/test_api_items_shortcuts.py +++ b/src/backend/core/tests/items/test_api_items_shortcuts.py @@ -124,6 +124,49 @@ def test_api_items_shortcuts_non_shortcut_target_is_none(): assert response.json()["target"] is None +def test_api_items_shortcuts_delete_detaches_the_target(): + """Deleting a shortcut detaches the restricted folder without touching it.""" + parent_owner = factories.UserFactory() + owner = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(parent_owner, "owner")], + ) + folder = _create_restricted_folder(parent, owner) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(parent_owner) + + response = client.delete(f"/api/v1.0/items/{shortcut.id!s}/") + + assert response.status_code == 204 + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + folder.refresh_from_db() + assert folder.is_restricted is True + assert folder.deleted_at is None + assert models.ItemAccess.objects.filter(item=folder, user=owner, role="owner").exists() + + +def test_api_items_shortcuts_delete_forbidden_for_reader(): + """A reader of the containing folder cannot detach a shortcut.""" + reader = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(reader, "reader")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + shortcut = folder.shortcut + + client = APIClient() + client.force_login(reader) + + response = client.delete(f"/api/v1.0/items/{shortcut.id!s}/") + + assert response.status_code == 403 + assert models.Item.objects.filter(pk=shortcut.pk).exists() + + def test_api_items_shortcuts_children_list_constant_queries(): """The number of queries does not grow with the number of shortcuts listed.""" parent_owner = factories.UserFactory() diff --git a/src/backend/core/tests/test_models_items_shortcuts.py b/src/backend/core/tests/test_models_items_shortcuts.py index 6580f204..7801c618 100644 --- a/src/backend/core/tests/test_models_items_shortcuts.py +++ b/src/backend/core/tests/test_models_items_shortcuts.py @@ -63,6 +63,31 @@ def test_models_items_shortcuts_move_rejects_own_target_subtree(): shortcut.move(folder) +def test_models_items_shortcuts_detach_deletes_the_row(): + """Detaching a shortcut deletes its row and leaves the target untouched.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + shortcut = folder.shortcut + + shortcut.detach() + + assert not models.Item.objects.filter(pk=shortcut.pk).exists() + folder.refresh_from_db() + assert folder.is_restricted is True + assert str(folder.path) == str(folder.id) + assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists() + + +def test_models_items_shortcuts_detach_rejects_other_types(): + """Only shortcuts can be detached.""" + folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + + with pytest.raises(ValidationError, match="Only shortcuts can be detached"): + folder.detach() + + def test_models_items_shortcuts_item_factory_never_generates_shortcuts(): """The generic item factory should only draw folder and file types.""" types = {factories.ItemFactory().type for _ in range(20)}