diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..398454d71 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Credentials the test suite runs with - copy to .env and fill in (see tests/env.py). +# pytest tests/integration +# Tests that only apply to one kind of token are skipped when another kind is set here. +SA_TOKEN= +SA_URL=https://api.devsuperannotate.com +# Required for an organization API key, which carries no team of its own. Any other key +# resolves its own team, and a value given here has to match it. +# SA_TEAM_ID= + +# The project-admin contributor suite (tests/integration/client) needs two more keys: +# a team contributor's personal key, and the team owner's to set the projects up with. +# It is skipped while SA_PROJECT_ADMIN_TOKEN is unset. +# SA_OWNER_PERSONAL_TOKEN= +# SA_CONTRIBUTOR_TOKEN= diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index 5a72270eb..c3487d60b 100644 --- a/docs/source/userguide/quickstart.rst +++ b/docs/source/userguide/quickstart.rst @@ -38,8 +38,9 @@ on the team setup page, for more details please visit our documentation at https - **Team API key** — scoped to one team. Works with ``SAClient``. - **Personal (team-user) API key** — scoped to one team, tied to your user. Works with ``SAClient``. -- **Organization API key** — not scoped to a team. Not supported by the SDK; - ``SAClient`` will reject it. +- **Organization API key** — not scoped to a team, so the team to operate in has to be + given along with it: ``SAClient(token="", team_id=)``, or + ``SA_TEAM_ID`` in the environment or the config file. SAClient can be used with or without arguments @@ -76,6 +77,13 @@ ______________________________________________ sa_client = SAClient(token="") +An Organization API key carries no team, so it is passed together with the team to +operate in: + +.. code-block:: python + + sa_client = SAClient(token="", team_id=) + *Method 2:* Create a custom config file: @@ -93,6 +101,8 @@ Custom config.ini example: [DEFAULT] SA_TOKEN = + ; Only an Organization API key needs it; other keys carry their own team. + SA_TEAM_ID = LOGGING_LEVEL = INFO LOGGING_PATH = /Users/username/data/superannotate_logs diff --git a/pytest.ini b/pytest.ini index c0f66b58e..eec5cc921 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -addopts = -n 6 --dist loadscope +;addopts = -n 12 --dist loadscope diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 4b0db4080..cb59ce665 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -28,7 +28,12 @@ class BaseInterfaceFacade: REGISTRY = [] @validate_arguments - def __init__(self, token: TokenStr | None = None, config_path: str | None = None): + def __init__( + self, + token: TokenStr | None = None, + config_path: str | None = None, + team_id: int | None = None, + ): try: if token: config = ConfigEntity(SA_TOKEN=token) @@ -65,6 +70,9 @@ def __init__(self, token: TokenStr | None = None, config_path: str | None = None raise AppException(wrap_error(e)) if not config: raise AppException("Credentials not provided.") + # An explicit team_id wins over whatever the config source provided. + if team_id is not None: + config.TEAM_ID = team_id setup_logging(config.LOGGING_LEVEL, config.LOGGING_PATH) self.controller = Controller(config) BaseInterfaceFacade.REGISTRY.append(self) @@ -80,6 +88,9 @@ def _retrieve_configs_from_json(path: Path) -> ConfigEntity: raise AppException("Invalid token.") host = json_data.get("main_endpoint") verify_ssl = json_data.get("ssl_verify") + team_id = json_data.get("team_id") + if team_id is not None: + config.TEAM_ID = int(team_id) if host: config.API_URL = host if verify_ssl: diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 587992093..b02011bca 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -301,10 +301,20 @@ class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta): :param config_path: path to config file :type config_path: path-like (str or Path) + :param team_id: the team to operate in. Required for an Organization API key, which + is not bound to a team; for any other key it is optional and, when given, must + match the team the key grants access to. + :type team_id: int + """ - def __init__(self, token: str | None = None, config_path: str | None = None): - super().__init__(token, config_path) + def __init__( + self, + token: str | None = None, + config_path: str | None = None, + team_id: int | None = None, + ): + super().__init__(token, config_path, team_id=team_id) def get_project_by_id(self, project_id: int): """Returns the project metadata diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 2208b60d3..a78e7ea96 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -128,9 +128,12 @@ def _validate_token(value: str) -> str: class ConfigEntity(BaseModel): - model_config = ConfigDict(extra="ignore") + model_config = ConfigDict(extra="ignore", populate_by_name=True) API_TOKEN: TokenStr = Field(alias="SA_TOKEN") + #: The team to operate in. Only an organization API key needs it — its scope carries + #: no team; every other token resolves its own team. + TEAM_ID: int | None = Field(alias="SA_TEAM_ID", default=None) API_URL: str = Field(alias="SA_URL", default=BACKEND_URL) LOGGING_LEVEL: Literal[ "NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" diff --git a/src/superannotate/lib/core/entities/project.py b/src/superannotate/lib/core/entities/project.py index 7bd5f322a..9bd325b7a 100644 --- a/src/superannotate/lib/core/entities/project.py +++ b/src/superannotate/lib/core/entities/project.py @@ -115,7 +115,7 @@ def __eq__(self, other): class UserEntity(BaseModel): - model_config = ConfigDict(extra="ignore") + model_config = ConfigDict(extra="allow") id: str | None = None first_name: str | None = None diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index eff5363c6..f279d790c 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -86,7 +86,7 @@ class WMUserEntity(TimedBaseModel): id: int | None = None team_id: int | None = None - role: WMUserTypeEnum + role: WMUserTypeEnum | None = None email: str | None = None state: WMUserStateEnum | None = None custom_fields: dict | None = Field(default_factory=dict, alias="customField") diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 64fb30579..cf7435374 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -87,6 +87,7 @@ def paginate( chunk_size: int = 2000, query_params: dict[str, Any] | None = None, headers: dict | None = None, + method: Literal["get", "post"] = "get", ) -> ServiceResponse: raise NotImplementedError @@ -233,6 +234,10 @@ def update_user_activity( def list_scores(self) -> WMScoreListResponse: raise NotImplementedError + @abstractmethod + def list_project_scores(self, project_id: int) -> WMScoreListResponse: + raise NotImplementedError + @abstractmethod def create_score( self, diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 9946174fb..288896c3a 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -62,6 +62,8 @@ ANNOTATION_CHUNK_SIZE_MB = 10 * 1024 * 1024 URI_THRESHOLD = 4 * 1024 - 120 +STATUS_CHANGE_ERROR_MSG = "Failed to change status." + @dataclass class Report: @@ -418,20 +420,25 @@ def execute(self): {i.item.name for i in items_to_upload} - set(self._report.failed_annotations).union(set(skipped)) ) - workflow = self._service_provider.work_management.get_workflow( - self._project.workflow_id - ) - if workflow.is_system(): - if uploaded_annotations and not self._keep_status: - statuses_changed = set_annotation_statuses_in_progress( - service_provider=self._service_provider, - project=self._project, - folder=self._folder, - item_names=uploaded_annotations, - ) - if not statuses_changed: - self._response.errors = AppException("Failed to change status.") - + try: + workflow = self._service_provider.work_management.get_workflow( + self._project.workflow_id + ) + if workflow.is_system(): + if uploaded_annotations and not self._keep_status: + statuses_changed = set_annotation_statuses_in_progress( + service_provider=self._service_provider, + project=self._project, + folder=self._folder, + item_names=uploaded_annotations, + ) + if not statuses_changed: + self._response.errors = AppException( + STATUS_CHANGE_ERROR_MSG + ) + except AppException as e: + if e.message != "Forbidden": + raise e self._response.data = { "succeeded": uploaded_annotations, "failed": failed, @@ -741,19 +748,22 @@ def execute(self): name_path_mappings.keys() - set(self._report.failed_annotations).union(set(missing_annotations)) ) - workflow = self._service_provider.work_management.get_workflow( - self._project.workflow_id - ) - if workflow.is_system() and uploaded_annotations and not self._keep_status: - statuses_changed = set_annotation_statuses_in_progress( - service_provider=self._service_provider, - project=self._project, - folder=self._folder, - item_names=uploaded_annotations, + try: + workflow = self._service_provider.work_management.get_workflow( + self._project.workflow_id ) - if not statuses_changed: - self._response.errors = AppException("Failed to change status.") - + if workflow.is_system() and uploaded_annotations and not self._keep_status: + statuses_changed = set_annotation_statuses_in_progress( + service_provider=self._service_provider, + project=self._project, + folder=self._folder, + item_names=uploaded_annotations, + ) + if not statuses_changed: + self._response.errors = AppException(STATUS_CHANGE_ERROR_MSG) + except AppException as e: + if e.message != "Forbidden": + raise e if missing_annotations: logger.warning( f"Couldn't find {len(missing_annotations)}/{len(name_path_mappings.keys())} " @@ -950,20 +960,25 @@ def execute(self): self.reporter.log_warning( f"Couldn't find attribute {attr}." ) - workflow = self._service_provider.work_management.get_workflow( - self._project.workflow_id - ) - if workflow.is_system() and not self._keep_status: - statuses_changed = set_annotation_statuses_in_progress( - service_provider=self._service_provider, - project=self._project, - folder=self._folder, - item_names=[self._image.name], + try: + workflow = self._service_provider.work_management.get_workflow( + self._project.workflow_id ) - if not statuses_changed: - self._response.errors = AppException( - "Failed to change status." + if workflow.is_system() and not self._keep_status: + statuses_changed = set_annotation_statuses_in_progress( + service_provider=self._service_provider, + project=self._project, + folder=self._folder, + item_names=[self._image.name], ) + if not statuses_changed: + self._response.errors = AppException( + STATUS_CHANGE_ERROR_MSG + ) + except AppException as e: + if e.message != "Forbidden": + raise e + if self._verbose: self.reporter.log_info( f"Uploading annotations for image {str(self._image.name)} in project {self._project.name}." @@ -1719,7 +1734,7 @@ def execute(self): ) ) except Exception as e: - logger.error(e) + logger.exception(e) self._response.errors = AppException("Can't get annotations.") return self._response self.reporter.stop_spinner() @@ -2021,22 +2036,26 @@ def execute(self): folder_id=folder.id, item_id_category_map=item_id_category_map, ) - workflow = self._service_provider.work_management.get_workflow( - self._project.workflow_id - ) uploaded.extend(uploaded_annotations) - if workflow.is_system(): - if uploaded_annotations and not self._keep_status: - statuses_changed = set_annotation_statuses_in_progress( - service_provider=self._service_provider, - project=self._project, - folder=folder, - item_names=uploaded_annotations, - ) - if not statuses_changed: - self._response.errors = AppException( - "Failed to change status." + try: + workflow = self._service_provider.work_management.get_workflow( + self._project.workflow_id + ) + if workflow.is_system(): + if uploaded_annotations and not self._keep_status: + statuses_changed = set_annotation_statuses_in_progress( + service_provider=self._service_provider, + project=self._project, + folder=folder, + item_names=uploaded_annotations, ) + if not statuses_changed: + self._response.errors = AppException( + STATUS_CHANGE_ERROR_MSG + ) + except AppException as e: + if e.message != "Forbidden": + raise e self.reporter.finish_progress() self._report.failed_annotations = [] diff --git a/src/superannotate/lib/core/usecases/images.py b/src/superannotate/lib/core/usecases/images.py index 8542c5acb..f3d5d34a1 100644 --- a/src/superannotate/lib/core/usecases/images.py +++ b/src/superannotate/lib/core/usecases/images.py @@ -712,15 +712,19 @@ def __init__( self._s3_repo = s3_repo self._service_provider = service_provider if annotation_status_value is None: - workflow = self._service_provider.work_management.get_workflow( - self._project.workflow_id - ) - if workflow.is_system(): - annotation_status_value = ( - self._service_provider.get_annotation_status_value( - self._project, "NotStarted" - ) + try: + workflow = self._service_provider.work_management.get_workflow( + self._project.workflow_id ) + if workflow.is_system(): + annotation_status_value = ( + self._service_provider.get_annotation_status_value( + self._project, "NotStarted" + ) + ) + except AppException as e: + if e.message != "Forbidden": + raise e self._annotation_status_value = annotation_status_value self._auth_data = None diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 744867c8d..9e6348628 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -703,7 +703,7 @@ def execute(self): status_changed = self._service_provider.items.set_statuses( project=self._project, folder=self._folder, - item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203, + item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203 annotation_status=self._annotation_status_code, ) if not status_changed.ok: @@ -772,7 +772,7 @@ def execute(self): response = self._service_provider.items.set_approval_statuses( project=self._project, folder=self._folder, - item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203, + item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203 approval_status=self._approval_status_code, ) if not response.ok: @@ -826,10 +826,13 @@ def execute(self): item_ids = [item.id for item in items] for i in range(0, len(item_ids), self.CHUNK_SIZE): - self._service_provider.items.delete_multiple( + response = self._service_provider.items.delete_multiple( project=self._project, item_ids=item_ids[i : i + self.CHUNK_SIZE], # noqa: E203 ) + if not response.ok: + self._response.errors = response.error + return self._response logger.info( f"Items deleted in project {self._project.name}{'/' + self._folder.name if not self._folder.is_root else ''}" ) @@ -864,7 +867,7 @@ def __init__( def __filter_duplicates( self, ): - def uniqueQ(item, seen): + def _unique(item, seen): result = True if "id" in item: if item["id"] in seen: @@ -880,13 +883,13 @@ def uniqueQ(item, seen): return result seen = set() - uniques = [x for x in self.items if uniqueQ(x, seen)] + uniques = [x for x in self.items if _unique(x, seen)] return uniques def __filter_invalid_items( self, ): - def validQ(item): + def _valid(item): if "id" in item: return True if "name" in item and "path" in item: @@ -894,7 +897,7 @@ def validQ(item): self.results["skipped"].append(item) return False - filtered_items = [x for x in self.items if validQ(x)] + filtered_items = [x for x in self.items if _valid(x)] return filtered_items diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 2da47bf7a..6c6e4d53a 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -320,7 +320,9 @@ def get_user_scores( scored_user: str, provided_score_names: list[str] | None = None, ): - score_fields_res = self.service_provider.work_management.list_scores() + score_fields_res = self.service_provider.work_management.list_project_scores( + project_id=project.id + ) # validate provided score names all_score_names = [s.name for s in score_fields_res.data] @@ -1678,11 +1680,7 @@ def __init__(self, config: ConfigEntity): self._user_id = None self._reporter = None - self._token_context = resolve_token_context( - api_url=config.API_URL, - token=config.API_TOKEN, - verify_ssl=config.VERIFY_SSL, - ) + self._token_context = resolve_token_context(config=config) self._team_id = self._token_context.team_id http_client = HttpClient( diff --git a/src/superannotate/lib/infrastructure/services/auth.py b/src/superannotate/lib/infrastructure/services/auth.py index 81bf61355..7a8e151ea 100644 --- a/src/superannotate/lib/infrastructure/services/auth.py +++ b/src/superannotate/lib/infrastructure/services/auth.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass +from typing import TYPE_CHECKING import lib.core as constants import requests @@ -9,6 +10,9 @@ from lib.core.entities.project import UserEntity from lib.core.exceptions import AppException +if TYPE_CHECKING: + from lib.core.entities.base import ConfigEntity + logger = logging.getLogger("sa") SDK_AUTH_TYPE = "sdk" @@ -22,12 +26,21 @@ TEAM_SCOPE_TYPE = "team" #: A key issued for one user of a team; it acts as that user. TEAM_USER_SCOPE_TYPE = "teamuser" +#: A key issued for an organization. It carries no team, so the team to operate in has +#: to be given explicitly. +ORGANIZATION_SCOPE_TYPE = "organization" #: Token scopes that carry a team, and therefore need no explicit team_id. TEAM_SCOPED_TYPES = (TEAM_SCOPE_TYPE, TEAM_USER_SCOPE_TYPE) -ORGANIZATION_API_KEY_ERROR = ( - "SAClient does not accept an Organization API key — it requires a Team or " - "Personal API key." +ORGANIZATION_MISSING_TEAM_CONTEXT_ERROR = ( + 'Team context not provided. An Organization API key requires a "team_id".' +) +UNRESOLVED_TEAM_ERROR = ( + "Unable to resolve the team the provided token grants access to." +) +TEAM_ID_MISMATCH_ERROR = ( + 'The provided "team_id" ({team_id}) does not match the team the token grants ' + "access to ({token_team_id})." ) AUTHENTICATION_ERROR = ( "Unable to authenticate the provided token. Please verify your credentials." @@ -59,33 +72,49 @@ def is_personal_key(self) -> bool: """Whether the token acts as one specific user of the team.""" return self.scope_type == TEAM_USER_SCOPE_TYPE + @property + def is_organization_key(self) -> bool: + """Whether the token was issued for an organization rather than a team.""" + return self.scope_type == ORGANIZATION_SCOPE_TYPE + -def resolve_token_context( - api_url: str, - token: str, - verify_ssl: bool = True, -) -> TokenContext: +def resolve_token_context(config: ConfigEntity) -> TokenContext: """Resolve the team (and acting user) a token grants access to. Legacy team-owner tokens carry the team id, so they are resolved offline. New-style API keys are resolved against the work-management service, which reports the scope - the key was issued for. The SDK operates within a single team, so a key that is not - scoped to one is rejected. + the key was issued for. The SDK operates within a single team: a team or team-user + key names that team itself, while an organization key names none, so the team has to + come from the config (``SAClient(team_id=...)``, ``SA_TEAM_ID``). """ + token = config.API_TOKEN + requested_team_id = config.TEAM_ID if is_legacy_token(token): - return TokenContext(team_id=int(token.split("=")[-1]), auth_type=SDK_AUTH_TYPE) + team_id = int(token.split("=")[-1]) + _validate_requested_team(requested_team_id, team_id) + return TokenContext(team_id=team_id, auth_type=SDK_AUTH_TYPE) - data = _fetch_token_context(api_url, token, verify_ssl) + data = _fetch_token_context(config.API_URL, token, config.VERIFY_SSL) token_data = data.get("token") or {} scope = token_data.get("scope") or {} scope_type = token_data.get("scope_type") token_team_id = scope.get("team_id") - # Anything outside the allowlist (an organization key, today) has no team to operate - # in; the team_id check keeps a malformed response from resolving to no team at all. - if scope_type not in TEAM_SCOPED_TYPES or token_team_id is None: + if scope_type == ORGANIZATION_SCOPE_TYPE: + # An organization key has no team of its own; the caller picks the one to use. + if requested_team_id is None: + raise AppException(ORGANIZATION_MISSING_TEAM_CONTEXT_ERROR) + token_team_id = requested_team_id + elif scope_type in TEAM_SCOPED_TYPES: + # The team_id check keeps a malformed response from resolving to no team at all. + if token_team_id is None: + logger.debug(f"Got a {scope_type} scoped token with no team.") + raise AppException(UNRESOLVED_TEAM_ERROR) + _validate_requested_team(requested_team_id, token_team_id) + else: + # Anything outside the known scopes has no team to operate in. logger.debug(f"Rejected a token of {scope_type} scope.") - raise AppException(ORGANIZATION_API_KEY_ERROR) + raise AppException(UNRESOLVED_TEAM_ERROR) logger.debug(f"Token resolved to {scope_type} scope, team {token_team_id}.") return TokenContext( @@ -96,6 +125,18 @@ def resolve_token_context( ) +def _validate_requested_team(requested_team_id, token_team_id) -> None: + """A team_id passed alongside a team-carrying token must agree with it.""" + if requested_team_id is None: + return + if int(requested_team_id) != int(token_team_id): + raise AppException( + TEAM_ID_MISMATCH_ERROR.format( + team_id=requested_team_id, token_team_id=token_team_id + ) + ) + + def _get_work_management_url(api_url: str) -> str: # The token scope has to be resolved before there is a client to ask, so the # work-management host is derived here as well as in the service provider. diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index 8e627cda5..7d8fcc139 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -130,7 +130,7 @@ def _request(self, url, method, session, retried=0, **kwargs): ) if response.status_code > 299: logger.debug( - f"Got {response.status_code} from {url} response from backend" + f"Got {method} {response.status_code} from {url} response from backend {response.text}" ) return response @@ -173,6 +173,7 @@ def paginate( chunk_size: int = 2000, query_params: dict[str, Any] = None, headers: dict = None, + method: Literal["get", "post"] = "get", ) -> ServiceResponse: offset = 0 total = [] @@ -182,7 +183,7 @@ def paginate( _url = f"{url}{splitter}offset={offset}" _response = self.request( _url, - method="get", + method=method, params=query_params, dispatcher="data", headers=headers, diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 1eb571e43..282b3ac16 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -69,6 +69,7 @@ class WorkManagementService(BaseWorkManagementService): URL_CREATE_CATEGORIES = "categories/bulk" URL_CUSTOM_FIELD_TEMPLATES = "customfieldtemplates" URL_SCORES = "scores" + URL_PROJECT_SCORES = "scores/getProjectScores" URL_DELETE_SCORE = "scores/{score_id}" URL_CUSTOM_FIELD_TEMPLATE_DELETE = "customfieldtemplates/{template_id}" URL_SET_CUSTOM_ENTITIES = "customentities/{pk}" @@ -76,7 +77,7 @@ class WorkManagementService(BaseWorkManagementService): URL_SEARCH_TEAM_USERS = "teamusers/search" URL_SEARCH_PROJECT_USERS = "projectusers/search" URL_SEARCH_PROJECTS = "projects/search" - URL_RESUME_PAUSE_USER = "teams/editprojectsusers" + URL_RESUME_PAUSE_USER = "projectusers/editpausestate" URL_EDIT_CUSTOM_ENTITIES = "customentities/edit" URL_SET_TEAM_USER_PERMISSIONS = "teamusers/setpermissions" URL_PERMISSION_GROUPS = "permissiongroups" @@ -465,6 +466,18 @@ def list_scores(self) -> WMScoreListResponse: item_type=WMScoreEntity, ) + def list_project_scores(self, project_id: int) -> WMScoreListResponse: + return self.client.paginate( + url=self.URL_PROJECT_SCORES, + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id, project_id=project_id + ), + }, + item_type=WMScoreEntity, + method="post", + ) + def create_score( self, name: str, diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..f9de8a2ba --- /dev/null +++ b/tests/README.md @@ -0,0 +1,75 @@ +# Running the tests + +Unit tests need no credentials: + +```bash +pytest tests/unit +``` + +The integration tests talk to a real team, and take their credentials from a `.env` file +in the repository root (copy `.env.example`): + +```ini +SA_TOKEN= +SA_URL=https://api.devsuperannotate.com +# Required for an organization API key, which carries no team of its own. +SA_TEAM_ID=6085 +``` + +```bash +pytest tests/integration +``` + +The file is read before any test module is imported, so the modules that build their +client at import time with a bare `SAClient()` pick it up. Variables already exported in +the environment win over the file, so CI can provide them without a `.env`; with neither, +the SDK falls back to `~/.superannotate/config.ini`. + +Unit tests are hidden from these variables (`tests/unit/conftest.py`) - they assert how +the SDK itself resolves credentials. + +## Running as a different token type + +What the backend allows depends on the token in the `.env`: + +| Token | Acts as | Notes | +| --- | --- | --- | +| Organization API key | the organization | carries no team — `SA_TEAM_ID` is required | +| Team API key | the team, with no user behind it | user-level operations are denied | +| Personal (team-user) API key | the user it was issued for | owner or team admin, per key | +| Legacy team-owner token | the team owner | carries its team in the token | + +To run the suite as another type, put that token in the `.env` and run it again. Tests +that only apply to one type declare it and are skipped for the others (see +`tests/env.py`): + +```python +from tests import env + +@env.requires_organization_token +def test_org_only(sa_client): + ... +``` + +## Suites that bring their own token + +Some suites describe one specific kind of key rather than the run's own, so they carry +their own variables and are skipped while those are unset: + +```ini +# What a project-admin contributor may do (tests/integration/client). +SA_OWNER_PERSONAL_TOKEN= +SA_PROJECT_ADMIN_TOKEN= +``` + +`test_project_admin_token.py` runs its setup as the owner - it creates two projects and +makes the contributor a ProjectAdmin of one of them - and then does everything else as +the contributor, so the role's reach is measured against a project it was never given. +Two of its tests are `xfail`: a project-admin key cannot list team users, and so cannot +add contributors either. Both break inside the SDK, and the reasons on the tests say +where. + +`requires_team_token`, `requires_user_token` (personal or legacy) and +`requires_team_scoped_token` (anything but an organization key) work the same way. The +`sa_client` fixture is the client the run authenticates as, and `sa_token_scope` is its +scope. diff --git a/tests/conftest.py b/tests/conftest.py index d0c1b94ac..c425e7778 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,46 @@ import os import pytest +from tests import env + +# Read before any test module is imported, so a module that builds its client at import +# time authenticates with the project's .env rather than falling back to the SDK's own +# ~/.superannotate/config.ini. +env.load_dotenv() @pytest.fixture(autouse=True) def tests_setup(): os.environ.update({"SA_TESTING": "True", "SA_VERSION_CHECK": "False"}) + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "requires_token_scope(*scopes): run only when the .env token has one of these " + "scopes (see tests/env.py).", + ) + + +def pytest_runtest_setup(item): + # Resolved here rather than at import time: reading the token's scope costs a request + # to the backend, so only a run that actually reaches such a test pays for it. + for marker in item.iter_markers(name="requires_token_scope"): + scope = env.token_scope() + if scope not in marker.args: + pytest.skip( + f"requires a token of scope {' or '.join(marker.args)}; " + f"the configured one is {scope}" + ) + + +@pytest.fixture(scope="session") +def sa_client(): + """The client the suite runs as, built from the .env credentials.""" + return env.get_client() + + +@pytest.fixture(scope="session") +def sa_token_scope(): + """The scope of the token the suite runs with.""" + return env.token_scope() diff --git a/tests/env.py b/tests/env.py new file mode 100644 index 000000000..d41586417 --- /dev/null +++ b/tests/env.py @@ -0,0 +1,184 @@ +"""Credentials the test suite runs with, taken from a ``.env`` file. + +The suite talks to a real team, and which token it uses changes what the backend allows: +a team-scoped API key acts as the team (there is no user behind it), a personal key acts +as the user it was issued for, and an organization key is not bound to a team at all, so +it only works together with a team id. + +Put the credentials in a ``.env`` file at the repository root (override the path with +``SA_TEST_ENV_FILE``):: + + SA_TOKEN= + SA_URL=https://api.devsuperannotate.com + # Only an organization key needs it; any other key carries its own team. + SA_TEAM_ID=6085 + +The file is read before the integration modules build their clients, so a plain +``SAClient()`` picks it up. Values already set in the environment win over the file, +which is how CI provides them. With no ``.env`` and no environment the suite falls back +to the SDK's own ``~/.superannotate/config.ini``, as it always did. + +Tests that only apply to one kind of token declare it, and are skipped when the ``.env`` +holds another kind:: + + @env.requires_organization_token + def test_something_org_only(): ... + +A suite may also need a token of its own, beyond the one the run authenticates as - a +project-admin contributor's key, say. Those live under their own variables and gate the +whole module:: + + @env.requires_tokens(env.SA_CONTRIBUTOR_TOKEN_ENV) + class TestSomething(TestCase): + @classmethod + def setUpClass(cls): + cls.client = env.build_client(env.token(env.SA_CONTRIBUTOR_TOKEN_ENV)) +""" + +import contextlib +import os +import unittest +from functools import lru_cache +from pathlib import Path + +#: Overrides the location of the .env file. +ENV_FILE_ENV = "SA_TEST_ENV_FILE" +DEFAULT_ENV_FILE = Path(__file__).parent.parent / ".env" + +#: Token scopes, as the backend reports them. +ORGANIZATION = "organization" +TEAM = "team" +PERSONAL = "teamuser" +#: A legacy team-owner token: it carries its team and reports no scope. +LEGACY = "legacy" + +#: Tokens the suite can build an extra client with, beyond the ``SA_TOKEN`` it runs as. +#: A suite that needs one declares it (``requires_tokens``) and is skipped without it. +OWNER_PERSONAL_TOKEN_ENV = "SA_OWNER_PERSONAL_TOKEN" +SA_CONTRIBUTOR_TOKEN_ENV = "SA_CONTRIBUTOR_TOKEN" + + +def env_file() -> Path: + return Path(os.environ.get(ENV_FILE_ENV) or DEFAULT_ENV_FILE).expanduser() + + +def load_dotenv(path=None) -> dict: + """Read a ``.env`` file into the environment and return what it set. + + Only keys that are not already in the environment are set, so an explicitly exported + variable (CI, or a one-off run) always wins over the file. + """ + path = Path(path) if path else env_file() + if not path.is_file(): + return {} + loaded = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip().removeprefix("export ").strip() + value = value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + loaded[key] = value + return loaded + + +@lru_cache(maxsize=None) +def get_client(): + """The client the suite runs as, built from the environment (``.env``). + + Cached: building one costs a token-scope round trip to the backend. + """ + from src.superannotate import SAClient + + load_dotenv() + return SAClient() + + +@contextlib.contextmanager +def environ(**values): + """Temporarily set environment variables; a ``None`` value unsets one.""" + saved = {key: os.environ.get(key) for key in values} + try: + for key, value in values.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + yield + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def build_client(token: str, team_id: int | None = None, team_id_via_env: bool = False): + """An ``SAClient`` for an ad-hoc token, on the backend the ``.env`` names. + + The token reaches the SDK the way the suite's own credentials do - through the + environment - so ``SA_URL`` from the ``.env`` still applies. Passing it as + ``SAClient(token=...)`` would not: only the no-argument path reads ``SA_URL``. + + The team is passed as the ``team_id`` argument, or as ``SA_TEAM_ID`` when + ``team_id_via_env`` is set; both are paths a caller has. It is never inherited from + the ``.env``, so a client can be built with no team at all. + """ + from src.superannotate import SAClient + + load_dotenv() + with environ( + SA_TOKEN=token, + SA_TEAM_ID=str(team_id) if team_id is not None and team_id_via_env else None, + ): + return SAClient(team_id=None if team_id_via_env else team_id) + + +def token(name: str) -> str: + """A token the ``.env`` provides under ``name`` (one of the ``*_TOKEN_ENV``).""" + load_dotenv() + return os.environ[name] + + +def missing_tokens(*names: str) -> list[str]: + """Which of these tokens the ``.env`` does not provide.""" + load_dotenv() + return [name for name in names if not os.environ.get(name)] + + +def requires_tokens(*names: str): + """Run only when the ``.env`` provides every one of these tokens. + + Unlike ``requires_token_scope``, this asks nothing of the backend: the tokens are + either in the environment or they are not, so a whole ``TestCase`` can be skipped + on the spot. + """ + missing = missing_tokens(*names) + return unittest.skipIf( + bool(missing), f"needs {', '.join(missing)} in the .env (see tests/env.py)" + ) + + +def token_scope() -> str: + """The scope of the token the suite runs with: one of the constants above.""" + context = get_client().controller.token_context + return LEGACY if context.is_legacy else context.scope_type + + +def _requires(*scopes): + import pytest + + return pytest.mark.requires_token_scope(*scopes) + + +#: Only runs when the .env token is an organization key. +requires_organization_token = _requires(ORGANIZATION) +#: Only runs when the .env token is a team key (acting as the team, with no user). +requires_team_token = _requires(TEAM) +#: Only runs when the .env token acts as a user: a personal key or a legacy token. +requires_user_token = _requires(PERSONAL, LEGACY) +#: Only runs when the .env token carries its own team - anything but an organization key. +requires_team_scoped_token = _requires(TEAM, PERSONAL, LEGACY) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index e69de29bb..7a0d196d9 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -0,0 +1,6 @@ +"""Integration tests, run against a real team. + +The credentials come from the project's ``.env`` file (see ``tests/env.py``), which +``tests/conftest.py`` reads into the environment before any of these modules is imported - +they build their client at import time with a bare ``SAClient()``. +""" diff --git a/tests/integration/annotations/validations/test_gen_ai_annotation_validation.py b/tests/integration/annotations/validations/test_gen_ai_annotation_validation.py deleted file mode 100644 index 30075bb2f..000000000 --- a/tests/integration/annotations/validations/test_gen_ai_annotation_validation.py +++ /dev/null @@ -1,17 +0,0 @@ -from unittest import TestCase -from unittest.mock import patch - -from src.superannotate import SAClient - -sa = SAClient() - - -class TestVectorValidators(TestCase): - PROJECT_TYPE = "Multimodal" - - @patch("builtins.print") - def test_validate_annotation_without_metadata(self, mock_print): - # Failed because the BED does not have a validation schema for Multimodal projects. - is_valid = sa.validate_annotations(self.PROJECT_TYPE, {"instances": []}) - assert not is_valid - mock_print.assert_any_call("'metadata' is a required property") diff --git a/tests/integration/client/__init__.py b/tests/integration/client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/client/test_annotator_token.py b/tests/integration/client/test_annotator_token.py new file mode 100644 index 000000000..0ad2fe552 --- /dev/null +++ b/tests/integration/client/test_annotator_token.py @@ -0,0 +1,178 @@ +import contextlib +from unittest import TestCase + +from lib.core.exceptions import AppException +from tests import env + + +@env.requires_tokens(env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) +class TestAnnotatorToken(TestCase): + PROJECT_NAME = "TestAnnotatorToken" + FOREIGN_PROJECT_NAME = "TestTestAnnotatorTokenForeign" + PROJECT_DESCRIPTION = "annotator token suite" + PROJECT_TYPE = "Multimodal" + FOLDER_NAME = "test" + + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def setUp(self) -> None: + #: The team owner, who sets the projects up and cleans them up. + self.owner = env.build_client(env.token(env.OWNER_PERSONAL_TOKEN_ENV)) + #: The client under test: a contributor's key, made project admin below. + self.annotator = env.build_client(env.token(env.SA_CONTRIBUTOR_TOKEN_ENV)) + #: The user that key acts as - the one the owner promotes. + self.annotator_email = self.annotator.controller.current_user.email + + self._delete_projects() + self._project = self.owner.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, + {"attribute": "UploadImages", "value": 1}, + {"attribute": "DeleteImages", "value": 1}, + ], + form=self.MULTIMODAL_FORM, + ) + self.owner.create_project( + self.FOREIGN_PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, + ], + form=self.MULTIMODAL_FORM, + ) + added, skipped = self.owner.add_contributors_to_project( + self.PROJECT_NAME, [self.annotator_email], "Annotator" + ) + assert self.annotator_email in added + skipped, ( + f"{self.annotator_email} is out of the team scope, so it cannot be made " + f"a annotator - {env.SA_CONTRIBUTOR_TOKEN_ENV} has to belong to a member " + f"of the team {env.OWNER_PERSONAL_TOKEN_ENV} owns" + ) + + def tearDown(self) -> None: + self._delete_projects() + + def _delete_projects(self) -> None: + for name in (self.PROJECT_NAME, self.FOREIGN_PROJECT_NAME): + for project in self.owner.list_projects(name=name): + with contextlib.suppress(Exception): + self.owner.delete_project(project["id"]) + + def _team_contributor(self): + """A team contributor for the project admin to add, found as the owner. + + The lookup runs as the owner on purpose: a project-admin key cannot list team + users (see ``test_lists_team_users``), so it cannot pick its own candidate. + """ + for user in self.owner.list_users(): + if user["role"] == "Contributor" and user["email"] != self.annotator_email: + return user + self.skipTest("the team has no other contributor to add to a project") + + def test_lists_only_the_projects_it_has_access_to(self): + visible = {p["name"] for p in self.annotator.list_projects()} + + assert self.PROJECT_NAME in visible + # The second project was never shared, so the role must not surface it. + assert self.FOREIGN_PROJECT_NAME not in visible + assert self.FOREIGN_PROJECT_NAME in { + p["name"] for p in self.owner.list_projects() + } + + def test_adds_a_contributor_to_its_project(self): + scapegoat = self._team_contributor() + with self.assertRaisesRegex( + AppException, "You do not have sufficient access to share this project." + ): + self.annotator.add_contributors_to_project( + self.PROJECT_NAME, [scapegoat["email"]], "Annotator" + ) + + project_roles = { + user["email"]: user["role"] + for user in self.annotator.list_users(project=self.PROJECT_NAME) + } + assert project_roles.get(scapegoat["email"]) == "Annotator" + + def test_lists_team_users(self): + team_users = self.annotator.list_users() + + assert self.annotator_email in {user["email"] for user in team_users} + + def test_lists_the_users_of_its_project(self): + project_users = self.annotator.list_users(project=self.PROJECT_NAME) + + project_roles = {user["email"]: user["role"] for user in project_users} + assert project_roles[self.annotator_email] == "Annotator" + + def test_creates_a_folder_in_its_project(self): + folder = self.annotator.create_folder(self.PROJECT_NAME, self.FOLDER_NAME) + + assert folder["name"] == self.FOLDER_NAME + assert self.FOLDER_NAME in { + f["name"] for f in self.annotator.list_folders(self.PROJECT_NAME) + } + + def test_get_list_delete_items(self): + self.owner.generate_items(self.PROJECT_NAME, count=5, name="test") + + items = self.annotator.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + item = self.annotator.get_item_metadata(self.PROJECT_NAME, items[0]["name"]) + assert len(items) == 5 + assert item is not None + self.annotator.delete_items(self.PROJECT_NAME) + item = self.annotator.get_item_metadata(self.PROJECT_NAME, items[0]["name"]) + assert len(items) == 0 + + def test_get_set_annotation(self): + self.annotator.generate_items(self.PROJECT_NAME, count=5, name="test") + annotations = self.annotator.get_annotations( + self.PROJECT_NAME, + ) + assert len(annotations) == 5 + self.annotator.upload_annotations(self.PROJECT_NAME, annotations) + + def test_get_project_metadata(self): + self.annotator.get_project_metadata( + project=self.PROJECT_NAME, + include_annotation_classes=True, + include_settings=True, + # include_workflow=True, + include_contributors=True, + include_complete_item_count=True, + ) + + def test_set_item_status(self): + self.owner.generate_items(self.PROJECT_NAME, count=1, name="test") + + items = self.annotator.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + self.owner.set_annotation_statuses( + self.PROJECT_NAME, "Completed", [items[0]["name"]] + ) + items = self.owner.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + assert items[0]["annotation_status"] == "Completed" diff --git a/tests/integration/client/test_project_admin_token.py b/tests/integration/client/test_project_admin_token.py new file mode 100644 index 000000000..3cb49a22f --- /dev/null +++ b/tests/integration/client/test_project_admin_token.py @@ -0,0 +1,473 @@ +"""What a project-admin contributor's API key can do. + +The suite runs only when the ``.env`` holds a personal API key of a team contributor +(``SA_PROJECT_ADMIN_TOKEN``); it is skipped otherwise. The setup runs as the team owner +(``SA_OWNER_PERSONAL_TOKEN``) and creates two projects, making that contributor a +ProjectAdmin of one of them - so what the role grants can be told apart from what it +does not. Neither client comes from the suite's own ``SA_TOKEN``: these tests describe +the project-admin key itself, whichever token the rest of the run uses. + +Two of them are ``xfail``: a project-admin key cannot list team users, and therefore +cannot add contributors either. Both fail inside the SDK rather than at the backend, see +the reasons on the tests. +""" + +import contextlib +import json +import os +import time +import uuid +from pathlib import Path +from unittest import TestCase + +from src.superannotate import AppException +from tests import env +from tests.integration.work_management.data_set import SCORE_TEMPLATES + + +class BaseProjectAdminTest(TestCase): + #: The project the contributor administers. + PROJECT_NAME = "TestProjectAdminToken" + #: A project they are never added to, so it has to stay out of their reach. + FOREIGN_PROJECT_NAME = "TestProjectAdminTokenForeign" + PROJECT_DESCRIPTION = "project-admin token suite" + PROJECT_TYPE = "Multimodal" + FOLDER_NAME = "created-by-project-admin" + SETTINGS = [ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, + {"attribute": "UploadImages", "value": 1}, + {"attribute": "DeleteImages", "value": 1}, + ] + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def setUp(self) -> None: + #: The team owner, who sets the projects up and cleans them up. + self.owner = env.build_client(env.token(env.OWNER_PERSONAL_TOKEN_ENV)) + #: The client under test: a contributor's key, made project admin below. + self.project_admin = env.build_client(env.token(env.SA_CONTRIBUTOR_TOKEN_ENV)) + #: The user that key acts as - the one the owner promotes. + self.project_admin_email = self.project_admin.controller.current_user.email + + self._delete_projects() + self._project = self.owner.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=self.SETTINGS, + form=self.MULTIMODAL_FORM, + ) + self.owner.create_project( + self.FOREIGN_PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, + ], + form=self.MULTIMODAL_FORM, + ) + added, skipped = self.owner.add_contributors_to_project( + self.PROJECT_NAME, [self.project_admin_email], "ProjectAdmin" + ) + assert self.project_admin_email in added + skipped, ( + f"{self.project_admin_email} is out of the team scope, so it cannot be made " + f"a project admin - {env.SA_CONTRIBUTOR_TOKEN_ENV} has to belong to a member " + f"of the team {env.OWNER_PERSONAL_TOKEN_ENV} owns" + ) + + def tearDown(self) -> None: + self._delete_projects() + + def _delete_projects(self) -> None: + for name in (self.PROJECT_NAME, self.FOREIGN_PROJECT_NAME): + for project in self.owner.list_projects(name=name): + with contextlib.suppress(Exception): + self.owner.delete_project(project["id"]) + + +@env.requires_tokens(env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) +class TestProjectAdminTokenFullAccess(BaseProjectAdminTest): + #: The project the contributor administers. + PROJECT_NAME = "TestProjectAdminToken" + #: A project they are never added to, so it has to stay out of their reach. + FOREIGN_PROJECT_NAME = "TestProjectAdminTokenForeign" + PROJECT_DESCRIPTION = "project-admin token suite" + PROJECT_TYPE = "Multimodal" + FOLDER_NAME = "created-by-project-admin" + + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def _team_contributor(self): + """A team contributor for the project admin to add, found as the owner. + + The lookup runs as the owner on purpose: a project-admin key cannot list team + users (see ``test_lists_team_users``), so it cannot pick its own candidate. + """ + for user in self.owner.list_users(): + if ( + user["role"] == "Contributor" + and user["email"] != self.project_admin_email + ): + return user + self.skipTest("the team has no other contributor to add to a project") + + def test_lists_only_the_projects_it_has_access_to(self): + visible = {p["name"] for p in self.project_admin.list_projects()} + + assert self.PROJECT_NAME in visible + # The second project was never shared, so the role must not surface it. + assert self.FOREIGN_PROJECT_NAME not in visible + assert self.FOREIGN_PROJECT_NAME in { + p["name"] for p in self.owner.list_projects() + } + + def test_add_remove_a_contributor_to_its_project(self): + # TODO should raise error on ProjectAdmin deletion + scapegoat = self._team_contributor() + + self.project_admin.add_contributors_to_project( + self.PROJECT_NAME, [scapegoat["email"]], "ProjectAdmin" + ) + + project_roles = { + user["email"]: user["role"] + for user in self.project_admin.list_users(project=self.PROJECT_NAME) + } + assert project_roles.get(scapegoat["email"]) == "ProjectAdmin" + self.project_admin.remove_users_from_project( + self.PROJECT_NAME, [scapegoat["email"]] + ) + project_roles = { + user["email"]: user["role"] + for user in self.project_admin.list_users(project=self.PROJECT_NAME) + } + assert scapegoat["email"] not in project_roles + + def test_lists_team_users(self): + team_users = self.project_admin.list_users() + + assert self.project_admin_email in {user["email"] for user in team_users} + + def test_lists_the_users_of_its_project_with_categories(self): + project_users = self.project_admin.list_users(project=self.PROJECT_NAME) + + project_roles = {user["email"]: user["role"] for user in project_users} + assert project_roles[self.project_admin_email] == "ProjectAdmin" + scapegoat = self._team_contributor() + + self.project_admin.add_contributors_to_project( + self.PROJECT_NAME, [scapegoat["email"]], "Annotator" + ) + self.project_admin.create_categories(self.PROJECT_NAME, ["test"]) + categories = self.project_admin.list_categories(self.PROJECT_NAME) + assert len(categories) == 1 + self.project_admin.set_contributors_categories( + self.PROJECT_NAME, [scapegoat["email"]], categories=["test"] + ) + users = self.project_admin.list_users( + project=self.PROJECT_NAME, email=scapegoat["email"], include=["categories"] + ) + assert users[0]["categories"][0]["name"] == "test" + + def test_creates_a_folder_in_its_project(self): + folder = self.project_admin.create_folder(self.PROJECT_NAME, self.FOLDER_NAME) + + assert folder["name"] == self.FOLDER_NAME + assert self.FOLDER_NAME in { + f["name"] for f in self.project_admin.list_folders(self.PROJECT_NAME) + } + + def test_get_list_delete_items(self): + self.project_admin.generate_items(self.PROJECT_NAME, count=5, name="test") + + items = self.project_admin.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + item = self.project_admin.get_item_metadata(self.PROJECT_NAME, items[0]["name"]) + assert len(items) == 5 + assert item is not None + self.project_admin.delete_items(self.PROJECT_NAME) + items = self.project_admin.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + assert len(items) == 0 + + def test_set_item_status(self): + self.project_admin.generate_items(self.PROJECT_NAME, count=1, name="test") + + items = self.project_admin.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + self.project_admin.set_annotation_statuses( + self.PROJECT_NAME, "Completed", [items[0]["name"]] + ) + items = self.project_admin.list_items( + self.PROJECT_NAME, include=["categories", "custom_metadata"] + ) + assert items[0]["annotation_status"] == "Completed" + + def test_get_set_annotation(self): + self.project_admin.generate_items(self.PROJECT_NAME, count=5, name="test") + annotations = self.project_admin.get_annotations( + self.PROJECT_NAME, data_spec="multimodal" + ) + assert len(annotations) == 5 + response = self.project_admin.upload_annotations( + self.PROJECT_NAME, annotations, data_spec="multimodal" + ) + assert len(response["succeeded"]) == 5 + + def test_get_project_metadata(self): + self.project_admin.get_project_metadata( + project=self.PROJECT_NAME, + include_annotation_classes=True, + include_settings=True, + # include_workflow=True, + include_contributors=True, + include_complete_item_count=True, + ) + + def test_delete_project(self): + # TODO fix project admin should not be able to delete project + self.project_admin.delete_project(self.PROJECT_NAME) + projects = self.project_admin.list_projects(name=self.PROJECT_NAME) + assert not projects + + +@env.requires_tokens(env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) +class TestProjectAdminSemiAccess(BaseProjectAdminTest): + PROJECT_NAME = "TestProjectAdminSemiAccess" + FOREIGN_PROJECT_NAME = "TestProjectAdminSemiAccessFOREIGN" + SETTINGS = [ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, + {"attribute": "UploadImages", "value": 0}, + {"attribute": "DeleteImages", "value": 0}, + ] + + def test_item_deletion(self): + self.owner.generate_items(self.PROJECT_NAME, count=5, name="test") + with self.assertRaisesRegex( + AppException, "You do not have sufficient access to delete this items." + ): + self.project_admin.delete_items(self.PROJECT_NAME) + + def test_create_items(self): + # todo update error message + with self.assertRaisesRegex( + AppException, "You do not have sufficient access export." + ): + self.project_admin.generate_items(self.PROJECT_NAME, count=5, name="test") + + +@env.requires_tokens(env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) +class TestProjectVectorProject(BaseProjectAdminTest): + PROJECT_NAME = "TestProjectAdminSemiAccess" + FOREIGN_PROJECT_NAME = "TestProjectAdminSemiAccessFOREIGN" + SETTINGS = [ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, + {"attribute": "UploadImages", "value": 0}, + {"attribute": "DeleteImages", "value": 0}, + ] + PROJECT_TYPE = "Vector" + MULTIMODAL_FORM = None + + def test_sets_default_image_quality_in_editor(self): + self.project_admin.set_project_default_image_quality_in_editor( + self.PROJECT_NAME, + "original", + ) + + settings = self.project_admin.get_project_settings(self.PROJECT_NAME) + setting_values = { + setting["attribute"]: setting["value"] for setting in settings + } + assert setting_values["ImageQuality"] == "original", ( + "set_project_default_image_quality_in_editor returned without an error but " + "left ImageQuality at " + f"{setting_values['ImageQuality']!r}; the same call as the team owner " + "applies it, so the Project Admin key is silently ignored" + ) + + +@env.requires_tokens(env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) +class TestProjectAdminUserScoring(TestCase): + """ + Test using mock Multimodal form template with dynamically generated scores created during setup. + """ + + PROJECT_NAME = "TestProjectAdminUserScoring" + PROJECT_TYPE = "Multimodal" + PROJECT_DESCRIPTION = "DESCRIPTION" + EDITOR_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form_with_scores.json", + ) + CLASSES_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1_classes.json", + ) + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def setUp(self, *args, **kwargs) -> None: + # setup user scores for test + self.owner = env.build_client(env.token(env.OWNER_PERSONAL_TOKEN_ENV)) + self.tearDown() + #: The client under test: a contributor's key, made project admin below. + self.project_admin = env.build_client(env.token(env.SA_CONTRIBUTOR_TOKEN_ENV)) + self.project_admin_email = self.project_admin.controller.current_user.email + self._project = self.owner.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[{"attribute": "TemplateState", "value": 1}], + form=self.MULTIMODAL_FORM, + ) + team = self.owner.controller.team + project = self.owner.controller.get_project(self.PROJECT_NAME) + self.owner.add_contributors_to_project( + self.PROJECT_NAME, [self.project_admin_email], "ProjectAdmin" + ) + time.sleep(5) + + # setup form template from crated scores + with open(self.EDITOR_TEMPLATE_PATH) as f: + template_data = json.load(f) + for data in SCORE_TEMPLATES: + req = ( + self.owner.controller.service_provider.work_management.create_score( + **data + ) + ) + assert req.status_code == 201 + for component in template_data["components"]: + if "scoring" in component and component["type"] == req.data["type"]: + component["scoring"]["id"] = req.data["id"] + + res = ( + self.owner.controller.service_provider.projects.attach_editor_template( + team, project, template=template_data + ) + ) + assert res.ok + self.owner.create_annotation_classes_from_classes_json( + self.PROJECT_NAME, self.CLASSES_TEMPLATE_PATH + ) + + users = self.owner.list_users() + scapegoat = [ + u for u in users if u["role"] == "Contributor" and u["state"] == "Confirmed" + ][0] + self.scapegoat = scapegoat + self.owner.add_contributors_to_project( + self.PROJECT_NAME, [scapegoat["email"]], "Annotator" + ) + + def tearDown(self) -> None: + # cleanup test scores and project + projects = self.owner.list_projects(name__in=[self.PROJECT_NAME]) + for project in projects: + try: + self.owner.delete_project(project=project["id"]) + except Exception as _: + pass + + score_templates_name_id_map = { + s.name: s.id + for s in self.owner.controller.service_provider.work_management.list_scores().data + } + for data in SCORE_TEMPLATES: + score_id = score_templates_name_id_map.get(data["name"]) + if score_id: + self.owner.controller.service_provider.work_management.delete_score( + score_id + ) + + def _attach_item(self, path, name): + self.owner.attach_items(path, [{"name": name, "url": "url"}]) + + def test_set_get_scores(self): + scores_name_payload_map = { + "SDK-my-score-1": { + "component_id": "r_34k7k7", # rating type score + "value": 5, + "weight": 0.5, + }, + "SDK-my-score-2": { + "component_id": "r_ioc7wd", # number type score + "value": 45, + "weight": 1.5, + }, + "SDK-my-score-3": { + "component_id": "r_tcof7o", # radio type score + "value": None, + "weight": None, + }, + } + item_name = f"test_item_{uuid.uuid4()}" + self._attach_item(self.PROJECT_NAME, item_name) + + with self.assertLogs("sa", level="INFO") as cm: + self.project_admin.set_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + scores=list(scores_name_payload_map.values()), + ) + assert cm.output[0] == "INFO:sa:Scores successfully set." + + created_scores = self.project_admin.get_user_scores( + project=self.PROJECT_NAME, + item=item_name, + scored_user=self.scapegoat["email"], + score_names=[s["name"] for s in SCORE_TEMPLATES], + ) + assert len(created_scores) == len(SCORE_TEMPLATES) + for score in created_scores: + score_pyload = scores_name_payload_map[score["name"]] + assert score["value"] == score_pyload["value"] + assert score["weight"] == score_pyload["weight"] + assert score["id"] + assert score["createdAt"] + assert score["updatedAt"] diff --git a/tests/integration/client/test_token_scopes.py b/tests/integration/client/test_token_scopes.py new file mode 100644 index 000000000..4b0f03ed1 --- /dev/null +++ b/tests/integration/client/test_token_scopes.py @@ -0,0 +1,76 @@ +"""What the token in the .env grants, checked against the backend. + +Every test here is tied to a token scope, so a run with one kind of key skips the tests +that only make sense for the others. See tests/env.py. +""" + +import os + +import pytest +from src.superannotate import AppException +from tests import env + + +def test_token_authenticates(sa_client): + assert sa_client.controller.team_id + # Every token resolves the user it acts as, or the creator behind a team key. + assert sa_client.controller.current_user.email + + +@env.requires_organization_token +def test_org_token_operates_in_the_configured_team(sa_client): + context = sa_client.controller.token_context + assert context.is_organization_key + assert not context.is_team_key + assert not context.is_personal_key + assert sa_client.controller.team_id == int(os.environ["SA_TEAM_ID"]) + + +@env.requires_organization_token +def test_org_token_without_team_id_is_rejected(): + # The team is not part of the key, so there is nothing to fall back on. + with pytest.raises( + AppException, match=r'Organization API key requires a "team_id"' + ): + env.build_client(os.environ["SA_TOKEN"]) + + +@env.requires_organization_token +def test_org_token_with_a_team_id_argument(): + # The same key, with the team passed as an argument instead of through the .env. + team_id = int(os.environ["SA_TEAM_ID"]) + client = env.build_client(os.environ["SA_TOKEN"], team_id=team_id) + assert client.controller.team_id == team_id + + +@env.requires_organization_token +def test_org_token_with_a_team_id_from_the_environment(): + # SA_TEAM_ID in the .env, which is how the suite itself is configured. + team_id = int(os.environ["SA_TEAM_ID"]) + client = env.build_client( + os.environ["SA_TOKEN"], team_id=team_id, team_id_via_env=True + ) + assert client.controller._config.TEAM_ID == team_id + assert client.controller.team_id == team_id + + +@env.requires_team_token +def test_team_token_acts_as_the_team(sa_client): + context = sa_client.controller.token_context + assert context.is_team_key + assert not context.is_personal_key + + +@env.requires_user_token +def test_personal_token_acts_as_a_user(sa_client): + context = sa_client.controller.token_context + assert context.is_personal_key or context.is_legacy + + +@env.requires_team_scoped_token +def test_team_scoped_token_rejects_a_conflicting_team_id(sa_client): + # The key names its own team, so a team_id that disagrees is a caller mistake. + with pytest.raises(AppException, match=r"does not match the team"): + env.build_client( + os.environ["SA_TOKEN"], team_id=sa_client.controller.team_id + 1 + ) diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index 3363d44e6..643a26370 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -2,11 +2,11 @@ import os import time import uuid -from pathlib import Path from unittest import TestCase from lib.core.exceptions import AppException from src.superannotate import SAClient +from tests import DATA_SET_PATH from tests.integration.work_management.data_set import SCORE_TEMPLATES sa = SAClient() @@ -21,12 +21,10 @@ class TestUserScoring(TestCase): PROJECT_TYPE = "Multimodal" PROJECT_DESCRIPTION = "DESCRIPTION" EDITOR_TEMPLATE_PATH = os.path.join( - Path(__file__).parent.parent.parent, - "data_set/editor_templates/form_with_scores.json", + DATA_SET_PATH / "editor_templates" / "form_with_scores.json" ) CLASSES_TEMPLATE_PATH = os.path.join( - Path(__file__).parent.parent.parent, - "data_set/editor_templates/form1_classes.json", + DATA_SET_PATH / "editor_templates" / "form1_classes.json" ) MULTIMODAL_FORM = { "components": [ diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 000000000..e89392b7c --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,28 @@ +"""Unit tests read no credentials. + +The suite loads the project's ``.env`` (``tests/conftest.py``) for the sake of modules +that build a client at import time. The tests here assert how the SDK picks up +credentials, so they must not see whatever happens to be in the developer's ``.env`` - +neither through the environment nor through a ``load_dotenv()`` of the code under test. +""" + +import pytest +from tests import env + +CREDENTIAL_VARS = ( + "SA_TOKEN", + "SA_URL", + "SA_TEAM_ID", + "SA_SSL", + env.OWNER_PERSONAL_TOKEN_ENV, + env.SA_CONTRIBUTOR_TOKEN_ENV, +) + + +@pytest.fixture(autouse=True) +def hide_credentials(tmp_path_factory): + overrides = {var: None for var in CREDENTIAL_VARS} + # A .env path that does not exist, so re-reading the file cannot bring them back. + overrides[env.ENV_FILE_ENV] = str(tmp_path_factory.mktemp("no-dotenv") / ".env") + with env.environ(**overrides): + yield diff --git a/tests/unit/test_env.py b/tests/unit/test_env.py new file mode 100644 index 000000000..c04557817 --- /dev/null +++ b/tests/unit/test_env.py @@ -0,0 +1,157 @@ +"""The test suite's own credential plumbing (tests/env.py, tests/conftest.py).""" + +import os +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from tests import conftest +from tests import env + +TOKEN = "sa_SOZVLlnbheUITTGb_PXlk2ON5QtqNPWY9bHZJctzlx4EPTkImzncQgRmybgh" + + +class LoadDotenvTestCase(TestCase): + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.addCleanup(self._dir.cleanup) + self.env_path = Path(self._dir.name) / ".env" + # The developer's own credentials must not leak into the assertions. + patcher = patch.dict(os.environ, {}, clear=False) + patcher.start() + self.addCleanup(patcher.stop) + for key in ("SA_TOKEN", "SA_URL", "SA_TEAM_ID"): + os.environ.pop(key, None) + + def test_values_are_read_into_the_environment(self): + self.env_path.write_text( + "# credentials\n" + f"SA_TOKEN={TOKEN}\n" + "\n" + 'SA_URL="https://api.devsuperannotate.com"\n' + "export SA_TEAM_ID = 6085\n" + "not a pair\n" + ) + loaded = env.load_dotenv(self.env_path) + + assert loaded == { + "SA_TOKEN": TOKEN, + "SA_URL": "https://api.devsuperannotate.com", + "SA_TEAM_ID": "6085", + } + assert os.environ["SA_TOKEN"] == TOKEN + # Quotes are stripped, comments and malformed lines are ignored. + assert os.environ["SA_URL"] == "https://api.devsuperannotate.com" + assert os.environ["SA_TEAM_ID"] == "6085" + + def test_the_environment_wins_over_the_file(self): + # CI exports the credentials; a leftover .env must not override them. + self.env_path.write_text(f"SA_TOKEN={TOKEN}\nSA_URL=from-file\n") + with patch.dict(os.environ, {"SA_URL": "from-environment"}): + loaded = env.load_dotenv(self.env_path) + assert os.environ["SA_URL"] == "from-environment" + assert "SA_URL" not in loaded + + def test_missing_file_is_not_an_error(self): + # Without a .env the SDK falls back to its own config, as it always did. + assert env.load_dotenv(Path(self._dir.name) / "absent") == {} + + def test_path_is_overridable(self): + self.env_path.write_text(f"SA_TOKEN={TOKEN}\n") + with patch.dict(os.environ, {env.ENV_FILE_ENV: str(self.env_path)}): + assert env.env_file() == self.env_path + assert env.load_dotenv() == {"SA_TOKEN": TOKEN} + + def test_dotenv_credentials_reach_the_client(self): + from superannotate import SAClient + + self.env_path.write_text("SA_TOKEN=token=6085\nSA_URL=https://sa.test\n") + env.load_dotenv(self.env_path) + with patch("lib.infrastructure.controller.Controller.get_team"), patch( + "lib.infrastructure.controller.Controller.get_current_user" + ): + client = SAClient() + assert client.controller.team_id == 6085 + assert client.controller._config.API_URL == "https://sa.test" + + +class RequiresTokensTestCase(TestCase): + """The gate in front of the suites that need an extra token from the .env.""" + + def _decorate(self, *names): + @env.requires_tokens(*names) + class Suite(TestCase): + pass + + return Suite + + def test_runs_when_every_token_is_there(self): + with env.environ(**{env.SA_CONTRIBUTOR_TOKEN_ENV: TOKEN}): + assert env.missing_tokens(env.SA_CONTRIBUTOR_TOKEN_ENV) == [] + suite = self._decorate(env.SA_CONTRIBUTOR_TOKEN_ENV) + assert getattr(suite, "__unittest_skip__", False) is False + + def test_skips_naming_only_the_missing_ones(self): + with env.environ( + **{env.SA_CONTRIBUTOR_TOKEN_ENV: TOKEN, env.OWNER_PERSONAL_TOKEN_ENV: None} + ): + names = (env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) + assert env.missing_tokens(*names) == [env.OWNER_PERSONAL_TOKEN_ENV] + suite = self._decorate(*names) + assert suite.__unittest_skip__ is True + assert env.OWNER_PERSONAL_TOKEN_ENV in suite.__unittest_skip_why__ + assert env.SA_CONTRIBUTOR_TOKEN_ENV not in suite.__unittest_skip_why__ + + +def _item(*scopes): + """A test item carrying a requires_token_scope marker.""" + item = MagicMock() + item.iter_markers.return_value = [pytest.mark.requires_token_scope(*scopes).mark] + return item + + +class TokenScopeMarkerTestCase(TestCase): + """The marker behind env.requires_organization_token and friends.""" + + def test_runs_when_the_scope_matches(self): + with patch.object(env, "token_scope", return_value=env.ORGANIZATION): + conftest.pytest_runtest_setup(_item(env.ORGANIZATION)) + + def test_skips_when_another_token_type_is_configured(self): + with patch.object(env, "token_scope", return_value=env.TEAM): + with pytest.raises(pytest.skip.Exception) as exc: + conftest.pytest_runtest_setup(_item(env.ORGANIZATION)) + assert "requires a token of scope organization" in str(exc.value) + assert "the configured one is team" in str(exc.value) + + def test_a_marker_may_accept_several_scopes(self): + # requires_user_token covers both a personal key and a legacy token. + for scope in (env.PERSONAL, env.LEGACY): + with patch.object(env, "token_scope", return_value=scope): + conftest.pytest_runtest_setup(_item(env.PERSONAL, env.LEGACY)) + + def test_declared_markers_carry_the_expected_scopes(self): + assert env.requires_organization_token.mark.args == (env.ORGANIZATION,) + assert env.requires_team_token.mark.args == (env.TEAM,) + assert env.requires_user_token.mark.args == (env.PERSONAL, env.LEGACY) + assert env.requires_team_scoped_token.mark.args == ( + env.TEAM, + env.PERSONAL, + env.LEGACY, + ) + + def test_legacy_token_reports_the_legacy_scope(self): + client = MagicMock() + client.controller.token_context.is_legacy = True + with patch.object(env, "get_client", return_value=client): + assert env.token_scope() == env.LEGACY + + def test_scope_comes_from_the_token_context(self): + client = MagicMock() + client.controller.token_context.is_legacy = False + client.controller.token_context.scope_type = env.ORGANIZATION + with patch.object(env, "get_client", return_value=client): + assert env.token_scope() == env.ORGANIZATION diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index c3f8b01b8..a3b0ce96d 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -302,20 +302,48 @@ def test_nested_service_clients_share_team_context(self, post, get_team): assert service.client.team_id == 6085 assert service.client.auth_type == "api_key" - def test_organization_api_key_rejected(self, post, get_team): + def test_organization_api_key_without_team_id_rejected(self, post, get_team): + # An organization key carries no team, so it cannot resolve one on its own. post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) with self.assertRaisesRegex( - AppException, r"does not accept an Organization API key" + AppException, r'Organization API key requires a "team_id"' ): SAClient(token=self._token) + def test_organization_api_key_with_team_id(self, post, get_team): + # The team is not part of the key, so the caller names the team to operate in. + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + sa = SAClient(token=self._token, team_id=6085) + + assert sa.controller.team_id == 6085 + context = sa.controller.token_context + assert context.scope_type == "organization" + assert context.is_organization_key + assert not context.is_team_key + assert not context.is_personal_key + # A team-less key has no user behind it either, so it falls back to its creator. + assert sa.controller.current_user.email == "vaghinak@superannotate.com" + + client = sa.controller.service_provider.client + assert client.team_id == 6085 + assert client.auth_type == "api_key" + + def test_team_id_matching_the_token_accepted(self, post, get_team): + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + sa = SAClient(token=self._token, team_id=6085) + assert sa.controller.team_id == 6085 + + def test_team_id_mismatching_the_token_rejected(self, post, get_team): + # A team key names its own team; a conflicting team_id is a caller mistake. + post.return_value = _mock_response(TEAM_TOKEN_RESPONSE) + with self.assertRaisesRegex(AppException, r"does not match the team"): + SAClient(token=self._token, team_id=42) + def test_unknown_scope_type_rejected(self, post, get_team): response = deepcopy(TEAM_TOKEN_RESPONSE) response["token"]["scope_type"] = "something-new" post.return_value = _mock_response(response) - with self.assertRaisesRegex( - AppException, r"does not accept an Organization API key" - ): + with self.assertRaisesRegex(AppException, r"Unable to resolve the team"): SAClient(token=self._token) def test_team_scope_without_team_id_rejected(self, post, get_team): @@ -323,9 +351,7 @@ def test_team_scope_without_team_id_rejected(self, post, get_team): response = deepcopy(TEAM_TOKEN_RESPONSE) response["token"]["scope"] = {} post.return_value = _mock_response(response) - with self.assertRaisesRegex( - AppException, r"does not accept an Organization API key" - ): + with self.assertRaisesRegex(AppException, r"Unable to resolve the team"): SAClient(token=self._token) def test_authentication_failure(self, post, get_team): @@ -334,6 +360,75 @@ def test_authentication_failure(self, post, get_team): SAClient(token=self._token) +@patch("lib.infrastructure.controller.Controller.get_team") +@patch("lib.infrastructure.services.auth.requests.post") +class TeamIdFromConfigTestCase(TestCase): + """The team an organization key operates in may come from any config source.""" + + _token = "sa_SOZVLlnbheUITTGb_PXlk2ON5QtqNPWY9bHZJctzlx4EPTkImzncQgRmybgh" + + def setUp(self): + self._config_dir = tempfile.TemporaryDirectory() + config_dir = self._config_dir.name + self._ini_path = f"{config_dir}/config.ini" + self._json_path = f"{config_dir}/config.json" + patches = ( + patch("lib.core.CONFIG_INI_FILE_LOCATION", self._ini_path), + patch("lib.core.CONFIG_JSON_FILE_LOCATION", self._json_path), + ) + for p in patches: + p.start() + self.addCleanup(p.stop) + self.addCleanup(self._config_dir.cleanup) + + def _write_ini(self, **values): + config_parser = ConfigParser() + config_parser.optionxform = str + config_parser["DEFAULT"] = {k: str(v) for k, v in values.items()} + with open(self._ini_path, "w") as config_ini: + config_parser.write(config_ini) + + def test_team_id_from_config_ini(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + self._write_ini(SA_TOKEN=self._token, SA_TEAM_ID=6085) + # Both the default location and an explicit path read the same file. + for kwargs in ({}, {"config_path": self._ini_path}): + sa = SAClient(**kwargs) + assert sa.controller._config.TEAM_ID == 6085 + assert sa.controller.team_id == 6085 + + def test_team_id_from_config_ini_by_field_name(self, post, get_team): + # The ini keys are read as-is, so the internal field name works as well. + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + self._write_ini(SA_TOKEN=self._token, TEAM_ID=6085) + assert SAClient().controller.team_id == 6085 + + def test_org_token_in_config_ini_without_team_id_rejected(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + self._write_ini(SA_TOKEN=self._token) + with self.assertRaisesRegex( + AppException, r'Organization API key requires a "team_id"' + ): + SAClient() + + def test_team_id_from_config_json(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with open(self._json_path, "w") as config_json: + json.dump({"token": self._token, "team_id": 6085}, config_json) + for kwargs in ({}, {"config_path": self._json_path}): + assert SAClient(**kwargs).controller.team_id == 6085 + + def test_team_id_from_env(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + with patch.dict(os.environ, {"SA_TOKEN": self._token, "SA_TEAM_ID": "6085"}): + assert SAClient().controller.team_id == 6085 + + def test_explicit_team_id_overrides_config_ini(self, post, get_team): + post.return_value = _mock_response(ORGANIZATION_TOKEN_RESPONSE) + self._write_ini(SA_TOKEN=self._token, SA_TEAM_ID=6085) + assert SAClient(team_id=1).controller.team_id == 1 + + class LegacyTokenTestCase(TestCase): @patch("lib.infrastructure.controller.Controller.get_current_user") @patch("lib.infrastructure.controller.Controller.get_team")