From 5fb4b25b1f86d99e24beff62bbae5cf41b9d0197 Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Tue, 4 Aug 2026 17:47:55 -0400 Subject: [PATCH 1/3] 6009: add Organization/Invitation edit and view permission querysets Adds Organization.filter_edit_queryset/filter_view_queryset (active OrganizationRole membership: admin for edit, admin/editor/viewer for view - excluding soft-deleted organizations), mirroring the existing Channel pattern. Extends Invitation.filter_edit_queryset/ filter_view_queryset with the same organization-role checks so organization invitations are covered by the existing permission model. Adds org_admin edit rights to Invitation.filter_edit_queryset via a single filter() so the multi-valued OrganizationRole join matches one row rather than across rows. Includes testdata helpers (organization, organization_role) and direct queryset tests (OrganizationTestCase, InvitationOrganizationTestCase) covering admin vs editor vs viewer vs pending vs anonymous access. --- contentcuration/contentcuration/models.py | 52 +++++- .../contentcuration/tests/test_models.py | 170 ++++++++++++++++++ .../contentcuration/tests/testdata.py | 16 ++ 3 files changed, 237 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 368734e980..888777ce47 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1895,6 +1895,40 @@ class Meta: def __str__(self): return self.name + @classmethod + def filter_edit_queryset(cls, queryset, user): + if user.is_anonymous: + return queryset.none() + + if user.is_admin: + return queryset + + return queryset.filter( + user_roles__user=user, + user_roles__role=ORGANIZATION_ADMIN, + user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + deleted=False, + ).distinct() + + @classmethod + def filter_view_queryset(cls, queryset, user): + if user.is_anonymous: + return queryset.none() + + if user.is_admin: + return queryset + + return queryset.filter( + user_roles__user=user, + user_roles__role__in=[ + ORGANIZATION_ADMIN, + ORGANIZATION_EDITOR, + ORGANIZATION_VIEWER, + ], + user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + deleted=False, + ).distinct() + class OrganizationRole(models.Model): """ @@ -3807,7 +3841,14 @@ def filter_edit_queryset(cls, queryset, user): return queryset return queryset.filter( - Q(email__iexact=user.email) | Q(sender=user) | Q(channel__editors=user) + Q(email__iexact=user.email) + | Q(sender=user) + | Q(channel__editors=user) + | Q( + organization__user_roles__user=user, + organization__user_roles__role=ORGANIZATION_ADMIN, + organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) ).distinct() @classmethod @@ -3822,6 +3863,15 @@ def filter_view_queryset(cls, queryset, user): | Q(sender=user) | Q(channel__editors=user) | Q(channel__viewers=user) + | Q( + organization__user_roles__user=user, + organization__user_roles__role__in=[ + ORGANIZATION_ADMIN, + ORGANIZATION_EDITOR, + ORGANIZATION_VIEWER, + ], + organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) ).distinct() diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index eaf4e0370b..bf891fc48a 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -16,6 +16,15 @@ from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission from contentcuration.constants import user_history +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_ACTIVE, +) +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_PENDING, +) +from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER from contentcuration.models import AssessmentItem from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change @@ -34,6 +43,8 @@ from contentcuration.models import Language from contentcuration.models import License from contentcuration.models import object_storage_name +from contentcuration.models import Organization +from contentcuration.models import OrganizationRole from contentcuration.models import RecommendationsEvent from contentcuration.models import RecommendationsInteractionEvent from contentcuration.models import User @@ -309,6 +320,165 @@ def create_change(server_rev, applied): self.assertEqual(channel.get_server_rev(), 2) +class OrganizationTestCase(PermissionQuerysetTestCase): + @property + def base_queryset(self): + return Organization.objects.all() + + def test_filter_edit_queryset__admin_role(self): + organization = testdata.organization() + user = testdata.user() + + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=organization.id) + + def test_filter_edit_queryset__editor_role_cannot_edit(self): + organization = testdata.organization() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + def test_filter_edit_queryset__pending_admin_cannot_edit(self): + organization = testdata.organization() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_PENDING, + ) + + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + def test_filter_edit_queryset__anonymous(self): + organization = testdata.organization() + + queryset = Organization.filter_edit_queryset( + self.base_queryset, user=self.anonymous_user + ) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + def test_filter_view_queryset__viewer_role(self): + organization = testdata.organization() + user = testdata.user() + + queryset = Organization.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Organization.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=organization.id) + + def test_filter_view_queryset__anonymous(self): + organization = testdata.organization() + + queryset = Organization.filter_view_queryset( + self.base_queryset, user=self.anonymous_user + ) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + +class InvitationOrganizationTestCase(PermissionQuerysetTestCase): + @property + def base_queryset(self): + return Invitation.objects.all() + + def _make_org_invitation(self): + organization = testdata.organization() + invitee = testdata.user(email="org-invitee@le.com") + invitation = Invitation.objects.create( + email=invitee.email, organization=organization + ) + return organization, invitation + + def test_filter_edit_queryset__organization_admin(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + + queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=invitation.id) + + def test_filter_edit_queryset__organization_editor_cannot_edit(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + def test_filter_view_queryset__organization_editor(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=invitation.id) + + def test_filter_view_queryset__organization_viewer(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=invitation.id) + + def test_filter_view_queryset__unrelated_user(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + class ContentNodeTestCase(PermissionQuerysetTestCase): @property def base_queryset(self): diff --git a/contentcuration/contentcuration/tests/testdata.py b/contentcuration/contentcuration/tests/testdata.py index 962d2e0a5b..52aa11de5a 100644 --- a/contentcuration/contentcuration/tests/testdata.py +++ b/contentcuration/contentcuration/tests/testdata.py @@ -19,6 +19,10 @@ from contentcuration.constants import ( community_library_submission as community_library_submission_constants, ) +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_ACTIVE, +) from contentcuration.tests.utils import mixer @@ -253,6 +257,18 @@ def channel(name="testchannel"): return channel +def organization(name="Test Organization"): + return cc.Organization.objects.create(name=name) + + +def organization_role( + user, organization, role=ORGANIZATION_ADMIN, status=ORGANIZATION_ROLE_STATUS_ACTIVE +): + return cc.OrganizationRole.objects.create( + user=user, organization=organization, role=role, status=status + ) + + def random_string(chars=10): """ Generate a random string From 79b6b056cc054801c8530f655967b91ba169787a Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Tue, 4 Aug 2026 17:48:19 -0400 Subject: [PATCH 2/3] 6009: extend InvitationSerializer/viewset for organization invitations - organization is now a serializer field alongside channel (via UserFilteredPrimaryKeyRelatedField, so it's scoped by Organization.filter_edit_queryset); channel becomes optional and validate() requires exactly one of channel/organization - not neither, not both. - get_fields(): match the invitee on email rather than the `invited` FK, since `invited` is only ever populated by the channel email-invite flow and is never set for invitations created through the sync API. Unlock `revoked` for any active org admin of the invitation's organization, not just the original sender, so multi-admin orgs can actually revoke each other's invitations. - update(): read accepted/revoked from validated_data, not initial_data, so get_fields' read-only flags can't be bypassed by the raw client payload; only trigger accept() on an actual incoming toggle rather than re-running it on every later update to an already-accepted invitation. - Tag user_id on the accept/decline echo events (sync-based update() and the REST accept/decline actions) so they're routable to the acting user. - filter_organization on InvitationFilter/InvitationViewSet, so the list endpoint and field_map expose organization_id like channel_id already does. --- .../contentcuration/viewsets/invitation.py | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index 75f40149cf..2a42dcb132 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -10,6 +10,7 @@ from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import Invitation +from contentcuration.models import Organization from contentcuration.viewsets.base import BulkListSerializer from contentcuration.viewsets.base import BulkModelSerializer from contentcuration.viewsets.base import ValuesViewset @@ -25,7 +26,12 @@ class InvitationSerializer(BulkModelSerializer): accepted = serializers.BooleanField(read_only=True) declined = serializers.BooleanField(read_only=True) - channel = UserFilteredPrimaryKeyRelatedField(queryset=Channel.objects.all()) + channel = UserFilteredPrimaryKeyRelatedField( + queryset=Channel.objects.all(), required=False + ) + organization = UserFilteredPrimaryKeyRelatedField( + queryset=Organization.objects.all(), required=False + ) class Meta: model = Invitation @@ -36,12 +42,28 @@ class Meta: "revoked", "email", "channel", + "organization", "share_mode", "first_name", "last_name", ) list_serializer_class = BulkListSerializer + def validate(self, data): + channel = data.get("channel", getattr(self.instance, "channel_id", None)) + organization = data.get( + "organization", getattr(self.instance, "organization_id", None) + ) + if not channel and not organization: + raise serializers.ValidationError( + "Invitation must specify either a channel or an organization." + ) + if channel and organization: + raise serializers.ValidationError( + "Invitation cannot specify both a channel and an organization." + ) + return data + def create(self, validated_data): # Need to remove default values for these non-model fields here if "request" in self.context: @@ -52,8 +74,10 @@ def create(self, validated_data): def update(self, instance, validated_data): instance = super(InvitationSerializer, self).update(instance, validated_data) - accepted = self.initial_data.get("accepted") or instance.accepted - revoked = self.initial_data.get("revoked") or instance.revoked + # validated_data, not initial_data, respects get_fields' read-only + # flags; only trigger accept() on an actual incoming toggle. + accepted = validated_data.get("accepted") + revoked = validated_data.get("revoked") or instance.revoked if accepted and not revoked: instance.accept() @@ -65,6 +89,7 @@ def update(self, instance, validated_data): "accepted": True, }, channel_id=instance.channel_id, + user_id=self.context["request"].user.id, ) ) @@ -76,11 +101,23 @@ def get_fields(self): # allow invitation state to be modified under the right conditions if request and request.user and self.instance: - if self.instance.invited == request.user: + # Match on email, not the `invited` FK - `invited` is only set by + # the channel email-invite flow, never for sync-created invitations. + if (request.user.email or "").lower() == ( + self.instance.email or "" + ).lower(): fields["accepted"].read_only = self.instance.revoked fields["declined"].read_only = False if self.instance.sender == request.user: fields["revoked"].read_only = False + if ( + self.instance.organization_id + and Organization.filter_edit_queryset( + Organization.objects.filter(id=self.instance.organization_id), + request.user, + ).exists() + ): + fields["revoked"].read_only = False return fields @@ -88,12 +125,14 @@ def get_fields(self): class InvitationFilter(FilterSet): invited = CharFilter(method="filter_invited") channel = CharFilter(method="filter_channel") + organization = CharFilter(method="filter_organization") class Meta: model = Invitation fields = ( "invited", "channel", + "organization", ) def filter_invited(self, queryset, name, value): @@ -102,6 +141,9 @@ def filter_invited(self, queryset, name, value): def filter_channel(self, queryset, name, value): return queryset.filter(channel_id=value) + def filter_organization(self, queryset, name, value): + return queryset.filter(organization_id=value) + def get_sender_name(item): return "{} {}".format(item.get("sender__first_name"), item.get("sender__last_name")) @@ -124,6 +166,7 @@ class InvitationViewSet(ValuesViewset): "sender__first_name", "sender__last_name", "channel_id", + "organization_id", "share_mode", "channel__name", ) @@ -133,6 +176,7 @@ class InvitationViewSet(ValuesViewset): "sender_name": get_sender_name, "channel_name": "channel__name", "channel": "channel_id", + "organization": "organization_id", } def perform_update(self, serializer): @@ -163,6 +207,7 @@ def accept(self, request, pk=None): INVITATION, {"accepted": True}, channel_id=invitation.channel_id, + user_id=request.user.id, ), applied=True, created_by_id=request.user.id, @@ -181,6 +226,7 @@ def decline(self, request, pk=None): INVITATION, {"declined": True}, channel_id=invitation.channel_id, + user_id=request.user.id, ), applied=True, created_by_id=request.user.id, From d4d88f27b2ebf76f8eba3525032b847096c7f352 Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Tue, 4 Aug 2026 17:48:58 -0400 Subject: [PATCH 3/3] 6009: add sync/CRUD test coverage for organization invitations Adds OrganizationInvitationSyncTestCase covering create/accept/ revoke/delete of organization invitations via /sync: non-admin rejection, cross-org isolation (an admin of org A can't touch org B's invitations or create into an org they don't manage), a different admin of the same org revoking another admin's invitation, an org admin unable to force-accept on behalf of the real invitee, the channel/organization mutual-exclusivity rejection, the "requires at least one of channel/organization" validation, and that org-scoped changes route the same way as any other self-only change (client tags user_id as its own id; a missing or mismatched user_id is rejected, not silently dropped or re-routed). Also adds a CRUDTestCase case confirming the REST create endpoint stays 405 for organization invitations (mirroring the existing channel-invitation coverage - both go through /sync only), and a channel-invitation test locking in that the "admin" (co-owner) share_mode currently grants the same editor access as "edit" (only VIEW_ACCESS is special-cased in _accept_channel_invitation). --- .../tests/viewsets/test_invitation.py | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index e07d52cb59..79904e2a00 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -3,6 +3,11 @@ from django.urls import reverse from contentcuration import models +from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_ACTIVE, +) +from contentcuration.models import ADMIN_ACCESS from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import generate_create_event @@ -141,6 +146,29 @@ def test_update_invitation_accept(self): ) self.assertTrue(models.Change.objects.filter(channel=self.channel).exists()) + def test_update_invitation_accept_admin_share_mode_grants_editor_access(self): + # _accept_channel_invitation only special-cases VIEW_ACCESS, so + # "admin" currently grants the same editor access as "edit". + invitation = models.Invitation.objects.create( + share_mode=ADMIN_ACCESS, **self.invitation_db_metadata + ) + + self.client.force_authenticate(user=self.invited_user) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"accepted": True}, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.accepted) + self.assertTrue(self.channel.editors.filter(pk=self.invited_user.id).exists()) + def test_update_invitation_revoke(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata) @@ -346,6 +374,402 @@ def test_delete_invitations(self): pass +class OrganizationInvitationSyncTestCase(SyncTestMixin, StudioAPITestCase): + @property + def invitation_metadata(self): + return { + "id": uuid.uuid4().hex, + "organization": self.organization.id, + "email": self.invited_user.email, + } + + def setUp(self): + super(OrganizationInvitationSyncTestCase, self).setUp() + self.organization = testdata.organization() + self.org_admin = testdata.user("org-admin@inc.com") + testdata.organization_role(self.org_admin, self.organization) + self.invited_user = testdata.user("org-invitee@inc.com") + self.client.force_authenticate(user=self.org_admin) + + def test_create_organization_invitation(self): + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + except models.Invitation.DoesNotExist: + self.fail("Organization invitation was not created") + + def test_create_organization_invitation_by_non_admin_rejected(self): + editor = testdata.user("org-editor@inc.com") + testdata.organization_role(editor, self.organization, role=ORGANIZATION_EDITOR) + self.client.force_authenticate(user=editor) + + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=editor.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Organization invitation was created by a non-admin") + except models.Invitation.DoesNotExist: + pass + + def test_create_invitation_requires_channel_or_organization(self): + self.client.force_authenticate(user=self.invited_user) + invitation = { + "id": uuid.uuid4().hex, + "email": self.invited_user.email, + } + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Invitation without channel or organization was created") + except models.Invitation.DoesNotExist: + pass + + def test_accept_organization_invitation_creates_role(self): + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + invited=self.invited_user, + sender=self.org_admin, + ) + self.client.force_authenticate(user=self.invited_user) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"accepted": True}, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.accepted) + role = models.OrganizationRole.objects.get( + user=self.invited_user, organization=self.organization + ) + self.assertEqual(role.role, ORGANIZATION_EDITOR) + self.assertEqual(role.status, ORGANIZATION_ROLE_STATUS_ACTIVE) + + def test_revoke_organization_invitation_by_admin(self): + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked) + + def test_revoke_organization_invitation_by_non_admin_rejected(self): + editor = testdata.user("org-editor2@inc.com") + testdata.organization_role(editor, self.organization, role=ORGANIZATION_EDITOR) + self.client.force_authenticate(user=editor) + + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + user_id=editor.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.revoked) + + def test_invitation_with_channel_and_organization_is_rejected(self): + channel = testdata.channel() + channel.editors.add(self.org_admin) + invitation = { + "id": uuid.uuid4().hex, + "channel": channel.id, + "organization": self.organization.id, + "email": self.invited_user.email, + } + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + channel_id=channel.id, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Invitation with both channel and organization was created") + except models.Invitation.DoesNotExist: + pass + + def test_create_organization_invitation_without_user_id_is_rejected(self): + # No org-specific routing in handle_changes - a missing user_id is + # rejected like any other self-only change, with feedback returned. + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.data["disallowed"]), 1) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail( + "Organization invitation without a client-supplied user_id " + "was created" + ) + except models.Invitation.DoesNotExist: + pass + + def test_organization_invitation_change_with_mismatched_user_id_is_rejected(self): + # A user_id that doesn't match the actor is rejected, not routed + # elsewhere - it must not inject a change into another user's feed. + unrelated_user = testdata.user("unrelated-target@inc.com") + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=unrelated_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.data["disallowed"]), 1) + self.assertFalse( + models.Change.objects.filter( + table=INVITATION, kwargs__key=invitation["id"] + ).exists() + ) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Invitation was created despite a mismatched user_id") + except models.Invitation.DoesNotExist: + pass + + def test_create_organization_invitation_for_different_org_is_rejected(self): + other_organization = testdata.organization() + invitation = self.invitation_metadata + invitation["organization"] = other_organization.id + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail( + "Invitation was created for an organization the admin doesn't manage" + ) + except models.Invitation.DoesNotExist: + pass + + def test_revoke_organization_invitation_by_different_admin(self): + other_admin = testdata.user("org-admin-3@inc.com") + testdata.organization_role(other_admin, self.organization) + + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + self.client.force_authenticate(user=other_admin) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + user_id=other_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked) + + def test_admin_cannot_force_accept_on_behalf_of_invitee(self): + # Org-admin edit rights must not let an admin trigger accept() on + # someone else's invitation - accepted stays read-only for them. + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"accepted": True}, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.accepted) + self.assertFalse( + models.OrganizationRole.objects.filter( + user=self.invited_user, organization=self.organization + ).exists() + ) + + def test_delete_organization_invitation(self): + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_delete_event( + invitation.id, + INVITATION, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation.id) + self.fail("Organization invitation was not deleted") + except models.Invitation.DoesNotExist: + pass + + def test_accept_organization_invitation_created_via_sync(self): + # Unlike the fixtures above, an invitation created via sync never + # gets `invited` populated - the real invitee must still accept it. + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=self.org_admin.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + created = models.Invitation.objects.get(id=invitation["id"]) + self.assertIsNone(created.invited) + + self.client.force_authenticate(user=self.invited_user) + response = self.sync_changes( + [ + generate_update_event( + invitation["id"], + INVITATION, + {"accepted": True}, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + created.refresh_from_db() + self.assertTrue(created.accepted) + self.assertTrue( + models.OrganizationRole.objects.filter( + user=self.invited_user, organization=self.organization + ).exists() + ) + + def test_list_invitations_filtered_by_organization(self): + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + other_organization = testdata.organization() + other_invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=other_organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.client.get( + reverse("invitation-list"), {"organization": self.organization.id} + ) + self.assertEqual(response.status_code, 200, response.content) + payload = response.json() + results = payload["results"] if isinstance(payload, dict) else payload + returned_ids = [item["id"] for item in results] + self.assertIn(invitation.id, returned_ids) + self.assertNotIn(other_invitation.id, returned_ids) + + class CRUDTestCase(StudioAPITestCase): @property def invitation_metadata(self): @@ -382,6 +806,23 @@ def test_create_invitation(self): ) self.assertEqual(response.status_code, 405, response.content) + def test_create_organization_invitation(self): + organization = testdata.organization() + org_admin = testdata.user("crud-org-admin@inc.com") + testdata.organization_role(org_admin, organization) + self.client.force_authenticate(user=org_admin) + invitation = { + "id": uuid.uuid4().hex, + "organization": organization.id, + "email": self.invited_user.email, + } + response = self.client.post( + reverse("invitation-list"), + invitation, + format="json", + ) + self.assertEqual(response.status_code, 405, response.content) + def test_update_invitation_accept(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata)