From 4bf38253575022a0da43d3643f44b14550e69e9a Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:53:23 -0700 Subject: [PATCH] test: add tag-descendant partial-permission coverage, verify pre-migration characterization Adds TestBulkEditObjectsTagDescendantPartialPermission, exercising the tag-descendant-expansion block in BulkEditObjectsView.post as a non-superuser with object-level change_tag granted on a parent tag and one of two children but not the other, confirming the expansion only pulls in descendants the requester actually has permission on. Verified both this test and the existing apply_to_all boundary test pass unchanged against the pre-migration get_objects_for_user_owner_aware/has_perms_owner_aware code (reverted via a scratch patch of the prior commit's views.py hunk, then restored), confirming they characterize genuine pre-existing behavior rather than something the permitted_object_ids migration made necessary. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UmMBGW9FKyDgmKRJ5H9rif --- .../test_permission_filtering_security.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/documents/tests/test_permission_filtering_security.py b/src/documents/tests/test_permission_filtering_security.py index e998644b9..07cfba43f 100644 --- a/src/documents/tests/test_permission_filtering_security.py +++ b/src/documents/tests/test_permission_filtering_security.py @@ -690,3 +690,78 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary: hidden.refresh_from_db() assert visible.owner == requester assert hidden.owner == owner + + +@pytest.mark.django_db +class TestBulkEditObjectsTagDescendantPartialPermission: + def test_apply_to_all_descendant_expansion_respects_per_object_permissions( + self, + rest_api_client, + ): + """ + GIVEN: + - A tag hierarchy (parent -> permitted_child, unpermitted_child) + - A non-superuser requester with object-level change_tag granted + on the parent and on only ONE of the two children + WHEN: + - bulk_edit_objects is called with all=True and a filter that + matches only the root (parent) tag, engaging the + tag-descendant-expansion logic in BulkEditObjectsView.post + THEN: + - The descendant expansion only pulls in descendants the + requester actually has permission on: the permitted child's + owner is reassigned alongside the parent's, while the + unpermitted child keeps its original owner. This pins that the + expansion checks per-object permissions (editable_ids), not + merely "is a descendant of a filter match". + + NOTE: this uses ``set_permissions`` (owner reassignment) rather than + ``delete`` as the operation, because Tag.tn_parent (django-treenode) + cascades deletes to descendants at the database/ORM level regardless + of which tags the view resolved into ``objs`` -- a delete-based test + would pass/fail based on FK cascade behavior, not on whether the + descendant-expansion logic itself respected per-object permissions. + """ + owner = User.objects.create_user(username="tag_hierarchy_owner") + requester = User.objects.create_user(username="tag_hierarchy_requester") + # global change_tag permission so the has_perm() gate passes and the + # object-level permitted_object_ids filtering is what's under test + requester.user_permissions.add( + Permission.objects.get(codename="change_tag"), + ) + rest_api_client.force_authenticate(user=requester) + + parent = TagFactory(owner=owner, name="parent-tag") + permitted_child = TagFactory( + owner=owner, + name="permitted-child-tag", + tn_parent=parent, + ) + unpermitted_child = TagFactory( + owner=owner, + name="unpermitted-child-tag", + tn_parent=parent, + ) + assign_perm("change_tag", requester, parent) + assign_perm("change_tag", requester, permitted_child) + # unpermitted_child is intentionally NOT granted change_tag + + response = rest_api_client.post( + "/api/bulk_edit_objects/", + { + "object_type": "tags", + "operation": "set_permissions", + "all": True, + "filters": {"is_root": True}, + "owner": requester.pk, + }, + format="json", + ) + assert response.status_code == HTTPStatus.OK + + parent.refresh_from_db() + permitted_child.refresh_from_db() + unpermitted_child.refresh_from_db() + assert parent.owner == requester + assert permitted_child.owner == requester + assert unpermitted_child.owner == owner