From 5137333e02f6d44c2cda1ceb196987f3a6e627a8 Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Mon, 11 May 2026 22:48:38 +0530 Subject: [PATCH 1/4] 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 38c93f7b69808313b35911bb468237cc869ecc92 Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Tue, 12 May 2026 13:31:55 +0530 Subject: [PATCH 2/4] 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 1f04b8b9e1c1e3ea2e3cfcafda24faee62e8a179 Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Wed, 13 May 2026 22:13:20 +0530 Subject: [PATCH 3/4] 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) From 98e22ea1a876c0ee1d45368172333629258439b2 Mon Sep 17 00:00:00 2001 From: Prashant-thakur77 Date: Wed, 27 May 2026 22:47:27 +0530 Subject: [PATCH 4/4] updated accordign to the review --- .../tests/viewsets/test_invitation.py | 28 +++++++++++++++---- .../contentcuration/viewsets/invitation.py | 6 ++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index e5eed46387..e07d52cb59 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -493,11 +493,15 @@ def test_decline_invitation_by_unrelated_user_is_not_found(self): invitation.refresh_from_db() self.assertFalse(invitation.declined) + def _make_admin(self, email="admin@example.com"): + user = testdata.user(email) + user.is_admin = True + user.save() + return user + 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() + admin_user = self._make_admin() self.client.force_authenticate(user=admin_user) response = self.client.post( @@ -509,9 +513,7 @@ def test_accept_invitation_by_admin_succeeds(self): 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() + admin_user = self._make_admin() self.client.force_authenticate(user=admin_user) response = self.client.post( @@ -520,3 +522,17 @@ def test_decline_invitation_by_admin_succeeds(self): self.assertEqual(response.status_code, 200, response.content) invitation.refresh_from_db() self.assertTrue(invitation.declined) + + def test_accept_revoked_invitation_returns_400(self): + invitation = models.Invitation.objects.create(**self.invitation_db_metadata) + invitation.revoked = True + invitation.save() + + self.client.force_authenticate(user=self.invited_user) + response = self.client.post( + reverse("invitation-accept", kwargs={"pk": invitation.id}) + ) + self.assertEqual(response.status_code, 400, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.accepted) + self.assertFalse(self.channel.editors.filter(pk=self.invited_user.id).exists()) diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index cd500cd74c..75f40149cf 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -1,6 +1,7 @@ from django_filters.rest_framework import CharFilter from django_filters.rest_framework import FilterSet from rest_framework import serializers +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated @@ -148,6 +149,11 @@ def _ensure_invitee(self, request, invitation): def accept(self, request, pk=None): invitation = self.get_edit_object() self._ensure_invitee(request, invitation) + if invitation.revoked: + return Response( + "Invitation has been revoked", + status=status.HTTP_400_BAD_REQUEST, + ) invitation.accept() invitation.accepted = True invitation.save()