Skip to content

Commit 8990a33

Browse files
mihowclaude
andcommitted
fix: use IsProjectMemberOrReadOnly for TaxaListViewSet permissions
ObjectPermission doesn't work for M2M-to-project models because BaseModel.get_project() returns None when get_project_accessor() returns "projects". This caused all write operations (create, update, delete) on taxa lists to be denied for every user. Switch to IsProjectMemberOrReadOnly which resolves the project via ProjectMixin.get_active_project() (from query param) instead of through the model instance. Add 10 permission tests covering member CRUD, anonymous read-only, non-member rejection, and owner access. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8f5144e commit 8990a33

2 files changed

Lines changed: 66 additions & 3 deletions

File tree

ami/main/api/views.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,7 +1634,7 @@ class TaxaListViewSet(DefaultViewSet, ProjectMixin):
16341634
"created_at",
16351635
"updated_at",
16361636
]
1637-
permission_classes = [ObjectPermission]
1637+
permission_classes = [IsProjectMemberOrReadOnly]
16381638
require_project = True
16391639

16401640
def get_queryset(self):
@@ -1652,8 +1652,6 @@ def perform_create(self, serializer):
16521652
16531653
Users cannot manually assign taxa lists to projects for security reasons.
16541654
A taxa list is always created in the context of the active project.
1655-
1656-
@TODO Do we need to check permissions here? Is this user allowed to add taxa lists to this project?
16571655
"""
16581656
instance = serializer.save()
16591657
project = self.get_active_project()

ami/main/tests.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3445,6 +3445,71 @@ def test_taxon_detail_visible_when_excluded_from_list(self):
34453445
self.assertEqual(res.status_code, status.HTTP_200_OK)
34463446

34473447

3448+
class TaxaListViewSetPermissionTestCase(TestCase):
3449+
"""Test TaxaListViewSet CRUD permissions for project members vs anonymous users."""
3450+
3451+
def setUp(self):
3452+
self.owner = User.objects.create_user(email="owner@example.com", password="testpass")
3453+
self.member = User.objects.create_user(email="member@example.com", password="testpass")
3454+
self.non_member = User.objects.create_user(email="nonmember@example.com", password="testpass")
3455+
self.project = Project.objects.create(name="Test Project", owner=self.owner)
3456+
self.project.members.add(self.member)
3457+
self.taxa_list = TaxaList.objects.create(name="Existing List", description="A list")
3458+
self.taxa_list.projects.add(self.project)
3459+
self.client = APIClient()
3460+
self.list_url = f"/api/v2/taxa/lists/?project_id={self.project.pk}"
3461+
self.detail_url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/?project_id={self.project.pk}"
3462+
3463+
def test_member_can_list_taxa_lists(self):
3464+
self.client.force_authenticate(self.member)
3465+
response = self.client.get(self.list_url)
3466+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3467+
3468+
def test_member_can_create_taxa_list(self):
3469+
self.client.force_authenticate(self.member)
3470+
response = self.client.post(self.list_url, {"name": "New List"})
3471+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
3472+
3473+
def test_member_can_update_taxa_list(self):
3474+
self.client.force_authenticate(self.member)
3475+
response = self.client.patch(self.detail_url, {"name": "Renamed"})
3476+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3477+
self.taxa_list.refresh_from_db()
3478+
self.assertEqual(self.taxa_list.name, "Renamed")
3479+
3480+
def test_member_can_delete_taxa_list(self):
3481+
self.client.force_authenticate(self.member)
3482+
response = self.client.delete(self.detail_url)
3483+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
3484+
3485+
def test_anonymous_can_read_taxa_lists(self):
3486+
response = self.client.get(self.list_url)
3487+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3488+
3489+
def test_anonymous_cannot_create_taxa_list(self):
3490+
response = self.client.post(self.list_url, {"name": "Anon List"})
3491+
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
3492+
3493+
def test_anonymous_cannot_update_taxa_list(self):
3494+
response = self.client.patch(self.detail_url, {"name": "Hacked"})
3495+
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
3496+
3497+
def test_non_member_cannot_create_taxa_list(self):
3498+
self.client.force_authenticate(self.non_member)
3499+
response = self.client.post(self.list_url, {"name": "Intruder List"})
3500+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
3501+
3502+
def test_non_member_cannot_update_taxa_list(self):
3503+
self.client.force_authenticate(self.non_member)
3504+
response = self.client.patch(self.detail_url, {"name": "Hacked"})
3505+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
3506+
3507+
def test_owner_can_manage_taxa_list(self):
3508+
self.client.force_authenticate(self.owner)
3509+
response = self.client.post(self.list_url, {"name": "Owner List"})
3510+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
3511+
3512+
34483513
class TaxaListTaxonAPITestCase(TestCase):
34493514
"""Test TaxaList taxa management operations via API."""
34503515

0 commit comments

Comments
 (0)