diff --git a/integration/tests/posit/connect/test_content_item_permissions.py b/integration/tests/posit/connect/test_content_item_permissions.py new file mode 100644 index 00000000..489bf543 --- /dev/null +++ b/integration/tests/posit/connect/test_content_item_permissions.py @@ -0,0 +1,82 @@ +from posit import connect +from posit.connect.content import ContentItem + + +class TestContentPermissions: + content: ContentItem + + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.content = cls.client.content.create(name="example") + + cls.user_aron = cls.client.users.create( + username="permission_aron", + email="permission_aron@example.com", + password="permission_s3cur3p@ssword", + ) + cls.user_bill = cls.client.users.create( + username="permission_bill", + email="permission_bill@example.com", + password="permission_s3cur3p@ssword", + ) + + cls.group_friends = cls.client.groups.create(name="Friends") + + @classmethod + def teardown_class(cls): + cls.content.delete() + assert cls.client.content.count() == 0 + + cls.group_friends.delete() + assert cls.client.groups.count() == 0 + + def test_permissions_add_destroy(self): + assert self.client.groups.count() == 1 + assert self.client.users.count() == 3 + assert self.content.permissions.find() == [] + + # Add permissions + self.content.permissions.create( + principal_guid=self.user_aron["guid"], + principal_type="user", + role="viewer", + ) + self.content.permissions.create( + principal_guid=self.group_friends["guid"], + principal_type="group", + role="owner", + ) + + def assert_permissions_match_guids(permissions, objs_with_guid): + for permission, obj_with_guid in zip(permissions, objs_with_guid): + assert permission["principal_guid"] == obj_with_guid["guid"] + + # Prove they have been added + assert_permissions_match_guids( + self.content.permissions.find(), + [self.user_aron, self.group_friends], + ) + + # Remove permissions (and from some that isn't an owner) + destroyed_permissions = self.content.permissions.destroy(self.user_aron, self.user_bill) + assert_permissions_match_guids( + destroyed_permissions, + [self.user_aron], + ) + + # Prove they have been removed + assert_permissions_match_guids( + self.content.permissions.find(), + [self.group_friends], + ) + + # Remove the last permission + destroyed_permissions = self.content.permissions.destroy(self.group_friends) + assert_permissions_match_guids( + destroyed_permissions, + [self.group_friends], + ) + + # Prove they have been removed + assert self.content.permissions.find() == [] diff --git a/integration/tests/posit/connect/test_users.py b/integration/tests/posit/connect/test_users.py index e1f68498..ed4efbad 100644 --- a/integration/tests/posit/connect/test_users.py +++ b/integration/tests/posit/connect/test_users.py @@ -5,6 +5,10 @@ class TestUser: @classmethod def setup_class(cls): cls.client = client = connect.Client() + + # Play nicely with other tests + cls.existing_user_count = client.users.count() + cls.aron = client.users.create( username="aron", email="aron@example.com", @@ -29,8 +33,8 @@ def test_lock(self): assert len(self.client.users.find(account_status="locked")) == 0 def test_count(self): - # aron, bill, cole, and me - assert self.client.users.count() == 4 + # aron, bill, cole, and me (and existing user) + assert self.client.users.count() == 3 + self.existing_user_count def test_find(self): assert self.client.users.find(prefix="aron") == [self.aron] diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index c5c9a268..5806e019 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -2,16 +2,20 @@ from __future__ import annotations -from typing import List, overload +from typing import TYPE_CHECKING, List, overload from requests.sessions import Session as Session from .resources import Resource, ResourceParameters, Resources +if TYPE_CHECKING: + from .groups import Group + from .users import User + class Permission(Resource): - def delete(self) -> None: - """Delete the permission.""" + def destroy(self) -> None: + """Destroy the permission.""" path = f"v1/content/{self['content_guid']}/permissions/{self['id']}" url = self.params.url + path self.params.session.delete(url) @@ -137,3 +141,85 @@ def get(self, uid: str) -> Permission: url = self.params.url + path response = self.params.session.get(url) return Permission(self.params, **response.json()) + + def destroy(self, *permissions: str | Group | User | Permission) -> list[Permission]: + """Remove supplied content item permissions. + + Removes all provided permissions from the content item's permissions. If a permission isn't + found, it is silently ignored. + + Parameters + ---------- + *permissions : str | Group | User | Permission + The content item permissions to remove. If a `str` is received, it is compared against + the `Permissions`'s `principal_guid`. If a `Group` or `User` is received, the associated + `Permission` will be removed. + + Returns + ------- + list[Permission] + The removed permissions. If a permission is not found, there is nothing to remove and + it is not included in the returned list. + + Examples + -------- + ```python + from posit import connect + + #### User-defined inputs #### + # 1. specify the guid for the content item + content_guid = "CONTENT_GUID_HERE" + # 2. specify either the principal_guid or group name prefix + principal_guid = "USER_OR_GROUP_GUID_HERE" + group_name_prefix = "GROUP_NAME_PREFIX_HERE" + ############################ + + client = connect.Client() + + # Remove a single permission by principal_guid + client.content.get(content_guid).permissions.destroy(principal_guid) + + # Remove by user (if principal_guid is a user) + user = client.users.get(principal_guid) + client.content.get(content_guid).permissions.destroy(user) + + # Remove by group (if principal_guid is a group) + group = client.groups.get(principal_guid) + client.content.get(content_guid).permissions.destroy(group) + + # Remove all groups with a matching prefix name + groups = client.groups.find(prefix=group_name_prefix) + client.content.get(content_guid).permissions.destroy(*groups) + + # Confirm new permissions + client.content.get(content_guid).permissions.find() + ``` + """ + from .groups import Group + from .users import User + + if len(permissions) == 0: + raise ValueError("Expected at least one `permission` to remove") + + principal_guids: set[str] = set() + + for arg in permissions: + if isinstance(arg, str): + principal_guid = arg + elif isinstance(arg, (Group, User)): + principal_guid: str = arg["guid"] + elif isinstance(arg, Permission): + principal_guid: str = arg["principal_guid"] + else: + raise TypeError( + f"destroy() expected argument type 'str', 'User', 'Group', or 'Permission' but got '{type(arg).__name__}'", + ) + principal_guids.add(principal_guid) + + destroyed_permissions: list[Permission] = [] + for permission in self.find(): + if permission["principal_guid"] in principal_guids: + permission.destroy() + destroyed_permissions.append(permission) + + return destroyed_permissions diff --git a/tests/posit/connect/api.py b/tests/posit/connect/api.py index de2651b0..06b5f6cc 100644 --- a/tests/posit/connect/api.py +++ b/tests/posit/connect/api.py @@ -2,10 +2,8 @@ import pyjson5 as json -from posit.connect._json import Jsonifiable, JsonifiableDict, JsonifiableList - -def load_mock(path: str) -> Jsonifiable: +def load_mock(path: str): """ Load mock data from a file. @@ -33,13 +31,13 @@ def load_mock(path: str) -> Jsonifiable: return json.loads((Path(__file__).parent / "__api__" / path).read_text()) -def load_mock_dict(path: str) -> JsonifiableDict: +def load_mock_dict(path: str) -> dict: result = load_mock(path) assert isinstance(result, dict) return result -def load_mock_list(path: str) -> JsonifiableList: +def load_mock_list(path: str) -> list: result = load_mock(path) assert isinstance(result, list) return result diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py index 0f3a390a..c8cc4b6a 100644 --- a/tests/posit/connect/test_permissions.py +++ b/tests/posit/connect/test_permissions.py @@ -1,18 +1,21 @@ import random import uuid +import pytest import requests import responses from responses import matchers +from posit.connect.groups import Group from posit.connect.permissions import Permission, Permissions from posit.connect.resources import ResourceParameters from posit.connect.urls import Url +from posit.connect.users import User from .api import load_mock, load_mock_dict, load_mock_list -class TestPermissionDelete: +class TestPermissionDestroy: @responses.activate def test(self): # data @@ -30,7 +33,7 @@ def test(self): permission = Permission(params, **fake_permission) # invoke - permission.delete() + permission.destroy() # assert assert mock_delete.call_count == 1 @@ -262,3 +265,80 @@ def test(self): # assert assert permission == fake_permission + + +class TestPermissionsDestroy: + @responses.activate + def test_destroy(self): + # data + permission_uid = "94" + content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fake_permissions = load_mock_list(f"v1/content/{content_guid}/permissions.json") + fake_followup_permissions = fake_permissions.copy() + fake_followup_permissions.pop(0) + fake_permission = load_mock_dict( + f"v1/content/{content_guid}/permissions/{permission_uid}.json" + ) + fake_user = load_mock_dict("v1/user.json") + fake_group = load_mock_dict("v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json") + + # behavior + + # Used in internal for-loop + mock_permissions_get = [ + responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions", + json=fake_permissions, + ), + responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions", + json=fake_followup_permissions, + ), + ] + # permission delete + mock_permission_delete = responses.delete( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{permission_uid}", + ) + + # setup + params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + permissions = Permissions(params, content_guid=content_guid) + + # (Doesn't match any permissions, but that's okay) + user_to_remove = User(params, **fake_user) + group_to_remove = Group(params, **fake_group) + permission_to_remove = Permission(params, **fake_permission) + + # invoke + destroyed_permission = permissions.destroy( + fake_permission["principal_guid"], + # Make sure duplicates are dropped + fake_permission["principal_guid"], + # Extract info from User, Group, Permission + user_to_remove, + group_to_remove, + permission_to_remove, + ) + + # Assert bad input value + with pytest.raises(TypeError): + permissions.destroy( + 42 # pyright: ignore[reportArgumentType] + ) + with pytest.raises(ValueError): + permissions.destroy() + + # Assert values + assert mock_permissions_get[0].call_count == 1 + assert mock_permissions_get[1].call_count == 0 + assert mock_permission_delete.call_count == 1 + assert len(destroyed_permission) == 1 + assert destroyed_permission[0] == fake_permission + + # Invoking again is a no-op + destroyed_permission = permissions.destroy(fake_permission["principal_guid"]) + + assert mock_permissions_get[0].call_count == 1 + assert mock_permissions_get[1].call_count == 1 + assert mock_permission_delete.call_count == 1 + assert len(destroyed_permission) == 0