This commit is contained in:
j3d1 2026-08-26 20:48:22 +02:00
parent 8de6ae8d8b
commit 4c9e8f942e
2 changed files with 63 additions and 1 deletions

View file

@ -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/<name>/<domain>/
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/<pk>/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/<int:pk>/', GroupDetail.as_view(), name='group_detail'),
path('groups/handle/<str:name>/<str:domain>/', GroupDetailByHandle.as_view(), name='group_detail_by_handle'),
path('groups/<int:pk>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),

View file

@ -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)