From 06b2f127d695f3705f35961f45b09a378493ba46 Mon Sep 17 00:00:00 2001 From: Vemarthula-Microsoft Date: Thu, 31 Jul 2025 17:00:31 +0530 Subject: [PATCH] feat: Use ManagedIdentityCredential instead of DefaultAzureCredentials (#235) * commiting the new changes * removing Credntials * app contect py file change * PR Review Changes * Test workflow changes * Test work flow build failed * test work flow failed fixes * test workflow final fixes * Pylint Fixes --- infra/main.bicep | 16 ++++ src/ContentProcessor/conftest.py | 34 +++++++ src/ContentProcessor/pytest.ini | 6 ++ .../src/helpers/azure_credential_utils.py | 44 ++++++++++ .../libs/application/application_context.py | 8 +- .../libs/azure_helper/app_configuration.py | 8 +- .../src/libs/azure_helper/azure_openai.py | 8 +- .../azure_helper/content_understanding.py | 5 +- .../src/libs/azure_helper/storage_blob.py | 5 +- .../libs/pipeline/pipeline_queue_helper.py | 8 +- .../src/libs/utils/remote_module_loader.py | 4 +- src/ContentProcessor/src/main.py | 4 +- .../tests/azure_helper/test_storage_blob.py | 16 ++-- src/ContentProcessor/src/tests/conftest.py | 4 + .../helpers/test_azure_credential_utils.py | 88 +++++++++++++++++++ src/ContentProcessor/src/tests/test_main.py | 3 +- src/ContentProcessorAPI/app/.env.dev | 9 ++ .../app/libs/app_configuration/helper.py | 5 +- .../app/libs/storage_blob/helper.py | 4 +- .../app/libs/storage_queue/helper.py | 6 +- .../app/tests/libs/test_storage_blob.py | 7 +- src/ContentProcessorAPI/conftest.py | 34 +++++++ .../helpers/azure_credential_utils.py | 35 ++++++++ src/ContentProcessorAPI/pytest.ini | 6 ++ src/ContentProcessorAPI/tests/conftest.py | 4 + .../helpers/test_azure_credential_utils.py | 88 +++++++++++++++++++ 26 files changed, 419 insertions(+), 40 deletions(-) create mode 100644 src/ContentProcessor/conftest.py create mode 100644 src/ContentProcessor/pytest.ini create mode 100644 src/ContentProcessor/src/helpers/azure_credential_utils.py create mode 100644 src/ContentProcessor/src/tests/conftest.py create mode 100644 src/ContentProcessor/src/tests/helpers/test_azure_credential_utils.py create mode 100644 src/ContentProcessorAPI/app/.env.dev create mode 100644 src/ContentProcessorAPI/conftest.py create mode 100644 src/ContentProcessorAPI/helpers/azure_credential_utils.py create mode 100644 src/ContentProcessorAPI/pytest.ini create mode 100644 src/ContentProcessorAPI/tests/conftest.py create mode 100644 src/ContentProcessorAPI/tests/helpers/test_azure_credential_utils.py diff --git a/infra/main.bicep b/infra/main.bicep index fec68cd5..9bc52f29 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -807,6 +807,10 @@ module avmContainerApp 'br/public:avm/res/app/container-app:0.17.0' = { name: 'APP_CONFIG_ENDPOINT' value: '' } + { + name: 'APP_ENV' + value: 'prod' + } ] } ] @@ -851,6 +855,10 @@ module avmContainerApp_API 'br/public:avm/res/app/container-app:0.17.0' = { name: 'APP_CONFIG_ENDPOINT' value: '' } + { + name: 'APP_ENV' + value: 'prod' + } ] probes: [ // Liveness Probe - Checks if the app is still running @@ -1266,6 +1274,10 @@ module avmContainerApp_update 'br/public:avm/res/app/container-app:0.17.0' = { name: 'APP_CONFIG_ENDPOINT' value: avmAppConfig.outputs.endpoint } + { + name: 'APP_ENV' + value: 'prod' + } ] } ] @@ -1321,6 +1333,10 @@ module avmContainerApp_API_update 'br/public:avm/res/app/container-app:0.17.0' = name: 'APP_CONFIG_ENDPOINT' value: avmAppConfig.outputs.endpoint } + { + name: 'APP_ENV' + value: 'prod' + } ] probes: [ // Liveness Probe - Checks if the app is still running diff --git a/src/ContentProcessor/conftest.py b/src/ContentProcessor/conftest.py new file mode 100644 index 00000000..d12580da --- /dev/null +++ b/src/ContentProcessor/conftest.py @@ -0,0 +1,34 @@ +""" +Global test configuration and fixtures for ContentProcessor tests. +""" +import sys +import os +import pytest +from unittest.mock import patch, MagicMock + +# Add src directory to Python path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +pytest_plugins = ["pytest_mock"] + + +@pytest.fixture(autouse=True, scope="function") +def mock_azure_credentials_for_helpers(request): + """ + Mock Azure credentials for azure_helper classes only. + Skip this for credential utility tests that need to test the actual logic. + """ + # Skip mocking for credential utility tests + if "test_azure_credential_utils" in str(request.fspath): + yield + return + + with patch("helpers.azure_credential_utils.get_azure_credential") as mock_get_cred, \ + patch("helpers.azure_credential_utils.get_azure_credential_async") as mock_get_cred_async: + + # Create mock credential objects + mock_credential = MagicMock() + mock_get_cred.return_value = mock_credential + mock_get_cred_async.return_value = mock_credential + + yield mock_credential diff --git a/src/ContentProcessor/pytest.ini b/src/ContentProcessor/pytest.ini new file mode 100644 index 00000000..457e85b8 --- /dev/null +++ b/src/ContentProcessor/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +addopts = -v --strict-markers --disable-warnings +python_files = tests.py test_*.py *_tests.py +testpaths = src/tests +markers = + asyncio: marks tests as async diff --git a/src/ContentProcessor/src/helpers/azure_credential_utils.py b/src/ContentProcessor/src/helpers/azure_credential_utils.py new file mode 100644 index 00000000..5b3ad041 --- /dev/null +++ b/src/ContentProcessor/src/helpers/azure_credential_utils.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from azure.identity import ManagedIdentityCredential, DefaultAzureCredential +from azure.identity.aio import ManagedIdentityCredential as AioManagedIdentityCredential, DefaultAzureCredential as AioDefaultAzureCredential + + +async def get_azure_credential_async(client_id=None): + """ + Returns an Azure credential asynchronously based on the application environment. + + If the environment is 'dev', it uses AioDefaultAzureCredential. + Otherwise, it uses AioManagedIdentityCredential. + + Args: + client_id (str, optional): The client ID for the Managed Identity Credential. + + Returns: + Credential object: Either AioDefaultAzureCredential or AioManagedIdentityCredential. + """ + if os.getenv("APP_ENV", "prod").lower() == 'dev': + return AioDefaultAzureCredential() # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + else: + return AioManagedIdentityCredential(client_id=client_id) + + +def get_azure_credential(client_id=None): + """ + Returns an Azure credential based on the application environment. + + If the environment is 'dev', it uses DefaultAzureCredential. + Otherwise, it uses ManagedIdentityCredential. + + Args: + client_id (str, optional): The client ID for the Managed Identity Credential. + + Returns: + Credential object: Either DefaultAzureCredential or ManagedIdentityCredential. + """ + if os.getenv("APP_ENV", "prod").lower() == 'dev': + return DefaultAzureCredential() # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + else: + return ManagedIdentityCredential(client_id=client_id) diff --git a/src/ContentProcessor/src/libs/application/application_context.py b/src/ContentProcessor/src/libs/application/application_context.py index 53f8987a..6d9bcbd0 100644 --- a/src/ContentProcessor/src/libs/application/application_context.py +++ b/src/ContentProcessor/src/libs/application/application_context.py @@ -1,4 +1,6 @@ -from azure.identity import DefaultAzureCredential +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +from typing import Any from libs.application.application_configuration import AppConfiguration from libs.base.application_models import AppModelBase @@ -11,10 +13,10 @@ class AppContext(AppModelBase): """ configuration: AppConfiguration = None - credential: DefaultAzureCredential = None + credential: Any = None # Azure credential object def set_configuration(self, configuration: AppConfiguration): self.configuration = configuration - def set_credential(self, credential: DefaultAzureCredential): + def set_credential(self, credential: Any): self.credential = credential diff --git a/src/ContentProcessor/src/libs/azure_helper/app_configuration.py b/src/ContentProcessor/src/libs/azure_helper/app_configuration.py index be83df30..73a632e0 100644 --- a/src/ContentProcessor/src/libs/azure_helper/app_configuration.py +++ b/src/ContentProcessor/src/libs/azure_helper/app_configuration.py @@ -1,16 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import os from azure.appconfiguration import AzureAppConfigurationClient -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential class AppConfigurationHelper: - credential: DefaultAzureCredential = None app_config_endpoint: str = None app_config_client: AzureAppConfigurationClient = None def __init__(self, app_config_endpoint: str): - self.credential = DefaultAzureCredential() + self.credential = get_azure_credential() self.app_config_endpoint = app_config_endpoint self._initialize_client() diff --git a/src/ContentProcessor/src/libs/azure_helper/azure_openai.py b/src/ContentProcessor/src/libs/azure_helper/azure_openai.py index cb6edbe6..13b3ce3f 100644 --- a/src/ContentProcessor/src/libs/azure_helper/azure_openai.py +++ b/src/ContentProcessor/src/libs/azure_helper/azure_openai.py @@ -1,9 +1,13 @@ -from azure.identity import DefaultAzureCredential, get_bearer_token_provider +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from azure.identity import get_bearer_token_provider +from helpers.azure_credential_utils import get_azure_credential from openai import AzureOpenAI def get_openai_client(azure_openai_endpoint: str) -> AzureOpenAI: - credential = DefaultAzureCredential() + credential = get_azure_credential() token_provider = get_bearer_token_provider( credential, "https://cognitiveservices.azure.com/.default" ) diff --git a/src/ContentProcessor/src/libs/azure_helper/content_understanding.py b/src/ContentProcessor/src/libs/azure_helper/content_understanding.py index e1ada645..b611256b 100644 --- a/src/ContentProcessor/src/libs/azure_helper/content_understanding.py +++ b/src/ContentProcessor/src/libs/azure_helper/content_understanding.py @@ -7,14 +7,13 @@ from pathlib import Path import requests -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from requests.models import Response COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" class AzureContentUnderstandingHelper: - credential: DefaultAzureCredential = None def __init__( self, @@ -22,7 +21,7 @@ def __init__( api_version: str = "2024-12-01-preview", x_ms_useragent: str = "cps-contentunderstanding/client", ): - self.credential = DefaultAzureCredential() + self.credential = get_azure_credential() if not api_version: raise ValueError("API version must be provided.") diff --git a/src/ContentProcessor/src/libs/azure_helper/storage_blob.py b/src/ContentProcessor/src/libs/azure_helper/storage_blob.py index 6a100b90..15b2e8de 100644 --- a/src/ContentProcessor/src/libs/azure_helper/storage_blob.py +++ b/src/ContentProcessor/src/libs/azure_helper/storage_blob.py @@ -3,12 +3,11 @@ from typing import IO, Union -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from azure.storage.blob import BlobServiceClient class StorageBlobHelper: - credential: DefaultAzureCredential = None blob_service_client: BlobServiceClient = None @staticmethod @@ -16,7 +15,7 @@ def get(account_url: str, container_name: str = None): return StorageBlobHelper(account_url=account_url, container_name=container_name) def __init__(self, account_url: str, container_name=None): - self.credential = DefaultAzureCredential() + self.credential = get_azure_credential() self.blob_service_client = BlobServiceClient( account_url=account_url, credential=self.credential ) diff --git a/src/ContentProcessor/src/libs/pipeline/pipeline_queue_helper.py b/src/ContentProcessor/src/libs/pipeline/pipeline_queue_helper.py index 25840fd9..97cdbfe5 100644 --- a/src/ContentProcessor/src/libs/pipeline/pipeline_queue_helper.py +++ b/src/ContentProcessor/src/libs/pipeline/pipeline_queue_helper.py @@ -4,7 +4,7 @@ import logging from azure.core.exceptions import ResourceNotFoundError -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from azure.storage.queue import QueueClient, QueueMessage from libs.pipeline import pipeline_step_helper @@ -28,7 +28,7 @@ def invalidate_queue(queue_client: QueueClient): def create_or_get_queue_client( - queue_name: str, accouont_url: str, credential: DefaultAzureCredential + queue_name: str, accouont_url: str, credential: get_azure_credential ) -> QueueClient: queue_client = QueueClient( account_url=accouont_url, queue_name=queue_name, credential=credential @@ -55,7 +55,7 @@ def has_messages(queue_client: QueueClient) -> bool: def pass_data_pipeline_to_next_step( - data_pipeline: DataPipeline, account_url: str, credential: DefaultAzureCredential + data_pipeline: DataPipeline, account_url: str, credential: get_azure_credential ): next_step_name = pipeline_step_helper.get_next_step_name( data_pipeline.pipeline_status, data_pipeline.pipeline_status.active_step @@ -70,7 +70,7 @@ def pass_data_pipeline_to_next_step( def _create_queue_client( - account_url: str, queue_name: str, credential: DefaultAzureCredential + account_url: str, queue_name: str, credential: get_azure_credential ) -> QueueClient: queue_client = QueueClient( account_url=account_url, queue_name=queue_name, credential=credential diff --git a/src/ContentProcessor/src/libs/utils/remote_module_loader.py b/src/ContentProcessor/src/libs/utils/remote_module_loader.py index 3b638060..956ccea9 100644 --- a/src/ContentProcessor/src/libs/utils/remote_module_loader.py +++ b/src/ContentProcessor/src/libs/utils/remote_module_loader.py @@ -4,7 +4,7 @@ import importlib.util import sys -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from azure.storage.blob import BlobServiceClient @@ -27,7 +27,7 @@ def load_schema_from_blob( def _download_blob_content(container_name, blob_name, account_url): # Create the BlobServiceClient object which will be used to create a container client - credential = DefaultAzureCredential() + credential = get_azure_credential() blob_service_client = BlobServiceClient( account_url=account_url, credential=credential ) diff --git a/src/ContentProcessor/src/main.py b/src/ContentProcessor/src/main.py index 1fc41111..b889e490 100644 --- a/src/ContentProcessor/src/main.py +++ b/src/ContentProcessor/src/main.py @@ -5,7 +5,7 @@ import os import sys -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from libs.base.application_main import AppMainBase from libs.process_host import handler_type_loader @@ -29,7 +29,7 @@ def __init__(self, **data): def _initialize_application(self): # Add Azure Credential - self.application_context.set_credential(DefaultAzureCredential()) + self.application_context.set_credential(get_azure_credential()) async def run(self, test_mode: bool = False): # Get Process lists from the configuration - ex. ["extract", "transform", "evaluate", "save", "custom1", "custom2"....] diff --git a/src/ContentProcessor/src/tests/azure_helper/test_storage_blob.py b/src/ContentProcessor/src/tests/azure_helper/test_storage_blob.py index d14a99d2..452ff426 100644 --- a/src/ContentProcessor/src/tests/azure_helper/test_storage_blob.py +++ b/src/ContentProcessor/src/tests/azure_helper/test_storage_blob.py @@ -1,20 +1,22 @@ import pytest from io import BytesIO -from libs.azure_helper.storage_blob import StorageBlobHelper +from unittest.mock import MagicMock, patch + +# Ensure Azure credentials are mocked before any imports +with patch("helpers.azure_credential_utils.get_azure_credential") as mock_cred: + mock_cred.return_value = MagicMock() + from libs.azure_helper.storage_blob import StorageBlobHelper @pytest.fixture def mock_blob_service_client(mocker): + """Mock BlobServiceClient class for tests.""" return mocker.patch("libs.azure_helper.storage_blob.BlobServiceClient") @pytest.fixture -def mock_default_azure_credential(mocker): - return mocker.patch("libs.azure_helper.storage_blob.DefaultAzureCredential") - - -@pytest.fixture -def storage_blob_helper(mock_blob_service_client, mock_default_azure_credential): +def storage_blob_helper(mock_blob_service_client): + """Create StorageBlobHelper with mocked BlobServiceClient.""" return StorageBlobHelper( account_url="https://testaccount.blob.core.windows.net", container_name="testcontainer", diff --git a/src/ContentProcessor/src/tests/conftest.py b/src/ContentProcessor/src/tests/conftest.py new file mode 100644 index 00000000..b4398129 --- /dev/null +++ b/src/ContentProcessor/src/tests/conftest.py @@ -0,0 +1,4 @@ +""" +Test configuration and fixtures for ContentProcessor tests. +""" +# Note: pytest_plugins is now defined in the top-level conftest.py diff --git a/src/ContentProcessor/src/tests/helpers/test_azure_credential_utils.py b/src/ContentProcessor/src/tests/helpers/test_azure_credential_utils.py new file mode 100644 index 00000000..c0cddb9b --- /dev/null +++ b/src/ContentProcessor/src/tests/helpers/test_azure_credential_utils.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pytest +import sys +import os +from unittest.mock import patch, MagicMock + +# Ensure src directory is on the Python path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +# Import after path modification (flake8: noqa) +import helpers.azure_credential_utils as azure_credential_utils # noqa: E402 + + +# Synchronous tests + + +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.DefaultAzureCredential") +@patch("helpers.azure_credential_utils.ManagedIdentityCredential") +def test_get_azure_credential_dev_env(mock_managed_identity_credential, mock_default_azure_credential, mock_getenv): + """Test get_azure_credential in dev environment.""" + mock_getenv.return_value = "dev" + mock_default_credential = MagicMock() + mock_default_azure_credential.return_value = mock_default_credential + + credential = azure_credential_utils.get_azure_credential() + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_default_azure_credential.assert_called_once() + mock_managed_identity_credential.assert_not_called() + assert credential == mock_default_credential + + +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.DefaultAzureCredential") +@patch("helpers.azure_credential_utils.ManagedIdentityCredential") +def test_get_azure_credential_non_dev_env(mock_managed_identity_credential, mock_default_azure_credential, mock_getenv): + """Test get_azure_credential in non-dev environment.""" + mock_getenv.return_value = "prod" + mock_managed_credential = MagicMock() + mock_managed_identity_credential.return_value = mock_managed_credential + credential = azure_credential_utils.get_azure_credential(client_id="test-client-id") + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_managed_identity_credential.assert_called_once_with(client_id="test-client-id") + mock_default_azure_credential.assert_not_called() + assert credential == mock_managed_credential + + +# Asynchronous tests + + +@pytest.mark.asyncio +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.AioDefaultAzureCredential") +@patch("helpers.azure_credential_utils.AioManagedIdentityCredential") +async def test_get_azure_credential_async_dev_env(mock_aio_managed_identity_credential, mock_aio_default_azure_credential, mock_getenv): + """Test get_azure_credential_async in dev environment.""" + mock_getenv.return_value = "dev" + mock_aio_default_credential = MagicMock() + mock_aio_default_azure_credential.return_value = mock_aio_default_credential + + credential = await azure_credential_utils.get_azure_credential_async() + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_aio_default_azure_credential.assert_called_once() + mock_aio_managed_identity_credential.assert_not_called() + assert credential == mock_aio_default_credential + + +@pytest.mark.asyncio +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.AioDefaultAzureCredential") +@patch("helpers.azure_credential_utils.AioManagedIdentityCredential") +async def test_get_azure_credential_async_non_dev_env(mock_aio_managed_identity_credential, mock_aio_default_azure_credential, mock_getenv): + """Test get_azure_credential_async in non-dev environment.""" + mock_getenv.return_value = "prod" + mock_aio_managed_credential = MagicMock() + mock_aio_managed_identity_credential.return_value = mock_aio_managed_credential + + credential = await azure_credential_utils.get_azure_credential_async(client_id="test-client-id") + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_aio_managed_identity_credential.assert_called_once_with(client_id="test-client-id") + mock_aio_default_azure_credential.assert_not_called() + assert credential == mock_aio_managed_credential diff --git a/src/ContentProcessor/src/tests/test_main.py b/src/ContentProcessor/src/tests/test_main.py index 29265c92..1df40435 100644 --- a/src/ContentProcessor/src/tests/test_main.py +++ b/src/ContentProcessor/src/tests/test_main.py @@ -36,8 +36,7 @@ async def test_application_run(mocker): "libs.process_host.handler_process_host.HandlerHostManager" ).return_value - # Mock the DefaultAzureCredential - mocker.patch("azure.identity.DefaultAzureCredential") + # Note: Azure credentials are mocked globally via conftest.py # Mock the read_configuration method to return a complete configuration mocker.patch( diff --git a/src/ContentProcessorAPI/app/.env.dev b/src/ContentProcessorAPI/app/.env.dev new file mode 100644 index 00000000..b160b20d --- /dev/null +++ b/src/ContentProcessorAPI/app/.env.dev @@ -0,0 +1,9 @@ +# Development Environment Configuration +# Set to 'dev' to use DefaultAzureCredential for local development +# Set to 'prod' to use ManagedIdentityCredential for production deployment +APP_ENV=dev + +# Add other API development configuration here as needed +# AZURE_STORAGE_ACCOUNT_URL= +# AZURE_APP_CONFIG_ENDPOINT= +# API_PORT=8000 diff --git a/src/ContentProcessorAPI/app/libs/app_configuration/helper.py b/src/ContentProcessorAPI/app/libs/app_configuration/helper.py index 7e1f03d5..73a632e0 100644 --- a/src/ContentProcessorAPI/app/libs/app_configuration/helper.py +++ b/src/ContentProcessorAPI/app/libs/app_configuration/helper.py @@ -4,16 +4,15 @@ import os from azure.appconfiguration import AzureAppConfigurationClient -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential class AppConfigurationHelper: - credential: DefaultAzureCredential = None app_config_endpoint: str = None app_config_client: AzureAppConfigurationClient = None def __init__(self, app_config_endpoint: str): - self.credential = DefaultAzureCredential() + self.credential = get_azure_credential() self.app_config_endpoint = app_config_endpoint self._initialize_client() diff --git a/src/ContentProcessorAPI/app/libs/storage_blob/helper.py b/src/ContentProcessorAPI/app/libs/storage_blob/helper.py index e74398c6..6fbbb478 100644 --- a/src/ContentProcessorAPI/app/libs/storage_blob/helper.py +++ b/src/ContentProcessorAPI/app/libs/storage_blob/helper.py @@ -1,13 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from azure.storage.blob import BlobServiceClient class StorageBlobHelper: def __init__(self, account_url, container_name=None): - credential = DefaultAzureCredential() + credential = get_azure_credential() self.blob_service_client = BlobServiceClient( account_url=account_url, credential=credential ) diff --git a/src/ContentProcessorAPI/app/libs/storage_queue/helper.py b/src/ContentProcessorAPI/app/libs/storage_queue/helper.py index 3c9a18b5..606bdd5f 100644 --- a/src/ContentProcessorAPI/app/libs/storage_queue/helper.py +++ b/src/ContentProcessorAPI/app/libs/storage_queue/helper.py @@ -4,14 +4,14 @@ import logging from azure.core.exceptions import ResourceNotFoundError -from azure.identity import DefaultAzureCredential +from helpers.azure_credential_utils import get_azure_credential from azure.storage.queue import QueueClient from pydantic import BaseModel class StorageQueueHelper: def __init__(self, account_url, queue_name): - credential = DefaultAzureCredential() + credential = get_azure_credential() self.queue_client = self.create_or_get_queue_client( queue_name=queue_name, accouont_url=account_url, credential=credential ) @@ -27,7 +27,7 @@ def _invalidate_queue(self, queue_client: QueueClient): queue_client.create_queue() def create_or_get_queue_client( - self, queue_name: str, accouont_url: str, credential: DefaultAzureCredential + self, queue_name: str, accouont_url: str, credential: get_azure_credential ) -> QueueClient: queue_client = QueueClient( account_url=accouont_url, queue_name=queue_name, credential=credential diff --git a/src/ContentProcessorAPI/app/tests/libs/test_storage_blob.py b/src/ContentProcessorAPI/app/tests/libs/test_storage_blob.py index 490c9859..b13df26a 100644 --- a/src/ContentProcessorAPI/app/tests/libs/test_storage_blob.py +++ b/src/ContentProcessorAPI/app/tests/libs/test_storage_blob.py @@ -1,7 +1,12 @@ import pytest from azure.storage.blob import BlobServiceClient, ContainerClient, BlobClient from azure.core.exceptions import ResourceNotFoundError -from app.libs.storage_blob.helper import StorageBlobHelper +from unittest.mock import MagicMock, patch + +# Ensure Azure credentials are mocked before any imports +with patch("helpers.azure_credential_utils.get_azure_credential") as mock_cred: + mock_cred.return_value = MagicMock() + from app.libs.storage_blob.helper import StorageBlobHelper @pytest.fixture diff --git a/src/ContentProcessorAPI/conftest.py b/src/ContentProcessorAPI/conftest.py new file mode 100644 index 00000000..dfc7a29f --- /dev/null +++ b/src/ContentProcessorAPI/conftest.py @@ -0,0 +1,34 @@ +""" +Global test configuration and fixtures for ContentProcessorAPI tests. +""" +import sys +import os +import pytest +from unittest.mock import patch, MagicMock + +# Add current directory to Python path for imports +sys.path.insert(0, os.path.dirname(__file__)) + +pytest_plugins = ["pytest_mock"] + + +@pytest.fixture(autouse=True, scope="function") +def mock_azure_credentials_for_helpers(request): + """ + Mock Azure credentials for azure_helper classes only. + Skip this for credential utility tests that need to test the actual logic. + """ + # Skip mocking for credential utility tests + if "test_azure_credential_utils" in str(request.fspath): + yield + return + + with patch("helpers.azure_credential_utils.get_azure_credential") as mock_get_cred, \ + patch("helpers.azure_credential_utils.get_azure_credential_async") as mock_get_cred_async: + + # Create mock credential objects + mock_credential = MagicMock() + mock_get_cred.return_value = mock_credential + mock_get_cred_async.return_value = mock_credential + + yield mock_credential diff --git a/src/ContentProcessorAPI/helpers/azure_credential_utils.py b/src/ContentProcessorAPI/helpers/azure_credential_utils.py new file mode 100644 index 00000000..fb1e6c6b --- /dev/null +++ b/src/ContentProcessorAPI/helpers/azure_credential_utils.py @@ -0,0 +1,35 @@ +import os +from azure.identity import ManagedIdentityCredential, DefaultAzureCredential +from azure.identity.aio import ManagedIdentityCredential as AioManagedIdentityCredential, DefaultAzureCredential as AioDefaultAzureCredential + + +async def get_azure_credential_async(client_id=None): + """ + Returns an Azure credential asynchronously based on the application environment. + If the environment is 'dev', it uses AioDefaultAzureCredential. + Otherwise, it uses AioManagedIdentityCredential. + Args: + client_id (str, optional): The client ID for the Managed Identity Credential. + Returns: + Credential object: Either AioDefaultAzureCredential or AioManagedIdentityCredential. + """ + if os.getenv("APP_ENV", "prod").lower() == 'dev': + return AioDefaultAzureCredential() # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + else: + return AioManagedIdentityCredential(client_id=client_id) + + +def get_azure_credential(client_id=None): + """ + Returns an Azure credential based on the application environment. + If the environment is 'dev', it uses DefaultAzureCredential. + Otherwise, it uses ManagedIdentityCredential. + Args: + client_id (str, optional): The client ID for the Managed Identity Credential. + Returns: + Credential object: Either DefaultAzureCredential or ManagedIdentityCredential. + """ + if os.getenv("APP_ENV", "prod").lower() == 'dev': + return DefaultAzureCredential() # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development + else: + return ManagedIdentityCredential(client_id=client_id) diff --git a/src/ContentProcessorAPI/pytest.ini b/src/ContentProcessorAPI/pytest.ini new file mode 100644 index 00000000..7446cfa5 --- /dev/null +++ b/src/ContentProcessorAPI/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +addopts = -v --strict-markers --disable-warnings +python_files = tests.py test_*.py *_tests.py +testpaths = tests +markers = + asyncio: marks tests as async diff --git a/src/ContentProcessorAPI/tests/conftest.py b/src/ContentProcessorAPI/tests/conftest.py new file mode 100644 index 00000000..a625f86a --- /dev/null +++ b/src/ContentProcessorAPI/tests/conftest.py @@ -0,0 +1,4 @@ +""" +Test configuration and fixtures for ContentProcessorAPI tests. +""" +# Note: pytest_plugins is now defined in the top-level conftest.py diff --git a/src/ContentProcessorAPI/tests/helpers/test_azure_credential_utils.py b/src/ContentProcessorAPI/tests/helpers/test_azure_credential_utils.py new file mode 100644 index 00000000..17d9eacc --- /dev/null +++ b/src/ContentProcessorAPI/tests/helpers/test_azure_credential_utils.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pytest +import sys +import os +from unittest.mock import patch, MagicMock + +# Ensure src/backend is on the Python path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +# Import after path modification (flake8: noqa) +import helpers.azure_credential_utils as azure_credential_utils # noqa: E402 + + +# Synchronous tests + + +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.DefaultAzureCredential") +@patch("helpers.azure_credential_utils.ManagedIdentityCredential") +def test_get_azure_credential_dev_env(mock_managed_identity_credential, mock_default_azure_credential, mock_getenv): + """Test get_azure_credential in dev environment.""" + mock_getenv.return_value = "dev" + mock_default_credential = MagicMock() + mock_default_azure_credential.return_value = mock_default_credential + + credential = azure_credential_utils.get_azure_credential() + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_default_azure_credential.assert_called_once() + mock_managed_identity_credential.assert_not_called() + assert credential == mock_default_credential + + +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.DefaultAzureCredential") +@patch("helpers.azure_credential_utils.ManagedIdentityCredential") +def test_get_azure_credential_non_dev_env(mock_managed_identity_credential, mock_default_azure_credential, mock_getenv): + """Test get_azure_credential in non-dev environment.""" + mock_getenv.return_value = "prod" + mock_managed_credential = MagicMock() + mock_managed_identity_credential.return_value = mock_managed_credential + credential = azure_credential_utils.get_azure_credential(client_id="test-client-id") + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_managed_identity_credential.assert_called_once_with(client_id="test-client-id") + mock_default_azure_credential.assert_not_called() + assert credential == mock_managed_credential + + +# Asynchronous tests + + +@pytest.mark.asyncio +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.AioDefaultAzureCredential") +@patch("helpers.azure_credential_utils.AioManagedIdentityCredential") +async def test_get_azure_credential_async_dev_env(mock_aio_managed_identity_credential, mock_aio_default_azure_credential, mock_getenv): + """Test get_azure_credential_async in dev environment.""" + mock_getenv.return_value = "dev" + mock_aio_default_credential = MagicMock() + mock_aio_default_azure_credential.return_value = mock_aio_default_credential + + credential = await azure_credential_utils.get_azure_credential_async() + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_aio_default_azure_credential.assert_called_once() + mock_aio_managed_identity_credential.assert_not_called() + assert credential == mock_aio_default_credential + + +@pytest.mark.asyncio +@patch("helpers.azure_credential_utils.os.getenv") +@patch("helpers.azure_credential_utils.AioDefaultAzureCredential") +@patch("helpers.azure_credential_utils.AioManagedIdentityCredential") +async def test_get_azure_credential_async_non_dev_env(mock_aio_managed_identity_credential, mock_aio_default_azure_credential, mock_getenv): + """Test get_azure_credential_async in non-dev environment.""" + mock_getenv.return_value = "prod" + mock_aio_managed_credential = MagicMock() + mock_aio_managed_identity_credential.return_value = mock_aio_managed_credential + + credential = await azure_credential_utils.get_azure_credential_async(client_id="test-client-id") + + mock_getenv.assert_called_once_with("APP_ENV", "prod") + mock_aio_managed_identity_credential.assert_called_once_with(client_id="test-client-id") + mock_aio_default_azure_credential.assert_not_called() + assert credential == mock_aio_managed_credential