diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c9a7cc..82c2c50 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -168,7 +168,7 @@ Structure: - Standard library imports (time, json, tempfile, requests, pathlib, datetime) - `utils`, `apimtypes`, `console`, `azure_resources` (including `az`, `get_infra_rg_name`, `get_account_info`) 2. USER CONFIGURATION section: - - `rg_location`: Azure region (default: 'eastus2') + - `rg_location`: Azure region (default: `Region.EAST_US_2`) - `index`: Deployment index for resource naming (default: 1) - `deployment`: Selected infrastructure type (reference INFRASTRUCTURE enum options) - `api_prefix`: Prefix for APIs to avoid naming collisions diff --git a/samples/costing-entra-appid/README.md b/samples/costing-entra-appid/README.md new file mode 100644 index 0000000..9cc3011 --- /dev/null +++ b/samples/costing-entra-appid/README.md @@ -0,0 +1,114 @@ +# Samples: APIM Cost Attribution by Entra ID Application + +This sample demonstrates how to track and allocate API costs by **Entra ID application** using the APIM `emit-metric` policy. Instead of relying on APIM subscription keys, it extracts the `appid` (or `azp`) claim from JWT tokens to identify each calling application and emit custom metrics to Application Insights. + +âš™ī¸ **Supported infrastructures**: All infrastructures (or bring your own existing APIM deployment) + +👟 **Expected *Run All* runtime (excl. infrastructure prerequisite): ~15 minutes** + +## đŸŽ¯ Objectives + +1. **Track API usage by Entra ID application** - Use the `emit-metric` policy to extract `appid`/`azp` JWT claims and emit per-caller custom metrics +2. **Capture caller-level request counts** - Log each API request as a `caller-requests` custom metric with a `CallerId` dimension in Application Insights +3. **Visualise cost allocation** - Deploy an Azure Monitor Workbook that shows proportional cost breakdown by calling application +4. **Compare to subscription-based tracking** - Understand when caller-level `emit-metric` attribution is more suitable than APIM subscription-based tracking (see the sibling `costing` sample) +5. **Enable budget alerts per caller** - Create scheduled query alerts when a caller exceeds a configurable request threshold + +## ✅ Prerequisites + +Before running this sample, ensure you have the following: + +### Required + +| Prerequisite | Description | +|---|---| +| **Azure subscription** | An active Azure subscription with Owner or Contributor access | +| **Azure CLI** | Logged in (`az login`) with the correct subscription selected (`az account set -s `) | +| **APIM instance** | Either deploy one via this repo's infrastructure, or bring your own | +| **Python environment** | Python 3.12+ with dependencies installed (`uv sync` or `pip install -r requirements.txt`) | + +### Azure RBAC Permissions + +The signed-in user needs the following role assignments: + +| Role | Scope | Purpose | +|---|---|---| +| **Contributor** | Resource Group | Deploy Bicep resources (App Insights, Log Analytics, Workbook, Diagnostic Settings) | + +### For Workbook Consumers + +Users who only need to **view** the deployed Azure Monitor Workbook (not deploy the sample) need: + +| Role | Scope | Purpose | +|---|---|---| +| **Monitoring Reader** | Resource Group | Open and view the workbook | +| **Log Analytics Reader** | Application Insights | Execute the KQL queries that power the workbook | + +## âš™ī¸ Configuration + +### Option A: Use a repository infrastructure (recommended) + +1. Navigate to the desired [infrastructure](../../infrastructure/) folder (e.g. [simple-apim](../../infrastructure/simple-apim/)) and follow its README.md to deploy +2. Open `create.ipynb` and set: + + ```python + deployment = INFRASTRUCTURE.SIMPLE_APIM # Match your deployed infra + index = 1 # Match your infra index + ``` + +3. Run All Cells + +### Option B: Bring your own existing APIM + +You can use any existing Azure API Management instance. The sample only adds diagnostic settings, a sample API, and monitoring resources. + +1. Set the correct Azure subscription: `az account set -s ` +2. Open `create.ipynb` and configure the user variables +3. Run All Cells + +## 📝 Scenario + +Some organisations identify API callers by their **Entra ID application registration** rather than by APIM subscription key. This is common when: + +- Multiple services share a single APIM subscription but need individual cost tracking +- OAuth 2.0 client-credentials flow is the primary authentication mechanism +- The organisation wants to correlate API costs with its Entra ID app catalogue +- Fine-grained, claim-based caller identification is preferred over subscription-level grouping + +### How It Works + +1. Each API call includes a JWT Bearer token containing an `appid` (v1) or `azp` (v2) claim +2. The `emit-metric` APIM policy extracts this claim and emits a `caller-requests` custom metric to Application Insights +3. The metric carries a `CallerId` dimension set to the extracted app ID +4. An Azure Monitor Workbook queries `customMetrics` to display usage and cost allocation per caller +5. Optional budget alerts fire when a caller exceeds a threshold + +### Key Difference from the `costing` Sample + +| Aspect | `costing` sample | `costing-entra-appid` sample | +|---|---|---| +| **Caller identification** | APIM subscription key (`ApimSubscriptionId`) | JWT `appid`/`azp` claim | +| **Data source** | `ApiManagementGatewayLogs` in Log Analytics | `customMetrics` in Application Insights | +| **Tracking mechanism** | Built-in APIM logging | `emit-metric` policy | +| **Cost Management export** | Yes (storage account) | No (metrics-based) | + +## đŸ›Šī¸ Lab Components + +This lab deploys and configures: + +- **Application Insights** - Receives `caller-requests` custom metrics from the `emit-metric` policy +- **Log Analytics Workspace** - Stores APIM diagnostic logs +- **Diagnostic Settings** - Links APIM to both Log Analytics and Application Insights +- **Sample API** - A demo API with the `emit-metric` policy applied +- **Azure Monitor Workbook** - Pre-built dashboard with: + - Usage by Caller ID (bar chart and table) + - Cost Allocation (proportional breakdown with pie chart) + - Request Trend (hourly time chart per caller) + +## 🔗 Additional Resources + +- [APIM emit-metric policy](https://learn.microsoft.com/azure/api-management/emit-metric-policy) +- [Application Insights custom metrics](https://learn.microsoft.com/azure/azure-monitor/essentials/metrics-custom-overview) +- [Azure Monitor Workbooks](https://learn.microsoft.com/azure/azure-monitor/visualize/workbooks-overview) +- [Azure API Management Pricing](https://azure.microsoft.com/pricing/details/api-management/) +- [Microsoft Entra ID application model](https://learn.microsoft.com/entra/identity-platform/application-model) diff --git a/samples/costing-entra-appid/budget-alert-threshold.kql b/samples/costing-entra-appid/budget-alert-threshold.kql new file mode 100644 index 0000000..7f09f25 --- /dev/null +++ b/samples/costing-entra-appid/budget-alert-threshold.kql @@ -0,0 +1,11 @@ +// Fires when a caller application exceeds a request threshold in a 1-hour window. +// +// Parameters (prepend as KQL 'let' bindings before running): +// let callerAppId = 'a5846c0e-...'; // Entra ID application (appid claim) +// let threshold = 1000; // Request count threshold +customMetrics +| where timestamp > ago(1h) and name == 'caller-requests' +| extend CallerId = tostring(customDimensions.CallerId) +| where CallerId == callerAppId +| summarize RequestCount = sum(value) +| where RequestCount > threshold diff --git a/samples/costing-entra-appid/create.ipynb b/samples/costing-entra-appid/create.ipynb new file mode 100644 index 0000000..c800900 --- /dev/null +++ b/samples/costing-entra-appid/create.ipynb @@ -0,0 +1,555 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### đŸ› ī¸ Initialize Notebook Variables\n", + "\n", + "**Only modify entries under _USER CONFIGURATION_.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import utils\n", + "\n", + "from apimtypes import API, APIM_SKU, GET_APIOperation2, INFRASTRUCTURE, Region\n", + "from console import print_error, print_info, print_ok, print_val, print_warning\n", + "from azure_resources import get_infra_rg_name, get_account_info\n", + "\n", + "# ------------------------------\n", + "# USER CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "rg_location = Region.EAST_US_2\n", + "index = 1\n", + "apim_sku = APIM_SKU.BASICV2 # Options: 'BASICV2', 'STANDARDV2', 'PREMIUMV2'\n", + "deployment = INFRASTRUCTURE.SIMPLE_APIM # Options: see supported_infras below\n", + "api_prefix = 'appid-cost-' # ENTER A PREFIX FOR THE APIS TO REDUCE COLLISION POTENTIAL WITH OTHER SAMPLES\n", + "tags = ['costing', 'emit-metric', 'entra-appid'] # ENTER DESCRIPTIVE TAG(S)\n", + "\n", + "# Sample data generation\n", + "generate_sample_load = True # Generate sample API calls to demonstrate cost tracking\n", + "sample_requests_per_caller = 50 # Base request count per simulated caller (multiplied by weight)\n", + "\n", + "# Budget alerts\n", + "alert_threshold = 1000 # Request count threshold per caller per hour\n", + "alert_email = 'alerts@contoso.com' # Email for alert notifications (leave empty to skip)\n", + "\n", + "\n", + "\n", + "# ------------------------------\n", + "# SYSTEM CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "sample_folder = 'costing-entra-appid'\n", + "rg_name = get_infra_rg_name(deployment, index)\n", + "supported_infras = [\n", + " INFRASTRUCTURE.AFD_APIM_PE,\n", + " INFRASTRUCTURE.APIM_ACA,\n", + " INFRASTRUCTURE.APPGW_APIM,\n", + " INFRASTRUCTURE.APPGW_APIM_PE,\n", + " INFRASTRUCTURE.SIMPLE_APIM\n", + "]\n", + "nb_helper = utils.NotebookHelper(\n", + " sample_folder,\n", + " rg_name,\n", + " rg_location,\n", + " deployment,\n", + " supported_infras,\n", + " True,\n", + " index = index,\n", + " apim_sku = apim_sku\n", + ")\n", + "\n", + "# Load the emit-metric policy XML\n", + "emit_metric_policy_path = utils.determine_policy_path('emit_metric_caller_id.xml', sample_folder)\n", + "emit_metric_policy_xml = Path(emit_metric_policy_path).read_text(encoding='utf-8')\n", + "\n", + "# Define the API and its operations\n", + "api_path = 'appid-cost-demo'\n", + "cost_demo_get = GET_APIOperation2('get-status', 'Get Status', '/get', 'Get Status')\n", + "\n", + "apis = [\n", + " API(\n", + " f'{api_prefix}cost-tracking-api',\n", + " 'Cost Tracking by App ID',\n", + " api_path,\n", + " 'API for demonstrating cost tracking by Entra ID application',\n", + " policyXml = emit_metric_policy_xml,\n", + " operations = [cost_demo_get],\n", + " tags = tags,\n", + " subscriptionRequired = True,\n", + " serviceUrl = 'https://httpbin.org'\n", + " )\n", + "]\n", + "\n", + "# Simulated caller app IDs (in a real scenario these would be actual Entra ID app registrations)\n", + "simulated_callers = [\n", + " {'appid': 'a5846c0e-1111-4000-8000-000000000001', 'name': 'HR Service', 'request_weight': 1.0},\n", + " {'appid': '9e6bfb3f-2222-4000-8000-000000000002', 'name': 'Finance Portal', 'request_weight': 2.5},\n", + " {'appid': 'c3d2e1f0-3333-4000-8000-000000000003', 'name': 'Mobile Gateway', 'request_weight': 0.5},\n", + " {'appid': 'b7a8c9d0-4444-4000-8000-000000000004', 'name': 'Engineering Tools', 'request_weight': 3.0}\n", + "]\n", + "\n", + "# Get Azure account information\n", + "current_user, current_user_id, tenant_id, subscription_id = get_account_info()\n", + "\n", + "if not subscription_id:\n", + " print_error('Could not determine Azure subscription ID. Run: az login')\n", + " raise SystemExit(1)\n", + "\n", + "print_ok('Notebook initialized')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 🚀 Deploy Infrastructure and APIs\n", + "\n", + "Creates the bicep deployment into the previously-specified resource group. A bicep parameters, `params.json`, file will be created prior to execution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Build the bicep parameters\n", + "bicep_parameters = {\n", + " 'location' : {'value': rg_location},\n", + " 'index' : {'value': index},\n", + " 'apis' : {'value': [api.to_dict() for api in apis]}\n", + "}\n", + "\n", + "# Deploy the sample\n", + "output = nb_helper.deploy_sample(bicep_parameters)\n", + "\n", + "if output.success:\n", + " # Extract deployment outputs\n", + " apim_name = output.get('apimServiceName', 'APIM Service Name')\n", + " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", + " app_insights_name = output.get('applicationInsightsName', 'Application Insights Name')\n", + " app_insights_connection_string = output.get('applicationInsightsConnectionString', '')\n", + " log_analytics_name = output.get('logAnalyticsWorkspaceName', 'Log Analytics Workspace Name')\n", + " workbook_name = output.get('workbookName', 'Workbook Name')\n", + " workbook_id = output.get('workbookId', '')\n", + "\n", + " # Extract subscription key for the API\n", + " api_outputs = output.getJson('apiOutputs', 'APIs')\n", + " subscription_key = api_outputs[0]['subscriptionPrimaryKey'] if api_outputs else None\n", + "\n", + " print_ok('Deployment completed successfully')\n", + "else:\n", + " print_error('Deployment failed!')\n", + " raise SystemExit(1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 🚀 Generate Sample API Traffic\n", + "\n", + "Generate sample API calls simulating different Entra ID applications (via crafted JWT `appid` claims) to demonstrate caller-based cost tracking.\n", + "\n", + "This creates `caller-requests` custom metrics in Application Insights via the `emit-metric` policy.\n", + "\n", + "> **Note:** In a production environment, callers would present real Entra ID tokens. Here we simulate them with minimal JWTs so that the `emit-metric` policy can extract the `appid` claim." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import base64\n", + "import json\n", + "import time\n", + "\n", + "from apimrequests import ApimRequests\n", + "\n", + "if 'apim_gateway_url' not in locals():\n", + " print_error('Please run the deployment cell first')\n", + " raise SystemExit(1)\n", + "\n", + "def make_fake_jwt(appid: str) -> str:\n", + " \"\"\"Create a minimal unsigned JWT with an appid claim for emit-metric extraction.\"\"\"\n", + " header = base64.urlsafe_b64encode(json.dumps({'alg': 'none', 'typ': 'JWT'}).encode()).rstrip(b'=').decode()\n", + " payload = base64.urlsafe_b64encode(json.dumps({'appid': appid}).encode()).rstrip(b'=').decode()\n", + " return f'{header}.{payload}.'\n", + "\n", + "if generate_sample_load:\n", + " print_info('Generating sample API traffic with simulated caller app IDs...')\n", + "\n", + " # Determine endpoints, URLs, etc. prior to test execution\n", + " endpoint_url, request_headers = utils.get_endpoint(deployment, rg_name, apim_gateway_url)\n", + "\n", + " for caller in simulated_callers:\n", + " caller_request_count = max(1, int(sample_requests_per_caller * caller.get('request_weight', 1.0)))\n", + " fake_jwt = make_fake_jwt(caller['appid'])\n", + "\n", + " # Merge auth header with any infrastructure-specific headers\n", + " auth_headers = dict(request_headers) if request_headers else {}\n", + " auth_headers['Authorization'] = f'Bearer {fake_jwt}'\n", + "\n", + " reqs = ApimRequests(endpoint_url, subscription_key, auth_headers)\n", + " reqs.multiGet(\n", + " f'/{api_path}/get',\n", + " caller_request_count,\n", + " msg = f'Generating {caller_request_count} requests for {caller[\"name\"]} ({caller[\"appid\"][:12]}...)',\n", + " printResponse = False,\n", + " sleepMs = 10\n", + " )\n", + "\n", + " print()\n", + " print_info('Note: Custom metrics typically take 5-10 minutes to appear in Application Insights')\n", + "else:\n", + " print_info('Sample load generation skipped (generate_sample_load = False)')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 🔍 Verify Metric Ingestion\n", + "\n", + "Waits for `caller-requests` custom metrics to arrive in Application Insights (auto-retries for up to 10 minutes)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import tempfile\n", + "import time\n", + "from pathlib import Path\n", + "\n", + "from azure_resources import run\n", + "\n", + "if 'app_insights_name' not in locals():\n", + " print_error('Please run the deployment cell first')\n", + " raise SystemExit(1)\n", + "\n", + "print_info('Waiting for caller-requests custom metrics to arrive in Application Insights...')\n", + "print_info('Metric ingestion typically takes 5-10 minutes after generating traffic')\n", + "print()\n", + "\n", + "print_val('Application Insights', app_insights_name)\n", + "\n", + "# Build the Application Insights resource ID for the ARM query endpoint\n", + "app_insights_resource_id = (\n", + " f'/subscriptions/{subscription_id}'\n", + " f'/resourceGroups/{rg_name}'\n", + " f'/providers/microsoft.insights/components/{app_insights_name}'\n", + ")\n", + "\n", + "# Load KQL from external file and wrap it in a JSON body\n", + "kql_path = utils.determine_policy_path('verify-metric-ingestion.kql', sample_folder)\n", + "kql_query = Path(kql_path).read_text(encoding='utf-8')\n", + "\n", + "query_body = {'query': kql_query}\n", + "\n", + "with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:\n", + " json.dump(query_body, f)\n", + " query_file_path = f.name\n", + "\n", + "# Poll Application Insights until caller-requests metrics appear\n", + "max_wait_minutes = 10\n", + "poll_interval_seconds = 30\n", + "max_attempts = (max_wait_minutes * 60) // poll_interval_seconds\n", + "metrics_found = False\n", + "\n", + "try:\n", + " for attempt in range(1, max_attempts + 1):\n", + " result = run(\n", + " f'az rest --method POST '\n", + " f'--url \"https://management.azure.com{app_insights_resource_id}/query?api-version=2018-04-20\" '\n", + " f'--body @{query_file_path} -o json',\n", + " log_command=False\n", + " )\n", + "\n", + " if not result.success:\n", + " print_error(f'Query failed: {result.text[:300]}')\n", + " break\n", + "\n", + " if result.json_data:\n", + " tables = result.json_data.get('tables', [])\n", + " if tables:\n", + " rows = tables[0].get('rows', [])\n", + " if rows and len(rows) > 0:\n", + " metric_count = float(rows[0][0])\n", + " if metric_count > 0:\n", + " print_ok(f'Found {int(metric_count)} caller-requests metric entries')\n", + " metrics_found = True\n", + " break\n", + "\n", + " elapsed = attempt * poll_interval_seconds\n", + " remaining = (max_wait_minutes * 60) - elapsed\n", + " print_info(f' No metrics yet... retrying in {poll_interval_seconds}s ({remaining}s remaining)')\n", + " time.sleep(poll_interval_seconds)\n", + "finally:\n", + " Path(query_file_path).unlink(missing_ok=True)\n", + "\n", + "if metrics_found:\n", + " print_ok('Metric ingestion verified - workbook should now display data')\n", + "elif result.success:\n", + " print_warning(f'Metrics did not appear within {max_wait_minutes} minutes')\n", + " print_info('This can happen with newly deployed emit-metric policies. Tips:')\n", + " print_info(' 1. Wait a few more minutes and re-run this cell')\n", + " print_info(' 2. Verify the emit-metric policy is applied in Azure Portal')\n", + " print_info(' 3. Re-run the traffic generation cell to send more requests')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 📊 Cost Analysis\n", + "\n", + "#### Cost Allocation Model\n", + "\n", + "| Component | Formula |\n", + "|---|---|\n", + "| **Base Cost** | Monthly platform cost for the APIM SKU |\n", + "| **Base Cost Share** | `Base Monthly Cost x (Caller Requests / Total Requests)` |\n", + "| **Total Allocated** | Proportional share of base cost per caller application |\n", + "\n", + "The workbook in Application Insights visualises this model. Use the **Monthly Base Cost** parameter to adjust the base cost figure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Store in variables for use by other cells\n", + "base_monthly_cost = 150.00\n", + "print_val('Base monthly cost (default)', f'${base_monthly_cost:.2f}')\n", + "\n", + "print()\n", + "print_info('The workbook uses the caller-requests custom metric from Application Insights.')\n", + "print_info('To adjust cost parameters, open the workbook and modify the parameters panel.')\n", + "print()\n", + "print_info('Key metric:')\n", + "print_val('customMetrics name', 'caller-requests')\n", + "print_val('Dimension', 'CallerId (Entra appid claim)')\n", + "\n", + "# Sample KQL for cost analysis\n", + "print()\n", + "print_info('Sample KQL for ad-hoc analysis in Application Insights:')\n", + "print_info('These queries use the customMetrics table (emit-metric mode).')\n", + "print()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 🔔 Set Up Budget Alerts per Caller Application\n", + "\n", + "Create Azure Monitor scheduled query alerts that fire when a caller application exceeds a configurable request threshold.\n", + "\n", + "Each alert:\n", + "- Runs a KQL query every **5 minutes** against Application Insights\n", + "- Triggers when a caller exceeds the threshold in a **1-hour** rolling window\n", + "- Sends notifications via an **Action Group** (email)\n", + "\n", + "> Adjust `alert_threshold` and `alert_email` in the initialization cell to match your requirements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import tempfile\n", + "from pathlib import Path\n", + "\n", + "from azure_resources import run\n", + "\n", + "if not alert_email:\n", + " print_warning('No alert_email configured - skipping budget alert setup')\n", + " print_info('Set alert_email above to enable budget alerts per caller')\n", + "else:\n", + " print_info('Setting up budget alerts per caller application...')\n", + "\n", + " # Get Application Insights resource ID\n", + " ai_resource_id = (\n", + " f'/subscriptions/{subscription_id}'\n", + " f'/resourceGroups/{rg_name}'\n", + " f'/providers/microsoft.insights/components/{app_insights_name}'\n", + " )\n", + "\n", + " # Create an Action Group for alert notifications\n", + " action_group_name = f'ag-appid-cost-alerts-{index}'\n", + " print_info(f'Creating action group: {action_group_name}...')\n", + "\n", + " ag_result = run(\n", + " f'az monitor action-group create '\n", + " f'--resource-group {rg_name} '\n", + " f'--name {action_group_name} '\n", + " f'--short-name appidcost '\n", + " f'--action email cost-alert-email {alert_email} '\n", + " f'-o json',\n", + " log_command=False\n", + " )\n", + "\n", + " if ag_result.success:\n", + " action_group_id = ag_result.json_data.get('id', '')\n", + " print_ok(f'Action group created: {action_group_name}')\n", + " else:\n", + " print_error(f'Failed to create action group: {ag_result.text}')\n", + " action_group_id = None\n", + "\n", + " if action_group_id:\n", + " # Load the KQL template from an external file\n", + " kql_path = utils.determine_policy_path('budget-alert-threshold.kql', sample_folder)\n", + " kql_template = Path(kql_path).read_text(encoding='utf-8')\n", + "\n", + " print_info(f'Creating alerts for {len(simulated_callers)} caller applications (threshold: {alert_threshold} requests/hour)...')\n", + "\n", + " for caller in simulated_callers:\n", + " safe_name = caller['name'].lower().replace(' ', '-')\n", + " alert_name = f'apim-budget-{safe_name}-{index}'\n", + "\n", + " # Prepend KQL let bindings to parameterise the query\n", + " kusto_query = f\"let callerAppId = '{caller['appid']}';\\nlet threshold = {alert_threshold};\\n{kql_template}\"\n", + "\n", + " alert_body = {\n", + " 'location': rg_location,\n", + " 'properties': {\n", + " 'displayName': f'APIM Budget Alert: {caller[\"name\"]}',\n", + " 'description': f'Fires when {caller[\"name\"]} ({caller[\"appid\"]}) exceeds {alert_threshold} API requests per hour',\n", + " 'severity': 2,\n", + " 'enabled': True,\n", + " 'evaluationFrequency': 'PT5M',\n", + " 'windowSize': 'PT1H',\n", + " 'scopes': [ai_resource_id],\n", + " 'criteria': {\n", + " 'allOf': [\n", + " {\n", + " 'query': kusto_query,\n", + " 'timeAggregation': 'Count',\n", + " 'operator': 'GreaterThan',\n", + " 'threshold': 0,\n", + " 'failingPeriods': {\n", + " 'numberOfEvaluationPeriods': 1,\n", + " 'minFailingPeriodsToAlert': 1\n", + " }\n", + " }\n", + " ]\n", + " },\n", + " 'actions': {\n", + " 'actionGroups': [action_group_id]\n", + " }\n", + " }\n", + " }\n", + "\n", + " alert_id = (\n", + " f'/subscriptions/{subscription_id}'\n", + " f'/resourceGroups/{rg_name}'\n", + " f'/providers/Microsoft.Insights/scheduledQueryRules/{alert_name}'\n", + " )\n", + "\n", + " with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:\n", + " json.dump(alert_body, f)\n", + " alert_body_path = f.name\n", + "\n", + " try:\n", + " result = run(\n", + " f'az rest --method PUT '\n", + " f'--uri https://management.azure.com{alert_id}?api-version=2023-03-15-preview '\n", + " f'--body @{alert_body_path}',\n", + " log_command=False\n", + " )\n", + " finally:\n", + " Path(alert_body_path).unlink(missing_ok=True)\n", + "\n", + " if result.success:\n", + " print_ok(f' Alert created: {alert_name}')\n", + " else:\n", + " print_error(f' Failed to create alert for {caller[\"name\"]}: {result.text[:200]}')\n", + "\n", + " print()\n", + " print_ok('Budget alerts configured!')\n", + " print_val('Action Group', action_group_name)\n", + " print_val('Alert Email', alert_email)\n", + " print_val('Threshold', f'{alert_threshold} requests per hour per caller')\n", + " print_val('Evaluation', 'Every 5 minutes, 1-hour rolling window')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 🔗 Verify Costing Setup\n", + "\n", + "Open these resources in the Azure Portal to verify the sample is working. They are listed in priority order.\n", + "\n", + "1. **Azure Monitor Workbook** - Confirm the cost dashboard renders and shows per-caller request breakdowns.\n", + "2. **Application Insights** - Open the **Metrics** blade and check for `caller-requests` custom metrics with `CallerId` dimension.\n", + "3. **APIM Service** - Review the APIs blade to confirm the cost tracking API and emit-metric policy are active." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from console import print_plain\n", + "\n", + "base_url = 'https://portal.azure.com/#@/resource'\n", + "rg_path = f'/subscriptions/{subscription_id}/resourceGroups/{rg_name}'\n", + "\n", + "# Priority-ordered links for verifying the costing sample\n", + "print_info('1. Azure Monitor Workbook (cost dashboard)')\n", + "if 'workbook_id' in locals() and workbook_id:\n", + " print_plain(f' {base_url}{workbook_id}/workbook')\n", + "else:\n", + " print_plain(' (not deployed)')\n", + "print_plain()\n", + "\n", + "print_info('2. Application Insights (custom metrics)')\n", + "print_plain(f' {base_url}{rg_path}/providers/microsoft.insights/components/{app_insights_name}/overview')\n", + "print_plain()\n", + "\n", + "print_info('3. APIM Service (APIs & policies)')\n", + "print_plain(f' {base_url}{rg_path}/providers/Microsoft.ApiManagement/service/{apim_name}/overview')\n", + "print_plain()\n", + "\n", + "print_ok('Setup complete!')\n", + "print_info('To clean up resources, open and run: clean-up.ipynb')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (.venv)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/samples/costing-entra-appid/emit_metric_caller_id.xml b/samples/costing-entra-appid/emit_metric_caller_id.xml new file mode 100644 index 0000000..914b7fc --- /dev/null +++ b/samples/costing-entra-appid/emit_metric_caller_id.xml @@ -0,0 +1,50 @@ + + + + + = 2) + { + var payload = System.Text.Encoding.UTF8.GetString( + Convert.FromBase64String(parts[1].PadRight(parts[1].Length + (4 - parts[1].Length % 4) % 4, '='))); + var json = Newtonsoft.Json.Linq.JObject.Parse(payload); + var appId = json["appid"]?.ToString() ?? json["azp"]?.ToString(); + if (!string.IsNullOrEmpty(appId)) { return appId; } + } + } + catch { } + } + return context.Subscription?.Id ?? "unknown"; + }" /> + + + + + + + + + + + + + + + + diff --git a/samples/costing-entra-appid/main.bicep b/samples/costing-entra-appid/main.bicep new file mode 100644 index 0000000..3c0d258 --- /dev/null +++ b/samples/costing-entra-appid/main.bicep @@ -0,0 +1,119 @@ +// ------------------ +// PARAMETERS +// ------------------ + +@description('Location to be used for resources. Defaults to the resource group location') +param location string = resourceGroup().location + +@description('The unique suffix to append. Defaults to a unique string based on subscription and resource group IDs.') +param resourceSuffix string = uniqueString(subscription().id, resourceGroup().id) + +@description('Name of the API Management service') +param apimName string = 'apim-${resourceSuffix}' + +@description('Deployment index for unique resource naming') +param index int + +@description('Array of APIs to deploy') +param apis array = [] + +@description('Deploy the cost attribution workbook. Defaults to true.') +param deployWorkbook bool = true + + +// ------------------ +// VARIABLES +// ------------------ + +var workbookName = 'APIM Cost Attribution by Caller ID ${index}' + + +// ------------------ +// RESOURCES +// ------------------ + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service +resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { + name: apimName +} + +// Reference the infrastructure's Application Insights and Log Analytics. +// The emit-metric policy sends custom metrics to the App Insights +// connected to the APIM service (configured by the infrastructure's apim-logger). +// Deploying a separate App Insights would leave it empty. +// https://learn.microsoft.com/azure/templates/microsoft.insights/components +resource infraAppInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: 'appi-${resourceSuffix}' +} + +// https://learn.microsoft.com/azure/templates/microsoft.operationalinsights/workspaces +resource infraLogAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = { + name: 'log-${resourceSuffix}' +} + +// APIM APIs +module apisModule '../../shared/bicep/modules/apim/v1/api.bicep' = [for api in apis: if(!empty(apis)) { + name: 'api-${api.name}' + params: { + apimName: apimName + appInsightsInstrumentationKey: infraAppInsights.properties.InstrumentationKey + appInsightsId: infraAppInsights.id + api: api + } +}] + +// https://learn.microsoft.com/azure/templates/microsoft.insights/workbooks +resource workbook 'Microsoft.Insights/workbooks@2023-06-01' = if (deployWorkbook) { + name: guid(resourceGroup().id, 'appid-costing-workbook', string(index)) + location: location + kind: 'shared' + properties: { + displayName: workbookName + serializedData: string(loadJsonContent('workbook.json')) + version: '1.0' + sourceId: infraAppInsights.id + category: 'APIM' + } +} + + +// ------------------ +// OUTPUTS +// ------------------ + +output apimServiceId string = apimService.id +output apimServiceName string = apimService.name +output apimResourceGatewayURL string = apimService.properties.gatewayUrl + +@description('Name of the Application Insights resource (from infrastructure)') +output applicationInsightsName string = infraAppInsights.name + +@description('Application Insights instrumentation key') +output applicationInsightsInstrumentationKey string = infraAppInsights.properties.InstrumentationKey + +@description('Application Insights connection string') +output applicationInsightsConnectionString string = infraAppInsights.properties.ConnectionString + +@description('Name of the Log Analytics Workspace (from infrastructure)') +output logAnalyticsWorkspaceName string = infraLogAnalytics.name + +@description('Log Analytics Workspace ID') +output logAnalyticsWorkspaceId string = infraLogAnalytics.id + +@description('Name of the Azure Monitor Workbook') +output workbookName string = workbookName + +@description('Workbook ID') +output workbookId string = deployWorkbook ? workbook.id : '' + +// API outputs +output apiOutputs array = [for i in range(0, length(apis)): { + name: apis[i].name + resourceId: apisModule[i].?outputs.?apiResourceId ?? '' + displayName: apisModule[i].?outputs.?apiDisplayName ?? '' + productAssociationCount: apisModule[i].?outputs.?productAssociationCount ?? 0 + subscriptionResourceId: apisModule[i].?outputs.?subscriptionResourceId ?? '' + subscriptionName: apisModule[i].?outputs.?subscriptionName ?? '' + subscriptionPrimaryKey: apisModule[i].?outputs.?subscriptionPrimaryKey ?? '' + subscriptionSecondaryKey: apisModule[i].?outputs.?subscriptionSecondaryKey ?? '' +}] diff --git a/samples/costing-entra-appid/verify-metric-ingestion.kql b/samples/costing-entra-appid/verify-metric-ingestion.kql new file mode 100644 index 0000000..f8fbca1 --- /dev/null +++ b/samples/costing-entra-appid/verify-metric-ingestion.kql @@ -0,0 +1,7 @@ +// Checks whether the emit-metric policy is emitting caller-requests +// custom metrics into Application Insights. +// Used to verify that the policy is applied and metrics are flowing. +customMetrics +| where name == 'caller-requests' +| where isnotempty(customDimensions.CallerId) +| summarize Count = sum(value) diff --git a/samples/costing-entra-appid/workbook.json b/samples/costing-entra-appid/workbook.json new file mode 100644 index 0000000..db4b9e0 --- /dev/null +++ b/samples/costing-entra-appid/workbook.json @@ -0,0 +1,289 @@ +{ + "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json", + "fallbackResourceIds": [], + "fromTemplateId": "sentinel-UserWorkbook", + "items": [ + { + "content": { + "parameters": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-100000000001", + "isRequired": true, + "label": "Time Range", + "name": "TimeRange", + "type": 4, + "typeSettings": { + "allowCustom": true, + "selectableValues": [ + { "durationMs": 3600000 }, + { "durationMs": 14400000 }, + { "durationMs": 43200000 }, + { "durationMs": 86400000 }, + { "durationMs": 172800000 }, + { "durationMs": 604800000 }, + { "durationMs": 2592000000 } + ] + }, + "value": { + "durationMs": 86400000 + }, + "version": "KqlParameterItem/1.0" + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-100000000002", + "isRequired": true, + "label": "Monthly Base Cost (USD)", + "name": "BaseCost", + "type": 1, + "typeSettings": { + "paramValidationRules": [ + { + "match": true, + "message": "Enter a valid dollar amount (e.g. 150.00)", + "regExp": "^\\d+(\\.\\d{1,2})?$" + } + ] + }, + "value": "150.00", + "version": "KqlParameterItem/1.0" + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-100000000003", + "isHiddenWhenLocked": true, + "label": "App ID Names (JSON)", + "name": "AppIdNames", + "type": 1, + "description": "Optional JSON mapping of App IDs to friendly names. Example: {\"a5846c0e-...\":\"HR Service\",\"9e6bfb3f-...\":\"Mobile Gateway\"}", + "value": "{}", + "version": "KqlParameterItem/1.0" + } + ], + "queryType": 0, + "resourceType": "microsoft.insights/components", + "style": "pills", + "version": "KqlParameterItem/1.0" + }, + "name": "parameters - 0", + "type": 9 + }, + { + "content": { + "json": "## APIM Cost Attribution by Caller ID\n\nThis workbook shows API usage and cost allocation by **Entra ID application** (`appid` claim). Data comes from the `emit-metric` policy's `caller-requests` custom metric.\n\n| Parameter | Description |\n|---|---|\n| **Monthly Base Cost** | Fixed platform cost split proportionally by request share |\n| **App ID Names** | Optional JSON mapping of App IDs to friendly names |\n\n> **Note:** Data typically takes 5-10 minutes to appear after API calls." + }, + "name": "text - header", + "type": 1 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "chartSettings": { + "ySettings": { + "min": 0 + } + }, + "query": "let appNames = parse_json('{AppIdNames}');\ncustomMetrics\n| where name == 'caller-requests'\n| extend CallerId = tostring(customDimensions.CallerId)\n| where isnotempty(CallerId)\n| summarize RequestCount = sum(value) by CallerId\n| extend Caller = iif(isnotempty(tostring(appNames[CallerId])), strcat(tostring(appNames[CallerId]), ' (', CallerId, ')'), CallerId)\n| project Caller, RequestCount\n| order by RequestCount desc", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "size": 1, + "timeContext": { + "durationMs": 0 + }, + "timeContextFromParameter": "TimeRange", + "title": "Total Requests by Caller ID", + "noDataMessage": "No caller-requests metrics found in the selected time range.", + "version": "KqlItem/1.0", + "visualization": "barchart" + }, + "customWidth": "60", + "name": "query - usage-chart", + "styleSettings": { + "maxWidth": "60%", + "showBorder": true + }, + "type": 3 + }, + { + "content": { + "query": "let appNames = parse_json('{AppIdNames}');\ncustomMetrics\n| where name == 'caller-requests'\n| extend CallerId = tostring(customDimensions.CallerId)\n| where isnotempty(CallerId)\n| summarize RequestCount = sum(value) by CallerId\n| extend Caller = iif(isnotempty(tostring(appNames[CallerId])), strcat(tostring(appNames[CallerId]), ' (', CallerId, ')'), CallerId)\n| project Caller, RequestCount\n| order by RequestCount desc", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "size": 0, + "timeContext": { + "durationMs": 0 + }, + "timeContextFromParameter": "TimeRange", + "title": "Usage Summary", + "noDataMessage": "No caller-requests metrics found in the selected time range.", + "version": "KqlItem/1.0", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "RequestCount", + "formatter": 1, + "numberFormat": { + "unit": 17, + "options": { + "style": "decimal", + "useGrouping": true + } + } + } + ], + "labelSettings": [ + { "columnId": "Caller", "label": "Caller" }, + { "columnId": "RequestCount", "label": "Requests" } + ] + } + }, + "customWidth": "40", + "name": "query - usage-table", + "styleSettings": { + "maxWidth": "40%", + "showBorder": true + }, + "type": 3 + } + ], + "loadType": "always", + "title": "Usage by Caller ID", + "version": "NotebookGroup/1.0" + }, + "name": "group - usage", + "type": 12 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "json": "Proportional cost breakdown based on the **Monthly Base Cost** parameter above. Each caller's share is calculated as their percentage of total requests applied to the base cost." + }, + "name": "text - cost-description", + "type": 1 + }, + { + "content": { + "query": "let appNames = parse_json('{AppIdNames}');\nlet baseCost = {BaseCost};\nlet metrics = customMetrics\n| where name == 'caller-requests'\n| extend CallerId = tostring(customDimensions.CallerId)\n| where isnotempty(CallerId);\nlet totalRequests = toscalar(metrics | summarize sum(value));\nmetrics\n| summarize RequestCount = sum(value) by CallerId\n| extend Caller = iif(isnotempty(tostring(appNames[CallerId])), strcat(tostring(appNames[CallerId]), ' (', CallerId, ')'), CallerId)\n| extend UsagePercent = round(RequestCount * 100.0 / totalRequests, 2)\n| extend AllocatedCost = round(baseCost * RequestCount / totalRequests, 2)\n| order by AllocatedCost desc\n| project Caller, RequestCount, ['Usage %'] = UsagePercent, ['Allocated Cost ($)'] = AllocatedCost", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "size": 0, + "timeContext": { + "durationMs": 0 + }, + "timeContextFromParameter": "TimeRange", + "title": "Cost Allocation by Caller ID", + "noDataMessage": "No caller-requests metrics found in the selected time range.", + "version": "KqlItem/1.0", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Usage %", + "formatOptions": { + "min": 0, + "max": 100, + "palette": "blue" + }, + "formatter": 4 + }, + { + "columnMatch": "Allocated Cost ($)", + "formatOptions": { + "min": 0, + "palette": "turquoise" + }, + "formatter": 8 + } + ] + } + }, + "customWidth": "60", + "name": "query - cost-table", + "styleSettings": { + "maxWidth": "60%", + "showBorder": true + }, + "type": 3 + }, + { + "content": { + "query": "let appNames = parse_json('{AppIdNames}');\nlet baseCost = {BaseCost};\nlet metrics = customMetrics\n| where name == 'caller-requests'\n| extend CallerId = tostring(customDimensions.CallerId)\n| where isnotempty(CallerId);\nlet totalRequests = toscalar(metrics | summarize sum(value));\nmetrics\n| summarize RequestCount = sum(value) by CallerId\n| extend Caller = iif(isnotempty(tostring(appNames[CallerId])), strcat(tostring(appNames[CallerId]), ' (', CallerId, ')'), CallerId)\n| extend AllocatedCost = round(baseCost * RequestCount / totalRequests, 2)\n| project Caller, AllocatedCost\n| order by AllocatedCost desc", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "size": 1, + "timeContext": { + "durationMs": 0 + }, + "timeContextFromParameter": "TimeRange", + "title": "Cost Distribution", + "noDataMessage": "No caller-requests metrics found in the selected time range.", + "version": "KqlItem/1.0", + "visualization": "piechart" + }, + "customWidth": "40", + "name": "query - cost-pie", + "styleSettings": { + "maxWidth": "40%", + "showBorder": true + }, + "type": 3 + } + ], + "loadType": "always", + "title": "Cost Allocation", + "version": "NotebookGroup/1.0" + }, + "name": "group - cost-allocation", + "type": 12 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "json": "Hourly request volume by caller over time. Use this to spot traffic spikes, identify peak usage periods, and detect anomalies by caller." + }, + "name": "text - trend-description", + "type": 1 + }, + { + "content": { + "query": "let appNames = parse_json('{AppIdNames}');\ncustomMetrics\n| where name == 'caller-requests'\n| extend CallerId = tostring(customDimensions.CallerId)\n| where isnotempty(CallerId)\n| extend Caller = iif(isnotempty(tostring(appNames[CallerId])), strcat(tostring(appNames[CallerId]), ' (', CallerId, ')'), CallerId)\n| summarize Requests = sum(value) by Caller, bin(timestamp, 1h)\n| order by timestamp asc", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "size": 0, + "timeContext": { + "durationMs": 0 + }, + "timeContextFromParameter": "TimeRange", + "title": "Hourly Request Trend by Caller ID", + "noDataMessage": "No caller-requests metrics found in the selected time range.", + "version": "KqlItem/1.0", + "visualization": "timechart" + }, + "name": "query - trend-chart", + "type": 3 + } + ], + "loadType": "always", + "title": "Request Trend", + "version": "NotebookGroup/1.0" + }, + "name": "group - trend", + "type": 12 + } + ], + "version": "Notebook/1.0" +} diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 3e2a586..20afde1 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -1,6 +1,7 @@ """ Shared test configuration and fixtures for pytest. """ + import os import sys from typing import Any @@ -8,45 +9,48 @@ import pytest # Add the shared/python directory to the Python path for all tests -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../shared/python'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../shared/python"))) # Add the tests/python directory to import test_helpers sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) # APIM Samples imports (must come after the sys path inserts) +from apimtypes import Region from test_helpers import ( + MockApimRequestsPatches, + MockInfrastructuresPatches, create_mock_http_response, create_mock_output, create_sample_apis, create_sample_policy_fragments, get_sample_infrastructure_params, - MockApimRequestsPatches, - MockInfrastructuresPatches ) - # ------------------------------ # SHARED FIXTURES # ------------------------------ -@pytest.fixture(scope='session') + +@pytest.fixture(scope="session") def shared_python_path() -> str: """Provide the path to the shared Python modules.""" - return os.path.abspath(os.path.join(os.path.dirname(__file__), '../../shared/python')) + return os.path.abspath(os.path.join(os.path.dirname(__file__), "../../shared/python")) + -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def test_data_path() -> str: """Provide the path to test data files.""" - return os.path.abspath(os.path.join(os.path.dirname(__file__), 'data')) + return os.path.abspath(os.path.join(os.path.dirname(__file__), "data")) + @pytest.fixture def sample_test_data() -> dict[str, Any]: """Provide sample test data for use across tests.""" return { - 'test_url': 'https://test-apim.azure-api.net', - 'test_subscription_key': 'test-subscription-key-123', - 'test_resource_group': 'rg-test-apim-01', - 'test_location': 'eastus2' + "test_url": "https://test-apim.azure-api.net", + "test_subscription_key": "test-subscription-key-123", + "test_resource_group": "rg-test-apim-01", + "test_location": Region.EAST_US_2, } @@ -54,6 +58,7 @@ def sample_test_data() -> dict[str, Any]: # MOCK FIXTURES # ------------------------------ + @pytest.fixture(autouse=True) def infrastructures_patches(): """Automatically patch infrastructures dependencies for tests.""" @@ -76,13 +81,13 @@ def mock_az(infrastructures_patches): @pytest.fixture def mock_az_success(): """Pre-configured successful Azure CLI output.""" - return create_mock_output(success=True, json_data={'result': 'success'}) + return create_mock_output(success=True, json_data={"result": "success"}) @pytest.fixture def mock_az_failure(): """Pre-configured failed Azure CLI output.""" - return create_mock_output(success=False, text='Error message') + return create_mock_output(success=False, text="Error message") @pytest.fixture @@ -106,19 +111,13 @@ def sample_infrastructure_params() -> dict[str, Any]: @pytest.fixture def mock_http_response_200(): """Pre-configured successful HTTP response.""" - return create_mock_http_response( - status_code=200, - json_data={'result': 'ok'} - ) + return create_mock_http_response(status_code=200, json_data={"result": "ok"}) @pytest.fixture def mock_http_response_error(): """Pre-configured error HTTP response.""" - return create_mock_http_response( - status_code=500, - text='Internal Server Error' - ) + return create_mock_http_response(status_code=500, text="Internal Server Error") @pytest.fixture diff --git a/tests/python/test_azure_resources.py b/tests/python/test_azure_resources.py index 432b28f..0e041d6 100644 --- a/tests/python/test_azure_resources.py +++ b/tests/python/test_azure_resources.py @@ -9,6 +9,8 @@ # APIM Samples imports import azure_resources as az import pytest +from apimtypes import INFRASTRUCTURE, Endpoints, Output, Region +import pytest from apimtypes import INFRASTRUCTURE, Endpoints, Output from test_helpers import suppress_module_functions @@ -16,16 +18,12 @@ # TEST DATA # ------------------------------ + # Static account info data for reuse across tests def _create_account_output(): """Create a standard account output for testing.""" - output = Output(True, '{}') - output.json_data = { - 'user': {'name': 'test.user@example.com'}, - 'id': 'sub-123', - 'tenantId': 'tenant-123', - 'name': 'Test Subscription' - } + output = Output(True, "{}") + output.json_data = {"user": {"name": "test.user@example.com"}, "id": "sub-123", "tenantId": "tenant-123", "name": "Test Subscription"} return output @@ -33,40 +31,43 @@ def _create_account_output(): # AZURE ROLE TESTS # ------------------------------ + def test_get_azure_role_guid_success(): """Test successful retrieval of Azure role GUID.""" - mock_data = {'Contributor': 'role-guid-123', 'Reader': 'role-guid-67890'} + mock_data = {"Contributor": "role-guid-123", "Reader": "role-guid-67890"} - with patch('builtins.open', mock_open(read_data=json.dumps(mock_data))): - result = az.get_azure_role_guid('Contributor') + with patch("builtins.open", mock_open(read_data=json.dumps(mock_data))): + result = az.get_azure_role_guid("Contributor") - assert result == 'role-guid-123' + assert result == "role-guid-123" def test_get_azure_role_guid_failure(): """Test get_azure_role_guid returns None when file not found.""" # Mock os.path functions to return a non-existent path - with patch('azure_resources.os.path.abspath', return_value='/nonexistent/path'): - with patch('azure_resources.os.path.dirname', return_value='/nonexistent'): - with patch('azure_resources.os.path.join', return_value='/nonexistent/azure-roles.json'): - with patch('azure_resources.os.path.normpath', return_value='/nonexistent/azure-roles.json'): - result = az.get_azure_role_guid('NonExistentRole') + with patch("azure_resources.os.path.abspath", return_value="/nonexistent/path"): + with patch("azure_resources.os.path.dirname", return_value="/nonexistent"): + with patch("azure_resources.os.path.join", return_value="/nonexistent/azure-roles.json"): + with patch("azure_resources.os.path.normpath", return_value="/nonexistent/azure-roles.json"): + result = az.get_azure_role_guid("NonExistentRole") assert result is None + # ------------------------------ # RESOURCE GROUP TESTS # ------------------------------ + def test_does_resource_group_exist_true(): """Test checking if resource group exists - returns True.""" with patch('azure_resources.run') as mock_run: mock_run.return_value = Output(True, 'true') - result = az.does_resource_group_exist('test-rg') + result = az.does_resource_group_exist("test-rg") assert result is True mock_run.assert_called_once_with('az group exists --name test-rg') @@ -78,7 +79,7 @@ def test_does_resource_group_exist_false(): with patch('azure_resources.run') as mock_run: mock_run.return_value = Output(True, 'false') - result = az.does_resource_group_exist('nonexistent-rg') + result = az.does_resource_group_exist("nonexistent-rg") assert result is False @@ -86,22 +87,22 @@ def test_does_resource_group_exist_false(): def test_get_resource_group_location_success(): """Test successful retrieval of resource group location.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, 'eastus2\n') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, f"{Region.EAST_US_2}\n") - result = az.get_resource_group_location('test-rg') + result = az.get_resource_group_location("test-rg") - assert result == 'eastus2' + assert result == Region.EAST_US_2 mock_run.assert_called_once_with('az group show --name test-rg --query "location" -o tsv') def test_get_resource_group_location_failure(): """Test get_resource_group_location returns None on failure.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'error message') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "error message") - result = az.get_resource_group_location('nonexistent-rg') + result = az.get_resource_group_location("nonexistent-rg") assert result is None @@ -109,10 +110,10 @@ def test_get_resource_group_location_failure(): def test_get_resource_group_location_empty(): """Test get_resource_group_location returns None on empty response.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, '') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, "") - result = az.get_resource_group_location('test-rg') + result = az.get_resource_group_location("test-rg") assert result is None @@ -121,49 +122,50 @@ def test_get_resource_group_location_empty(): # ACCOUNT INFO TESTS # ------------------------------ + def test_get_account_info_success(): """Test successful retrieval of account information.""" - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: account_output = _create_account_output() - ad_user_output = Output(True, '{}') - ad_user_output.json_data = {'id': 'user-id-123'} + ad_user_output = Output(True, "{}") + ad_user_output.json_data = {"id": "user-id-123"} mock_run.side_effect = [account_output, ad_user_output] current_user, current_user_id, tenant_id, subscription_id = az.get_account_info() - assert current_user == 'test.user@example.com' - assert current_user_id == 'user-id-123' - assert tenant_id == 'tenant-123' - assert subscription_id == 'sub-123' + assert current_user == "test.user@example.com" + assert current_user_id == "user-id-123" + assert tenant_id == "tenant-123" + assert subscription_id == "sub-123" def test_get_account_info_failure(): """Test get_account_info raises exception on failure.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'authentication error') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "authentication error") with pytest.raises(Exception) as exc_info: az.get_account_info() - assert 'Failed to retrieve account information' in str(exc_info.value) + assert "Failed to retrieve account information" in str(exc_info.value) def test_get_account_info_no_json(): """Test get_account_info raises exception when no JSON data.""" - with patch('azure_resources.run') as mock_run: - output = Output(True, 'some text') + with patch("azure_resources.run") as mock_run: + output = Output(True, "some text") output.json_data = None mock_run.return_value = output with pytest.raises(Exception) as exc_info: az.get_account_info() - assert 'Failed to retrieve account information' in str(exc_info.value) + assert "Failed to retrieve account information" in str(exc_info.value) # ------------------------------ @@ -179,48 +181,48 @@ def test_get_apim_subscription_key_selects_active_and_returns_primary(monkeypatc def fake_run(cmd: str, *args, **kwargs): calls.append(cmd) - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'suspended', 'displayName': 'Suspended'}}, - {'name': 'sid-2', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "suspended", "displayName": "Suspended"}}, + {"name": "sid-2", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - assert '/subscriptions/sid-2/listSecrets' in cmd - return Output(True, json.dumps({'primaryKey': 'pk-abc', 'secondaryKey': 'sk-def'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + assert "/subscriptions/sid-2/listSecrets" in cmd + return Output(True, json.dumps({"primaryKey": "pk-abc", "secondaryKey": "sk-def"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - key = az.get_apim_subscription_key('apim-name', 'rg-name') + key = az.get_apim_subscription_key("apim-name", "rg-name") - assert key == 'pk-abc' - assert any('az rest --method get' in c for c in calls) - assert any('listSecrets' in c for c in calls) + assert key == "pk-abc" + assert any("az rest --method get" in c for c in calls) + assert any("listSecrets" in c for c in calls) def test_get_apim_subscription_key_returns_none_when_no_subscriptions(monkeypatch): """Returns None when APIM has no subscriptions.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: - return Output(True, json.dumps({'value': []})) + if "az rest --method get" in cmd and "/subscriptions?" in cmd: + return Output(True, json.dumps({"value": []})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_uses_provided_sid(monkeypatch): @@ -231,48 +233,48 @@ def test_get_apim_subscription_key_uses_provided_sid(monkeypatch): def fake_run(cmd: str, *args, **kwargs): calls.append(cmd) - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - assert '/subscriptions/sid-explicit/listSecrets' in cmd - return Output(True, json.dumps({'primaryKey': 'pk-xyz'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + assert "/subscriptions/sid-explicit/listSecrets" in cmd + return Output(True, json.dumps({"primaryKey": "pk-xyz"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - key = az.get_apim_subscription_key('apim-name', 'rg-name', sid = 'sid-explicit') - assert key == 'pk-xyz' - assert not any('az rest --method get' in c and '/subscriptions?' in c for c in calls) + key = az.get_apim_subscription_key("apim-name", "rg-name", sid="sid-explicit") + assert key == "pk-xyz" + assert not any("az rest --method get" in c and "/subscriptions?" in c for c in calls) def test_get_apim_subscription_key_account_show_fails(monkeypatch): """Returns None when az account show fails.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(False, '') + if cmd.startswith("az account show"): + return Output(False, "") - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_account_show_empty(monkeypatch): """Returns None when az account show returns empty text.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, ' \n ') + if cmd.startswith("az account show"): + return Output(True, " \n ") - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_no_active_uses_first(monkeypatch): @@ -283,225 +285,225 @@ def test_get_apim_subscription_key_no_active_uses_first(monkeypatch): def fake_run(cmd: str, *args, **kwargs): calls.append(cmd) - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'suspended', 'displayName': 'First'}}, - {'name': 'sid-2', 'properties': {'state': 'cancelled', 'displayName': 'Second'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "suspended", "displayName": "First"}}, + {"name": "sid-2", "properties": {"state": "cancelled", "displayName": "Second"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - assert '/subscriptions/sid-1/listSecrets' in cmd - return Output(True, json.dumps({'primaryKey': 'pk-first', 'secondaryKey': 'sk-first'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + assert "/subscriptions/sid-1/listSecrets" in cmd + return Output(True, json.dumps({"primaryKey": "pk-first", "secondaryKey": "sk-first"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - key = az.get_apim_subscription_key('apim-name', 'rg-name') + key = az.get_apim_subscription_key("apim-name", "rg-name") - assert key == 'pk-first' + assert key == "pk-first" def test_get_apim_subscription_key_subscription_name_empty(monkeypatch): """Returns None when subscription name is empty or missing.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': '', 'properties': {'state': 'active', 'displayName': 'Empty name'}}, + "value": [ + {"name": "", "properties": {"state": "active", "displayName": "Empty name"}}, ] } return Output(True, json.dumps(payload)) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_subscription_name_missing(monkeypatch): """Returns None when subscription has no name key.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'properties': {'state': 'active', 'displayName': 'No name key'}}, + "value": [ + {"properties": {"state": "active", "displayName": "No name key"}}, ] } return Output(True, json.dumps(payload)) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_secrets_call_fails(monkeypatch): """Returns None when listSecrets REST call fails.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(False, 'API error') + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(False, "API error") - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_secrets_not_dict(monkeypatch): """Returns None when listSecrets returns non-dict JSON.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(True, json.dumps(['not', 'a', 'dict'])) + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(True, json.dumps(["not", "a", "dict"])) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_returns_secondary_key(monkeypatch): """Returns secondaryKey when requested.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(True, json.dumps({'primaryKey': 'pk-abc', 'secondaryKey': 'sk-xyz'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(True, json.dumps({"primaryKey": "pk-abc", "secondaryKey": "sk-xyz"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - key = az.get_apim_subscription_key('apim-name', 'rg-name', key_name = 'secondaryKey') + key = az.get_apim_subscription_key("apim-name", "rg-name", key_name="secondaryKey") - assert key == 'sk-xyz' + assert key == "sk-xyz" def test_get_apim_subscription_key_key_value_empty(monkeypatch): """Returns None when key value is empty string.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(True, json.dumps({'primaryKey': ' ', 'secondaryKey': 'sk-xyz'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(True, json.dumps({"primaryKey": " ", "secondaryKey": "sk-xyz"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_key_value_not_string(monkeypatch): """Returns None when key value is not a string.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(True, json.dumps({'primaryKey': 123, 'secondaryKey': 'sk-xyz'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(True, json.dumps({"primaryKey": 123, "secondaryKey": "sk-xyz"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_key_missing(monkeypatch): """Returns None when requested key is not in response.""" def fake_run(cmd: str, *args, **kwargs): - if cmd.startswith('az account show'): - return Output(True, 'sub-123\n') + if cmd.startswith("az account show"): + return Output(True, "sub-123\n") - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: + if "az rest --method get" in cmd and "/subscriptions?" in cmd: payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(True, json.dumps({'someOtherKey': 'value'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(True, json.dumps({"someOtherKey": "value"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - assert az.get_apim_subscription_key('apim-name', 'rg-name') is None + assert az.get_apim_subscription_key("apim-name", "rg-name") is None def test_get_apim_subscription_key_uses_provided_subscription_id(monkeypatch): @@ -512,36 +514,33 @@ def test_get_apim_subscription_key_uses_provided_subscription_id(monkeypatch): def fake_run(cmd: str, *args, **kwargs): calls.append(cmd) - if 'az rest --method get' in cmd and '/subscriptions?' in cmd: - assert '/subscriptions/custom-sub-id/' in cmd + if "az rest --method get" in cmd and "/subscriptions?" in cmd: + assert "/subscriptions/custom-sub-id/" in cmd payload = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'active', 'displayName': 'Active'}}, + "value": [ + {"name": "sid-1", "properties": {"state": "active", "displayName": "Active"}}, ] } return Output(True, json.dumps(payload)) - if 'az rest --method post' in cmd and 'listSecrets' in cmd: - return Output(True, json.dumps({'primaryKey': 'pk-custom', 'secondaryKey': 'sk-custom'})) + if "az rest --method post" in cmd and "listSecrets" in cmd: + return Output(True, json.dumps({"primaryKey": "pk-custom", "secondaryKey": "sk-custom"})) - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - key = az.get_apim_subscription_key( - 'apim-name', - 'rg-name', - subscription_id = 'custom-sub-id' - ) + key = az.get_apim_subscription_key("apim-name", "rg-name", subscription_id="custom-sub-id") - assert key == 'pk-custom' - assert not any('az account show' in c for c in calls) + assert key == "pk-custom" + assert not any("az account show" in c for c in calls) # ------------------------------ # JWT SIGNING KEY CLEANUP TESTS # ------------------------------ + def test_cleanup_old_jwt_signing_keys_success(monkeypatch): """Test successful cleanup of old JWT signing keys.""" @@ -550,34 +549,34 @@ def test_cleanup_old_jwt_signing_keys_success(monkeypatch): def fake_run(cmd: str, *args, **kwargs): run_calls.append(cmd) - if 'nv list' in cmd: - return Output(True, 'JwtSigningKey-sample-123\nJwtSigningKey-sample-456\n') + if "nv list" in cmd: + return Output(True, "JwtSigningKey-sample-123\nJwtSigningKey-sample-456\n") - if 'nv delete' in cmd: + if "nv delete" in cmd: # Only the non-current key should be deleted - return Output(True, '') + return Output(True, "") - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) - suppress_module_functions(monkeypatch, az, ['print_message', 'print_info', 'print_ok', 'print_error']) + monkeypatch.setattr(az, "run", fake_run) + suppress_module_functions(monkeypatch, az, ["print_message", "print_info", "print_ok", "print_error"]) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-sample-456') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-sample-456") assert result is True - assert any('nv list' in c for c in run_calls) - delete_calls = [c for c in run_calls if 'nv delete' in c] + assert any("nv list" in c for c in run_calls) + delete_calls = [c for c in run_calls if "nv delete" in c] assert len(delete_calls) == 1 - assert 'JwtSigningKey-sample-123' in delete_calls[0] + assert "JwtSigningKey-sample-123" in delete_calls[0] def test_cleanup_old_jwt_signing_keys_invalid_pattern(monkeypatch): """Test cleanup when current key name does not match expected pattern.""" - monkeypatch.setattr(az, 'run', lambda *a, **k: pytest.fail('run should not be called')) - suppress_module_functions(monkeypatch, az, ['print_message', 'print_info', 'print_ok']) + monkeypatch.setattr(az, "run", lambda *a, **k: pytest.fail("run should not be called")) + suppress_module_functions(monkeypatch, az, ["print_message", "print_info", "print_ok"]) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'invalid-key-name') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "invalid-key-name") assert result is False @@ -586,59 +585,60 @@ def test_cleanup_old_jwt_signing_keys_invalid_pattern(monkeypatch): # APIM BLOB PERMISSIONS TESTS # ------------------------------ + def test_check_apim_blob_permissions_success(monkeypatch): """Test blob permission check succeeds when role assignment and access test succeed.""" - monkeypatch.setattr(az, 'get_azure_role_guid', lambda *_: 'role-guid') - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) + monkeypatch.setattr(az, "get_azure_role_guid", lambda *_: "role-guid") + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) run_calls: list[str] = [] def fake_run(cmd: str, *args, **kwargs): run_calls.append(cmd) - if 'apim show' in cmd: - return Output(True, 'principal-id\n') + if "apim show" in cmd: + return Output(True, "principal-id\n") - if 'storage account show' in cmd: - return Output(True, 'notice\n/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n') + if "storage account show" in cmd: + return Output(True, "notice\n/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n") - if 'role assignment list' in cmd: - return Output(True, 'assignment-id\n') + if "role assignment list" in cmd: + return Output(True, "assignment-id\n") - if 'storage blob list' in cmd: - return Output(True, 'blob-name\n') + if "storage blob list" in cmd: + return Output(True, "blob-name\n") - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) - monkeypatch.setattr(az.time, 'sleep', lambda *a, **k: None) + monkeypatch.setattr(az, "run", fake_run) + monkeypatch.setattr(az.time, "sleep", lambda *a, **k: None) - result = az.check_apim_blob_permissions('apim', 'storage', 'rg', max_wait_minutes = 1) + result = az.check_apim_blob_permissions("apim", "storage", "rg", max_wait_minutes=1) assert result is True - assert any('role assignment list' in c for c in run_calls) - assert any('storage blob list' in c for c in run_calls) + assert any("role assignment list" in c for c in run_calls) + assert any("storage blob list" in c for c in run_calls) def test_check_apim_blob_permissions_missing_resource_id(monkeypatch): """Test blob permission check fails when storage account ID cannot be parsed.""" - monkeypatch.setattr(az, 'get_azure_role_guid', lambda *_: 'role-guid') - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) + monkeypatch.setattr(az, "get_azure_role_guid", lambda *_: "role-guid") + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) def fake_run(cmd: str, *args, **kwargs): - if 'apim show' in cmd: - return Output(True, 'principal-id\n') + if "apim show" in cmd: + return Output(True, "principal-id\n") - if 'storage account show' in cmd: - return Output(True, 'no matching id here') + if "storage account show" in cmd: + return Output(True, "no matching id here") - return Output(False, 'unexpected command') + return Output(False, "unexpected command") - monkeypatch.setattr(az, 'run', fake_run) + monkeypatch.setattr(az, "run", fake_run) - result = az.check_apim_blob_permissions('apim', 'storage', 'rg') + result = az.check_apim_blob_permissions("apim", "storage", "rg") assert result is False @@ -647,72 +647,71 @@ def fake_run(cmd: str, *args, **kwargs): # DEPLOYMENT NAME TESTS # ------------------------------ -@patch('azure_resources.time.time') -@patch('azure_resources.os.path.basename') -@patch('azure_resources.os.getcwd') + +@patch("azure_resources.time.time") +@patch("azure_resources.os.path.basename") +@patch("azure_resources.os.getcwd") def test_get_deployment_name_with_directory(mock_getcwd, mock_basename, mock_time): """Test deployment name generation with explicit directory.""" mock_time.return_value = 1234567890 - result = az.get_deployment_name('my-sample') + result = az.get_deployment_name("my-sample") - assert result == 'deploy-my-sample-1234567890' + assert result == "deploy-my-sample-1234567890" mock_getcwd.assert_not_called() # Note: patching `os.path.basename` affects the shared `os.path` module, which is also # used by stdlib logging internals. Avoid strict call-count assertions here. -@patch('azure_resources.time.time') -@patch('azure_resources.os.path.basename') -@patch('azure_resources.os.getcwd') +@patch("azure_resources.time.time") +@patch("azure_resources.os.path.basename") +@patch("azure_resources.os.getcwd") def test_get_deployment_name_current_directory(mock_getcwd, mock_basename, mock_time): """Test deployment name generation using current directory.""" mock_time.return_value = 1234567890 - mock_getcwd.return_value = '/path/to/current-folder' - mock_basename.return_value = 'current-folder' + mock_getcwd.return_value = "/path/to/current-folder" + mock_basename.return_value = "current-folder" result = az.get_deployment_name() - assert result == 'deploy-current-folder-1234567890' + assert result == "deploy-current-folder-1234567890" mock_getcwd.assert_called_once() - assert any(call_args.args == ('/path/to/current-folder',) for call_args in mock_basename.call_args_list) + assert any(call_args.args == ("/path/to/current-folder",) for call_args in mock_basename.call_args_list) # ------------------------------ # FRONT DOOR TESTS # ------------------------------ + def test_get_frontdoor_url_afd_success(): """Test successful Front Door URL retrieval.""" - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: # Create mock outputs - profile_output = Output(True, '') + profile_output = Output(True, "") profile_output.json_data = [{"name": "test-afd"}] - endpoint_output = Output(True, '') + endpoint_output = Output(True, "") endpoint_output.json_data = [{"hostName": "test.azurefd.net"}] mock_run.side_effect = [profile_output, endpoint_output] - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") - assert result == 'https://test.azurefd.net' + assert result == "https://test.azurefd.net" - expected_calls = [ - call('az afd profile list -g test-rg -o json'), - call('az afd endpoint list -g test-rg --profile-name test-afd -o json') - ] + expected_calls = [call("az afd profile list -g test-rg -o json"), call("az afd endpoint list -g test-rg --profile-name test-afd -o json")] mock_run.assert_has_calls(expected_calls) def test_get_frontdoor_url_wrong_infrastructure(): """Test Front Door URL with wrong infrastructure type.""" - with patch('azure_resources.run') as mock_run: - result = az.get_frontdoor_url(INFRASTRUCTURE.SIMPLE_APIM, 'test-rg') + with patch("azure_resources.run") as mock_run: + result = az.get_frontdoor_url(INFRASTRUCTURE.SIMPLE_APIM, "test-rg") assert result is None mock_run.assert_not_called() @@ -721,10 +720,10 @@ def test_get_frontdoor_url_wrong_infrastructure(): def test_get_frontdoor_url_no_profile(): """Test Front Door URL when no profile found.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'No profiles found') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "No profiles found") - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert result is None @@ -732,13 +731,13 @@ def test_get_frontdoor_url_no_profile(): def test_get_frontdoor_url_no_endpoints(): """Test Front Door URL when profile exists but no endpoints.""" - with patch('azure_resources.run') as mock_run: - profile_output = Output(True, '') - profile_output.json_data = [{'name': 'test-afd'}] - endpoint_output = Output(False, 'No endpoints found') + with patch("azure_resources.run") as mock_run: + profile_output = Output(True, "") + profile_output.json_data = [{"name": "test-afd"}] + endpoint_output = Output(False, "No endpoints found") mock_run.side_effect = [profile_output, endpoint_output] - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert result is None @@ -747,26 +746,27 @@ def test_get_frontdoor_url_no_endpoints(): # APIM URL TESTS # ------------------------------ + def test_get_apim_url_success(): """Test successful APIM URL retrieval.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, '') - mock_run.return_value.json_data = [{'name': 'test-apim', 'gatewayUrl': 'https://test-apim.azure-api.net'}] + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, "") + mock_run.return_value.json_data = [{"name": "test-apim", "gatewayUrl": "https://test-apim.azure-api.net"}] - result = az.get_apim_url('test-rg') + result = az.get_apim_url("test-rg") - assert result == 'https://test-apim.azure-api.net' - mock_run.assert_called_once_with('az apim list -g test-rg -o json') + assert result == "https://test-apim.azure-api.net" + mock_run.assert_called_once_with("az apim list -g test-rg -o json") def test_get_apim_url_failure(): """Test APIM URL retrieval failure.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'No APIM services found') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "No APIM services found") - result = az.get_apim_url('test-rg') + result = az.get_apim_url("test-rg") assert result is None @@ -774,11 +774,11 @@ def test_get_apim_url_failure(): def test_get_apim_url_no_gateway(): """Test APIM URL when service exists but no gateway URL.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, '') - mock_run.return_value.json_data = [{'name': 'test-apim', 'gatewayUrl': None}] + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, "") + mock_run.return_value.json_data = [{"name": "test-apim", "gatewayUrl": None}] - result = az.get_apim_url('test-rg') + result = az.get_apim_url("test-rg") assert result is None @@ -787,30 +787,33 @@ def test_get_apim_url_no_gateway(): # APPLICATION GATEWAY TESTS # ------------------------------ + def test_get_appgw_endpoint_success(): """Test successful Application Gateway endpoint retrieval.""" - with patch('azure_resources.run') as mock_run: - appgw_output = Output(True, '') - appgw_output.json_data = [{ - 'name': 'test-appgw', - 'httpListeners': [{'hostName': 'api.contoso.com'}], - 'frontendIPConfigurations': [{ - 'publicIPAddress': {'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/publicIPAddresses/test-pip'} - }] - }] - ip_output = Output(True, '') - ip_output.json_data = {'ipAddress': '1.2.3.4'} + with patch("azure_resources.run") as mock_run: + appgw_output = Output(True, "") + appgw_output.json_data = [ + { + "name": "test-appgw", + "httpListeners": [{"hostName": "api.contoso.com"}], + "frontendIPConfigurations": [ + {"publicIPAddress": {"id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/publicIPAddresses/test-pip"}} + ], + } + ] + ip_output = Output(True, "") + ip_output.json_data = {"ipAddress": "1.2.3.4"} mock_run.side_effect = [appgw_output, ip_output] - hostname, ip = az.get_appgw_endpoint('test-rg') + hostname, ip = az.get_appgw_endpoint("test-rg") - assert hostname == 'api.contoso.com' - assert ip == '1.2.3.4' + assert hostname == "api.contoso.com" + assert ip == "1.2.3.4" expected_calls = [ - call('az network application-gateway list -g test-rg -o json'), - call('az network public-ip show -g test-rg -n test-pip -o json') + call("az network application-gateway list -g test-rg -o json"), + call("az network public-ip show -g test-rg -n test-pip -o json"), ] mock_run.assert_has_calls(expected_calls) @@ -818,10 +821,10 @@ def test_get_appgw_endpoint_success(): def test_get_appgw_endpoint_no_gateway(): """Test Application Gateway endpoint when no gateway found.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'No gateways found') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "No gateways found") - hostname, ip = az.get_appgw_endpoint('test-rg') + hostname, ip = az.get_appgw_endpoint("test-rg") assert hostname is None assert ip is None @@ -830,15 +833,11 @@ def test_get_appgw_endpoint_no_gateway(): def test_get_appgw_endpoint_no_listeners(): """Test Application Gateway endpoint with no HTTP listeners.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, '') - mock_run.return_value.json_data = [{ - 'name': 'test-appgw', - 'httpListeners': [], - 'frontendIPConfigurations': [] - }] + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, "") + mock_run.return_value.json_data = [{"name": "test-appgw", "httpListeners": [], "frontendIPConfigurations": []}] - hostname, ip = az.get_appgw_endpoint('test-rg') + hostname, ip = az.get_appgw_endpoint("test-rg") assert hostname is None assert ip is None @@ -848,12 +847,13 @@ def test_get_appgw_endpoint_no_listeners(): # NAMING FUNCTION TESTS # ------------------------------ + def test_get_infra_rg_name_without_index(): """Test infrastructure resource group name generation without index.""" result = az.get_infra_rg_name(INFRASTRUCTURE.SIMPLE_APIM) - assert result == 'apim-infra-simple-apim' + assert result == "apim-infra-simple-apim" def test_get_infra_rg_name_with_index(): @@ -861,129 +861,133 @@ def test_get_infra_rg_name_with_index(): result = az.get_infra_rg_name(INFRASTRUCTURE.AFD_APIM_PE, 42) - assert result == 'apim-infra-afd-apim-pe-42' + assert result == "apim-infra-afd-apim-pe-42" def test_get_rg_name_without_index(): """Test sample resource group name generation without index.""" - result = az.get_rg_name('test-sample') + result = az.get_rg_name("test-sample") - assert result == 'apim-sample-test-sample' + assert result == "apim-sample-test-sample" def test_get_rg_name_with_index(): """Test sample resource group name generation with index.""" - result = az.get_rg_name('test-sample', 5) + result = az.get_rg_name("test-sample", 5) - assert result == 'apim-sample-test-sample-5' + assert result == "apim-sample-test-sample-5" # ------------------------------ # UNIQUE SUFFIX TESTS # ------------------------------ -@patch('azure_resources.tempfile.NamedTemporaryFile') -@patch('azure_resources.time.time') -@patch('azure_resources.os.unlink') + +@patch("azure_resources.tempfile.NamedTemporaryFile") +@patch("azure_resources.time.time") +@patch("azure_resources.os.unlink") def test_get_unique_suffix_for_resource_group_success(mock_unlink, mock_time, mock_tempfile): """Test successful unique suffix retrieval.""" mock_time.return_value = 1234567890 mock_file = Mock() - mock_file.name = '/tmp/template.json' + mock_file.name = "/tmp/template.json" mock_tempfile.return_value.__enter__.return_value = mock_file - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, 'abc123def456\n') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, "abc123def456\n") - result = az.get_unique_suffix_for_resource_group('test-rg') + result = az.get_unique_suffix_for_resource_group("test-rg") - assert result == 'abc123def456' + assert result == "abc123def456" mock_run.assert_called_once() - mock_unlink.assert_called_once_with('/tmp/template.json') + mock_unlink.assert_called_once_with("/tmp/template.json") -@patch('azure_resources.tempfile.NamedTemporaryFile') -@patch('azure_resources.time.time') -@patch('azure_resources.os.unlink') +@patch("azure_resources.tempfile.NamedTemporaryFile") +@patch("azure_resources.time.time") +@patch("azure_resources.os.unlink") def test_get_unique_suffix_for_resource_group_failure(mock_unlink, mock_time, mock_tempfile): """Test unique suffix retrieval failure.""" mock_time.return_value = 1234567890 mock_file = Mock() - mock_file.name = '/tmp/template.json' + mock_file.name = "/tmp/template.json" mock_tempfile.return_value.__enter__.return_value = mock_file - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'Deployment failed') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "Deployment failed") - result = az.get_unique_suffix_for_resource_group('test-rg') + result = az.get_unique_suffix_for_resource_group("test-rg") assert not result - mock_unlink.assert_called_once_with('/tmp/template.json') + mock_unlink.assert_called_once_with("/tmp/template.json") # ------------------------------ # ENDPOINTS TESTS # ------------------------------ -@patch('azure_resources.get_frontdoor_url') -@patch('azure_resources.get_apim_url') -@patch('azure_resources.get_appgw_endpoint') + +@patch("azure_resources.get_frontdoor_url") +@patch("azure_resources.get_apim_url") +@patch("azure_resources.get_appgw_endpoint") def test_get_endpoints_success(mock_appgw, mock_apim, mock_afd): """Test successful endpoints retrieval.""" - mock_afd.return_value = 'https://test.azurefd.net' - mock_apim.return_value = 'https://test-apim.azure-api.net' - mock_appgw.return_value = ('api.contoso.com', '1.2.3.4') + mock_afd.return_value = "https://test.azurefd.net" + mock_apim.return_value = "https://test-apim.azure-api.net" + mock_appgw.return_value = ("api.contoso.com", "1.2.3.4") - result = az.get_endpoints(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_endpoints(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert isinstance(result, Endpoints) - assert result.afd_endpoint_url == 'https://test.azurefd.net' - assert result.apim_endpoint_url == 'https://test-apim.azure-api.net' - assert result.appgw_hostname == 'api.contoso.com' - assert result.appgw_public_ip == '1.2.3.4' + assert result.afd_endpoint_url == "https://test.azurefd.net" + assert result.apim_endpoint_url == "https://test-apim.azure-api.net" + assert result.appgw_hostname == "api.contoso.com" + assert result.appgw_public_ip == "1.2.3.4" + + mock_afd.assert_called_once_with(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") + mock_apim.assert_called_once_with("test-rg") + mock_appgw.assert_called_once_with("test-rg") - mock_afd.assert_called_once_with(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') - mock_apim.assert_called_once_with('test-rg') - mock_appgw.assert_called_once_with('test-rg') # ------------------------------ # ERROR HANDLING TESTS # ------------------------------ + def test_run_with_debug_flag_injection(monkeypatch): """Test that --debug flag is injected when logging is in DEBUG level.""" mock_process = Mock() mock_process.returncode = 0 - mock_process.stdout = 'test output' - mock_process.stderr = '' + mock_process.stdout = "test output" + mock_process.stderr = "" - monkeypatch.setattr('subprocess.run', lambda *a, **k: mock_process) + monkeypatch.setattr("subprocess.run", lambda *a, **k: mock_process) - az.run('az account show') + az.run("az account show") # Verify run method works without errors def test_get_resource_group_location_with_whitespace(monkeypatch): """Test get_resource_group_location handles whitespace in response.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, ' eastus \n') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, " eastus \n") - result = az.get_resource_group_location('test-rg') - assert result == 'eastus' + result = az.get_resource_group_location("test-rg") + assert result == "eastus" def test_get_account_info_missing_user_id(monkeypatch): """Test get_account_info when user ID is not available.""" - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: account_output = _create_account_output() - ad_user_output = Output(False, 'User not found') + ad_user_output = Output(False, "User not found") mock_run.side_effect = [account_output, ad_user_output] with pytest.raises(Exception): @@ -992,9 +996,9 @@ def test_get_account_info_missing_user_id(monkeypatch): def test_cleanup_old_jwt_signing_keys_no_matching_pattern(monkeypatch): """Test cleanup_old_jwt_signing_keys with non-matching key pattern.""" - suppress_module_functions(monkeypatch, az, ['print_message', 'print_info', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_message", "print_info", "print_ok"]) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'InvalidKeyPattern-123') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "InvalidKeyPattern-123") assert result is False @@ -1004,104 +1008,107 @@ def test_cleanup_old_jwt_signing_keys_all_deleted(monkeypatch): def fake_run(cmd, *args, **kwargs): run_calls.append(cmd) - if 'nv list' in cmd: - return Output(True, 'JwtSigningKey-sample-123\nJwtSigningKey-sample-67890\n') - if 'nv delete' in cmd: - return Output(True, 'Deleted') - return Output(False, 'Unknown') + if "nv list" in cmd: + return Output(True, "JwtSigningKey-sample-123\nJwtSigningKey-sample-67890\n") + if "nv delete" in cmd: + return Output(True, "Deleted") + return Output(False, "Unknown") - monkeypatch.setattr('azure_resources.run', fake_run) - suppress_module_functions(monkeypatch, az, ['print_message', 'print_info', 'print_ok', 'print_error']) + monkeypatch.setattr("azure_resources.run", fake_run) + suppress_module_functions(monkeypatch, az, ["print_message", "print_info", "print_ok", "print_error"]) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-sample-99999') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-sample-99999") assert result is True def test_get_frontdoor_url_no_hostname(monkeypatch): """Test get_frontdoor_url when endpoint has no hostname.""" - with patch('azure_resources.run') as mock_run: - profile_output = Output(True, '') - profile_output.json_data = [{'name': 'test-afd'}] + with patch("azure_resources.run") as mock_run: + profile_output = Output(True, "") + profile_output.json_data = [{"name": "test-afd"}] - endpoint_output = Output(True, '') + endpoint_output = Output(True, "") endpoint_output.json_data = [] # Empty endpoint list mock_run.side_effect = [profile_output, endpoint_output] - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert result is None def test_get_apim_url_multiple_services(monkeypatch): """Test get_apim_url returns first service when multiple exist.""" - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, '') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, "") mock_run.return_value.json_data = [ - {'name': 'apim-1', 'gatewayUrl': 'https://apim-1.azure-api.net'}, - {'name': 'apim-2', 'gatewayUrl': 'https://apim-2.azure-api.net'} + {"name": "apim-1", "gatewayUrl": "https://apim-1.azure-api.net"}, + {"name": "apim-2", "gatewayUrl": "https://apim-2.azure-api.net"}, ] - result = az.get_apim_url('test-rg') - assert result == 'https://apim-1.azure-api.net' + result = az.get_apim_url("test-rg") + assert result == "https://apim-1.azure-api.net" def test_get_appgw_endpoint_no_public_ip(monkeypatch): """Test get_appgw_endpoint when public IP retrieval fails.""" - with patch('azure_resources.run') as mock_run: - appgw_output = Output(True, '') - appgw_output.json_data = [{ - 'name': 'test-appgw', - 'httpListeners': [{'hostName': 'api.contoso.com'}], - 'frontendIPConfigurations': [{ - 'publicIPAddress': {'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/publicIPAddresses/pip'} - }] - }] - - ip_output = Output(False, 'IP not found') + with patch("azure_resources.run") as mock_run: + appgw_output = Output(True, "") + appgw_output.json_data = [ + { + "name": "test-appgw", + "httpListeners": [{"hostName": "api.contoso.com"}], + "frontendIPConfigurations": [ + {"publicIPAddress": {"id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/publicIPAddresses/pip"}} + ], + } + ] + + ip_output = Output(False, "IP not found") mock_run.side_effect = [appgw_output, ip_output] - hostname, ip = az.get_appgw_endpoint('test-rg') - assert hostname == 'api.contoso.com' + hostname, ip = az.get_appgw_endpoint("test-rg") + assert hostname == "api.contoso.com" assert ip is None def test_get_infra_rg_name_with_zero_index(monkeypatch): """Test get_infra_rg_name with zero index.""" result = az.get_infra_rg_name(INFRASTRUCTURE.SIMPLE_APIM, 0) - assert result == 'apim-infra-simple-apim-0' + assert result == "apim-infra-simple-apim-0" def test_get_infra_rg_name_with_negative_index(monkeypatch): """Test get_infra_rg_name with negative index.""" result = az.get_infra_rg_name(INFRASTRUCTURE.APIM_ACA, -1) - assert result == 'apim-infra-apim-aca--1' + assert result == "apim-infra-apim-aca--1" def test_get_rg_name_with_zero_index(monkeypatch): """Test get_rg_name with zero index.""" - result = az.get_rg_name('sample', 0) - assert result == 'apim-sample-sample-0' + result = az.get_rg_name("sample", 0) + assert result == "apim-sample-sample-0" def test_get_deployment_name_different_samples(monkeypatch): """Test get_deployment_name with different sample names.""" - with patch('azure_resources.time.time', return_value=1000): - with patch('azure_resources.os.getcwd', return_value='/path/to/sample-1'): - with patch('azure_resources.os.path.basename', return_value='sample-1'): - result = az.get_deployment_name('my-custom-sample') - assert 'my-custom-sample' in result - assert '1000' in result + with patch("azure_resources.time.time", return_value=1000): + with patch("azure_resources.os.getcwd", return_value="/path/to/sample-1"): + with patch("azure_resources.os.path.basename", return_value="sample-1"): + result = az.get_deployment_name("my-custom-sample") + assert "my-custom-sample" in result + assert "1000" in result def test_find_infrastructure_instances_multiple_indexes(monkeypatch): """Test find_infrastructure_instances with multiple indexes.""" + def fake_run(cmd, *args, **kwargs): - if 'apim-aca' in cmd: - return Output(True, 'apim-infra-apim-aca-1\napim-infra-apim-aca-2\napim-infra-apim-aca-3\n') - return Output(False, '') + if "apim-aca" in cmd: + return Output(True, "apim-infra-apim-aca-1\napim-infra-apim-aca-2\napim-infra-apim-aca-3\n") + return Output(False, "") - monkeypatch.setattr('azure_resources.run', fake_run) + monkeypatch.setattr("azure_resources.run", fake_run) result = az.find_infrastructure_instances(INFRASTRUCTURE.APIM_ACA) assert len(result) == 3 @@ -1112,10 +1119,11 @@ def fake_run(cmd, *args, **kwargs): def test_find_infrastructure_instances_invalid_format(monkeypatch): """Test find_infrastructure_instances handles invalid response format.""" + def fake_run(cmd, *args, **kwargs): - return Output(True, 'invalid-format-no-index\n') + return Output(True, "invalid-format-no-index\n") - monkeypatch.setattr('azure_resources.run', fake_run) + monkeypatch.setattr("azure_resources.run", fake_run) result = az.find_infrastructure_instances(INFRASTRUCTURE.AFD_APIM_PE) assert not result @@ -1125,6 +1133,7 @@ def fake_run(cmd, *args, **kwargs): # COMMAND STRING GENERATION TESTS # ------------------------------ + def test_run_command_building_with_output_format(monkeypatch): """Test that run constructs proper Azure CLI commands.""" @@ -1134,29 +1143,29 @@ def capture_command(*args, **kwargs): called_commands.append(args[0]) mock_process = Mock() mock_process.returncode = 0 - mock_process.stdout = 'output' - mock_process.stderr = '' + mock_process.stdout = "output" + mock_process.stderr = "" return mock_process - monkeypatch.setattr('subprocess.run', capture_command) + monkeypatch.setattr("subprocess.run", capture_command) - az.run('az group show --name test-rg -o json') + az.run("az group show --name test-rg -o json") assert len(called_commands) > 0 def test_get_unique_suffix_with_empty_rg_list(monkeypatch): """Test get_unique_suffix_for_resource_group with empty list response.""" - with patch('azure_resources.tempfile.NamedTemporaryFile') as mock_tempfile: + with patch("azure_resources.tempfile.NamedTemporaryFile") as mock_tempfile: mock_file = Mock() - mock_file.name = '/tmp/template.json' + mock_file.name = "/tmp/template.json" mock_tempfile.return_value.__enter__.return_value = mock_file - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'No resources found') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "No resources found") - with patch('azure_resources.os.unlink'): - result = az.get_unique_suffix_for_resource_group('test-rg') + with patch("azure_resources.os.unlink"): + result = az.get_unique_suffix_for_resource_group("test-rg") assert not result @@ -1164,17 +1173,17 @@ def test_get_unique_suffix_with_empty_rg_list(monkeypatch): # INTEGRATION AND EDGE CASES # ------------------------------ + def test_get_endpoints_with_partial_data(monkeypatch): """Test get_endpoints when some endpoints are missing.""" - with patch('azure_resources.get_frontdoor_url', return_value=None): - with patch('azure_resources.get_apim_url', return_value='https://test-apim.azure-api.net'): - with patch('azure_resources.get_appgw_endpoint', return_value=(None, None)): - - result = az.get_endpoints(INFRASTRUCTURE.SIMPLE_APIM, 'test-rg') + with patch("azure_resources.get_frontdoor_url", return_value=None): + with patch("azure_resources.get_apim_url", return_value="https://test-apim.azure-api.net"): + with patch("azure_resources.get_appgw_endpoint", return_value=(None, None)): + result = az.get_endpoints(INFRASTRUCTURE.SIMPLE_APIM, "test-rg") assert isinstance(result, Endpoints) assert result.afd_endpoint_url is None - assert result.apim_endpoint_url == 'https://test-apim.azure-api.net' + assert result.apim_endpoint_url == "https://test-apim.azure-api.net" assert result.appgw_hostname is None assert result.appgw_public_ip is None @@ -1191,20 +1200,20 @@ def test_does_resource_group_exist_with_malformed_response(monkeypatch): def test_create_resource_group_with_empty_tags(monkeypatch): """Test create_resource_group with empty tags dictionary.""" - monkeypatch.setattr('azure_resources.does_resource_group_exist', lambda x: False) + monkeypatch.setattr("azure_resources.does_resource_group_exist", lambda x: False) run_calls = [] def capture_run(cmd, *args, **kwargs): run_calls.append(cmd) - return Output(True, '{}') + return Output(True, "{}") - monkeypatch.setattr('azure_resources.run', capture_run) + monkeypatch.setattr("azure_resources.run", capture_run) - az.create_resource_group('test-rg', 'eastus', {}) + az.create_resource_group("test-rg", "eastus", {}) assert len(run_calls) > 0 - assert '--tags' in run_calls[0] # Tags should still be included (with defaults) + assert "--tags" in run_calls[0] # Tags should still be included (with defaults) def test_create_resource_group_skips_existence_check_when_rg_exists_provided(monkeypatch): @@ -1229,15 +1238,15 @@ def capture_run(cmd, *args, **kwargs): def test_get_azure_role_guid_with_multiple_roles(monkeypatch): """Test get_azure_role_guid retrieval from file with multiple roles.""" mock_data = { - 'Owner': 'role-owner', - 'Contributor': 'role-contrib', - 'Reader': 'role-reader', - 'Storage Blob Data Reader': 'role-storage-reader', - 'Storage Account Contributor': 'role-storage-contrib', - 'Key Vault Administrator': 'role-kv-admin', + "Owner": "role-owner", + "Contributor": "role-contrib", + "Reader": "role-reader", + "Storage Blob Data Reader": "role-storage-reader", + "Storage Account Contributor": "role-storage-contrib", + "Key Vault Administrator": "role-kv-admin", } - with patch('builtins.open', mock_open(read_data=json.dumps(mock_data))): + with patch("builtins.open", mock_open(read_data=json.dumps(mock_data))): for role_name, expected_guid in mock_data.items(): result = az.get_azure_role_guid(role_name) assert result == expected_guid @@ -1245,75 +1254,79 @@ def test_get_azure_role_guid_with_multiple_roles(monkeypatch): def test_check_apim_blob_permissions_no_principal_id(monkeypatch): """Test check_apim_blob_permissions when APIM has no principal ID.""" + def fake_run(cmd, *args, **kwargs): - if 'apim show' in cmd: - return Output(True, '') # No principal ID - return Output(False, 'Error') + if "apim show" in cmd: + return Output(True, "") # No principal ID + return Output(False, "Error") - monkeypatch.setattr('azure_resources.run', fake_run) - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) + monkeypatch.setattr("azure_resources.run", fake_run) + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) - result = az.check_apim_blob_permissions('apim', 'storage', 'rg') + result = az.check_apim_blob_permissions("apim", "storage", "rg") assert result is False def test_check_apim_blob_permissions_timeout_waiting_for_propagation(monkeypatch): """Test blob permission check times out when waiting for role assignment propagation.""" + def fake_run(cmd, *args, **kwargs): - if 'apim show' in cmd: - return Output(True, 'principal-id\n') - if 'storage account show' in cmd: - return Output(True, '/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n') - if 'role assignment list' in cmd: + if "apim show" in cmd: + return Output(True, "principal-id\n") + if "storage account show" in cmd: + return Output(True, "/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n") + if "role assignment list" in cmd: # Never return a role assignment (timeout scenario) - return Output(True, '') - return Output(False, 'unexpected') + return Output(True, "") + return Output(False, "unexpected") - monkeypatch.setattr(az, 'run', fake_run) - monkeypatch.setattr(az, 'get_azure_role_guid', lambda *_: 'role-guid') - monkeypatch.setattr(az.time, 'sleep', lambda *a, **k: None) - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) + monkeypatch.setattr(az, "run", fake_run) + monkeypatch.setattr(az, "get_azure_role_guid", lambda *_: "role-guid") + monkeypatch.setattr(az.time, "sleep", lambda *a, **k: None) + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) - result = az.check_apim_blob_permissions('apim', 'storage', 'rg', max_wait_minutes=1) + result = az.check_apim_blob_permissions("apim", "storage", "rg", max_wait_minutes=1) assert result is False def test_check_apim_blob_permissions_storage_account_retrieval_fails(monkeypatch): """Test blob permission check fails when storage account retrieval fails.""" + def fake_run(cmd, *args, **kwargs): - if 'apim show' in cmd: - return Output(True, 'principal-id\n') - if 'storage account show' in cmd: - return Output(False, 'Error retrieving account') - return Output(False, 'unexpected') + if "apim show" in cmd: + return Output(True, "principal-id\n") + if "storage account show" in cmd: + return Output(False, "Error retrieving account") + return Output(False, "unexpected") - monkeypatch.setattr(az, 'run', fake_run) - monkeypatch.setattr(az, 'get_azure_role_guid', lambda *_: 'role-guid') - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) + monkeypatch.setattr(az, "run", fake_run) + monkeypatch.setattr(az, "get_azure_role_guid", lambda *_: "role-guid") + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) - result = az.check_apim_blob_permissions('apim', 'storage', 'rg') + result = az.check_apim_blob_permissions("apim", "storage", "rg") assert result is False def test_check_apim_blob_permissions_role_assignment_exists_but_blob_access_fails(monkeypatch): """Test when role assignment exists but blob access test fails.""" + def fake_run(cmd, *args, **kwargs): - if 'apim show' in cmd: - return Output(True, 'principal-id\n') - if 'storage account show' in cmd: - return Output(True, '/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n') - if 'role assignment list' in cmd: - return Output(True, 'assignment-id\n') - if 'storage blob list' in cmd: - return Output(True, 'access-test-failed') - return Output(False, 'unexpected') - - monkeypatch.setattr(az, 'run', fake_run) - monkeypatch.setattr(az, 'get_azure_role_guid', lambda *_: 'role-guid') - monkeypatch.setattr(az.time, 'sleep', lambda *a, **k: None) - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) - - result = az.check_apim_blob_permissions('apim', 'storage', 'rg', max_wait_minutes=1) + if "apim show" in cmd: + return Output(True, "principal-id\n") + if "storage account show" in cmd: + return Output(True, "/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n") + if "role assignment list" in cmd: + return Output(True, "assignment-id\n") + if "storage blob list" in cmd: + return Output(True, "access-test-failed") + return Output(False, "unexpected") + + monkeypatch.setattr(az, "run", fake_run) + monkeypatch.setattr(az, "get_azure_role_guid", lambda *_: "role-guid") + monkeypatch.setattr(az.time, "sleep", lambda *a, **k: None) + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) + + result = az.check_apim_blob_permissions("apim", "storage", "rg", max_wait_minutes=1) assert result is False @@ -1325,20 +1338,20 @@ def fake_sleep(seconds): call_times.append(seconds) def fake_run(cmd, *args, **kwargs): - if 'apim show' in cmd: - return Output(True, 'principal-id\n') - if 'storage account show' in cmd: - return Output(True, '/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n') - if 'role assignment list' in cmd: - return Output(True, '') # Never find it, trigger timeout - return Output(False, 'unexpected') - - monkeypatch.setattr(az, 'run', fake_run) - monkeypatch.setattr(az, 'get_azure_role_guid', lambda *_: 'role-guid') - monkeypatch.setattr(az.time, 'sleep', fake_sleep) - suppress_module_functions(monkeypatch, az, ['print_info', 'print_ok', 'print_warning', 'print_error']) - - result = az.check_apim_blob_permissions('apim', 'storage', 'rg', max_wait_minutes=2) + if "apim show" in cmd: + return Output(True, "principal-id\n") + if "storage account show" in cmd: + return Output(True, "/subscriptions/123/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage\n") + if "role assignment list" in cmd: + return Output(True, "") # Never find it, trigger timeout + return Output(False, "unexpected") + + monkeypatch.setattr(az, "run", fake_run) + monkeypatch.setattr(az, "get_azure_role_guid", lambda *_: "role-guid") + monkeypatch.setattr(az.time, "sleep", fake_sleep) + suppress_module_functions(monkeypatch, az, ["print_info", "print_ok", "print_warning", "print_error"]) + + result = az.check_apim_blob_permissions("apim", "storage", "rg", max_wait_minutes=2) assert result is False # Verify sleep was called with correct interval assert all(seconds == 30 for seconds in call_times) @@ -1346,213 +1359,214 @@ def fake_run(cmd, *args, **kwargs): def test_get_account_info_all_fields_present(monkeypatch): """Test get_account_info successfully retrieves all account information.""" - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: account_output = _create_account_output() - ad_user_output = Output(True, '{}') - ad_user_output.json_data = {'id': 'user-id-xyz'} + ad_user_output = Output(True, "{}") + ad_user_output.json_data = {"id": "user-id-xyz"} mock_run.side_effect = [account_output, ad_user_output] user, user_id, tenant_id, subscription_id = az.get_account_info() - assert user == 'test.user@example.com' - assert user_id == 'user-id-xyz' - assert tenant_id == 'tenant-123' - assert subscription_id == 'sub-123' + assert user == "test.user@example.com" + assert user_id == "user-id-xyz" + assert tenant_id == "tenant-123" + assert subscription_id == "sub-123" # ------------------------------ # UTILITY FUNCTION TESTS # ------------------------------ + def test_redact_secrets_with_access_token(): """Test _redact_secrets redacts accessToken in JSON.""" text = '{"accessToken": "secretToken123"}' result = az._redact_secrets(text) - assert 'secretToken123' not in result - assert '***REDACTED***' in result + assert "secretToken123" not in result + assert "***REDACTED***" in result def test_redact_secrets_with_refresh_token(): """Test _redact_secrets redacts refreshToken in JSON.""" text = '{"refreshToken": "refreshSecret456"}' result = az._redact_secrets(text) - assert 'refreshSecret456' not in result - assert '***REDACTED***' in result + assert "refreshSecret456" not in result + assert "***REDACTED***" in result def test_redact_secrets_with_client_secret(): """Test _redact_secrets redacts client_secret in JSON.""" text = '{"client_secret": "clientSecret789"}' result = az._redact_secrets(text) - assert 'clientSecret789' not in result - assert '***REDACTED***' in result + assert "clientSecret789" not in result + assert "***REDACTED***" in result def test_redact_secrets_with_bearer_token(): """Test _redact_secrets redacts Authorization: Bearer tokens.""" - text = 'Authorization: Bearer myBearerToken123' + text = "Authorization: Bearer myBearerToken123" result = az._redact_secrets(text) - assert 'myBearerToken123' not in result - assert '***REDACTED***' in result + assert "myBearerToken123" not in result + assert "***REDACTED***" in result def test_redact_secrets_with_empty_string(): """Test _redact_secrets handles empty string.""" - assert not az._redact_secrets('') + assert not az._redact_secrets("") assert az._redact_secrets(None) is None def test_maybe_add_az_debug_flag_when_debug_enabled(): """Test _maybe_add_az_debug_flag adds --debug when logging is DEBUG.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - result = az._maybe_add_az_debug_flag('az group list') - assert '--debug' in result + with patch("azure_resources.is_debug_enabled", return_value=True): + result = az._maybe_add_az_debug_flag("az group list") + assert "--debug" in result def test_maybe_add_az_debug_flag_when_debug_disabled(): """Test _maybe_add_az_debug_flag doesn't add --debug when logging is not DEBUG.""" - with patch('azure_resources.is_debug_enabled', return_value=False): - result = az._maybe_add_az_debug_flag('az group list') - assert result == 'az group list' + with patch("azure_resources.is_debug_enabled", return_value=False): + result = az._maybe_add_az_debug_flag("az group list") + assert result == "az group list" def test_maybe_add_az_debug_flag_with_pipe(): """Test _maybe_add_az_debug_flag handles commands with pipes.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - result = az._maybe_add_az_debug_flag('az group list | jq .') - assert '--debug' in result - assert result.index('--debug') < result.index('|') + with patch("azure_resources.is_debug_enabled", return_value=True): + result = az._maybe_add_az_debug_flag("az group list | jq .") + assert "--debug" in result + assert result.index("--debug") < result.index("|") def test_maybe_add_az_debug_flag_with_redirect(): """Test _maybe_add_az_debug_flag handles commands with output redirection.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - result = az._maybe_add_az_debug_flag('az group list > output.txt') - assert '--debug' in result - assert result.index('--debug') < result.index('>') + with patch("azure_resources.is_debug_enabled", return_value=True): + result = az._maybe_add_az_debug_flag("az group list > output.txt") + assert "--debug" in result + assert result.index("--debug") < result.index(">") def test_maybe_add_az_debug_flag_already_has_debug(): """Test _maybe_add_az_debug_flag doesn't duplicate --debug flag.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - result = az._maybe_add_az_debug_flag('az group list --debug') - assert result.count('--debug') == 1 + with patch("azure_resources.is_debug_enabled", return_value=True): + result = az._maybe_add_az_debug_flag("az group list --debug") + assert result.count("--debug") == 1 def test_maybe_add_az_debug_flag_non_az_command(): """Test _maybe_add_az_debug_flag doesn't modify non-az commands.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - result = az._maybe_add_az_debug_flag('echo hello') - assert result == 'echo hello' + with patch("azure_resources.is_debug_enabled", return_value=True): + result = az._maybe_add_az_debug_flag("echo hello") + assert result == "echo hello" def test_extract_az_cli_error_message_with_json_error(): """Test _extract_az_cli_error_message extracts from JSON error payload.""" output = '{"error": {"code": "NotFound", "message": "Resource not found"}}' result = az._extract_az_cli_error_message(output) - assert result == 'Resource not found' + assert result == "Resource not found" def test_extract_az_cli_error_message_with_json_message(): """Test _extract_az_cli_error_message extracts from JSON message field.""" output = '{"message": "Deployment failed"}' result = az._extract_az_cli_error_message(output) - assert result == 'Deployment failed' + assert result == "Deployment failed" def test_extract_az_cli_error_message_with_error_prefix(): """Test _extract_az_cli_error_message extracts from ERROR: line.""" - output = 'ERROR: Resource group not found' + output = "ERROR: Resource group not found" result = az._extract_az_cli_error_message(output) - assert result == 'Resource group not found' + assert result == "Resource group not found" def test_extract_az_cli_error_message_with_az_error_prefix(): """Test _extract_az_cli_error_message extracts from az: error: line.""" - output = 'az: error: argument --name is required' + output = "az: error: argument --name is required" result = az._extract_az_cli_error_message(output) - assert result == 'argument --name is required' + assert result == "argument --name is required" def test_extract_az_cli_error_message_with_code_and_message(): """Test _extract_az_cli_error_message combines Code: and Message: lines.""" - output = 'Code: ResourceNotFound\nMessage: The resource was not found' + output = "Code: ResourceNotFound\nMessage: The resource was not found" result = az._extract_az_cli_error_message(output) - assert 'ResourceNotFound' in result - assert 'The resource was not found' in result + assert "ResourceNotFound" in result + assert "The resource was not found" in result def test_extract_az_cli_error_message_with_empty_string(): """Test _extract_az_cli_error_message handles empty string.""" - assert not az._extract_az_cli_error_message('') + assert not az._extract_az_cli_error_message("") def test_extract_az_cli_error_message_skips_traceback(): """Test _extract_az_cli_error_message skips traceback lines.""" output = 'Some error\nTraceback (most recent call last):\n File "test.py"' result = az._extract_az_cli_error_message(output) - assert result == 'Some error' + assert result == "Some error" def test_extract_az_cli_error_message_skips_warnings(): """Test _extract_az_cli_error_message skips warning lines.""" - output = 'WARNING: This is deprecated\nERROR: Real error here' + output = "WARNING: This is deprecated\nERROR: Real error here" result = az._extract_az_cli_error_message(output) - assert result == 'Real error here' + assert result == "Real error here" def test_extract_az_cli_error_message_with_ansi_codes(): """Test _extract_az_cli_error_message strips ANSI codes.""" - output = '\x1b[31mERROR: Resource failed\x1b[0m' + output = "\x1b[31mERROR: Resource failed\x1b[0m" result = az._extract_az_cli_error_message(output) - assert result == 'Resource failed' + assert result == "Resource failed" def test_extract_az_cli_error_message_finds_first_non_empty_line(): """Test _extract_az_cli_error_message returns first meaningful line.""" - output = '\n\n\nSome error occurred\nMore details' + output = "\n\n\nSome error occurred\nMore details" result = az._extract_az_cli_error_message(output) - assert result == 'Some error occurred' + assert result == "Some error occurred" def test_extract_az_cli_error_message_with_json_array(): """Test _extract_az_cli_error_message handles JSON arrays (not dict).""" - output = '[1, 2, 3] error' + output = "[1, 2, 3] error" result = az._extract_az_cli_error_message(output) # JSON array is skipped, falls back to returning first non-empty line - assert result == '[1, 2, 3] error' + assert result == "[1, 2, 3] error" def test_extract_az_cli_error_message_with_message_only_no_code(): """Test _extract_az_cli_error_message with Message field but no Code.""" - output = 'Message: Something went wrong\nOther line' + output = "Message: Something went wrong\nOther line" result = az._extract_az_cli_error_message(output) - assert result == 'Something went wrong' + assert result == "Something went wrong" def test_extract_az_cli_error_message_with_code_only_no_message(): """Test _extract_az_cli_error_message with Code field but no Message.""" - output = 'Code: ResourceNotFound\nOther line' + output = "Code: ResourceNotFound\nOther line" result = az._extract_az_cli_error_message(output) # Should return first meaningful line since no message - assert result in ('Code: ResourceNotFound', 'Other line') + assert result in ("Code: ResourceNotFound", "Other line") def test_extract_az_cli_error_message_with_whitespace_only(): """Test _extract_az_cli_error_message handles whitespace-only text.""" - output = ' \n \n ' + output = " \n \n " result = az._extract_az_cli_error_message(output) assert not result def test_extract_az_cli_error_message_with_error_no_message_part(): """Test _extract_az_cli_error_message handles ERROR: with no message after colon.""" - output = 'ERROR:\nOther line' + output = "ERROR:\nOther line" result = az._extract_az_cli_error_message(output) # Falls back to the original line - assert 'ERROR' in result or result == 'Other line' + assert "ERROR" in result or result == "Other line" def test_extract_az_cli_error_message_with_json_error_without_message(): @@ -1568,111 +1582,111 @@ def test_extract_az_cli_error_message_with_json_and_error_prefix(): output = '{"message": "JSON error"}\nERROR: Text error' result = az._extract_az_cli_error_message(output) # Should prefer JSON message - assert result == 'JSON error' + assert result == "JSON error" def test_extract_az_cli_error_message_multiple_warnings_and_error(): """Test _extract_az_cli_error_message with multiple warnings and actual error.""" - output = 'WARNING: Old feature\nWARNING: Deprecated\nERROR: Real problem' + output = "WARNING: Old feature\nWARNING: Deprecated\nERROR: Real problem" result = az._extract_az_cli_error_message(output) - assert result == 'Real problem' + assert result == "Real problem" def test_extract_az_cli_error_message_with_az_error_no_message(): """Test _extract_az_cli_error_message with az: error: but no message.""" - output = 'az: error:\nOther content' + output = "az: error:\nOther content" result = az._extract_az_cli_error_message(output) # Falls back to the original line - assert 'az: error' in result or result == 'Other content' + assert "az: error" in result or result == "Other content" def test_looks_like_json_with_valid_json(): """Test _looks_like_json identifies JSON strings.""" assert az._looks_like_json('{"key": "value"}') is True - assert az._looks_like_json('[1, 2, 3]') is True + assert az._looks_like_json("[1, 2, 3]") is True def test_looks_like_json_with_non_json(): """Test _looks_like_json rejects non-JSON strings.""" - assert az._looks_like_json('plain text') is False - assert az._looks_like_json('') is False + assert az._looks_like_json("plain text") is False + assert az._looks_like_json("") is False def test_strip_ansi_removes_codes(): """Test _strip_ansi removes ANSI escape codes.""" - text = '\x1b[31mRed text\x1b[0m normal' + text = "\x1b[31mRed text\x1b[0m normal" result = az._strip_ansi(text) - assert '\x1b' not in result - assert 'Red text' in result - assert 'normal' in result + assert "\x1b" not in result + assert "Red text" in result + assert "normal" in result def test_is_az_command_recognizes_az_commands(): """Test _is_az_command identifies az CLI commands.""" - assert az._is_az_command('az group list') is True - assert az._is_az_command(' az account show ') is True - assert az._is_az_command('az') is True + assert az._is_az_command("az group list") is True + assert az._is_az_command(" az account show ") is True + assert az._is_az_command("az") is True def test_is_az_command_rejects_non_az_commands(): """Test _is_az_command rejects non-az commands.""" - assert az._is_az_command('echo hello') is False - assert az._is_az_command('python script.py') is False - assert az._is_az_command('azurecli') is False + assert az._is_az_command("echo hello") is False + assert az._is_az_command("python script.py") is False + assert az._is_az_command("azurecli") is False def test_run_with_exception_in_subprocess(): """Test run() handles subprocess exceptions gracefully.""" - with patch('azure_resources.subprocess.run') as mock_subprocess: - mock_subprocess.side_effect = Exception('Subprocess failed') + with patch("azure_resources.subprocess.run") as mock_subprocess: + mock_subprocess.side_effect = Exception("Subprocess failed") - result = az.run('az group list') + result = az.run("az group list") assert result.success is False - assert 'Subprocess failed' in result.text + assert "Subprocess failed" in result.text def test_run_with_stderr_only(): """Test run() handles commands that only output to stderr.""" - with patch('azure_resources.subprocess.run') as mock_subprocess: + with patch("azure_resources.subprocess.run") as mock_subprocess: mock_process = Mock() mock_process.returncode = 0 - mock_process.stdout = '' - mock_process.stderr = 'Some warning message' + mock_process.stdout = "" + mock_process.stderr = "Some warning message" mock_subprocess.return_value = mock_process - result = az.run('az group list') + result = az.run("az group list") assert result.success is True def test_run_with_az_debug_flag_already_present(): """Test run() doesn't duplicate --debug flag.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - with patch('azure_resources.subprocess.run') as mock_subprocess: + with patch("azure_resources.is_debug_enabled", return_value=True): + with patch("azure_resources.subprocess.run") as mock_subprocess: mock_process = Mock() mock_process.returncode = 0 - mock_process.stdout = '[]' - mock_process.stderr = '' + mock_process.stdout = "[]" + mock_process.stderr = "" mock_subprocess.return_value = mock_process - az.run('az group list --debug') + az.run("az group list --debug") # Check that --debug appears only once in the command called_command = mock_subprocess.call_args[0][0] - assert called_command.count('--debug') == 1 + assert called_command.count("--debug") == 1 def test_run_with_json_output_success(): """Test run() with successful JSON output.""" - with patch('azure_resources.subprocess.run') as mock_subprocess: + with patch("azure_resources.subprocess.run") as mock_subprocess: mock_process = Mock() mock_process.returncode = 0 mock_process.stdout = '{"result": "success"}' - mock_process.stderr = '' + mock_process.stderr = "" mock_subprocess.return_value = mock_process - result = az.run('az group show --name test-rg') + result = az.run("az group show --name test-rg") assert result.success is True assert '{"result": "success"}' in result.text @@ -1680,22 +1694,23 @@ def test_run_with_json_output_success(): def test_run_with_complex_shell_expression(): """Test run() handles complex shell expressions with operators.""" - with patch('azure_resources.is_debug_enabled', return_value=True): - with patch('azure_resources.subprocess.run') as mock_subprocess: + with patch("azure_resources.is_debug_enabled", return_value=True): + with patch("azure_resources.subprocess.run") as mock_subprocess: mock_process = Mock() mock_process.returncode = 0 - mock_process.stdout = 'output' - mock_process.stderr = '' + mock_process.stdout = "output" + mock_process.stderr = "" mock_subprocess.return_value = mock_process az.run('az group list || echo "failed"') # --debug should be inserted before the || called_command = mock_subprocess.call_args[0][0] - debug_pos = called_command.find('--debug') - pipe_pos = called_command.find('||') + debug_pos = called_command.find("--debug") + pipe_pos = called_command.find("||") assert debug_pos < pipe_pos + # ======================================== # ADDITIONAL COVERAGE TESTS (MIGRATED) # ======================================== @@ -1705,22 +1720,22 @@ class TestStripAnsi: """Test ANSI escape sequence removal.""" def test_strip_ansi_with_color_codes(self): - text = '\x1b[1;32mSuccess\x1b[0m' + text = "\x1b[1;32mSuccess\x1b[0m" result = az._strip_ansi(text) - assert result == 'Success' + assert result == "Success" def test_strip_ansi_with_multiple_codes(self): - text = '\x1b[31mError\x1b[0m \x1b[1;33mWarning\x1b[0m' + text = "\x1b[31mError\x1b[0m \x1b[1;33mWarning\x1b[0m" result = az._strip_ansi(text) - assert result == 'Error Warning' + assert result == "Error Warning" def test_strip_ansi_with_no_codes(self): - text = 'Plain text' + text = "Plain text" result = az._strip_ansi(text) - assert result == 'Plain text' + assert result == "Plain text" def test_strip_ansi_empty_string(self): - result = az._strip_ansi('') + result = az._strip_ansi("") assert not result @@ -1730,29 +1745,29 @@ class TestRedactSecrets: def test_redact_access_token(self): text = '{"accessToken": "secret-token-value"}' result = az._redact_secrets(text) - assert 'secret-token-value' not in result - assert '***REDACTED***' in result + assert "secret-token-value" not in result + assert "***REDACTED***" in result def test_redact_refresh_token(self): text = '{"refreshToken": "my-refresh-token"}' result = az._redact_secrets(text) - assert 'my-refresh-token' not in result - assert '***REDACTED***' in result + assert "my-refresh-token" not in result + assert "***REDACTED***" in result def test_redact_client_secret(self): text = '{"client_secret": "super-secret"}' result = az._redact_secrets(text) - assert 'super-secret' not in result - assert '***REDACTED***' in result + assert "super-secret" not in result + assert "***REDACTED***" in result def test_redact_bearer_token(self): - text = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + text = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" result = az._redact_secrets(text) - assert 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' not in result - assert '***REDACTED***' in result + assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result + assert "***REDACTED***" in result def test_redact_empty_string(self): - result = az._redact_secrets('') + result = az._redact_secrets("") assert not result def test_redact_none_value(self): @@ -1762,85 +1777,85 @@ def test_redact_none_value(self): def test_redact_case_insensitive(self): text = '{"AccessToken": "secret"}' result = az._redact_secrets(text) - assert 'secret' not in result + assert "secret" not in result class TestIsAzCommand: """Test Azure CLI command detection.""" def test_is_az_command_with_whitespace(self): - assert az._is_az_command(' az group list') is True - assert az._is_az_command('az account show ') is True + assert az._is_az_command(" az group list") is True + assert az._is_az_command("az account show ") is True def test_is_az_command_with_arguments(self): - assert az._is_az_command('az group list -g test') is True - assert az._is_az_command('az account show -o json') is True - assert az._is_az_command('az apim list --query') is True + assert az._is_az_command("az group list -g test") is True + assert az._is_az_command("az account show -o json") is True + assert az._is_az_command("az apim list --query") is True def test_is_az_command_just_az(self): - assert az._is_az_command('az') is True + assert az._is_az_command("az") is True def test_is_not_az_command(self): - assert az._is_az_command('echo hello') is False - assert az._is_az_command('python script.py') is False - assert az._is_az_command('azurecli list') is False - assert az._is_az_command('') is False + assert az._is_az_command("echo hello") is False + assert az._is_az_command("python script.py") is False + assert az._is_az_command("azurecli list") is False + assert az._is_az_command("") is False class TestMaybeAddAzDebugFlag: """Test adding --debug flag to az commands.""" def test_add_debug_flag_disabled_logging(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: False) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: False) - command = 'az group list' + command = "az group list" result = az._maybe_add_az_debug_flag(command) - assert '--debug' not in result + assert "--debug" not in result assert result == command def test_add_debug_flag_non_az_command(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) - command = 'python script.py' + command = "python script.py" result = az._maybe_add_az_debug_flag(command) - assert '--debug' not in result + assert "--debug" not in result def test_add_debug_flag_already_present(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) - command = 'az group list --debug' + command = "az group list --debug" result = az._maybe_add_az_debug_flag(command) - assert result.count('--debug') == 1 + assert result.count("--debug") == 1 def test_add_debug_flag_before_pipe(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) - command = 'az group list | grep test' + command = "az group list | grep test" result = az._maybe_add_az_debug_flag(command) - assert '--debug' in result - assert result.index('--debug') < result.index('|') + assert "--debug" in result + assert result.index("--debug") < result.index("|") def test_add_debug_flag_before_redirect(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) - command = 'az group list > output.txt' + command = "az group list > output.txt" result = az._maybe_add_az_debug_flag(command) - assert '--debug' in result - assert result.index('--debug') < result.index('>') + assert "--debug" in result + assert result.index("--debug") < result.index(">") def test_add_debug_flag_before_or_operator(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) - command = 'az group list || echo failed' + command = "az group list || echo failed" result = az._maybe_add_az_debug_flag(command) - assert '--debug' in result + assert "--debug" in result def test_add_debug_flag_before_and_operator(self, monkeypatch): - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) - command = 'az group list && az account show' + command = "az group list && az account show" result = az._maybe_add_az_debug_flag(command) - assert '--debug' in result + assert "--debug" in result class TestExtractAzCliErrorMessage: @@ -1849,35 +1864,35 @@ class TestExtractAzCliErrorMessage: def test_extract_json_error_with_error_object(self): output = '{"error": {"message": "Resource not found"}}' result = az._extract_az_cli_error_message(output) - assert result == 'Resource not found' + assert result == "Resource not found" def test_extract_json_error_with_message_field(self): output = '{"message": "Operation failed"}' result = az._extract_az_cli_error_message(output) - assert result == 'Operation failed' + assert result == "Operation failed" def test_extract_error_prefix(self): - output = 'ERROR: Resource group not found' + output = "ERROR: Resource group not found" result = az._extract_az_cli_error_message(output) - assert result == 'Resource group not found' + assert result == "Resource group not found" def test_extract_az_error_prefix(self): - output = 'az: error: Invalid argument' + output = "az: error: Invalid argument" result = az._extract_az_cli_error_message(output) - assert result == 'Invalid argument' + assert result == "Invalid argument" def test_extract_code_and_message(self): - output = 'Code: AuthenticationFailed\nMessage: Token expired' + output = "Code: AuthenticationFailed\nMessage: Token expired" result = az._extract_az_cli_error_message(output) - assert result == 'AuthenticationFailed: Token expired' + assert result == "AuthenticationFailed: Token expired" def test_extract_message_only(self): - output = 'Some other line\nMessage: Parameter is required' + output = "Some other line\nMessage: Parameter is required" result = az._extract_az_cli_error_message(output) - assert 'Parameter is required' in result or result == 'Message: Parameter is required' + assert "Parameter is required" in result or result == "Message: Parameter is required" def test_extract_empty_output(self): - result = az._extract_az_cli_error_message('') + result = az._extract_az_cli_error_message("") assert not result def test_extract_none_output(self): @@ -1885,27 +1900,27 @@ def test_extract_none_output(self): assert not result def test_extract_with_ansi_codes(self): - output = '\x1b[31mERROR: \x1b[0mOperation failed' + output = "\x1b[31mERROR: \x1b[0mOperation failed" result = az._extract_az_cli_error_message(output) - assert 'Operation failed' in result + assert "Operation failed" in result def test_extract_json_in_middle_of_text(self): output = 'Some output\n{"error": {"message": "Actual error"}}\nMore text' result = az._extract_az_cli_error_message(output) - assert result == 'Actual error' + assert result == "Actual error" def test_extract_with_traceback(self): output = 'Traceback (most recent call last):\n File "test.py"\nError: Something failed' result = az._extract_az_cli_error_message(output) - assert 'Traceback' not in result + assert "Traceback" not in result def test_extract_warning_ignored(self): - output = 'WARNING: Something\nERROR: Actual error' + output = "WARNING: Something\nERROR: Actual error" result = az._extract_az_cli_error_message(output) - assert result == 'Actual error' + assert result == "Actual error" def test_extract_only_empty_lines(self): - output = '\n\nTraceback (most recent call last):\n' + output = "\n\nTraceback (most recent call last):\n" result = az._extract_az_cli_error_message(output) assert not result @@ -1916,13 +1931,13 @@ class TestFormatDuration: def test_format_duration_seconds(self): start_time = time.time() - 5 result = az._format_duration(start_time) - assert '[0m:' in result - assert 's]' in result + assert "[0m:" in result + assert "s]" in result def test_format_duration_minutes_and_seconds(self): start_time = time.time() - 65 result = az._format_duration(start_time) - assert '[1m:' in result + assert "[1m:" in result class TestLooksLikeJson: @@ -1933,76 +1948,76 @@ def test_looks_like_json_with_object(self): assert az._looks_like_json(' {"key": "value"}') is True def test_looks_like_json_with_array(self): - assert az._looks_like_json('[1, 2, 3]') is True - assert az._looks_like_json(' [1, 2, 3]') is True + assert az._looks_like_json("[1, 2, 3]") is True + assert az._looks_like_json(" [1, 2, 3]") is True def test_looks_like_json_with_invalid(self): assert az._looks_like_json('{"key": value}') is False - assert az._looks_like_json('not json') is False + assert az._looks_like_json("not json") is False def test_looks_like_json_empty(self): - assert az._looks_like_json('') is False + assert az._looks_like_json("") is False def test_looks_like_json_only_whitespace(self): - assert az._looks_like_json(' ') is False + assert az._looks_like_json(" ") is False def test_looks_like_json_xml(self): - assert az._looks_like_json('') is False + assert az._looks_like_json("") is False def test_looks_like_json_plain_text(self): - assert az._looks_like_json('plain text') is False + assert az._looks_like_json("plain text") is False class TestRunFunctionEdgeCases: """Test edge cases in the run() function.""" def test_run_with_stderr_only(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_command', 'print_error', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_command", "print_error", "print_ok"]) mock_completed = Mock() mock_completed.returncode = 0 - mock_completed.stdout = '' - mock_completed.stderr = 'Some warning' + mock_completed.stdout = "" + mock_completed.stderr = "Some warning" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_completed) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_completed) - result = az.run('echo test') + result = az.run("echo test") assert result.success is True def test_run_with_empty_output(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_command', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_command", "print_ok"]) mock_completed = Mock() mock_completed.returncode = 0 - mock_completed.stdout = '' - mock_completed.stderr = '' + mock_completed.stdout = "" + mock_completed.stderr = "" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_completed) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_completed) - result = az.run('echo test') + result = az.run("echo test") assert result.success is True assert not result.text def test_run_with_none_stdout_stderr(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_command', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_command", "print_ok"]) mock_completed = Mock() mock_completed.returncode = 0 mock_completed.stdout = None mock_completed.stderr = None - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_completed) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_completed) - result = az.run('echo test') + result = az.run("echo test") assert result.success is True def test_run_with_non_az_command(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_command', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_command", "print_ok"]) mock_completed = Mock() mock_completed.returncode = 0 - mock_completed.stdout = 'output' - mock_completed.stderr = '' + mock_completed.stdout = "output" + mock_completed.stderr = "" run_calls = [] @@ -2010,35 +2025,35 @@ def mock_run(*args, **kwargs): run_calls.append((args, kwargs)) return mock_completed - monkeypatch.setattr('azure_resources.subprocess.run', mock_run) + monkeypatch.setattr("azure_resources.subprocess.run", mock_run) - result = az.run('echo test') + result = az.run("echo test") assert result.success is True assert len(run_calls) == 1 def test_run_with_json_stdout(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_command', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_command", "print_ok"]) mock_completed = Mock() mock_completed.returncode = 0 mock_completed.stdout = '{"key": "value"}' - mock_completed.stderr = '' + mock_completed.stderr = "" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_completed) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_completed) - result = az.run('az group list -o json') + result = az.run("az group list -o json") assert result.success is True - assert result.json_data == {'key': 'value'} + assert result.json_data == {"key": "value"} def test_run_command_with_special_characters(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_command', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_command", "print_ok"]) mock_completed = Mock() mock_completed.returncode = 0 - mock_completed.stdout = 'output' - mock_completed.stderr = '' + mock_completed.stdout = "output" + mock_completed.stderr = "" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_completed) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_completed) result = az.run('echo "test with spaces" && echo done') assert result.success is True @@ -2048,7 +2063,7 @@ class TestGetAccountInfoEdgeCases: """Test edge cases in get_account_info().""" def test_get_account_info_partial_failure(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val', 'print_error']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_error"]) account_output = _create_account_output() @@ -2060,64 +2075,64 @@ def test_get_account_info_partial_failure(self, monkeypatch): def mock_run(cmd, *args, **kwargs): call_count[0] += 1 - if 'account show' in cmd: + if "account show" in cmd: return account_output return ad_output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) with pytest.raises(Exception): az.get_account_info() def test_get_account_info_success(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val', 'print_error']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_error"]) account_output = _create_account_output() ad_output = Mock() ad_output.success = True - ad_output.json_data = {'id': 'user-123'} + ad_output.json_data = {"id": "user-123"} call_count = [0] def mock_run(cmd, *args, **kwargs): call_count[0] += 1 - if 'account show' in cmd: + if "account show" in cmd: return account_output return ad_output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) user, user_id, tenant, subscription = az.get_account_info() - assert user == 'test.user@example.com' - assert user_id == 'user-123' - assert tenant == 'tenant-123' - assert subscription == 'sub-123' + assert user == "test.user@example.com" + assert user_id == "user-123" + assert tenant == "tenant-123" + assert subscription == "sub-123" class TestGetDeploymentName: """Test get_deployment_name function.""" def test_get_deployment_name_custom_directory(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) - result = az.get_deployment_name('my-sample') - assert 'deploy-my-sample-' in result + result = az.get_deployment_name("my-sample") + assert "deploy-my-sample-" in result class TestGetFrontdoorUrl: """Test get_frontdoor_url function.""" def test_get_frontdoor_url_not_found(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) mock_output = Mock() mock_output.success = False mock_output.json_data = None - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.get_frontdoor_url(INFRASTRUCTURE.SIMPLE_APIM, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.SIMPLE_APIM, "test-rg") assert result is None @@ -2125,16 +2140,16 @@ class TestGetApimUrl: """Test get_apim_url function.""" def test_get_apim_url_no_results(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) mock_output = Mock() mock_output.success = True mock_output.json_data = [] mock_output.is_json = True - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.get_apim_url('test-rg') + result = az.get_apim_url("test-rg") assert result is None @@ -2142,56 +2157,51 @@ class TestListApimSubscriptions: """Test list_apim_subscriptions function.""" def test_list_apim_subscriptions_success(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) mock_output = Mock() mock_output.success = True - mock_output.json_data = { - 'value': [ - {'id': 'sub-1', 'displayName': 'Subscription 1'}, - {'id': 'sub-2', 'displayName': 'Subscription 2'} - ] - } + mock_output.json_data = {"value": [{"id": "sub-1", "displayName": "Subscription 1"}, {"id": "sub-2", "displayName": "Subscription 2"}]} mock_output.is_json = True - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.list_apim_subscriptions('test-apim', 'test-rg') + result = az.list_apim_subscriptions("test-apim", "test-rg") assert len(result) == 2 - assert result[0]['id'] == 'sub-1' + assert result[0]["id"] == "sub-1" def test_list_apim_subscriptions_empty(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) mock_output = Mock() mock_output.success = True - mock_output.json_data = {'value': []} + mock_output.json_data = {"value": []} mock_output.is_json = True - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.list_apim_subscriptions('test-apim', 'test-rg') + result = az.list_apim_subscriptions("test-apim", "test-rg") assert result == [] def test_list_apim_subscriptions_failure(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) mock_output = Mock() mock_output.success = False mock_output.json_data = None - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.list_apim_subscriptions('test-apim', 'test-rg') + result = az.list_apim_subscriptions("test-apim", "test-rg") assert result == [] def test_list_subscriptions_with_empty_params(self): """Test list_apim_subscriptions returns empty list for invalid params.""" - result = az.list_apim_subscriptions('', 'rg') + result = az.list_apim_subscriptions("", "rg") assert result == [] - result = az.list_apim_subscriptions('apim', '') + result = az.list_apim_subscriptions("apim", "") assert result == [] def test_list_subscriptions_account_show_fails(self, monkeypatch): @@ -2199,11 +2209,11 @@ def test_list_subscriptions_account_show_fails(self, monkeypatch): mock_output = Mock() mock_output.success = False - mock_output.text = '' + mock_output.text = "" - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.list_apim_subscriptions('apim', 'rg') + result = az.list_apim_subscriptions("apim", "rg") assert result == [] @@ -2217,17 +2227,17 @@ def mock_run(cmd, *args, **kwargs): if call_count[0] == 1: # First call is account show output = Mock() output.success = True - output.text = 'sub-123' + output.text = "sub-123" return output else: # Second call is REST API output = Mock() output.success = True - output.json_data = {'value': 'not-a-list'} + output.json_data = {"value": "not-a-list"} return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.list_apim_subscriptions('apim', 'rg') + result = az.list_apim_subscriptions("apim", "rg") assert result == [] @@ -2236,12 +2246,12 @@ class TestGetAppGwEndpoint: """Test get_appgw_endpoint function.""" def test_get_appgw_endpoint_not_found(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_warning']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_warning"]) - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(False, 'No gateways found') + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(False, "No gateways found") - hostname, ip = az.get_appgw_endpoint('test-rg') + hostname, ip = az.get_appgw_endpoint("test-rg") assert hostname is None assert ip is None @@ -2255,11 +2265,11 @@ def test_get_unique_suffix_empty_rg(self, monkeypatch): def mock_run(cmd, *args, **kwargs): output = Mock() output.success = True - output.text = 'abcd1234efgh5' + output.text = "abcd1234efgh5" return output - monkeypatch.setattr('azure_resources.run', mock_run) - result = az.get_unique_suffix_for_resource_group('') + monkeypatch.setattr("azure_resources.run", mock_run) + result = az.get_unique_suffix_for_resource_group("") assert isinstance(result, str) @@ -2267,15 +2277,15 @@ class TestFindInfrastructureInstances: """Test find_infrastructure_instances function.""" def test_find_infrastructure_instances_no_matches(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val', 'print_message']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_message"]) def mock_run(cmd, *args, **kwargs): output = Mock() output.success = True - output.text = '' + output.text = "" return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) result = az.find_infrastructure_instances(INFRASTRUCTURE.SIMPLE_APIM) assert not result @@ -2289,7 +2299,7 @@ def test_find_with_invalid_index_format(self, monkeypatch): apim-infra-simple-apim-abc apim-infra-simple-apim-2""" - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) result = az.find_infrastructure_instances(INFRASTRUCTURE.SIMPLE_APIM) @@ -2303,50 +2313,50 @@ class TestGetInfraRgName: """Test get_infra_rg_name function.""" def test_get_infra_rg_name_with_index(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) result = az.get_infra_rg_name(INFRASTRUCTURE.SIMPLE_APIM, 1) - assert 'simple-apim' in result - assert '1' in result + assert "simple-apim" in result + assert "1" in result def test_get_infra_rg_name_without_index(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) result = az.get_infra_rg_name(INFRASTRUCTURE.APIM_ACA) - assert 'apim-aca' in result + assert "apim-aca" in result class TestGetRgName: """Test get_rg_name function.""" def test_get_rg_name_with_index(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) - result = az.get_rg_name('my-sample', 2) - assert 'my-sample' in result - assert '2' in result + result = az.get_rg_name("my-sample", 2) + assert "my-sample" in result + assert "2" in result def test_get_rg_name_without_index(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) - result = az.get_rg_name('test-deployment') - assert 'test-deployment' in result - assert '-test-deployment' in result + result = az.get_rg_name("test-deployment") + assert "test-deployment" in result + assert "-test-deployment" in result class TestCheckApimBlobPermissions: """Test check_apim_blob_permissions function.""" def test_check_apim_blob_permissions_no_principal_id(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val', 'print_info', 'print_error', 'print_warning']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_info", "print_error", "print_warning"]) mock_output = Mock() mock_output.success = False mock_output.json_data = None - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.check_apim_blob_permissions('apim', 'storage', 'rg', max_wait_minutes=1) + result = az.check_apim_blob_permissions("apim", "storage", "rg", max_wait_minutes=1) assert result is False @@ -2354,28 +2364,28 @@ class TestCleanupOldJwtSigningKeys: """Test cleanup_old_jwt_signing_keys function.""" def test_cleanup_old_jwt_no_other_keys(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val', 'print_info', 'print_message']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_info", "print_message"]) mock_output = Mock() mock_output.success = True - mock_output.json_data = [{'name': 'JwtSigningKey-authX-123'}] + mock_output.json_data = [{"name": "JwtSigningKey-authX-123"}] mock_output.is_json = True - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-authX-123') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-authX-123") assert isinstance(result, bool) def test_cleanup_old_jwt_list_fails(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_val', 'print_info', 'print_message', 'print_error']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_info", "print_message", "print_error"]) mock_output = Mock() mock_output.success = False mock_output.json_data = None - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-authX-123') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-authX-123") assert result is False @@ -2383,10 +2393,10 @@ class TestGetApimSubscriptionKey: """Test get_apim_subscription_key function.""" def test_get_apim_subscription_key_invalid_params(self): - result = az.get_apim_subscription_key('', 'rg') + result = az.get_apim_subscription_key("", "rg") assert result is None - result = az.get_apim_subscription_key('apim', '') + result = az.get_apim_subscription_key("apim", "") assert result is None @@ -2394,16 +2404,16 @@ class TestGetEndpoints: """Test get_endpoints function.""" def test_get_endpoints_with_simple_apim(self, monkeypatch): - suppress_module_functions(monkeypatch, az, ['print_message', 'print_val']) + suppress_module_functions(monkeypatch, az, ["print_message", "print_val"]) - monkeypatch.setattr('azure_resources.get_frontdoor_url', lambda *a, **k: None) - monkeypatch.setattr('azure_resources.get_apim_url', lambda *a, **k: 'https://apim.azure-api.net') - monkeypatch.setattr('azure_resources.get_appgw_endpoint', lambda *a, **k: (None, None)) + monkeypatch.setattr("azure_resources.get_frontdoor_url", lambda *a, **k: None) + monkeypatch.setattr("azure_resources.get_apim_url", lambda *a, **k: "https://apim.azure-api.net") + monkeypatch.setattr("azure_resources.get_appgw_endpoint", lambda *a, **k: (None, None)) - result = az.get_endpoints(INFRASTRUCTURE.SIMPLE_APIM, 'test-rg') + result = az.get_endpoints(INFRASTRUCTURE.SIMPLE_APIM, "test-rg") assert result is not None - assert result.apim_endpoint_url == 'https://apim.azure-api.net' + assert result.apim_endpoint_url == "https://apim.azure-api.net" class TestRunFunction: @@ -2412,93 +2422,93 @@ class TestRunFunction: def test_run_with_non_json_stdout_in_debug(self, monkeypatch): """Test that non-JSON stdout is printed in debug mode.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_error', 'print_plain']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_error", "print_plain"]) - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) mock_result = Mock() mock_result.returncode = 0 - mock_result.stdout = 'Plain text output that is not JSON' - mock_result.stderr = '' + mock_result.stdout = "Plain text output that is not JSON" + mock_result.stderr = "" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_result) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_result) - result = az.run('az test command') + result = az.run("az test command") assert result.success is True - assert 'Plain text output' in result.text + assert "Plain text output" in result.text def test_run_with_stderr_and_debug_disabled(self, monkeypatch): """Test that stderr is printed when debug is disabled.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_error', 'print_plain']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_error", "print_plain"]) - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: False) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: False) mock_result = Mock() mock_result.returncode = 1 - mock_result.stdout = '' - mock_result.stderr = 'Error message in stderr' + mock_result.stdout = "" + mock_result.stderr = "Error message in stderr" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_result) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_result) - result = az.run('az test command') + result = az.run("az test command") assert result.success is False def test_run_failure_with_no_normalized_error_and_debug_enabled(self, monkeypatch): """Test run failure when error extraction returns empty and debug is enabled.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_error', 'print_plain']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_error", "print_plain"]) - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) - monkeypatch.setattr('azure_resources._extract_az_cli_error_message', lambda *a: '') + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) + monkeypatch.setattr("azure_resources._extract_az_cli_error_message", lambda *a: "") mock_result = Mock() mock_result.returncode = 1 - mock_result.stdout = 'Some error output' - mock_result.stderr = '' + mock_result.stdout = "Some error output" + mock_result.stderr = "" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_result) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_result) - result = az.run('az test command') + result = az.run("az test command") assert result.success is False - assert 'Some error output' in result.text + assert "Some error output" in result.text def test_run_with_stderr_in_debug_mode(self, monkeypatch): """Test that stderr is logged in debug mode.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_error', 'print_plain']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_error", "print_plain"]) - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: True) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: True) mock_result = Mock() mock_result.returncode = 0 mock_result.stdout = '{"result": "success"}' - mock_result.stderr = 'Some warning in stderr' + mock_result.stderr = "Some warning in stderr" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_result) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_result) - result = az.run('az test command') + result = az.run("az test command") assert result.success is True def test_run_success_with_non_json_stdout_not_in_debug(self, monkeypatch): """Test that non-JSON stdout is logged even when debug is disabled.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_error', 'print_plain']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_error", "print_plain"]) - monkeypatch.setattr('azure_resources.is_debug_enabled', lambda: False) + monkeypatch.setattr("azure_resources.is_debug_enabled", lambda: False) mock_result = Mock() mock_result.returncode = 0 - mock_result.stdout = 'Plain text success output' - mock_result.stderr = '' + mock_result.stdout = "Plain text success output" + mock_result.stderr = "" - monkeypatch.setattr('azure_resources.subprocess.run', lambda *a, **k: mock_result) + monkeypatch.setattr("azure_resources.subprocess.run", lambda *a, **k: mock_result) - result = az.run('az test command', 'Success message') + result = az.run("az test command", "Success message") assert result.success is True @@ -2509,15 +2519,15 @@ class TestCleanupJwtSigningKeysEdgeCases: def test_cleanup_with_empty_key_list(self, monkeypatch): """Test cleanup when API returns empty string.""" - suppress_module_functions(monkeypatch, az, ['print_info', 'print_error']) + suppress_module_functions(monkeypatch, az, ["print_info", "print_error"]) mock_output = Mock() mock_output.success = True - mock_output.text = '' + mock_output.text = "" - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-authX-123') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-authX-123") assert result is True @@ -2528,18 +2538,18 @@ class TestCreateResourceGroupWithTags: def test_create_resource_group_with_tags(self, monkeypatch): """Test creating resource group with additional tags.""" - suppress_module_functions(monkeypatch, az, ['print_val', 'print_ok']) + suppress_module_functions(monkeypatch, az, ["print_val", "print_ok"]) calls = [] def mock_run(cmd, *args, **kwargs): calls.append(cmd) - return Output(True, 'Resource group created') + return Output(True, "Resource group created") - monkeypatch.setattr('azure_resources.run', mock_run) - monkeypatch.setattr('azure_resources.does_resource_group_exist', lambda *a, **k: False) + monkeypatch.setattr("azure_resources.run", mock_run) + monkeypatch.setattr("azure_resources.does_resource_group_exist", lambda *a, **k: False) - az.create_resource_group('test-rg', 'eastus', tags={'environment': 'test', 'owner': 'user'}) + az.create_resource_group("test-rg", "eastus", tags={"environment": "test", "owner": "user"}) assert len(calls) == 1 assert 'environment="test"' in calls[0] @@ -2559,30 +2569,27 @@ def mock_run(cmd, *args, **kwargs): if call_count[0] == 1: # account show output = Mock() output.success = True - output.text = 'sub-123' + output.text = "sub-123" return output - elif 'subscriptions?' in cmd: # list subscriptions + elif "subscriptions?" in cmd: # list subscriptions output = Mock() output.success = True output.json_data = { - 'value': [ - {'name': 'sid-1', 'properties': {'state': 'suspended'}}, - {'name': 'sid-2', 'properties': {'state': 'cancelled'}} - ] + "value": [{"name": "sid-1", "properties": {"state": "suspended"}}, {"name": "sid-2", "properties": {"state": "cancelled"}}] } return output else: # listSecrets output = Mock() output.success = True - output.json_data = {'primaryKey': 'key-123'} + output.json_data = {"primaryKey": "key-123"} return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) # Should use first available subscription even if not active - result = az.get_apim_subscription_key('apim', 'rg') + result = az.get_apim_subscription_key("apim", "rg") - assert result == 'key-123' + assert result == "key-123" def test_get_key_secrets_call_fails(self, monkeypatch): """Test get_apim_subscription_key when secrets retrieval fails.""" @@ -2594,14 +2601,12 @@ def mock_run(cmd, *args, **kwargs): if call_count[0] == 1: # account show output = Mock() output.success = True - output.text = 'sub-123' + output.text = "sub-123" return output - elif 'subscriptions?' in cmd: # list subscriptions + elif "subscriptions?" in cmd: # list subscriptions output = Mock() output.success = True - output.json_data = { - 'value': [{'name': 'sid-1', 'properties': {'state': 'active'}}] - } + output.json_data = {"value": [{"name": "sid-1", "properties": {"state": "active"}}]} return output else: # listSecrets fails output = Mock() @@ -2609,9 +2614,9 @@ def mock_run(cmd, *args, **kwargs): output.json_data = None return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.get_apim_subscription_key('apim', 'rg') + result = az.get_apim_subscription_key("apim", "rg") assert result is None @@ -2625,24 +2630,22 @@ def mock_run(cmd, *args, **kwargs): if call_count[0] == 1: # account show output = Mock() output.success = True - output.text = 'sub-123' + output.text = "sub-123" return output - elif 'subscriptions?' in cmd: # list subscriptions + elif "subscriptions?" in cmd: # list subscriptions output = Mock() output.success = True - output.json_data = { - 'value': [{'name': 'sid-1', 'properties': {'state': 'active'}}] - } + output.json_data = {"value": [{"name": "sid-1", "properties": {"state": "active"}}]} return output else: # listSecrets returns empty key output = Mock() output.success = True - output.json_data = {'primaryKey': ' '} + output.json_data = {"primaryKey": " "} return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.get_apim_subscription_key('apim', 'rg') + result = az.get_apim_subscription_key("apim", "rg") assert result is None @@ -2651,11 +2654,11 @@ def test_get_key_account_show_fails(self, monkeypatch): mock_output = Mock() mock_output.success = False - mock_output.text = '' + mock_output.text = "" - monkeypatch.setattr('azure_resources.run', lambda *a, **k: mock_output) + monkeypatch.setattr("azure_resources.run", lambda *a, **k: mock_output) - result = az.get_apim_subscription_key('apim', 'rg') + result = az.get_apim_subscription_key("apim", "rg") assert result is None @@ -2666,21 +2669,21 @@ def test_get_key_list_subscriptions_returns_empty(self, monkeypatch): def mock_run(cmd, *args, **kwargs): call_count[0] += 1 - if 'account show' in cmd: + if "account show" in cmd: output = Mock() output.success = True - output.text = 'sub-123' + output.text = "sub-123" return output - elif 'subscriptions?' in cmd: + elif "subscriptions?" in cmd: output = Mock() output.success = True - output.json_data = {'value': []} + output.json_data = {"value": []} return output - return Mock(success=False, text='') + return Mock(success=False, text="") - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.get_apim_subscription_key('apim', 'rg') + result = az.get_apim_subscription_key("apim", "rg") assert result is None @@ -2691,21 +2694,22 @@ class TestGetRgNameWithIndex: def test_get_rg_name_formats_with_index(self, monkeypatch): """Test get_rg_name properly formats name with index.""" - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) - result = az.get_rg_name('my-sample', 5) + result = az.get_rg_name("my-sample", 5) - assert 'apim-sample-my-sample-5' == result + assert "apim-sample-my-sample-5" == result def test_get_rg_name_with_none_index(self, monkeypatch): """Test get_rg_name with explicit None index.""" - suppress_module_functions(monkeypatch, az, ['print_val']) + suppress_module_functions(monkeypatch, az, ["print_val"]) + + result = az.get_rg_name("my-sample", None) - result = az.get_rg_name('my-sample', None) + assert "apim-sample-my-sample" == result + assert not result.count("-5") # Should not have index suffix - assert 'apim-sample-my-sample' == result - assert not result.count('-5') # Should not have index suffix # Test run() method success = not completed.returncode branch (returncode = 0) class TestRunMethodBranches: @@ -2713,32 +2717,34 @@ class TestRunMethodBranches: def test_run_success_returncode_zero(self, monkeypatch): """Test run() when subprocess returncode is 0 (success = True).""" + def mock_run(*args, **kwargs): completed = Mock() - completed.stdout = 'output text' + completed.stdout = "output text" completed.stderr = None completed.returncode = 0 return completed - monkeypatch.setattr('subprocess.run', mock_run) + monkeypatch.setattr("subprocess.run", mock_run) - result = az.run('echo test', ok_message='Success') + result = az.run("echo test", ok_message="Success") assert result.success is True - assert 'output text' in result.text + assert "output text" in result.text def test_run_failure_nonzero_returncode(self, monkeypatch): """Test run() when subprocess returncode is non-zero (success = False).""" + def mock_run(*args, **kwargs): completed = Mock() - completed.stdout = 'some output' - completed.stderr = 'error output' + completed.stdout = "some output" + completed.stderr = "error output" completed.returncode = 1 return completed - monkeypatch.setattr('subprocess.run', mock_run) + monkeypatch.setattr("subprocess.run", mock_run) - result = az.run('bad command', error_message='Failed') + result = az.run("bad command", error_message="Failed") assert result.success is False @@ -2746,22 +2752,23 @@ def mock_run(*args, **kwargs): # Test cleanup_old_jwt_signing_keys with different key counts def test_cleanup_old_jwt_signing_keys_with_multiple_old_keys(monkeypatch): """Test cleanup_old_jwt_signing_keys when there are multiple old keys to delete.""" + def mock_run(cmd, *args, **kwargs): - if 'list' in cmd: + if "list" in cmd: output = Mock() output.success = True - output.text = 'JwtSigningKey-test-sample-1\nJwtSigningKey-test-sample-2\nJwtSigningKey-test-sample-3\nJwtSigningKey-other-1' + output.text = "JwtSigningKey-test-sample-1\nJwtSigningKey-test-sample-2\nJwtSigningKey-test-sample-3\nJwtSigningKey-other-1" return output - elif 'delete' in cmd and 'test-sample-1' in cmd: + elif "delete" in cmd and "test-sample-1" in cmd: return Mock(success=True) - elif 'delete' in cmd and 'test-sample-2' in cmd: + elif "delete" in cmd and "test-sample-2" in cmd: return Mock(success=True) else: return Mock(success=True) - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-test-sample-3') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-test-sample-3") assert result is True @@ -2769,20 +2776,21 @@ def mock_run(cmd, *args, **kwargs): # Test cleanup_old_jwt_signing_keys when current key is first def test_cleanup_old_jwt_signing_keys_current_is_first(monkeypatch): """Test cleanup_old_jwt_signing_keys when current key is the first one.""" + def mock_run(cmd, *args, **kwargs): - if 'list' in cmd: + if "list" in cmd: output = Mock() output.success = True - output.text = 'JwtSigningKey-test-sample-1\nJwtSigningKey-test-sample-2' + output.text = "JwtSigningKey-test-sample-1\nJwtSigningKey-test-sample-2" return output - elif 'delete' in cmd: + elif "delete" in cmd: return Mock(success=True) else: return Mock(success=True) - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.cleanup_old_jwt_signing_keys('apim', 'rg', 'JwtSigningKey-test-sample-1') + result = az.cleanup_old_jwt_signing_keys("apim", "rg", "JwtSigningKey-test-sample-1") assert result is True @@ -2790,16 +2798,17 @@ def mock_run(cmd, *args, **kwargs): # Test get_frontdoor_url with empty hostname def test_get_frontdoor_url_endpoint_no_hostname(monkeypatch): """Test get_frontdoor_url when endpoint has no hostname.""" + def mock_run(cmd, *args, **kwargs): - if 'profile list' in cmd: - return Mock(success=True, json_data=[{'name': 'profile-123'}]) - elif 'endpoint list' in cmd: - return Mock(success=True, json_data=[{'hostName': None}]) + if "profile list" in cmd: + return Mock(success=True, json_data=[{"name": "profile-123"}]) + elif "endpoint list" in cmd: + return Mock(success=True, json_data=[{"hostName": None}]) return Mock(success=False) - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.get_frontdoor_url('rg', INFRASTRUCTURE.AFD_APIM_PE) + result = az.get_frontdoor_url("rg", INFRASTRUCTURE.AFD_APIM_PE) assert result is None @@ -2807,14 +2816,15 @@ def mock_run(cmd, *args, **kwargs): # Test get_frontdoor_url with empty profile name def test_get_frontdoor_url_empty_profile_name(monkeypatch): """Test get_frontdoor_url when profile name is empty.""" + def mock_run(cmd, *args, **kwargs): - if 'profile list' in cmd: - return Mock(success=True, json_data=[{'name': ''}]) + if "profile list" in cmd: + return Mock(success=True, json_data=[{"name": ""}]) return Mock(success=False) - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.get_frontdoor_url('rg', INFRASTRUCTURE.AFD_APIM_PE) + result = az.get_frontdoor_url("rg", INFRASTRUCTURE.AFD_APIM_PE) assert result is None @@ -2822,15 +2832,16 @@ def mock_run(cmd, *args, **kwargs): # Test list_apim_subscriptions with non-dict json_data def test_list_apim_subscriptions_invalid_json_data(monkeypatch): """Test list_apim_subscriptions when json_data is not a dict.""" + def mock_run(cmd, *args, **kwargs): output = Mock() output.success = True output.json_data = None return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.list_apim_subscriptions('apim', 'rg') + result = az.list_apim_subscriptions("apim", "rg") assert result == [] @@ -2838,15 +2849,16 @@ def mock_run(cmd, *args, **kwargs): # Test list_apim_subscriptions with missing value key def test_list_apim_subscriptions_missing_value_key(monkeypatch): """Test list_apim_subscriptions when value key is missing in response.""" + def mock_run(cmd, *args, **kwargs): output = Mock() output.success = True - output.json_data = {'items': []} + output.json_data = {"items": []} return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - result = az.list_apim_subscriptions('apim', 'rg') + result = az.list_apim_subscriptions("apim", "rg") assert result == [] @@ -2854,19 +2866,16 @@ def mock_run(cmd, *args, **kwargs): # Test get_appgw_endpoint with empty hostname def test_get_appgw_endpoint_empty_hostname(monkeypatch): """Test get_appgw_endpoint when hostname is empty in listener.""" + def mock_run(cmd, *args, **kwargs): output = Mock() output.success = True - output.json_data = [{ - 'name': 'appgw-123', - 'httpListeners': [{'hostName': ''}], - 'frontendIPConfigurations': [] - }] + output.json_data = [{"name": "appgw-123", "httpListeners": [{"hostName": ""}], "frontendIPConfigurations": []}] return output - monkeypatch.setattr('azure_resources.run', mock_run) + monkeypatch.setattr("azure_resources.run", mock_run) - hostname, public_ip = az.get_appgw_endpoint('rg') + hostname, public_ip = az.get_appgw_endpoint("rg") assert hostname is None assert public_ip is None @@ -2875,25 +2884,21 @@ def mock_run(cmd, *args, **kwargs): def test_run_with_az_lock_acquired(monkeypatch): """Test run function when az command lock is acquired (not None path).""" - suppress_module_functions(monkeypatch, az, ['print_command']) + suppress_module_functions(monkeypatch, az, ["print_command"]) - with patch('azure_resources.is_debug_enabled', return_value=False): - with patch('azure_resources._is_az_command', return_value=True): - with patch('azure_resources._AZ_CLI_LOCK') as mock_lock: + with patch("azure_resources.is_debug_enabled", return_value=False): + with patch("azure_resources._is_az_command", return_value=True): + with patch("azure_resources._AZ_CLI_LOCK") as mock_lock: mock_lock.__enter__ = Mock(return_value=None) mock_lock.__exit__ = Mock(return_value=None) - with patch('subprocess.run') as mock_subprocess: - mock_subprocess.return_value = Mock( - returncode=0, - stdout='success output', - stderr='' - ) + with patch("subprocess.run") as mock_subprocess: + mock_subprocess.return_value = Mock(returncode=0, stdout="success output", stderr="") - result = az.run('az group list') + result = az.run("az group list") assert result.success - assert result.text == 'success output' + assert result.text == "success output" mock_lock.__enter__.assert_called_once() mock_lock.__exit__.assert_called_once() @@ -2901,56 +2906,46 @@ def test_run_with_az_lock_acquired(monkeypatch): def test_run_with_explicit_log_command_false(monkeypatch): """Test run function with explicit log_command=False to hit line 203.""" - suppress_module_functions(monkeypatch, az, ['print_command']) + suppress_module_functions(monkeypatch, az, ["print_command"]) - with patch('azure_resources.is_debug_enabled', return_value=False): - with patch('subprocess.run') as mock_subprocess: - mock_subprocess.return_value = Mock( - returncode=0, - stdout='test output', - stderr='' - ) + with patch("azure_resources.is_debug_enabled", return_value=False): + with patch("subprocess.run") as mock_subprocess: + mock_subprocess.return_value = Mock(returncode=0, stdout="test output", stderr="") - result = az.run('echo test', log_command=False) + result = az.run("echo test", log_command=False) assert result.success - assert result.text == 'test output' + assert result.text == "test output" def test_run_with_explicit_log_command_true(monkeypatch): """Test run function with explicit log_command=True to hit line 203.""" - suppress_module_functions(monkeypatch, az, ['print_command']) + suppress_module_functions(monkeypatch, az, ["print_command"]) - with patch('azure_resources.is_debug_enabled', return_value=False): - with patch('subprocess.run') as mock_subprocess: - mock_subprocess.return_value = Mock( - returncode=0, - stdout='test output', - stderr='' - ) + with patch("azure_resources.is_debug_enabled", return_value=False): + with patch("subprocess.run") as mock_subprocess: + mock_subprocess.return_value = Mock(returncode=0, stdout="test output", stderr="") - result = az.run('echo test', log_command=True) + result = az.run("echo test", log_command=True) assert result.success - assert result.text == 'test output' + assert result.text == "test output" def test_cleanup_old_jwt_signing_keys_no_deletions(monkeypatch): """Test cleanup when all keys are kept (deleted_count == 0).""" - current_key = 'JwtSigningKey-authx-1234567890' + current_key = "JwtSigningKey-authx-1234567890" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_info', 'print_message']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_info", "print_message"]) - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: mock_run.side_effect = [ - Output(True, json.dumps([ - {'name': current_key} - ])), + Output(True, json.dumps([{"name": current_key}])), ] - result = az.cleanup_old_jwt_signing_keys('test-apim', 'test-rg', current_key) + result = az.cleanup_old_jwt_signing_keys("test-apim", "test-rg", current_key) assert result is True @@ -2958,15 +2953,15 @@ def test_cleanup_old_jwt_signing_keys_no_deletions(monkeypatch): def test_get_frontdoor_url_no_hostname_in_endpoint(monkeypatch): """Test when Front Door endpoint exists but has no hostname.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_warning', 'print_val']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_warning", "print_val"]) - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: mock_run.side_effect = [ - Output(True, json.dumps([{'name': 'afd-profile'}])), - Output(True, json.dumps([{'name': 'endpoint1', 'hostName': None}])), + Output(True, json.dumps([{"name": "afd-profile"}])), + Output(True, json.dumps([{"name": "endpoint1", "hostName": None}])), ] - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert result is None @@ -2974,15 +2969,15 @@ def test_get_frontdoor_url_no_hostname_in_endpoint(monkeypatch): def test_get_frontdoor_url_empty_hostname(monkeypatch): """Test when Front Door endpoint has empty hostname string.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_warning', 'print_val']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_warning", "print_val"]) - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: mock_run.side_effect = [ - Output(True, json.dumps([{'name': 'afd-profile'}])), - Output(True, json.dumps([{'name': 'endpoint1', 'hostName': ''}])), + Output(True, json.dumps([{"name": "afd-profile"}])), + Output(True, json.dumps([{"name": "endpoint1", "hostName": ""}])), ] - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert result is None @@ -2990,40 +2985,37 @@ def test_get_frontdoor_url_empty_hostname(monkeypatch): def test_get_appgw_endpoint_no_public_ip_id(monkeypatch): """Test when Application Gateway has no public IP ID in frontend config.""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_warning']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_warning"]) - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, json.dumps([{ - 'name': 'appgw', - 'httpListeners': [{'hostName': 'test.example.com'}], - 'frontendIPConfigurations': [ - {'name': 'config1'} - ] - }])) + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output( + True, + json.dumps([{"name": "appgw", "httpListeners": [{"hostName": "test.example.com"}], "frontendIPConfigurations": [{"name": "config1"}]}]), + ) - hostname, ip = az.get_appgw_endpoint('test-rg') + hostname, ip = az.get_appgw_endpoint("test-rg") - assert hostname == 'test.example.com' + assert hostname == "test.example.com" assert ip is None def test_cleanup_old_jwt_signing_keys_deletion_fails(monkeypatch): """Test cleanup when deletion fails (delete_output.success is False).""" - current_key = 'JwtSigningKey-authx-1234567890' - old_key = 'JwtSigningKey-authx-1111111111' + current_key = "JwtSigningKey-authx-1234567890" + old_key = "JwtSigningKey-authx-1111111111" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_info', 'print_message']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_info", "print_message"]) - with patch('azure_resources.run') as mock_run: + with patch("azure_resources.run") as mock_run: mock_run.side_effect = [ # List of keys (TSV format: one name per line) - Output(True, f'{current_key}\n{old_key}'), + Output(True, f"{current_key}\n{old_key}"), # Delete command fails - Output(False, 'Delete failed'), + Output(False, "Delete failed"), ] - result = az.cleanup_old_jwt_signing_keys('test-apim', 'test-rg', current_key) + result = az.cleanup_old_jwt_signing_keys("test-apim", "test-rg", current_key) assert result is True # Verify the run was called twice (list + delete) @@ -3033,12 +3025,12 @@ def test_cleanup_old_jwt_signing_keys_deletion_fails(monkeypatch): def test_get_frontdoor_url_no_profile_name(monkeypatch): """Test when Front Door profile has no name (line 688 False branch).""" - suppress_module_functions(monkeypatch, az, ['print_ok', 'print_warning']) + suppress_module_functions(monkeypatch, az, ["print_ok", "print_warning"]) - with patch('azure_resources.run') as mock_run: - mock_run.return_value = Output(True, json.dumps([{'name': ''}])) + with patch("azure_resources.run") as mock_run: + mock_run.return_value = Output(True, json.dumps([{"name": ""}])) - result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'test-rg') + result = az.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, "test-rg") assert result is None # Only called once since afd_profile_name is empty diff --git a/tests/python/test_helpers.py b/tests/python/test_helpers.py index e1090ec..1e1e68a 100644 --- a/tests/python/test_helpers.py +++ b/tests/python/test_helpers.py @@ -2,21 +2,21 @@ Shared test helpers, mock factories, and assertion utilities. """ +import builtins import io +import json as json_module import logging -import builtins from collections.abc import Callable -from unittest.mock import Mock, MagicMock, mock_open, patch -import json as json_module +from unittest.mock import MagicMock, Mock, mock_open, patch # APIM Samples imports -from apimtypes import APIM_SKU, APIMNetworkMode, API, APIOperation, PolicyFragment, Output, HTTP_VERB - +from apimtypes import API, APIM_SKU, HTTP_VERB, APIMNetworkMode, APIOperation, Output, PolicyFragment, Region # ------------------------------ # PATCH HELPERS # ------------------------------ + def suppress_module_functions(monkeypatch, module, names: list[str]) -> None: """Suppress noisy functions on a module by replacing them with a no-op.""" @@ -47,8 +47,8 @@ def patch_module_thread_safe_printing( *, print_log: Callable[..., object] | None = None, lock: object | None = None, - lock_attr: str = '_print_lock', - log_attr: str = '_print_log' + lock_attr: str = "_print_lock", + log_attr: str = "_print_log", ) -> object: """Patch a module's internal thread-safe printing primitives. @@ -71,6 +71,7 @@ def patch_module_thread_safe_printing( lock = MagicMock() if print_log is None: + def _noop(*args, **kwargs): return None @@ -81,13 +82,7 @@ def _noop(*args, **kwargs): return lock -def capture_module_print_log( - monkeypatch, - module, - *, - lock_attr: str = '_print_lock', - log_attr: str = '_print_log' -) -> list[dict[str, object]]: +def capture_module_print_log(monkeypatch, module, *, lock_attr: str = "_print_lock", log_attr: str = "_print_log") -> list[dict[str, object]]: """Capture calls to a module's internal print-log function. Returns a list of dict entries with keys: msg, icon, color, kwargs. @@ -96,24 +91,13 @@ def capture_module_print_log( calls: list[dict[str, object]] = [] def _print_log(msg, icon, color, **kwargs): - calls.append({'msg': msg, 'icon': icon, 'color': color, 'kwargs': kwargs}) - - patch_module_thread_safe_printing( - monkeypatch, - module, - print_log=_print_log, - lock_attr=lock_attr, - log_attr=log_attr - ) + calls.append({"msg": msg, "icon": icon, "color": color, "kwargs": kwargs}) + + patch_module_thread_safe_printing(monkeypatch, module, print_log=_print_log, lock_attr=lock_attr, log_attr=log_attr) return calls -def patch_open_for_text_read( - monkeypatch, - *, - match: str | Callable[[str], bool], - read_data: str | None = None, - raises: Exception | None = None -): + +def patch_open_for_text_read(monkeypatch, *, match: str | Callable[[str], bool], read_data: str | None = None, raises: Exception | None = None): """Patch builtins.open for a specific text-mode path match. Only intercepts when 'b' is not present in the requested mode. @@ -123,18 +107,18 @@ def patch_open_for_text_read( open_mock = mock_open(read_data=read_data) if read_data is not None else None def open_selector(file, *args, **kwargs): - mode = kwargs.get('mode', args[0] if args else 'r') + mode = kwargs.get("mode", args[0] if args else "r") file_str = str(file) is_match = match(file_str) if callable(match) else file_str == str(match) - if is_match and 'b' not in mode: + if is_match and "b" not in mode: if raises is not None: raise raises return open_mock(file, *args, **kwargs) return real_open(file, *args, **kwargs) - monkeypatch.setattr(builtins, 'open', open_selector) + monkeypatch.setattr(builtins, "open", open_selector) return open_mock @@ -155,28 +139,24 @@ def __enter__(self): def __exit__(self, *args): return False - monkeypatch.setattr('subprocess.Popen', MockProcess) + monkeypatch.setattr("subprocess.Popen", MockProcess) def patch_os_paths( - monkeypatch, - *, - cwd: str = '/test/dir', - exists: bool | Callable[[str], bool] = True, - basename: str | Callable[[str], str] = 'test-dir' + monkeypatch, *, cwd: str = "/test/dir", exists: bool | Callable[[str], bool] = True, basename: str | Callable[[str], str] = "test-dir" ) -> None: """Patch common os.getcwd / os.path.exists / os.path.basename for tests.""" - monkeypatch.setattr('os.getcwd', MagicMock(return_value=cwd)) + monkeypatch.setattr("os.getcwd", MagicMock(return_value=cwd)) if callable(exists): - monkeypatch.setattr('os.path.exists', exists) + monkeypatch.setattr("os.path.exists", exists) else: - monkeypatch.setattr('os.path.exists', MagicMock(return_value=exists)) + monkeypatch.setattr("os.path.exists", MagicMock(return_value=exists)) if callable(basename): - monkeypatch.setattr('os.path.basename', basename) + monkeypatch.setattr("os.path.basename", basename) else: - monkeypatch.setattr('os.path.basename', MagicMock(return_value=basename)) + monkeypatch.setattr("os.path.basename", MagicMock(return_value=basename)) def patch_create_bicep_deployment_group_dependencies( @@ -184,9 +164,9 @@ def patch_create_bicep_deployment_group_dependencies( *, az_module, run_success: bool = True, - cwd: str = '/test/dir', + cwd: str = "/test/dir", exists: bool | Callable[[str], bool] = True, - basename: str | Callable[[str], str] = 'test-dir' + basename: str | Callable[[str], str] = "test-dir", ): """Patch common dependencies for utils.create_bicep_deployment_group tests. @@ -194,14 +174,14 @@ def patch_create_bicep_deployment_group_dependencies( tuple: (mock_create_resource_group, mock_az_run, mock_open) """ mock_create_rg = MagicMock() - monkeypatch.setattr(az_module, 'create_resource_group', mock_create_rg) + monkeypatch.setattr(az_module, "create_resource_group", mock_create_rg) mock_run = MagicMock(return_value=MagicMock(success=run_success)) - monkeypatch.setattr(az_module, 'run', mock_run) + monkeypatch.setattr(az_module, "run", mock_run) open_mock = mock_open() - monkeypatch.setattr(builtins, 'open', open_mock) - monkeypatch.setattr(builtins, 'print', MagicMock()) + monkeypatch.setattr(builtins, "open", open_mock) + monkeypatch.setattr(builtins, "print", MagicMock()) patch_os_paths(monkeypatch, cwd=cwd, exists=exists, basename=basename) @@ -212,7 +192,8 @@ def patch_create_bicep_deployment_group_dependencies( # MOCK FACTORIES # ------------------------------ -def create_mock_output(success: bool = True, text: str = '', json_data: dict | None = None) -> Output: + +def create_mock_output(success: bool = True, text: str = "", json_data: dict | None = None) -> Output: """ Factory for creating consistent mock Azure CLI Output objects. @@ -232,11 +213,11 @@ def create_mock_output(success: bool = True, text: str = '', json_data: dict | N def create_mock_az_module( rg_exists: bool = True, - rg_name: str = 'rg-test-infrastructure-01', - account_info: tuple = ('test_user', 'test_user_id', 'test_tenant', 'test_subscription'), - resource_suffix: str = 'abc123def456', + rg_name: str = "rg-test-infrastructure-01", + account_info: tuple = ("test_user", "test_user_id", "test_tenant", "test_subscription"), + resource_suffix: str = "abc123def456", run_success: bool = True, - run_output: dict | str | None = None + run_output: dict | str | None = None, ): """ Factory for creating a mock azure_resources (az) module. @@ -261,15 +242,15 @@ def create_mock_az_module( # Configure default run output if run_output is None: - run_output = {'outputs': 'test'} + run_output = {"outputs": "test"} mock_output = Mock() mock_output.success = run_success if isinstance(run_output, dict): mock_output.json_data = run_output - mock_output.get.return_value = 'https://test-apim.azure-api.net' - mock_output.getJson.return_value = ['api1', 'api2'] + mock_output.get.return_value = "https://test-apim.azure-api.net" + mock_output.getJson.return_value = ["api1", "api2"] else: mock_output.text = run_output @@ -280,9 +261,9 @@ def create_mock_az_module( def create_mock_utils_module( tags: dict | None = None, - policy_xml: str = '', - policy_path: str = '/mock/path/policy.xml', - verify_result: bool = True + policy_xml: str = "", + policy_path: str = "/mock/path/policy.xml", + verify_result: bool = True, ): """ Factory for creating a mock utils module. @@ -297,7 +278,7 @@ def create_mock_utils_module( Mock configured with common utils patterns """ if tags is None: - tags = {'environment': 'test', 'project': 'apim-samples'} + tags = {"environment": "test", "project": "apim-samples"} mock_utils = Mock() mock_utils.build_infrastructure_tags.return_value = tags @@ -318,14 +299,7 @@ def create_sample_policy_fragments(count: int = 2) -> list[PolicyFragment]: Returns: List of PolicyFragment objects """ - return [ - PolicyFragment( - f'Test-Fragment-{i+1}', - f'test{i+1}', - f'Test fragment {i+1}' - ) - for i in range(count) - ] + return [PolicyFragment(f"Test-Fragment-{i + 1}", f"test{i + 1}", f"Test fragment {i + 1}") for i in range(count)] def create_sample_apis(count: int = 2) -> list[API]: @@ -339,13 +313,7 @@ def create_sample_apis(count: int = 2) -> list[API]: List of API objects """ return [ - API( - f'test-api-{i+1}', - f'Test API {i+1}', - f'/test{i+1}', - f'Test API {i+1} description', - f'api{i+1}' - ) + API(f"test-api-{i + 1}", f"Test API {i + 1}", f"/test{i + 1}", f"Test API {i + 1} description", f"api{i + 1}") for i in range(count) ] @@ -362,13 +330,7 @@ def create_sample_api_operations(count: int = 2) -> list[APIOperation]: """ verbs = [HTTP_VERB.GET, HTTP_VERB.POST, HTTP_VERB.PUT, HTTP_VERB.DELETE] return [ - APIOperation( - f'operation-{i+1}', - f'Operation {i+1}', - verbs[i % len(verbs)], - f'/resource{i+1}', - f'operation{i+1}' - ) + APIOperation(f"operation-{i + 1}", f"Operation {i + 1}", verbs[i % len(verbs)], f"/resource{i + 1}", f"operation{i + 1}") for i in range(count) ] @@ -377,6 +339,7 @@ def create_sample_api_operations(count: int = 2) -> list[APIOperation]: # ASSERTION HELPERS # ------------------------------ + def assert_bicep_params_structure(params: dict) -> None: """ Verify bicep parameters have the expected structure. @@ -390,18 +353,13 @@ def assert_bicep_params_structure(params: dict) -> None: assert isinstance(params, dict), "Bicep params must be a dict" # Common required parameters - required_keys = ['location', 'resourceSuffix'] + required_keys = ["location", "resourceSuffix"] for key in required_keys: assert key in params, f"Missing required bicep parameter: {key}" - assert 'value' in params[key], f"Parameter {key} missing 'value' key" + assert "value" in params[key], f"Parameter {key} missing 'value' key" -def assert_infrastructure_components( - infra, - expected_min_apis: int = 1, - expected_min_pfs: int = 6, - check_rg: bool = True -) -> None: +def assert_infrastructure_components(infra, expected_min_apis: int = 1, expected_min_pfs: int = 6, check_rg: bool = True) -> None: """ Verify infrastructure instance has expected components initialized. @@ -418,14 +376,12 @@ def assert_infrastructure_components( apis = infra._define_apis() pfs = infra._define_policy_fragments() - assert len(apis) >= expected_min_apis, \ - f"Expected at least {expected_min_apis} APIs, got {len(apis)}" - assert len(pfs) >= expected_min_pfs, \ - f"Expected at least {expected_min_pfs} policy fragments, got {len(pfs)}" + assert len(apis) >= expected_min_apis, f"Expected at least {expected_min_apis} APIs, got {len(apis)}" + assert len(pfs) >= expected_min_pfs, f"Expected at least {expected_min_pfs} policy fragments, got {len(pfs)}" if check_rg: - assert hasattr(infra, 'rg_name'), "Infrastructure missing rg_name" - assert hasattr(infra, 'rg_location'), "Infrastructure missing rg_location" + assert hasattr(infra, "rg_name"), "Infrastructure missing rg_name" + assert hasattr(infra, "rg_location"), "Infrastructure missing rg_location" assert infra.rg_name, "rg_name should not be empty" @@ -443,9 +399,9 @@ def assert_api_structure(api: API, check_operations: bool = False) -> None: assert api.name, "API name should not be empty" assert api.displayName, "API displayName should not be empty" assert api.path, "API path should not be empty" - assert hasattr(api, 'operations'), "API missing operations attribute" - assert hasattr(api, 'tags'), "API missing tags attribute" - assert hasattr(api, 'productNames'), "API missing productNames attribute" + assert hasattr(api, "operations"), "API missing operations attribute" + assert hasattr(api, "tags"), "API missing tags attribute" + assert hasattr(api, "productNames"), "API missing productNames attribute" if check_operations: assert isinstance(api.operations, list), "API operations must be a list" @@ -463,13 +419,14 @@ def assert_policy_fragment_structure(pf: PolicyFragment) -> None: """ assert pf.name, "PolicyFragment name should not be empty" assert pf.policyXml, "PolicyFragment policyXml should not be empty" - assert hasattr(pf, 'description'), "PolicyFragment missing description" + assert hasattr(pf, "description"), "PolicyFragment missing description" # ------------------------------ # TEST DATA GENERATORS # ------------------------------ + def get_sample_bicep_params() -> dict: """ Get a sample bicep parameters dictionary for testing. @@ -478,11 +435,11 @@ def get_sample_bicep_params() -> dict: Dictionary with sample bicep parameters """ return { - 'location': {'value': 'eastus2'}, - 'resourceSuffix': {'value': 'abc123'}, - 'apimSku': {'value': 'BasicV2'}, - 'apis': {'value': []}, - 'policyFragments': {'value': []} + "location": {"value": Region.EAST_US_2}, + "resourceSuffix": {"value": "abc123"}, + "apimSku": {"value": "BasicV2"}, + "apis": {"value": []}, + "policyFragments": {"value": []}, } @@ -493,24 +450,20 @@ def get_sample_infrastructure_params() -> dict: Returns: Dictionary with common infrastructure parameters """ - return { - 'rg_location': 'eastus2', - 'index': 1, - 'apim_sku': APIM_SKU.BASICV2, - 'networkMode': APIMNetworkMode.PUBLIC - } + return {"rg_location": Region.EAST_US_2, "index": 1, "apim_sku": APIM_SKU.BASICV2, "networkMode": APIMNetworkMode.PUBLIC} # ------------------------------ # HTTP MOCK FACTORIES # ------------------------------ + def create_mock_http_response( status_code: int = 200, json_data: dict | None = None, text: str | None = None, headers: dict | None = None, - raise_for_status_error: Exception | None = None + raise_for_status_error: Exception | None = None, ): """ Factory for creating mock HTTP response objects. @@ -526,12 +479,12 @@ def create_mock_http_response( Mock response object configured for HTTP testing """ if headers is None: - headers = {'Content-Type': 'application/json'} + headers = {"Content-Type": "application/json"} if json_data is not None and text is None: text = json_module.dumps(json_data, indent=4) elif text is None: - text = '' + text = "" mock_response = MagicMock() mock_response.status_code = status_code @@ -575,6 +528,7 @@ def create_mock_session_with_response(response): # CONTEXT MANAGERS FOR PATCHING # ------------------------------ + class MockApimRequestsPatches: """ Context manager for common apimrequests module patches. @@ -592,12 +546,12 @@ def __init__(self): def __enter__(self): patch_targets = [ - ('apimrequests.requests.request', 'request'), - ('apimrequests.print_message', 'print_message'), - ('apimrequests.print_info', 'print_info'), - ('apimrequests.print_error', 'print_error'), - ('apimrequests.print_val', 'print_val'), - ('apimrequests.print_ok', 'print_ok') + ("apimrequests.requests.request", "request"), + ("apimrequests.print_message", "print_message"), + ("apimrequests.print_info", "print_info"), + ("apimrequests.print_error", "print_error"), + ("apimrequests.print_val", "print_val"), + ("apimrequests.print_ok", "print_ok"), ] for target, name in patch_targets: @@ -629,37 +583,37 @@ def __init__(self): def __enter__(self): # Patch az - self.az_patch = patch('infrastructures.az') + self.az_patch = patch("infrastructures.az") self.az = self.az_patch.__enter__() - self.az.get_infra_rg_name.return_value = 'rg-test-infrastructure-01' + self.az.get_infra_rg_name.return_value = "rg-test-infrastructure-01" self.az.create_resource_group.return_value = None self.az.does_resource_group_exist.return_value = True - self.az.get_account_info.return_value = ('test_user', 'test_user_id', 'test_tenant', 'test_subscription') - self.az.get_unique_suffix_for_resource_group.return_value = 'abc123def456' + self.az.get_account_info.return_value = ("test_user", "test_user_id", "test_tenant", "test_subscription") + self.az.get_unique_suffix_for_resource_group.return_value = "abc123def456" mock_output = Mock() mock_output.success = True - mock_output.json_data = {'outputs': 'test'} - mock_output.get.return_value = 'https://test-apim.azure-api.net' - mock_output.getJson.return_value = ['api1', 'api2'] + mock_output.json_data = {"outputs": "test"} + mock_output.get.return_value = "https://test-apim.azure-api.net" + mock_output.getJson.return_value = ["api1", "api2"] self.az.run.return_value = mock_output self.patches.append(self.az_patch) # Patch utils - self.utils_patch = patch('infrastructures.utils') + self.utils_patch = patch("infrastructures.utils") self.utils = self.utils_patch.__enter__() - self.utils.build_infrastructure_tags.return_value = {'environment': 'test', 'project': 'apim-samples'} - self.utils.read_policy_xml.return_value = '' - self.utils.determine_shared_policy_path.return_value = '/mock/path/policy.xml' + self.utils.build_infrastructure_tags.return_value = {"environment": "test", "project": "apim-samples"} + self.utils.read_policy_xml.return_value = "" + self.utils.determine_shared_policy_path.return_value = "/mock/path/policy.xml" self.utils.verify_infrastructure.return_value = True self.patches.append(self.utils_patch) # Patch apimtypes._read_policy_xml to prevent file system access in tests - self.apimtypes_read_policy_patch = patch('apimtypes._read_policy_xml') + self.apimtypes_read_policy_patch = patch("apimtypes._read_policy_xml") self.apimtypes_read_policy = self.apimtypes_read_policy_patch.__enter__() - self.apimtypes_read_policy.return_value = '' + self.apimtypes_read_policy.return_value = "" self.patches.append(self.apimtypes_read_policy_patch) return self @@ -673,6 +627,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): # CONSOLE OUTPUT CAPTURE # ------------------------------ + def capture_console_output(func: Callable, *args, **kwargs) -> str: """ Capture console logging output from a function call. @@ -687,13 +642,13 @@ def capture_console_output(func: Callable, *args, **kwargs) -> str: """ captured_output = io.StringIO() - logger = logging.getLogger('console') + logger = logging.getLogger("console") previous_level = logger.level previous_handlers = list(logger.handlers) previous_propagate = logger.propagate handler = logging.StreamHandler(captured_output) - handler.setFormatter(logging.Formatter('%(message)s')) + handler.setFormatter(logging.Formatter("%(message)s")) logger.handlers = [handler] logger.setLevel(logging.DEBUG)