From 4c9e8f942ea7fa4d7ad9629420e195122be588fe Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 20:48:22 +0200 Subject: [PATCH] stash --- backend/toolshed/api/group.py | 15 ++++++++- backend/toolshed/tests/test_group.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/toolshed/api/group.py b/backend/toolshed/api/group.py index 757d871..74e8e47 100644 --- a/backend/toolshed/api/group.py +++ b/backend/toolshed/api/group.py @@ -47,6 +47,17 @@ class GroupDetail(APIView, ViewSetMixin): return Response(GroupSerializer(group).data) +class GroupDetailByHandle(APIView, ViewSetMixin): + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, name, domain, format=None): # /api/groups/handle/// + group = get_object_or_404(Group, name=name, domain=domain) + if not group.is_member(request.user): + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(GroupSerializer(group).data) + + @api_view(['DELETE']) @authentication_classes([SignatureAuthentication]) @permission_classes([IsAuthenticated]) @@ -80,7 +91,7 @@ def createGroupInvite(request, pk, format=None): # /api/groups//invites/ return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'}) secret = secrets.token_hex(64) GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain, - secret=secret) + secret=secret) return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'}) @@ -88,6 +99,7 @@ class GroupInvitesIncoming(APIView, ViewSetMixin): """/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery endpoint an inviter's client posts to directly on the invitee's own home backend (see docs/design-in-progress/groups-mvp.md's invite/accept dance).""" + def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites raw_request = request.body.decode('utf-8') if not (user := authenticate_request_against_local_users(request, raw_request)): @@ -186,6 +198,7 @@ def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lan urlpatterns = [ path('groups/', Groups.as_view(), name='groups'), path('groups//', GroupDetail.as_view(), name='group_detail'), + path('groups/handle///', GroupDetailByHandle.as_view(), name='group_detail_by_handle'), path('groups//members//', removeGroupMember, name='remove_group_member'), path('groups//invites/', createGroupInvite, name='create_group_invite'), path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'), diff --git a/backend/toolshed/tests/test_group.py b/backend/toolshed/tests/test_group.py index d6074b0..bf9ac7a 100644 --- a/backend/toolshed/tests/test_group.py +++ b/backend/toolshed/tests/test_group.py @@ -63,6 +63,24 @@ class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user2']) self.assertEqual(reply.status_code, 404) + def test_group_detail_by_handle_member(self): + self.prepare_groups() + group = self.f['group1'] + reply = client.get('/api/groups/handle/{}/{}/'.format(group.name, group.domain), self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.json()['id'], group.id) + self.assertEqual(reply.json()['handle'], str(group)) + + def test_group_detail_by_handle_non_member(self): + self.prepare_groups() + group = self.f['group1'] + reply = client.get('/api/groups/handle/{}/{}/'.format(group.name, group.domain), self.f['local_user2']) + self.assertEqual(reply.status_code, 404) + + def test_group_detail_by_handle_no_such_group(self): + reply = client.get('/api/groups/handle/nonexistent/example.com/', self.f['local_user1']) + self.assertEqual(reply.status_code, 404) + def test_remove_member(self): self.prepare_groups() self.f['group1'].members.add(self.f['local_user2'].public_identity) @@ -180,6 +198,9 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): self.assertEqual(reply.status_code, 201) identity = KnownIdentity.objects.get(username='newmember', domain='remote.example') self.assertTrue(group.is_member(identity)) + # This lands on the group's own home backend, not the invitee's -- the invitee here isn't + # even a local ToolshedUser on this backend, so there's nothing to point at locally. + self.assertEqual(GroupMembership.objects.count(), 0) def test_accept_bad_signature(self): group = self.f['group1'] @@ -247,6 +268,23 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase self.assertEqual(reply.status_code, 204) self.assertEqual(GroupMembership.objects.count(), 0) + def test_record_membership_already_a_member_is_idempotent(self): + # Re-invited (or re-accepting) into a group we already have a pointer for shouldn't blow + # up on the unique_together constraint, and shouldn't duplicate the pointer either. + invitee = self.f['local_user2'] + GroupMembership.objects.create( + user=invitee, group_name='remoteworkshop', group_domain='other.example') + incoming = GroupInviteIncoming.objects.create( + group_name='remoteworkshop', group_domain='other.example', + inviter_username='someone', inviter_domain='other.example', + invitee_user=invitee, secret='some-secret') + + reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee) + + self.assertEqual(reply.status_code, 201) + self.assertEqual(GroupMembership.objects.filter(user=invitee).count(), 1) + self.assertEqual(GroupInviteIncoming.objects.count(), 0) + def test_list_memberships(self): GroupMembership.objects.create( user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example') @@ -259,3 +297,14 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase reply2 = client.get('/api/groupmemberships/', self.f['local_user2']) self.assertEqual(reply2.status_code, 200) self.assertEqual(len(reply2.json()), 0) + + def test_list_memberships_unauthorized(self): + GroupMembership.objects.create( + user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example') + + reply = client.get('/api/groupmemberships/', self.f['ext_user1']) + + # authenticate() returns bare None (not raise) for a caller with no local ToolshedUser, so + # DRF falls through to IsAuthenticated denying an anonymous request -- 403, not 401 (same + # as any other SignatureAuthenticationLocal-only endpoint, see e.g. dropFriend). + self.assertEqual(reply.status_code, 403)