From 06175673071e997389f7408c2bc68164c1aed287 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 9 Dec 2024 19:41:34 +0000 Subject: [PATCH] Add GET membership API Add an API for getting a single membership. --- docs/_extra/api-reference/hypothesis-v1.yaml | 24 ++++ docs/_extra/api-reference/hypothesis-v2.yaml | 26 +++- .../api-reference/schemas/membership.yaml | 1 - h/views/api/group_members.py | 12 ++ tests/functional/api/groups/members_test.py | 113 ++++++++++++++++++ tests/unit/h/views/api/group_members_test.py | 17 +++ 6 files changed, 191 insertions(+), 2 deletions(-) diff --git a/docs/_extra/api-reference/hypothesis-v1.yaml b/docs/_extra/api-reference/hypothesis-v1.yaml index a5f7ae23b9f..2689e1f95e6 100644 --- a/docs/_extra/api-reference/hypothesis-v1.yaml +++ b/docs/_extra/api-reference/hypothesis-v1.yaml @@ -880,6 +880,30 @@ paths: $ref: '#/components/schemas/User' /groups/{id}/members/{user}: + # ---------------------------------------------------------- + # GET groups/{id}/members/{user} - Get group member + # ---------------------------------------------------------- + get: + tags: + - groups + summary: Get group member + description: Get a user's membership of a group. + Authenticated user must have read access to the group. + Does not require authentication for reading members of public groups. + security: + - ApiKey: [] + - {} + parameters: + - $ref: '#/components/parameters/GroupID' + - $ref: '#/components/parameters/UserID' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Membership' + # ---------------------------------------------------------- # POST groups/{id}/members/{user} - Add user to group # ---------------------------------------------------------- diff --git a/docs/_extra/api-reference/hypothesis-v2.yaml b/docs/_extra/api-reference/hypothesis-v2.yaml index 214cabf16e8..2ac7c49676b 100644 --- a/docs/_extra/api-reference/hypothesis-v2.yaml +++ b/docs/_extra/api-reference/hypothesis-v2.yaml @@ -862,7 +862,7 @@ paths: Fetch a list of all members (users) in a group. Returned user resource only contains public-facing user data. Authenticated user must have read access to the group. Does not require authentication for reading members of - public groups. Returned members are unsorted. + public groups. security: - AuthClient: [] - ApiKey: [] @@ -878,6 +878,30 @@ paths: $ref: '#/components/schemas/User' /groups/{id}/members/{user}: + # ---------------------------------------------------------- + # GET groups/{id}/members/{user} - Get group member + # ---------------------------------------------------------- + get: + tags: + - groups + summary: Get group member + description: Fetch a user's membership of a group. + Authenticated user must have read access to the group. + Does not require authentication for reading members of public groups. + security: + - ApiKey: [] + - {} + parameters: + - $ref: '#/components/parameters/GroupID' + - $ref: '#/components/parameters/UserID' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Membership' + # ---------------------------------------------------------- # POST groups/{id}/members/{user} - Add user to group # ---------------------------------------------------------- diff --git a/docs/_extra/api-reference/schemas/membership.yaml b/docs/_extra/api-reference/schemas/membership.yaml index c1b93533631..5907bc7532f 100644 --- a/docs/_extra/api-reference/schemas/membership.yaml +++ b/docs/_extra/api-reference/schemas/membership.yaml @@ -38,4 +38,3 @@ Membership: format: date-time description: "When this membership was last updated (for example to change the role)" example: "1970-01-01T00:00:00.000000+00:00" - diff --git a/h/views/api/group_members.py b/h/views/api/group_members.py index 0aab42d904b..99072e3b063 100644 --- a/h/views/api/group_members.py +++ b/h/views/api/group_members.py @@ -71,6 +71,18 @@ def list_members(context: GroupContext, request): return {"meta": {"page": {"total": total}}, "data": membership_dicts} +@api_config( + versions=["v1", "v2"], + route_name="api.group_member", + request_method="GET", + link_name="group.member.read", + description="Fetch a group membership", + permission=Permission.Group.READ, +) +def get_member(context: GroupMembershipContext, request): + return GroupMembershipJSONPresenter(request, context.membership).asdict() + + @api_config( versions=["v1", "v2"], route_name="api.group_member", diff --git a/tests/functional/api/groups/members_test.py b/tests/functional/api/groups/members_test.py index 2307df73efb..58f6d4e1c47 100644 --- a/tests/functional/api/groups/members_test.py +++ b/tests/functional/api/groups/members_test.py @@ -260,6 +260,119 @@ def test_it_returns_an_error_if_offset_and_limit_are_invalid( } +class TestGetMember: + def test_it(self, app, db_session, do_request, group, target_user): + response = do_request() + + assert response.json == { + "authority": group.authority, + "userid": target_user.userid, + "username": target_user.username, + "display_name": target_user.display_name, + "roles": [GroupMembershipRoles.MEMBER], + "actions": [ + "delete", + "updates.roles.member", + "updates.roles.moderator", + "updates.roles.admin", + "updates.roles.owner", + ], + "created": f"1970-01-01T00:00:00.000000+00:00", + "updated": f"1970-01-01T00:00:01.000000+00:00", + } + + def test_it_when_group_doesnt_exist(self, do_request): + response = do_request(pubid="doesnt_exist", status=404) + + def test_it_when_target_user_doesnt_exist(self, do_request): + response = do_request(userid="doesnt_exist", status=404) + + def test_it_when_authenticated_user_isnt_a_member_of_the_group( + self, do_request, factories, headers + ): + headers.update( + **token_authorization_header( + factories.DeveloperToken(user=factories.User()) + ) + ) + + do_request(status=404) + + def test_it_when_not_authenticated(self, do_request, headers): + del headers["Authorization"] + + do_request(status=404) + + def test_it_with_an_open_group(self, do_request, factories, headers, target_user): + group = factories.OpenGroup(memberships=[GroupMembership(user=target_user)]) + # Non-group members and unauthenticated requests can read the + # memberships of open groups. + del headers["Authorization"] + + do_request(pubid=group.pubid) + + def test_it_with_a_restricted_group( + self, do_request, factories, headers, target_user + ): + group = factories.RestrictedGroup( + memberships=[GroupMembership(user=target_user)] + ) + # Non-group members and unauthenticated requests can read the + # memberships of restricted groups. + del headers["Authorization"] + + do_request(pubid=group.pubid) + + @pytest.fixture(autouse=True) + def group(self, factories): + return factories.Group() + + @pytest.fixture(autouse=True) + def target_user(self, factories, group): + target_user = factories.User() + group.memberships.append( + GroupMembership( + user=target_user, + created=datetime(1970, 1, 1, 0, 0, 0), + updated=datetime(1970, 1, 1, 0, 0, 1), + ) + ) + return target_user + + @pytest.fixture(autouse=True) + def authenticated_user(self, factories, group): + authenticated_user = factories.User() + group.memberships.append( + GroupMembership( + user=authenticated_user, + roles=[GroupMembershipRoles.OWNER], + created=datetime(1971, 1, 1, 0, 0, 0), + updated=datetime(1971, 1, 1, 0, 0, 1), + ) + ) + return authenticated_user + + @pytest.fixture(autouse=True) + def token(self, factories, authenticated_user): + return factories.DeveloperToken(user=authenticated_user) + + @pytest.fixture + def headers(self, factories, token): + return token_authorization_header(token) + + @pytest.fixture + def do_request(self, app, db_session, group, target_user, headers): + def do_request( + pubid=group.pubid, userid=target_user.userid, headers=headers, status=200 + ): + db_session.commit() + return app.get( + f"/api/groups/{pubid}/members/{userid}", headers=headers, status=status + ) + + return do_request + + class TestAddMember: def test_it(self, do_request, group, user): do_request() diff --git a/tests/unit/h/views/api/group_members_test.py b/tests/unit/h/views/api/group_members_test.py index 0555c6209b3..ee77f4c291f 100644 --- a/tests/unit/h/views/api/group_members_test.py +++ b/tests/unit/h/views/api/group_members_test.py @@ -122,6 +122,23 @@ def context(self, factories): ) +class TestGetMember: + def test_it(self, context, pyramid_request, GroupMembershipJSONPresenter): + response = views.get_member(context, pyramid_request) + + GroupMembershipJSONPresenter.assert_called_once_with( + pyramid_request, sentinel.membership + ) + GroupMembershipJSONPresenter.return_value.asdict.assert_called_once_with() + assert response == GroupMembershipJSONPresenter.return_value.asdict.return_value + + @pytest.fixture + def context(self): + return GroupMembershipContext( + group=sentinel.group, user=sentinel.user, membership=sentinel.membership + ) + + class TestRemoveMember: def test_it(self, context, pyramid_request, group_members_service): response = views.remove_member(context, pyramid_request)