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) 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']) @api_view(['DELETE'])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@ -88,6 +99,7 @@ class GroupInvitesIncoming(APIView, ViewSetMixin):
"""/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery """/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 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).""" 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 def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites
raw_request = request.body.decode('utf-8') raw_request = request.body.decode('utf-8')
if not (user := authenticate_request_against_local_users(request, raw_request)): 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 = [ urlpatterns = [
path('groups/', Groups.as_view(), name='groups'), path('groups/', Groups.as_view(), name='groups'),
path('groups/<int:pk>/', GroupDetail.as_view(), name='group_detail'), 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>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'), path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'), 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']) reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user2'])
self.assertEqual(reply.status_code, 404) 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): def test_remove_member(self):
self.prepare_groups() self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity) 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) self.assertEqual(reply.status_code, 201)
identity = KnownIdentity.objects.get(username='newmember', domain='remote.example') identity = KnownIdentity.objects.get(username='newmember', domain='remote.example')
self.assertTrue(group.is_member(identity)) 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): def test_accept_bad_signature(self):
group = self.f['group1'] group = self.f['group1']
@ -247,6 +268,23 @@ class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase
self.assertEqual(reply.status_code, 204) self.assertEqual(reply.status_code, 204)
self.assertEqual(GroupMembership.objects.count(), 0) 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): def test_list_memberships(self):
GroupMembership.objects.create( GroupMembership.objects.create(
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example') 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']) reply2 = client.get('/api/groupmemberships/', self.f['local_user2'])
self.assertEqual(reply2.status_code, 200) self.assertEqual(reply2.status_code, 200)
self.assertEqual(len(reply2.json()), 0) 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)