From 7617bc6cced3e010a761bdc8eac1424aff769fdc Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:51:32 -0800 Subject: [PATCH 01/18] fix: update permission logic to allow admins to publish private score sets --- src/mavedb/lib/permissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 99b2ada0e..c4671f333 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -279,11 +279,11 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") else: return PermissionResponse(False) - # Only the owner may publish a private score set. + # Only the owner or admins may publish a private score set. elif action == Action.PUBLISH: if user_may_edit: return PermissionResponse(True) - elif roles_permitted(active_roles, []): + elif roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) elif private: # Do not acknowledge the existence of a private entity. From 12d1d1e76c2a4316a8593eb29e0abb22a8575a21 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:52:13 -0800 Subject: [PATCH 02/18] fix: update permission logic to allow admins to publish private collections --- src/mavedb/lib/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index c4671f333..44ac402ef 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -349,7 +349,7 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> elif action == Action.PUBLISH: if user_is_admin: return PermissionResponse(True) - elif roles_permitted(active_roles, []): + elif roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) elif private and not user_may_view_private: # Do not acknowledge the existence of a private entity. From 3bbb561ebcd3cb33ec03087c442d90ad07eb87ff Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:52:46 -0800 Subject: [PATCH 03/18] fix: update permission logic to return 404 for private collections role additions --- src/mavedb/lib/permissions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 44ac402ef..b0914b4e8 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -379,6 +379,9 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> # Both collection admins and MaveDB admins can add a user to a collection role if user_is_admin or roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) + elif private and not user_may_view_private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") else: return PermissionResponse(False, 403, "Insufficient permissions to add user role.") # only MaveDB admins may add a badge name to a collection, which makes the collection considered "official" From 9040a6559be52ad075a4443a1c01058ba376227e Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:53:24 -0800 Subject: [PATCH 04/18] fix: return 404 for private calibration updates when user does not have access --- src/mavedb/lib/permissions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index b0914b4e8..8ec1c0d4b 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -436,6 +436,8 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> elif private: # Do not acknowledge the existence of a private entity. return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") else: return PermissionResponse(False) # Only the owner may publish a private calibration. From f02b2298174a2fa4d00c31f147065d3e7929693e Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:53:46 -0800 Subject: [PATCH 05/18] fix: return 401 for insufficient permissions when user data is missing in calibration publishing permissions --- src/mavedb/lib/permissions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 8ec1c0d4b..2cb5fbf31 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -449,6 +449,8 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> elif private: # Do not acknowledge the existence of a private entity. return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") else: return PermissionResponse(False) elif action == Action.CHANGE_RANK: From 20e21dacec4591fdb5f4fc4a601d783037421334 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:53:58 -0800 Subject: [PATCH 06/18] fix: update permission logic to return 404 for private score calibrations and 401 for missing user data --- src/mavedb/lib/permissions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 2cb5fbf31..9a4b08f67 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -458,6 +458,11 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> return PermissionResponse(True) elif roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) + elif private: + # Do not acknowledge the existence of a private entity. + return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") else: return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") From 634bb1c5a8b1b426c2f53c13852ef2552812630e Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:54:18 -0800 Subject: [PATCH 07/18] fix: update permission logic in user permission to return 401 for insufficient permissions when user data is missing --- src/mavedb/lib/permissions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py index 9a4b08f67..4e7174aca 100644 --- a/src/mavedb/lib/permissions.py +++ b/src/mavedb/lib/permissions.py @@ -484,18 +484,24 @@ def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> return PermissionResponse(True) elif roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, "Insufficient permissions for user view.") else: - return PermissionResponse(False, 403, "Insufficient permissions for user update.") + return PermissionResponse(False, 403, "Insufficient permissions for user view.") elif action == Action.UPDATE: if user_is_self: return PermissionResponse(True) elif roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, "Insufficient permissions for user update.") else: return PermissionResponse(False, 403, "Insufficient permissions for user update.") elif action == Action.ADD_ROLE: if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) + elif user_data is None or user_data.user is None: + return PermissionResponse(False, 401, "Insufficient permissions to add user role.") else: return PermissionResponse(False, 403, "Insufficient permissions to add user role.") elif action == Action.DELETE: From ecc2c707768c93776fa8fccaaf52b6a8726a8a3f Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 10:54:53 -0800 Subject: [PATCH 08/18] tests: Add comprehensive test cases for permissions module --- tests/lib/test_permissions.py | 3165 +++++++++++++++++++++++++++++++++ 1 file changed, 3165 insertions(+) create mode 100644 tests/lib/test_permissions.py diff --git a/tests/lib/test_permissions.py b/tests/lib/test_permissions.py new file mode 100644 index 000000000..5091e2db7 --- /dev/null +++ b/tests/lib/test_permissions.py @@ -0,0 +1,3165 @@ +from dataclasses import dataclass +from typing import Optional, Union +from unittest.mock import Mock + +import pytest + +from mavedb.lib.permissions import ( + Action, + PermissionException, + PermissionResponse, + assert_permission, + has_permission, + roles_permitted, +) +from mavedb.models.collection import Collection +from mavedb.models.collection_user_association import CollectionUserAssociation +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + + +@dataclass +class PermissionTest: + """Represents a single permission test case. + + Field Values: + - entity_type: "ScoreSet", "Experiment", "ExperimentSet", "Collection", "User", "ScoreCalibration" + - entity_state: "private", "published", or None (for entities like User without states) + - user_type: "admin", "owner", "contributor", "other_user", "anonymous", "self" + - For Collections: "contributor" is generic, use collection_role to specify "collection_admin", "collection_editor", "collection_viewer" + - action: Action enum value (READ, UPDATE, DELETE, ADD_SCORE_SET, etc.) + - should_be_permitted: True if permission should be granted, False if denied, "NotImplementedError" if action not supported + - expected_code: HTTP error code when permission denied (403, 404, 401, etc.) + - description: Human-readable test description + - investigator_provided: True/False for ScoreCalibration tests, None for other entities + - collection_role: "collection_admin", "collection_editor", "collection_viewer" for Collection entity tests, None for others + """ + + entity_type: str # "ScoreSet", "Experiment", "ExperimentSet", "Collection", "User", "ScoreCalibration" + entity_state: Optional[str] # "private", "published", or None for stateless entities + user_type: str # "admin", "owner", "contributor", "other_user", "anonymous", "self" + action: Action + should_be_permitted: Union[bool, str] # True/False for normal cases, "NotImplementedError" for unsupported actions + expected_code: Optional[int] = None # HTTP error code when denied (403, 404, 401) + description: Optional[str] = None + investigator_provided: Optional[bool] = None # ScoreCalibration: True=investigator, False=community + collection_role: Optional[str] = ( + None # "collection_admin", "collection_editor", "collection_viewer" for Collection tests + ) + + +class PermissionTestData: + """Contains all explicit permission test cases.""" + + @staticmethod + def get_all_permission_tests() -> list[PermissionTest]: + """Get all permission test cases in one explicit list.""" + return [ + # ============================================================================= + # EXPERIMENT SET PERMISSIONS + # ============================================================================= + # ExperimentSet READ permissions - Private + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.READ, + True, + description="Admin can read private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "owner", + Action.READ, + True, + description="Owner can read their private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "contributor", + Action.READ, + True, + description="Contributor can read private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "other_user", + Action.READ, + False, + 404, + "Other user gets 404 for private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "anonymous", + Action.READ, + False, + 404, + "Anonymous gets 404 for private experiment set", + ), + # ExperimentSet READ permissions - Published + PermissionTest( + "ExperimentSet", + "published", + "admin", + Action.READ, + True, + description="Admin can read published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "owner", + Action.READ, + True, + description="Owner can read their published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "contributor", + Action.READ, + True, + description="Contributor can read published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "other_user", + Action.READ, + True, + description="Other user can read published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "anonymous", + Action.READ, + True, + description="Anonymous can read published experiment set", + ), + # ExperimentSet UPDATE permissions - Private + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.UPDATE, + True, + description="Admin can update private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "owner", + Action.UPDATE, + True, + description="Owner can update their private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "contributor", + Action.UPDATE, + True, + description="Contributor can update private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "other_user", + Action.UPDATE, + False, + 404, + "Other user gets 404 for private experiment set update", + ), + PermissionTest( + "ExperimentSet", + "private", + "anonymous", + Action.UPDATE, + False, + 404, + "Anonymous gets 404 for private experiment set update", + ), + # ExperimentSet UPDATE permissions - Published + PermissionTest( + "ExperimentSet", + "published", + "admin", + Action.UPDATE, + True, + description="Admin can update published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "owner", + Action.UPDATE, + True, + description="Owner can update their published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "contributor", + Action.UPDATE, + True, + description="Contributor can update published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "other_user", + Action.UPDATE, + False, + 403, + "Other user cannot update published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "anonymous", + Action.UPDATE, + False, + 401, + "Anonymous cannot update published experiment set", + ), + # ExperimentSet DELETE permissions - Private (unpublished) + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.DELETE, + True, + description="Admin can delete private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "owner", + Action.DELETE, + True, + description="Owner can delete unpublished experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "contributor", + Action.DELETE, + True, + description="Contributor can delete unpublished experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "other_user", + Action.DELETE, + False, + 404, + "Other user gets 404 for private experiment set delete", + ), + PermissionTest( + "ExperimentSet", + "private", + "anonymous", + Action.DELETE, + False, + 404, + "Anonymous gets 404 for private experiment set delete", + ), + # ExperimentSet DELETE permissions - Published + PermissionTest( + "ExperimentSet", + "published", + "admin", + Action.DELETE, + True, + description="Admin can delete published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "owner", + Action.DELETE, + False, + 403, + "Owner cannot delete published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Contributor cannot delete published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "other_user", + Action.DELETE, + False, + 403, + "Other user cannot delete published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "anonymous", + Action.DELETE, + False, + 403, + "Anonymous cannot delete published experiment set", + ), + # ExperimentSet ADD_EXPERIMENT permissions - Private + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.ADD_EXPERIMENT, + True, + description="Admin can add experiment to private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "owner", + Action.ADD_EXPERIMENT, + True, + description="Owner can add experiment to their experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "contributor", + Action.ADD_EXPERIMENT, + True, + description="Contributor can add experiment to experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "other_user", + Action.ADD_EXPERIMENT, + False, + 404, + "Other user gets 404 for private experiment set", + ), + PermissionTest( + "ExperimentSet", + "private", + "anonymous", + Action.ADD_EXPERIMENT, + False, + 404, + "Anonymous gets 404 for private experiment set", + ), + # ExperimentSet ADD_EXPERIMENT permissions - Published + PermissionTest( + "ExperimentSet", + "published", + "admin", + Action.ADD_EXPERIMENT, + True, + description="Admin can add experiment to published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "owner", + Action.ADD_EXPERIMENT, + True, + description="Owner can add experiment to their published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + description="Contributor can add experiment to published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "other_user", + Action.ADD_EXPERIMENT, + False, + 403, + "Other user cannot add experiment to published experiment set", + ), + PermissionTest( + "ExperimentSet", + "published", + "anonymous", + Action.ADD_EXPERIMENT, + False, + 403, + "Anonymous cannot add experiment to experiment set", + ), + # ============================================================================= + # EXPERIMENT PERMISSIONS + # ============================================================================= + # Experiment READ permissions - Private + PermissionTest( + "Experiment", + "private", + "admin", + Action.READ, + True, + description="Admin can read private experiment", + ), + PermissionTest( + "Experiment", + "private", + "owner", + Action.READ, + True, + description="Owner can read their private experiment", + ), + PermissionTest( + "Experiment", + "private", + "contributor", + Action.READ, + True, + description="Contributor can read private experiment", + ), + PermissionTest( + "Experiment", + "private", + "other_user", + Action.READ, + False, + 404, + "Other user gets 404 for private experiment", + ), + PermissionTest( + "Experiment", + "private", + "anonymous", + Action.READ, + False, + 404, + "Anonymous gets 404 for private experiment", + ), + # Experiment READ permissions - Published + PermissionTest( + "Experiment", + "published", + "admin", + Action.READ, + True, + description="Admin can read published experiment", + ), + PermissionTest( + "Experiment", + "published", + "owner", + Action.READ, + True, + description="Owner can read their published experiment", + ), + PermissionTest( + "Experiment", + "published", + "contributor", + Action.READ, + True, + description="Contributor can read published experiment", + ), + PermissionTest( + "Experiment", + "published", + "other_user", + Action.READ, + True, + description="Other user can read published experiment", + ), + PermissionTest( + "Experiment", + "published", + "anonymous", + Action.READ, + True, + description="Anonymous can read published experiment", + ), + # Experiment UPDATE permissions - Private + PermissionTest( + "Experiment", + "private", + "admin", + Action.UPDATE, + True, + description="Admin can update private experiment", + ), + PermissionTest( + "Experiment", + "private", + "owner", + Action.UPDATE, + True, + description="Owner can update their private experiment", + ), + PermissionTest( + "Experiment", + "private", + "contributor", + Action.UPDATE, + True, + description="Contributor can update private experiment", + ), + PermissionTest( + "Experiment", + "private", + "other_user", + Action.UPDATE, + False, + 404, + "Other user gets 404 for private experiment update", + ), + PermissionTest( + "Experiment", + "private", + "anonymous", + Action.UPDATE, + False, + 404, + "Anonymous gets 404 for private experiment update", + ), + # Experiment UPDATE permissions - Published + PermissionTest( + "Experiment", + "published", + "admin", + Action.UPDATE, + True, + description="Admin can update published experiment", + ), + PermissionTest( + "Experiment", + "published", + "owner", + Action.UPDATE, + True, + description="Owner can update their published experiment", + ), + PermissionTest( + "Experiment", + "published", + "contributor", + Action.UPDATE, + True, + description="Contributor can update published experiment", + ), + PermissionTest( + "Experiment", + "published", + "other_user", + Action.UPDATE, + False, + 403, + "Other user cannot update published experiment", + ), + PermissionTest( + "Experiment", + "published", + "anonymous", + Action.UPDATE, + False, + 401, + "Anonymous cannot update published experiment", + ), + # Experiment DELETE permissions - Private (unpublished) + PermissionTest( + "Experiment", + "private", + "admin", + Action.DELETE, + True, + description="Admin can delete private experiment", + ), + PermissionTest( + "Experiment", + "private", + "owner", + Action.DELETE, + True, + description="Owner can delete unpublished experiment", + ), + PermissionTest( + "Experiment", + "private", + "contributor", + Action.DELETE, + True, + description="Contributor can delete unpublished experiment", + ), + PermissionTest( + "Experiment", + "private", + "other_user", + Action.DELETE, + False, + 404, + "Other user gets 404 for private experiment delete", + ), + PermissionTest( + "Experiment", + "private", + "anonymous", + Action.DELETE, + False, + 404, + "Anonymous gets 404 for private experiment delete", + ), + # Experiment DELETE permissions - Published + PermissionTest( + "Experiment", + "published", + "admin", + Action.DELETE, + True, + description="Admin can delete published experiment", + ), + PermissionTest( + "Experiment", + "published", + "owner", + Action.DELETE, + False, + 403, + "Owner cannot delete published experiment", + ), + PermissionTest( + "Experiment", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Contributor cannot delete published experiment", + ), + PermissionTest( + "Experiment", + "published", + "other_user", + Action.DELETE, + False, + 403, + "Other user gets 403 for published experiment delete", + ), + PermissionTest( + "Experiment", + "published", + "anonymous", + Action.DELETE, + False, + 403, + "Anonymous gets 403 for published experiment delete", + ), + # Experiment ADD_SCORE_SET permissions - Private + PermissionTest( + "Experiment", + "private", + "admin", + Action.ADD_SCORE_SET, + True, + description="Admin can add score set to private experiment", + ), + PermissionTest( + "Experiment", + "private", + "owner", + Action.ADD_SCORE_SET, + True, + description="Owner can add score set to their private experiment", + ), + PermissionTest( + "Experiment", + "private", + "contributor", + Action.ADD_SCORE_SET, + True, + description="Contributor can add score set to private experiment", + ), + PermissionTest( + "Experiment", + "private", + "other_user", + Action.ADD_SCORE_SET, + False, + 404, + "Other user gets 404 for private experiment", + ), + PermissionTest( + "Experiment", + "private", + "anonymous", + Action.ADD_SCORE_SET, + False, + 404, + "Anonymous gets 404 for private experiment", + ), + # Experiment ADD_SCORE_SET permissions - Published (any signed in user can add) + PermissionTest( + "Experiment", + "published", + "admin", + Action.ADD_SCORE_SET, + True, + description="Admin can add score set to published experiment", + ), + PermissionTest( + "Experiment", + "published", + "owner", + Action.ADD_SCORE_SET, + True, + description="Owner can add score set to their published experiment", + ), + PermissionTest( + "Experiment", + "published", + "contributor", + Action.ADD_SCORE_SET, + True, + description="Contributor can add score set to published experiment", + ), + PermissionTest( + "Experiment", + "published", + "other_user", + Action.ADD_SCORE_SET, + True, + description="Other user can add score set to published experiment", + ), + PermissionTest( + "Experiment", + "published", + "anonymous", + Action.ADD_SCORE_SET, + False, + 403, + "Anonymous cannot add score set to experiment", + ), + # ============================================================================= + # SCORE SET PERMISSIONS + # ============================================================================= + # ScoreSet READ permissions - Private + PermissionTest( + "ScoreSet", + "private", + "admin", + Action.READ, + True, + description="Admin can read private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "owner", + Action.READ, + True, + description="Owner can read their private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "contributor", + Action.READ, + True, + description="Contributor can read private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "other_user", + Action.READ, + False, + 404, + "Other user gets 404 for private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "anonymous", + Action.READ, + False, + 404, + "Anonymous gets 404 for private score set", + ), + # ScoreSet READ permissions - Published + PermissionTest( + "ScoreSet", + "published", + "admin", + Action.READ, + True, + description="Admin can read published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "owner", + Action.READ, + True, + description="Owner can read their published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "contributor", + Action.READ, + True, + description="Contributor can read published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "other_user", + Action.READ, + True, + description="Other user can read published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "anonymous", + Action.READ, + True, + description="Anonymous can read published score set", + ), + # ScoreSet UPDATE permissions - Private + PermissionTest( + "ScoreSet", + "private", + "admin", + Action.UPDATE, + True, + description="Admin can update private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "owner", + Action.UPDATE, + True, + description="Owner can update their private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "contributor", + Action.UPDATE, + True, + description="Contributor can update private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "other_user", + Action.UPDATE, + False, + 404, + "Other user gets 404 for private score set update", + ), + PermissionTest( + "ScoreSet", + "private", + "anonymous", + Action.UPDATE, + False, + 404, + "Anonymous gets 404 for private score set update", + ), + # ScoreSet UPDATE permissions - Published + PermissionTest( + "ScoreSet", + "published", + "admin", + Action.UPDATE, + True, + description="Admin can update published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "owner", + Action.UPDATE, + True, + description="Owner can update their published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "contributor", + Action.UPDATE, + True, + description="Contributor can update published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "other_user", + Action.UPDATE, + False, + 403, + "Other user cannot update published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "anonymous", + Action.UPDATE, + False, + 401, + "Anonymous cannot update published score set", + ), + # ScoreSet DELETE permissions - Private (unpublished) + PermissionTest( + "ScoreSet", + "private", + "admin", + Action.DELETE, + True, + description="Admin can delete private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "owner", + Action.DELETE, + True, + description="Owner can delete unpublished score set", + ), + PermissionTest( + "ScoreSet", + "private", + "contributor", + Action.DELETE, + True, + description="Contributor can delete unpublished score set", + ), + PermissionTest( + "ScoreSet", + "private", + "other_user", + Action.DELETE, + False, + 404, + "Other user gets 404 for private score set delete", + ), + PermissionTest( + "ScoreSet", + "private", + "anonymous", + Action.DELETE, + False, + 404, + "Anonymous gets 404 for private score set delete", + ), + # ScoreSet DELETE permissions - Published + PermissionTest( + "ScoreSet", + "published", + "admin", + Action.DELETE, + True, + description="Admin can delete published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "owner", + Action.DELETE, + False, + 403, + "Owner cannot delete published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Contributor cannot delete published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "other_user", + Action.DELETE, + False, + 403, + "Other user gets 403 for published score set delete", + ), + PermissionTest( + "ScoreSet", + "published", + "anonymous", + Action.DELETE, + False, + 403, + "Anonymous gets 403 for published score set delete", + ), + # ScoreSet PUBLISH permissions - Private + PermissionTest( + "ScoreSet", + "private", + "admin", + Action.PUBLISH, + True, + description="Admin can publish private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "owner", + Action.PUBLISH, + True, + description="Owner can publish their private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "contributor", + Action.PUBLISH, + True, + description="Contributor can publish private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "other_user", + Action.PUBLISH, + False, + 404, + "Other user gets 404 for private score set publish", + ), + PermissionTest( + "ScoreSet", + "private", + "anonymous", + Action.PUBLISH, + False, + 404, + "Anonymous gets 404 for private score set publish", + ), + # ScoreSet SET_SCORES permissions - Private + PermissionTest( + "ScoreSet", + "private", + "admin", + Action.SET_SCORES, + True, + description="Admin can set scores on private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "owner", + Action.SET_SCORES, + True, + description="Owner can set scores on their private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "contributor", + Action.SET_SCORES, + True, + description="Contributor can set scores on private score set", + ), + PermissionTest( + "ScoreSet", + "private", + "other_user", + Action.SET_SCORES, + False, + 404, + "Other user gets 404 for private score set scores", + ), + PermissionTest( + "ScoreSet", + "private", + "anonymous", + Action.SET_SCORES, + False, + 404, + "Anonymous gets 404 for private score set scores", + ), + # ScoreSet SET_SCORES permissions - Published + PermissionTest( + "ScoreSet", + "published", + "admin", + Action.SET_SCORES, + True, + description="Admin can set scores on published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "owner", + Action.SET_SCORES, + True, + description="Owner can set scores on their published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "contributor", + Action.SET_SCORES, + True, + description="Contributor can set scores on published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "other_user", + Action.SET_SCORES, + False, + 403, + "Other user cannot set scores on published score set", + ), + PermissionTest( + "ScoreSet", + "published", + "anonymous", + Action.SET_SCORES, + False, + 403, + "Anonymous cannot set scores on score set", + ), + # ============================================================================= + # COLLECTION PERMISSIONS + # ============================================================================= + # Collection READ permissions - Private + PermissionTest( + "Collection", + "private", + "admin", + Action.READ, + True, + description="Admin can read private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.READ, + True, + description="Owner can read their private collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.READ, + True, + description="Collection admin can read private collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.READ, + True, + description="Collection editor can read private collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.READ, + True, + description="Collection viewer can read private collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.READ, + False, + 404, + "Other user gets 404 for private collection", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.READ, + False, + 404, + "Anonymous gets 404 for private collection", + ), + # Collection READ permissions - Published + PermissionTest( + "Collection", + "published", + "admin", + Action.READ, + True, + description="Admin can read published collection", + ), + PermissionTest( + "Collection", + "published", + "owner", + Action.READ, + True, + description="Owner can read their published collection", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.READ, + True, + description="Collection admin can read published collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.READ, + True, + description="Collection editor can read published collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.READ, + True, + description="Collection viewer can read published collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.READ, + True, + description="Other user can read published collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.READ, + True, + description="Anonymous can read published collection", + ), + # Collection UPDATE permissions - Private + PermissionTest( + "Collection", + "private", + "admin", + Action.UPDATE, + True, + description="Admin can update private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.UPDATE, + True, + description="Owner can update their private collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.UPDATE, + True, + description="Collection admin can update private collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.UPDATE, + True, + description="Collection editor can update private collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.UPDATE, + False, + 403, + "Collection viewer cannot update private collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.UPDATE, + False, + 404, + "Other user gets 404 for private collection update", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.UPDATE, + False, + 404, + "Anonymous gets 404 for private collection update", + ), + # Collection UPDATE permissions - Published + PermissionTest( + "Collection", + "published", + "admin", + Action.UPDATE, + True, + description="Admin can update published collection", + ), + PermissionTest( + "Collection", + "published", + "owner", + Action.UPDATE, + True, + description="Owner can update their published collection", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.UPDATE, + True, + description="Collection admin can update published collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.UPDATE, + True, + description="Collection editor can update published collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.UPDATE, + False, + 403, + "Collection viewer cannot update published collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.UPDATE, + False, + 403, + "Other user cannot update published collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.UPDATE, + False, + 401, + "Anonymous cannot update published collection", + ), + # Collection DELETE permissions - Private + PermissionTest( + "Collection", + "private", + "admin", + Action.DELETE, + True, + description="Admin can delete private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.DELETE, + True, + description="Owner can delete unpublished collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.DELETE, + False, + 403, + "Collection admin cannot delete private collection (only owner can)", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.DELETE, + False, + 403, + "Collection editor cannot delete private collection (only owner can)", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.DELETE, + False, + 403, + "Collection viewer cannot delete private collection (only owner can)", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.DELETE, + False, + 404, + "Other user gets 404 for private collection delete", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.DELETE, + False, + 404, + "Anonymous gets 404 for private collection delete", + ), + # Collection DELETE permissions - Published + PermissionTest( + "Collection", + "published", + "admin", + Action.DELETE, + True, + description="Admin can delete published collection", + ), + # TODO: only admins can delete collections with badges + PermissionTest( + "Collection", + "published", + "owner", + Action.DELETE, + True, + description="Owner can delete published collection w/o badges", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Collection admin cannot delete published collection (only MaveDB admin can)", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Collection editor cannot delete published collection (only MaveDB admin can)", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Collection viewer cannot delete published collection (only MaveDB admin can)", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.DELETE, + False, + 403, + "Other user cannot delete published collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.DELETE, + False, + 403, + "Anonymous cannot delete published collection", + ), + # Collection PUBLISH permissions - Private + PermissionTest( + "Collection", + "private", + "admin", + Action.PUBLISH, + True, + description="MaveDB admin can publish private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.PUBLISH, + True, + description="Owner can publish their collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.PUBLISH, + True, + description="Collection admin can publish collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.PUBLISH, + False, + 403, + "Collection editor cannot publish collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.PUBLISH, + False, + 403, + "Collection viewer cannot publish collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.PUBLISH, + False, + 404, + "Other user gets 404 for private collection publish", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.PUBLISH, + False, + 404, + "Anonymous gets 404 for private collection publish", + ), + # Collection ADD_EXPERIMENT permissions - Private (Collections add experiments, not experiment sets) + PermissionTest( + "Collection", + "private", + "admin", + Action.ADD_EXPERIMENT, + True, + description="Admin can add experiment to private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.ADD_EXPERIMENT, + True, + description="Owner can add experiment to their collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_EXPERIMENT, + True, + description="Collection admin can add experiment to collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_EXPERIMENT, + True, + description="Collection editor can add experiment to collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_EXPERIMENT, + False, + 403, + "Collection viewer cannot add experiment to private collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.ADD_EXPERIMENT, + False, + 404, + "Other user gets 404 for private collection", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.ADD_EXPERIMENT, + False, + 404, + "Anonymous gets 404 for private collection", + ), + # Collection ADD_EXPERIMENT permissions - Published + PermissionTest( + "Collection", + "published", + "admin", + Action.ADD_EXPERIMENT, + True, + description="Admin can add experiment to published collection", + ), + PermissionTest( + "Collection", + "published", + "owner", + Action.ADD_EXPERIMENT, + True, + description="Owner can add experiment to their published collection", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + description="Collection admin can add experiment to published collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + description="Collection editor can add experiment to published collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + False, + 403, + "Collection viewer cannot add experiment to published collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.ADD_EXPERIMENT, + False, + 403, + "Other user cannot add to published collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.ADD_EXPERIMENT, + False, + 403, + "Anonymous cannot add to collection", + ), + # Collection ADD_SCORE_SET permissions - Private + PermissionTest( + "Collection", + "private", + "admin", + Action.ADD_SCORE_SET, + True, + description="Admin can add score set to private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.ADD_SCORE_SET, + True, + description="Owner can add score set to their private collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_SCORE_SET, + True, + description="Collection admin can add score set to private collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_SCORE_SET, + True, + description="Collection editor can add score set to private collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_SCORE_SET, + False, + 403, + "Collection viewer cannot add score set to private collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.ADD_SCORE_SET, + False, + 404, + "Other user gets 404 for private collection", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.ADD_SCORE_SET, + False, + 404, + "Anonymous gets 404 for private collection", + ), + # Collection ADD_SCORE_SET permissions - Published + PermissionTest( + "Collection", + "published", + "admin", + Action.ADD_SCORE_SET, + True, + description="Admin can add score set to published collection", + ), + PermissionTest( + "Collection", + "published", + "owner", + Action.ADD_SCORE_SET, + True, + description="Owner can add score set to their published collection", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + True, + description="Collection admin can add score set to published collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + True, + description="Collection editor can add score set to published collection", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + False, + 403, + "Collection viewer cannot add score set to published collection", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.ADD_SCORE_SET, + False, + 403, + "Other user cannot add score set to published collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.ADD_SCORE_SET, + False, + 403, + "Anonymous cannot add score set to collection", + ), + # Collection ADD_ROLE permissions + PermissionTest( + "Collection", + "private", + "admin", + Action.ADD_ROLE, + True, + description="Admin can add roles to private collection", + ), + PermissionTest( + "Collection", + "private", + "owner", + Action.ADD_ROLE, + True, + description="Owner can add roles to their collection", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_ROLE, + True, + description="Collection admin can add roles to collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_ROLE, + False, + 403, + "Collection editor cannot add roles to collection (only admin can)", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_ROLE, + False, + 403, + "Collection viewer cannot add roles to collection (only admin can)", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "private", + "other_user", + Action.ADD_ROLE, + False, + 404, + "Other user gets 404 for private collection", + ), + PermissionTest( + "Collection", + "private", + "anonymous", + Action.ADD_ROLE, + False, + 404, + "Anonymous gets 404 for private collection", + ), + # Collection ADD_ROLE permissions - Published + PermissionTest( + "Collection", + "published", + "admin", + Action.ADD_ROLE, + True, + description="Admin can add roles to published collection", + ), + PermissionTest( + "Collection", + "published", + "owner", + Action.ADD_ROLE, + True, + description="Owner can add roles to their published collection", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + True, + description="Collection admin can add roles to published collection", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + False, + 403, + "Collection editor cannot add roles to published collection (only admin can)", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + False, + 403, + "Collection viewer cannot add roles to published collection (only admin can)", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.ADD_ROLE, + False, + 403, + "Other user cannot add roles to published collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.ADD_ROLE, + False, + 403, + "Anonymous cannot add roles to published collection", + ), + # Collection ADD_BADGE permissions (only admin) + PermissionTest( + "Collection", + "published", + "admin", + Action.ADD_BADGE, + True, + description="Admin can add badge to published collection", + ), + PermissionTest( + "Collection", + "published", + "owner", + Action.ADD_BADGE, + False, + 403, + "Owner cannot add badge to collection", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + "Collection admin cannot add badge to collection (only MaveDB admin can)", + collection_role="collection_admin", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + "Collection editor cannot add badge to collection (only MaveDB admin can)", + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + "Collection viewer cannot add badge to collection (only MaveDB admin can)", + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "other_user", + Action.ADD_BADGE, + False, + 403, + "Other user cannot add badge to collection", + ), + PermissionTest( + "Collection", + "published", + "anonymous", + Action.ADD_BADGE, + False, + 401, + "Anonymous cannot add badge to collection", + ), + # ============================================================================= + # USER PERMISSIONS + # ============================================================================= + # User READ permissions (accessing user profiles) + PermissionTest( + "User", + None, + "admin", + Action.READ, + True, + description="Admin can read any user profile", + ), + PermissionTest( + "User", + None, + "self", + Action.READ, + True, + description="User can read their own profile", + ), + PermissionTest( + "User", + None, + "other_user", + Action.READ, + False, + 403, + description="Users cannot read other user profiles", + ), + PermissionTest( + "User", + None, + "anonymous", + Action.READ, + False, + 401, + description="Anonymous cannot read user profiles", + ), + # User UPDATE permissions + PermissionTest( + "User", + None, + "admin", + Action.UPDATE, + True, + description="Admin can update any user profile", + ), + PermissionTest( + "User", + None, + "self", + Action.UPDATE, + True, + description="User can update their own profile", + ), + PermissionTest( + "User", + None, + "other_user", + Action.UPDATE, + False, + 403, + "User cannot update other user profiles", + ), + PermissionTest( + "User", + None, + "anonymous", + Action.UPDATE, + False, + 401, + "Anonymous cannot update user profiles", + ), + # User DELETE permissions - not implemented + # User LOOKUP permissions (for search/autocomplete) + PermissionTest( + "User", + None, + "admin", + Action.LOOKUP, + True, + description="Admin can lookup users", + ), + PermissionTest( + "User", + None, + "owner", + Action.LOOKUP, + True, + description="User can lookup other users", + ), + PermissionTest( + "User", + None, + "contributor", + Action.LOOKUP, + True, + description="User can lookup other users", + ), + PermissionTest( + "User", + None, + "other_user", + Action.LOOKUP, + True, + description="User can lookup other users", + ), + PermissionTest( + "User", + None, + "anonymous", + Action.LOOKUP, + False, + 401, + "Anonymous cannot lookup users", + ), + # User ADD_ROLE permissions + PermissionTest( + "User", + None, + "admin", + Action.ADD_ROLE, + True, + description="Admin can add roles to users", + ), + PermissionTest( + "User", + None, + "self", + Action.ADD_ROLE, + False, + 403, + "User cannot add roles to themselves", + ), + PermissionTest( + "User", + None, + "other_user", + Action.ADD_ROLE, + False, + 403, + "User cannot add roles to others", + ), + PermissionTest( + "User", + None, + "anonymous", + Action.ADD_ROLE, + False, + 401, + "Anonymous cannot add roles", + ), + # ============================================================================= + # SCORE CALIBRATION PERMISSIONS + # ============================================================================= + # ScoreCalibration READ permissions - Private, Investigator Provided + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.READ, + True, + description="Admin can read private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.READ, + True, + description="Owner can read their private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.READ, + True, + description="Contributor can read private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.READ, + False, + 404, + "Other user gets 404 for private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.READ, + False, + 404, + "Anonymous gets 404 for private investigator calibration", + investigator_provided=True, + ), + # ScoreCalibration UPDATE permissions - Private, Investigator Provided (follows score set model) + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.UPDATE, + True, + description="Admin can update private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.UPDATE, + True, + description="Owner can update their private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.UPDATE, + True, + description="Contributor can update private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.UPDATE, + False, + 404, + "Other user gets 404 for private investigator calibration update", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.UPDATE, + False, + 404, + "Anonymous gets 404 for private investigator calibration update", + investigator_provided=True, + ), + # ScoreCalibration READ permissions - Private, Community Provided + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.READ, + True, + description="Admin can read private community calibration", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.READ, + True, + description="Owner can read their private community calibration", + investigator_provided=False, + ), + # NOTE: Contributors do not exist for community-provided calibrations + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.READ, + False, + 404, + "Other user gets 404 for private community calibration", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.READ, + False, + 404, + "Anonymous gets 404 for private community calibration", + investigator_provided=False, + ), + # ScoreCalibration UPDATE permissions - Private, Community Provided (only owner can edit) + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.UPDATE, + True, + description="Admin can update private community calibration", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.UPDATE, + True, + description="Owner can update their private community calibration", + investigator_provided=False, + ), + # NOTE: Contributors do not exist for community-provided calibrations + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.UPDATE, + False, + 404, + "Other user gets 404 for private community calibration update", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.UPDATE, + False, + 404, + "Anonymous gets 404 for private community calibration update", + investigator_provided=False, + ), + # ScoreCalibration PUBLISH permissions - Private + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.PUBLISH, + True, + description="Admin can publish private calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.PUBLISH, + True, + description="Owner can publish their private calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.PUBLISH, + True, + description="Contributor can publish private investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.PUBLISH, + False, + 404, + "Other user gets 404 for private calibration publish", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.PUBLISH, + False, + 404, + "Anonymous gets 404 for private calibration publish", + investigator_provided=True, + ), + # ScoreCalibration CHANGE_RANK permissions - Private + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.CHANGE_RANK, + True, + description="Admin can change calibration rank", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.CHANGE_RANK, + True, + description="Owner can change their calibration rank", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.CHANGE_RANK, + True, + description="Contributor can change investigator calibration rank", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.CHANGE_RANK, + False, + 404, + "Other user gets 404 for private calibration rank change", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.CHANGE_RANK, + False, + 404, + "Anonymous gets 404 for private calibration rank change", + investigator_provided=True, + ), + # ScoreCalibration DELETE permissions - Private (investigator-provided) + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.DELETE, + True, + description="Admin can delete private calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.DELETE, + True, + description="Owner can delete unpublished calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.DELETE, + True, + description="Contributor can delete unpublished investigator calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.DELETE, + False, + 404, + "Other user gets 404 for private calibration delete", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.DELETE, + False, + 404, + "Anonymous gets 404 for private calibration delete", + investigator_provided=True, + ), + # ScoreCalibration DELETE permissions - Published (investigator-provided) + PermissionTest( + "ScoreCalibration", + "published", + "admin", + Action.DELETE, + True, + description="Admin can delete published calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "published", + "owner", + Action.DELETE, + False, + 403, + "Owner cannot delete published calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "published", + "contributor", + Action.DELETE, + False, + 403, + "Contributor cannot delete published calibration", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "published", + "other_user", + Action.DELETE, + False, + 403, + "Other user gets 403 for published calibration delete", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "published", + "anonymous", + Action.DELETE, + False, + 401, + "Anonymous gets 401 for published calibration delete", + investigator_provided=True, + ), + # ScoreCalibration DELETE permissions - Private (community-provided) + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.DELETE, + True, + description="Admin can delete private community calibration", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "private", + "owner", + Action.DELETE, + True, + description="Owner can delete unpublished community calibration", + investigator_provided=False, + ), + # NOTE: Contributors do not exist for community-provided calibrations + PermissionTest( + "ScoreCalibration", + "private", + "other_user", + Action.DELETE, + False, + 404, + "Other user gets 404 for private community calibration delete", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "private", + "anonymous", + Action.DELETE, + False, + 404, + "Anonymous gets 404 for private community calibration delete", + investigator_provided=False, + ), + # ScoreCalibration DELETE permissions - Published (community-provided) + PermissionTest( + "ScoreCalibration", + "published", + "admin", + Action.DELETE, + True, + description="Admin can delete published community calibration", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "published", + "owner", + Action.DELETE, + False, + 403, + "Owner cannot delete published community calibration", + investigator_provided=False, + ), + # NOTE: Contributors do not exist for community-provided calibrations + PermissionTest( + "ScoreCalibration", + "published", + "other_user", + Action.DELETE, + False, + 403, + "Other user gets 403 for published community calibration delete", + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", + "published", + "anonymous", + Action.DELETE, + False, + 401, + "Anonymous gets 401 for published community calibration delete", + investigator_provided=False, + ), + # =========================== + # NotImplementedError Test Cases + # =========================== + # These test cases expect NotImplementedError for unsupported action/entity combinations + # ExperimentSet unsupported actions + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.PUBLISH, + "NotImplementedError", + description="ExperimentSet PUBLISH not implemented", + ), + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.SET_SCORES, + "NotImplementedError", + description="ExperimentSet SET_SCORES not implemented", + ), + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.ADD_ROLE, + "NotImplementedError", + description="ExperimentSet ADD_ROLE not implemented", + ), + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.ADD_BADGE, + "NotImplementedError", + description="ExperimentSet ADD_BADGE not implemented", + ), + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.CHANGE_RANK, + "NotImplementedError", + description="ExperimentSet CHANGE_RANK not implemented", + ), + PermissionTest( + "ExperimentSet", + "private", + "admin", + Action.LOOKUP, + "NotImplementedError", + description="ExperimentSet LOOKUP not implemented", + ), + # Experiment unsupported actions + PermissionTest( + "Experiment", + "private", + "admin", + Action.PUBLISH, + "NotImplementedError", + description="Experiment PUBLISH not implemented", + ), + PermissionTest( + "Experiment", + "private", + "admin", + Action.SET_SCORES, + "NotImplementedError", + description="Experiment SET_SCORES not implemented", + ), + PermissionTest( + "Experiment", + "private", + "admin", + Action.ADD_ROLE, + "NotImplementedError", + description="Experiment ADD_ROLE not implemented", + ), + PermissionTest( + "Experiment", + "private", + "admin", + Action.ADD_BADGE, + "NotImplementedError", + description="Experiment ADD_BADGE not implemented", + ), + PermissionTest( + "Experiment", + "private", + "admin", + Action.CHANGE_RANK, + "NotImplementedError", + description="Experiment CHANGE_RANK not implemented", + ), + PermissionTest( + "Experiment", + "private", + "admin", + Action.LOOKUP, + "NotImplementedError", + description="Experiment LOOKUP not implemented", + ), + # Collection unsupported actions + PermissionTest( + "Collection", + "private", + "admin", + Action.LOOKUP, + "NotImplementedError", + description="Collection LOOKUP not implemented", + ), + # ScoreCalibration unsupported actions + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.ADD_EXPERIMENT, + "NotImplementedError", + description="ScoreCalibration ADD_EXPERIMENT not implemented", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.ADD_SCORE_SET, + "NotImplementedError", + description="ScoreCalibration ADD_SCORE_SET not implemented", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.ADD_ROLE, + "NotImplementedError", + description="ScoreCalibration ADD_ROLE not implemented", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.ADD_BADGE, + "NotImplementedError", + description="ScoreCalibration ADD_BADGE not implemented", + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "private", + "admin", + Action.LOOKUP, + "NotImplementedError", + description="ScoreCalibration LOOKUP not implemented", + investigator_provided=True, + ), + # User unsupported actions + PermissionTest( + "User", + None, + "admin", + Action.DELETE, + "NotImplementedError", + description="User DELETE not implemented", + ), + PermissionTest( + "User", + None, + "admin", + Action.PUBLISH, + "NotImplementedError", + description="User PUBLISH not implemented", + ), + PermissionTest( + "User", + None, + "admin", + Action.SET_SCORES, + "NotImplementedError", + description="User SET_SCORES not implemented", + ), + PermissionTest( + "User", + None, + "admin", + Action.ADD_SCORE_SET, + "NotImplementedError", + description="User ADD_SCORE_SET not implemented", + ), + PermissionTest( + "User", + None, + "admin", + Action.ADD_EXPERIMENT, + "NotImplementedError", + description="User ADD_EXPERIMENT not implemented", + ), + PermissionTest( + "User", + None, + "admin", + Action.ADD_BADGE, + "NotImplementedError", + description="User ADD_BADGE not implemented", + ), + PermissionTest( + "User", + None, + "admin", + Action.CHANGE_RANK, + "NotImplementedError", + description="User CHANGE_RANK not implemented", + ), + ] + + +class EntityTestHelper: + """Helper methods for creating test entities with specific states.""" + + @staticmethod + def create_score_set(owner_id: int = 2, contributors: list = []) -> ScoreSet: + """Create a private ScoreSet for testing.""" + score_set = Mock(spec=ScoreSet) + score_set.urn = "urn:mavedb:00000001-a-1" + score_set.created_by_id = owner_id + score_set.contributors = [Mock(orcid_id=c) for c in contributors] + return score_set + + @staticmethod + def create_experiment(owner_id: int = 2, contributors: list = []) -> Experiment: + """Create a private Experiment for testing.""" + experiment = Mock(spec=Experiment) + experiment.urn = "urn:mavedb:00000001-a" + experiment.created_by_id = owner_id + experiment.contributors = [Mock(orcid_id=c) for c in contributors] + return experiment + + @staticmethod + def create_investigator_calibration(owner_id: int = 2, contributors: list = []): + """Create an investigator-provided score calibration for testing.""" + calibration = Mock(spec=ScoreCalibration) + calibration.id = 1 + calibration.created_by_id = owner_id + calibration.investigator_provided = True + calibration.score_set = EntityTestHelper.create_score_set(owner_id, contributors) + return calibration + + @staticmethod + def create_community_calibration(owner_id: int = 2, contributors: list = []): + """Create a community-provided score calibration for testing.""" + calibration = Mock(spec=ScoreCalibration) + calibration.id = 1 + calibration.created_by_id = owner_id + calibration.investigator_provided = False + calibration.score_set = EntityTestHelper.create_score_set(owner_id, contributors) + return calibration + + @staticmethod + def create_experiment_set(owner_id: int = 2, contributors: list = []) -> ExperimentSet: + """Create an ExperimentSet for testing in the specified state.""" + exp_set = Mock(spec=ExperimentSet) + exp_set.urn = "urn:mavedb:00000001" + exp_set.created_by_id = owner_id + exp_set.contributors = [Mock(orcid_id=c) for c in contributors] + return exp_set + + @staticmethod + def create_collection( + owner_id: int = 2, + user_role: Optional[str] = None, + user_id: int = 3, + ) -> Collection: + """Create a Collection for testing.""" + collection = Mock(spec=Collection) + collection.urn = "urn:mavedb:col000001" + collection.created_by_id = owner_id + collection.badge_name = None # Not an official collection by default + + # Create user_associations for Collection permissions + user_associations = [] + + # Add owner as admin (unless owner_id is the same as user_id with a specific role) + if not (user_role and user_id == owner_id): + owner_assoc = Mock(spec=CollectionUserAssociation) + owner_assoc.user_id = owner_id + owner_assoc.contribution_role = ContributionRole.admin + user_associations.append(owner_assoc) + + # Add specific role if requested + if user_role: + role_mapping = { + "collection_admin": ContributionRole.admin, + "collection_editor": ContributionRole.editor, + "collection_viewer": ContributionRole.viewer, + } + if user_role in role_mapping: + user_assoc = Mock(spec=CollectionUserAssociation) + user_assoc.user_id = user_id + user_assoc.contribution_role = role_mapping[user_role] + user_associations.append(user_assoc) + + collection.user_associations = user_associations + return collection + + @staticmethod + def create_user(user_id: int = 3) -> User: + """Create a User for testing.""" + user = Mock(spec=User) + user.id = user_id + user.username = "3333-3333-3333-333X" + user.email = "target@example.com" + user.first_name = "Target" + user.last_name = "User" + user.is_active = True + return user + + +class TestPermissions: + """Test all permission scenarios.""" + + def setup_method(self): + """Set up test data for each test method.""" + self.users = { + "admin": Mock(user=Mock(id=1, username="1111-1111-1111-111X"), active_roles=[UserRole.admin]), + "owner": Mock(user=Mock(id=2, username="2222-2222-2222-222X"), active_roles=[]), + "contributor": Mock(user=Mock(id=3, username="3333-3333-3333-333X"), active_roles=[]), + "other_user": Mock(user=Mock(id=4, username="4444-4444-4444-444X"), active_roles=[]), + "self": Mock( # For User entity tests where user is checking themselves + user=Mock(id=3, username="3333-3333-3333-333X"), active_roles=[] + ), + "anonymous": None, + } + + @pytest.mark.parametrize( + "test_case", + PermissionTestData.get_all_permission_tests(), + ids=lambda tc: f"{tc.entity_type}_{tc.entity_state or 'no_state'}_{tc.user_type}_{tc.action.value}", + ) + def test_permission(self, test_case: PermissionTest): + """Test a single permission scenario.""" + # Handle NotImplementedError test cases + if test_case.should_be_permitted == "NotImplementedError": + with pytest.raises(NotImplementedError): + entity = self._create_entity(test_case) + user_data = self.users[test_case.user_type] + has_permission(user_data, entity, test_case.action) + return + + # Arrange - Create entity for normal test cases + entity = self._create_entity(test_case) + user_data = self.users[test_case.user_type] + + # Act + result = has_permission(user_data, entity, test_case.action) + + # Assert + assert result.permitted == test_case.should_be_permitted, ( + f"Expected {test_case.should_be_permitted} but got {result.permitted}. " + f"Description: {test_case.description}" + ) + + if not test_case.should_be_permitted and test_case.expected_code: + assert ( + result.http_code == test_case.expected_code + ), f"Expected error code {test_case.expected_code} but got {result.http_code}" + + def _create_entity(self, test_case: PermissionTest): + """Create an entity based on the test case specification.""" + # For most entities, contributors are ORCiDs. For Collections, handle roles differently. + if test_case.entity_type == "Collection": + # Collection uses specific role-based permissions + contributors = [] # Don't use ORCiD contributors for Collections + else: + contributors = ["3333-3333-3333-333X"] if test_case.user_type == "contributor" else [] + + if test_case.entity_type == "ScoreSet": + entity = EntityTestHelper.create_score_set(owner_id=2, contributors=contributors) + entity.private = test_case.entity_state == "private" + entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None + return entity + + elif test_case.entity_type == "Experiment": + entity = EntityTestHelper.create_experiment(owner_id=2, contributors=contributors) + entity.private = test_case.entity_state == "private" + entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None + return entity + + elif test_case.entity_type == "ScoreCalibration": + if test_case.investigator_provided is True: + entity = EntityTestHelper.create_investigator_calibration(owner_id=2, contributors=contributors) + entity.private = test_case.entity_state == "private" + entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None + return entity + elif test_case.investigator_provided is False: + entity = EntityTestHelper.create_community_calibration(owner_id=2, contributors=contributors) + entity.private = test_case.entity_state == "private" + entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None + return entity + + elif test_case.entity_type == "ExperimentSet": + entity = EntityTestHelper.create_experiment_set(owner_id=2, contributors=contributors) + entity.private = test_case.entity_state == "private" + entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None + return entity + + elif test_case.entity_type == "Collection": + entity = EntityTestHelper.create_collection( + owner_id=2, + user_role=test_case.collection_role, + user_id=3, # User ID for the collection contributor role + ) + entity.private = test_case.entity_state == "private" + entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None + return entity + + elif test_case.entity_type == "User": + # For User tests, create a target user (id=3) that will be acted upon + return EntityTestHelper.create_user(user_id=3) + + raise ValueError(f"Unknown entity type/state: {test_case.entity_type}/{test_case.entity_state}") + + +class TestPermissionResponse: + """Test PermissionResponse class functionality.""" + + def test_permission_response_permitted(self): + """Test PermissionResponse when permission is granted.""" + response = PermissionResponse(True) + + assert response.permitted is True + assert response.http_code is None + assert response.message is None + + def test_permission_response_denied_default_code(self): + """Test PermissionResponse when permission is denied with default error code.""" + response = PermissionResponse(False) + + assert response.permitted is False + assert response.http_code == 403 + assert response.message is None + + def test_permission_response_denied_custom_code_and_message(self): + """Test PermissionResponse when permission is denied with custom error code and message.""" + response = PermissionResponse(False, 404, "Resource not found") + + assert response.permitted is False + assert response.http_code == 404 + assert response.message == "Resource not found" + + +class TestPermissionException: + """Test PermissionException class functionality.""" + + def test_permission_exception_creation(self): + """Test PermissionException creation and properties.""" + exception = PermissionException(403, "Insufficient permissions") + + assert exception.http_code == 403 + assert exception.message == "Insufficient permissions" + + +class TestRolesPermitted: + """Test roles_permitted function functionality.""" + + def test_roles_permitted_with_matching_role(self): + """Test roles_permitted when user has a matching role.""" + + user_roles = [UserRole.admin, UserRole.mapper] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_roles_permitted_with_no_matching_role(self): + """Test roles_permitted when user has no matching roles.""" + + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_roles_permitted_with_empty_user_roles(self): + """Test roles_permitted when user has no roles.""" + + user_roles = [] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + +class TestAssertPermission: + """Test assert_permission function functionality.""" + + def test_assert_permission_when_permitted(self): + """Test assert_permission when permission is granted.""" + + admin_data = Mock(user=Mock(id=1, username="1111-1111-1111-111X"), active_roles=[UserRole.admin]) + + # Create a private score set that admin should have access to + score_set = EntityTestHelper.create_score_set() + + # Should not raise exception + result = assert_permission(admin_data, score_set, Action.READ) + assert result.permitted is True + + def test_assert_permission_when_denied(self): + """Test assert_permission when permission is denied - should raise PermissionException.""" + + other_user_data = Mock(user=Mock(id=4, username="4444-4444-4444-444X"), active_roles=[]) + + # Create a private score set that other user should not have access to + score_set = EntityTestHelper.create_score_set() + + # Should raise PermissionException + with pytest.raises(PermissionException) as exc_info: + assert_permission(other_user_data, score_set, Action.READ) + + assert exc_info.value.http_code == 404 + assert "not found" in exc_info.value.message From c177c42294346a58b4415a7a9f03e0960a67ad00 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 18:01:04 -0800 Subject: [PATCH 09/18] fix: add type hint for investigator_provided field in ScoreCalibration model --- src/mavedb/models/score_calibration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mavedb/models/score_calibration.py b/src/mavedb/models/score_calibration.py index 988d4d04e..ef32c1072 100644 --- a/src/mavedb/models/score_calibration.py +++ b/src/mavedb/models/score_calibration.py @@ -33,7 +33,7 @@ class ScoreCalibration(Base): title = Column(String, nullable=False) research_use_only = Column(Boolean, nullable=False, default=False) primary = Column(Boolean, nullable=False, default=False) - investigator_provided = Column(Boolean, nullable=False, default=False) + investigator_provided: Mapped[bool] = Column(Boolean, nullable=False, default=False) private = Column(Boolean, nullable=False, default=True) notes = Column(String, nullable=True) From de3622e446f1497c1b7635dc1ef3d72657a2f00e Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 3 Dec 2025 18:18:00 -0800 Subject: [PATCH 10/18] refactor: Refactor permissions into module for improved readability and testability - Refactored the `permissions.py` file into a permissions module - Entities now have their own has_permission function, and the core `has_permission` function acts as a dispatcher. This structure significantly improves readability and testability of the permissions boundary. It also greatly improves its extensibility for future permissions updates. - Added comprehensive tests for all implemented permissions. Tests are modular and can be easily added to and changed. --- src/mavedb/lib/permissions.py | 524 --- src/mavedb/lib/permissions/__init__.py | 27 + src/mavedb/lib/permissions/actions.py | 15 + src/mavedb/lib/permissions/collection.py | 441 +++ src/mavedb/lib/permissions/core.py | 123 + src/mavedb/lib/permissions/exceptions.py | 4 + src/mavedb/lib/permissions/experiment.py | 259 ++ src/mavedb/lib/permissions/experiment_set.py | 259 ++ src/mavedb/lib/permissions/models.py | 25 + .../lib/permissions/score_calibration.py | 312 ++ src/mavedb/lib/permissions/score_set.py | 296 ++ src/mavedb/lib/permissions/user.py | 220 ++ src/mavedb/lib/permissions/utils.py | 52 + src/mavedb/server_main.py | 4 +- tests/lib/permissions/__init__.py | 1 + tests/lib/permissions/conftest.py | 194 + tests/lib/permissions/test_collection.py | 769 ++++ tests/lib/permissions/test_core.py | 128 + tests/lib/permissions/test_experiment.py | 317 ++ tests/lib/permissions/test_experiment_set.py | 329 ++ tests/lib/permissions/test_models.py | 39 + .../lib/permissions/test_score_calibration.py | 597 ++++ tests/lib/permissions/test_score_set.py | 363 ++ tests/lib/permissions/test_user.py | 256 ++ tests/lib/permissions/test_utils.py | 141 + tests/lib/test_permissions.py | 3165 ----------------- 26 files changed, 5169 insertions(+), 3691 deletions(-) delete mode 100644 src/mavedb/lib/permissions.py create mode 100644 src/mavedb/lib/permissions/__init__.py create mode 100644 src/mavedb/lib/permissions/actions.py create mode 100644 src/mavedb/lib/permissions/collection.py create mode 100644 src/mavedb/lib/permissions/core.py create mode 100644 src/mavedb/lib/permissions/exceptions.py create mode 100644 src/mavedb/lib/permissions/experiment.py create mode 100644 src/mavedb/lib/permissions/experiment_set.py create mode 100644 src/mavedb/lib/permissions/models.py create mode 100644 src/mavedb/lib/permissions/score_calibration.py create mode 100644 src/mavedb/lib/permissions/score_set.py create mode 100644 src/mavedb/lib/permissions/user.py create mode 100644 src/mavedb/lib/permissions/utils.py create mode 100644 tests/lib/permissions/__init__.py create mode 100644 tests/lib/permissions/conftest.py create mode 100644 tests/lib/permissions/test_collection.py create mode 100644 tests/lib/permissions/test_core.py create mode 100644 tests/lib/permissions/test_experiment.py create mode 100644 tests/lib/permissions/test_experiment_set.py create mode 100644 tests/lib/permissions/test_models.py create mode 100644 tests/lib/permissions/test_score_calibration.py create mode 100644 tests/lib/permissions/test_score_set.py create mode 100644 tests/lib/permissions/test_user.py create mode 100644 tests/lib/permissions/test_utils.py delete mode 100644 tests/lib/test_permissions.py diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py deleted file mode 100644 index 4e7174aca..000000000 --- a/src/mavedb/lib/permissions.py +++ /dev/null @@ -1,524 +0,0 @@ -import logging -from enum import Enum -from typing import Optional - -from mavedb.db.base import Base -from mavedb.lib.authentication import UserData -from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.models.collection import Collection -from mavedb.models.enums.contribution_role import ContributionRole -from mavedb.models.enums.user_role import UserRole -from mavedb.models.experiment import Experiment -from mavedb.models.experiment_set import ExperimentSet -from mavedb.models.score_calibration import ScoreCalibration -from mavedb.models.score_set import ScoreSet -from mavedb.models.user import User - -logger = logging.getLogger(__name__) - - -class Action(Enum): - LOOKUP = "lookup" - READ = "read" - UPDATE = "update" - DELETE = "delete" - ADD_EXPERIMENT = "add_experiment" - ADD_SCORE_SET = "add_score_set" - SET_SCORES = "set_scores" - ADD_ROLE = "add_role" - PUBLISH = "publish" - ADD_BADGE = "add_badge" - CHANGE_RANK = "change_rank" - - -class PermissionResponse: - def __init__(self, permitted: bool, http_code: int = 403, message: Optional[str] = None): - self.permitted = permitted - self.http_code = http_code if not permitted else None - self.message = message if not permitted else None - - save_to_logging_context({"permission_message": self.message, "access_permitted": self.permitted}) - if self.permitted: - logger.debug( - msg="Access to the requested resource is permitted.", - extra=logging_context(), - ) - else: - logger.debug( - msg="Access to the requested resource is not permitted.", - extra=logging_context(), - ) - - -class PermissionException(Exception): - def __init__(self, http_code: int, message: str): - self.http_code = http_code - self.message = message - - -def roles_permitted(user_roles: list[UserRole], permitted_roles: list[UserRole]) -> bool: - save_to_logging_context({"permitted_roles": [role.name for role in permitted_roles]}) - - if not user_roles: - logger.debug(msg="User has no associated roles.", extra=logging_context()) - return False - - return any(role in permitted_roles for role in user_roles) - - -def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> PermissionResponse: - private = False - user_is_owner = False - user_is_self = False - user_may_edit = False - user_may_view_private = False - active_roles = user_data.active_roles if user_data else [] - - if isinstance(item, ExperimentSet) or isinstance(item, Experiment) or isinstance(item, ScoreSet): - assert item.private is not None - private = item.private - published = item.published_date is not None - user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False - user_may_edit = user_is_owner or ( - user_data is not None and user_data.user.username in [c.orcid_id for c in item.contributors] - ) - - save_to_logging_context({"resource_is_published": published}) - - if isinstance(item, Collection): - assert item.private is not None - private = item.private - published = item.private is False - user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False - admin_user_ids = set() - editor_user_ids = set() - viewer_user_ids = set() - for user_association in item.user_associations: - if user_association.contribution_role == ContributionRole.admin: - admin_user_ids.add(user_association.user_id) - elif user_association.contribution_role == ContributionRole.editor: - editor_user_ids.add(user_association.user_id) - elif user_association.contribution_role == ContributionRole.viewer: - viewer_user_ids.add(user_association.user_id) - user_is_admin = user_is_owner or (user_data is not None and user_data.user.id in admin_user_ids) - user_may_edit = user_is_admin or (user_data is not None and user_data.user.id in editor_user_ids) - user_may_view_private = user_may_edit or (user_data is not None and (user_data.user.id in viewer_user_ids)) - - save_to_logging_context({"resource_is_published": published}) - - if isinstance(item, ScoreCalibration): - assert item.private is not None - private = item.private - published = item.private is False - user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False - - # If the calibration is investigator provided, treat permissions like score set permissions where contributors - # may also make changes to the calibration. Otherwise, only allow the calibration owner to edit the calibration. - if item.investigator_provided: - user_may_edit = user_is_owner or ( - user_data is not None and user_data.user.username in [c.orcid_id for c in item.score_set.contributors] - ) - else: - user_may_edit = user_is_owner - - if isinstance(item, User): - user_is_self = item.id == user_data.user.id if user_data is not None else False - user_may_edit = user_is_self - - save_to_logging_context( - { - "resource_is_private": private, - "user_is_owner_of_resource": user_is_owner, - "user_is_may_edit_resource": user_may_edit, - "user_is_self": user_is_self, - } - ) - - if isinstance(item, ExperimentSet): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Owner may only delete an experiment set if it has not already been published. - if user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.ADD_EXPERIMENT: - # Only permitted users can add an experiment to an existing experiment set. - return PermissionResponse( - user_may_edit or roles_permitted(active_roles, [UserRole.admin]), - 404 if private else 403, - ( - f"experiment set with URN '{item.urn}' not found" - if private - else f"insufficient permissions for URN '{item.urn}'" - ), - ) - else: - raise NotImplementedError(f"has_permission(User, ExperimentSet, {action}, Role)") - - elif isinstance(item, Experiment): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Owner may only delete an experiment if it has not already been published. - if user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.ADD_SCORE_SET: - # Only permitted users can add a score set to a private experiment. - if user_may_edit or roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - return PermissionResponse(False, 404, f"experiment with URN '{item.urn}' not found") - # Any signed in user has permissions to add a score set to a public experiment - elif user_data is not None: - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - else: - raise NotImplementedError(f"has_permission(User, Experiment, {action}, Role)") - - elif isinstance(item, ScoreSet): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Owner may only delete a score set if it has not already been published. - if user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - # Only the owner or admins may publish a private score set. - elif action == Action.PUBLISH: - if user_may_edit: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.SET_SCORES: - return PermissionResponse( - (user_may_edit or roles_permitted(active_roles, [UserRole.admin])), - 404 if private else 403, - ( - f"score set with URN '{item.urn}' not found" - if private - else f"insufficient permissions for URN '{item.urn}'" - ), - ) - else: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - - elif isinstance(item, Collection): - if action == Action.READ: - if user_may_view_private or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # A collection may be deleted even if it has been published, as long as it is not an official collection. - if user_is_owner: - return PermissionResponse( - not item.badge_name, - 403, - f"insufficient permissions for URN '{item.urn}'", - ) - # MaveDB admins may delete official collections. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.PUBLISH: - if user_is_admin: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.ADD_SCORE_SET: - # Whether the collection is private or public, only permitted users can add a score set to a collection. - if user_may_edit or roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.ADD_EXPERIMENT: - # Only permitted users can add an experiment to an existing collection. - return PermissionResponse( - user_may_edit or roles_permitted(active_roles, [UserRole.admin]), - 404 if private and not user_may_view_private else 403, - ( - f"collection with URN '{item.urn}' not found" - if private and not user_may_view_private - else f"insufficient permissions for URN '{item.urn}'" - ), - ) - elif action == Action.ADD_ROLE: - # Both collection admins and MaveDB admins can add a user to a collection role - if user_is_admin or roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - else: - return PermissionResponse(False, 403, "Insufficient permissions to add user role.") - # only MaveDB admins may add a badge name to a collection, which makes the collection considered "official" - elif action == Action.ADD_BADGE: - # Roles which may perform this operation. - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - else: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - elif isinstance(item, ScoreCalibration): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - # TODO#549: Allow editing of certain fields even if published. For now, - # Owner may only edit if a calibration is not published. - elif user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Roles which may perform this operation. - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - # Owner may only delete a calibration if it has not already been published. - elif user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False) - # Only the owner may publish a private calibration. - elif action == Action.PUBLISH: - if user_may_edit: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False) - elif action == Action.CHANGE_RANK: - if user_may_edit: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - - else: - raise NotImplementedError(f"has_permission(User, ScoreCalibration, {action}, Role)") - - elif isinstance(item, User): - if action == Action.LOOKUP: - # any existing user can look up any mavedb user by Orcid ID - # lookup differs from read because lookup means getting the first name, last name, and orcid ID of the user, - # while read means getting an admin view of the user's details - if user_data is not None and user_data.user is not None: - return PermissionResponse(True) - else: - # TODO is this inappropriately acknowledging the existence of the user? - return PermissionResponse(False, 401, "Insufficient permissions for user lookup.") - if action == Action.READ: - if user_is_self: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, "Insufficient permissions for user view.") - else: - return PermissionResponse(False, 403, "Insufficient permissions for user view.") - elif action == Action.UPDATE: - if user_is_self: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, "Insufficient permissions for user update.") - else: - return PermissionResponse(False, 403, "Insufficient permissions for user update.") - elif action == Action.ADD_ROLE: - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, "Insufficient permissions to add user role.") - else: - return PermissionResponse(False, 403, "Insufficient permissions to add user role.") - elif action == Action.DELETE: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - else: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - - else: - raise NotImplementedError(f"has_permission(User, {item.__class__}, {action}, Role)") - - -def assert_permission(user_data: Optional[UserData], item: Base, action: Action) -> PermissionResponse: - save_to_logging_context({"permission_boundary": action.name}) - permission = has_permission(user_data, item, action) - - if not permission.permitted: - assert permission.http_code and permission.message - raise PermissionException(http_code=permission.http_code, message=permission.message) - - return permission diff --git a/src/mavedb/lib/permissions/__init__.py b/src/mavedb/lib/permissions/__init__.py new file mode 100644 index 000000000..2f226cef3 --- /dev/null +++ b/src/mavedb/lib/permissions/__init__.py @@ -0,0 +1,27 @@ +""" +Permission system for MaveDB entities. + +This module provides a comprehensive permission system for checking user access +to various entity types including ScoreSets, Experiments, Collections, etc. + +Main Functions: + has_permission: Check if a user has permission for an action on an entity + assert_permission: Assert permission or raise exception + +Usage: + >>> from mavedb.lib.permissions import Action, has_permission, assert_permission + >>> + >>> # Check permission and handle response + >>> result = has_permission(user_data, score_set, Action.READ) + >>> if result.permitted: + ... # User has access + ... pass + >>> + >>> # Assert permission (raises exception if denied) + >>> assert_permission(user_data, score_set, Action.UPDATE) +""" + +from .actions import Action +from .core import assert_permission, has_permission + +__all__ = ["has_permission", "assert_permission", "Action"] diff --git a/src/mavedb/lib/permissions/actions.py b/src/mavedb/lib/permissions/actions.py new file mode 100644 index 000000000..cc3a95596 --- /dev/null +++ b/src/mavedb/lib/permissions/actions.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class Action(Enum): + LOOKUP = "lookup" + READ = "read" + UPDATE = "update" + DELETE = "delete" + ADD_EXPERIMENT = "add_experiment" + ADD_SCORE_SET = "add_score_set" + SET_SCORES = "set_scores" + ADD_ROLE = "add_role" + PUBLISH = "publish" + ADD_BADGE = "add_badge" + CHANGE_RANK = "change_rank" diff --git a/src/mavedb/lib/permissions/collection.py b/src/mavedb/lib/permissions/collection.py new file mode 100644 index 000000000..fb11277ee --- /dev/null +++ b/src/mavedb/lib/permissions/collection.py @@ -0,0 +1,441 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.collection import Collection +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + + +def has_permission(user_data: Optional[UserData], entity: Collection, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a Collection entity. + + This function evaluates user permissions based on Collection role associations, + ownership, and user roles. Collections use a special permission model with + role-based user associations. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT_SET). + entity: The Collection entity to check permissions for. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for Collection entities. + + Note: + Collections use CollectionUserAssociation objects to define user roles + (admin, editor, viewer) rather than simple contributor lists. + """ + if entity.private is None: + raise ValueError("Collection entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + collection_roles = [] + active_roles = [] + + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + + # Find the user's collection roles in this collection through user_associations. + user_associations = [assoc for assoc in entity.user_associations if assoc.user_id == user_data.user.id] + if user_associations: + collection_roles = [assoc.contribution_role for assoc in user_associations] + + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "collection_roles": [role.value for role in collection_roles] if collection_roles else None, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, + Action.ADD_ROLE: _handle_add_role_action, + Action.ADD_BADGE: _handle_add_badge_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for Collection entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + entity.badge_name is not None, + user_is_owner, + collection_roles, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for Collection entities. + + Public Collections are readable by anyone. Private Collections are only readable + by users with Collection roles, owners, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being accessed. + private: Whether the Collection is private. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private collection. + if not private: + return PermissionResponse(True) + # The owner may read a private collection. + if user_is_owner: + return PermissionResponse(True) + # Collection role holders may read a private collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor, ContributionRole.viewer]): + return PermissionResponse(True) + # Users with these specific roles may read a private collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_update_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for Collection entities. + + Only owners, Collection admins/editors, and system admins can update Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being updated. + private: Whether the Collection is private. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner may update the collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins and editors may update the collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor]): + return PermissionResponse(True) + # Users with these specific roles may update the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for Collection entities. + + System admins can delete any Collection. Owners and Collection admins can only + delete unpublished Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being deleted. + private: Whether the Collection is private. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # System admins may delete any collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Other users may only delete non-official collections. + if not official_collection: + # Owners may delete a collection only if it has not been published. + # Collection admins/editors/viewers may not delete collections. + if user_is_owner: + return PermissionResponse( + private, + 403, + f"insufficient permissions for URN '{entity.urn}'", + ) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_publish_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle PUBLISH action permission check for Collection entities. + + Only owners, Collection admins, and system admins can publish Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being published. + private: Whether the Collection is private. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow publish access under the following conditions: + # The owner may publish a collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins may publish the collection. + if roles_permitted(collection_roles, [ContributionRole.admin]): + return PermissionResponse(True) + # Users with these specific roles may publish the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_add_experiment_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_EXPERIMENT action permission check for Collection entities. + + Only owners, Collection admins/editors, and system admins can add experiment sets + to private Collections. Any authenticated user can add to public Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add an experiment to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add experiment add access under the following conditions: + # The owner may add an experiment to a private collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins/editors may add an experiment to the collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor]): + return PermissionResponse(True) + # Users with these specific roles may add an experiment to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_add_score_set_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_SCORE_SET action permission check for Collection entities. + + Only owners, Collection admins/editors, and system admins can add score sets + to private Collections. Any authenticated user can add to public Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add a score set to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add score set access under the following conditions: + # The owner may add a score set to a private collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins/editors may add a score set to the collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor]): + return PermissionResponse(True) + # Users with these specific roles may add a score set to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_add_role_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_ROLE action permission check for Collection entities. + + Only owners and Collection admins can add roles to Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add a role to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add role access under the following conditions: + # The owner may add a role. + if user_is_owner: + return PermissionResponse(True) + # Collection admins may add a role to the collection. + if roles_permitted(collection_roles, [ContributionRole.admin]): + return PermissionResponse(True) + # Users with these specific roles may add a role to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _handle_add_badge_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_BADGE action permission check for Collection entities. + + Only system admins can add badges to Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add a badge to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add badge access under the following conditions: + # Users with these specific roles may add a badge to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + + +def _deny_action_for_collection( + entity: Collection, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool = False, +) -> PermissionResponse: + """ + Generate appropriate denial response for Collection permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to a Collection based on its privacy and user authentication. + + Args: + entity: The Collection entity being accessed. + private: Whether the Collection is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has any role allowing them to view private collections. + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + Returns 404 for private entities to avoid information disclosure, + 401 for unauthenticated users, and 403 for insufficient permissions. + """ + # Do not acknowledge the existence of a private collection. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"collection with URN '{entity.urn}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") + + # The authenticated user lacks sufficient permissions. + return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") diff --git a/src/mavedb/lib/permissions/core.py b/src/mavedb/lib/permissions/core.py new file mode 100644 index 000000000..e58a03197 --- /dev/null +++ b/src/mavedb/lib/permissions/core.py @@ -0,0 +1,123 @@ +from typing import Any, Callable, Optional, Union + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.exceptions import PermissionException +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.models.collection import Collection +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + +# Import entity-specific permission modules +from . import ( + collection, + experiment, + experiment_set, + score_calibration, + score_set, + user, +) + +# Define the supported entity types +EntityType = Union[ + Collection, + Experiment, + ExperimentSet, + ScoreCalibration, + ScoreSet, + User, +] + + +def has_permission(user_data: Optional[UserData], entity: EntityType, action: Action) -> PermissionResponse: + """ + Main dispatcher function for permission checks across all entity types. + + This function routes permission checks to the appropriate entity-specific + module based on the type of the entity provided. Each entity type has + its own permission logic and supported actions. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The entity to check permissions for. Must be one of the supported types. + action: The action to be performed on the entity. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + NotImplementedError: If the entity type is not supported. + + Example: + >>> from mavedb.lib.permissions.core import has_permission + >>> from mavedb.lib.permissions.actions import Action + >>> result = has_permission(user_data, score_set, Action.READ) + >>> if result.permitted: + ... # User has permission + ... pass + + Note: + This is the main entry point for all permission checks in the application. + Each entity type delegates to its own module for specific permission logic. + """ + # Dictionary mapping entity types to their corresponding permission modules + entity_handlers: dict[type, Callable[[Optional[UserData], Any, Action], PermissionResponse]] = { + Collection: collection.has_permission, + Experiment: experiment.has_permission, + ExperimentSet: experiment_set.has_permission, + ScoreCalibration: score_calibration.has_permission, + ScoreSet: score_set.has_permission, + User: user.has_permission, + } + + entity_type = type(entity) + + if entity_type not in entity_handlers: + supported_types = ", ".join(cls.__name__ for cls in entity_handlers.keys()) + raise NotImplementedError( + f"Permission checks are not implemented for entity type '{entity_type.__name__}'. " + f"Supported entity types: {supported_types}" + ) + + handler = entity_handlers[entity_type] + return handler(user_data, entity, action) + + +def assert_permission(user_data: Optional[UserData], entity: EntityType, action: Action) -> PermissionResponse: + """ + Assert that a user has permission to perform an action on an entity. + + This function checks permissions and raises an exception if the user lacks + the necessary permissions. It's a convenience wrapper around has_permission + for cases where you want to fail fast on permission denials. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The entity to check permissions for. + action: The action to be performed on the entity. + + Returns: + PermissionResponse: The permission result if access is granted. + + Raises: + PermissionException: If the user lacks sufficient permissions. + + Example: + >>> from mavedb.lib.permissions.core import assert_permission + >>> from mavedb.lib.permissions.actions import Action + >>> # This will raise PermissionException if user can't update + >>> assert_permission(user_data, score_set, Action.UPDATE) + """ + save_to_logging_context({"permission_boundary": action.name}) + permission = has_permission(user_data, entity, action) + + if not permission.permitted: + http_code = permission.http_code if permission.http_code is not None else 403 + message = permission.message if permission.message is not None else "Permission denied" + raise PermissionException(http_code=http_code, message=message) + + return permission diff --git a/src/mavedb/lib/permissions/exceptions.py b/src/mavedb/lib/permissions/exceptions.py new file mode 100644 index 000000000..d3ebf87e7 --- /dev/null +++ b/src/mavedb/lib/permissions/exceptions.py @@ -0,0 +1,4 @@ +class PermissionException(Exception): + def __init__(self, http_code: int, message: str): + self.http_code = http_code + self.message = message diff --git a/src/mavedb/lib/permissions/experiment.py b/src/mavedb/lib/permissions/experiment.py new file mode 100644 index 000000000..c33f24eb8 --- /dev/null +++ b/src/mavedb/lib/permissions/experiment.py @@ -0,0 +1,259 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.experiment import Experiment + + +def has_permission(user_data: Optional[UserData], entity: Experiment, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on an Experiment entity. + + This function evaluates user permissions based on ownership, contributor status, + and user roles. It handles both private and public Experiments with different + access control rules. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + action: The action to be performed (READ, UPDATE, DELETE, ADD_SCORE_SET). + entity: The Experiment entity to check permissions for. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for Experiment entities. + """ + if entity.private is None: + raise ValueError("Experiment entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + user_is_contributor = user_data.user.username in [c.orcid_id for c in entity.contributors] + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "user_is_contributor": user_is_contributor, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for Experiment entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + user_is_owner, + user_is_contributor, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for Experiment entities. + + Public Experiments are readable by anyone. Private Experiments are only readable + by owners, contributors, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity being accessed. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private experiment. + if not private: + return PermissionResponse(True) + # The owner or contributors may read a private experiment. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may read a private experiment. + if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): + return PermissionResponse(True) + + return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_update_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for Experiment entities. + + Only owners, contributors, and admins can update Experiments. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity being updated. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner or contributors may update the experiment. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the experiment. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for Experiment entities. + + Admins can delete any Experiment. Owners can only delete unpublished Experiments. + Contributors cannot delete Experiments. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity being deleted. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # Admins may delete any experiment. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete an experiment only if it has not been published. Contributors may not delete an experiment. + if user_is_owner: + published = entity.published_date is not None + return PermissionResponse( + not published, + 403, + f"insufficient permissions for URN '{entity.urn}'", + ) + + return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_add_score_set_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_SCORE_SET action permission check for Experiment entities. + + Only permitted users can add a score set to a private experiment. + Any authenticated user can add a score set to a public experiment. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity to add a score set to. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add score set access under the following conditions: + # Owners or contributors may add a score set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the experiment. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _deny_action_for_experiment( + entity: Experiment, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool, +) -> PermissionResponse: + """ + Generate appropriate denial response for Experiment permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to an Experiment based on its privacy and user authentication. + + Args: + entity: The Experiment entity being accessed. + private: Whether the Experiment is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has permission to view private experiments. + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + Returns 404 for private entities to avoid information disclosure, + 401 for unauthenticated users, and 403 for insufficient permissions. + """ + # Do not acknowledge the existence of a private experiment. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"experiment with URN '{entity.urn}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") + + # The authenticated user lacks sufficient permissions. + return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") diff --git a/src/mavedb/lib/permissions/experiment_set.py b/src/mavedb/lib/permissions/experiment_set.py new file mode 100644 index 000000000..809d55c54 --- /dev/null +++ b/src/mavedb/lib/permissions/experiment_set.py @@ -0,0 +1,259 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.experiment_set import ExperimentSet + + +def has_permission(user_data: Optional[UserData], entity: ExperimentSet, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on an ExperimentSet entity. + + This function evaluates user permissions based on ownership, contributor status, + and user roles. It handles both private and public ExperimentSets with different + access control rules. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT). + entity: The ExperimentSet entity to check permissions for. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for ExperimentSet entities. + """ + if entity.private is None: + raise ValueError("ExperimentSet entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + user_is_contributor = user_data.user.username in [c.orcid_id for c in entity.contributors] + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "user_is_contributor": user_is_contributor, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for ExperimentSet entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + user_is_owner, + user_is_contributor, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for ExperimentSet entities. + + Public ExperimentSets are readable by anyone. Private ExperimentSets are only readable + by owners, contributors, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity being accessed. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private experiment set. + if not private: + return PermissionResponse(True) + # The owner or contributors may read a private experiment set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may read a private experiment set. + if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): + return PermissionResponse(True) + + return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_update_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for ExperimentSet entities. + + Only owners, contributors, and admins can update ExperimentSets. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity being updated. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner or contributors may update the experiment set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the experiment set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for ExperimentSet entities. + + Admins can delete any ExperimentSet. Owners can only delete unpublished ExperimentSets. + Contributors cannot delete ExperimentSets. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity being deleted. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # Admins may delete any experiment set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete an experiment set only if it has not been published. Contributors may not delete an experiment set. + if user_is_owner: + published = entity.published_date is not None + return PermissionResponse( + not published, + 403, + f"insufficient permissions for URN '{entity.urn}'", + ) + + return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_add_experiment_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_EXPERIMENT action permission check for ExperimentSet entities. + + Only permitted users can add an experiment to a private experiment set. + Any authenticated user can add an experiment to a public experiment set. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity to add an experiment to. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add experiment access under the following conditions: + # Owners or contributors may add an experiment. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may add an experiment to the experiment set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _deny_action_for_experiment_set( + entity: ExperimentSet, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool, +) -> PermissionResponse: + """ + Generate appropriate denial response for ExperimentSet permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to an ExperimentSet based on its privacy and user authentication. + + Args: + entity: The ExperimentSet entity being accessed. + private: Whether the ExperimentSet is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has permission to view private experiment sets. + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + Returns 404 for private entities to avoid information disclosure, + 401 for unauthenticated users, and 403 for insufficient permissions. + """ + # Do not acknowledge the existence of a private experiment set. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"experiment set with URN '{entity.urn}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") + + # The authenticated user lacks sufficient permissions. + return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") diff --git a/src/mavedb/lib/permissions/models.py b/src/mavedb/lib/permissions/models.py new file mode 100644 index 000000000..0145fc085 --- /dev/null +++ b/src/mavedb/lib/permissions/models.py @@ -0,0 +1,25 @@ +import logging +from typing import Optional + +from mavedb.lib.logging.context import logging_context, save_to_logging_context + +logger = logging.getLogger(__name__) + + +class PermissionResponse: + def __init__(self, permitted: bool, http_code: int = 403, message: Optional[str] = None): + self.permitted = permitted + self.http_code = http_code if not permitted else None + self.message = message if not permitted else None + + save_to_logging_context({"permission_message": self.message, "access_permitted": self.permitted}) + if self.permitted: + logger.debug( + msg="Access to the requested resource is permitted.", + extra=logging_context(), + ) + else: + logger.debug( + msg="Access to the requested resource is not permitted.", + extra=logging_context(), + ) diff --git a/src/mavedb/lib/permissions/score_calibration.py b/src/mavedb/lib/permissions/score_calibration.py new file mode 100644 index 000000000..dfedaf0b6 --- /dev/null +++ b/src/mavedb/lib/permissions/score_calibration.py @@ -0,0 +1,312 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.score_calibration import ScoreCalibration + + +def has_permission(user_data: Optional[UserData], entity: ScoreCalibration, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a ScoreCalibration entity. + + This function evaluates user permissions for ScoreCalibration entities, which are + typically administrative objects that require special permissions to modify. + ScoreCalibrations don't have traditional ownership but are tied to ScoreSets. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + action: The action to be performed (READ, UPDATE, DELETE, CREATE). + entity: The ScoreCalibration entity to check permissions for. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + NotImplementedError: If the action is not supported for ScoreCalibration entities. + """ + if entity.private is None: + raise ValueError("ScoreCalibration entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor_to_score_set = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + # Contributor status is determined by matching the user's username (ORCID ID) against the contributors' ORCID IDs, + # as well as by matching the user's ID against the created_by_id and modified_by_id fields of the ScoreSet. + user_is_contributor_to_score_set = ( + user_data.user.username in [c.orcid_id for c in entity.score_set.contributors] + or user_data.user.id == entity.score_set.created_by_id + or user_data.user.id == entity.score_set.modified_by_id + ) + active_roles = user_data.active_roles + + save_to_logging_context( + { + "user_is_owner": user_is_owner, + "user_is_contributor_to_score_set": user_is_contributor_to_score_set, + "score_calibration_id": entity.id, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.CHANGE_RANK: _handle_change_rank_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for ScoreCalibration entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + user_is_owner, + user_is_contributor_to_score_set, + entity.private, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for ScoreCalibration entities. + + ScoreCalibrations are generally readable by anyone who can access the + associated ScoreSet, as they provide important contextual information + about the score data. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a ScoreCalibration if it is not private. + if not private: + return PermissionResponse(True) + # Owners of the ScoreCalibration may read it. + if user_is_owner: + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreSet may read it. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + # System admins may read any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + + +def _handle_update_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for ScoreCalibration entities. + + Updating ScoreCalibrations is typically restricted to administrators + or the original creators, as changes can significantly impact + the interpretation of score data. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user crated the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # System admins may update any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # TODO#549: Allow editing of certain fields if the calibration is published. + # For now, published calibrations cannot be updated. + if entity.private: + # Owners may update their own ScoreCalibration if it is not published. + if user_is_owner: + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreSet may update it if not published. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for ScoreCalibration entities. + + Deleting ScoreCalibrations is a sensitive operation typically reserved + for administrators or the original creators, as it can affect data integrity. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # System admins may delete any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete their own ScoreCalibration if it is private. + if private and user_is_owner: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + + +def _handle_publish_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle PUBLISH action permission check for ScoreCalibration entities. + + Publishing ScoreCalibrations is typically restricted to administrators + or the original creators, as it signifies that the calibration is + finalized and ready for public use. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow publish access under the following conditions: + # System admins may publish any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may publish their own ScoreCalibration. + if user_is_owner: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + + +def _handle_change_rank_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle CHANGE_RANK action permission check for ScoreCalibration entities. + + Changing the rank of ScoreCalibrations is typically restricted to administrators + or the original creators, as it affects the order in which calibrations are applied. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow change rank access under the following conditions: + # System admins may change the rank of any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may change the rank of their own ScoreCalibration. + if user_is_owner: + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreSet may change its rank. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + + +def _deny_action_for_score_calibration( + entity: ScoreCalibration, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool, +) -> PermissionResponse: + """ + Generate appropriate denial response for ScoreCalibration permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to a ScoreCalibration based on user authentication. + + Args: + entity: The ScoreCalibration entity being accessed. + private: Whether the ScoreCalibration is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has permission to view private ScoreCalibrations + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + ScoreCalibrations use the ID for error messages since they don't have URNs. + """ + # Do not acknowledge the existence of private ScoreCalibrations. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"score calibration '{entity.id}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for score calibration '{entity.id}'") + + # The authenticated user lacks sufficient permissions. + return PermissionResponse(False, 403, f"insufficient permissions for score calibration '{entity.id}'") diff --git a/src/mavedb/lib/permissions/score_set.py b/src/mavedb/lib/permissions/score_set.py new file mode 100644 index 000000000..a69b69a5e --- /dev/null +++ b/src/mavedb/lib/permissions/score_set.py @@ -0,0 +1,296 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.score_set import ScoreSet + + +def has_permission(user_data: Optional[UserData], entity: ScoreSet, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a ScoreSet entity. + + This function evaluates user permissions based on ownership, contributor status, + and user roles. It handles both private and public ScoreSets with different + access control rules. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + action: The action to be performed (READ, UPDATE, DELETE, PUBLISH, SET_SCORES). + entity: The ScoreSet entity to check permissions for. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for ScoreSet entities. + """ + if entity.private is None: + raise ValueError("ScoreSet entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + user_is_contributor = user_data.user.username in [c.orcid_id for c in entity.contributors] + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "user_is_contributor": user_is_contributor, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.SET_SCORES: _handle_set_scores_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for ScoreSet entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + user_is_owner, + user_is_contributor, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for ScoreSet entities. + + Public ScoreSets are readable by anyone. Private ScoreSets are only readable + by owners, contributors, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being accessed. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private score set. + if not private: + return PermissionResponse(True) + # The owner or contributors may read a private score set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may read a private score set. + if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): + return PermissionResponse(True) + + return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_update_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for ScoreSet entities. + + Only owners, contributors, and admins can update ScoreSets. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being updated. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner or contributors may update the score set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the score set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for ScoreSet entities. + + Admins can delete any ScoreSet. Owners can only delete unpublished ScoreSets. + Contributors cannot delete ScoreSets. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being deleted. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # Admins may delete any score set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete a score set only if it has not been published. Contributors may not delete a score set. + if user_is_owner: + published = not private + return PermissionResponse( + not published, + 403, + f"insufficient permissions for URN '{entity.urn}'", + ) + + return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_publish_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle PUBLISH action permission check for ScoreSet entities. + + Owners, contributors, and admins can publish private ScoreSets to make them + publicly accessible. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being published. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow publish access under the following conditions: + # The owner may publish the score set. + if user_is_owner: + return PermissionResponse(True) + # Users with these specific roles may publish the score set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _handle_set_scores_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle SET_SCORES action permission check for ScoreSet entities. + + Only owners, contributors, and admins can modify the scores data within + a ScoreSet. This is a critical operation that affects the scientific data. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity whose scores are being modified. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow set scores access under the following conditions: + # The owner or contributors may set scores. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may set scores. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + + +def _deny_action_for_score_set( + entity: ScoreSet, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool, +) -> PermissionResponse: + """ + Generate appropriate denial response for ScoreSet permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to a ScoreSet based on its privacy and user authentication. + + Args: + entity: The ScoreSet entity being accessed. + private: Whether the ScoreSet is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has permission to view private ScoreSets. + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + Returns 404 for private entities to avoid information disclosure, + 401 for unauthenticated users, and 403 for insufficient permissions. + """ + # Do not acknowledge the existence of a private score set. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"score set with URN '{entity.urn}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") + + # The authenticated user lacks sufficient permissions. + return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") diff --git a/src/mavedb/lib/permissions/user.py b/src/mavedb/lib/permissions/user.py new file mode 100644 index 000000000..920f998de --- /dev/null +++ b/src/mavedb/lib/permissions/user.py @@ -0,0 +1,220 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.user import User + + +def has_permission(user_data: Optional[UserData], entity: User, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a User entity. + + This function evaluates user permissions based on user identity and roles. + User entities have different access patterns since they don't have public/private + states or ownership in the traditional sense. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + action: The action to be performed (READ, UPDATE, DELETE). + entity: The User entity to check permissions for. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + NotImplementedError: If the action is not supported for User entities. + + Note: + User entities do not have private/public states or traditional ownership models. + Permissions are based on user identity and administrative roles. + """ + user_is_self = False + active_roles = [] + + if user_data is not None: + user_is_self = entity.id == user_data.user.id + active_roles = user_data.active_roles + + save_to_logging_context( + { + "user_is_self": user_is_self, + "target_user_id": entity.id, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.LOOKUP: _handle_lookup_action, + Action.ADD_ROLE: _handle_add_role_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for User entities. " f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + user_is_self, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for User entities. + + Users can read their own profile. Admins can read any user profile. + READ access to profiles refers to admin level properties. Basic user info + is handled by the LOOKUP action. + + Args: + user_data: The user's authentication data. + entity: The User entity being accessed. + user_is_self: Whether the user is viewing their own profile. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + + Note: + Basic user information (username, display name) is typically public, + but sensitive information requires appropriate permissions. + """ + ## Allow read access under the following conditions: + # Users can always read their own profile. + if user_is_self: + return PermissionResponse(True) + # Admins can read any user profile. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_user(entity, user_data) + + +def _handle_lookup_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle LOOKUP action permission check for User entities. + + Any authenticated user can look up basic information about other users. + Anonymous users cannot perform LOOKUP actions. + + Args: + user_data: The user's authentication data. + entity: The User entity being looked up. + user_is_self: Whether the user is looking up their own profile. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow lookup access under the following conditions: + # Any authenticated user can look up basic user information. + if user_data is not None and user_data.user is not None: + return PermissionResponse(True) + + return _deny_action_for_user(entity, user_data) + + +def _handle_update_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for User entities. + + Users can update their own profile. Admins can update any user profile. + + Args: + user_data: The user's authentication data. + entity: The User entity being updated. + user_is_self: Whether the user is updating their own profile. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # Users can update their own profile. + if user_is_self: + return PermissionResponse(True) + # Admins can update any user profile. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_user(entity, user_data) + + +def _handle_add_role_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_ROLE action permission check for User entities. + + Only admins can add roles to users. + + Args: + user_data: The user's authentication data. + entity: The User entity being modified. + user_is_self: Whether the user is modifying their own profile. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add role access under the following conditions: + # Only admins can add roles to users. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return _deny_action_for_user(entity, user_data) + + +def _deny_action_for_user( + entity: User, + user_data: Optional[UserData], +) -> PermissionResponse: + """ + Generate appropriate denial response for User permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to a User entity based on authentication status. + + Args: + entity: The User entity being accessed. + user_data: The user's authentication data (None for anonymous). + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + For User entities, we don't use 404 responses as user existence + is typically not considered sensitive information in this context. + """ + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse(False, 401, f"insufficient permissions for user '{entity.username}'") + + # The authenticated user lacks sufficient permissions. + return PermissionResponse(False, 403, f"insufficient permissions for user '{entity.username}'") diff --git a/src/mavedb/lib/permissions/utils.py b/src/mavedb/lib/permissions/utils.py new file mode 100644 index 000000000..848c6fd52 --- /dev/null +++ b/src/mavedb/lib/permissions/utils.py @@ -0,0 +1,52 @@ +import logging +from typing import Union, overload + +from mavedb.lib.logging.context import logging_context, save_to_logging_context +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + +logger = logging.getLogger(__name__) + + +@overload +def roles_permitted( + user_roles: list[UserRole], + permitted_roles: list[UserRole], +) -> bool: ... + + +@overload +def roles_permitted( + user_roles: list[ContributionRole], + permitted_roles: list[ContributionRole], +) -> bool: ... + + +def roles_permitted( + user_roles: Union[list[UserRole], list[ContributionRole]], + permitted_roles: Union[list[UserRole], list[ContributionRole]], +) -> bool: + save_to_logging_context({"permitted_roles": [role.name for role in permitted_roles]}) + + if not user_roles: + logger.debug(msg="User has no associated roles.", extra=logging_context()) + return False + + # Validate that both lists contain the same enum type + if user_roles and permitted_roles: + user_role_types = {type(role) for role in user_roles} + permitted_role_types = {type(role) for role in permitted_roles} + + # Check if either list has mixed types + if len(user_role_types) > 1: + raise ValueError("user_roles list cannot contain mixed role types (UserRole and ContributionRole)") + if len(permitted_role_types) > 1: + raise ValueError("permitted_roles list cannot contain mixed role types (UserRole and ContributionRole)") + + # Check if the lists have different role types + if user_role_types != permitted_role_types: + raise ValueError( + "user_roles and permitted_roles must contain the same role type (both UserRole or both ContributionRole)" + ) + + return any(role in permitted_roles for role in user_roles) diff --git a/src/mavedb/server_main.py b/src/mavedb/server_main.py index 80db54039..23717e431 100644 --- a/src/mavedb/server_main.py +++ b/src/mavedb/server_main.py @@ -31,11 +31,12 @@ logging_context, save_to_logging_context, ) -from mavedb.lib.permissions import PermissionException +from mavedb.lib.permissions.exceptions import PermissionException from mavedb.lib.slack import send_slack_error from mavedb.models import * # noqa: F403 from mavedb.routers import ( access_keys, + alphafold, api_information, collections, controlled_keywords, @@ -59,7 +60,6 @@ taxonomies, users, variants, - alphafold, ) logger = logging.getLogger(__name__) diff --git a/tests/lib/permissions/__init__.py b/tests/lib/permissions/__init__.py new file mode 100644 index 000000000..78b319a5b --- /dev/null +++ b/tests/lib/permissions/__init__.py @@ -0,0 +1 @@ +"""Tests for the modular permissions system.""" diff --git a/tests/lib/permissions/conftest.py b/tests/lib/permissions/conftest.py new file mode 100644 index 000000000..b01c228c0 --- /dev/null +++ b/tests/lib/permissions/conftest.py @@ -0,0 +1,194 @@ +"""Shared fixtures and helpers for permissions tests.""" + +from dataclasses import dataclass +from typing import Optional, Union +from unittest.mock import Mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + + +@dataclass +class PermissionTest: + """Represents a single permission test case for action handler testing. + + Used for parametrized testing of individual action handlers (_handle_read_action, etc.) + rather than comprehensive end-to-end permission testing. + + Args: + entity_type: Entity type name for context (not used in handler tests) + entity_state: "private" or "published" (None for stateless entities like User) + user_type: "admin", "owner", "contributor", "other_user", "anonymous", "self" + action: Action enum value (for documentation, handlers test specific actions) + should_be_permitted: True/False for normal cases, "NotImplementedError" for unsupported + expected_code: HTTP error code when denied (403, 404, 401, etc.) + description: Human-readable test description + collection_role: For Collection tests: "collection_admin", "collection_editor", "collection_viewer" + investigator_provided: For ScoreCalibration tests: True=investigator, False=community + """ + + entity_type: str + entity_state: Optional[str] + user_type: str + action: Action + should_be_permitted: Union[bool, str] + expected_code: Optional[int] = None + description: Optional[str] = None + collection_role: Optional[str] = None + collection_badge: Optional[str] = None + investigator_provided: Optional[bool] = None + + +class EntityTestHelper: + """Helper class to create test entities and user data with consistent properties.""" + + @staticmethod + def create_user_data(user_type: str): + """Create UserData mock for different user types. + + Args: + user_type: "admin", "owner", "contributor", "other_user", "anonymous", "self", "mapper" + + Returns: + Mock UserData object or None for anonymous users + """ + user_configs = { + "admin": (1, "1111-1111-1111-111X", [UserRole.admin]), + "owner": (2, "2222-2222-2222-222X", []), + "contributor": (3, "3333-3333-3333-333X", []), + "other_user": (4, "4444-4444-4444-444X", []), + "self": (5, "5555-5555-5555-555X", []), + "mapper": (6, "6666-6666-6666-666X", [UserRole.mapper]), + } + + if user_type == "anonymous": + return None + + if user_type not in user_configs: + raise ValueError(f"Unknown user type: {user_type}") + + user_id, username, roles = user_configs[user_type] + return Mock(user=Mock(id=user_id, username=username), active_roles=roles) + + @staticmethod + def create_score_set(entity_state: str = "private", owner_id: int = 2): + """Create a ScoreSet mock for testing.""" + private = entity_state == "private" + published_date = None if private else "2023-01-01" + contributors = [Mock(orcid_id="3333-3333-3333-333X")] + + return Mock( + id=1, + urn="urn:mavedb:00000001-a-1", + private=private, + created_by_id=owner_id, + published_date=published_date, + contributors=contributors, + ) + + @staticmethod + def create_experiment(entity_state: str = "private", owner_id: int = 2): + """Create an Experiment mock for testing.""" + private = entity_state == "private" + published_date = None if private else "2023-01-01" + contributors = [Mock(orcid_id="3333-3333-3333-333X")] + + return Mock( + id=1, + urn="urn:mavedb:00000001-a", + private=private, + created_by_id=owner_id, + published_date=published_date, + contributors=contributors, + ) + + @staticmethod + def create_experiment_set(entity_state: str = "private", owner_id: int = 2): + """Create an ExperimentSet mock for testing.""" + private = entity_state == "private" + published_date = None if private else "2023-01-01" + contributors = [Mock(orcid_id="3333-3333-3333-333X")] + + return Mock( + id=1, + urn="urn:mavedb:00000001", + private=private, + created_by_id=owner_id, + published_date=published_date, + contributors=contributors, + ) + + @staticmethod + def create_collection( + entity_state: str = "private", + owner_id: int = 2, + collection_role: Optional[str] = None, + badge_name: Optional[str] = None, + ): + """Create a Collection mock for testing. + + Args: + entity_state: "private" or "published" + owner_id: ID of the collection owner + collection_role: "collection_admin", "collection_editor", or "collection_viewer" + to create user association for contributor user (ID=3) + """ + private = entity_state == "private" + published_date = None if private else "2023-01-01" + + user_associations = [] + if collection_role: + role_map = { + "collection_admin": ContributionRole.admin, + "collection_editor": ContributionRole.editor, + "collection_viewer": ContributionRole.viewer, + } + user_associations.append(Mock(user_id=3, contribution_role=role_map[collection_role])) + + return Mock( + id=1, + urn="urn:mavedb:collection-001", + private=private, + created_by_id=owner_id, + published_date=published_date, + user_associations=user_associations, + badge_name=badge_name, + ) + + @staticmethod + def create_user(user_id: int = 5): + """Create a User mock for testing.""" + return Mock( + id=user_id, + username=f"{user_id}{user_id}{user_id}{user_id}-{user_id}{user_id}{user_id}{user_id}-{user_id}{user_id}{user_id}{user_id}-{user_id}{user_id}{user_id}X", + ) + + @staticmethod + def create_score_calibration(entity_state: str = "private", investigator_provided: bool = False): + """Create a ScoreCalibration mock for testing. + + Args: + entity_state: "private" or "published" (affects score_set and private property) + investigator_provided: True if investigator-provided, False if community-provided + """ + private = entity_state == "private" + score_set = EntityTestHelper.create_score_set(entity_state) + + # ScoreCalibrations have their own private property plus associated ScoreSet + return Mock( + id=1, + private=private, + score_set=score_set, + investigator_provided=investigator_provided, + created_by_id=2, # owner + modified_by_id=2, # owner + ) + + +@pytest.fixture +def entity_helper(): + """Fixture providing EntityTestHelper instance.""" + return EntityTestHelper() diff --git a/tests/lib/permissions/test_collection.py b/tests/lib/permissions/test_collection.py new file mode 100644 index 000000000..7c68f3a07 --- /dev/null +++ b/tests/lib/permissions/test_collection.py @@ -0,0 +1,769 @@ +"""Tests for Collection permissions module.""" + +from typing import Callable, List +from unittest import mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.collection import ( + _deny_action_for_collection, + _handle_add_badge_action, + _handle_add_experiment_action, + _handle_add_role_action, + _handle_add_score_set_action, + _handle_delete_action, + _handle_publish_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +COLLECTION_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, + Action.ADD_ROLE: _handle_add_role_action, + Action.ADD_BADGE: _handle_add_badge_action, +} + +COLLECTION_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.LOOKUP, + Action.CHANGE_RANK, + Action.SET_SCORES, +] + +COLLECTION_ROLE_MAP = { + "collection_admin": ContributionRole.admin, + "collection_editor": ContributionRole.editor, + "collection_viewer": ContributionRole.viewer, +} + + +def test_collection_handles_all_actions() -> None: + """Test that all Collection actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(COLLECTION_SUPPORTED_ACTIONS) + unsupported = set(COLLECTION_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for collections." + + +class TestCollectionHasPermission: + """Test the main has_permission dispatcher function for Collection entities.""" + + @pytest.mark.parametrize("action, handler", COLLECTION_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + collection = entity_helper.create_collection() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.collection." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, collection, action) + mock_handler.assert_called_once_with( + admin_user, + collection, + collection.private, + collection.badge_name is not None, + False, # admin is not the owner + [], # admin has no collection roles + [UserRole.admin], + ) + + def test_has_permission_calls_helper_with_collection_roles_when_present(self, entity_helper: EntityTestHelper): + """Test that has_permission passes collection roles to action handlers.""" + collection = entity_helper.create_collection(collection_role="collection_editor") + contributor_user = entity_helper.create_user_data("contributor") + + with mock.patch( + "mavedb.lib.permissions.collection._handle_read_action", wraps=_handle_read_action + ) as mock_handler: + has_permission(contributor_user, collection, Action.READ) + mock_handler.assert_called_once_with( + contributor_user, + collection, + collection.private, + collection.badge_name is not None, + False, # contributor is not the owner + [ContributionRole.editor], # collection role + [], # user has no active roles + ) + + @pytest.mark.parametrize("action", COLLECTION_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + collection = entity_helper.create_collection() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, collection, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in COLLECTION_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if Collection.private is None.""" + collection = entity_helper.create_collection() + collection.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, collection, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestCollectionReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can read any Collection + PermissionTest("Collection", "published", "admin", Action.READ, True), + PermissionTest("Collection", "private", "admin", Action.READ, True), + # Owners can read any Collection they own + PermissionTest("Collection", "published", "owner", Action.READ, True), + PermissionTest("Collection", "private", "owner", Action.READ, True), + # Collection admins can read any Collection they have admin role for + PermissionTest( + "Collection", "published", "contributor", Action.READ, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "private", "contributor", Action.READ, True, collection_role="collection_admin" + ), + # Collection editors can read any Collection they have editor role for + PermissionTest( + "Collection", "published", "contributor", Action.READ, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", "private", "contributor", Action.READ, True, collection_role="collection_editor" + ), + # Collection viewers can read any Collection they have viewer role for + PermissionTest( + "Collection", "published", "contributor", Action.READ, True, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", "private", "contributor", Action.READ, True, collection_role="collection_viewer" + ), + # Other users can only read published Collections + PermissionTest("Collection", "published", "other_user", Action.READ, True), + PermissionTest("Collection", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published Collections + PermissionTest("Collection", "published", "anonymous", Action.READ, True), + PermissionTest("Collection", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can update any Collection + PermissionTest("Collection", "private", "admin", Action.UPDATE, True), + PermissionTest("Collection", "published", "admin", Action.UPDATE, True), + # Owners can update any Collection they own + PermissionTest("Collection", "private", "owner", Action.UPDATE, True), + PermissionTest("Collection", "published", "owner", Action.UPDATE, True), + # Collection admins can update any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.UPDATE, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.UPDATE, True, collection_role="collection_admin" + ), + # Collection editors can update any Collection they have editor role for + PermissionTest( + "Collection", "private", "contributor", Action.UPDATE, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", "published", "contributor", Action.UPDATE, True, collection_role="collection_editor" + ), + # Collection viewers cannot update Collections + PermissionTest( + "Collection", "private", "contributor", Action.UPDATE, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", "published", "contributor", Action.UPDATE, False, 403, collection_role="collection_viewer" + ), + # Other users cannot update Collections + PermissionTest("Collection", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update Collections + PermissionTest("Collection", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can delete any Collection + PermissionTest("Collection", "private", "admin", Action.DELETE, True), + PermissionTest("Collection", "published", "admin", Action.DELETE, True), + PermissionTest("Collection", "private", "admin", Action.DELETE, True, collection_badge="official"), + PermissionTest("Collection", "published", "admin", Action.DELETE, True, collection_badge="official"), + # Owners can only delete unpublished, unofficial Collections + PermissionTest("Collection", "private", "owner", Action.DELETE, True), + PermissionTest("Collection", "published", "owner", Action.DELETE, False, 403), + PermissionTest("Collection", "private", "owner", Action.DELETE, False, 403, collection_badge="official"), + PermissionTest("Collection", "published", "owner", Action.DELETE, False, 403, collection_badge="official"), + # Collection admins cannot delete Collections + PermissionTest( + "Collection", "private", "contributor", Action.DELETE, False, 403, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.DELETE, False, 403, collection_role="collection_admin" + ), + # Collection editors cannot delete Collections + PermissionTest( + "Collection", "private", "contributor", Action.DELETE, False, 403, collection_role="collection_editor" + ), + PermissionTest( + "Collection", "published", "contributor", Action.DELETE, False, 403, collection_role="collection_editor" + ), + # Collection viewers cannot delete Collections + PermissionTest( + "Collection", "private", "contributor", Action.DELETE, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", "published", "contributor", Action.DELETE, False, 403, collection_role="collection_viewer" + ), + # Other users cannot delete Collections + PermissionTest("Collection", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete Collections + PermissionTest("Collection", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.DELETE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection( + test_case.entity_state, collection_role=test_case.collection_role, badge_name=test_case.collection_badge + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionPublishActionHandler: + """Test the _handle_publish_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can publish any Collection + PermissionTest("Collection", "private", "admin", Action.PUBLISH, True), + PermissionTest("Collection", "published", "admin", Action.PUBLISH, True), + # Owners can publish any Collection they own + PermissionTest("Collection", "private", "owner", Action.PUBLISH, True), + PermissionTest("Collection", "published", "owner", Action.PUBLISH, True), + # Collection admins can publish any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.PUBLISH, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.PUBLISH, True, collection_role="collection_admin" + ), + # Collection editors cannot publish Collections + PermissionTest( + "Collection", "private", "contributor", Action.PUBLISH, False, 403, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.PUBLISH, + False, + 403, + collection_role="collection_editor", + ), + # Collection viewers cannot publish Collections + PermissionTest( + "Collection", "private", "contributor", Action.PUBLISH, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.PUBLISH, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot publish Collections + PermissionTest("Collection", "private", "other_user", Action.PUBLISH, False, 404), + PermissionTest("Collection", "published", "other_user", Action.PUBLISH, False, 403), + # Anonymous users cannot publish Collections + PermissionTest("Collection", "private", "anonymous", Action.PUBLISH, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.PUBLISH, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_publish_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_publish_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddExperimentActionHandler: + """Test the _handle_add_experiment_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add experiments to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_EXPERIMENT, True), + PermissionTest("Collection", "published", "admin", Action.ADD_EXPERIMENT, True), + # Owners can add experiments to any Collection they own + PermissionTest("Collection", "private", "owner", Action.ADD_EXPERIMENT, True), + PermissionTest("Collection", "published", "owner", Action.ADD_EXPERIMENT, True), + # Collection admins can add experiments to any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_EXPERIMENT, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + collection_role="collection_admin", + ), + # Collection editors can add experiments to any Collection they have editor role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_EXPERIMENT, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + collection_role="collection_editor", + ), + # Collection viewers cannot add experiments to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_EXPERIMENT, + False, + 403, + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add experiments to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_EXPERIMENT, False, 403), + # Anonymous users cannot add experiments to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_EXPERIMENT, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_experiment_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_experiment_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_experiment_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddScoreSetActionHandler: + """Test the _handle_add_score_set_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add score sets to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_SCORE_SET, True), + PermissionTest("Collection", "published", "admin", Action.ADD_SCORE_SET, True), + # Owners can add score sets to any Collection they own + PermissionTest("Collection", "private", "owner", Action.ADD_SCORE_SET, True), + PermissionTest("Collection", "published", "owner", Action.ADD_SCORE_SET, True), + # Collection admins can add score sets to any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_SCORE_SET, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.ADD_SCORE_SET, True, collection_role="collection_admin" + ), + # Collection editors can add score sets to any Collection they have editor role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_SCORE_SET, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + True, + collection_role="collection_editor", + ), + # Collection viewers cannot add score sets to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_SCORE_SET, + False, + 403, + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add score sets to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_SCORE_SET, False, 403), + # Anonymous users cannot add score sets to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_SCORE_SET, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_score_set_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_score_set_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_score_set_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddRoleActionHandler: + """Test the _handle_add_role_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add roles to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_ROLE, True), + PermissionTest("Collection", "published", "admin", Action.ADD_ROLE, True), + # Owners can add roles to any Collection they own + PermissionTest("Collection", "private", "owner", Action.ADD_ROLE, True), + PermissionTest("Collection", "published", "owner", Action.ADD_ROLE, True), + # Collection admins can add roles to any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_ROLE, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.ADD_ROLE, True, collection_role="collection_admin" + ), + # Collection editors cannot add roles to Collections + PermissionTest( + "Collection", "private", "contributor", Action.ADD_ROLE, False, 403, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + False, + 403, + collection_role="collection_editor", + ), + # Collection viewers cannot add roles to Collections + PermissionTest( + "Collection", "private", "contributor", Action.ADD_ROLE, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add roles to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_ROLE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_ROLE, False, 403), + # Anonymous users cannot add roles to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_ROLE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_ROLE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_role_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_role_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_role_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddBadgeActionHandler: + """Test the _handle_add_badge_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add badges to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_BADGE, True), + PermissionTest("Collection", "published", "admin", Action.ADD_BADGE, True), + # Owners cannot add badges to Collections (admin-only operation) + PermissionTest("Collection", "private", "owner", Action.ADD_BADGE, False, 403), + PermissionTest("Collection", "published", "owner", Action.ADD_BADGE, False, 403), + # Collection admins cannot add badges to Collections (system admin-only) + PermissionTest( + "Collection", "private", "contributor", Action.ADD_BADGE, False, 403, collection_role="collection_admin" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_admin", + ), + # Collection editors cannot add badges to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_editor", + ), + # Collection viewers cannot add badges to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add badges to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_BADGE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_BADGE, False, 403), + # Anonymous users cannot add badges to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_BADGE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_BADGE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_badge_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_badge_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_badge_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionDenyActionHandler: + """Test collection deny action handler.""" + + def test_deny_action_for_private_collection(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_collection helper function for private Collection.""" + collection = entity_helper.create_collection("private") + + # Private entity should return 404 + result = _deny_action_for_collection(collection, True, entity_helper.create_user_data("other_user"), False) + assert result.permitted is False + assert result.http_code == 404 + + def test_deny_action_for_public_collection_anonymous_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_collection helper function for public Collection with anonymous user.""" + collection = entity_helper.create_collection("published") + + # Public entity, anonymous user should return 401 + result = _deny_action_for_collection(collection, False, None, False) + assert result.permitted is False + assert result.http_code == 401 + + def test_deny_action_for_public_collection_authenticated_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_collection helper function for public Collection with authenticated user.""" + collection = entity_helper.create_collection("published") + + # Public entity, authenticated user should return 403 + result = _deny_action_for_collection(collection, False, entity_helper.create_user_data("other_user"), False) + assert result.permitted is False + assert result.http_code == 403 + + def test_deny_action_for_private_collection_with_collection_role(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_collection helper function for private Collection when user has collection role.""" + collection = entity_helper.create_collection("private") + + # Private entity, user with collection role should return 403 (still private, but user knows it exists) + result = _deny_action_for_collection(collection, True, entity_helper.create_user_data("other_user"), True) + assert result.permitted is False + assert result.http_code == 403 diff --git a/tests/lib/permissions/test_core.py b/tests/lib/permissions/test_core.py new file mode 100644 index 000000000..69074d346 --- /dev/null +++ b/tests/lib/permissions/test_core.py @@ -0,0 +1,128 @@ +"""Tests for core permissions functionality.""" + +from unittest.mock import Mock, patch + +import pytest + +from mavedb.lib.permissions import ( + assert_permission, + collection, + experiment, + experiment_set, + score_calibration, + score_set, + user, +) +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.core import has_permission as core_has_permission +from mavedb.lib.permissions.exceptions import PermissionException +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.models.collection import Collection +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + +SUPPORTED_ENTITY_TYPES = { + ScoreSet: score_set.has_permission, + Experiment: experiment.has_permission, + ExperimentSet: experiment_set.has_permission, + Collection: collection.has_permission, + User: user.has_permission, + ScoreCalibration: score_calibration.has_permission, +} + + +class TestCoreDispatcher: + """Test the core permission dispatcher functionality.""" + + @pytest.mark.parametrize("entity, handler", SUPPORTED_ENTITY_TYPES.items()) + def test_dispatcher_routes_to_correct_entity_handler(self, entity_helper, entity, handler): + """Test that the dispatcher routes requests to the correct entity-specific handler.""" + admin_user = entity_helper.create_user_data("admin") + + with ( + patch("mavedb.lib.permissions.core.type", return_value=entity), + patch( + f"mavedb.lib.permissions.core.{handler.__module__.split('.')[-1]}.{handler.__name__}", + return_value=PermissionResponse(True), + ) as mocked_handler, + ): + core_has_permission(admin_user, entity, Action.READ) + mocked_handler.assert_called_once_with(admin_user, entity, Action.READ) + + def test_dispatcher_raises_for_unsupported_entity_type(self, entity_helper): + """Test that unsupported entity types raise NotImplementedError.""" + admin_user = entity_helper.create_user_data("admin") + unsupported_entity = Mock() # Some random object + + with pytest.raises(NotImplementedError) as exc_info: + core_has_permission(admin_user, unsupported_entity, Action.READ) + + error_msg = str(exc_info.value) + assert "not implemented" in error_msg.lower() + assert "Mock" in error_msg # Should mention the actual type + assert "Supported entity types" in error_msg + + +class TestAssertPermission: + """Test the assert_permission function.""" + + def test_assert_permission_returns_result_when_permitted(self, entity_helper): + """Test that assert_permission returns the PermissionResponse when access is granted.""" + + with patch("mavedb.lib.permissions.core.has_permission", return_value=PermissionResponse(True)): + user_data = entity_helper.create_user_data("admin") + score_set = entity_helper.create_score_set("published") + + result = assert_permission(user_data, score_set, Action.READ) + + assert isinstance(result, PermissionResponse) + assert result.permitted is True + + def test_assert_permission_raises_when_denied(self, entity_helper): + """Test that assert_permission raises PermissionException when access is denied.""" + + with ( + patch( + "mavedb.lib.permissions.core.has_permission", + return_value=PermissionResponse(False, http_code=404, message="Not found"), + ), + pytest.raises(PermissionException) as exc_info, + ): + user_data = entity_helper.create_user_data("admin") + score_set = entity_helper.create_score_set("published") + + assert_permission(user_data, score_set, Action.READ) + + exception = exc_info.value + assert hasattr(exception, "http_code") + assert hasattr(exception, "message") + assert exception.http_code == 404 + assert "not found" in exception.message.lower() + + @pytest.mark.parametrize( + "http_code,message", + [ + (403, "Forbidden"), + (401, "Unauthorized"), + (404, "Not Found"), + ], + ) + def test_assert_permission_preserves_error_details(self, entity_helper, http_code, message): + """Test that assert_permission preserves HTTP codes and messages from permission check.""" + + with ( + patch( + "mavedb.lib.permissions.core.has_permission", + return_value=PermissionResponse(False, http_code=http_code, message=message), + ), + pytest.raises(PermissionException) as exc_info, + ): + user_data = entity_helper.create_user_data("admin") + score_set = entity_helper.create_score_set("published") + + assert_permission(user_data, score_set, Action.READ) + + assert exc_info.value.http_code == http_code, f"Expected {http_code} for {http_code} on {message} entity" diff --git a/tests/lib/permissions/test_experiment.py b/tests/lib/permissions/test_experiment.py new file mode 100644 index 000000000..252875cfa --- /dev/null +++ b/tests/lib/permissions/test_experiment.py @@ -0,0 +1,317 @@ +"""Tests for Experiment permissions module.""" + +from typing import Callable, List +from unittest import mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.experiment import ( + _deny_action_for_experiment, + _handle_add_score_set_action, + _handle_delete_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +EXPERIMENT_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, +} + +EXPERIMENT_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_EXPERIMENT, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.CHANGE_RANK, + Action.SET_SCORES, + Action.PUBLISH, +] + + +def test_experiment_handles_all_actions() -> None: + """Test that all Experiment actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(EXPERIMENT_SUPPORTED_ACTIONS) + unsupported = set(EXPERIMENT_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for experiments." + + +class TestExperimentHasPermission: + """Test the main has_permission dispatcher function for Experiment entities.""" + + @pytest.mark.parametrize("action, handler", EXPERIMENT_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + experiment = entity_helper.create_experiment() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.experiment." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, experiment, action) + mock_handler.assert_called_once_with( + admin_user, + experiment, + experiment.private, + False, # admin is not the owner + False, # admin is not a contributor + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", EXPERIMENT_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + experiment = entity_helper.create_experiment() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, experiment, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in EXPERIMENT_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if Experiment.private is None.""" + experiment = entity_helper.create_experiment() + experiment.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, experiment, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestExperimentReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any Experiment + PermissionTest("Experiment", "published", "admin", Action.READ, True), + PermissionTest("Experiment", "private", "admin", Action.READ, True), + # Owners can read any Experiment they own + PermissionTest("Experiment", "published", "owner", Action.READ, True), + PermissionTest("Experiment", "private", "owner", Action.READ, True), + # Contributors can read any Experiment they contribute to + PermissionTest("Experiment", "published", "contributor", Action.READ, True), + PermissionTest("Experiment", "private", "contributor", Action.READ, True), + # Mappers can read any Experiment (including private) + PermissionTest("Experiment", "published", "mapper", Action.READ, True), + PermissionTest("Experiment", "private", "mapper", Action.READ, True), + # Other users can only read published Experiments + PermissionTest("Experiment", "published", "other_user", Action.READ, True), + PermissionTest("Experiment", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published Experiments + PermissionTest("Experiment", "published", "anonymous", Action.READ, True), + PermissionTest("Experiment", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action(user_data, experiment, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any Experiment + PermissionTest("Experiment", "private", "admin", Action.UPDATE, True), + PermissionTest("Experiment", "published", "admin", Action.UPDATE, True), + # Owners can update any Experiment they own + PermissionTest("Experiment", "private", "owner", Action.UPDATE, True), + PermissionTest("Experiment", "published", "owner", Action.UPDATE, True), + # Contributors can update any Experiment they contribute to + PermissionTest("Experiment", "private", "contributor", Action.UPDATE, True), + PermissionTest("Experiment", "published", "contributor", Action.UPDATE, True), + # Mappers cannot update Experiments + PermissionTest("Experiment", "private", "mapper", Action.UPDATE, False, 404), + PermissionTest("Experiment", "published", "mapper", Action.UPDATE, False, 403), + # Other users cannot update Experiments + PermissionTest("Experiment", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("Experiment", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update Experiments + PermissionTest("Experiment", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("Experiment", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action(user_data, experiment, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can delete any Experiment + PermissionTest("Experiment", "private", "admin", Action.DELETE, True), + PermissionTest("Experiment", "published", "admin", Action.DELETE, True), + # Owners can only delete unpublished Experiments + PermissionTest("Experiment", "private", "owner", Action.DELETE, True), + PermissionTest("Experiment", "published", "owner", Action.DELETE, False, 403), + # Contributors cannot delete + PermissionTest("Experiment", "private", "contributor", Action.DELETE, False, 403), + PermissionTest("Experiment", "published", "contributor", Action.DELETE, False, 403), + # Other users cannot delete + PermissionTest("Experiment", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("Experiment", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete + PermissionTest("Experiment", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("Experiment", "published", "anonymous", Action.DELETE, False, 401), + # Mappers cannot delete + PermissionTest("Experiment", "private", "mapper", Action.DELETE, False, 404), + PermissionTest("Experiment", "published", "mapper", Action.DELETE, False, 403), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action(user_data, experiment, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentAddScoreSetActionHandler: + """Test the _handle_add_score_set_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can add score sets to any Experiment + PermissionTest("Experiment", "private", "admin", Action.ADD_SCORE_SET, True), + PermissionTest("Experiment", "published", "admin", Action.ADD_SCORE_SET, True), + # Owners can add score sets to any Experiment they own + PermissionTest("Experiment", "private", "owner", Action.ADD_SCORE_SET, True), + PermissionTest("Experiment", "published", "owner", Action.ADD_SCORE_SET, True), + # Contributors can add score sets to any Experiment they contribute to + PermissionTest("Experiment", "private", "contributor", Action.ADD_SCORE_SET, True), + PermissionTest("Experiment", "published", "contributor", Action.ADD_SCORE_SET, True), + # Mappers cannot add score sets to Experiments + PermissionTest("Experiment", "private", "mapper", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Experiment", "published", "mapper", Action.ADD_SCORE_SET, False, 403), + # Other users cannot add score sets to Experiments + PermissionTest("Experiment", "private", "other_user", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Experiment", "published", "other_user", Action.ADD_SCORE_SET, False, 403), + # Anonymous users cannot add score sets to Experiments + PermissionTest("Experiment", "private", "anonymous", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Experiment", "published", "anonymous", Action.ADD_SCORE_SET, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_score_set_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_score_set_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_score_set_action( + user_data, experiment, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentDenyActionHandler: + """Test experiment deny action handler.""" + + def test_deny_action_for_private_experiment(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment helper function for private Experiment.""" + experiment = entity_helper.create_experiment("private") + + # Private entity should return 404 + result = _deny_action_for_experiment(experiment, True, entity_helper.create_user_data("other_user"), False) + assert result.permitted is False + assert result.http_code == 404 + + def test_deny_action_for_public_experiment_anonymous_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment helper function for public Experiment with anonymous user.""" + experiment = entity_helper.create_experiment("published") + + # Public entity, anonymous user should return 401 + result = _deny_action_for_experiment(experiment, False, None, False) + assert result.permitted is False + assert result.http_code == 401 + + def test_deny_action_for_public_experiment_authenticated_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment helper function for public Experiment with authenticated user.""" + experiment = entity_helper.create_experiment("published") + + # Public entity, authenticated user should return 403 + result = _deny_action_for_experiment(experiment, False, entity_helper.create_user_data("other_user"), False) + assert result.permitted is False + assert result.http_code == 403 + + def test_deny_action_for_private_experiment_with_view_permission(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment helper function for private Experiment when user can view private.""" + experiment = entity_helper.create_experiment("private") + + # Private entity, user can view but lacks other permissions should return 403 + result = _deny_action_for_experiment(experiment, True, entity_helper.create_user_data("other_user"), True) + assert result.permitted is False + assert result.http_code == 403 diff --git a/tests/lib/permissions/test_experiment_set.py b/tests/lib/permissions/test_experiment_set.py new file mode 100644 index 000000000..cf40db77e --- /dev/null +++ b/tests/lib/permissions/test_experiment_set.py @@ -0,0 +1,329 @@ +"""Tests for ExperimentSet permissions module.""" + +from typing import Callable, List +from unittest import mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.experiment_set import ( + _deny_action_for_experiment_set, + _handle_add_experiment_action, + _handle_delete_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +EXPERIMENT_SET_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, +} + +EXPERIMENT_SET_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_SCORE_SET, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.CHANGE_RANK, + Action.SET_SCORES, + Action.PUBLISH, +] + + +def test_experiment_set_handles_all_actions() -> None: + """Test that all ExperimentSet actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(EXPERIMENT_SET_SUPPORTED_ACTIONS) + unsupported = set(EXPERIMENT_SET_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for experiment sets." + + +class TestExperimentSetHasPermission: + """Test the main has_permission dispatcher function for ExperimentSet entities.""" + + @pytest.mark.parametrize("action, handler", EXPERIMENT_SET_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + experiment_set = entity_helper.create_experiment_set() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.experiment_set." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, experiment_set, action) + mock_handler.assert_called_once_with( + admin_user, + experiment_set, + experiment_set.private, + False, # admin is not the owner + False, # admin is not a contributor + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", EXPERIMENT_SET_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + experiment_set = entity_helper.create_experiment_set() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, experiment_set, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in EXPERIMENT_SET_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if ExperimentSet.private is None.""" + experiment_set = entity_helper.create_experiment_set() + experiment_set.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, experiment_set, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestExperimentSetReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any ExperimentSet + PermissionTest("ExperimentSet", "published", "admin", Action.READ, True), + PermissionTest("ExperimentSet", "private", "admin", Action.READ, True), + # Owners can read any ExperimentSet they own + PermissionTest("ExperimentSet", "published", "owner", Action.READ, True), + PermissionTest("ExperimentSet", "private", "owner", Action.READ, True), + # Contributors can read any ExperimentSet they contribute to + PermissionTest("ExperimentSet", "published", "contributor", Action.READ, True), + PermissionTest("ExperimentSet", "private", "contributor", Action.READ, True), + # Mappers can read any ExperimentSet (including private) + PermissionTest("ExperimentSet", "published", "mapper", Action.READ, True), + PermissionTest("ExperimentSet", "private", "mapper", Action.READ, True), + # Other users can only read published ExperimentSets + PermissionTest("ExperimentSet", "published", "other_user", Action.READ, True), + PermissionTest("ExperimentSet", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published ExperimentSets + PermissionTest("ExperimentSet", "published", "anonymous", Action.READ, True), + PermissionTest("ExperimentSet", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any ExperimentSet + PermissionTest("ExperimentSet", "private", "admin", Action.UPDATE, True), + PermissionTest("ExperimentSet", "published", "admin", Action.UPDATE, True), + # Owners can update any ExperimentSet they own + PermissionTest("ExperimentSet", "private", "owner", Action.UPDATE, True), + PermissionTest("ExperimentSet", "published", "owner", Action.UPDATE, True), + # Contributors can update any ExperimentSet they contribute to + PermissionTest("ExperimentSet", "private", "contributor", Action.UPDATE, True), + PermissionTest("ExperimentSet", "published", "contributor", Action.UPDATE, True), + # Mappers cannot update ExperimentSets + PermissionTest("ExperimentSet", "private", "mapper", Action.UPDATE, False, 404), + PermissionTest("ExperimentSet", "published", "mapper", Action.UPDATE, False, 403), + # Other users cannot update ExperimentSets + PermissionTest("ExperimentSet", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("ExperimentSet", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update ExperimentSets + PermissionTest("ExperimentSet", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("ExperimentSet", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can delete any ExperimentSet + PermissionTest("ExperimentSet", "private", "admin", Action.DELETE, True), + PermissionTest("ExperimentSet", "published", "admin", Action.DELETE, True), + # Owners can only delete unpublished ExperimentSets + PermissionTest("ExperimentSet", "private", "owner", Action.DELETE, True), + PermissionTest("ExperimentSet", "published", "owner", Action.DELETE, False, 403), + # Contributors cannot delete + PermissionTest("ExperimentSet", "private", "contributor", Action.DELETE, False, 403), + PermissionTest("ExperimentSet", "published", "contributor", Action.DELETE, False, 403), + # Other users cannot delete + PermissionTest("ExperimentSet", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("ExperimentSet", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete + PermissionTest("ExperimentSet", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("ExperimentSet", "published", "anonymous", Action.DELETE, False, 401), + # Mappers cannot delete + PermissionTest("ExperimentSet", "private", "mapper", Action.DELETE, False, 404), + PermissionTest("ExperimentSet", "published", "mapper", Action.DELETE, False, 403), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetAddExperimentActionHandler: + """Test the _handle_add_experiment_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can add experiments to any ExperimentSet + PermissionTest("ExperimentSet", "private", "admin", Action.ADD_EXPERIMENT, True), + PermissionTest("ExperimentSet", "published", "admin", Action.ADD_EXPERIMENT, True), + # Owners can add experiments to any ExperimentSet they own + PermissionTest("ExperimentSet", "private", "owner", Action.ADD_EXPERIMENT, True), + PermissionTest("ExperimentSet", "published", "owner", Action.ADD_EXPERIMENT, True), + # Contributors can add experiments to any ExperimentSet they contribute to + PermissionTest("ExperimentSet", "private", "contributor", Action.ADD_EXPERIMENT, True), + PermissionTest("ExperimentSet", "published", "contributor", Action.ADD_EXPERIMENT, True), + # Mappers cannot add experiments to ExperimentSets + PermissionTest("ExperimentSet", "private", "mapper", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("ExperimentSet", "published", "mapper", Action.ADD_EXPERIMENT, False, 403), + # Other users cannot add experiments to ExperimentSets + PermissionTest("ExperimentSet", "private", "other_user", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("ExperimentSet", "published", "other_user", Action.ADD_EXPERIMENT, False, 403), + # Anonymous users cannot add experiments to ExperimentSets + PermissionTest("ExperimentSet", "private", "anonymous", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("ExperimentSet", "published", "anonymous", Action.ADD_EXPERIMENT, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_experiment_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_experiment_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_experiment_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetDenyActionHandler: + """Test experiment set deny action handler.""" + + def test_deny_action_for_private_experiment_set_non_contributor(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment_set helper function for private ExperimentSet.""" + experiment_set = entity_helper.create_experiment_set("private") + + # Private entity should return 404 + result = _deny_action_for_experiment_set( + experiment_set, True, entity_helper.create_user_data("other_user"), False + ) + assert result.permitted is False + assert result.http_code == 404 + + def test_deny_action_for_private_experiment_set_contributor(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment_set helper function for private ExperimentSet with contributor user.""" + experiment_set = entity_helper.create_experiment_set("private") + + # Private entity, contributor user should return 404 + result = _deny_action_for_experiment_set( + experiment_set, True, entity_helper.create_user_data("contributor"), True + ) + assert result.permitted is False + assert result.http_code == 403 + + def test_deny_action_for_public_experiment_set_anonymous_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment_set helper function for public ExperimentSet with anonymous user.""" + experiment_set = entity_helper.create_experiment_set("published") + + # Public entity, anonymous user should return 401 + result = _deny_action_for_experiment_set(experiment_set, False, None, False) + assert result.permitted is False + assert result.http_code == 401 + + def test_deny_action_for_public_experiment_set_authenticated_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_experiment_set helper function for public ExperimentSet with authenticated user.""" + experiment_set = entity_helper.create_experiment_set("published") + + # Public entity, authenticated user should return 403 + result = _deny_action_for_experiment_set( + experiment_set, False, entity_helper.create_user_data("other_user"), False + ) + assert result.permitted is False + assert result.http_code == 403 diff --git a/tests/lib/permissions/test_models.py b/tests/lib/permissions/test_models.py new file mode 100644 index 000000000..e571d51cc --- /dev/null +++ b/tests/lib/permissions/test_models.py @@ -0,0 +1,39 @@ +"""Tests for permissions models module.""" + +from mavedb.lib.permissions.models import PermissionResponse + + +class TestPermissionResponse: + """Test the PermissionResponse class.""" + + def test_permitted_response_creation(self): + """Test creating a PermissionResponse for permitted access.""" + response = PermissionResponse(permitted=True) + + assert response.permitted is True + assert response.http_code is None + assert response.message is None + + def test_denied_response_creation_with_defaults(self): + """Test creating a PermissionResponse for denied access with default values.""" + response = PermissionResponse(permitted=False) + + assert response.permitted is False + assert response.http_code == 403 + assert response.message is None + + def test_denied_response_creation_with_custom_values(self): + """Test creating a PermissionResponse for denied access with custom values.""" + response = PermissionResponse(permitted=False, http_code=404, message="Resource not found") + + assert response.permitted is False + assert response.http_code == 404 + assert response.message == "Resource not found" + + def test_permitted_response_ignores_error_parameters(self): + """Test that permitted responses ignore http_code and message parameters.""" + response = PermissionResponse(permitted=True, http_code=404, message="This should be ignored") + + assert response.permitted is True + assert response.http_code is None + assert response.message is None diff --git a/tests/lib/permissions/test_score_calibration.py b/tests/lib/permissions/test_score_calibration.py new file mode 100644 index 000000000..f1bf03096 --- /dev/null +++ b/tests/lib/permissions/test_score_calibration.py @@ -0,0 +1,597 @@ +"""Tests for ScoreCalibration permissions module.""" + +from typing import Callable, List +from unittest import mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.score_calibration import ( + _deny_action_for_score_calibration, + _handle_change_rank_action, + _handle_delete_action, + _handle_publish_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +SCORE_CALIBRATION_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.CHANGE_RANK: _handle_change_rank_action, +} + +SCORE_CALIBRATION_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_EXPERIMENT, + Action.ADD_SCORE_SET, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.SET_SCORES, +] + + +def test_score_calibration_handles_all_actions() -> None: + """Test that all ScoreCalibration actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(SCORE_CALIBRATION_SUPPORTED_ACTIONS) + unsupported = set(SCORE_CALIBRATION_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for score calibrations." + + +class TestScoreCalibrationHasPermission: + """Test the main has_permission dispatcher function for ScoreCalibration entities.""" + + @pytest.mark.parametrize("action, handler", SCORE_CALIBRATION_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + score_calibration = entity_helper.create_score_calibration() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.score_calibration." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, score_calibration, action) + mock_handler.assert_called_once_with( + admin_user, + score_calibration, + False, # admin is not the owner + False, # admin is not a contributor to score set + score_calibration.private, + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", SCORE_CALIBRATION_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + score_calibration = entity_helper.create_score_calibration() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, score_calibration, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in SCORE_CALIBRATION_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if ScoreCalibration.private is None.""" + score_calibration = entity_helper.create_score_calibration() + score_calibration.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, score_calibration, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestScoreCalibrationReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can read any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "published", "admin", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.READ, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "private", "admin", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.READ, True, investigator_provided=False), + # Owners: Can read any ScoreCalibration they created regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "published", "owner", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "owner", Action.READ, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "private", "owner", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.READ, True, investigator_provided=False), + # Contributors to associated ScoreSet: Can read published ScoreCalibrations (any type) and private investigator-provided ScoreCalibrations, but NOT private community-provided ones + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.READ, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.READ, True, investigator_provided=False + ), + PermissionTest("ScoreCalibration", "private", "contributor", Action.READ, True, investigator_provided=True), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.READ, False, 404, investigator_provided=False + ), + # Other users: Can only read published ScoreCalibrations, cannot access any private ones + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.READ, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.READ, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.READ, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.READ, False, 404, investigator_provided=False + ), + # Anonymous users: Can only read published ScoreCalibrations, cannot access any private ones + PermissionTest("ScoreCalibration", "published", "anonymous", Action.READ, True, investigator_provided=True), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.READ, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.READ, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.READ, False, 404, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can update any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "admin", Action.UPDATE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.UPDATE, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "admin", Action.UPDATE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.UPDATE, True, investigator_provided=False), + # Owners: Can update only their own private ScoreCalibrations, cannot update published ones (even their own) + PermissionTest("ScoreCalibration", "private", "owner", Action.UPDATE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.UPDATE, True, investigator_provided=False), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.UPDATE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.UPDATE, False, 403, investigator_provided=False + ), + # Contributors to associated ScoreSet: Can update only private investigator-provided ScoreCalibrations, cannot update community-provided or published ones + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.UPDATE, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.UPDATE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.UPDATE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.UPDATE, False, 403, investigator_provided=False + ), + # Other users: Cannot update any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.UPDATE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.UPDATE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.UPDATE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.UPDATE, False, 403, investigator_provided=False + ), + # Anonymous users: Cannot update any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.UPDATE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.UPDATE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.UPDATE, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.UPDATE, False, 401, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can delete any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "admin", Action.DELETE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.DELETE, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "admin", Action.DELETE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.DELETE, True, investigator_provided=False), + # Owners: Can delete only their own private ScoreCalibrations, cannot delete published ones (even their own) + PermissionTest("ScoreCalibration", "private", "owner", Action.DELETE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.DELETE, True, investigator_provided=False), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.DELETE, False, 403, investigator_provided=False + ), + # Contributors to associated ScoreSet: Cannot delete any ScoreCalibrations (even investigator-provided ones they can read/update) + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.DELETE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.DELETE, False, 403, investigator_provided=False + ), + # Other users: Cannot delete any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.DELETE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.DELETE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.DELETE, False, 403, investigator_provided=False + ), + # Anonymous users: Cannot delete any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.DELETE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.DELETE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.DELETE, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.DELETE, False, 401, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationPublishActionHandler: + """Test the _handle_publish_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can publish any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "admin", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.PUBLISH, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "admin", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.PUBLISH, True, investigator_provided=False), + # Owners: Can publish their own ScoreCalibrations regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "owner", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.PUBLISH, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "owner", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "owner", Action.PUBLISH, True, investigator_provided=False), + # Contributors to associated ScoreSet: Cannot publish any ScoreCalibrations (even investigator-provided ones they can read/update) + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.PUBLISH, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.PUBLISH, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.PUBLISH, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.PUBLISH, False, 403, investigator_provided=False + ), + # Other users: Cannot publish any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.PUBLISH, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.PUBLISH, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.PUBLISH, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.PUBLISH, False, 403, investigator_provided=False + ), + # Anonymous users: Cannot publish any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.PUBLISH, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.PUBLISH, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.PUBLISH, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.PUBLISH, False, 401, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_publish_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_publish_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationChangeRankActionHandler: + """Test the _handle_change_rank_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can change rank of any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest( + "ScoreCalibration", "private", "admin", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "admin", Action.CHANGE_RANK, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "admin", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "admin", Action.CHANGE_RANK, True, investigator_provided=False + ), + # Owners: Can change rank of their own ScoreCalibrations regardless of state or investigator_provided flag + PermissionTest( + "ScoreCalibration", "private", "owner", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "owner", Action.CHANGE_RANK, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.CHANGE_RANK, True, investigator_provided=False + ), + # Contributors to associated ScoreSet: Can change rank of investigator-provided ScoreCalibrations (private or published), but cannot change rank of community-provided ones + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.CHANGE_RANK, + False, + 404, + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", + "published", + "contributor", + Action.CHANGE_RANK, + False, + 403, + investigator_provided=False, + ), + # Other users: Cannot change rank of any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.CHANGE_RANK, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.CHANGE_RANK, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", + "published", + "other_user", + Action.CHANGE_RANK, + False, + 403, + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "published", + "other_user", + Action.CHANGE_RANK, + False, + 403, + investigator_provided=False, + ), + # Anonymous users: Cannot change rank of any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.CHANGE_RANK, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.CHANGE_RANK, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.CHANGE_RANK, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", + "published", + "anonymous", + Action.CHANGE_RANK, + False, + 401, + investigator_provided=False, + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_change_rank_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_change_rank_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_change_rank_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationDenyActionHandler: + """Test score calibration deny action handler.""" + + def test_deny_action_for_private_score_calibration_not_contributor(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_calibration helper function for private ScoreCalibration.""" + score_calibration = entity_helper.create_score_calibration("private", True) + + # Private entity should return 404 + result = _deny_action_for_score_calibration( + score_calibration, True, entity_helper.create_user_data("other_user"), False + ) + assert result.permitted is False + assert result.http_code == 404 + + def test_deny_action_for_public_score_calibration_anonymous_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_calibration helper function for public ScoreCalibration with anonymous user.""" + score_calibration = entity_helper.create_score_calibration("published", True) + + # Public entity, anonymous user should return 401 + result = _deny_action_for_score_calibration(score_calibration, False, None, False) + assert result.permitted is False + assert result.http_code == 401 + + def test_deny_action_for_public_score_calibration_authenticated_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_calibration helper function for public ScoreCalibration with authenticated user.""" + score_calibration = entity_helper.create_score_calibration("published", True) + + # Public entity, authenticated user should return 403 + result = _deny_action_for_score_calibration( + score_calibration, False, entity_helper.create_user_data("other_user"), False + ) + assert result.permitted is False + assert result.http_code == 403 + + def test_deny_action_for_private_score_calibration_with_contributor(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_calibration helper function for private ScoreCalibration with contributor user.""" + score_calibration = entity_helper.create_score_calibration("private", True) + + # Private entity with contributor user should return 403 + result = _deny_action_for_score_calibration( + score_calibration, True, entity_helper.create_user_data("contributor"), True + ) + assert result.permitted is False + assert result.http_code == 403 diff --git a/tests/lib/permissions/test_score_set.py b/tests/lib/permissions/test_score_set.py new file mode 100644 index 000000000..002e1544b --- /dev/null +++ b/tests/lib/permissions/test_score_set.py @@ -0,0 +1,363 @@ +"""Tests for ScoreSet permissions module.""" + +from typing import Callable, List +from unittest import mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.score_set import ( + _deny_action_for_score_set, + _handle_delete_action, + _handle_publish_action, + _handle_read_action, + _handle_set_scores_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +SCORE_SET_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.SET_SCORES: _handle_set_scores_action, + Action.PUBLISH: _handle_publish_action, +} + +SCORE_SET_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_EXPERIMENT, + Action.ADD_SCORE_SET, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.CHANGE_RANK, +] + + +def test_score_set_handles_all_actions() -> None: + """Test that all ScoreSet actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(SCORE_SET_SUPPORTED_ACTIONS) + unsupported = set(SCORE_SET_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for score sets." + + +class TestScoreSetHasPermission: + """Test the main has_permission dispatcher function for ScoreSet entities.""" + + @pytest.mark.parametrize("action, handler", SCORE_SET_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + score_set = entity_helper.create_score_set() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.score_set." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, score_set, action) + mock_handler.assert_called_once_with( + admin_user, + score_set, + score_set.private, + False, # admin is not the owner + False, # admin is not a contributor + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", SCORE_SET_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + score_set = entity_helper.create_score_set() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, score_set, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in SCORE_SET_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if ScoreSet.private is None.""" + score_set = entity_helper.create_score_set() + score_set.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, score_set, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestScoreSetReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any ScoreSet + PermissionTest("ScoreSet", "published", "admin", Action.READ, True), + PermissionTest("ScoreSet", "private", "admin", Action.READ, True), + # Owners can read any ScoreSet they own + PermissionTest("ScoreSet", "published", "owner", Action.READ, True), + PermissionTest("ScoreSet", "private", "owner", Action.READ, True), + # Contributors can read any ScoreSet they contribute to + PermissionTest("ScoreSet", "published", "contributor", Action.READ, True), + PermissionTest("ScoreSet", "private", "contributor", Action.READ, True), + # Mappers can read any ScoreSet (including private) + PermissionTest("ScoreSet", "published", "mapper", Action.READ, True), + PermissionTest("ScoreSet", "private", "mapper", Action.READ, True), + # Other users can only read published ScoreSets + PermissionTest("ScoreSet", "published", "other_user", Action.READ, True), + PermissionTest("ScoreSet", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published ScoreSets + PermissionTest("ScoreSet", "published", "anonymous", Action.READ, True), + PermissionTest("ScoreSet", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.UPDATE, True), + PermissionTest("ScoreSet", "published", "admin", Action.UPDATE, True), + # Owners can update any ScoreSet they own + PermissionTest("ScoreSet", "private", "owner", Action.UPDATE, True), + PermissionTest("ScoreSet", "published", "owner", Action.UPDATE, True), + # Contributors can update any ScoreSet they contribute to + PermissionTest("ScoreSet", "private", "contributor", Action.UPDATE, True), + PermissionTest("ScoreSet", "published", "contributor", Action.UPDATE, True), + # Mappers cannot update ScoreSets + PermissionTest("ScoreSet", "private", "mapper", Action.UPDATE, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.UPDATE, False, 403), + # Other users cannot update ScoreSets + PermissionTest("ScoreSet", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update ScoreSets + PermissionTest("ScoreSet", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can delete any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.DELETE, True), + PermissionTest("ScoreSet", "published", "admin", Action.DELETE, True), + # Owners can only delete unpublished ScoreSets + PermissionTest("ScoreSet", "private", "owner", Action.DELETE, True), + PermissionTest("ScoreSet", "published", "owner", Action.DELETE, False, 403), + # Contributors cannot delete + PermissionTest("ScoreSet", "private", "contributor", Action.DELETE, False, 403), + PermissionTest("ScoreSet", "published", "contributor", Action.DELETE, False, 403), + # Other users cannot delete + PermissionTest("ScoreSet", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete + PermissionTest("ScoreSet", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.DELETE, False, 401), + # Mappers cannot delete + PermissionTest("ScoreSet", "private", "mapper", Action.DELETE, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.DELETE, False, 403), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetSetScoresActionHandler: + """Test the _handle_set_scores_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can set scores on any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.SET_SCORES, True), + PermissionTest("ScoreSet", "published", "admin", Action.SET_SCORES, True), + # Owners can set scores on any ScoreSet they own + PermissionTest("ScoreSet", "private", "owner", Action.SET_SCORES, True), + PermissionTest("ScoreSet", "published", "owner", Action.SET_SCORES, True), + # Contributors can set scores on any ScoreSet they contribute to + PermissionTest("ScoreSet", "private", "contributor", Action.SET_SCORES, True), + PermissionTest("ScoreSet", "published", "contributor", Action.SET_SCORES, True), + # Mappers cannot set scores on ScoreSets + PermissionTest("ScoreSet", "private", "mapper", Action.SET_SCORES, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.SET_SCORES, False, 403), + # Other users cannot set scores on ScoreSets + PermissionTest("ScoreSet", "private", "other_user", Action.SET_SCORES, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.SET_SCORES, False, 403), + # Anonymous users cannot set scores on ScoreSets + PermissionTest("ScoreSet", "private", "anonymous", Action.SET_SCORES, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.SET_SCORES, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_set_scores_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_set_scores_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_set_scores_action( + user_data, score_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetPublishActionHandler: + """Test the _handle_publish_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can publish any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.PUBLISH, True), + PermissionTest("ScoreSet", "published", "admin", Action.PUBLISH, True), + # Owners can publish any ScoreSet they own + PermissionTest("ScoreSet", "private", "owner", Action.PUBLISH, True), + PermissionTest("ScoreSet", "published", "owner", Action.PUBLISH, True), + # Contributors cannot publish ScoreSets they contribute to + PermissionTest("ScoreSet", "private", "contributor", Action.PUBLISH, False, 403), + PermissionTest("ScoreSet", "published", "contributor", Action.PUBLISH, False, 403), + # Mappers cannot publish ScoreSets + PermissionTest("ScoreSet", "private", "mapper", Action.PUBLISH, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.PUBLISH, False, 403), + # Other users cannot publish ScoreSets + PermissionTest("ScoreSet", "private", "other_user", Action.PUBLISH, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.PUBLISH, False, 403), + # Anonymous users cannot publish ScoreSets + PermissionTest("ScoreSet", "private", "anonymous", Action.PUBLISH, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.PUBLISH, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_publish_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_publish_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetDenyActionHandler: + """Test score set deny action handler.""" + + def test_deny_action_for_private_score_set_non_contributor(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_set helper function for private ScoreSet.""" + score_set = entity_helper.create_score_set("private") + + # Private entity should return 404 + result = _deny_action_for_score_set(score_set, True, entity_helper.create_user_data("other_user"), False) + assert result.permitted is False + assert result.http_code == 404 + + def test_deny_action_for_private_score_set_contributor(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_set helper function for private ScoreSet with contributor user.""" + score_set = entity_helper.create_score_set("private") + + # Private entity, contributor user should return 404 + result = _deny_action_for_score_set(score_set, True, entity_helper.create_user_data("contributor"), True) + assert result.permitted is False + assert result.http_code == 403 + + def test_deny_action_for_public_score_set_anonymous_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_set helper function for public ScoreSet with anonymous user.""" + score_set = entity_helper.create_score_set("published") + + # Public entity, anonymous user should return 401 + result = _deny_action_for_score_set(score_set, False, None, False) + assert result.permitted is False + assert result.http_code == 401 + + def test_deny_action_for_public_score_set_authenticated_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_score_set helper function for public ScoreSet with authenticated user.""" + score_set = entity_helper.create_score_set("published") + + # Public entity, authenticated user should return 403 + result = _deny_action_for_score_set(score_set, False, entity_helper.create_user_data("other_user"), False) + assert result.permitted is False + assert result.http_code == 403 diff --git a/tests/lib/permissions/test_user.py b/tests/lib/permissions/test_user.py new file mode 100644 index 000000000..15f13eec7 --- /dev/null +++ b/tests/lib/permissions/test_user.py @@ -0,0 +1,256 @@ +"""Tests for User permissions module.""" + +from typing import Callable, List +from unittest import mock + +import pytest + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.user import ( + _deny_action_for_user, + _handle_add_role_action, + _handle_lookup_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +USER_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.LOOKUP: _handle_lookup_action, + Action.ADD_ROLE: _handle_add_role_action, +} + +USER_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.DELETE, + Action.ADD_EXPERIMENT, + Action.ADD_SCORE_SET, + Action.ADD_BADGE, + Action.CHANGE_RANK, + Action.SET_SCORES, + Action.PUBLISH, +] + + +def test_user_handles_all_actions() -> None: + """Test that all User actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(USER_SUPPORTED_ACTIONS) + unsupported = set(USER_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for users." + + +class TestUserHasPermission: + """Test the main has_permission dispatcher function for User entities.""" + + @pytest.mark.parametrize("action, handler", USER_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + user = entity_helper.create_user() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.user." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, user, action) + mock_handler.assert_called_once_with( + admin_user, + user, + False, # admin is not viewing self + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", USER_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + user = entity_helper.create_user() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, user, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in USER_SUPPORTED_ACTIONS) + + +class TestUserReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any User profile + PermissionTest("User", None, "admin", Action.READ, True), + # Users can read their own profile + PermissionTest("User", None, "self", Action.READ, True), + # Owners cannot read other user profiles (no special privilege) + PermissionTest("User", None, "owner", Action.READ, False, 403), + # Contributors cannot read other user profiles + PermissionTest("User", None, "contributor", Action.READ, False, 403), + # Mappers cannot read other user profiles + PermissionTest("User", None, "mapper", Action.READ, False, 403), + # Other users cannot read other user profiles + PermissionTest("User", None, "other_user", Action.READ, False, 403), + # Anonymous users cannot read user profiles + PermissionTest("User", None, "anonymous", Action.READ, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any User profile + PermissionTest("User", None, "admin", Action.UPDATE, True), + # Users can update their own profile + PermissionTest("User", None, "self", Action.UPDATE, True), + # Owners cannot update other user profiles (no special privilege) + PermissionTest("User", None, "owner", Action.UPDATE, False, 403), + # Contributors cannot update other user profiles + PermissionTest("User", None, "contributor", Action.UPDATE, False, 403), + # Mappers cannot update other user profiles + PermissionTest("User", None, "mapper", Action.UPDATE, False, 403), + # Other users cannot update other user profiles + PermissionTest("User", None, "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update user profiles + PermissionTest("User", None, "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserLookupActionHandler: + """Test the _handle_lookup_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can lookup any User + PermissionTest("User", None, "admin", Action.LOOKUP, True), + # Users can lookup themselves + PermissionTest("User", None, "self", Action.LOOKUP, True), + # Owners can lookup other users (authenticated user privilege) + PermissionTest("User", None, "owner", Action.LOOKUP, True), + # Contributors can lookup other users (authenticated user privilege) + PermissionTest("User", None, "contributor", Action.LOOKUP, True), + # Mappers can lookup other users (authenticated user privilege) + PermissionTest("User", None, "mapper", Action.LOOKUP, True), + # Other authenticated users can lookup other users + PermissionTest("User", None, "other_user", Action.LOOKUP, True), + # Anonymous users cannot lookup users + PermissionTest("User", None, "anonymous", Action.LOOKUP, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_lookup_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_lookup_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_lookup_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserAddRoleActionHandler: + """Test the _handle_add_role_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can add roles to any User + PermissionTest("User", None, "admin", Action.ADD_ROLE, True), + # Users cannot add roles to themselves + PermissionTest("User", None, "self", Action.ADD_ROLE, False, 403), + # Owners cannot add roles to other users + PermissionTest("User", None, "owner", Action.ADD_ROLE, False, 403), + # Contributors cannot add roles to other users + PermissionTest("User", None, "contributor", Action.ADD_ROLE, False, 403), + # Mappers cannot add roles to other users + PermissionTest("User", None, "mapper", Action.ADD_ROLE, False, 403), + # Other users cannot add roles to other users + PermissionTest("User", None, "other_user", Action.ADD_ROLE, False, 403), + # Anonymous users cannot add roles to users + PermissionTest("User", None, "anonymous", Action.ADD_ROLE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_role_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_role_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_role_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserDenyActionHandler: + """Test user deny action handler.""" + + def test_deny_action_for_user_anonymous_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_user helper function for anonymous user.""" + user = entity_helper.create_user() + + # Anonymous user should return 401 + result = _deny_action_for_user(user, None) + assert result.permitted is False + assert result.http_code == 401 + + def test_deny_action_for_user_authenticated_user(self, entity_helper: EntityTestHelper) -> None: + """Test _deny_action_for_user helper function for authenticated user with insufficient permissions.""" + user = entity_helper.create_user() + + # Authenticated user with insufficient permissions should return 403 + result = _deny_action_for_user(user, entity_helper.create_user_data("other_user")) + assert result.permitted is False + assert result.http_code == 403 diff --git a/tests/lib/permissions/test_utils.py b/tests/lib/permissions/test_utils.py new file mode 100644 index 000000000..f846a6654 --- /dev/null +++ b/tests/lib/permissions/test_utils.py @@ -0,0 +1,141 @@ +"""Tests for permissions utils module.""" + +import pytest + +from mavedb.lib.permissions.utils import roles_permitted +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + + +class TestRolesPermitted: + """Test the roles_permitted utility function.""" + + def test_user_role_permission_granted(self): + """Test that permission is granted when user has a permitted role.""" + user_roles = [UserRole.admin, UserRole.mapper] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_user_role_permission_denied(self): + """Test that permission is denied when user lacks permitted roles.""" + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_contribution_role_permission_granted(self): + """Test that permission is granted for contribution roles.""" + user_roles = [ContributionRole.admin, ContributionRole.editor] + permitted_roles = [ContributionRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_contribution_role_permission_denied(self): + """Test that permission is denied for contribution roles.""" + user_roles = [ContributionRole.viewer] + permitted_roles = [ContributionRole.admin, ContributionRole.editor] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_empty_user_roles_permission_denied(self): + """Test that permission is denied when user has no roles.""" + user_roles = [] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_multiple_matching_roles(self): + """Test permission when user has multiple permitted roles.""" + user_roles = [UserRole.admin, UserRole.mapper] + permitted_roles = [UserRole.admin, UserRole.mapper] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_partial_role_match(self): + """Test permission when user has some but not all permitted roles.""" + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin, UserRole.mapper] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_no_role_overlap(self): + """Test permission when user roles don't overlap with permitted roles.""" + user_roles = [ContributionRole.viewer] + permitted_roles = [ContributionRole.admin, ContributionRole.editor] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_empty_permitted_roles(self): + """Test behavior when no roles are permitted.""" + user_roles = [UserRole.admin] + permitted_roles = [] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_both_empty_roles(self): + """Test behavior when both user and permitted roles are empty.""" + user_roles = [] + permitted_roles = [] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_consistent_role_types_allowed(self): + """Test behavior with consistent role types (should work fine).""" + user_roles = [UserRole.admin] + permitted_roles = [UserRole.admin, UserRole.mapper] + assert roles_permitted(user_roles, permitted_roles) is True + + user_roles = [ContributionRole.editor] + permitted_roles = [ContributionRole.admin, ContributionRole.editor, ContributionRole.viewer] + assert roles_permitted(user_roles, permitted_roles) is True + + def test_mixed_user_role_types_raises_error(self): + """Test that mixed role types in user_roles list raises ValueError.""" + permitted_roles = [UserRole.admin] + mixed_user_roles = [UserRole.admin, ContributionRole.editor] + + with pytest.raises(ValueError) as exc_info: + roles_permitted(mixed_user_roles, permitted_roles) + + assert "user_roles list cannot contain mixed role types" in str(exc_info.value) + + def test_mixed_permitted_role_types_raises_error(self): + """Test that mixed role types in permitted_roles list raises ValueError.""" + user_roles = [UserRole.admin] + mixed_permitted_roles = [UserRole.admin, ContributionRole.editor] + + with pytest.raises(ValueError) as exc_info: + roles_permitted(user_roles, mixed_permitted_roles) + + assert "permitted_roles list cannot contain mixed role types" in str(exc_info.value) + + def test_different_role_types_between_lists_raises_error(self): + """Test that different role types between lists raises ValueError.""" + user_roles = [UserRole.admin] + permitted_roles = [ContributionRole.admin] + + with pytest.raises(ValueError) as exc_info: + roles_permitted(user_roles, permitted_roles) + + assert "user_roles and permitted_roles must contain the same role type" in str(exc_info.value) + + def test_single_role_lists(self): + """Test with single-item role lists.""" + user_roles = [UserRole.admin] + permitted_roles = [UserRole.admin] + assert roles_permitted(user_roles, permitted_roles) is True + + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin] + assert roles_permitted(user_roles, permitted_roles) is False diff --git a/tests/lib/test_permissions.py b/tests/lib/test_permissions.py deleted file mode 100644 index 5091e2db7..000000000 --- a/tests/lib/test_permissions.py +++ /dev/null @@ -1,3165 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union -from unittest.mock import Mock - -import pytest - -from mavedb.lib.permissions import ( - Action, - PermissionException, - PermissionResponse, - assert_permission, - has_permission, - roles_permitted, -) -from mavedb.models.collection import Collection -from mavedb.models.collection_user_association import CollectionUserAssociation -from mavedb.models.enums.contribution_role import ContributionRole -from mavedb.models.enums.user_role import UserRole -from mavedb.models.experiment import Experiment -from mavedb.models.experiment_set import ExperimentSet -from mavedb.models.score_calibration import ScoreCalibration -from mavedb.models.score_set import ScoreSet -from mavedb.models.user import User - - -@dataclass -class PermissionTest: - """Represents a single permission test case. - - Field Values: - - entity_type: "ScoreSet", "Experiment", "ExperimentSet", "Collection", "User", "ScoreCalibration" - - entity_state: "private", "published", or None (for entities like User without states) - - user_type: "admin", "owner", "contributor", "other_user", "anonymous", "self" - - For Collections: "contributor" is generic, use collection_role to specify "collection_admin", "collection_editor", "collection_viewer" - - action: Action enum value (READ, UPDATE, DELETE, ADD_SCORE_SET, etc.) - - should_be_permitted: True if permission should be granted, False if denied, "NotImplementedError" if action not supported - - expected_code: HTTP error code when permission denied (403, 404, 401, etc.) - - description: Human-readable test description - - investigator_provided: True/False for ScoreCalibration tests, None for other entities - - collection_role: "collection_admin", "collection_editor", "collection_viewer" for Collection entity tests, None for others - """ - - entity_type: str # "ScoreSet", "Experiment", "ExperimentSet", "Collection", "User", "ScoreCalibration" - entity_state: Optional[str] # "private", "published", or None for stateless entities - user_type: str # "admin", "owner", "contributor", "other_user", "anonymous", "self" - action: Action - should_be_permitted: Union[bool, str] # True/False for normal cases, "NotImplementedError" for unsupported actions - expected_code: Optional[int] = None # HTTP error code when denied (403, 404, 401) - description: Optional[str] = None - investigator_provided: Optional[bool] = None # ScoreCalibration: True=investigator, False=community - collection_role: Optional[str] = ( - None # "collection_admin", "collection_editor", "collection_viewer" for Collection tests - ) - - -class PermissionTestData: - """Contains all explicit permission test cases.""" - - @staticmethod - def get_all_permission_tests() -> list[PermissionTest]: - """Get all permission test cases in one explicit list.""" - return [ - # ============================================================================= - # EXPERIMENT SET PERMISSIONS - # ============================================================================= - # ExperimentSet READ permissions - Private - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.READ, - True, - description="Admin can read private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "owner", - Action.READ, - True, - description="Owner can read their private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "contributor", - Action.READ, - True, - description="Contributor can read private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "other_user", - Action.READ, - False, - 404, - "Other user gets 404 for private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "anonymous", - Action.READ, - False, - 404, - "Anonymous gets 404 for private experiment set", - ), - # ExperimentSet READ permissions - Published - PermissionTest( - "ExperimentSet", - "published", - "admin", - Action.READ, - True, - description="Admin can read published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "owner", - Action.READ, - True, - description="Owner can read their published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "contributor", - Action.READ, - True, - description="Contributor can read published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "other_user", - Action.READ, - True, - description="Other user can read published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "anonymous", - Action.READ, - True, - description="Anonymous can read published experiment set", - ), - # ExperimentSet UPDATE permissions - Private - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.UPDATE, - True, - description="Admin can update private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "owner", - Action.UPDATE, - True, - description="Owner can update their private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "contributor", - Action.UPDATE, - True, - description="Contributor can update private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "other_user", - Action.UPDATE, - False, - 404, - "Other user gets 404 for private experiment set update", - ), - PermissionTest( - "ExperimentSet", - "private", - "anonymous", - Action.UPDATE, - False, - 404, - "Anonymous gets 404 for private experiment set update", - ), - # ExperimentSet UPDATE permissions - Published - PermissionTest( - "ExperimentSet", - "published", - "admin", - Action.UPDATE, - True, - description="Admin can update published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "owner", - Action.UPDATE, - True, - description="Owner can update their published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "contributor", - Action.UPDATE, - True, - description="Contributor can update published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "other_user", - Action.UPDATE, - False, - 403, - "Other user cannot update published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "anonymous", - Action.UPDATE, - False, - 401, - "Anonymous cannot update published experiment set", - ), - # ExperimentSet DELETE permissions - Private (unpublished) - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.DELETE, - True, - description="Admin can delete private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "owner", - Action.DELETE, - True, - description="Owner can delete unpublished experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "contributor", - Action.DELETE, - True, - description="Contributor can delete unpublished experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "other_user", - Action.DELETE, - False, - 404, - "Other user gets 404 for private experiment set delete", - ), - PermissionTest( - "ExperimentSet", - "private", - "anonymous", - Action.DELETE, - False, - 404, - "Anonymous gets 404 for private experiment set delete", - ), - # ExperimentSet DELETE permissions - Published - PermissionTest( - "ExperimentSet", - "published", - "admin", - Action.DELETE, - True, - description="Admin can delete published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "owner", - Action.DELETE, - False, - 403, - "Owner cannot delete published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Contributor cannot delete published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "other_user", - Action.DELETE, - False, - 403, - "Other user cannot delete published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "anonymous", - Action.DELETE, - False, - 403, - "Anonymous cannot delete published experiment set", - ), - # ExperimentSet ADD_EXPERIMENT permissions - Private - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.ADD_EXPERIMENT, - True, - description="Admin can add experiment to private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "owner", - Action.ADD_EXPERIMENT, - True, - description="Owner can add experiment to their experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "contributor", - Action.ADD_EXPERIMENT, - True, - description="Contributor can add experiment to experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "other_user", - Action.ADD_EXPERIMENT, - False, - 404, - "Other user gets 404 for private experiment set", - ), - PermissionTest( - "ExperimentSet", - "private", - "anonymous", - Action.ADD_EXPERIMENT, - False, - 404, - "Anonymous gets 404 for private experiment set", - ), - # ExperimentSet ADD_EXPERIMENT permissions - Published - PermissionTest( - "ExperimentSet", - "published", - "admin", - Action.ADD_EXPERIMENT, - True, - description="Admin can add experiment to published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "owner", - Action.ADD_EXPERIMENT, - True, - description="Owner can add experiment to their published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "contributor", - Action.ADD_EXPERIMENT, - True, - description="Contributor can add experiment to published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "other_user", - Action.ADD_EXPERIMENT, - False, - 403, - "Other user cannot add experiment to published experiment set", - ), - PermissionTest( - "ExperimentSet", - "published", - "anonymous", - Action.ADD_EXPERIMENT, - False, - 403, - "Anonymous cannot add experiment to experiment set", - ), - # ============================================================================= - # EXPERIMENT PERMISSIONS - # ============================================================================= - # Experiment READ permissions - Private - PermissionTest( - "Experiment", - "private", - "admin", - Action.READ, - True, - description="Admin can read private experiment", - ), - PermissionTest( - "Experiment", - "private", - "owner", - Action.READ, - True, - description="Owner can read their private experiment", - ), - PermissionTest( - "Experiment", - "private", - "contributor", - Action.READ, - True, - description="Contributor can read private experiment", - ), - PermissionTest( - "Experiment", - "private", - "other_user", - Action.READ, - False, - 404, - "Other user gets 404 for private experiment", - ), - PermissionTest( - "Experiment", - "private", - "anonymous", - Action.READ, - False, - 404, - "Anonymous gets 404 for private experiment", - ), - # Experiment READ permissions - Published - PermissionTest( - "Experiment", - "published", - "admin", - Action.READ, - True, - description="Admin can read published experiment", - ), - PermissionTest( - "Experiment", - "published", - "owner", - Action.READ, - True, - description="Owner can read their published experiment", - ), - PermissionTest( - "Experiment", - "published", - "contributor", - Action.READ, - True, - description="Contributor can read published experiment", - ), - PermissionTest( - "Experiment", - "published", - "other_user", - Action.READ, - True, - description="Other user can read published experiment", - ), - PermissionTest( - "Experiment", - "published", - "anonymous", - Action.READ, - True, - description="Anonymous can read published experiment", - ), - # Experiment UPDATE permissions - Private - PermissionTest( - "Experiment", - "private", - "admin", - Action.UPDATE, - True, - description="Admin can update private experiment", - ), - PermissionTest( - "Experiment", - "private", - "owner", - Action.UPDATE, - True, - description="Owner can update their private experiment", - ), - PermissionTest( - "Experiment", - "private", - "contributor", - Action.UPDATE, - True, - description="Contributor can update private experiment", - ), - PermissionTest( - "Experiment", - "private", - "other_user", - Action.UPDATE, - False, - 404, - "Other user gets 404 for private experiment update", - ), - PermissionTest( - "Experiment", - "private", - "anonymous", - Action.UPDATE, - False, - 404, - "Anonymous gets 404 for private experiment update", - ), - # Experiment UPDATE permissions - Published - PermissionTest( - "Experiment", - "published", - "admin", - Action.UPDATE, - True, - description="Admin can update published experiment", - ), - PermissionTest( - "Experiment", - "published", - "owner", - Action.UPDATE, - True, - description="Owner can update their published experiment", - ), - PermissionTest( - "Experiment", - "published", - "contributor", - Action.UPDATE, - True, - description="Contributor can update published experiment", - ), - PermissionTest( - "Experiment", - "published", - "other_user", - Action.UPDATE, - False, - 403, - "Other user cannot update published experiment", - ), - PermissionTest( - "Experiment", - "published", - "anonymous", - Action.UPDATE, - False, - 401, - "Anonymous cannot update published experiment", - ), - # Experiment DELETE permissions - Private (unpublished) - PermissionTest( - "Experiment", - "private", - "admin", - Action.DELETE, - True, - description="Admin can delete private experiment", - ), - PermissionTest( - "Experiment", - "private", - "owner", - Action.DELETE, - True, - description="Owner can delete unpublished experiment", - ), - PermissionTest( - "Experiment", - "private", - "contributor", - Action.DELETE, - True, - description="Contributor can delete unpublished experiment", - ), - PermissionTest( - "Experiment", - "private", - "other_user", - Action.DELETE, - False, - 404, - "Other user gets 404 for private experiment delete", - ), - PermissionTest( - "Experiment", - "private", - "anonymous", - Action.DELETE, - False, - 404, - "Anonymous gets 404 for private experiment delete", - ), - # Experiment DELETE permissions - Published - PermissionTest( - "Experiment", - "published", - "admin", - Action.DELETE, - True, - description="Admin can delete published experiment", - ), - PermissionTest( - "Experiment", - "published", - "owner", - Action.DELETE, - False, - 403, - "Owner cannot delete published experiment", - ), - PermissionTest( - "Experiment", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Contributor cannot delete published experiment", - ), - PermissionTest( - "Experiment", - "published", - "other_user", - Action.DELETE, - False, - 403, - "Other user gets 403 for published experiment delete", - ), - PermissionTest( - "Experiment", - "published", - "anonymous", - Action.DELETE, - False, - 403, - "Anonymous gets 403 for published experiment delete", - ), - # Experiment ADD_SCORE_SET permissions - Private - PermissionTest( - "Experiment", - "private", - "admin", - Action.ADD_SCORE_SET, - True, - description="Admin can add score set to private experiment", - ), - PermissionTest( - "Experiment", - "private", - "owner", - Action.ADD_SCORE_SET, - True, - description="Owner can add score set to their private experiment", - ), - PermissionTest( - "Experiment", - "private", - "contributor", - Action.ADD_SCORE_SET, - True, - description="Contributor can add score set to private experiment", - ), - PermissionTest( - "Experiment", - "private", - "other_user", - Action.ADD_SCORE_SET, - False, - 404, - "Other user gets 404 for private experiment", - ), - PermissionTest( - "Experiment", - "private", - "anonymous", - Action.ADD_SCORE_SET, - False, - 404, - "Anonymous gets 404 for private experiment", - ), - # Experiment ADD_SCORE_SET permissions - Published (any signed in user can add) - PermissionTest( - "Experiment", - "published", - "admin", - Action.ADD_SCORE_SET, - True, - description="Admin can add score set to published experiment", - ), - PermissionTest( - "Experiment", - "published", - "owner", - Action.ADD_SCORE_SET, - True, - description="Owner can add score set to their published experiment", - ), - PermissionTest( - "Experiment", - "published", - "contributor", - Action.ADD_SCORE_SET, - True, - description="Contributor can add score set to published experiment", - ), - PermissionTest( - "Experiment", - "published", - "other_user", - Action.ADD_SCORE_SET, - True, - description="Other user can add score set to published experiment", - ), - PermissionTest( - "Experiment", - "published", - "anonymous", - Action.ADD_SCORE_SET, - False, - 403, - "Anonymous cannot add score set to experiment", - ), - # ============================================================================= - # SCORE SET PERMISSIONS - # ============================================================================= - # ScoreSet READ permissions - Private - PermissionTest( - "ScoreSet", - "private", - "admin", - Action.READ, - True, - description="Admin can read private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "owner", - Action.READ, - True, - description="Owner can read their private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "contributor", - Action.READ, - True, - description="Contributor can read private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "other_user", - Action.READ, - False, - 404, - "Other user gets 404 for private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "anonymous", - Action.READ, - False, - 404, - "Anonymous gets 404 for private score set", - ), - # ScoreSet READ permissions - Published - PermissionTest( - "ScoreSet", - "published", - "admin", - Action.READ, - True, - description="Admin can read published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "owner", - Action.READ, - True, - description="Owner can read their published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "contributor", - Action.READ, - True, - description="Contributor can read published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "other_user", - Action.READ, - True, - description="Other user can read published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "anonymous", - Action.READ, - True, - description="Anonymous can read published score set", - ), - # ScoreSet UPDATE permissions - Private - PermissionTest( - "ScoreSet", - "private", - "admin", - Action.UPDATE, - True, - description="Admin can update private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "owner", - Action.UPDATE, - True, - description="Owner can update their private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "contributor", - Action.UPDATE, - True, - description="Contributor can update private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "other_user", - Action.UPDATE, - False, - 404, - "Other user gets 404 for private score set update", - ), - PermissionTest( - "ScoreSet", - "private", - "anonymous", - Action.UPDATE, - False, - 404, - "Anonymous gets 404 for private score set update", - ), - # ScoreSet UPDATE permissions - Published - PermissionTest( - "ScoreSet", - "published", - "admin", - Action.UPDATE, - True, - description="Admin can update published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "owner", - Action.UPDATE, - True, - description="Owner can update their published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "contributor", - Action.UPDATE, - True, - description="Contributor can update published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "other_user", - Action.UPDATE, - False, - 403, - "Other user cannot update published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "anonymous", - Action.UPDATE, - False, - 401, - "Anonymous cannot update published score set", - ), - # ScoreSet DELETE permissions - Private (unpublished) - PermissionTest( - "ScoreSet", - "private", - "admin", - Action.DELETE, - True, - description="Admin can delete private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "owner", - Action.DELETE, - True, - description="Owner can delete unpublished score set", - ), - PermissionTest( - "ScoreSet", - "private", - "contributor", - Action.DELETE, - True, - description="Contributor can delete unpublished score set", - ), - PermissionTest( - "ScoreSet", - "private", - "other_user", - Action.DELETE, - False, - 404, - "Other user gets 404 for private score set delete", - ), - PermissionTest( - "ScoreSet", - "private", - "anonymous", - Action.DELETE, - False, - 404, - "Anonymous gets 404 for private score set delete", - ), - # ScoreSet DELETE permissions - Published - PermissionTest( - "ScoreSet", - "published", - "admin", - Action.DELETE, - True, - description="Admin can delete published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "owner", - Action.DELETE, - False, - 403, - "Owner cannot delete published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Contributor cannot delete published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "other_user", - Action.DELETE, - False, - 403, - "Other user gets 403 for published score set delete", - ), - PermissionTest( - "ScoreSet", - "published", - "anonymous", - Action.DELETE, - False, - 403, - "Anonymous gets 403 for published score set delete", - ), - # ScoreSet PUBLISH permissions - Private - PermissionTest( - "ScoreSet", - "private", - "admin", - Action.PUBLISH, - True, - description="Admin can publish private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "owner", - Action.PUBLISH, - True, - description="Owner can publish their private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "contributor", - Action.PUBLISH, - True, - description="Contributor can publish private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "other_user", - Action.PUBLISH, - False, - 404, - "Other user gets 404 for private score set publish", - ), - PermissionTest( - "ScoreSet", - "private", - "anonymous", - Action.PUBLISH, - False, - 404, - "Anonymous gets 404 for private score set publish", - ), - # ScoreSet SET_SCORES permissions - Private - PermissionTest( - "ScoreSet", - "private", - "admin", - Action.SET_SCORES, - True, - description="Admin can set scores on private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "owner", - Action.SET_SCORES, - True, - description="Owner can set scores on their private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "contributor", - Action.SET_SCORES, - True, - description="Contributor can set scores on private score set", - ), - PermissionTest( - "ScoreSet", - "private", - "other_user", - Action.SET_SCORES, - False, - 404, - "Other user gets 404 for private score set scores", - ), - PermissionTest( - "ScoreSet", - "private", - "anonymous", - Action.SET_SCORES, - False, - 404, - "Anonymous gets 404 for private score set scores", - ), - # ScoreSet SET_SCORES permissions - Published - PermissionTest( - "ScoreSet", - "published", - "admin", - Action.SET_SCORES, - True, - description="Admin can set scores on published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "owner", - Action.SET_SCORES, - True, - description="Owner can set scores on their published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "contributor", - Action.SET_SCORES, - True, - description="Contributor can set scores on published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "other_user", - Action.SET_SCORES, - False, - 403, - "Other user cannot set scores on published score set", - ), - PermissionTest( - "ScoreSet", - "published", - "anonymous", - Action.SET_SCORES, - False, - 403, - "Anonymous cannot set scores on score set", - ), - # ============================================================================= - # COLLECTION PERMISSIONS - # ============================================================================= - # Collection READ permissions - Private - PermissionTest( - "Collection", - "private", - "admin", - Action.READ, - True, - description="Admin can read private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.READ, - True, - description="Owner can read their private collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.READ, - True, - description="Collection admin can read private collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.READ, - True, - description="Collection editor can read private collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.READ, - True, - description="Collection viewer can read private collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.READ, - False, - 404, - "Other user gets 404 for private collection", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.READ, - False, - 404, - "Anonymous gets 404 for private collection", - ), - # Collection READ permissions - Published - PermissionTest( - "Collection", - "published", - "admin", - Action.READ, - True, - description="Admin can read published collection", - ), - PermissionTest( - "Collection", - "published", - "owner", - Action.READ, - True, - description="Owner can read their published collection", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.READ, - True, - description="Collection admin can read published collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.READ, - True, - description="Collection editor can read published collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.READ, - True, - description="Collection viewer can read published collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.READ, - True, - description="Other user can read published collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.READ, - True, - description="Anonymous can read published collection", - ), - # Collection UPDATE permissions - Private - PermissionTest( - "Collection", - "private", - "admin", - Action.UPDATE, - True, - description="Admin can update private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.UPDATE, - True, - description="Owner can update their private collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.UPDATE, - True, - description="Collection admin can update private collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.UPDATE, - True, - description="Collection editor can update private collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.UPDATE, - False, - 403, - "Collection viewer cannot update private collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.UPDATE, - False, - 404, - "Other user gets 404 for private collection update", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.UPDATE, - False, - 404, - "Anonymous gets 404 for private collection update", - ), - # Collection UPDATE permissions - Published - PermissionTest( - "Collection", - "published", - "admin", - Action.UPDATE, - True, - description="Admin can update published collection", - ), - PermissionTest( - "Collection", - "published", - "owner", - Action.UPDATE, - True, - description="Owner can update their published collection", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.UPDATE, - True, - description="Collection admin can update published collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.UPDATE, - True, - description="Collection editor can update published collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.UPDATE, - False, - 403, - "Collection viewer cannot update published collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.UPDATE, - False, - 403, - "Other user cannot update published collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.UPDATE, - False, - 401, - "Anonymous cannot update published collection", - ), - # Collection DELETE permissions - Private - PermissionTest( - "Collection", - "private", - "admin", - Action.DELETE, - True, - description="Admin can delete private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.DELETE, - True, - description="Owner can delete unpublished collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.DELETE, - False, - 403, - "Collection admin cannot delete private collection (only owner can)", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.DELETE, - False, - 403, - "Collection editor cannot delete private collection (only owner can)", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.DELETE, - False, - 403, - "Collection viewer cannot delete private collection (only owner can)", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.DELETE, - False, - 404, - "Other user gets 404 for private collection delete", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.DELETE, - False, - 404, - "Anonymous gets 404 for private collection delete", - ), - # Collection DELETE permissions - Published - PermissionTest( - "Collection", - "published", - "admin", - Action.DELETE, - True, - description="Admin can delete published collection", - ), - # TODO: only admins can delete collections with badges - PermissionTest( - "Collection", - "published", - "owner", - Action.DELETE, - True, - description="Owner can delete published collection w/o badges", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Collection admin cannot delete published collection (only MaveDB admin can)", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Collection editor cannot delete published collection (only MaveDB admin can)", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Collection viewer cannot delete published collection (only MaveDB admin can)", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.DELETE, - False, - 403, - "Other user cannot delete published collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.DELETE, - False, - 403, - "Anonymous cannot delete published collection", - ), - # Collection PUBLISH permissions - Private - PermissionTest( - "Collection", - "private", - "admin", - Action.PUBLISH, - True, - description="MaveDB admin can publish private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.PUBLISH, - True, - description="Owner can publish their collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.PUBLISH, - True, - description="Collection admin can publish collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.PUBLISH, - False, - 403, - "Collection editor cannot publish collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.PUBLISH, - False, - 403, - "Collection viewer cannot publish collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.PUBLISH, - False, - 404, - "Other user gets 404 for private collection publish", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.PUBLISH, - False, - 404, - "Anonymous gets 404 for private collection publish", - ), - # Collection ADD_EXPERIMENT permissions - Private (Collections add experiments, not experiment sets) - PermissionTest( - "Collection", - "private", - "admin", - Action.ADD_EXPERIMENT, - True, - description="Admin can add experiment to private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.ADD_EXPERIMENT, - True, - description="Owner can add experiment to their collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_EXPERIMENT, - True, - description="Collection admin can add experiment to collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_EXPERIMENT, - True, - description="Collection editor can add experiment to collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_EXPERIMENT, - False, - 403, - "Collection viewer cannot add experiment to private collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.ADD_EXPERIMENT, - False, - 404, - "Other user gets 404 for private collection", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.ADD_EXPERIMENT, - False, - 404, - "Anonymous gets 404 for private collection", - ), - # Collection ADD_EXPERIMENT permissions - Published - PermissionTest( - "Collection", - "published", - "admin", - Action.ADD_EXPERIMENT, - True, - description="Admin can add experiment to published collection", - ), - PermissionTest( - "Collection", - "published", - "owner", - Action.ADD_EXPERIMENT, - True, - description="Owner can add experiment to their published collection", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_EXPERIMENT, - True, - description="Collection admin can add experiment to published collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_EXPERIMENT, - True, - description="Collection editor can add experiment to published collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_EXPERIMENT, - False, - 403, - "Collection viewer cannot add experiment to published collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.ADD_EXPERIMENT, - False, - 403, - "Other user cannot add to published collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.ADD_EXPERIMENT, - False, - 403, - "Anonymous cannot add to collection", - ), - # Collection ADD_SCORE_SET permissions - Private - PermissionTest( - "Collection", - "private", - "admin", - Action.ADD_SCORE_SET, - True, - description="Admin can add score set to private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.ADD_SCORE_SET, - True, - description="Owner can add score set to their private collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_SCORE_SET, - True, - description="Collection admin can add score set to private collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_SCORE_SET, - True, - description="Collection editor can add score set to private collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_SCORE_SET, - False, - 403, - "Collection viewer cannot add score set to private collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.ADD_SCORE_SET, - False, - 404, - "Other user gets 404 for private collection", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.ADD_SCORE_SET, - False, - 404, - "Anonymous gets 404 for private collection", - ), - # Collection ADD_SCORE_SET permissions - Published - PermissionTest( - "Collection", - "published", - "admin", - Action.ADD_SCORE_SET, - True, - description="Admin can add score set to published collection", - ), - PermissionTest( - "Collection", - "published", - "owner", - Action.ADD_SCORE_SET, - True, - description="Owner can add score set to their published collection", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_SCORE_SET, - True, - description="Collection admin can add score set to published collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_SCORE_SET, - True, - description="Collection editor can add score set to published collection", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_SCORE_SET, - False, - 403, - "Collection viewer cannot add score set to published collection", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.ADD_SCORE_SET, - False, - 403, - "Other user cannot add score set to published collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.ADD_SCORE_SET, - False, - 403, - "Anonymous cannot add score set to collection", - ), - # Collection ADD_ROLE permissions - PermissionTest( - "Collection", - "private", - "admin", - Action.ADD_ROLE, - True, - description="Admin can add roles to private collection", - ), - PermissionTest( - "Collection", - "private", - "owner", - Action.ADD_ROLE, - True, - description="Owner can add roles to their collection", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_ROLE, - True, - description="Collection admin can add roles to collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_ROLE, - False, - 403, - "Collection editor cannot add roles to collection (only admin can)", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "private", - "contributor", - Action.ADD_ROLE, - False, - 403, - "Collection viewer cannot add roles to collection (only admin can)", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "private", - "other_user", - Action.ADD_ROLE, - False, - 404, - "Other user gets 404 for private collection", - ), - PermissionTest( - "Collection", - "private", - "anonymous", - Action.ADD_ROLE, - False, - 404, - "Anonymous gets 404 for private collection", - ), - # Collection ADD_ROLE permissions - Published - PermissionTest( - "Collection", - "published", - "admin", - Action.ADD_ROLE, - True, - description="Admin can add roles to published collection", - ), - PermissionTest( - "Collection", - "published", - "owner", - Action.ADD_ROLE, - True, - description="Owner can add roles to their published collection", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_ROLE, - True, - description="Collection admin can add roles to published collection", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_ROLE, - False, - 403, - "Collection editor cannot add roles to published collection (only admin can)", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_ROLE, - False, - 403, - "Collection viewer cannot add roles to published collection (only admin can)", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.ADD_ROLE, - False, - 403, - "Other user cannot add roles to published collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.ADD_ROLE, - False, - 403, - "Anonymous cannot add roles to published collection", - ), - # Collection ADD_BADGE permissions (only admin) - PermissionTest( - "Collection", - "published", - "admin", - Action.ADD_BADGE, - True, - description="Admin can add badge to published collection", - ), - PermissionTest( - "Collection", - "published", - "owner", - Action.ADD_BADGE, - False, - 403, - "Owner cannot add badge to collection", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_BADGE, - False, - 403, - "Collection admin cannot add badge to collection (only MaveDB admin can)", - collection_role="collection_admin", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_BADGE, - False, - 403, - "Collection editor cannot add badge to collection (only MaveDB admin can)", - collection_role="collection_editor", - ), - PermissionTest( - "Collection", - "published", - "contributor", - Action.ADD_BADGE, - False, - 403, - "Collection viewer cannot add badge to collection (only MaveDB admin can)", - collection_role="collection_viewer", - ), - PermissionTest( - "Collection", - "published", - "other_user", - Action.ADD_BADGE, - False, - 403, - "Other user cannot add badge to collection", - ), - PermissionTest( - "Collection", - "published", - "anonymous", - Action.ADD_BADGE, - False, - 401, - "Anonymous cannot add badge to collection", - ), - # ============================================================================= - # USER PERMISSIONS - # ============================================================================= - # User READ permissions (accessing user profiles) - PermissionTest( - "User", - None, - "admin", - Action.READ, - True, - description="Admin can read any user profile", - ), - PermissionTest( - "User", - None, - "self", - Action.READ, - True, - description="User can read their own profile", - ), - PermissionTest( - "User", - None, - "other_user", - Action.READ, - False, - 403, - description="Users cannot read other user profiles", - ), - PermissionTest( - "User", - None, - "anonymous", - Action.READ, - False, - 401, - description="Anonymous cannot read user profiles", - ), - # User UPDATE permissions - PermissionTest( - "User", - None, - "admin", - Action.UPDATE, - True, - description="Admin can update any user profile", - ), - PermissionTest( - "User", - None, - "self", - Action.UPDATE, - True, - description="User can update their own profile", - ), - PermissionTest( - "User", - None, - "other_user", - Action.UPDATE, - False, - 403, - "User cannot update other user profiles", - ), - PermissionTest( - "User", - None, - "anonymous", - Action.UPDATE, - False, - 401, - "Anonymous cannot update user profiles", - ), - # User DELETE permissions - not implemented - # User LOOKUP permissions (for search/autocomplete) - PermissionTest( - "User", - None, - "admin", - Action.LOOKUP, - True, - description="Admin can lookup users", - ), - PermissionTest( - "User", - None, - "owner", - Action.LOOKUP, - True, - description="User can lookup other users", - ), - PermissionTest( - "User", - None, - "contributor", - Action.LOOKUP, - True, - description="User can lookup other users", - ), - PermissionTest( - "User", - None, - "other_user", - Action.LOOKUP, - True, - description="User can lookup other users", - ), - PermissionTest( - "User", - None, - "anonymous", - Action.LOOKUP, - False, - 401, - "Anonymous cannot lookup users", - ), - # User ADD_ROLE permissions - PermissionTest( - "User", - None, - "admin", - Action.ADD_ROLE, - True, - description="Admin can add roles to users", - ), - PermissionTest( - "User", - None, - "self", - Action.ADD_ROLE, - False, - 403, - "User cannot add roles to themselves", - ), - PermissionTest( - "User", - None, - "other_user", - Action.ADD_ROLE, - False, - 403, - "User cannot add roles to others", - ), - PermissionTest( - "User", - None, - "anonymous", - Action.ADD_ROLE, - False, - 401, - "Anonymous cannot add roles", - ), - # ============================================================================= - # SCORE CALIBRATION PERMISSIONS - # ============================================================================= - # ScoreCalibration READ permissions - Private, Investigator Provided - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.READ, - True, - description="Admin can read private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.READ, - True, - description="Owner can read their private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "contributor", - Action.READ, - True, - description="Contributor can read private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.READ, - False, - 404, - "Other user gets 404 for private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.READ, - False, - 404, - "Anonymous gets 404 for private investigator calibration", - investigator_provided=True, - ), - # ScoreCalibration UPDATE permissions - Private, Investigator Provided (follows score set model) - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.UPDATE, - True, - description="Admin can update private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.UPDATE, - True, - description="Owner can update their private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "contributor", - Action.UPDATE, - True, - description="Contributor can update private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.UPDATE, - False, - 404, - "Other user gets 404 for private investigator calibration update", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.UPDATE, - False, - 404, - "Anonymous gets 404 for private investigator calibration update", - investigator_provided=True, - ), - # ScoreCalibration READ permissions - Private, Community Provided - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.READ, - True, - description="Admin can read private community calibration", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.READ, - True, - description="Owner can read their private community calibration", - investigator_provided=False, - ), - # NOTE: Contributors do not exist for community-provided calibrations - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.READ, - False, - 404, - "Other user gets 404 for private community calibration", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.READ, - False, - 404, - "Anonymous gets 404 for private community calibration", - investigator_provided=False, - ), - # ScoreCalibration UPDATE permissions - Private, Community Provided (only owner can edit) - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.UPDATE, - True, - description="Admin can update private community calibration", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.UPDATE, - True, - description="Owner can update their private community calibration", - investigator_provided=False, - ), - # NOTE: Contributors do not exist for community-provided calibrations - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.UPDATE, - False, - 404, - "Other user gets 404 for private community calibration update", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.UPDATE, - False, - 404, - "Anonymous gets 404 for private community calibration update", - investigator_provided=False, - ), - # ScoreCalibration PUBLISH permissions - Private - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.PUBLISH, - True, - description="Admin can publish private calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.PUBLISH, - True, - description="Owner can publish their private calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "contributor", - Action.PUBLISH, - True, - description="Contributor can publish private investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.PUBLISH, - False, - 404, - "Other user gets 404 for private calibration publish", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.PUBLISH, - False, - 404, - "Anonymous gets 404 for private calibration publish", - investigator_provided=True, - ), - # ScoreCalibration CHANGE_RANK permissions - Private - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.CHANGE_RANK, - True, - description="Admin can change calibration rank", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.CHANGE_RANK, - True, - description="Owner can change their calibration rank", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "contributor", - Action.CHANGE_RANK, - True, - description="Contributor can change investigator calibration rank", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.CHANGE_RANK, - False, - 404, - "Other user gets 404 for private calibration rank change", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.CHANGE_RANK, - False, - 404, - "Anonymous gets 404 for private calibration rank change", - investigator_provided=True, - ), - # ScoreCalibration DELETE permissions - Private (investigator-provided) - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.DELETE, - True, - description="Admin can delete private calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.DELETE, - True, - description="Owner can delete unpublished calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "contributor", - Action.DELETE, - True, - description="Contributor can delete unpublished investigator calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.DELETE, - False, - 404, - "Other user gets 404 for private calibration delete", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.DELETE, - False, - 404, - "Anonymous gets 404 for private calibration delete", - investigator_provided=True, - ), - # ScoreCalibration DELETE permissions - Published (investigator-provided) - PermissionTest( - "ScoreCalibration", - "published", - "admin", - Action.DELETE, - True, - description="Admin can delete published calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "published", - "owner", - Action.DELETE, - False, - 403, - "Owner cannot delete published calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "published", - "contributor", - Action.DELETE, - False, - 403, - "Contributor cannot delete published calibration", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "published", - "other_user", - Action.DELETE, - False, - 403, - "Other user gets 403 for published calibration delete", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "published", - "anonymous", - Action.DELETE, - False, - 401, - "Anonymous gets 401 for published calibration delete", - investigator_provided=True, - ), - # ScoreCalibration DELETE permissions - Private (community-provided) - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.DELETE, - True, - description="Admin can delete private community calibration", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "private", - "owner", - Action.DELETE, - True, - description="Owner can delete unpublished community calibration", - investigator_provided=False, - ), - # NOTE: Contributors do not exist for community-provided calibrations - PermissionTest( - "ScoreCalibration", - "private", - "other_user", - Action.DELETE, - False, - 404, - "Other user gets 404 for private community calibration delete", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "private", - "anonymous", - Action.DELETE, - False, - 404, - "Anonymous gets 404 for private community calibration delete", - investigator_provided=False, - ), - # ScoreCalibration DELETE permissions - Published (community-provided) - PermissionTest( - "ScoreCalibration", - "published", - "admin", - Action.DELETE, - True, - description="Admin can delete published community calibration", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "published", - "owner", - Action.DELETE, - False, - 403, - "Owner cannot delete published community calibration", - investigator_provided=False, - ), - # NOTE: Contributors do not exist for community-provided calibrations - PermissionTest( - "ScoreCalibration", - "published", - "other_user", - Action.DELETE, - False, - 403, - "Other user gets 403 for published community calibration delete", - investigator_provided=False, - ), - PermissionTest( - "ScoreCalibration", - "published", - "anonymous", - Action.DELETE, - False, - 401, - "Anonymous gets 401 for published community calibration delete", - investigator_provided=False, - ), - # =========================== - # NotImplementedError Test Cases - # =========================== - # These test cases expect NotImplementedError for unsupported action/entity combinations - # ExperimentSet unsupported actions - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.PUBLISH, - "NotImplementedError", - description="ExperimentSet PUBLISH not implemented", - ), - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.SET_SCORES, - "NotImplementedError", - description="ExperimentSet SET_SCORES not implemented", - ), - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.ADD_ROLE, - "NotImplementedError", - description="ExperimentSet ADD_ROLE not implemented", - ), - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.ADD_BADGE, - "NotImplementedError", - description="ExperimentSet ADD_BADGE not implemented", - ), - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.CHANGE_RANK, - "NotImplementedError", - description="ExperimentSet CHANGE_RANK not implemented", - ), - PermissionTest( - "ExperimentSet", - "private", - "admin", - Action.LOOKUP, - "NotImplementedError", - description="ExperimentSet LOOKUP not implemented", - ), - # Experiment unsupported actions - PermissionTest( - "Experiment", - "private", - "admin", - Action.PUBLISH, - "NotImplementedError", - description="Experiment PUBLISH not implemented", - ), - PermissionTest( - "Experiment", - "private", - "admin", - Action.SET_SCORES, - "NotImplementedError", - description="Experiment SET_SCORES not implemented", - ), - PermissionTest( - "Experiment", - "private", - "admin", - Action.ADD_ROLE, - "NotImplementedError", - description="Experiment ADD_ROLE not implemented", - ), - PermissionTest( - "Experiment", - "private", - "admin", - Action.ADD_BADGE, - "NotImplementedError", - description="Experiment ADD_BADGE not implemented", - ), - PermissionTest( - "Experiment", - "private", - "admin", - Action.CHANGE_RANK, - "NotImplementedError", - description="Experiment CHANGE_RANK not implemented", - ), - PermissionTest( - "Experiment", - "private", - "admin", - Action.LOOKUP, - "NotImplementedError", - description="Experiment LOOKUP not implemented", - ), - # Collection unsupported actions - PermissionTest( - "Collection", - "private", - "admin", - Action.LOOKUP, - "NotImplementedError", - description="Collection LOOKUP not implemented", - ), - # ScoreCalibration unsupported actions - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.ADD_EXPERIMENT, - "NotImplementedError", - description="ScoreCalibration ADD_EXPERIMENT not implemented", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.ADD_SCORE_SET, - "NotImplementedError", - description="ScoreCalibration ADD_SCORE_SET not implemented", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.ADD_ROLE, - "NotImplementedError", - description="ScoreCalibration ADD_ROLE not implemented", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.ADD_BADGE, - "NotImplementedError", - description="ScoreCalibration ADD_BADGE not implemented", - investigator_provided=True, - ), - PermissionTest( - "ScoreCalibration", - "private", - "admin", - Action.LOOKUP, - "NotImplementedError", - description="ScoreCalibration LOOKUP not implemented", - investigator_provided=True, - ), - # User unsupported actions - PermissionTest( - "User", - None, - "admin", - Action.DELETE, - "NotImplementedError", - description="User DELETE not implemented", - ), - PermissionTest( - "User", - None, - "admin", - Action.PUBLISH, - "NotImplementedError", - description="User PUBLISH not implemented", - ), - PermissionTest( - "User", - None, - "admin", - Action.SET_SCORES, - "NotImplementedError", - description="User SET_SCORES not implemented", - ), - PermissionTest( - "User", - None, - "admin", - Action.ADD_SCORE_SET, - "NotImplementedError", - description="User ADD_SCORE_SET not implemented", - ), - PermissionTest( - "User", - None, - "admin", - Action.ADD_EXPERIMENT, - "NotImplementedError", - description="User ADD_EXPERIMENT not implemented", - ), - PermissionTest( - "User", - None, - "admin", - Action.ADD_BADGE, - "NotImplementedError", - description="User ADD_BADGE not implemented", - ), - PermissionTest( - "User", - None, - "admin", - Action.CHANGE_RANK, - "NotImplementedError", - description="User CHANGE_RANK not implemented", - ), - ] - - -class EntityTestHelper: - """Helper methods for creating test entities with specific states.""" - - @staticmethod - def create_score_set(owner_id: int = 2, contributors: list = []) -> ScoreSet: - """Create a private ScoreSet for testing.""" - score_set = Mock(spec=ScoreSet) - score_set.urn = "urn:mavedb:00000001-a-1" - score_set.created_by_id = owner_id - score_set.contributors = [Mock(orcid_id=c) for c in contributors] - return score_set - - @staticmethod - def create_experiment(owner_id: int = 2, contributors: list = []) -> Experiment: - """Create a private Experiment for testing.""" - experiment = Mock(spec=Experiment) - experiment.urn = "urn:mavedb:00000001-a" - experiment.created_by_id = owner_id - experiment.contributors = [Mock(orcid_id=c) for c in contributors] - return experiment - - @staticmethod - def create_investigator_calibration(owner_id: int = 2, contributors: list = []): - """Create an investigator-provided score calibration for testing.""" - calibration = Mock(spec=ScoreCalibration) - calibration.id = 1 - calibration.created_by_id = owner_id - calibration.investigator_provided = True - calibration.score_set = EntityTestHelper.create_score_set(owner_id, contributors) - return calibration - - @staticmethod - def create_community_calibration(owner_id: int = 2, contributors: list = []): - """Create a community-provided score calibration for testing.""" - calibration = Mock(spec=ScoreCalibration) - calibration.id = 1 - calibration.created_by_id = owner_id - calibration.investigator_provided = False - calibration.score_set = EntityTestHelper.create_score_set(owner_id, contributors) - return calibration - - @staticmethod - def create_experiment_set(owner_id: int = 2, contributors: list = []) -> ExperimentSet: - """Create an ExperimentSet for testing in the specified state.""" - exp_set = Mock(spec=ExperimentSet) - exp_set.urn = "urn:mavedb:00000001" - exp_set.created_by_id = owner_id - exp_set.contributors = [Mock(orcid_id=c) for c in contributors] - return exp_set - - @staticmethod - def create_collection( - owner_id: int = 2, - user_role: Optional[str] = None, - user_id: int = 3, - ) -> Collection: - """Create a Collection for testing.""" - collection = Mock(spec=Collection) - collection.urn = "urn:mavedb:col000001" - collection.created_by_id = owner_id - collection.badge_name = None # Not an official collection by default - - # Create user_associations for Collection permissions - user_associations = [] - - # Add owner as admin (unless owner_id is the same as user_id with a specific role) - if not (user_role and user_id == owner_id): - owner_assoc = Mock(spec=CollectionUserAssociation) - owner_assoc.user_id = owner_id - owner_assoc.contribution_role = ContributionRole.admin - user_associations.append(owner_assoc) - - # Add specific role if requested - if user_role: - role_mapping = { - "collection_admin": ContributionRole.admin, - "collection_editor": ContributionRole.editor, - "collection_viewer": ContributionRole.viewer, - } - if user_role in role_mapping: - user_assoc = Mock(spec=CollectionUserAssociation) - user_assoc.user_id = user_id - user_assoc.contribution_role = role_mapping[user_role] - user_associations.append(user_assoc) - - collection.user_associations = user_associations - return collection - - @staticmethod - def create_user(user_id: int = 3) -> User: - """Create a User for testing.""" - user = Mock(spec=User) - user.id = user_id - user.username = "3333-3333-3333-333X" - user.email = "target@example.com" - user.first_name = "Target" - user.last_name = "User" - user.is_active = True - return user - - -class TestPermissions: - """Test all permission scenarios.""" - - def setup_method(self): - """Set up test data for each test method.""" - self.users = { - "admin": Mock(user=Mock(id=1, username="1111-1111-1111-111X"), active_roles=[UserRole.admin]), - "owner": Mock(user=Mock(id=2, username="2222-2222-2222-222X"), active_roles=[]), - "contributor": Mock(user=Mock(id=3, username="3333-3333-3333-333X"), active_roles=[]), - "other_user": Mock(user=Mock(id=4, username="4444-4444-4444-444X"), active_roles=[]), - "self": Mock( # For User entity tests where user is checking themselves - user=Mock(id=3, username="3333-3333-3333-333X"), active_roles=[] - ), - "anonymous": None, - } - - @pytest.mark.parametrize( - "test_case", - PermissionTestData.get_all_permission_tests(), - ids=lambda tc: f"{tc.entity_type}_{tc.entity_state or 'no_state'}_{tc.user_type}_{tc.action.value}", - ) - def test_permission(self, test_case: PermissionTest): - """Test a single permission scenario.""" - # Handle NotImplementedError test cases - if test_case.should_be_permitted == "NotImplementedError": - with pytest.raises(NotImplementedError): - entity = self._create_entity(test_case) - user_data = self.users[test_case.user_type] - has_permission(user_data, entity, test_case.action) - return - - # Arrange - Create entity for normal test cases - entity = self._create_entity(test_case) - user_data = self.users[test_case.user_type] - - # Act - result = has_permission(user_data, entity, test_case.action) - - # Assert - assert result.permitted == test_case.should_be_permitted, ( - f"Expected {test_case.should_be_permitted} but got {result.permitted}. " - f"Description: {test_case.description}" - ) - - if not test_case.should_be_permitted and test_case.expected_code: - assert ( - result.http_code == test_case.expected_code - ), f"Expected error code {test_case.expected_code} but got {result.http_code}" - - def _create_entity(self, test_case: PermissionTest): - """Create an entity based on the test case specification.""" - # For most entities, contributors are ORCiDs. For Collections, handle roles differently. - if test_case.entity_type == "Collection": - # Collection uses specific role-based permissions - contributors = [] # Don't use ORCiD contributors for Collections - else: - contributors = ["3333-3333-3333-333X"] if test_case.user_type == "contributor" else [] - - if test_case.entity_type == "ScoreSet": - entity = EntityTestHelper.create_score_set(owner_id=2, contributors=contributors) - entity.private = test_case.entity_state == "private" - entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None - return entity - - elif test_case.entity_type == "Experiment": - entity = EntityTestHelper.create_experiment(owner_id=2, contributors=contributors) - entity.private = test_case.entity_state == "private" - entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None - return entity - - elif test_case.entity_type == "ScoreCalibration": - if test_case.investigator_provided is True: - entity = EntityTestHelper.create_investigator_calibration(owner_id=2, contributors=contributors) - entity.private = test_case.entity_state == "private" - entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None - return entity - elif test_case.investigator_provided is False: - entity = EntityTestHelper.create_community_calibration(owner_id=2, contributors=contributors) - entity.private = test_case.entity_state == "private" - entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None - return entity - - elif test_case.entity_type == "ExperimentSet": - entity = EntityTestHelper.create_experiment_set(owner_id=2, contributors=contributors) - entity.private = test_case.entity_state == "private" - entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None - return entity - - elif test_case.entity_type == "Collection": - entity = EntityTestHelper.create_collection( - owner_id=2, - user_role=test_case.collection_role, - user_id=3, # User ID for the collection contributor role - ) - entity.private = test_case.entity_state == "private" - entity.published_date = "2023-01-01" if test_case.entity_state == "published" else None - return entity - - elif test_case.entity_type == "User": - # For User tests, create a target user (id=3) that will be acted upon - return EntityTestHelper.create_user(user_id=3) - - raise ValueError(f"Unknown entity type/state: {test_case.entity_type}/{test_case.entity_state}") - - -class TestPermissionResponse: - """Test PermissionResponse class functionality.""" - - def test_permission_response_permitted(self): - """Test PermissionResponse when permission is granted.""" - response = PermissionResponse(True) - - assert response.permitted is True - assert response.http_code is None - assert response.message is None - - def test_permission_response_denied_default_code(self): - """Test PermissionResponse when permission is denied with default error code.""" - response = PermissionResponse(False) - - assert response.permitted is False - assert response.http_code == 403 - assert response.message is None - - def test_permission_response_denied_custom_code_and_message(self): - """Test PermissionResponse when permission is denied with custom error code and message.""" - response = PermissionResponse(False, 404, "Resource not found") - - assert response.permitted is False - assert response.http_code == 404 - assert response.message == "Resource not found" - - -class TestPermissionException: - """Test PermissionException class functionality.""" - - def test_permission_exception_creation(self): - """Test PermissionException creation and properties.""" - exception = PermissionException(403, "Insufficient permissions") - - assert exception.http_code == 403 - assert exception.message == "Insufficient permissions" - - -class TestRolesPermitted: - """Test roles_permitted function functionality.""" - - def test_roles_permitted_with_matching_role(self): - """Test roles_permitted when user has a matching role.""" - - user_roles = [UserRole.admin, UserRole.mapper] - permitted_roles = [UserRole.admin] - - result = roles_permitted(user_roles, permitted_roles) - assert result is True - - def test_roles_permitted_with_no_matching_role(self): - """Test roles_permitted when user has no matching roles.""" - - user_roles = [UserRole.mapper] - permitted_roles = [UserRole.admin] - - result = roles_permitted(user_roles, permitted_roles) - assert result is False - - def test_roles_permitted_with_empty_user_roles(self): - """Test roles_permitted when user has no roles.""" - - user_roles = [] - permitted_roles = [UserRole.admin] - - result = roles_permitted(user_roles, permitted_roles) - assert result is False - - -class TestAssertPermission: - """Test assert_permission function functionality.""" - - def test_assert_permission_when_permitted(self): - """Test assert_permission when permission is granted.""" - - admin_data = Mock(user=Mock(id=1, username="1111-1111-1111-111X"), active_roles=[UserRole.admin]) - - # Create a private score set that admin should have access to - score_set = EntityTestHelper.create_score_set() - - # Should not raise exception - result = assert_permission(admin_data, score_set, Action.READ) - assert result.permitted is True - - def test_assert_permission_when_denied(self): - """Test assert_permission when permission is denied - should raise PermissionException.""" - - other_user_data = Mock(user=Mock(id=4, username="4444-4444-4444-444X"), active_roles=[]) - - # Create a private score set that other user should not have access to - score_set = EntityTestHelper.create_score_set() - - # Should raise PermissionException - with pytest.raises(PermissionException) as exc_info: - assert_permission(other_user_data, score_set, Action.READ) - - assert exc_info.value.http_code == 404 - assert "not found" in exc_info.value.message From 746be94360231ff784bdb0c85208a680b833785b Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 4 Dec 2025 10:57:02 -0800 Subject: [PATCH 11/18] refactor: Move EntityType definition to types --- src/mavedb/lib/permissions/core.py | 13 ++----------- src/mavedb/lib/types/permissions.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 src/mavedb/lib/types/permissions.py diff --git a/src/mavedb/lib/permissions/core.py b/src/mavedb/lib/permissions/core.py index e58a03197..c14190ea3 100644 --- a/src/mavedb/lib/permissions/core.py +++ b/src/mavedb/lib/permissions/core.py @@ -1,10 +1,11 @@ -from typing import Any, Callable, Optional, Union +from typing import Any, Callable, Optional from mavedb.lib.authentication import UserData from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.exceptions import PermissionException from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.types.permissions import EntityType from mavedb.models.collection import Collection from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet @@ -22,16 +23,6 @@ user, ) -# Define the supported entity types -EntityType = Union[ - Collection, - Experiment, - ExperimentSet, - ScoreCalibration, - ScoreSet, - User, -] - def has_permission(user_data: Optional[UserData], entity: EntityType, action: Action) -> PermissionResponse: """ diff --git a/src/mavedb/lib/types/permissions.py b/src/mavedb/lib/types/permissions.py new file mode 100644 index 000000000..aa9628c75 --- /dev/null +++ b/src/mavedb/lib/types/permissions.py @@ -0,0 +1,18 @@ +from typing import Union + +from mavedb.models.collection import Collection +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + +# Define the supported entity types +EntityType = Union[ + Collection, + Experiment, + ExperimentSet, + ScoreCalibration, + ScoreSet, + User, +] From 7fef9acfd65d1277146c7161eddbf3211288c333 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 4 Dec 2025 14:10:46 -0800 Subject: [PATCH 12/18] fix: fetch_score_set_by_urn permission filtering was effecting calibration permissions When fetching score sets via this method, score calibration relationships were being unset in an unsafe manner. Because of this, functions in this router were refactored to access score sets directly and load the score set contributors directly when loading calibrations. --- src/mavedb/routers/score_calibrations.py | 107 ++++++++++++++++++----- 1 file changed, 84 insertions(+), 23 deletions(-) diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index daac19502..4ae2a59a9 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -1,31 +1,30 @@ import logging +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from mavedb import deps +from mavedb.lib.authentication import UserData, get_current_user +from mavedb.lib.authorization import require_current_user from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import ( logging_context, save_to_logging_context, ) -from mavedb.lib.authentication import get_current_user, UserData -from mavedb.lib.authorization import require_current_user from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.lib.score_calibrations import ( create_score_calibration_in_score_set, - modify_score_calibration, delete_score_calibration, demote_score_calibration_from_primary, + modify_score_calibration, promote_score_calibration_to_primary, publish_score_calibration, ) from mavedb.models.score_calibration import ScoreCalibration -from mavedb.routers.score_sets import fetch_score_set_by_urn +from mavedb.models.score_set import ScoreSet from mavedb.view_models import score_calibration - logger = logging.getLogger(__name__) router = APIRouter( @@ -52,7 +51,12 @@ def get_score_calibration( """ save_to_logging_context({"requested_resource": urn}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -76,12 +80,23 @@ async def get_score_calibrations_for_score_set( Retrieve all score calibrations for a given score set URN. """ save_to_logging_context({"requested_resource": score_set_urn, "resource_property": "calibrations"}) - score_set = await fetch_score_set_by_urn(db, score_set_urn, user_data, None, False) + score_set = db.query(ScoreSet).filter(ScoreSet.urn == score_set_urn).one_or_none() + + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{score_set_urn}' not found") + + assert_permission(user_data, score_set, Action.READ) + + calibrations = ( + db.query(ScoreCalibration) + .filter(ScoreCalibration.score_set_id == score_set.id) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .all() + ) permitted_calibrations = [ - calibration - for calibration in score_set.score_calibrations - if has_permission(user_data, calibration, Action.READ).permitted + calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted ] if not permitted_calibrations: logger.debug("No score calibrations found for the requested score set", extra=logging_context()) @@ -105,12 +120,23 @@ async def get_primary_score_calibrations_for_score_set( Retrieve the primary score calibration for a given score set URN. """ save_to_logging_context({"requested_resource": score_set_urn, "resource_property": "calibrations"}) - score_set = await fetch_score_set_by_urn(db, score_set_urn, user_data, None, False) + + score_set = db.query(ScoreSet).filter(ScoreSet.urn == score_set_urn).one_or_none() + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{score_set_urn}' not found") + + assert_permission(user_data, score_set, Action.READ) + + calibrations = ( + db.query(ScoreCalibration) + .filter(ScoreCalibration.score_set_id == score_set.id) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .all() + ) permitted_calibrations = [ - calibration - for calibration in score_set.score_calibrations - if has_permission(user_data, calibration, Action.READ) + calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted ] if not permitted_calibrations: logger.debug("No score calibrations found for the requested score set", extra=logging_context()) @@ -155,7 +181,11 @@ async def create_score_calibration_route( save_to_logging_context({"requested_resource": calibration.score_set_urn, "resource_property": "calibrations"}) - score_set = await fetch_score_set_by_urn(db, calibration.score_set_urn, user_data, None, False) + score_set = db.query(ScoreSet).filter(ScoreSet.urn == calibration.score_set_urn).one_or_none() + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{calibration.score_set_urn}' not found") + # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with # permission to update the score set itself. assert_permission(user_data, score_set, Action.UPDATE) @@ -187,13 +217,24 @@ async def modify_score_calibration_route( # If the user supplies a new score_set_urn, validate it exists and the user has permission to use it. if calibration_update.score_set_urn is not None: - score_set = await fetch_score_set_by_urn(db, calibration_update.score_set_urn, user_data, None, False) + score_set = db.query(ScoreSet).filter(ScoreSet.urn == calibration_update.score_set_urn).one_or_none() + + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException( + status_code=404, detail=f"ScoreSet with URN '{calibration_update.score_set_urn}' not found" + ) # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with # permission to update the score set itself. assert_permission(user_data, score_set, Action.UPDATE) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -225,7 +266,12 @@ async def delete_score_calibration_route( """ save_to_logging_context({"requested_resource": urn}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -259,7 +305,12 @@ async def promote_score_calibration_to_primary_route( {"requested_resource": urn, "resource_property": "primary", "demote_existing_primary": demote_existing_primary} ) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -318,7 +369,12 @@ def demote_score_calibration_from_primary_route( """ save_to_logging_context({"requested_resource": urn, "resource_property": "primary"}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -352,7 +408,12 @@ def publish_score_calibration_route( """ save_to_logging_context({"requested_resource": urn, "resource_property": "private"}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") From 236bb14388a016135119c87a6961b85088abfd60 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 4 Dec 2025 14:11:40 -0800 Subject: [PATCH 13/18] refactor: deny helper, permission tests, and error messages for consistency and duplication - Added unified deny action handler for reduced duplication - Removed now unused deny action tests from score calibration, score set, and user permission tests. - Updated error messages in various tests to consistently reference the entity type (e.g., "ScoreCalibration", "ScoreSet", "User") in the detail messages. - Adjusted test assertions to ensure they check for the correct error messages when permissions are insufficient or entities are not found. - Renamed tests to clarify expected outcomes, particularly for contributor permissions. --- src/mavedb/lib/permissions/collection.py | 70 ++++----------- src/mavedb/lib/permissions/experiment.py | 62 +++---------- src/mavedb/lib/permissions/experiment_set.py | 59 ++---------- .../lib/permissions/score_calibration.py | 53 ++--------- src/mavedb/lib/permissions/score_set.py | 63 +++---------- src/mavedb/lib/permissions/user.py | 41 ++------- src/mavedb/lib/permissions/utils.py | 81 ++++++++++++++++- src/mavedb/routers/experiments.py | 2 +- src/mavedb/routers/score_sets.py | 30 +++---- tests/lib/permissions/test_collection.py | 41 --------- tests/lib/permissions/test_experiment.py | 49 +--------- tests/lib/permissions/test_experiment_set.py | 47 ---------- .../lib/permissions/test_score_calibration.py | 47 ---------- tests/lib/permissions/test_score_set.py | 41 --------- tests/lib/permissions/test_user.py | 23 ----- tests/lib/permissions/test_utils.py | 80 ++++++++++++++++- tests/routers/test_collections.py | 15 ++-- tests/routers/test_experiments.py | 28 +++--- tests/routers/test_permissions.py | 37 ++++++-- tests/routers/test_score_calibrations.py | 73 ++++++++------- tests/routers/test_score_set.py | 90 +++++-------------- tests/routers/test_users.py | 2 +- 22 files changed, 354 insertions(+), 680 deletions(-) diff --git a/src/mavedb/lib/permissions/collection.py b/src/mavedb/lib/permissions/collection.py index fb11277ee..8768e72ad 100644 --- a/src/mavedb/lib/permissions/collection.py +++ b/src/mavedb/lib/permissions/collection.py @@ -4,7 +4,7 @@ from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.models import PermissionResponse -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.collection import Collection from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.enums.user_role import UserRole @@ -20,8 +20,8 @@ def has_permission(user_data: Optional[UserData], entity: Collection, action: Ac Args: user_data: The user's authentication data and roles. None for anonymous users. - action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT_SET). entity: The Collection entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT, ADD_SCORE_SET, ADD_ROLE, ADD_BADGE). Returns: PermissionResponse: Contains permission result, HTTP status code, and message. @@ -107,6 +107,7 @@ def _handle_read_action( user_data: The user's authentication data. entity: The Collection entity being accessed. private: Whether the Collection is private. + official_collection: Whether the Collection is an official collection. user_is_owner: Whether the user owns the Collection. collection_roles: The user's roles in this Collection (admin/editor/viewer). active_roles: List of the user's active roles. @@ -128,7 +129,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_update_action( @@ -149,6 +150,7 @@ def _handle_update_action( user_data: The user's authentication data. entity: The Collection entity being updated. private: Whether the Collection is private. + official_collection: Whether the Collection is an official collection. user_is_owner: Whether the user owns the Collection. collection_roles: The user's roles in this Collection (admin/editor/viewer). active_roles: List of the user's active roles. @@ -167,7 +169,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_delete_action( @@ -189,6 +191,7 @@ def _handle_delete_action( user_data: The user's authentication data. entity: The Collection entity being deleted. private: Whether the Collection is private. + official_collection: Whether the Collection is official. user_is_owner: Whether the user owns the Collection. collection_roles: The user's roles in this Collection (admin/editor/viewer). active_roles: List of the user's active roles. @@ -202,16 +205,12 @@ def _handle_delete_action( return PermissionResponse(True) # Other users may only delete non-official collections. if not official_collection: - # Owners may delete a collection only if it has not been published. + # Owners may delete a collection only if it is still private. # Collection admins/editors/viewers may not delete collections. - if user_is_owner: - return PermissionResponse( - private, - 403, - f"insufficient permissions for URN '{entity.urn}'", - ) + if user_is_owner and private: + return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_publish_action( @@ -232,6 +231,7 @@ def _handle_publish_action( user_data: The user's authentication data. entity: The Collection entity being published. private: Whether the Collection is private. + official_collection: Whether the Collection is official. user_is_owner: Whether the user owns the Collection. collection_roles: The user's roles in this Collection (admin/editor/viewer). active_roles: List of the user's active roles. @@ -249,7 +249,7 @@ def _handle_publish_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_add_experiment_action( @@ -290,7 +290,7 @@ def _handle_add_experiment_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_add_score_set_action( @@ -330,7 +330,7 @@ def _handle_add_score_set_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_add_role_action( @@ -369,7 +369,7 @@ def _handle_add_role_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) def _handle_add_badge_action( @@ -402,40 +402,4 @@ def _handle_add_badge_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_collection(entity, private, user_data, bool(collection_roles) or user_is_owner) - - -def _deny_action_for_collection( - entity: Collection, - private: bool, - user_data: Optional[UserData], - user_may_view_private: bool = False, -) -> PermissionResponse: - """ - Generate appropriate denial response for Collection permission checks. - - This helper function determines the correct HTTP status code and message - when denying access to a Collection based on its privacy and user authentication. - - Args: - entity: The Collection entity being accessed. - private: Whether the Collection is private. - user_data: The user's authentication data (None for anonymous). - user_may_view_private: Whether the user has any role allowing them to view private collections. - - Returns: - PermissionResponse: Denial response with appropriate HTTP status and message. - - Note: - Returns 404 for private entities to avoid information disclosure, - 401 for unauthenticated users, and 403 for insufficient permissions. - """ - # Do not acknowledge the existence of a private collection. - if private and not user_may_view_private: - return PermissionResponse(False, 404, f"collection with URN '{entity.urn}' not found") - # No authenticated user is present. - if user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") - - # The authenticated user lacks sufficient permissions. - return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) diff --git a/src/mavedb/lib/permissions/experiment.py b/src/mavedb/lib/permissions/experiment.py index c33f24eb8..2c4462bb9 100644 --- a/src/mavedb/lib/permissions/experiment.py +++ b/src/mavedb/lib/permissions/experiment.py @@ -4,7 +4,7 @@ from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.models import PermissionResponse -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.user_role import UserRole from mavedb.models.experiment import Experiment @@ -19,8 +19,8 @@ def has_permission(user_data: Optional[UserData], entity: Experiment, action: Ac Args: user_data: The user's authentication data and roles. None for anonymous users. - action: The action to be performed (READ, UPDATE, DELETE, ADD_SCORE_SET). entity: The Experiment entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, ADD_SCORE_SET). Returns: PermissionResponse: Contains permission result, HTTP status code, and message. @@ -108,7 +108,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): return PermissionResponse(True) - return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_update_action( @@ -143,7 +143,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_delete_action( @@ -175,16 +175,11 @@ def _handle_delete_action( # Admins may delete any experiment. if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - # Owners may delete an experiment only if it has not been published. Contributors may not delete an experiment. - if user_is_owner: - published = entity.published_date is not None - return PermissionResponse( - not published, - 403, - f"insufficient permissions for URN '{entity.urn}'", - ) + # Owners may delete an experiment only if it is still private. Contributors may not delete an experiment. + if user_is_owner and private: + return PermissionResponse(True) - return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_add_score_set_action( @@ -219,41 +214,8 @@ def _handle_add_score_set_action( # Users with these specific roles may update the experiment. if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) + # Any authenticated user may add a score set to a non-private experiment. + if not private and user_data is not None: + return PermissionResponse(True) - return _deny_action_for_experiment(entity, private, user_data, user_is_contributor or user_is_owner) - - -def _deny_action_for_experiment( - entity: Experiment, - private: bool, - user_data: Optional[UserData], - user_may_view_private: bool, -) -> PermissionResponse: - """ - Generate appropriate denial response for Experiment permission checks. - - This helper function determines the correct HTTP status code and message - when denying access to an Experiment based on its privacy and user authentication. - - Args: - entity: The Experiment entity being accessed. - private: Whether the Experiment is private. - user_data: The user's authentication data (None for anonymous). - user_may_view_private: Whether the user has permission to view private experiments. - - Returns: - PermissionResponse: Denial response with appropriate HTTP status and message. - - Note: - Returns 404 for private entities to avoid information disclosure, - 401 for unauthenticated users, and 403 for insufficient permissions. - """ - # Do not acknowledge the existence of a private experiment. - if private and not user_may_view_private: - return PermissionResponse(False, 404, f"experiment with URN '{entity.urn}' not found") - # No authenticated user is present. - if user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") - - # The authenticated user lacks sufficient permissions. - return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) diff --git a/src/mavedb/lib/permissions/experiment_set.py b/src/mavedb/lib/permissions/experiment_set.py index 809d55c54..44bffe2c0 100644 --- a/src/mavedb/lib/permissions/experiment_set.py +++ b/src/mavedb/lib/permissions/experiment_set.py @@ -4,7 +4,7 @@ from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.models import PermissionResponse -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.user_role import UserRole from mavedb.models.experiment_set import ExperimentSet @@ -19,8 +19,8 @@ def has_permission(user_data: Optional[UserData], entity: ExperimentSet, action: Args: user_data: The user's authentication data and roles. None for anonymous users. - action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT). entity: The ExperimentSet entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT). Returns: PermissionResponse: Contains permission result, HTTP status code, and message. @@ -108,7 +108,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): return PermissionResponse(True) - return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_update_action( @@ -143,7 +143,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_delete_action( @@ -175,16 +175,11 @@ def _handle_delete_action( # Admins may delete any experiment set. if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - # Owners may delete an experiment set only if it has not been published. Contributors may not delete an experiment set. - if user_is_owner: - published = entity.published_date is not None - return PermissionResponse( - not published, - 403, - f"insufficient permissions for URN '{entity.urn}'", - ) + # Owners may delete an experiment set only if it is still private. Contributors may not delete an experiment set. + if user_is_owner and private: + return PermissionResponse(True) - return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_add_experiment_action( @@ -220,40 +215,4 @@ def _handle_add_experiment_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_experiment_set(entity, private, user_data, user_is_contributor or user_is_owner) - - -def _deny_action_for_experiment_set( - entity: ExperimentSet, - private: bool, - user_data: Optional[UserData], - user_may_view_private: bool, -) -> PermissionResponse: - """ - Generate appropriate denial response for ExperimentSet permission checks. - - This helper function determines the correct HTTP status code and message - when denying access to an ExperimentSet based on its privacy and user authentication. - - Args: - entity: The ExperimentSet entity being accessed. - private: Whether the ExperimentSet is private. - user_data: The user's authentication data (None for anonymous). - user_may_view_private: Whether the user has permission to view private experiment sets. - - Returns: - PermissionResponse: Denial response with appropriate HTTP status and message. - - Note: - Returns 404 for private entities to avoid information disclosure, - 401 for unauthenticated users, and 403 for insufficient permissions. - """ - # Do not acknowledge the existence of a private experiment set. - if private and not user_may_view_private: - return PermissionResponse(False, 404, f"experiment set with URN '{entity.urn}' not found") - # No authenticated user is present. - if user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") - - # The authenticated user lacks sufficient permissions. - return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) diff --git a/src/mavedb/lib/permissions/score_calibration.py b/src/mavedb/lib/permissions/score_calibration.py index dfedaf0b6..90241eab5 100644 --- a/src/mavedb/lib/permissions/score_calibration.py +++ b/src/mavedb/lib/permissions/score_calibration.py @@ -4,7 +4,7 @@ from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.models import PermissionResponse -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.user_role import UserRole from mavedb.models.score_calibration import ScoreCalibration @@ -19,8 +19,8 @@ def has_permission(user_data: Optional[UserData], entity: ScoreCalibration, acti Args: user_data: The user's authentication data and roles. None for anonymous users. - action: The action to be performed (READ, UPDATE, DELETE, CREATE). entity: The ScoreCalibration entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, CREATE). Returns: PermissionResponse: Contains permission result, HTTP status code, and message. @@ -119,7 +119,7 @@ def _handle_read_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private) def _handle_update_action( @@ -162,7 +162,7 @@ def _handle_update_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private) def _handle_delete_action( @@ -193,12 +193,12 @@ def _handle_delete_action( # System admins may delete any ScoreCalibration. if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - # Owners may delete their own ScoreCalibration if it is private. - if private and user_is_owner: + # Owners may delete their own ScoreCalibration if it is still private. Contributors may not delete ScoreCalibrations. + if user_is_owner and private: return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private) def _handle_publish_action( @@ -235,7 +235,7 @@ def _handle_publish_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private) def _handle_change_rank_action( @@ -274,39 +274,4 @@ def _handle_change_rank_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return _deny_action_for_score_calibration(entity, private, user_data, user_may_view_private) - - -def _deny_action_for_score_calibration( - entity: ScoreCalibration, - private: bool, - user_data: Optional[UserData], - user_may_view_private: bool, -) -> PermissionResponse: - """ - Generate appropriate denial response for ScoreCalibration permission checks. - - This helper function determines the correct HTTP status code and message - when denying access to a ScoreCalibration based on user authentication. - - Args: - entity: The ScoreCalibration entity being accessed. - private: Whether the ScoreCalibration is private. - user_data: The user's authentication data (None for anonymous). - user_may_view_private: Whether the user has permission to view private ScoreCalibrations - - Returns: - PermissionResponse: Denial response with appropriate HTTP status and message. - - Note: - ScoreCalibrations use the ID for error messages since they don't have URNs. - """ - # Do not acknowledge the existence of private ScoreCalibrations. - if private and not user_may_view_private: - return PermissionResponse(False, 404, f"score calibration '{entity.id}' not found") - # No authenticated user is present. - if user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for score calibration '{entity.id}'") - - # The authenticated user lacks sufficient permissions. - return PermissionResponse(False, 403, f"insufficient permissions for score calibration '{entity.id}'") + return deny_action_for_entity(entity, private, user_data, user_may_view_private) diff --git a/src/mavedb/lib/permissions/score_set.py b/src/mavedb/lib/permissions/score_set.py index a69b69a5e..239361561 100644 --- a/src/mavedb/lib/permissions/score_set.py +++ b/src/mavedb/lib/permissions/score_set.py @@ -4,7 +4,7 @@ from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.models import PermissionResponse -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.user_role import UserRole from mavedb.models.score_set import ScoreSet @@ -19,8 +19,8 @@ def has_permission(user_data: Optional[UserData], entity: ScoreSet, action: Acti Args: user_data: The user's authentication data and roles. None for anonymous users. - action: The action to be performed (READ, UPDATE, DELETE, PUBLISH, SET_SCORES). entity: The ScoreSet entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, PUBLISH, SET_SCORES). Returns: PermissionResponse: Contains permission result, HTTP status code, and message. @@ -109,7 +109,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): return PermissionResponse(True) - return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_update_action( @@ -144,7 +144,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_delete_action( @@ -176,16 +176,11 @@ def _handle_delete_action( # Admins may delete any score set. if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - # Owners may delete a score set only if it has not been published. Contributors may not delete a score set. - if user_is_owner: - published = not private - return PermissionResponse( - not published, - 403, - f"insufficient permissions for URN '{entity.urn}'", - ) + # Owners may delete a score set only if it is still private. Contributors may not delete a score set. + if user_is_owner and private: + return PermissionResponse(True) - return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_publish_action( @@ -199,7 +194,7 @@ def _handle_publish_action( """ Handle PUBLISH action permission check for ScoreSet entities. - Owners, contributors, and admins can publish private ScoreSets to make them + Owners, and admins can publish private ScoreSets to make them publicly accessible. Args: @@ -221,7 +216,7 @@ def _handle_publish_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) def _handle_set_scores_action( @@ -257,40 +252,4 @@ def _handle_set_scores_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_score_set(entity, private, user_data, user_is_contributor or user_is_owner) - - -def _deny_action_for_score_set( - entity: ScoreSet, - private: bool, - user_data: Optional[UserData], - user_may_view_private: bool, -) -> PermissionResponse: - """ - Generate appropriate denial response for ScoreSet permission checks. - - This helper function determines the correct HTTP status code and message - when denying access to a ScoreSet based on its privacy and user authentication. - - Args: - entity: The ScoreSet entity being accessed. - private: Whether the ScoreSet is private. - user_data: The user's authentication data (None for anonymous). - user_may_view_private: Whether the user has permission to view private ScoreSets. - - Returns: - PermissionResponse: Denial response with appropriate HTTP status and message. - - Note: - Returns 404 for private entities to avoid information disclosure, - 401 for unauthenticated users, and 403 for insufficient permissions. - """ - # Do not acknowledge the existence of a private score set. - if private and not user_may_view_private: - return PermissionResponse(False, 404, f"score set with URN '{entity.urn}' not found") - # No authenticated user is present. - if user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{entity.urn}'") - - # The authenticated user lacks sufficient permissions. - return PermissionResponse(False, 403, f"insufficient permissions for URN '{entity.urn}'") + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) diff --git a/src/mavedb/lib/permissions/user.py b/src/mavedb/lib/permissions/user.py index 920f998de..ed76dc151 100644 --- a/src/mavedb/lib/permissions/user.py +++ b/src/mavedb/lib/permissions/user.py @@ -4,7 +4,7 @@ from mavedb.lib.logging.context import save_to_logging_context from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.models import PermissionResponse -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.user_role import UserRole from mavedb.models.user import User @@ -19,8 +19,8 @@ def has_permission(user_data: Optional[UserData], entity: User, action: Action) Args: user_data: The user's authentication data and roles. None for anonymous users. - action: The action to be performed (READ, UPDATE, DELETE). entity: The User entity to check permissions for. + action: The action to be performed (READ, UPDATE, LOOKUP, ADD_ROLE). Returns: PermissionResponse: Contains permission result, HTTP status code, and message. @@ -101,7 +101,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_user(entity, user_data) + return deny_action_for_entity(entity, False, user_data, False) def _handle_lookup_action( @@ -129,7 +129,7 @@ def _handle_lookup_action( if user_data is not None and user_data.user is not None: return PermissionResponse(True) - return _deny_action_for_user(entity, user_data) + return deny_action_for_entity(entity, False, user_data, False) def _handle_update_action( @@ -160,7 +160,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_user(entity, user_data) + return deny_action_for_entity(entity, False, user_data, False) def _handle_add_role_action( @@ -188,33 +188,4 @@ def _handle_add_role_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return _deny_action_for_user(entity, user_data) - - -def _deny_action_for_user( - entity: User, - user_data: Optional[UserData], -) -> PermissionResponse: - """ - Generate appropriate denial response for User permission checks. - - This helper function determines the correct HTTP status code and message - when denying access to a User entity based on authentication status. - - Args: - entity: The User entity being accessed. - user_data: The user's authentication data (None for anonymous). - - Returns: - PermissionResponse: Denial response with appropriate HTTP status and message. - - Note: - For User entities, we don't use 404 responses as user existence - is typically not considered sensitive information in this context. - """ - # No authenticated user is present. - if user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for user '{entity.username}'") - - # The authenticated user lacks sufficient permissions. - return PermissionResponse(False, 403, f"insufficient permissions for user '{entity.username}'") + return deny_action_for_entity(entity, False, user_data, False) diff --git a/src/mavedb/lib/permissions/utils.py b/src/mavedb/lib/permissions/utils.py index 848c6fd52..4e00735f0 100644 --- a/src/mavedb/lib/permissions/utils.py +++ b/src/mavedb/lib/permissions/utils.py @@ -1,7 +1,10 @@ import logging -from typing import Union, overload +from typing import Optional, Union, overload +from mavedb.lib.authentication import UserData from mavedb.lib.logging.context import logging_context, save_to_logging_context +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.types.permissions import EntityType from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.enums.user_role import UserRole @@ -26,6 +29,32 @@ def roles_permitted( user_roles: Union[list[UserRole], list[ContributionRole]], permitted_roles: Union[list[UserRole], list[ContributionRole]], ) -> bool: + """ + Check if any user role is permitted based on a list of allowed roles. + + This function validates that both user_roles and permitted_roles are lists of the same enum type + (either all UserRole or all ContributionRole), and checks if any user role is present in the permitted roles. + Raises ValueError if either list contains mixed role types or if the lists are of different types. + + Args: + user_roles: List of roles assigned to the user (UserRole or ContributionRole). + permitted_roles: List of roles that are permitted for the action (UserRole or ContributionRole). + + Returns: + bool: True if any user role is permitted, False otherwise. + + Raises: + ValueError: If user_roles or permitted_roles contain mixed role types, or if the lists are of different types. + + Example: + >>> roles_permitted([UserRole.admin], [UserRole.admin, UserRole.editor]) + True + >>> roles_permitted([ContributionRole.admin], [ContributionRole.editor]) + False + + Note: + This function is used to enforce type safety and prevent mixing of role enums in permission checks. + """ save_to_logging_context({"permitted_roles": [role.name for role in permitted_roles]}) if not user_roles: @@ -50,3 +79,53 @@ def roles_permitted( ) return any(role in permitted_roles for role in user_roles) + + +def deny_action_for_entity( + entity: EntityType, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool, +) -> PermissionResponse: + """ + Generate appropriate denial response for entity permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to an entity based on its privacy and user authentication. + + Args: + entity: The entity being accessed. + private: Whether the entity is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has permission to view private entities. + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + Returns 404 for private entities to avoid information disclosure, + 401 for unauthenticated users, and 403 for insufficient permissions. + """ + + def _identifier_for_entity(entity: EntityType) -> tuple[str, str]: + if hasattr(entity, "urn") and entity.urn is not None: + return "URN", entity.urn + elif hasattr(entity, "id") and entity.id is not None: + return "ID", str(entity.id) + else: + return "unknown", "unknown" + + field, identifier = _identifier_for_entity(entity) + # Do not acknowledge the existence of a private score set. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"{entity.__class__.__name__} with {field} '{identifier}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse( + False, 401, f"authentication required to access {entity.__class__.__name__} with {field} '{identifier}'" + ) + + # The authenticated user lacks sufficient permissions. + return PermissionResponse( + False, 403, f"insufficient permissions on {entity.__class__.__name__} with {field} '{identifier}'" + ) diff --git a/src/mavedb/routers/experiments.py b/src/mavedb/routers/experiments.py index 5d37ecb37..a0e52ff66 100644 --- a/src/mavedb/routers/experiments.py +++ b/src/mavedb/routers/experiments.py @@ -247,7 +247,7 @@ async def create_experiment( ) raise HTTPException( status_code=404, - detail=f"experiment set with URN '{item_create.experiment_set_urn}' not found.", + detail=f"ExperimentSet with URN '{item_create.experiment_set_urn}' not found.", ) save_to_logging_context({"experiment_set": experiment_set.urn}) diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index 959f91337..b93d918e7 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -179,7 +179,7 @@ async def score_set_update( item = existing_item or db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() if not item or item.id is None: logger.info(msg="Failed to update score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, item, Action.UPDATE) @@ -507,7 +507,7 @@ async def fetch_score_set_by_urn( if not item: logger.info(msg="Could not fetch the requested score set; No such score sets exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user, item, Action.READ) @@ -767,7 +767,7 @@ def get_score_set_variants_csv( score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: logger.info(msg="Could not fetch the requested scores; No such score set exists.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, score_set, Action.READ) @@ -835,7 +835,7 @@ def get_score_set_scores_csv( score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: logger.info(msg="Could not fetch the requested scores; No such score set exists.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, score_set, Action.READ) @@ -893,7 +893,7 @@ async def get_score_set_counts_csv( score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: logger.info(msg="Could not fetch the requested counts; No such score set exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -924,7 +924,7 @@ def get_score_set_mapped_variants( logger.info( msg="Could not fetch the requested mapped variants; No such score set exist.", extra=logging_context() ) - raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1090,7 +1090,7 @@ def get_score_set_annotated_variants( msg="Could not fetch the requested pathogenicity evidence lines; No such score set exists.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1206,7 +1206,7 @@ def get_score_set_annotated_variants_functional_statement( msg="Could not fetch the requested functional impact statements; No such score set exists.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1326,7 +1326,7 @@ def get_score_set_annotated_variants_functional_study_result( msg="Could not fetch the requested functional study results; No such score set exists.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1823,7 +1823,7 @@ async def update_score_set_with_variants( raise RequestValidationError(errors=e.errors()) else: logger.info(msg="Failed to update score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") itemUpdateResult = await score_set_update( db=db, @@ -1958,7 +1958,7 @@ async def delete_score_set( item = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() if not item: logger.info(msg="Failed to delete score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, item, Action.DELETE) @@ -1988,7 +1988,7 @@ async def publish_score_set( item: Optional[ScoreSet] = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() if not item: logger.info(msg="Failed to publish score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, item, Action.PUBLISH) @@ -2101,7 +2101,7 @@ async def get_clinical_controls_for_score_set( msg="Failed to fetch clinical controls for score set; The requested score set does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, item, Action.READ) @@ -2165,7 +2165,7 @@ async def get_clinical_controls_options_for_score_set( msg="Failed to fetch clinical control options for score set; The requested score set does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, item, Action.READ) @@ -2229,7 +2229,7 @@ async def get_gnomad_variants_for_score_set( msg="Failed to fetch gnomad variants for score set; The requested score set does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") assert_permission(user_data, item, Action.READ) diff --git a/tests/lib/permissions/test_collection.py b/tests/lib/permissions/test_collection.py index 7c68f3a07..7217b07a5 100644 --- a/tests/lib/permissions/test_collection.py +++ b/tests/lib/permissions/test_collection.py @@ -7,7 +7,6 @@ from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.collection import ( - _deny_action_for_collection, _handle_add_badge_action, _handle_add_experiment_action, _handle_add_role_action, @@ -727,43 +726,3 @@ def test_handle_add_badge_action(self, test_case: PermissionTest, entity_helper: assert result.permitted == test_case.should_be_permitted if not test_case.should_be_permitted and test_case.expected_code: assert result.http_code == test_case.expected_code - - -class TestCollectionDenyActionHandler: - """Test collection deny action handler.""" - - def test_deny_action_for_private_collection(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_collection helper function for private Collection.""" - collection = entity_helper.create_collection("private") - - # Private entity should return 404 - result = _deny_action_for_collection(collection, True, entity_helper.create_user_data("other_user"), False) - assert result.permitted is False - assert result.http_code == 404 - - def test_deny_action_for_public_collection_anonymous_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_collection helper function for public Collection with anonymous user.""" - collection = entity_helper.create_collection("published") - - # Public entity, anonymous user should return 401 - result = _deny_action_for_collection(collection, False, None, False) - assert result.permitted is False - assert result.http_code == 401 - - def test_deny_action_for_public_collection_authenticated_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_collection helper function for public Collection with authenticated user.""" - collection = entity_helper.create_collection("published") - - # Public entity, authenticated user should return 403 - result = _deny_action_for_collection(collection, False, entity_helper.create_user_data("other_user"), False) - assert result.permitted is False - assert result.http_code == 403 - - def test_deny_action_for_private_collection_with_collection_role(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_collection helper function for private Collection when user has collection role.""" - collection = entity_helper.create_collection("private") - - # Private entity, user with collection role should return 403 (still private, but user knows it exists) - result = _deny_action_for_collection(collection, True, entity_helper.create_user_data("other_user"), True) - assert result.permitted is False - assert result.http_code == 403 diff --git a/tests/lib/permissions/test_experiment.py b/tests/lib/permissions/test_experiment.py index 252875cfa..2118685ee 100644 --- a/tests/lib/permissions/test_experiment.py +++ b/tests/lib/permissions/test_experiment.py @@ -7,7 +7,6 @@ from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.experiment import ( - _deny_action_for_experiment, _handle_add_score_set_action, _handle_delete_action, _handle_read_action, @@ -245,12 +244,12 @@ class TestExperimentAddScoreSetActionHandler: # Contributors can add score sets to any Experiment they contribute to PermissionTest("Experiment", "private", "contributor", Action.ADD_SCORE_SET, True), PermissionTest("Experiment", "published", "contributor", Action.ADD_SCORE_SET, True), - # Mappers cannot add score sets to Experiments + # Mappers can add score sets to public Experiments PermissionTest("Experiment", "private", "mapper", Action.ADD_SCORE_SET, False, 404), - PermissionTest("Experiment", "published", "mapper", Action.ADD_SCORE_SET, False, 403), - # Other users cannot add score sets to Experiments + PermissionTest("Experiment", "published", "mapper", Action.ADD_SCORE_SET, True), + # Other users can add score sets to public Experiments PermissionTest("Experiment", "private", "other_user", Action.ADD_SCORE_SET, False, 404), - PermissionTest("Experiment", "published", "other_user", Action.ADD_SCORE_SET, False, 403), + PermissionTest("Experiment", "published", "other_user", Action.ADD_SCORE_SET, True), # Anonymous users cannot add score sets to Experiments PermissionTest("Experiment", "private", "anonymous", Action.ADD_SCORE_SET, False, 404), PermissionTest("Experiment", "published", "anonymous", Action.ADD_SCORE_SET, False, 401), @@ -275,43 +274,3 @@ def test_handle_add_score_set_action(self, test_case: PermissionTest, entity_hel assert result.permitted == test_case.should_be_permitted if not test_case.should_be_permitted and test_case.expected_code: assert result.http_code == test_case.expected_code - - -class TestExperimentDenyActionHandler: - """Test experiment deny action handler.""" - - def test_deny_action_for_private_experiment(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment helper function for private Experiment.""" - experiment = entity_helper.create_experiment("private") - - # Private entity should return 404 - result = _deny_action_for_experiment(experiment, True, entity_helper.create_user_data("other_user"), False) - assert result.permitted is False - assert result.http_code == 404 - - def test_deny_action_for_public_experiment_anonymous_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment helper function for public Experiment with anonymous user.""" - experiment = entity_helper.create_experiment("published") - - # Public entity, anonymous user should return 401 - result = _deny_action_for_experiment(experiment, False, None, False) - assert result.permitted is False - assert result.http_code == 401 - - def test_deny_action_for_public_experiment_authenticated_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment helper function for public Experiment with authenticated user.""" - experiment = entity_helper.create_experiment("published") - - # Public entity, authenticated user should return 403 - result = _deny_action_for_experiment(experiment, False, entity_helper.create_user_data("other_user"), False) - assert result.permitted is False - assert result.http_code == 403 - - def test_deny_action_for_private_experiment_with_view_permission(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment helper function for private Experiment when user can view private.""" - experiment = entity_helper.create_experiment("private") - - # Private entity, user can view but lacks other permissions should return 403 - result = _deny_action_for_experiment(experiment, True, entity_helper.create_user_data("other_user"), True) - assert result.permitted is False - assert result.http_code == 403 diff --git a/tests/lib/permissions/test_experiment_set.py b/tests/lib/permissions/test_experiment_set.py index cf40db77e..2f3f3df16 100644 --- a/tests/lib/permissions/test_experiment_set.py +++ b/tests/lib/permissions/test_experiment_set.py @@ -7,7 +7,6 @@ from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.experiment_set import ( - _deny_action_for_experiment_set, _handle_add_experiment_action, _handle_delete_action, _handle_read_action, @@ -281,49 +280,3 @@ def test_handle_add_experiment_action(self, test_case: PermissionTest, entity_he assert result.permitted == test_case.should_be_permitted if not test_case.should_be_permitted and test_case.expected_code: assert result.http_code == test_case.expected_code - - -class TestExperimentSetDenyActionHandler: - """Test experiment set deny action handler.""" - - def test_deny_action_for_private_experiment_set_non_contributor(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment_set helper function for private ExperimentSet.""" - experiment_set = entity_helper.create_experiment_set("private") - - # Private entity should return 404 - result = _deny_action_for_experiment_set( - experiment_set, True, entity_helper.create_user_data("other_user"), False - ) - assert result.permitted is False - assert result.http_code == 404 - - def test_deny_action_for_private_experiment_set_contributor(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment_set helper function for private ExperimentSet with contributor user.""" - experiment_set = entity_helper.create_experiment_set("private") - - # Private entity, contributor user should return 404 - result = _deny_action_for_experiment_set( - experiment_set, True, entity_helper.create_user_data("contributor"), True - ) - assert result.permitted is False - assert result.http_code == 403 - - def test_deny_action_for_public_experiment_set_anonymous_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment_set helper function for public ExperimentSet with anonymous user.""" - experiment_set = entity_helper.create_experiment_set("published") - - # Public entity, anonymous user should return 401 - result = _deny_action_for_experiment_set(experiment_set, False, None, False) - assert result.permitted is False - assert result.http_code == 401 - - def test_deny_action_for_public_experiment_set_authenticated_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_experiment_set helper function for public ExperimentSet with authenticated user.""" - experiment_set = entity_helper.create_experiment_set("published") - - # Public entity, authenticated user should return 403 - result = _deny_action_for_experiment_set( - experiment_set, False, entity_helper.create_user_data("other_user"), False - ) - assert result.permitted is False - assert result.http_code == 403 diff --git a/tests/lib/permissions/test_score_calibration.py b/tests/lib/permissions/test_score_calibration.py index f1bf03096..0d96c4fb9 100644 --- a/tests/lib/permissions/test_score_calibration.py +++ b/tests/lib/permissions/test_score_calibration.py @@ -7,7 +7,6 @@ from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.score_calibration import ( - _deny_action_for_score_calibration, _handle_change_rank_action, _handle_delete_action, _handle_publish_action, @@ -549,49 +548,3 @@ def test_handle_change_rank_action(self, test_case: PermissionTest, entity_helpe assert result.permitted == test_case.should_be_permitted if not test_case.should_be_permitted and test_case.expected_code: assert result.http_code == test_case.expected_code - - -class TestScoreCalibrationDenyActionHandler: - """Test score calibration deny action handler.""" - - def test_deny_action_for_private_score_calibration_not_contributor(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_calibration helper function for private ScoreCalibration.""" - score_calibration = entity_helper.create_score_calibration("private", True) - - # Private entity should return 404 - result = _deny_action_for_score_calibration( - score_calibration, True, entity_helper.create_user_data("other_user"), False - ) - assert result.permitted is False - assert result.http_code == 404 - - def test_deny_action_for_public_score_calibration_anonymous_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_calibration helper function for public ScoreCalibration with anonymous user.""" - score_calibration = entity_helper.create_score_calibration("published", True) - - # Public entity, anonymous user should return 401 - result = _deny_action_for_score_calibration(score_calibration, False, None, False) - assert result.permitted is False - assert result.http_code == 401 - - def test_deny_action_for_public_score_calibration_authenticated_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_calibration helper function for public ScoreCalibration with authenticated user.""" - score_calibration = entity_helper.create_score_calibration("published", True) - - # Public entity, authenticated user should return 403 - result = _deny_action_for_score_calibration( - score_calibration, False, entity_helper.create_user_data("other_user"), False - ) - assert result.permitted is False - assert result.http_code == 403 - - def test_deny_action_for_private_score_calibration_with_contributor(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_calibration helper function for private ScoreCalibration with contributor user.""" - score_calibration = entity_helper.create_score_calibration("private", True) - - # Private entity with contributor user should return 403 - result = _deny_action_for_score_calibration( - score_calibration, True, entity_helper.create_user_data("contributor"), True - ) - assert result.permitted is False - assert result.http_code == 403 diff --git a/tests/lib/permissions/test_score_set.py b/tests/lib/permissions/test_score_set.py index 002e1544b..f871eb617 100644 --- a/tests/lib/permissions/test_score_set.py +++ b/tests/lib/permissions/test_score_set.py @@ -7,7 +7,6 @@ from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.score_set import ( - _deny_action_for_score_set, _handle_delete_action, _handle_publish_action, _handle_read_action, @@ -321,43 +320,3 @@ def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: E assert result.permitted == test_case.should_be_permitted if not test_case.should_be_permitted and test_case.expected_code: assert result.http_code == test_case.expected_code - - -class TestScoreSetDenyActionHandler: - """Test score set deny action handler.""" - - def test_deny_action_for_private_score_set_non_contributor(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_set helper function for private ScoreSet.""" - score_set = entity_helper.create_score_set("private") - - # Private entity should return 404 - result = _deny_action_for_score_set(score_set, True, entity_helper.create_user_data("other_user"), False) - assert result.permitted is False - assert result.http_code == 404 - - def test_deny_action_for_private_score_set_contributor(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_set helper function for private ScoreSet with contributor user.""" - score_set = entity_helper.create_score_set("private") - - # Private entity, contributor user should return 404 - result = _deny_action_for_score_set(score_set, True, entity_helper.create_user_data("contributor"), True) - assert result.permitted is False - assert result.http_code == 403 - - def test_deny_action_for_public_score_set_anonymous_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_set helper function for public ScoreSet with anonymous user.""" - score_set = entity_helper.create_score_set("published") - - # Public entity, anonymous user should return 401 - result = _deny_action_for_score_set(score_set, False, None, False) - assert result.permitted is False - assert result.http_code == 401 - - def test_deny_action_for_public_score_set_authenticated_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_score_set helper function for public ScoreSet with authenticated user.""" - score_set = entity_helper.create_score_set("published") - - # Public entity, authenticated user should return 403 - result = _deny_action_for_score_set(score_set, False, entity_helper.create_user_data("other_user"), False) - assert result.permitted is False - assert result.http_code == 403 diff --git a/tests/lib/permissions/test_user.py b/tests/lib/permissions/test_user.py index 15f13eec7..66e731e4f 100644 --- a/tests/lib/permissions/test_user.py +++ b/tests/lib/permissions/test_user.py @@ -7,7 +7,6 @@ from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.user import ( - _deny_action_for_user, _handle_add_role_action, _handle_lookup_action, _handle_read_action, @@ -232,25 +231,3 @@ def test_handle_add_role_action(self, test_case: PermissionTest, entity_helper: assert result.permitted == test_case.should_be_permitted if not test_case.should_be_permitted and test_case.expected_code: assert result.http_code == test_case.expected_code - - -class TestUserDenyActionHandler: - """Test user deny action handler.""" - - def test_deny_action_for_user_anonymous_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_user helper function for anonymous user.""" - user = entity_helper.create_user() - - # Anonymous user should return 401 - result = _deny_action_for_user(user, None) - assert result.permitted is False - assert result.http_code == 401 - - def test_deny_action_for_user_authenticated_user(self, entity_helper: EntityTestHelper) -> None: - """Test _deny_action_for_user helper function for authenticated user with insufficient permissions.""" - user = entity_helper.create_user() - - # Authenticated user with insufficient permissions should return 403 - result = _deny_action_for_user(user, entity_helper.create_user_data("other_user")) - assert result.permitted is False - assert result.http_code == 403 diff --git a/tests/lib/permissions/test_utils.py b/tests/lib/permissions/test_utils.py index f846a6654..513edc6f3 100644 --- a/tests/lib/permissions/test_utils.py +++ b/tests/lib/permissions/test_utils.py @@ -1,8 +1,10 @@ """Tests for permissions utils module.""" +from unittest.mock import Mock + import pytest -from mavedb.lib.permissions.utils import roles_permitted +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.enums.user_role import UserRole @@ -139,3 +141,79 @@ def test_single_role_lists(self): user_roles = [UserRole.mapper] permitted_roles = [UserRole.admin] assert roles_permitted(user_roles, permitted_roles) is False + + +class TestDenyActionForEntity: + """Test the deny_action_for_entity utility function.""" + + @pytest.mark.parametrize( + "entity_is_private, user_data, user_can_view_private, expected_status", + [ + # Private entity, anonymous user + (True, None, False, 404), + # Private entity, authenticated user without permissions + (True, Mock(user=Mock(id=1)), False, 404), + # Private entity, authenticated user with permissions + (True, Mock(user=Mock(id=1)), True, 403), + # Public entity, anonymous user + (False, None, False, 401), + # Public entity, authenticated user + (False, Mock(user=Mock(id=1)), False, 403), + ], + ids=[ + "private_anonymous_not-viewer", + "private_authenticated_not-viewer", + "private_authenticated_viewer", + "public_anonymous", + "public_authenticated", + ], + ) + def test_deny_action(self, entity_is_private, user_data, user_can_view_private, expected_status): + """Test denial for various user and entity privacy scenarios.""" + + entity = Mock(urn="entity:1234") + response = deny_action_for_entity(entity, entity_is_private, user_data, user_can_view_private) + + assert response.permitted is False + assert response.http_code == expected_status + + def test_deny_action_urn_available(self): + """Test denial message includes URN when available.""" + entity = Mock(urn="entity:5678") + response = deny_action_for_entity(entity, True, None, False) + + assert "URN 'entity:5678'" in response.message + + def test_deny_action_id_available(self): + """Test denial message includes ID when URN is not available.""" + entity = Mock(urn=None, id=42) + response = deny_action_for_entity(entity, True, None, False) + + assert "ID '42'" in response.message + + def test_deny_action_no_identifier(self): + """Test denial message when neither URN nor ID is available.""" + entity = Mock(urn=None, id=None) + response = deny_action_for_entity(entity, True, None, False) + + assert "unknown" in response.message + + def test_deny_handles_undefined_attributres(self): + """Test denial message when identifier attributes are undefined.""" + entity = Mock() + del entity.urn # Remove urn attribute + del entity.id # Remove id attribute + response = deny_action_for_entity(entity, True, None, False) + + assert "unknown" in response.message + + def test_deny_action_entity_name_in_message(self): + """Test denial message includes entity class name.""" + + class CustomEntity: + pass + + entity = CustomEntity() + response = deny_action_for_entity(entity, True, None, False) + + assert "CustomEntity" in response.message diff --git a/tests/routers/test_collections.py b/tests/routers/test_collections.py index 3b3bec652..3a39dd3a3 100644 --- a/tests/routers/test_collections.py +++ b/tests/routers/test_collections.py @@ -14,12 +14,11 @@ from mavedb.lib.validation.urn_re import MAVEDB_COLLECTION_URN_RE from mavedb.models.enums.contribution_role import ContributionRole from mavedb.view_models.collection import Collection - from tests.helpers.constants import ( EXTRA_USER, - TEST_USER, TEST_COLLECTION, TEST_COLLECTION_RESPONSE, + TEST_USER, ) from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util.collection import create_collection @@ -198,7 +197,7 @@ def test_unauthorized_user_cannot_read_private_collection(session, client, setup response = client.get(f"/api/v1/collections/{collection['urn']}") assert response.status_code == 404 - assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"Collection with URN '{collection['urn']}'" in response.json()["detail"] def test_anonymous_cannot_read_private_collection(session, client, setup_router_db, anonymous_app_overrides): @@ -208,7 +207,7 @@ def test_anonymous_cannot_read_private_collection(session, client, setup_router_ response = client.get(f"/api/v1/collections/{collection['urn']}") assert response.status_code == 404 - assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"Collection with URN '{collection['urn']}'" in response.json()["detail"] def test_anonymous_can_read_public_collection(session, client, setup_router_db, anonymous_app_overrides): @@ -360,7 +359,7 @@ def test_viewer_cannot_add_experiment_to_collection( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{collection['urn']}'" in response_data["detail"] + assert f"insufficient permissions on Collection with URN '{collection['urn']}'" in response_data["detail"] def test_unauthorized_user_cannot_add_experiment_to_collection( @@ -385,7 +384,7 @@ def test_unauthorized_user_cannot_add_experiment_to_collection( ) assert response.status_code == 404 - assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"Collection with URN '{collection['urn']}' not found" in response.json()["detail"] def test_anonymous_cannot_add_experiment_to_collection( @@ -544,7 +543,7 @@ def test_viewer_cannot_add_score_set_to_collection( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{collection['urn']}'" in response_data["detail"] + assert f"insufficient permissions on Collection with URN '{collection['urn']}'" in response_data["detail"] def test_unauthorized_user_cannot_add_score_set_to_collection( @@ -568,7 +567,7 @@ def test_unauthorized_user_cannot_add_score_set_to_collection( ) assert response.status_code == 404 - assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"Collection with URN '{collection['urn']}' not found" in response.json()["detail"] def test_anonymous_cannot_add_score_set_to_collection( diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index 9767c1259..25947119f 100644 --- a/tests/routers/test_experiments.py +++ b/tests/routers/test_experiments.py @@ -531,7 +531,7 @@ def test_cannot_assign_to_missing_experiment_set(client, setup_router_db): response = client.post("/api/v1/experiments/", json=experiment_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"experiment set with URN '{experiment_set_urn}' not found" in response_data["detail"] + assert f"ExperimentSet with URN '{experiment_set_urn}' not found" in response_data["detail"] def test_can_update_own_private_experiment_set(session, client, setup_router_db): @@ -553,7 +553,7 @@ def test_cannot_update_other_users_private_experiment_set(session, client, setup response = client.post("/api/v1/experiments/", json=experiment_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"experiment set with URN '{experiment['experimentSetUrn']}' not found" in response_data["detail"] + assert f"ExperimentSet with URN '{experiment['experimentSetUrn']}' not found" in response_data["detail"] def test_anonymous_cannot_update_other_users_private_experiment_set( @@ -621,7 +621,10 @@ def test_cannot_update_other_users_public_experiment_set(session, data_provider, response = client.post("/api/v1/experiments/", json=experiment_post_payload) assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{published_experiment_set_urn}'" in response_data["detail"] + assert ( + f"insufficient permissions on ExperimentSet with URN '{published_experiment_set_urn}'" + in response_data["detail"] + ) def test_anonymous_cannot_update_others_user_public_experiment_set( @@ -756,7 +759,7 @@ def test_cannot_edit_other_users_private_experiment(client, session, setup_route response = client.put(f"/api/v1/experiments/{experiment['urn']}", json=experiment_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"experiment with URN '{experiment['urn']}' not found" in response_data["detail"] + assert f"Experiment with URN '{experiment['urn']}' not found" in response_data["detail"] @pytest.mark.parametrize( @@ -1136,7 +1139,7 @@ def test_cannot_get_other_users_private_experiment(session, client, setup_router response = client.get(f"/api/v1/experiments/{experiment['urn']}") assert response.status_code == 404 response_data = response.json() - assert f"experiment with URN '{experiment['urn']}' not found" in response_data["detail"] + assert f"Experiment with URN '{experiment['urn']}' not found" in response_data["detail"] def test_anonymous_cannot_get_users_private_experiment(session, client, anonymous_app_overrides, setup_router_db): @@ -1146,7 +1149,7 @@ def test_anonymous_cannot_get_users_private_experiment(session, client, anonymou assert response.status_code == 404 response_data = response.json() - assert f"experiment with URN '{experiment['urn']}' not found" in response_data["detail"] + assert f"Experiment with URN '{experiment['urn']}' not found" in response_data["detail"] def test_admin_can_get_other_users_private_experiment(client, admin_app_overrides, setup_router_db): @@ -1651,10 +1654,12 @@ def test_cannot_delete_own_published_experiment(session, data_provider, client, assert del_response.status_code == 403 del_response_data = del_response.json() - assert f"insufficient permissions for URN '{experiment_urn}'" in del_response_data["detail"] + assert f"insufficient permissions on Experiment with URN '{experiment_urn}'" in del_response_data["detail"] -def test_contributor_can_delete_other_users_private_experiment(session, client, setup_router_db, admin_app_overrides): +def test_contributor_cannot_delete_other_users_private_experiment( + session, client, setup_router_db, admin_app_overrides +): experiment = create_experiment(client) change_ownership(session, experiment["urn"], ExperimentDbModel) add_contributor( @@ -1667,7 +1672,8 @@ def test_contributor_can_delete_other_users_private_experiment(session, client, ) response = client.delete(f"/api/v1/experiments/{experiment['urn']}") - assert response.status_code == 200 + assert response.status_code == 403 + assert f"insufficient permissions on Experiment with URN '{experiment['urn']}'" in response.json()["detail"] def test_admin_can_delete_other_users_private_experiment(session, client, setup_router_db, admin_app_overrides): @@ -1808,7 +1814,7 @@ def test_cannot_add_experiment_to_others_private_experiment_set(session, client, response = client.post("/api/v1/experiments/", json=test_experiment) assert response.status_code == 404 response_data = response.json() - assert f"experiment set with URN '{experiment_set_urn}' not found" in response_data["detail"] + assert f"ExperimentSet with URN '{experiment_set_urn}' not found" in response_data["detail"] def test_cannot_add_experiment_to_others_public_experiment_set( @@ -1833,4 +1839,4 @@ def test_cannot_add_experiment_to_others_public_experiment_set( response = client.post("/api/v1/experiments/", json=test_experiment) assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{experiment_set_urn}'" in response_data["detail"] + assert f"insufficient permissions on ExperimentSet with URN '{experiment_set_urn}'" in response_data["detail"] diff --git a/tests/routers/test_permissions.py b/tests/routers/test_permissions.py index 74405a470..b60a924e8 100644 --- a/tests/routers/test_permissions.py +++ b/tests/routers/test_permissions.py @@ -131,7 +131,7 @@ def test_contributor_gets_true_permission_from_others_experiment_update_check(se assert response.json() -def test_contributor_gets_true_permission_from_others_experiment_delete_check(session, client, setup_router_db): +def test_contributor_gets_false_permission_from_others_experiment_delete_check(session, client, setup_router_db): experiment = create_experiment(client) change_ownership(session, experiment["urn"], ExperimentDbModel) add_contributor( @@ -145,7 +145,7 @@ def test_contributor_gets_true_permission_from_others_experiment_delete_check(se response = client.get(f"/api/v1/permissions/user-is-permitted/experiment/{experiment['urn']}/delete") assert response.status_code == 200 - assert response.json() + assert not response.json() def test_contributor_gets_true_permission_from_others_private_experiment_add_score_set_check( @@ -282,7 +282,7 @@ def test_contributor_gets_true_permission_from_others_score_set_update_check(ses assert response.json() -def test_contributor_gets_true_permission_from_others_score_set_delete_check(session, client, setup_router_db): +def test_contributor_gets_false_permission_from_others_score_set_delete_check(session, client, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) change_ownership(session, score_set["urn"], ScoreSetDbModel) @@ -297,10 +297,10 @@ def test_contributor_gets_true_permission_from_others_score_set_delete_check(ses response = client.get(f"/api/v1/permissions/user-is-permitted/score-set/{score_set['urn']}/delete") assert response.status_code == 200 - assert response.json() + assert not response.json() -def test_contributor_gets_true_permission_from_others_score_set_publish_check(session, client, setup_router_db): +def test_contributor_gets_false_permission_from_others_score_set_publish_check(session, client, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) change_ownership(session, score_set["urn"], ScoreSetDbModel) @@ -315,7 +315,7 @@ def test_contributor_gets_true_permission_from_others_score_set_publish_check(se response = client.get(f"/api/v1/permissions/user-is-permitted/score-set/{score_set['urn']}/publish") assert response.status_code == 200 - assert response.json() + assert not response.json() def test_get_false_permission_from_others_score_set_delete_check(session, client, setup_router_db): @@ -423,7 +423,7 @@ def test_contributor_gets_true_permission_from_others_investigator_provided_scor assert response.json() -def test_contributor_gets_true_permission_from_others_investigator_provided_score_calibration_delete_check( +def test_contributor_gets_false_permission_from_others_investigator_provided_score_calibration_delete_check( session, client, setup_router_db, extra_user_app_overrides ): experiment = create_experiment(client) @@ -445,10 +445,12 @@ def test_contributor_gets_true_permission_from_others_investigator_provided_scor ) assert response.status_code == 200 - assert response.json() + assert not response.json() -def test_get_false_permission_from_others_score_calibration_update_check(session, client, setup_router_db): +def test_get_true_permission_as_score_set_owner_on_others_investigator_provided_score_calibration_update_check( + session, client, setup_router_db +): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) score_calibration = create_test_score_calibration_in_score_set_via_client( @@ -458,6 +460,23 @@ def test_get_false_permission_from_others_score_calibration_update_check(session response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update") + assert response.status_code == 200 + assert response.json() + + +def test_get_false_permission_as_score_set_owner_on_others_community_score_calibration_update_check( + session, client, setup_router_db, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + + with DependencyOverrider(admin_app_overrides): + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + + response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update") + assert response.status_code == 200 assert not response.json() diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index 307394ecb..211e37298 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -13,6 +13,14 @@ from mavedb.models.score_calibration import ScoreCalibration as CalibrationDbModel from mavedb.models.score_set import ScoreSet as ScoreSetDbModel +from tests.helpers.constants import ( + EXTRA_USER, + TEST_BIORXIV_IDENTIFIER, + TEST_BRNICH_SCORE_CALIBRATION, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, + VALID_CALIBRATION_URN, +) from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util.common import deepcamelize from tests.helpers.util.contributor import add_contributor @@ -24,15 +32,6 @@ ) from tests.helpers.util.score_set import create_seq_score_set_with_mapped_variants, publish_score_set -from tests.helpers.constants import ( - EXTRA_USER, - TEST_BIORXIV_IDENTIFIER, - TEST_BRNICH_SCORE_CALIBRATION, - TEST_PATHOGENICITY_SCORE_CALIBRATION, - TEST_PUBMED_IDENTIFIER, - VALID_CALIBRATION_URN, -) - ########################################################### # GET /score-calibrations/{calibration_urn} ########################################################### @@ -76,7 +75,7 @@ def test_anonymous_user_cannot_get_score_calibration_when_private( assert response.status_code == 404 error = response.json() - assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -109,7 +108,7 @@ def test_other_user_cannot_get_score_calibration_when_private( assert response.status_code == 404 error = response.json() - assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -236,7 +235,7 @@ def test_contributing_user_cannot_get_score_calibration_when_private_and_not_inv assert response.status_code == 404 error = response.json() - assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -520,7 +519,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -553,7 +552,7 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -881,7 +880,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibra assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -921,7 +920,7 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibration assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -1228,7 +1227,7 @@ def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, s assert response.status_code == 404 error = response.json() - assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] + assert "ScoreSet with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] @pytest.mark.parametrize( @@ -1264,7 +1263,7 @@ def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -1303,7 +1302,7 @@ def test_cannot_create_score_calibration_in_public_score_set_when_score_set_not_ assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{score_set['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1501,7 +1500,7 @@ def test_cannot_update_score_calibration_when_score_set_not_exists( assert response.status_code == 404 error = response.json() - assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] + assert "ScoreSet with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] @pytest.mark.parametrize( @@ -1614,7 +1613,7 @@ def test_cannot_update_score_calibration_when_score_set_not_owned_by_user( assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -1656,7 +1655,7 @@ def test_cannot_update_score_calibration_in_published_score_set_when_score_set_n assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{score_set['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1736,7 +1735,7 @@ def test_cannot_update_published_score_calibration_as_score_set_owner( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1843,7 +1842,7 @@ def test_cannot_update_non_investigator_score_calibration_as_score_set_contribut assert response.status_code == 404 calibration_response = response.json() - assert f"score calibration with URN '{calibration['urn']}' not found" in calibration_response["detail"] + assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in calibration_response["detail"] @pytest.mark.parametrize( @@ -2029,7 +2028,7 @@ def test_user_may_not_move_investigator_calibration_when_lacking_permissions_on_ assert response.status_code == 404 error = response.json() - assert f"score set with URN '{score_set2['urn']}' not found" in error["detail"] + assert f"ScoreSet with URN '{score_set2['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -2224,7 +2223,7 @@ def test_cannot_delete_score_calibration_when_score_set_not_owned_by_user( assert response.status_code == 404 error = response.json() - assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -2291,7 +2290,7 @@ def test_cannot_delete_published_score_calibration_as_owner( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2304,7 +2303,7 @@ def test_cannot_delete_published_score_calibration_as_owner( ], indirect=["mock_publication_fetch"], ) -def test_can_delete_investigator_score_calibration_as_score_set_contributor( +def test_cannot_delete_investigator_score_calibration_as_score_set_contributor( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -2331,11 +2330,9 @@ def test_can_delete_investigator_score_calibration_as_score_set_contributor( with DependencyOverrider(extra_user_app_overrides): response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") - assert response.status_code == 204 - - # verify it's deleted - get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") - assert get_response.status_code == 404 + error = response.json() + assert response.status_code == 403 + assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2487,7 +2484,7 @@ def test_cannot_delete_primary_score_calibration( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] ########################################################### @@ -2573,7 +2570,7 @@ def test_cannot_promote_score_calibration_when_score_calibration_not_owned_by_us assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2910,7 +2907,7 @@ def test_cannot_promote_to_primary_with_demote_existing_flag_if_user_does_not_ha assert response.status_code == 403 promotion_response = response.json() - assert "insufficient permissions for URN" in promotion_response["detail"] + assert "insufficient permissions on ScoreCalibration with URN" in promotion_response["detail"] # verify the previous primary is still primary @@ -3002,7 +2999,7 @@ def test_cannot_demote_score_calibration_when_score_calibration_not_owned_by_use assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -3255,7 +3252,7 @@ def test_cannot_publish_score_calibration_when_score_calibration_not_owned_by_us assert response.status_code == 404 error = response.json() - assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( diff --git a/tests/routers/test_score_set.py b/tests/routers/test_score_set.py index 862343926..203274f5e 100644 --- a/tests/routers/test_score_set.py +++ b/tests/routers/test_score_set.py @@ -679,7 +679,7 @@ def test_cannot_get_other_user_private_score_set(session, client, setup_router_d response = client.get(f"/api/v1/score-sets/{score_set['urn']}") assert response.status_code == 404 response_data = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] def test_anonymous_user_cannot_get_user_private_score_set(session, client, setup_router_db, anonymous_app_overrides): @@ -691,7 +691,7 @@ def test_anonymous_user_cannot_get_user_private_score_set(session, client, setup assert response.status_code == 404 response_data = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] def test_can_add_contributor_in_both_experiment_and_score_set(session, client, setup_router_db): @@ -1048,7 +1048,7 @@ def test_cannot_add_scores_to_other_user_score_set(session, client, setup_router ) assert response.status_code == 404 response_data = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] # A user should not be able to add scores to another users' score set. Therefore, they should also not be able @@ -1386,7 +1386,7 @@ def test_cannot_publish_other_user_private_score_set(session, data_provider, cli worker_queue.assert_not_called() response_data = response.json() - assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] def test_anonymous_cannot_publish_user_private_score_set( @@ -1408,7 +1408,7 @@ def test_anonymous_cannot_publish_user_private_score_set( assert "Could not validate credentials" in response_data["detail"] -def test_contributor_can_publish_other_users_score_set(session, data_provider, client, setup_router_db, data_files): +def test_contributor_cannot_publish_other_users_score_set(session, data_provider, client, setup_router_db, data_files): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) score_set = mock_worker_variant_insertion(client, session, data_provider, score_set, data_files / "scores.csv") @@ -1423,60 +1423,15 @@ def test_contributor_can_publish_other_users_score_set(session, data_provider, c ) with patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as worker_queue: - published_score_set = publish_score_set(client, score_set["urn"]) - worker_queue.assert_called_once() - - assert published_score_set["urn"] == "urn:mavedb:00000001-a-1" - assert published_score_set["experiment"]["urn"] == "urn:mavedb:00000001-a" - - expected_response = update_expected_response_for_created_resources( - deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), published_score_set["experiment"], published_score_set - ) - expected_response["experiment"].update({"publishedDate": date.today().isoformat(), "numScoreSets": 1}) - expected_response.update( - { - "urn": published_score_set["urn"], - "publishedDate": date.today().isoformat(), - "numVariants": 3, - "private": False, - "datasetColumns": SAVED_MINIMAL_DATASET_COLUMNS, - "processingState": ProcessingState.success.name, - } - ) - expected_response["contributors"] = [ - { - "recordType": "Contributor", - "orcidId": TEST_USER["username"], - "givenName": TEST_USER["first_name"], - "familyName": TEST_USER["last_name"], - } - ] - expected_response["createdBy"] = { - "recordType": "User", - "orcidId": EXTRA_USER["username"], - "firstName": EXTRA_USER["first_name"], - "lastName": EXTRA_USER["last_name"], - } - expected_response["modifiedBy"] = { - "recordType": "User", - "orcidId": EXTRA_USER["username"], - "firstName": EXTRA_USER["first_name"], - "lastName": EXTRA_USER["last_name"], - } - assert sorted(expected_response.keys()) == sorted(published_score_set.keys()) - - # refresh score set to post worker state - score_set = (client.get(f"/api/v1/score-sets/{published_score_set['urn']}")).json() - for key in expected_response: - assert (key, expected_response[key]) == (key, score_set[key]) + response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") + assert response.status_code == 403 + worker_queue.assert_not_called() + response_data = response.json() - score_set_variants = session.execute( - select(VariantDbModel).join(ScoreSetDbModel).where(ScoreSetDbModel.urn == score_set["urn"]) - ).scalars() - assert all([variant.urn.startswith("urn:mavedb:") for variant in score_set_variants]) + assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in response_data["detail"] -def test_admin_cannot_publish_other_user_private_score_set( +def test_admin_can_publish_other_user_private_score_set( session, data_provider, client, admin_app_overrides, setup_router_db, data_files ): experiment = create_experiment(client) @@ -1488,11 +1443,8 @@ def test_admin_cannot_publish_other_user_private_score_set( patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as queue, ): response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") - assert response.status_code == 404 - queue.assert_not_called() - response_data = response.json() - - assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] + assert response.status_code == 200 + queue.assert_called_once() ######################################################################################################################## @@ -2334,7 +2286,9 @@ def test_cannot_delete_own_published_scoreset(session, data_provider, client, se assert del_response.status_code == 403 del_response_data = del_response.json() - assert f"insufficient permissions for URN '{published_score_set['urn']}'" in del_response_data["detail"] + assert ( + f"insufficient permissions on ScoreSet with URN '{published_score_set['urn']}'" in del_response_data["detail"] + ) def test_contributor_can_delete_other_users_private_scoreset( @@ -2355,7 +2309,9 @@ def test_contributor_can_delete_other_users_private_scoreset( response = client.delete(f"/api/v1/score-sets/{score_set['urn']}") - assert response.status_code == 200 + assert response.status_code == 403 + response_data = response.json() + assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in response_data["detail"] def test_admin_can_delete_other_users_private_scoreset( @@ -2409,7 +2365,7 @@ def test_cannot_add_score_set_to_others_private_experiment(session, client, setu response = client.post("/api/v1/score-sets/", json=score_set_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"experiment with URN '{experiment_urn}' not found" in response_data["detail"] + assert f"Experiment with URN '{experiment_urn}' not found" in response_data["detail"] def test_can_add_score_set_to_own_public_experiment(session, data_provider, client, setup_router_db, data_files): @@ -2966,7 +2922,7 @@ def test_cannot_fetch_clinical_controls_for_nonexistent_score_set( assert response.status_code == 404 response_data = response.json() - assert f"score set with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] + assert f"ScoreSet with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] def test_cannot_fetch_clinical_controls_for_score_set_when_none_exist( @@ -3031,7 +2987,7 @@ def test_cannot_get_annotated_variants_for_nonexistent_score_set(client, setup_r response_data = response.json() assert response.status_code == 404 - assert f"score set with URN {score_set['urn'] + 'xxx'} not found" in response_data["detail"] + assert f"ScoreSet with URN {score_set['urn'] + 'xxx'} not found" in response_data["detail"] @pytest.mark.parametrize( @@ -3564,7 +3520,7 @@ def test_cannot_fetch_gnomad_variants_for_nonexistent_score_set( assert response.status_code == 404 response_data = response.json() - assert f"score set with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] + assert f"ScoreSet with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] def test_cannot_fetch_gnomad_variants_for_score_set_when_none_exist( diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index 03b57c0bc..1009aabc5 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -241,7 +241,7 @@ def test_user_cannot_update_other_users(client, setup_router_db, field_name, fie response = client.put("/api/v1/users//2", json=user_update) assert response.status_code == 403 response_value = response.json() - assert response_value["detail"] in "Insufficient permissions for user update." + assert response_value["detail"] in "insufficient permissions on User with ID '2'" @pytest.mark.parametrize( From d4c685c68f5f39f69c300a31fe42f94f489b6eaf Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 4 Dec 2025 15:34:19 -0800 Subject: [PATCH 14/18] fix: add importorskip for permission tests --- tests/lib/permissions/conftest.py | 8 +++++--- tests/lib/permissions/test_collection.py | 8 ++++++-- tests/lib/permissions/test_core.py | 8 ++++++-- tests/lib/permissions/test_experiment.py | 8 ++++++-- tests/lib/permissions/test_experiment_set.py | 8 ++++++-- tests/lib/permissions/test_models.py | 6 ++++++ tests/lib/permissions/test_score_calibration.py | 8 ++++++-- tests/lib/permissions/test_score_set.py | 8 ++++++-- tests/lib/permissions/test_user.py | 8 ++++++-- tests/lib/permissions/test_utils.py | 8 ++++++-- 10 files changed, 59 insertions(+), 19 deletions(-) diff --git a/tests/lib/permissions/conftest.py b/tests/lib/permissions/conftest.py index b01c228c0..302159f5e 100644 --- a/tests/lib/permissions/conftest.py +++ b/tests/lib/permissions/conftest.py @@ -1,15 +1,17 @@ """Shared fixtures and helpers for permissions tests.""" from dataclasses import dataclass -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union from unittest.mock import Mock import pytest -from mavedb.lib.permissions.actions import Action from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.enums.user_role import UserRole +if TYPE_CHECKING: + from mavedb.lib.permissions.actions import Action + @dataclass class PermissionTest: @@ -33,7 +35,7 @@ class PermissionTest: entity_type: str entity_state: Optional[str] user_type: str - action: Action + action: "Action" should_be_permitted: Union[bool, str] expected_code: Optional[int] = None description: Optional[str] = None diff --git a/tests/lib/permissions/test_collection.py b/tests/lib/permissions/test_collection.py index 7217b07a5..ab0593bbc 100644 --- a/tests/lib/permissions/test_collection.py +++ b/tests/lib/permissions/test_collection.py @@ -1,10 +1,14 @@ +# ruff: noqa: E402 + """Tests for Collection permissions module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from typing import Callable, List from unittest import mock -import pytest - from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.collection import ( _handle_add_badge_action, diff --git a/tests/lib/permissions/test_core.py b/tests/lib/permissions/test_core.py index 69074d346..55a99107c 100644 --- a/tests/lib/permissions/test_core.py +++ b/tests/lib/permissions/test_core.py @@ -1,9 +1,13 @@ -"""Tests for core permissions functionality.""" +# ruff: noqa: E402 -from unittest.mock import Mock, patch +"""Tests for core permissions functionality.""" import pytest +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from unittest.mock import Mock, patch + from mavedb.lib.permissions import ( assert_permission, collection, diff --git a/tests/lib/permissions/test_experiment.py b/tests/lib/permissions/test_experiment.py index 2118685ee..b4e5dc240 100644 --- a/tests/lib/permissions/test_experiment.py +++ b/tests/lib/permissions/test_experiment.py @@ -1,10 +1,14 @@ +# ruff: noqa: E402 + """Tests for Experiment permissions module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from typing import Callable, List from unittest import mock -import pytest - from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.experiment import ( _handle_add_score_set_action, diff --git a/tests/lib/permissions/test_experiment_set.py b/tests/lib/permissions/test_experiment_set.py index 2f3f3df16..adf109fb7 100644 --- a/tests/lib/permissions/test_experiment_set.py +++ b/tests/lib/permissions/test_experiment_set.py @@ -1,10 +1,14 @@ +# ruff: noqa: E402 + """Tests for ExperimentSet permissions module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from typing import Callable, List from unittest import mock -import pytest - from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.experiment_set import ( _handle_add_experiment_action, diff --git a/tests/lib/permissions/test_models.py b/tests/lib/permissions/test_models.py index e571d51cc..7627d56a2 100644 --- a/tests/lib/permissions/test_models.py +++ b/tests/lib/permissions/test_models.py @@ -1,5 +1,11 @@ +# ruff: noqa: E402 + """Tests for permissions models module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from mavedb.lib.permissions.models import PermissionResponse diff --git a/tests/lib/permissions/test_score_calibration.py b/tests/lib/permissions/test_score_calibration.py index 0d96c4fb9..a33843680 100644 --- a/tests/lib/permissions/test_score_calibration.py +++ b/tests/lib/permissions/test_score_calibration.py @@ -1,10 +1,14 @@ +# ruff: noqa: E402 + """Tests for ScoreCalibration permissions module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from typing import Callable, List from unittest import mock -import pytest - from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.score_calibration import ( _handle_change_rank_action, diff --git a/tests/lib/permissions/test_score_set.py b/tests/lib/permissions/test_score_set.py index f871eb617..2349359f7 100644 --- a/tests/lib/permissions/test_score_set.py +++ b/tests/lib/permissions/test_score_set.py @@ -1,10 +1,14 @@ +# ruff: noqa: E402 + """Tests for ScoreSet permissions module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from typing import Callable, List from unittest import mock -import pytest - from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.score_set import ( _handle_delete_action, diff --git a/tests/lib/permissions/test_user.py b/tests/lib/permissions/test_user.py index 66e731e4f..b4efa876b 100644 --- a/tests/lib/permissions/test_user.py +++ b/tests/lib/permissions/test_user.py @@ -1,10 +1,14 @@ +# ruff: noqa: E402 + """Tests for User permissions module.""" +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + from typing import Callable, List from unittest import mock -import pytest - from mavedb.lib.permissions.actions import Action from mavedb.lib.permissions.user import ( _handle_add_role_action, diff --git a/tests/lib/permissions/test_utils.py b/tests/lib/permissions/test_utils.py index 513edc6f3..d0ae7c830 100644 --- a/tests/lib/permissions/test_utils.py +++ b/tests/lib/permissions/test_utils.py @@ -1,9 +1,13 @@ -"""Tests for permissions utils module.""" +# ruff: noqa: E402 -from unittest.mock import Mock +"""Tests for permissions utils module.""" import pytest +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from unittest.mock import Mock + from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted from mavedb.models.enums.contribution_role import ContributionRole from mavedb.models.enums.user_role import UserRole From f02ba1e2d741bf4176bbcfb7e2df8181640f080c Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Fri, 12 Dec 2025 18:08:10 -0800 Subject: [PATCH 15/18] fix: use TEST_ENVIRONMENT envvar in email validator tester rather than manipulating domain list --- tests/conftest.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c79c033e8..b11f728c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import logging # noqa: F401 +import sys from datetime import datetime from unittest import mock -import sys import email_validator import pytest @@ -11,35 +11,33 @@ from sqlalchemy.pool import NullPool from mavedb.db.base import Base +from mavedb.models import * # noqa: F403 +from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet -from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation -from mavedb.models.user import User, UserRole, Role from mavedb.models.license import License -from mavedb.models.taxonomy import Taxonomy -from mavedb.models.publication_identifier import PublicationIdentifier -from mavedb.models.experiment import Experiment -from mavedb.models.variant import Variant from mavedb.models.mapped_variant import MappedVariant +from mavedb.models.publication_identifier import PublicationIdentifier from mavedb.models.score_set import ScoreSet - -from mavedb.models import * # noqa: F403 - +from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation +from mavedb.models.taxonomy import Taxonomy +from mavedb.models.user import Role, User, UserRole +from mavedb.models.variant import Variant from tests.helpers.constants import ( ADMIN_USER, EXTRA_USER, - TEST_LICENSE, + TEST_BRNICH_SCORE_CALIBRATION, TEST_INACTIVE_LICENSE, + TEST_LICENSE, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, TEST_SAVED_TAXONOMY, TEST_USER, - VALID_VARIANT_URN, - VALID_SCORE_SET_URN, - VALID_EXPERIMENT_URN, - VALID_EXPERIMENT_SET_URN, - TEST_PUBMED_IDENTIFIER, TEST_VALID_POST_MAPPED_VRS_ALLELE_VRS2_X, TEST_VALID_PRE_MAPPED_VRS_ALLELE_VRS2_X, - TEST_BRNICH_SCORE_CALIBRATION, - TEST_PATHOGENICITY_SCORE_CALIBRATION, + VALID_EXPERIMENT_SET_URN, + VALID_EXPERIMENT_URN, + VALID_SCORE_SET_URN, + VALID_VARIANT_URN, ) sys.path.append(".") @@ -56,7 +54,7 @@ assert pytest_postgresql.factories # Allow the @test domain name through our email validator. -email_validator.SPECIAL_USE_DOMAIN_NAMES.remove("test") +email_validator.TEST_ENVIRONMENT = True @pytest.fixture() From e746a424950f304a865a5f6654fd5b66707f064d Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 15 Dec 2025 11:29:21 -0800 Subject: [PATCH 16/18] fix: do not show internal model names in user facing error messages. See https://github.com/VariantEffect/mavedb-api/issues/613 for a possible solution to the pain renaming many distributed user facing model names. --- src/mavedb/lib/permissions/collection.py | 18 +++---- src/mavedb/lib/permissions/experiment.py | 10 ++-- src/mavedb/lib/permissions/experiment_set.py | 10 ++-- .../lib/permissions/score_calibration.py | 12 ++--- src/mavedb/lib/permissions/score_set.py | 12 ++--- src/mavedb/lib/permissions/user.py | 11 +++-- src/mavedb/lib/permissions/utils.py | 7 +-- src/mavedb/routers/experiment_sets.py | 2 +- src/mavedb/routers/experiments.py | 4 +- src/mavedb/routers/score_calibrations.py | 8 ++-- src/mavedb/routers/score_sets.py | 30 ++++++------ src/mavedb/routers/users.py | 6 +-- tests/lib/permissions/test_utils.py | 4 +- tests/routers/test_collections.py | 12 ++--- tests/routers/test_experiments.py | 20 ++++---- tests/routers/test_score_calibrations.py | 48 +++++++++---------- tests/routers/test_score_set.py | 22 ++++----- tests/routers/test_users.py | 10 ++-- 18 files changed, 124 insertions(+), 122 deletions(-) diff --git a/src/mavedb/lib/permissions/collection.py b/src/mavedb/lib/permissions/collection.py index 8768e72ad..916db06bf 100644 --- a/src/mavedb/lib/permissions/collection.py +++ b/src/mavedb/lib/permissions/collection.py @@ -73,7 +73,7 @@ def has_permission(user_data: Optional[UserData], entity: Collection, action: Ac if action not in handlers: supported_actions = ", ".join(a.value for a in handlers.keys()) raise NotImplementedError( - f"Action '{action.value}' is not supported for Collection entities. " + f"Action '{action.value}' is not supported for collection entities. " f"Supported actions: {supported_actions}" ) @@ -129,7 +129,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_update_action( @@ -169,7 +169,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_delete_action( @@ -210,7 +210,7 @@ def _handle_delete_action( if user_is_owner and private: return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_publish_action( @@ -249,7 +249,7 @@ def _handle_publish_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_add_experiment_action( @@ -290,7 +290,7 @@ def _handle_add_experiment_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_add_score_set_action( @@ -330,7 +330,7 @@ def _handle_add_score_set_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_add_role_action( @@ -369,7 +369,7 @@ def _handle_add_role_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") def _handle_add_badge_action( @@ -402,4 +402,4 @@ def _handle_add_badge_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner) + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") diff --git a/src/mavedb/lib/permissions/experiment.py b/src/mavedb/lib/permissions/experiment.py index 2c4462bb9..834de45be 100644 --- a/src/mavedb/lib/permissions/experiment.py +++ b/src/mavedb/lib/permissions/experiment.py @@ -58,7 +58,7 @@ def has_permission(user_data: Optional[UserData], entity: Experiment, action: Ac if action not in handlers: supported_actions = ", ".join(a.value for a in handlers.keys()) raise NotImplementedError( - f"Action '{action.value}' is not supported for Experiment entities. " + f"Action '{action.value}' is not supported for experiment entities. " f"Supported actions: {supported_actions}" ) @@ -108,7 +108,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") def _handle_update_action( @@ -143,7 +143,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") def _handle_delete_action( @@ -179,7 +179,7 @@ def _handle_delete_action( if user_is_owner and private: return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") def _handle_add_score_set_action( @@ -218,4 +218,4 @@ def _handle_add_score_set_action( if not private and user_data is not None: return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") diff --git a/src/mavedb/lib/permissions/experiment_set.py b/src/mavedb/lib/permissions/experiment_set.py index 44bffe2c0..13497fb31 100644 --- a/src/mavedb/lib/permissions/experiment_set.py +++ b/src/mavedb/lib/permissions/experiment_set.py @@ -58,7 +58,7 @@ def has_permission(user_data: Optional[UserData], entity: ExperimentSet, action: if action not in handlers: supported_actions = ", ".join(a.value for a in handlers.keys()) raise NotImplementedError( - f"Action '{action.value}' is not supported for ExperimentSet entities. " + f"Action '{action.value}' is not supported for experiment set entities. " f"Supported actions: {supported_actions}" ) @@ -108,7 +108,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") def _handle_update_action( @@ -143,7 +143,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") def _handle_delete_action( @@ -179,7 +179,7 @@ def _handle_delete_action( if user_is_owner and private: return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") def _handle_add_experiment_action( @@ -215,4 +215,4 @@ def _handle_add_experiment_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") diff --git a/src/mavedb/lib/permissions/score_calibration.py b/src/mavedb/lib/permissions/score_calibration.py index 90241eab5..08c27068d 100644 --- a/src/mavedb/lib/permissions/score_calibration.py +++ b/src/mavedb/lib/permissions/score_calibration.py @@ -64,7 +64,7 @@ def has_permission(user_data: Optional[UserData], entity: ScoreCalibration, acti if action not in handlers: supported_actions = ", ".join(a.value for a in handlers.keys()) raise NotImplementedError( - f"Action '{action.value}' is not supported for ScoreCalibration entities. " + f"Action '{action.value}' is not supported for score calibration entities. " f"Supported actions: {supported_actions}" ) @@ -119,7 +119,7 @@ def _handle_read_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return deny_action_for_entity(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") def _handle_update_action( @@ -162,7 +162,7 @@ def _handle_update_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return deny_action_for_entity(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") def _handle_delete_action( @@ -198,7 +198,7 @@ def _handle_delete_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return deny_action_for_entity(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") def _handle_publish_action( @@ -235,7 +235,7 @@ def _handle_publish_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return deny_action_for_entity(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") def _handle_change_rank_action( @@ -274,4 +274,4 @@ def _handle_change_rank_action( return PermissionResponse(True) user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) - return deny_action_for_entity(entity, private, user_data, user_may_view_private) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") diff --git a/src/mavedb/lib/permissions/score_set.py b/src/mavedb/lib/permissions/score_set.py index 239361561..6a9922406 100644 --- a/src/mavedb/lib/permissions/score_set.py +++ b/src/mavedb/lib/permissions/score_set.py @@ -59,7 +59,7 @@ def has_permission(user_data: Optional[UserData], entity: ScoreSet, action: Acti if action not in handlers: supported_actions = ", ".join(a.value for a in handlers.keys()) raise NotImplementedError( - f"Action '{action.value}' is not supported for ScoreSet entities. " + f"Action '{action.value}' is not supported for score set entities. " f"Supported actions: {supported_actions}" ) @@ -109,7 +109,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") def _handle_update_action( @@ -144,7 +144,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") def _handle_delete_action( @@ -180,7 +180,7 @@ def _handle_delete_action( if user_is_owner and private: return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") def _handle_publish_action( @@ -216,7 +216,7 @@ def _handle_publish_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") def _handle_set_scores_action( @@ -252,4 +252,4 @@ def _handle_set_scores_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner) + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") diff --git a/src/mavedb/lib/permissions/user.py b/src/mavedb/lib/permissions/user.py index ed76dc151..908c84d69 100644 --- a/src/mavedb/lib/permissions/user.py +++ b/src/mavedb/lib/permissions/user.py @@ -56,7 +56,8 @@ def has_permission(user_data: Optional[UserData], entity: User, action: Action) if action not in handlers: supported_actions = ", ".join(a.value for a in handlers.keys()) raise NotImplementedError( - f"Action '{action.value}' is not supported for User entities. " f"Supported actions: {supported_actions}" + f"Action '{action.value}' is not supported for user profile entities. " + f"Supported actions: {supported_actions}" ) return handlers[action]( @@ -101,7 +102,7 @@ def _handle_read_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, False, user_data, False) + return deny_action_for_entity(entity, False, user_data, False, "user profile") def _handle_lookup_action( @@ -129,7 +130,7 @@ def _handle_lookup_action( if user_data is not None and user_data.user is not None: return PermissionResponse(True) - return deny_action_for_entity(entity, False, user_data, False) + return deny_action_for_entity(entity, False, user_data, False, "user profile") def _handle_update_action( @@ -160,7 +161,7 @@ def _handle_update_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, False, user_data, False) + return deny_action_for_entity(entity, False, user_data, False, "user profile") def _handle_add_role_action( @@ -188,4 +189,4 @@ def _handle_add_role_action( if roles_permitted(active_roles, [UserRole.admin]): return PermissionResponse(True) - return deny_action_for_entity(entity, False, user_data, False) + return deny_action_for_entity(entity, False, user_data, False, "user profile") diff --git a/src/mavedb/lib/permissions/utils.py b/src/mavedb/lib/permissions/utils.py index 4e00735f0..4d3a32bf5 100644 --- a/src/mavedb/lib/permissions/utils.py +++ b/src/mavedb/lib/permissions/utils.py @@ -86,6 +86,7 @@ def deny_action_for_entity( private: bool, user_data: Optional[UserData], user_may_view_private: bool, + user_facing_model_name: str = "entity", ) -> PermissionResponse: """ Generate appropriate denial response for entity permission checks. @@ -118,14 +119,14 @@ def _identifier_for_entity(entity: EntityType) -> tuple[str, str]: field, identifier = _identifier_for_entity(entity) # Do not acknowledge the existence of a private score set. if private and not user_may_view_private: - return PermissionResponse(False, 404, f"{entity.__class__.__name__} with {field} '{identifier}' not found") + return PermissionResponse(False, 404, f"{user_facing_model_name} with {field} '{identifier}' not found") # No authenticated user is present. if user_data is None or user_data.user is None: return PermissionResponse( - False, 401, f"authentication required to access {entity.__class__.__name__} with {field} '{identifier}'" + False, 401, f"authentication required to access {user_facing_model_name} with {field} '{identifier}'" ) # The authenticated user lacks sufficient permissions. return PermissionResponse( - False, 403, f"insufficient permissions on {entity.__class__.__name__} with {field} '{identifier}'" + False, 403, f"insufficient permissions on {user_facing_model_name} with {field} '{identifier}'" ) diff --git a/src/mavedb/routers/experiment_sets.py b/src/mavedb/routers/experiment_sets.py index 386da37b9..1166fb7f0 100644 --- a/src/mavedb/routers/experiment_sets.py +++ b/src/mavedb/routers/experiment_sets.py @@ -57,7 +57,7 @@ def fetch_experiment_set( # the exception is raised, not returned - you will get a validation # error otherwise. logger.debug(msg="The requested resources does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"Experiment set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"experiment set with URN {urn} not found") else: item.experiments.sort(key=attrgetter("urn")) diff --git a/src/mavedb/routers/experiments.py b/src/mavedb/routers/experiments.py index a0e52ff66..2064196b6 100644 --- a/src/mavedb/routers/experiments.py +++ b/src/mavedb/routers/experiments.py @@ -155,7 +155,7 @@ def fetch_experiment( if not item: logger.debug(msg="The requested experiment does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"Experiment with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"experiment with URN {urn} not found") assert_permission(user_data, item, Action.READ) return enrich_experiment_with_num_score_sets(item, user_data) @@ -247,7 +247,7 @@ async def create_experiment( ) raise HTTPException( status_code=404, - detail=f"ExperimentSet with URN '{item_create.experiment_set_urn}' not found.", + detail=f"experiment set with URN '{item_create.experiment_set_urn}' not found.", ) save_to_logging_context({"experiment_set": experiment_set.urn}) diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index 4ae2a59a9..d5bceb887 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -84,7 +84,7 @@ async def get_score_calibrations_for_score_set( if not score_set: logger.debug("ScoreSet not found", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{score_set_urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{score_set_urn}' not found") assert_permission(user_data, score_set, Action.READ) @@ -124,7 +124,7 @@ async def get_primary_score_calibrations_for_score_set( score_set = db.query(ScoreSet).filter(ScoreSet.urn == score_set_urn).one_or_none() if not score_set: logger.debug("ScoreSet not found", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{score_set_urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{score_set_urn}' not found") assert_permission(user_data, score_set, Action.READ) @@ -184,7 +184,7 @@ async def create_score_calibration_route( score_set = db.query(ScoreSet).filter(ScoreSet.urn == calibration.score_set_urn).one_or_none() if not score_set: logger.debug("ScoreSet not found", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{calibration.score_set_urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{calibration.score_set_urn}' not found") # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with # permission to update the score set itself. @@ -222,7 +222,7 @@ async def modify_score_calibration_route( if not score_set: logger.debug("ScoreSet not found", extra=logging_context()) raise HTTPException( - status_code=404, detail=f"ScoreSet with URN '{calibration_update.score_set_urn}' not found" + status_code=404, detail=f"score set with URN '{calibration_update.score_set_urn}' not found" ) # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with diff --git a/src/mavedb/routers/score_sets.py b/src/mavedb/routers/score_sets.py index b93d918e7..959f91337 100644 --- a/src/mavedb/routers/score_sets.py +++ b/src/mavedb/routers/score_sets.py @@ -179,7 +179,7 @@ async def score_set_update( item = existing_item or db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() if not item or item.id is None: logger.info(msg="Failed to update score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, item, Action.UPDATE) @@ -507,7 +507,7 @@ async def fetch_score_set_by_urn( if not item: logger.info(msg="Could not fetch the requested score set; No such score sets exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user, item, Action.READ) @@ -767,7 +767,7 @@ def get_score_set_variants_csv( score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: logger.info(msg="Could not fetch the requested scores; No such score set exists.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, score_set, Action.READ) @@ -835,7 +835,7 @@ def get_score_set_scores_csv( score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: logger.info(msg="Could not fetch the requested scores; No such score set exists.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, score_set, Action.READ) @@ -893,7 +893,7 @@ async def get_score_set_counts_csv( score_set = db.query(ScoreSet).filter(ScoreSet.urn == urn).first() if not score_set: logger.info(msg="Could not fetch the requested counts; No such score set exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -924,7 +924,7 @@ def get_score_set_mapped_variants( logger.info( msg="Could not fetch the requested mapped variants; No such score set exist.", extra=logging_context() ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1090,7 +1090,7 @@ def get_score_set_annotated_variants( msg="Could not fetch the requested pathogenicity evidence lines; No such score set exists.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1206,7 +1206,7 @@ def get_score_set_annotated_variants_functional_statement( msg="Could not fetch the requested functional impact statements; No such score set exists.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1326,7 +1326,7 @@ def get_score_set_annotated_variants_functional_study_result( msg="Could not fetch the requested functional study results; No such score set exists.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"score set with URN {urn} not found") assert_permission(user_data, score_set, Action.READ) @@ -1823,7 +1823,7 @@ async def update_score_set_with_variants( raise RequestValidationError(errors=e.errors()) else: logger.info(msg="Failed to update score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") itemUpdateResult = await score_set_update( db=db, @@ -1958,7 +1958,7 @@ async def delete_score_set( item = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() if not item: logger.info(msg="Failed to delete score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, item, Action.DELETE) @@ -1988,7 +1988,7 @@ async def publish_score_set( item: Optional[ScoreSet] = db.query(ScoreSet).filter(ScoreSet.urn == urn).one_or_none() if not item: logger.info(msg="Failed to publish score set; The requested score set does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, item, Action.PUBLISH) @@ -2101,7 +2101,7 @@ async def get_clinical_controls_for_score_set( msg="Failed to fetch clinical controls for score set; The requested score set does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, item, Action.READ) @@ -2165,7 +2165,7 @@ async def get_clinical_controls_options_for_score_set( msg="Failed to fetch clinical control options for score set; The requested score set does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, item, Action.READ) @@ -2229,7 +2229,7 @@ async def get_gnomad_variants_for_score_set( msg="Failed to fetch gnomad variants for score set; The requested score set does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"ScoreSet with URN '{urn}' not found") + raise HTTPException(status_code=404, detail=f"score set with URN '{urn}' not found") assert_permission(user_data, item, Action.READ) diff --git a/src/mavedb/routers/users.py b/src/mavedb/routers/users.py index fd3a4d959..79c9cb88e 100644 --- a/src/mavedb/routers/users.py +++ b/src/mavedb/routers/users.py @@ -104,7 +104,7 @@ async def show_user_admin( msg="Could not show user; Requested user does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"User with ID {id} not found") + raise HTTPException(status_code=404, detail=f"user profile with ID {id} not found") # moving toward always accessing permissions module, even though this function does already require admin role to access assert_permission(user_data, item, Action.READ) @@ -135,7 +135,7 @@ async def show_user( msg="Could not show user; Requested user does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"User with ID {orcid_id} not found") + raise HTTPException(status_code=404, detail=f"user profile with ID {orcid_id} not found") # moving toward always accessing permissions module, even though this function does already require existing user in order to access assert_permission(user_data, item, Action.LOOKUP) @@ -217,7 +217,7 @@ async def update_user( msg="Could not update user; Requested user does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"User with id {id} not found.") + raise HTTPException(status_code=404, detail=f"user profile with id {id} not found.") assert_permission(user_data, item, Action.UPDATE) assert_permission(user_data, item, Action.ADD_ROLE) diff --git a/tests/lib/permissions/test_utils.py b/tests/lib/permissions/test_utils.py index d0ae7c830..0cc8d76a2 100644 --- a/tests/lib/permissions/test_utils.py +++ b/tests/lib/permissions/test_utils.py @@ -218,6 +218,6 @@ class CustomEntity: pass entity = CustomEntity() - response = deny_action_for_entity(entity, True, None, False) + response = deny_action_for_entity(entity, True, None, False, "custom entity") - assert "CustomEntity" in response.message + assert "custom entity" in response.message diff --git a/tests/routers/test_collections.py b/tests/routers/test_collections.py index 3a39dd3a3..f7103a9b9 100644 --- a/tests/routers/test_collections.py +++ b/tests/routers/test_collections.py @@ -197,7 +197,7 @@ def test_unauthorized_user_cannot_read_private_collection(session, client, setup response = client.get(f"/api/v1/collections/{collection['urn']}") assert response.status_code == 404 - assert f"Collection with URN '{collection['urn']}'" in response.json()["detail"] + assert f"collection with URN '{collection['urn']}'" in response.json()["detail"] def test_anonymous_cannot_read_private_collection(session, client, setup_router_db, anonymous_app_overrides): @@ -207,7 +207,7 @@ def test_anonymous_cannot_read_private_collection(session, client, setup_router_ response = client.get(f"/api/v1/collections/{collection['urn']}") assert response.status_code == 404 - assert f"Collection with URN '{collection['urn']}'" in response.json()["detail"] + assert f"collection with URN '{collection['urn']}'" in response.json()["detail"] def test_anonymous_can_read_public_collection(session, client, setup_router_db, anonymous_app_overrides): @@ -359,7 +359,7 @@ def test_viewer_cannot_add_experiment_to_collection( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions on Collection with URN '{collection['urn']}'" in response_data["detail"] + assert f"insufficient permissions on collection with URN '{collection['urn']}'" in response_data["detail"] def test_unauthorized_user_cannot_add_experiment_to_collection( @@ -384,7 +384,7 @@ def test_unauthorized_user_cannot_add_experiment_to_collection( ) assert response.status_code == 404 - assert f"Collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] def test_anonymous_cannot_add_experiment_to_collection( @@ -543,7 +543,7 @@ def test_viewer_cannot_add_score_set_to_collection( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions on Collection with URN '{collection['urn']}'" in response_data["detail"] + assert f"insufficient permissions on collection with URN '{collection['urn']}'" in response_data["detail"] def test_unauthorized_user_cannot_add_score_set_to_collection( @@ -567,7 +567,7 @@ def test_unauthorized_user_cannot_add_score_set_to_collection( ) assert response.status_code == 404 - assert f"Collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] def test_anonymous_cannot_add_score_set_to_collection( diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index 25947119f..cd4a54ada 100644 --- a/tests/routers/test_experiments.py +++ b/tests/routers/test_experiments.py @@ -531,7 +531,7 @@ def test_cannot_assign_to_missing_experiment_set(client, setup_router_db): response = client.post("/api/v1/experiments/", json=experiment_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"ExperimentSet with URN '{experiment_set_urn}' not found" in response_data["detail"] + assert f"experiment set with URN '{experiment_set_urn}' not found" in response_data["detail"] def test_can_update_own_private_experiment_set(session, client, setup_router_db): @@ -553,7 +553,7 @@ def test_cannot_update_other_users_private_experiment_set(session, client, setup response = client.post("/api/v1/experiments/", json=experiment_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"ExperimentSet with URN '{experiment['experimentSetUrn']}' not found" in response_data["detail"] + assert f"experiment set with URN '{experiment['experimentSetUrn']}' not found" in response_data["detail"] def test_anonymous_cannot_update_other_users_private_experiment_set( @@ -622,7 +622,7 @@ def test_cannot_update_other_users_public_experiment_set(session, data_provider, assert response.status_code == 403 response_data = response.json() assert ( - f"insufficient permissions on ExperimentSet with URN '{published_experiment_set_urn}'" + f"insufficient permissions on experiment set with URN '{published_experiment_set_urn}'" in response_data["detail"] ) @@ -759,7 +759,7 @@ def test_cannot_edit_other_users_private_experiment(client, session, setup_route response = client.put(f"/api/v1/experiments/{experiment['urn']}", json=experiment_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"Experiment with URN '{experiment['urn']}' not found" in response_data["detail"] + assert f"experiment with URN '{experiment['urn']}' not found" in response_data["detail"] @pytest.mark.parametrize( @@ -1139,7 +1139,7 @@ def test_cannot_get_other_users_private_experiment(session, client, setup_router response = client.get(f"/api/v1/experiments/{experiment['urn']}") assert response.status_code == 404 response_data = response.json() - assert f"Experiment with URN '{experiment['urn']}' not found" in response_data["detail"] + assert f"experiment with URN '{experiment['urn']}' not found" in response_data["detail"] def test_anonymous_cannot_get_users_private_experiment(session, client, anonymous_app_overrides, setup_router_db): @@ -1149,7 +1149,7 @@ def test_anonymous_cannot_get_users_private_experiment(session, client, anonymou assert response.status_code == 404 response_data = response.json() - assert f"Experiment with URN '{experiment['urn']}' not found" in response_data["detail"] + assert f"experiment with URN '{experiment['urn']}' not found" in response_data["detail"] def test_admin_can_get_other_users_private_experiment(client, admin_app_overrides, setup_router_db): @@ -1654,7 +1654,7 @@ def test_cannot_delete_own_published_experiment(session, data_provider, client, assert del_response.status_code == 403 del_response_data = del_response.json() - assert f"insufficient permissions on Experiment with URN '{experiment_urn}'" in del_response_data["detail"] + assert f"insufficient permissions on experiment with URN '{experiment_urn}'" in del_response_data["detail"] def test_contributor_cannot_delete_other_users_private_experiment( @@ -1673,7 +1673,7 @@ def test_contributor_cannot_delete_other_users_private_experiment( response = client.delete(f"/api/v1/experiments/{experiment['urn']}") assert response.status_code == 403 - assert f"insufficient permissions on Experiment with URN '{experiment['urn']}'" in response.json()["detail"] + assert f"insufficient permissions on experiment with URN '{experiment['urn']}'" in response.json()["detail"] def test_admin_can_delete_other_users_private_experiment(session, client, setup_router_db, admin_app_overrides): @@ -1814,7 +1814,7 @@ def test_cannot_add_experiment_to_others_private_experiment_set(session, client, response = client.post("/api/v1/experiments/", json=test_experiment) assert response.status_code == 404 response_data = response.json() - assert f"ExperimentSet with URN '{experiment_set_urn}' not found" in response_data["detail"] + assert f"experiment set with URN '{experiment_set_urn}' not found" in response_data["detail"] def test_cannot_add_experiment_to_others_public_experiment_set( @@ -1839,4 +1839,4 @@ def test_cannot_add_experiment_to_others_public_experiment_set( response = client.post("/api/v1/experiments/", json=test_experiment) assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions on ExperimentSet with URN '{experiment_set_urn}'" in response_data["detail"] + assert f"insufficient permissions on experiment set with URN '{experiment_set_urn}'" in response_data["detail"] diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index 211e37298..5235decb2 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -75,7 +75,7 @@ def test_anonymous_user_cannot_get_score_calibration_when_private( assert response.status_code == 404 error = response.json() - assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -108,7 +108,7 @@ def test_other_user_cannot_get_score_calibration_when_private( assert response.status_code == 404 error = response.json() - assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -235,7 +235,7 @@ def test_contributing_user_cannot_get_score_calibration_when_private_and_not_inv assert response.status_code == 404 error = response.json() - assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -519,7 +519,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_private assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -552,7 +552,7 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_private( assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -880,7 +880,7 @@ def test_anonymous_user_cannot_get_score_calibrations_for_score_set_when_calibra assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -920,7 +920,7 @@ def test_other_user_cannot_get_score_calibrations_for_score_set_when_calibration assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -1227,7 +1227,7 @@ def test_cannot_create_score_calibration_when_score_set_does_not_exist(client, s assert response.status_code == 404 error = response.json() - assert "ScoreSet with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] + assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] @pytest.mark.parametrize( @@ -1263,7 +1263,7 @@ def test_cannot_create_score_calibration_when_score_set_not_owned_by_user( assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -1302,7 +1302,7 @@ def test_cannot_create_score_calibration_in_public_score_set_when_score_set_not_ assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in error["detail"] + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1500,7 +1500,7 @@ def test_cannot_update_score_calibration_when_score_set_not_exists( assert response.status_code == 404 error = response.json() - assert "ScoreSet with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] + assert "score set with URN 'urn:ngs:score-set:nonexistent' not found" in error["detail"] @pytest.mark.parametrize( @@ -1613,7 +1613,7 @@ def test_cannot_update_score_calibration_when_score_set_not_owned_by_user( assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -1655,7 +1655,7 @@ def test_cannot_update_score_calibration_in_published_score_set_when_score_set_n assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in error["detail"] + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1735,7 +1735,7 @@ def test_cannot_update_published_score_calibration_as_score_set_owner( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1842,7 +1842,7 @@ def test_cannot_update_non_investigator_score_calibration_as_score_set_contribut assert response.status_code == 404 calibration_response = response.json() - assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in calibration_response["detail"] + assert f"score calibration with URN '{calibration['urn']}' not found" in calibration_response["detail"] @pytest.mark.parametrize( @@ -2028,7 +2028,7 @@ def test_user_may_not_move_investigator_calibration_when_lacking_permissions_on_ assert response.status_code == 404 error = response.json() - assert f"ScoreSet with URN '{score_set2['urn']}' not found" in error["detail"] + assert f"score set with URN '{score_set2['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -2223,7 +2223,7 @@ def test_cannot_delete_score_calibration_when_score_set_not_owned_by_user( assert response.status_code == 404 error = response.json() - assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( @@ -2290,7 +2290,7 @@ def test_cannot_delete_published_score_calibration_as_owner( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2332,7 +2332,7 @@ def test_cannot_delete_investigator_score_calibration_as_score_set_contributor( error = response.json() assert response.status_code == 403 - assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2484,7 +2484,7 @@ def test_cannot_delete_primary_score_calibration( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] ########################################################### @@ -2570,7 +2570,7 @@ def test_cannot_promote_score_calibration_when_score_calibration_not_owned_by_us assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2907,7 +2907,7 @@ def test_cannot_promote_to_primary_with_demote_existing_flag_if_user_does_not_ha assert response.status_code == 403 promotion_response = response.json() - assert "insufficient permissions on ScoreCalibration with URN" in promotion_response["detail"] + assert "insufficient permissions on score calibration with URN" in promotion_response["detail"] # verify the previous primary is still primary @@ -2999,7 +2999,7 @@ def test_cannot_demote_score_calibration_when_score_calibration_not_owned_by_use assert response.status_code == 403 error = response.json() - assert f"insufficient permissions on ScoreCalibration with URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -3252,7 +3252,7 @@ def test_cannot_publish_score_calibration_when_score_calibration_not_owned_by_us assert response.status_code == 404 error = response.json() - assert f"ScoreCalibration with URN '{calibration['urn']}' not found" in error["detail"] + assert f"score calibration with URN '{calibration['urn']}' not found" in error["detail"] @pytest.mark.parametrize( diff --git a/tests/routers/test_score_set.py b/tests/routers/test_score_set.py index 203274f5e..09a2c25b7 100644 --- a/tests/routers/test_score_set.py +++ b/tests/routers/test_score_set.py @@ -679,7 +679,7 @@ def test_cannot_get_other_user_private_score_set(session, client, setup_router_d response = client.get(f"/api/v1/score-sets/{score_set['urn']}") assert response.status_code == 404 response_data = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] def test_anonymous_user_cannot_get_user_private_score_set(session, client, setup_router_db, anonymous_app_overrides): @@ -691,7 +691,7 @@ def test_anonymous_user_cannot_get_user_private_score_set(session, client, setup assert response.status_code == 404 response_data = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] def test_can_add_contributor_in_both_experiment_and_score_set(session, client, setup_router_db): @@ -1048,7 +1048,7 @@ def test_cannot_add_scores_to_other_user_score_set(session, client, setup_router ) assert response.status_code == 404 response_data = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] # A user should not be able to add scores to another users' score set. Therefore, they should also not be able @@ -1386,7 +1386,7 @@ def test_cannot_publish_other_user_private_score_set(session, data_provider, cli worker_queue.assert_not_called() response_data = response.json() - assert f"ScoreSet with URN '{score_set['urn']}' not found" in response_data["detail"] + assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] def test_anonymous_cannot_publish_user_private_score_set( @@ -1428,7 +1428,7 @@ def test_contributor_cannot_publish_other_users_score_set(session, data_provider worker_queue.assert_not_called() response_data = response.json() - assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in response_data["detail"] + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in response_data["detail"] def test_admin_can_publish_other_user_private_score_set( @@ -2287,7 +2287,7 @@ def test_cannot_delete_own_published_scoreset(session, data_provider, client, se assert del_response.status_code == 403 del_response_data = del_response.json() assert ( - f"insufficient permissions on ScoreSet with URN '{published_score_set['urn']}'" in del_response_data["detail"] + f"insufficient permissions on score set with URN '{published_score_set['urn']}'" in del_response_data["detail"] ) @@ -2311,7 +2311,7 @@ def test_contributor_can_delete_other_users_private_scoreset( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions on ScoreSet with URN '{score_set['urn']}'" in response_data["detail"] + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in response_data["detail"] def test_admin_can_delete_other_users_private_scoreset( @@ -2365,7 +2365,7 @@ def test_cannot_add_score_set_to_others_private_experiment(session, client, setu response = client.post("/api/v1/score-sets/", json=score_set_post_payload) assert response.status_code == 404 response_data = response.json() - assert f"Experiment with URN '{experiment_urn}' not found" in response_data["detail"] + assert f"experiment with URN '{experiment_urn}' not found" in response_data["detail"] def test_can_add_score_set_to_own_public_experiment(session, data_provider, client, setup_router_db, data_files): @@ -2922,7 +2922,7 @@ def test_cannot_fetch_clinical_controls_for_nonexistent_score_set( assert response.status_code == 404 response_data = response.json() - assert f"ScoreSet with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] + assert f"score set with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] def test_cannot_fetch_clinical_controls_for_score_set_when_none_exist( @@ -2987,7 +2987,7 @@ def test_cannot_get_annotated_variants_for_nonexistent_score_set(client, setup_r response_data = response.json() assert response.status_code == 404 - assert f"ScoreSet with URN {score_set['urn'] + 'xxx'} not found" in response_data["detail"] + assert f"score set with URN {score_set['urn'] + 'xxx'} not found" in response_data["detail"] @pytest.mark.parametrize( @@ -3520,7 +3520,7 @@ def test_cannot_fetch_gnomad_variants_for_nonexistent_score_set( assert response.status_code == 404 response_data = response.json() - assert f"ScoreSet with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] + assert f"score set with URN '{score_set['urn'] + 'xxx'}' not found" in response_data["detail"] def test_cannot_fetch_gnomad_variants_for_score_set_when_none_exist( diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index 1009aabc5..79f13caed 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -142,7 +142,7 @@ def test_fetching_nonexistent_user_as_admin_raises_exception(client, setup_route assert response.status_code == 404 response_value = response.json() - assert "User with ID 0 not found" in response_value["detail"] + assert "user profile with ID 0 not found" in response_value["detail"] # Some lingering db transaction holds this test open unless it is explicitly closed. session.commit() @@ -209,7 +209,7 @@ def test_admin_can_set_logged_in_property_on_self(client, setup_router_db, admin [ ("email", "updated@test.com"), ("first_name", "Updated"), - ("last_name", "User"), + ("last_name", "user profile"), ("roles", ["admin"]), ], ) @@ -231,7 +231,7 @@ def test_anonymous_user_cannot_update_other_users( [ ("email", "updated@test.com"), ("first_name", "Updated"), - ("last_name", "User"), + ("last_name", "user profile"), ("roles", ["admin"]), ], ) @@ -241,7 +241,7 @@ def test_user_cannot_update_other_users(client, setup_router_db, field_name, fie response = client.put("/api/v1/users//2", json=user_update) assert response.status_code == 403 response_value = response.json() - assert response_value["detail"] in "insufficient permissions on User with ID '2'" + assert response_value["detail"] in "insufficient permissions on user profile with ID '2'" @pytest.mark.parametrize( @@ -249,7 +249,7 @@ def test_user_cannot_update_other_users(client, setup_router_db, field_name, fie [ ("email", "updated@test.com"), ("first_name", "Updated"), - ("last_name", "User"), + ("last_name", "user profile"), ("roles", ["admin"]), ], ) From a18ebf58d4e614aefd9f91f4701366b0b6a4ef85 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 15 Dec 2025 11:32:15 -0800 Subject: [PATCH 17/18] fix: flip error message assertion 'in' statements --- tests/routers/test_users.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index 79f13caed..c6b0e4116 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -21,14 +21,14 @@ def test_cannot_list_users_as_anonymous_user(client, setup_router_db, anonymous_ assert response.status_code == 401 response_value = response.json() - assert response_value["detail"] in "Could not validate credentials" + assert "Could not validate credentials" in response_value["detail"] def test_cannot_list_users_as_normal_user(client, setup_router_db): response = client.get("/api/v1/users/") assert response.status_code == 403 response_value = response.json() - assert response_value["detail"] in "You are not authorized to use this feature" + assert "You are not authorized to use this feature" in response_value["detail"] def test_can_list_users_as_admin_user(admin_app_overrides, setup_router_db, client): @@ -50,7 +50,7 @@ def test_cannot_get_anonymous_user(client, setup_router_db, session, anonymous_a assert response.status_code == 401 response_value = response.json() - assert response_value["detail"] in "Could not validate credentials" + assert "Could not validate credentials" in response_value["detail"] # Some lingering db transaction holds this test open unless it is explicitly closed. session.commit() @@ -223,7 +223,7 @@ def test_anonymous_user_cannot_update_other_users( assert response.status_code == 401 response_value = response.json() - assert response_value["detail"] in "Could not validate credentials" + assert "Could not validate credentials" in response_value["detail"] @pytest.mark.parametrize( @@ -241,7 +241,7 @@ def test_user_cannot_update_other_users(client, setup_router_db, field_name, fie response = client.put("/api/v1/users//2", json=user_update) assert response.status_code == 403 response_value = response.json() - assert response_value["detail"] in "insufficient permissions on user profile with ID '2'" + assert "insufficient permissions on user profile with ID '2'" in response_value["detail"] @pytest.mark.parametrize( From 6e4bfa00204aa5a2976201920df9e61f177cdc7f Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 15 Dec 2025 11:33:03 -0800 Subject: [PATCH 18/18] fix: remove unnecessary session.commit() calls in user tests --- tests/routers/test_users.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index c6b0e4116..68fa382de 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -52,9 +52,6 @@ def test_cannot_get_anonymous_user(client, setup_router_db, session, anonymous_a response_value = response.json() assert "Could not validate credentials" in response_value["detail"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_get_current_user(client, setup_router_db, session): response = client.get("/api/v1/users/me") @@ -62,9 +59,6 @@ def test_get_current_user(client, setup_router_db, session): response_value = response.json() assert response_value["orcidId"] == TEST_USER["username"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_get_current_admin_user(client, admin_app_overrides, setup_router_db, session): with DependencyOverrider(admin_app_overrides): @@ -75,9 +69,6 @@ def test_get_current_admin_user(client, admin_app_overrides, setup_router_db, se assert response_value["orcidId"] == ADMIN_USER["username"] assert response_value["roles"] == ["admin"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_cannot_impersonate_admin_user_as_default_user(client, setup_router_db, session): # NOTE: We can't mock JWTBearer directly because the object is created when the `get_current_user` function is called. @@ -100,9 +91,6 @@ def test_cannot_impersonate_admin_user_as_default_user(client, setup_router_db, assert response.status_code == 403 assert response.json()["detail"] in "This user is not a member of the requested acting role." - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_cannot_fetch_single_user_as_anonymous_user(client, setup_router_db, session, anonymous_app_overrides): with DependencyOverrider(anonymous_app_overrides): @@ -111,18 +99,12 @@ def test_cannot_fetch_single_user_as_anonymous_user(client, setup_router_db, ses assert response.status_code == 401 assert response.json()["detail"] in "Could not validate credentials" - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_cannot_fetch_single_user_as_normal_user(client, setup_router_db, session): response = client.get("/api/v1/users/2") assert response.status_code == 403 assert response.json()["detail"] in "You are not authorized to use this feature" - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_can_fetch_single_user_as_admin_user(client, setup_router_db, session, admin_app_overrides): with DependencyOverrider(admin_app_overrides): @@ -132,9 +114,6 @@ def test_can_fetch_single_user_as_admin_user(client, setup_router_db, session, a response_value = response.json() assert response_value["orcidId"] == EXTRA_USER["username"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_fetching_nonexistent_user_as_admin_raises_exception(client, setup_router_db, session, admin_app_overrides): with DependencyOverrider(admin_app_overrides): @@ -144,9 +123,6 @@ def test_fetching_nonexistent_user_as_admin_raises_exception(client, setup_route response_value = response.json() assert "user profile with ID 0 not found" in response_value["detail"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_anonymous_user_cannot_update_self(client, setup_router_db, anonymous_app_overrides): user_update = TEST_USER.copy()