From ca83e4e3172f0dd65096059ded7eea0c8676045e Mon Sep 17 00:00:00 2001 From: Miguel Angel Alvarez Date: Mon, 7 Oct 2024 12:11:44 +0200 Subject: [PATCH] refactor: Auth0 integration - Introduced a new `external` folder to organize and encapsulate all external connection-related code. - Added an `Auth0` module containing the `Auth0Manager` class to centralize and streamline all Auth0-related functionality. - Refactored existing code to align with the new Auth0 structure, ensuring consistency throughout the project. - Resolved an issue in the tests where old roles were not being deleted before adding new ones. --- docs/modules.rst | 2 +- nexusml/api/__init__.py | 12 +- nexusml/api/external/__init__.py | 0 nexusml/api/external/auth0.py | 149 ++++++++++ nexusml/api/{ => external}/ext.py | 0 nexusml/api/jobs/periodic_jobs.py | 2 +- nexusml/api/make_celery.py | 2 +- nexusml/api/resources/base.py | 2 +- nexusml/api/resources/files.py | 2 +- nexusml/api/resources/organizations.py | 37 ++- nexusml/api/utils.py | 104 ------- nexusml/api/views/ai.py | 2 +- nexusml/api/views/core.py | 2 +- nexusml/api/views/myaccount.py | 8 +- nexusml/api/views/organizations.py | 10 +- nexusml/api/views/services.py | 2 +- nexusml/database/organizations.py | 2 + nexusml/engine/services/utils.py | 2 +- tests/api/integration/conftest.py | 2 +- tests/api/integration/test_ai.py | 4 +- tests/api/integration/test_examples.py | 2 +- tests/api/integration/test_files.py | 6 +- tests/api/integration/test_myaccount.py | 19 +- tests/api/integration/test_organizations.py | 2 +- tests/api/integration/test_permissions.py | 2 +- tests/api/integration/test_quotas.py | 3 +- tests/api/integration/test_resource_id.py | 2 +- tests/api/integration/test_services.py | 2 +- tests/api/integration/test_tasks.py | 2 +- tests/api/integration/utils.py | 2 +- tests/api/unit/external/__init__.py | 0 tests/api/unit/external/test_auth0.py | 269 ++++++++++++++++++ .../api/unit/resources/test_organizations.py | 5 +- tests/api/unit/test_permissions.py | 2 +- tests/api/unit/test_utils.py | 165 ----------- tests/api/utils.py | 2 +- 36 files changed, 494 insertions(+), 337 deletions(-) create mode 100644 nexusml/api/external/__init__.py create mode 100644 nexusml/api/external/auth0.py rename nexusml/api/{ => external}/ext.py (100%) create mode 100644 tests/api/unit/external/__init__.py create mode 100644 tests/api/unit/external/test_auth0.py delete mode 100644 tests/api/unit/test_utils.py diff --git a/docs/modules.rst b/docs/modules.rst index da6f665..56071ed 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -91,7 +91,7 @@ Module Documentation :undoc-members: :show-inheritance: -.. automodule:: nexusml.api.ext +.. automodule:: nexusml.api.external.ext :members: :undoc-members: :show-inheritance: diff --git a/nexusml/api/__init__.py b/nexusml/api/__init__.py index 51d8c88..f9b2db8 100644 --- a/nexusml/api/__init__.py +++ b/nexusml/api/__init__.py @@ -17,12 +17,12 @@ from nexusml.api import routes from nexusml.api.endpoints import ENDPOINT_SYS_CONFIG -from nexusml.api.ext import cache -from nexusml.api.ext import celery -from nexusml.api.ext import cors -from nexusml.api.ext import docs -from nexusml.api.ext import init_celery -from nexusml.api.ext import mail +from nexusml.api.external.ext import cache +from nexusml.api.external.ext import celery +from nexusml.api.external.ext import cors +from nexusml.api.external.ext import docs +from nexusml.api.external.ext import init_celery +from nexusml.api.external.ext import mail from nexusml.api.utils import config from nexusml.api.utils import decode_api_key from nexusml.api.utils import DEFAULT_CONFIG diff --git a/nexusml/api/external/__init__.py b/nexusml/api/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nexusml/api/external/auth0.py b/nexusml/api/external/auth0.py new file mode 100644 index 0000000..13da1e7 --- /dev/null +++ b/nexusml/api/external/auth0.py @@ -0,0 +1,149 @@ +import os +import re +import urllib.parse + +from flask import g +import requests +from requests import Response +from werkzeug.exceptions import BadRequest + +from nexusml.env import ENV_AUTH0_CLIENT_ID +from nexusml.env import ENV_AUTH0_CLIENT_SECRET +from nexusml.env import ENV_AUTH0_DOMAIN + + +class Auth0Manager: + """Manages interactions with Auth0 API for retrieving, updating, and deleting user data. + + This class provides functionalities to authenticate with Auth0, manage API tokens, and handle user data through + Auth0 Management API, including retrieving user information based on Auth0 ID or email, updating user details, and + deleting users from Auth0. + """ + auth0_domain_url: str = f'https://{os.environ[ENV_AUTH0_DOMAIN]}' + auth0_user_url: str = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users' + + def __init__(self, auth0_token: str = '') -> None: + """ + Initializes the Auth0Manager with an optional token for authorization. + + Args: + auth0_token (str): The Auth0 API token for authorization. Defaults to an empty string. + """ + self._auth0_token: str = auth0_token + self.authorization_headers: dict = {'Authorization': 'Bearer ' + auth0_token} + + if auth0_token == '': + self.update_authorization_header() + + def update_authorization_header(self) -> None: + """ + Updates the authorization headers by retrieving a new Auth0 management API token. + + This function is called when no token is provided during initialization, ensuring that the instance has valid + credentials to interact with Auth0 Management API. + """ + auth0_token: str = self._get_auth0_management_api_token() + self.authorization_headers = {'Authorization': 'Bearer ' + auth0_token} + self._auth0_token = auth0_token + + def _get_auth0_management_api_token(self) -> str: + """ + Retrieves the Auth0 Management API token required for accessing the Auth0 database. + + This function sends a POST request to the Auth0 token endpoint with the necessary credentials, + including client ID, client secret, and audience. The returned access token is used for making + further Auth0 Management API calls. + + Returns: + str: The Auth0 Management API access token. + """ + access_token: str + payload: dict = { + 'grant_type': 'client_credentials', + 'client_id': os.environ[ENV_AUTH0_CLIENT_ID], + 'client_secret': os.environ[ENV_AUTH0_CLIENT_SECRET], + 'audience': f'{self.auth0_domain_url}/api/v2/' + } + + headers: dict = {'Content-Type': 'application/json'} + + response_data: Response = requests.post(f'{self.auth0_domain_url}/oauth/token', json=payload, headers=headers) + json_data: dict = response_data.json() + access_token = json_data['access_token'] + + return access_token + + def get_auth0_user_data(self, auth0_id_or_email: str) -> dict: + """ + Matches an Auth0 ID or email to retrieve the associated user data. + + This function checks if the provided identifier is an email or an Auth0 ID, constructs the appropriate URL, + and sends a GET request to retrieve the user account data. If the identifier is an email, it searches by email; + otherwise, it searches by Auth0 ID. + + WARNING: If more than one user data is received in the response, only the first user data will be returned. + + Args: + auth0_id_or_email (str): The Auth0 ID or email to match. + + Returns: + dict: The matched user data. + + Raises: + BadRequest: If no Auth0 user is associated with the provided identifier. + """ + auth0_user_data: dict + encoded_email_or_auth0_id = urllib.parse.quote(auth0_id_or_email) + + regex_email = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' + if re.fullmatch(regex_email, auth0_id_or_email): + url = f'{self.auth0_user_url}?q=email:{encoded_email_or_auth0_id}&search_engine=v3' + else: + url = f'{self.auth0_user_url}/{encoded_email_or_auth0_id}' + + response: Response = requests.get(url, headers=self.authorization_headers) + if response.status_code != 200: + raise BadRequest(description=f'No Auth0 user associated with "{auth0_id_or_email}"') + + auth0_user_data = response.json() + if auth0_user_data and isinstance(auth0_user_data, list): + auth0_user_data = auth0_user_data[0] + + return auth0_user_data + + def delete_auth0_user(self, auth0_id: str) -> None: + """ + Deletes an Auth0 user account based on the provided Auth0 ID. + + This function retrieves an Auth0 management API token, constructs the URL for the user deletion endpoint, + and sends a DELETE request to remove the user. + + Args: + auth0_id (str): The Auth0 ID of the user to delete. + + Raises: + requests.HTTPError: If the DELETE request does not return a status code of 2XX. + """ + url = f'{self.auth0_user_url}/{auth0_id}' + response: Response = requests.delete(url, headers=self.authorization_headers) + response.raise_for_status() + + def patch_auth0_user(self, updated_data: dict): + """ + Updates user data in Auth0 based on the provided information. + + This function constructs the URL using the current user's Auth0 ID (retrieved from the `g` object), + and sends a PATCH request to update the user details with the provided data. + + Args: + updated_data (dict): The new data to update the user with. + + Raises: + requests.HTTPError: If the PATCH request does not return a status code of 2XX. + """ + url = f'{self.auth0_user_url}/{g.user_auth0_id}' + headers: dict = self.authorization_headers + headers['content-type'] = 'application/json' + + response: Response = requests.patch(url, json=updated_data, headers=headers) + response.raise_for_status() diff --git a/nexusml/api/ext.py b/nexusml/api/external/ext.py similarity index 100% rename from nexusml/api/ext.py rename to nexusml/api/external/ext.py diff --git a/nexusml/api/jobs/periodic_jobs.py b/nexusml/api/jobs/periodic_jobs.py index c8c275f..8ee647f 100644 --- a/nexusml/api/jobs/periodic_jobs.py +++ b/nexusml/api/jobs/periodic_jobs.py @@ -16,7 +16,7 @@ from sqlalchemy import or_ as sql_or from sqlalchemy import text as sql_text -from nexusml.api.ext import mail +from nexusml.api.external.ext import mail from nexusml.api.jobs.event_jobs import train from nexusml.api.resources.organizations import User from nexusml.api.resources.tasks import Task diff --git a/nexusml/api/make_celery.py b/nexusml/api/make_celery.py index 8c8edd6..17ab6a3 100644 --- a/nexusml/api/make_celery.py +++ b/nexusml/api/make_celery.py @@ -1,5 +1,5 @@ from nexusml.api import create_app -from nexusml.api.ext import celery +from nexusml.api.external.ext import celery flask_app = create_app() celery_app = celery diff --git a/nexusml/api/resources/base.py b/nexusml/api/resources/base.py index 04ce95c..36a6845 100644 --- a/nexusml/api/resources/base.py +++ b/nexusml/api/resources/base.py @@ -14,7 +14,7 @@ from sqlalchemy.exc import StatementError from sqlalchemy.inspection import inspect -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.schemas.base import ResourceRequestSchema from nexusml.api.schemas.base import ResourceResponseSchema from nexusml.api.utils import API_DOMAIN diff --git a/nexusml/api/resources/files.py b/nexusml/api/resources/files.py index 440cfd2..7ad4ec5 100644 --- a/nexusml/api/resources/files.py +++ b/nexusml/api/resources/files.py @@ -7,7 +7,7 @@ from nexusml.api.endpoints import ENDPOINT_ORG_FILE from nexusml.api.endpoints import ENDPOINT_TASK_FILE -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.base import PermissionDeniedError from nexusml.api.resources.base import QuotaError from nexusml.api.resources.base import Resource diff --git a/nexusml/api/resources/organizations.py b/nexusml/api/resources/organizations.py index 8b8670f..340697d 100644 --- a/nexusml/api/resources/organizations.py +++ b/nexusml/api/resources/organizations.py @@ -3,8 +3,6 @@ import os from typing import Iterable, List, Optional, Union -from flask import g -import requests from sqlalchemy.exc import OperationalError from nexusml.api.endpoints import ENDPOINT_CLIENT @@ -12,7 +10,8 @@ from nexusml.api.endpoints import ENDPOINT_ORGANIZATION from nexusml.api.endpoints import ENDPOINT_ROLE from nexusml.api.endpoints import ENDPOINT_USER -from nexusml.api.ext import cache +from nexusml.api.external.auth0 import Auth0Manager +from nexusml.api.external.ext import cache from nexusml.api.resources.base import DuplicateResourceError from nexusml.api.resources.base import InvalidDataError from nexusml.api.resources.base import PermissionDeniedError @@ -33,9 +32,6 @@ from nexusml.api.schemas.organizations import RoleResponseSchema from nexusml.api.schemas.organizations import UserRequestSchema from nexusml.api.schemas.organizations import UserResponseSchema -from nexusml.api.utils import delete_auth0_user -from nexusml.api.utils import get_auth0_management_api_token -from nexusml.api.utils import get_auth0_user_data from nexusml.constants import ADMIN_ROLE from nexusml.constants import API_NAME from nexusml.constants import MAINTAINER_ROLE @@ -61,7 +57,6 @@ from nexusml.enums import OrgFileUse from nexusml.enums import ResourceAction from nexusml.enums import ResourceType -from nexusml.env import ENV_AUTH0_DOMAIN from nexusml.env import ENV_SUPPORT_EMAIL _CONTACT_MSG = f'Please, contact {os.environ[ENV_SUPPORT_EMAIL]}' @@ -102,6 +97,10 @@ def wrapper(*args, **kwargs): class User(Resource): + def __init__(self): + super().__init__() + self._auth0_manager: Auth0Manager = Auth0Manager() + @classmethod def db_model(cls): return UserDB @@ -198,11 +197,11 @@ def delete(self, notify_to: Iterable[UserDB] = None): check_last_admin_deletion(user=self, user_roles=user_roles) - delete_auth0_user(auth0_id=self.db_object().auth0_id) + self._auth0_manager.delete_auth0_user(auth0_id=self.db_object().auth0_id) super().delete(notify_to=notify_to) @staticmethod - def download_auth0_user_data(auth0_id_or_email: str) -> dict: + def download_auth0_user_data(auth0_id_or_email: str, auth0_manager: Optional[Auth0Manager] = None) -> dict: """ Downloads user's data from Auth0. @@ -215,6 +214,7 @@ def download_auth0_user_data(auth0_id_or_email: str) -> dict: Args: auth0_id_or_email (str): The user's `auth0_id` or email. + auth0_manager(Auth0Manager): Auth0Manager instance. Returns: dict: The account data. @@ -222,9 +222,10 @@ def download_auth0_user_data(auth0_id_or_email: str) -> dict: Raises: ResourceNotFoundError: If no account is found for the given UUID or email. """ - mgmt_api_access_token: str = get_auth0_management_api_token() - auth0_user_data: dict = get_auth0_user_data(access_token=mgmt_api_access_token, - auth0_id_or_email=auth0_id_or_email) + if not auth0_manager: + auth0_manager: Auth0Manager = Auth0Manager() + + auth0_user_data: dict = auth0_manager.get_auth0_user_data(auth0_id_or_email=auth0_id_or_email) if not auth0_user_data: raise ResourceNotFoundError(f'No Auth0 user found for {auth0_id_or_email}') @@ -245,14 +246,7 @@ def put(self, data: dict, notify_to: Iterable[UserDB] = None) -> None: fields_map: dict = {'first_name': 'given_name', 'last_name': 'family_name'} updated_data: dict = {fields_map.get(key, key): value for key, value in data.items()} - mgmt_api_access_token = get_auth0_management_api_token() - url = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users/{g.user_auth0_id}' - headers = {'Authorization': f'Bearer {mgmt_api_access_token}', 'content-type': 'application/json'} - - response = requests.patch(url, json=updated_data, headers=headers) - if response.status_code < 200 or response.status_code >= 300: - # TODO: Replace this and other 'ifs' with response http error handler method 'raise_for_status' - raise requests.HTTPError('Auth0 patch request error') + self._auth0_manager.patch_auth0_user(updated_data=updated_data) def dump( self, @@ -272,7 +266,8 @@ def dump( db_user_data.pop('public_id') auth0_id: str = self.db_object().auth0_id - auth0_user_data: dict = self.download_auth0_user_data(auth0_id_or_email=auth0_id) + auth0_user_data: dict = self.download_auth0_user_data(auth0_id_or_email=auth0_id, + auth0_manager=self._auth0_manager) public_auth0_user_data: dict = { 'email': auth0_user_data['email'], 'first_name': auth0_user_data['first_name'], diff --git a/nexusml/api/utils.py b/nexusml/api/utils.py index b697fa0..30d79cf 100644 --- a/nexusml/api/utils.py +++ b/nexusml/api/utils.py @@ -7,7 +7,6 @@ import sys import tempfile from typing import Any, IO, Optional, Union -import urllib.parse from urllib.parse import unquote_plus import uuid @@ -19,9 +18,6 @@ import jwt from PIL import Image from platformdirs import user_data_dir -import requests -from requests import Response -from werkzeug.exceptions import BadRequest from nexusml.constants import API_NAME from nexusml.constants import API_VERSION @@ -30,9 +26,6 @@ from nexusml.enums import EngineType from nexusml.enums import FileStorageBackend from nexusml.env import ENV_API_DOMAIN -from nexusml.env import ENV_AUTH0_CLIENT_ID -from nexusml.env import ENV_AUTH0_CLIENT_SECRET -from nexusml.env import ENV_AUTH0_DOMAIN from nexusml.env import ENV_AUTH0_JWKS from nexusml.env import ENV_AUTH0_TOKEN_AUDIENCE from nexusml.env import ENV_AUTH0_TOKEN_ISSUER @@ -493,103 +486,6 @@ def decode_api_key(api_key: str, verify: bool = True) -> dict: return _decode_jwt(token=api_key, public_key=config.rsa_public_key(), verify=verify, issuer=API_NAME) -############# -# Auth0 API # -############# - - -def get_auth0_management_api_token() -> str: - """ - Retrieves the Auth0 Management API token required for accessing the Auth0 database. - - This function sends a POST request to the Auth0 token endpoint with the necessary credentials, - including client ID, client secret, and audience. The returned access token is used for making - further Auth0 Management API calls. - - Returns: - str: The Auth0 Management API access token. - """ - access_token: str - payload: dict = { - 'grant_type': 'client_credentials', - 'client_id': os.environ[ENV_AUTH0_CLIENT_ID], - 'client_secret': os.environ[ENV_AUTH0_CLIENT_SECRET], - 'audience': f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/' - } - - headers: dict = {'Content-Type': 'application/json'} - - response_data: Response = requests.post(f'https://{os.environ[ENV_AUTH0_DOMAIN]}/oauth/token', - json=payload, - headers=headers) - json_data: dict = response_data.json() - access_token = json_data['access_token'] - - return access_token - - -def get_auth0_user_data(access_token: str, auth0_id_or_email: str) -> dict: - """ - Matches an Auth0 ID or email to retrieve the associated user data. - - This function checks if the provided identifier is an email or an Auth0 ID, constructs the appropriate URL, - and sends a GET request to retrieve the user account data. If the identifier is an email, it searches by email; - otherwise, it searches by Auth0 ID. - - Args: - access_token (str): The Auth0 access token for authorization. - auth0_id_or_email (str): The Auth0 ID or email to match. - - Returns: - dict: The matched user data. - - Raises: - BadRequest: If no Auth0 user is associated with the provided identifier. - """ - auth0_user_data: dict - - headers: dict = {'Authorization': 'Bearer ' + access_token} - regex_email = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' - - encoded_email_or_auth0_id = urllib.parse.quote(auth0_id_or_email) - - auth0_domain = os.environ[ENV_AUTH0_DOMAIN] - if re.fullmatch(regex_email, auth0_id_or_email): - url = f'https://{auth0_domain}/api/v2/users?q=email:{encoded_email_or_auth0_id}&search_engine=v3' - else: - url = f'https://{auth0_domain}/api/v2/users/{encoded_email_or_auth0_id}' - - response: Response = requests.get(url, headers=headers) - if response.status_code != 200: - raise BadRequest(description=f'No Auth0 user associated with "{auth0_id_or_email}"') - - auth0_user_data = response.json() - if auth0_user_data and isinstance(auth0_user_data, list): - auth0_user_data: dict = auth0_user_data[0] - - return auth0_user_data - - -def delete_auth0_user(auth0_id: str) -> None: - """ - Deletes an Auth0 user account based on the provided Auth0 ID. - - This function retrieves an Auth0 management API token, constructs the URL for the user deletion endpoint, - and sends a DELETE request to remove the user. - - Args: - auth0_id (str): The Auth0 ID of the user to delete. - - Raises: - AssertionError: If the DELETE request does not return a status code of 204. - """ - auth0_token = get_auth0_management_api_token() - url = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users/{urllib.parse.quote(auth0_id)}' - headers = {'Authorization': 'Bearer ' + auth0_token} - res: Response = requests.delete(url, headers=headers) - assert res.status_code == 204 - - ################ # FILE STORAGE # ################ diff --git a/nexusml/api/views/ai.py b/nexusml/api/views/ai.py index a4942d7..8719c40 100644 --- a/nexusml/api/views/ai.py +++ b/nexusml/api/views/ai.py @@ -13,7 +13,7 @@ from marshmallow import validate from sqlalchemy import or_ as sql_or -from nexusml.api.ext import redis_buffer +from nexusml.api.external.ext import redis_buffer from nexusml.api.jobs.event_jobs import run_mon_service from nexusml.api.jobs.event_jobs import train from nexusml.api.resources.ai import AIModel diff --git a/nexusml/api/views/core.py b/nexusml/api/views/core.py index 39e0d1c..0b67f1e 100644 --- a/nexusml/api/views/core.py +++ b/nexusml/api/views/core.py @@ -20,7 +20,7 @@ from sqlalchemy.sql.elements import UnaryExpression from werkzeug.exceptions import BadRequest -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources import DuplicateResourceError from nexusml.api.resources import ImmutableResourceError from nexusml.api.resources import InvalidDataError diff --git a/nexusml/api/views/myaccount.py b/nexusml/api/views/myaccount.py index 816f04d..84d2a41 100644 --- a/nexusml/api/views/myaccount.py +++ b/nexusml/api/views/myaccount.py @@ -9,6 +9,7 @@ import jwt from marshmallow import fields +from nexusml.api.external.auth0 import Auth0Manager from nexusml.api.resources.base import collaborators_permissions from nexusml.api.resources.base import dump from nexusml.api.resources.base import ResourceNotFoundError @@ -28,8 +29,6 @@ from nexusml.api.schemas.organizations import OrganizationResponseSchema from nexusml.api.schemas.organizations import UserResponseSchema from nexusml.api.schemas.organizations import UserUpdateSchema -from nexusml.api.utils import get_auth0_management_api_token -from nexusml.api.utils import get_auth0_user_data from nexusml.api.views.base import create_view from nexusml.api.views.common import paginated_response from nexusml.api.views.common import permissions_jsons @@ -84,9 +83,8 @@ def _get_user_from_token(self) -> User: try: user_db_object: UserDB = agent_from_token() except jwt.InvalidTokenError as token_error: - mgmt_api_access_token = get_auth0_management_api_token() - auth0_user_data: dict = get_auth0_user_data(access_token=mgmt_api_access_token, - auth0_id_or_email=g.user_auth0_id) + auth0_manager: Auth0Manager = Auth0Manager() + auth0_user_data: dict = auth0_manager.get_auth0_user_data(auth0_id_or_email=g.user_auth0_id) user_invitation: InvitationDB = self._get_user_invitation(token_error=token_error, email=auth0_user_data['email']) new_user: User = self._create_new_user_from_invitation(user_invitation=user_invitation, diff --git a/nexusml/api/views/organizations.py b/nexusml/api/views/organizations.py index 55f6e77..bc2a9a9 100644 --- a/nexusml/api/views/organizations.py +++ b/nexusml/api/views/organizations.py @@ -14,7 +14,8 @@ from flask_mail import Message from nexusml.api.endpoints import ENDPOINT_CLIENT_API_KEY -from nexusml.api.ext import mail +from nexusml.api.external.auth0 import Auth0Manager +from nexusml.api.external.ext import mail from nexusml.api.resources.base import dump from nexusml.api.resources.base import DuplicateResourceError from nexusml.api.resources.base import InvalidDataError @@ -51,8 +52,6 @@ from nexusml.api.schemas.organizations import UsersPage from nexusml.api.utils import config from nexusml.api.utils import decode_api_key -from nexusml.api.utils import get_auth0_management_api_token -from nexusml.api.utils import get_auth0_user_data from nexusml.api.views.base import create_view from nexusml.api.views.common import PermissionAssignmentView from nexusml.api.views.core import agent_from_token @@ -212,9 +211,8 @@ def post(self, **kwargs): oldest_entry = WaitList.query().order_by(WaitList.id_).first() delete_from_db(oldest_entry) - mgmt_api_access_token = get_auth0_management_api_token() - auth0_user_data: dict = get_auth0_user_data(access_token=mgmt_api_access_token, - auth0_id_or_email=g.user_auth0_id) + auth0_manager: Auth0Manager = Auth0Manager() + auth0_user_data: dict = auth0_manager.get_auth0_user_data(auth0_id_or_email=g.user_auth0_id) db_entry = WaitList(uuid=g.agent_uuid, email=auth0_user_data['email'], first_name=auth0_user_data['given_name'], diff --git a/nexusml/api/views/services.py b/nexusml/api/views/services.py index e307839..f2d211f 100644 --- a/nexusml/api/views/services.py +++ b/nexusml/api/views/services.py @@ -14,7 +14,7 @@ from marshmallow import ValidationError from sqlalchemy import and_ as sql_and -from nexusml.api.ext import mail +from nexusml.api.external.ext import mail from nexusml.api.resources.base import Resource from nexusml.api.resources.organizations import User from nexusml.api.resources.tasks import Task diff --git a/nexusml/database/organizations.py b/nexusml/database/organizations.py index 4dbb50a..f8a48ed 100644 --- a/nexusml/database/organizations.py +++ b/nexusml/database/organizations.py @@ -452,6 +452,8 @@ def create_default_admin_and_maintainer_roles(): """ Create default roles """ + db.session.query(RoleDB).delete() + db.session.commit() roles = [ RoleDB(role_id=1, organization_id=1, name=ADMIN_ROLE, description='Administrator'), diff --git a/nexusml/engine/services/utils.py b/nexusml/engine/services/utils.py index 24bd576..16e65be 100644 --- a/nexusml/engine/services/utils.py +++ b/nexusml/engine/services/utils.py @@ -5,7 +5,7 @@ from flask import render_template from flask_mail import Message -from nexusml.api.ext import mail +from nexusml.api.external.ext import mail from nexusml.api.resources.organizations import User from nexusml.api.resources.tasks import Task from nexusml.constants import API_NAME diff --git a/tests/api/integration/conftest.py b/tests/api/integration/conftest.py index 3abbe06..6ed95c1 100644 --- a/tests/api/integration/conftest.py +++ b/tests/api/integration/conftest.py @@ -34,7 +34,7 @@ from nexusml.api import create_app from nexusml.api.endpoints import ENDPOINT_ORG_FILES from nexusml.api.endpoints import ENDPOINT_TASK_FILES -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.organizations import Organization from nexusml.api.resources.tasks import Task from nexusml.api.utils import API_DOMAIN diff --git a/tests/api/integration/test_ai.py b/tests/api/integration/test_ai.py index 7fe3356..a811277 100644 --- a/tests/api/integration/test_ai.py +++ b/tests/api/integration/test_ai.py @@ -16,8 +16,8 @@ from nexusml.api.endpoints import ENDPOINT_AI_PREDICTION_LOGS from nexusml.api.endpoints import ENDPOINT_AI_TESTING from nexusml.api.endpoints import ENDPOINT_AI_TRAINING -from nexusml.api.ext import cache -from nexusml.api.ext import redis_buffer +from nexusml.api.external.ext import cache +from nexusml.api.external.ext import redis_buffer from nexusml.api.resources.ai import AIModel from nexusml.api.resources.ai import PredictionLog from nexusml.api.resources.tasks import Task diff --git a/tests/api/integration/test_examples.py b/tests/api/integration/test_examples.py index 25ba4e7..ba683e5 100644 --- a/tests/api/integration/test_examples.py +++ b/tests/api/integration/test_examples.py @@ -9,7 +9,7 @@ from nexusml.api.endpoints import ENDPOINT_EXAMPLE_SHAPE from nexusml.api.endpoints import ENDPOINT_EXAMPLE_SLICE from nexusml.api.endpoints import ENDPOINT_EXAMPLES -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.examples import Comment from nexusml.api.resources.examples import Example from nexusml.api.resources.examples import Shape diff --git a/tests/api/integration/test_files.py b/tests/api/integration/test_files.py index d4786ce..027bbc9 100644 --- a/tests/api/integration/test_files.py +++ b/tests/api/integration/test_files.py @@ -44,7 +44,7 @@ from nexusml.api.endpoints import ENDPOINT_TASK_LOCAL_FILE_STORE_DOWNLOAD from nexusml.api.endpoints import ENDPOINT_TASK_LOCAL_FILE_STORE_MULTIPART_UPLOAD from nexusml.api.endpoints import ENDPOINT_TASK_LOCAL_FILE_STORE_UPLOAD -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.files import OrgFile from nexusml.api.resources.files import TaskFile from nexusml.api.resources.organizations import Organization @@ -121,12 +121,12 @@ def _local_storage_backend(monkeypatch, create_root_path: bool = False) -> Flask monkeypatch.setattr('nexusml.api.utils.config', config) monkeypatch.setattr('nexusml.api.config', config) - # Monkeypatch `nexusml.api.ext.docs`. + # Monkeypatch `nexusml.api.external.ext.docs`. # TODO: Is this a sign of a bad design? # Note: We also monkeypatch the imported copies used by the following functions: # - `nexusml.api.create_app()` docs = FlaskApiSpec() - monkeypatch.setattr('nexusml.api.ext.docs', docs) + monkeypatch.setattr('nexusml.api.external.ext.docs', docs) monkeypatch.setattr('nexusml.api.docs', docs) # Monkeypatch the `nexusml.api.utils.get_file_storage_backend()` function to ensure it uses the patched config. diff --git a/tests/api/integration/test_myaccount.py b/tests/api/integration/test_myaccount.py index 9468241..b24ddac 100644 --- a/tests/api/integration/test_myaccount.py +++ b/tests/api/integration/test_myaccount.py @@ -13,6 +13,7 @@ from nexusml.api.endpoints import ENDPOINT_MYACCOUNT_SETTINGS from nexusml.api.endpoints import ENDPOINT_TASK from nexusml.api.endpoints import ENDPOINT_TASK_FILE +from nexusml.api.external.auth0 import Auth0Manager from nexusml.api.utils import API_DOMAIN from nexusml.api.utils import config from nexusml.constants import HTTP_DELETE_STATUS_CODE @@ -57,9 +58,10 @@ def test_delete_last_admin(self, client: MockClient, session_user_id: str): db_commit_and_expire() assert UserDB.get_from_uuid(session_user_id) is not None - def test_delete(self, mock_request_responses, mocker, client: MockClient, session_user_id: str): + def test_delete(self, mocker, client: MockClient, session_user_id: str): endpoint_url = get_endpoint(parameterized_endpoint=ENDPOINT_MYACCOUNT) mocker.patch('nexusml.api.resources.organizations.get_user_roles', return_value=['not_admin']) + mocker.patch.object(Auth0Manager, 'delete_auth0_user') response = client.send_request(method='DELETE', url=endpoint_url) @@ -67,8 +69,17 @@ def test_delete(self, mock_request_responses, mocker, client: MockClient, sessio db_commit_and_expire() assert UserDB.get_from_uuid(session_user_id) is None - def test_get(self, mock_request_responses, client: MockClient, session_user_id: str): + def test_get(self, mocker, client: MockClient, session_user_id: str): endpoint_url = get_endpoint(parameterized_endpoint=ENDPOINT_MYACCOUNT) + mock_auth0_return_data: dict = { + 'user_id': 'test_id', + 'email': 'test@testorg.com', + 'email_verified': True, + 'given_name': 'Test', + 'family_name': 'User' + } + mocker.patch.object(Auth0Manager, 'get_auth0_user_data', return_value=mock_auth0_return_data) + mocker.patch.object(Auth0Manager, '__init__', return_value=None) response = client.send_request(method='GET', url=endpoint_url) assert response.status_code == HTTP_GET_STATUS_CODE user = UserDB.get_from_uuid(session_user_id) @@ -87,8 +98,10 @@ def test_get(self, mock_request_responses, client: MockClient, session_user_id: 'email_verified': True } - def test_put(self, mock_request_responses, client: MockClient): + def test_put(self, mocker, client: MockClient): endpoint_url = get_endpoint(parameterized_endpoint=ENDPOINT_MYACCOUNT) + mock_auth0_manager = mocker.patch('nexusml.api.resources.organizations.Auth0Manager') + mock_auth0_manager.patch_auth0_user.return_value = None request_json: dict = {'first_name': 'new_f_name', 'last_name': 'new_l_name'} response = client.send_request(method='PUT', url=endpoint_url, json=request_json) assert response.status_code == HTTP_PUT_STATUS_CODE diff --git a/tests/api/integration/test_organizations.py b/tests/api/integration/test_organizations.py index e7a9f97..0d77847 100644 --- a/tests/api/integration/test_organizations.py +++ b/tests/api/integration/test_organizations.py @@ -24,7 +24,7 @@ from nexusml.api.endpoints import ENDPOINT_USER_PERMISSIONS from nexusml.api.endpoints import ENDPOINT_USER_ROLES from nexusml.api.endpoints import ENDPOINT_USERS -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources import ResourceNotFoundError from nexusml.api.resources.organizations import Client from nexusml.api.resources.organizations import Collaborator diff --git a/tests/api/integration/test_permissions.py b/tests/api/integration/test_permissions.py index 3282442..3ecdbf7 100644 --- a/tests/api/integration/test_permissions.py +++ b/tests/api/integration/test_permissions.py @@ -5,7 +5,7 @@ from nexusml.api.endpoints import ENDPOINT_EXAMPLE from nexusml.api.endpoints import ENDPOINT_TAG from nexusml.api.endpoints import ENDPOINT_TASK -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.ai import AIModel from nexusml.api.resources.ai import PredictionLog from nexusml.api.resources.base import Resource diff --git a/tests/api/integration/test_quotas.py b/tests/api/integration/test_quotas.py index 92a1ecd..d462b2c 100644 --- a/tests/api/integration/test_quotas.py +++ b/tests/api/integration/test_quotas.py @@ -6,6 +6,7 @@ from nexusml.api.endpoints import ENDPOINT_MYACCOUNT from nexusml.api.endpoints import ENDPOINT_ORGANIZATION from nexusml.api.endpoints import ENDPOINT_TASKS +from nexusml.api.external.auth0 import Auth0Manager from nexusml.api.resources.organizations import Organization from nexusml.constants import ADMIN_ROLE from nexusml.constants import HTTP_UNPROCESSABLE_ENTITY_STATUS_CODE @@ -66,7 +67,7 @@ def test_space_limit(self): def test_users_limit(self, mocker, mock_request_responses, client: MockClient): mocker.patch('nexusml.api.views.myaccount.agent_from_token', side_effect=jwt.InvalidTokenError()) - mocker.patch('nexusml.api.views.myaccount.get_auth0_management_api_token', return_value=MagicMock()) + mocker.patch.object(Auth0Manager, 'get_auth0_user_data', return_value=MagicMock()) mock_user_invitation = MagicMock() mock_user_invitation.organization_id = 1 mocker.patch('nexusml.api.views.myaccount.MyAccountView._get_user_invitation', return_value=MagicMock()) diff --git a/tests/api/integration/test_resource_id.py b/tests/api/integration/test_resource_id.py index 8767e85..8290e38 100644 --- a/tests/api/integration/test_resource_id.py +++ b/tests/api/integration/test_resource_id.py @@ -14,7 +14,7 @@ from nexusml.api.endpoints import ENDPOINT_TAG from nexusml.api.endpoints import ENDPOINT_TASK from nexusml.api.endpoints import ENDPOINT_TASK_FILE -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.ai import AIModel from nexusml.api.resources.ai import PredictionLog from nexusml.api.resources.examples import Example diff --git a/tests/api/integration/test_services.py b/tests/api/integration/test_services.py index 358109b..7dbc118 100644 --- a/tests/api/integration/test_services.py +++ b/tests/api/integration/test_services.py @@ -20,7 +20,7 @@ from nexusml.api.endpoints import ENDPOINT_MONITORING_SERVICE_STATUS from nexusml.api.endpoints import ENDPOINT_MONITORING_SERVICE_TEMPLATES from nexusml.api.endpoints import ENDPOINT_SERVICES -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.tasks import Task from nexusml.api.views import services from nexusml.constants import DATETIME_FORMAT diff --git a/tests/api/integration/test_tasks.py b/tests/api/integration/test_tasks.py index f439e1e..d229c28 100644 --- a/tests/api/integration/test_tasks.py +++ b/tests/api/integration/test_tasks.py @@ -18,7 +18,7 @@ from nexusml.api.endpoints import ENDPOINT_TASK_SETTINGS from nexusml.api.endpoints import ENDPOINT_TASK_STATUS from nexusml.api.endpoints import ENDPOINT_TASKS -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.tasks import InputCategory from nexusml.api.resources.tasks import InputElement from nexusml.api.resources.tasks import MetadataCategory diff --git a/tests/api/integration/utils.py b/tests/api/integration/utils.py index ba3d6c1..de18edc 100644 --- a/tests/api/integration/utils.py +++ b/tests/api/integration/utils.py @@ -18,7 +18,7 @@ from nexusml.api.endpoints import ENDPOINT_AI_PREDICTION_LOG from nexusml.api.endpoints import ENDPOINT_EXAMPLE -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.ai import PredictionLog from nexusml.api.resources.base import Permission from nexusml.api.resources.base import Resource diff --git a/tests/api/unit/external/__init__.py b/tests/api/unit/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/external/test_auth0.py b/tests/api/unit/external/test_auth0.py new file mode 100644 index 0000000..dc40947 --- /dev/null +++ b/tests/api/unit/external/test_auth0.py @@ -0,0 +1,269 @@ +import pytest +import requests +from requests import Response +from werkzeug.exceptions import BadRequest + +from nexusml.api.external.auth0 import Auth0Manager +from nexusml.env import ENV_AUTH0_CLIENT_ID +from nexusml.env import ENV_AUTH0_CLIENT_SECRET + + +class TestAuth0Manager: + """Tests for the Auth0Manager class as a whole.""" + + @pytest.fixture(autouse=True) + def setup_environment(self, monkeypatch): + """ + Setup environment variables for tests. + """ + Auth0Manager.auth0_domain_url = 'https://example.auth0.com' + Auth0Manager.auth0_user_url = 'https://example.auth0.com/api/v2/users' + + monkeypatch.setenv(ENV_AUTH0_CLIENT_ID, 'your_client_id') + monkeypatch.setenv(ENV_AUTH0_CLIENT_SECRET, 'your_client_secret') + + class TestInit: + """Tests for the __init__ method of Auth0Manager.""" + + def test_initialization_with_token(self): + """ + Test initialization with a provided token. + + Asserts: + - The provided token is set in the Auth0Manager instance. + - Authorization header includes the correct Bearer token. + """ + auth0_token: str = 'some_token' + auth0_manager: Auth0Manager = Auth0Manager(auth0_token=auth0_token) + assert auth0_manager._auth0_token == auth0_token + assert auth0_manager.authorization_headers['Authorization'] == f'Bearer {auth0_token}' + + def test_initialization_without_token(self, mocker): + """ + Test initialization without a provided token. + + Mocks: + - Mocks the `set_header` method of Auth0Manager. + + Asserts: + - Ensures `set_header` is called during initialization without a token. + """ + mock_set_header = mocker.patch.object(Auth0Manager, 'update_authorization_header') + Auth0Manager() + mock_set_header.assert_called_once() + + class TestSetHeader: + """Tests for the set_header method of Auth0Manager.""" + + def test_set_header(self, mocker): + """ + Test the `set_header` method to update authorization headers. + + Mocks: + - Mocks `_get_auth0_management_api_token` to return a new token. + + Asserts: + - Ensures the token is updated and the authorization headers are set correctly. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='existing_token') + mocker.patch.object(aut0_manager, '_get_auth0_management_api_token', return_value='new_token') + + aut0_manager.update_authorization_header() + + assert aut0_manager._auth0_token == 'new_token' + assert aut0_manager.authorization_headers['Authorization'] == 'Bearer new_token' + + class TestGetAuth0ManagementApiToken: + """Tests for the _get_auth0_management_api_token method of Auth0Manager.""" + + def test_get_auth0_management_api_token(self, mocker): + """ + Test retrieval of the Auth0 management API token. + + Mocks: + - Mocks the `requests.post` method to simulate token retrieval. + + Asserts: + - The correct token is returned from the mocked response. + - The correct API endpoint and payload are used in the request. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.json.return_value = {'access_token': 'mock_access_token'} + mocker.patch('requests.post', return_value=mock_response) + + token = aut0_manager._get_auth0_management_api_token() + + assert token == 'mock_access_token' + requests.post.assert_called_once_with('https://example.auth0.com/oauth/token', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'your_client_id', + 'client_secret': 'your_client_secret', + 'audience': 'https://example.auth0.com/api/v2/', + }, + headers={'Content-Type': 'application/json'}) + + class TestGetAuth0UserData: + """Tests for the get_auth0_user_data method of Auth0Manager.""" + + def test_get_auth0_user_data_by_email(self, mocker): + """ + Test retrieval of Auth0 user data by email. + + Mocks: + - Mocks the `requests.get` method to simulate fetching user data. + + Asserts: + - The correct user data is returned. + - The correct API endpoint and headers are used in the request. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{'email': 'user@example.com'}] + mocker.patch('requests.get', return_value=mock_response) + + result = aut0_manager.get_auth0_user_data(auth0_id_or_email='user@example.com') + + assert result == {'email': 'user@example.com'} + requests.get.assert_called_once_with( + 'https://example.auth0.com/api/v2/users?q=email:user%40example.com&search_engine=v3', + headers=aut0_manager.authorization_headers) + + def test_get_auth0_user_data_by_auth0_id(self, mocker): + """ + Test retrieval of Auth0 user data by Auth0 ID. + + Mocks: + - Mocks the `requests.get` method to simulate fetching user data. + + Asserts: + - The correct user data is returned. + - The correct API endpoint and headers are used in the request. + """ + manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {'user_id': 'auth0|123456'} + mocker.patch('requests.get', return_value=mock_response) + + result = manager.get_auth0_user_data(auth0_id_or_email='auth0|123456') + + assert result == {'user_id': 'auth0|123456'} + requests.get.assert_called_once_with('https://example.auth0.com/api/v2/users/auth0%7C123456', + headers=manager.authorization_headers) + + def test_get_auth0_user_data_not_found(self, mocker): + """ + Test handling of not-found Auth0 user data. + + Mocks: + - Mocks the `requests.get` method to simulate a 404 response. + + Asserts: + - Ensures a BadRequest exception is raised for a missing user. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.status_code = 404 + mocker.patch('requests.get', return_value=mock_response) + + with pytest.raises(BadRequest): + aut0_manager.get_auth0_user_data(auth0_id_or_email='unknown_user') + + class TestDeleteAuth0User: + """Tests for the delete_auth0_user method of Auth0Manager.""" + + def test_delete_auth0_user(self, mocker): + """ + Test successful deletion of an Auth0 user. + + Mocks: + - Mocks the `requests.delete` method to simulate user deletion. + + Asserts: + - Ensures the delete request is sent to the correct API endpoint. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.raise_for_status = mocker.Mock() + mocker.patch('requests.delete', return_value=mock_response) + + aut0_manager.delete_auth0_user(auth0_id='auth0|123456') + + requests.delete.assert_called_once_with('https://example.auth0.com/api/v2/users/auth0|123456', + headers=aut0_manager.authorization_headers) + + def test_delete_auth0_user_failed(self, mocker): + """ + Test handling of failed Auth0 user deletion. + + Mocks: + - Mocks the `requests.delete` method to simulate a deletion failure. + + Asserts: + - Ensures an HTTPError is raised when the deletion fails. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.raise_for_status.side_effect = requests.HTTPError('User deletion failed') + mocker.patch('requests.delete', return_value=mock_response) + + with pytest.raises(requests.HTTPError): + aut0_manager.delete_auth0_user(auth0_id='auth0|123456') + + class TestPatchAuth0User: + """Tests for the patch_auth0_user method of Auth0Manager.""" + + def test_patch_auth0_user(self, mocker, app): + """ + Test successful patch of an Auth0 user. + + Mocks: + - Mocks the `requests.patch` method to simulate patching user data. + - Mocks the Flask app context for accessing user info. + + Asserts: + - Ensures the correct patch request is sent with updated user data. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.raise_for_status = mocker.Mock() + mocker.patch('requests.patch', return_value=mock_response) + + with app.app_context(): + mocker.patch('nexusml.api.external.auth0.g', + new_callable=mocker.PropertyMock, + user_auth0_id='auth0|123456') + aut0_manager.patch_auth0_user(updated_data={'email': 'newemail@example.com'}) + + requests.patch.assert_called_once_with('https://example.auth0.com/api/v2/users/auth0|123456', + json={'email': 'newemail@example.com'}, + headers={ + 'Authorization': 'Bearer some_token', + 'content-type': 'application/json' + }) + + def test_patch_auth0_user_failed(self, mocker, app): + """ + Test handling of failed patch operation on an Auth0 user. + + Mocks: + - Mocks the `requests.patch` method to simulate a patch failure. + + Asserts: + - Ensures an HTTPError is raised when the patch request fails. + """ + aut0_manager: Auth0Manager = Auth0Manager(auth0_token='some_token') + mock_response = mocker.Mock(spec=Response) + mock_response.raise_for_status.side_effect = requests.HTTPError('User patch failed') + mocker.patch('requests.patch', return_value=mock_response) + + with app.app_context(): + mocker.patch('nexusml.api.external.auth0.g', + new_callable=mocker.PropertyMock, + user_auth0_id='auth0|123456') + + with pytest.raises(requests.HTTPError): + aut0_manager.patch_auth0_user(updated_data={'email': 'newemail@example.com'}) diff --git a/tests/api/unit/resources/test_organizations.py b/tests/api/unit/resources/test_organizations.py index afbc6bd..620a4e2 100644 --- a/tests/api/unit/resources/test_organizations.py +++ b/tests/api/unit/resources/test_organizations.py @@ -2,6 +2,7 @@ import pytest +from nexusml.api.external.auth0 import Auth0Manager from nexusml.api.resources import ResourceNotFoundError from nexusml.api.resources.organizations import User from nexusml.env import ENV_AUTH0_CLIENT_ID @@ -18,12 +19,12 @@ def mock_env(monkeypatch): @pytest.fixture(scope='function') def mock_get_auth0_user_data(mocker): - return mocker.patch('nexusml.api.resources.organizations.get_auth0_user_data') + return mocker.patch.object(Auth0Manager, 'get_auth0_user_data', retutn_value={}) @pytest.fixture(scope='function') def mock_get_auth0_management_api_token(mocker): - return mocker.patch('nexusml.api.resources.organizations.get_auth0_management_api_token') + return mocker.patch.object(Auth0Manager, '_get_auth0_management_api_token', return_value='auth0_token') class TestDownloadAuth0UserData: diff --git a/tests/api/unit/test_permissions.py b/tests/api/unit/test_permissions.py index 26d0188..718c7cd 100644 --- a/tests/api/unit/test_permissions.py +++ b/tests/api/unit/test_permissions.py @@ -8,7 +8,7 @@ import pytest from sqlalchemy import bindparam -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.ai import AIModel from nexusml.api.resources.base import Permission from nexusml.api.resources.base import Resource diff --git a/tests/api/unit/test_utils.py b/tests/api/unit/test_utils.py deleted file mode 100644 index 2364168..0000000 --- a/tests/api/unit/test_utils.py +++ /dev/null @@ -1,165 +0,0 @@ -import os -from unittest.mock import MagicMock - -import pytest - -from nexusml.api.utils import BadRequest -from nexusml.api.utils import delete_auth0_user -from nexusml.api.utils import get_auth0_management_api_token -from nexusml.api.utils import get_auth0_user_data -from nexusml.env import ENV_AUTH0_DOMAIN - - -@pytest.fixture(scope='function', autouse=True) -def mock_env(monkeypatch): - monkeypatch.setenv(ENV_AUTH0_DOMAIN, 'test_domain') - - -def test_get_auth0_management_api_token(mocker): - """ - Test get_auth0_management_api_token function for retrieving Auth0 management API token. - - This test verifies that the get_auth0_management_api_token function correctly sends a POST - request to retrieve the Auth0 management API token and returns the token value. - - Test Steps: - 1. Patch the 'os.environ' to mock environment variables. - 2. Patch the 'requests.post' method to mock the POST request response. - 3. Mock the response of the POST request with a JSON response containing the access token. - 4. Call get_auth0_management_api_token. - - Assertions: - - The POST request is sent to the correct endpoint. - - The function returns the correct Auth0 management API token retrieved from the mocked response. - """ - - auth0_json_response: dict = {'access_token': 'access_token_value'} - - mocker.patch('os.environ') - - mock_post = mocker.patch('requests.post') - mock_post_response: MagicMock = MagicMock() - mock_post_response.json.return_value = auth0_json_response - mock_post.return_value = mock_post_response - - result = get_auth0_management_api_token() - - mock_post.assert_called_once() - assert result == 'access_token_value' - - -@pytest.mark.parametrize('auth0_id_or_email, expected_url_extension', - [('example@example.com', '?q=email:example%40example.com&search_engine=v3'), - ('UUID-UUID-UUID-UUID', '/UUID-UUID-UUID-UUID')]) -def test_get_auth0_user_data(mocker, auth0_id_or_email, expected_url_extension): - """ - Parameterized test for get_auth0_user_data function. - - This test verifies the behavior of get_auth0_user_data function for different - user identifiers (email and UUID). It checks that the function constructs the correct URL - based on the provided user identifier and sends the appropriate GET request. - - Test Steps: - 1. Mock the Auth0 access token, expected URL (including extension), and mock account data. - 2. Patch the 'requests.get' method to mock the GET request response. - 3. Mock the response of the GET request with a 200 status code and mock account data. - 4. Call get_auth0_user_data with the access token and user identifier. - - Parameters: - - user_uuid_or_email: The user identifier (email or UUID) used to construct the URL. - - expected_url_extension: The expected URL extension based on the user identifier. - - Assertions: - - The GET request is sent to the correct URL with the appropriate headers. - - The function returns the correct user account data retrieved from the mocked response. - """ - access_token = 'dummy_access_token' - expected_url: str = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users{expected_url_extension}' - mock_account_data: list = ['account_data'] - - # Mocking the response of the GET request - mock_get = mocker.patch('requests.get') - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = mock_account_data - mock_get.return_value = mock_response - - result = get_auth0_user_data(access_token, auth0_id_or_email) - - mock_get.assert_called_once_with(expected_url, headers={'Authorization': 'Bearer ' + access_token}) - assert result == mock_account_data[0] - - -def test_get_auth0_user_data_raises_error(mocker): - """ - Test get_auth0_user_data raises BadRequest error. - - This test verifies that the function get_auth0_user_data correctly raises - a BadRequest error when the external API call returns a 400 status code. - - Test Steps: - 1. Create a mock access token and user identifier (UUID or email). - 2. Patch the 'requests.get' method to return a mock response with a 400 status code. - 3. Call get_auth0_user_data with the mock access token and user identifier. - 4. Verify that the function raises a BadRequest error. - - Assertions: - - The function raises a BadRequest error when the API response status code is 400. - """ - - access_token = 'dummy_access_token' - auth0_id_or_email = 'example@example.com' - - mock_get = mocker.patch('requests.get') - mock_response = MagicMock() - mock_response.status_code = 400 - mock_get.return_value = mock_response - - with pytest.raises(BadRequest): - get_auth0_user_data(access_token, auth0_id_or_email) - - -def test_delete_auth0_user(mocker): - """ - Test delete_auth0_user function behavior for successful and failed delete operations. - - This test verifies that the delete_auth0_user function sends the correct DELETE request - and handles both successful (204 No Content) and failed (400 Bad Request) responses - appropriately. - - Test Steps: - 1. Create mock Auth0 token and ID, and construct the expected URL. - 2. Patch the 'requests.delete' method to mock the DELETE request response. - 3. Mock the response of the DELETE request with a 204 status code (success). - 4. Call delete_auth0_user with the mock token and ID. - 5. Assert the DELETE request was made with the correct URL and headers. - 6. Change the mock response status code to 400 (bad request). - 7. Verify that delete_auth0_user raises a BadRequest error when the response status code is 400. - - Assertions: - - The DELETE request is sent to the correct URL with the appropriate headers. - - The function does not raise an error for a 204 No Content response. - - The function raises a BadRequest error for a 400 Bad Request response. - """ - - auth0_token = 'dummy_auth0_token' - auth0_id = 'dummy_auth0_id' - expected_url = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users/{auth0_id}' - - # Mocking the response of the DELETE request - mock_delete = mocker.patch('requests.delete') - mock_response = MagicMock() - mock_response.status_code = 204 - mock_delete.return_value = mock_response - - mock_auth0_token = mocker.patch('nexusml.api.utils.get_auth0_management_api_token') - mock_auth0_token.return_value = auth0_token - - delete_auth0_user(auth0_id) - - # Assertions - mock_delete.assert_called_once_with(expected_url, headers={'Authorization': 'Bearer ' + auth0_token}) - - mock_response.status_code = 400 - with pytest.raises(AssertionError): - delete_auth0_user(auth0_id) diff --git a/tests/api/utils.py b/tests/api/utils.py index 3427bf3..d2b29a3 100644 --- a/tests/api/utils.py +++ b/tests/api/utils.py @@ -5,7 +5,7 @@ import boto3 -from nexusml.api.ext import cache +from nexusml.api.external.ext import cache from nexusml.api.resources.base import Resource from nexusml.api.resources.organizations import Organization from nexusml.api.resources.organizations import Role