From e3232e316959274ac860e3de2cf328d0218ca1c6 Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Mon, 11 May 2026 22:48:38 +0530 Subject: [PATCH 1/3] Resolved the issue --- .../tests/viewsets/test_invitation.py | 60 +++++++++++++++++++ .../contentcuration/viewsets/invitation.py | 17 +++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index f044f50a99..5f8d17f3d6 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -446,3 +446,63 @@ def test_update_invitation_decline(self): ).exists() ) self.assertTrue(models.Change.objects.filter(channel=self.channel).exists()) + + def test_accept_invitation_by_channel_editor_is_forbidden(self): + """ + self.user is a channel editor (not the invited user). + filter_edit_queryset allows editors to view the invitation, but + _ensure_invitee must prevent them from accepting it (403). + """ + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("invitation-accept", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 403, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.accepted) + + def test_decline_invitation_by_channel_editor_is_forbidden(self): + """ + self.user is a channel editor (not the invited user). + filter_edit_queryset allows editors to view the invitation, but + _ensure_invitee must prevent them from declining it (403). + """ + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("invitation-decline", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 403, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.declined) + + def test_accept_invitation_by_unrelated_user_is_not_found(self): + """ + A completely unrelated user (not the invitee, sender, or channel editor) + cannot even retrieve the invitation from get_edit_object() — they get 404. + """ + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + unrelated_user = testdata.user("unrelated@example.com") + self.client.force_authenticate(user=unrelated_user) + response = self.client.post( + reverse("invitation-accept", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 404, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.accepted) + + def test_decline_invitation_by_unrelated_user_is_not_found(self): + """ + A completely unrelated user (not the invitee, sender, or channel editor) + cannot even retrieve the invitation from get_edit_object() — they get 404. + """ + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + unrelated_user = testdata.user("unrelated@example.com") + self.client.force_authenticate(user=unrelated_user) + response = self.client.post( + reverse("invitation-decline", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 404, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.declined) diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index 7d8ff577f6..c0a4fc01f7 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -2,6 +2,7 @@ from django_filters.rest_framework import FilterSet from rest_framework import serializers from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -137,9 +138,20 @@ def perform_update(self, serializer): instance = serializer.save() instance.save() + def _ensure_invitee(self, request, invitation): + """ + Raise PermissionDenied unless the requesting user is the invited user + (matched by email, case-insensitively) or a site admin. + """ + if request.user.is_admin: + return + if (request.user.email or "").lower() != (invitation.email or "").lower(): + raise PermissionDenied("Only the invited user may perform this action.") + @action(detail=True, methods=["post"]) def accept(self, request, pk=None): - invitation = self.get_object() + invitation = self.get_edit_object() + self._ensure_invitee(request, invitation) invitation.accept() invitation.accepted = True invitation.save() @@ -157,7 +169,8 @@ def accept(self, request, pk=None): @action(detail=True, methods=["post"]) def decline(self, request, pk=None): - invitation = self.get_object() + invitation = self.get_edit_object() + self._ensure_invitee(request, invitation) invitation.declined = True invitation.save() Change.create_change( From 116bf273f13a9c8d5e570b7e33be5b83e494a20a Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Tue, 12 May 2026 13:31:55 +0530 Subject: [PATCH 2/3] FIX issue --- .../tests/viewsets/test_invitation.py | 22 ++++--------------- .../contentcuration/viewsets/invitation.py | 4 ---- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index 5f8d17f3d6..48a30fb24b 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -448,12 +448,8 @@ def test_update_invitation_decline(self): self.assertTrue(models.Change.objects.filter(channel=self.channel).exists()) def test_accept_invitation_by_channel_editor_is_forbidden(self): - """ - self.user is a channel editor (not the invited user). - filter_edit_queryset allows editors to view the invitation, but - _ensure_invitee must prevent them from accepting it (403). - """ invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + self.client.force_authenticate(user=self.user) response = self.client.post( reverse("invitation-accept", kwargs={"pk": invitation.id}) @@ -463,12 +459,8 @@ def test_accept_invitation_by_channel_editor_is_forbidden(self): self.assertFalse(invitation.accepted) def test_decline_invitation_by_channel_editor_is_forbidden(self): - """ - self.user is a channel editor (not the invited user). - filter_edit_queryset allows editors to view the invitation, but - _ensure_invitee must prevent them from declining it (403). - """ invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + self.client.force_authenticate(user=self.user) response = self.client.post( reverse("invitation-decline", kwargs={"pk": invitation.id}) @@ -478,12 +470,9 @@ def test_decline_invitation_by_channel_editor_is_forbidden(self): self.assertFalse(invitation.declined) def test_accept_invitation_by_unrelated_user_is_not_found(self): - """ - A completely unrelated user (not the invitee, sender, or channel editor) - cannot even retrieve the invitation from get_edit_object() — they get 404. - """ invitation = models.Invitation.objects.create(**self.invitation_db_metadata) unrelated_user = testdata.user("unrelated@example.com") + self.client.force_authenticate(user=unrelated_user) response = self.client.post( reverse("invitation-accept", kwargs={"pk": invitation.id}) @@ -493,12 +482,9 @@ def test_accept_invitation_by_unrelated_user_is_not_found(self): self.assertFalse(invitation.accepted) def test_decline_invitation_by_unrelated_user_is_not_found(self): - """ - A completely unrelated user (not the invitee, sender, or channel editor) - cannot even retrieve the invitation from get_edit_object() — they get 404. - """ invitation = models.Invitation.objects.create(**self.invitation_db_metadata) unrelated_user = testdata.user("unrelated@example.com") + self.client.force_authenticate(user=unrelated_user) response = self.client.post( reverse("invitation-decline", kwargs={"pk": invitation.id}) diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index c0a4fc01f7..cd500cd74c 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -139,10 +139,6 @@ def perform_update(self, serializer): instance.save() def _ensure_invitee(self, request, invitation): - """ - Raise PermissionDenied unless the requesting user is the invited user - (matched by email, case-insensitively) or a site admin. - """ if request.user.is_admin: return if (request.user.email or "").lower() != (invitation.email or "").lower(): From ef87eb66ec8481015ee1fd5af250b41d5748fa07 Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Wed, 13 May 2026 22:13:20 +0530 Subject: [PATCH 3/3] added tests --- .../tests/viewsets/test_invitation.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index 48a30fb24b..e5eed46387 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -492,3 +492,31 @@ def test_decline_invitation_by_unrelated_user_is_not_found(self): self.assertEqual(response.status_code, 404, response.content) invitation.refresh_from_db() self.assertFalse(invitation.declined) + + def test_accept_invitation_by_admin_succeeds(self): + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + admin_user = testdata.user("admin@example.com") + admin_user.is_admin = True + admin_user.save() + + self.client.force_authenticate(user=admin_user) + response = self.client.post( + reverse("invitation-accept", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.accepted) + + def test_decline_invitation_by_admin_succeeds(self): + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + admin_user = testdata.user("admin@example.com") + admin_user.is_admin = True + admin_user.save() + + self.client.force_authenticate(user=admin_user) + response = self.client.post( + reverse("invitation-decline", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.declined)