From bdeaf519841f4cf74157d00a4d720e22ce44387e Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 26 Aug 2026 13:00:41 -0700 Subject: [PATCH 1/4] Updating App Config test resources --- .../tests/conftest.py | 58 ++++++++++++++- .../tests/test_readiness.py | 55 ++++++++++++++ .../azure-appconfiguration/tests/conftest.py | 62 +++++++++++++++- .../tests/test_readiness.py | 55 ++++++++++++++ sdk/appconfiguration/test-resources.json | 73 ++----------------- 5 files changed, 233 insertions(+), 70 deletions(-) create mode 100644 sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py create mode 100644 sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py index 6dc7e45229eb..0a7847a2201d 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py @@ -1,4 +1,7 @@ +import logging import os +import time + from devtools_testutils import ( add_general_regex_sanitizer, add_general_string_sanitizer, @@ -7,22 +10,71 @@ remove_batch_sanitizers, add_remove_header_sanitizer, add_uri_string_sanitizer, + get_credential, is_live, ) import pytest from azure.appconfiguration import AzureAppConfigurationClient -from azure.identity import DefaultAzureCredential +from azure.core.exceptions import HttpResponseError from testcase import setup_configs, cleanup_test_resources + +_LOGGER = logging.getLogger(__name__) +_RBAC_PROPAGATION_TIMEOUT = 15 * 60 +_MAX_RETRY_DELAY = 30 + # autouse=True will trigger this fixture on each pytest run, even if it's not explicitly used by a test method # Module-level storage for snapshot names created during session setup snapshot_names = {} +def _wait_for_data_plane_access(client, timeout=_RBAC_PROPAGATION_TIMEOUT): + deadline = time.monotonic() + timeout + retry_delay = 1 + + while True: + try: + next(client.list_configuration_settings(key_filter="__rbac_readiness_probe__"), None) + return + except HttpResponseError as error: + if error.status_code != 403: + raise + + remaining_time = deadline - time.monotonic() + if remaining_time <= 0: + raise TimeoutError("App Configuration data-plane role assignment did not propagate in time.") from error + + sleep_time = min(retry_delay, remaining_time) + _LOGGER.info( + "Waiting %.0f seconds for the App Configuration data-plane role assignment to propagate.", + sleep_time, + ) + time.sleep(sleep_time) + retry_delay = min(retry_delay * 2, _MAX_RETRY_DELAY) + + +@pytest.fixture(scope="session", autouse=True) +def wait_for_data_plane_access(): + if not is_live(): + return + + endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") + if not endpoint: + pytest.fail("APPCONFIGURATION_ENDPOINT_STRING must be set when running live tests.") + + client = AzureAppConfigurationClient(endpoint, get_credential()) + try: + _wait_for_data_plane_access(client) + finally: + client.close() + + @pytest.fixture(scope="session", autouse=True) -def setup_app_config_keys(): +def setup_app_config_keys(wait_for_data_plane_access): """Pre-populate App Configuration with test keys and snapshots once per session (live mode only).""" + del wait_for_data_plane_access + if not is_live(): yield return @@ -32,7 +84,7 @@ def setup_app_config_keys(): yield return - credential = DefaultAzureCredential() + credential = get_credential() client = AzureAppConfigurationClient(endpoint, credential) keyvault_secret_url = os.environ.get("APPCONFIGURATION_KEY_VAULT_REFERENCE") keyvault_secret_url2 = os.environ.get("APPCONFIGURATION_KEY_VAULT_REFERENCE2") diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py new file mode 100644 index 000000000000..5ecb25320f6e --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.exceptions import HttpResponseError + +from conftest import _wait_for_data_plane_access + + +def _http_error(status_code): + response = MagicMock() + response.status_code = status_code + response.reason = "Forbidden" + return HttpResponseError(response=response) + + +def test_wait_for_data_plane_access_succeeds_immediately(): + client = MagicMock() + client.list_configuration_settings.return_value = iter([]) + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_retries_forbidden_response(): + client = MagicMock() + client.list_configuration_settings.side_effect = [_http_error(403), iter([])] + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_called_once_with(1) + + +def test_wait_for_data_plane_access_does_not_retry_other_errors(): + client = MagicMock() + client.list_configuration_settings.side_effect = _http_error(500) + + with patch("conftest.time.sleep") as sleep, pytest.raises(HttpResponseError): + _wait_for_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_times_out(): + client = MagicMock() + client.list_configuration_settings.side_effect = _http_error(403) + + with patch("conftest.time.monotonic", side_effect=[0, 2]), patch( + "conftest.time.sleep" + ) as sleep, pytest.raises(TimeoutError): + _wait_for_data_plane_access(client, timeout=1) + + sleep.assert_not_called() diff --git a/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py index d0dcda865be9..519d53c2d843 100644 --- a/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py @@ -23,9 +23,67 @@ # IN THE SOFTWARE. # # -------------------------------------------------------------------------- -import pytest +import logging import os -from devtools_testutils import add_general_regex_sanitizer, test_proxy, set_bodiless_matcher, remove_batch_sanitizers +import time + +import pytest +from azure.appconfiguration import AzureAppConfigurationClient +from azure.core.exceptions import HttpResponseError +from devtools_testutils import ( + add_general_regex_sanitizer, + get_credential, + is_live, + remove_batch_sanitizers, + set_bodiless_matcher, + test_proxy, +) + + +_LOGGER = logging.getLogger(__name__) +_RBAC_PROPAGATION_TIMEOUT = 15 * 60 +_MAX_RETRY_DELAY = 30 + + +def _wait_for_data_plane_access(client, timeout=_RBAC_PROPAGATION_TIMEOUT): + deadline = time.monotonic() + timeout + retry_delay = 1 + + while True: + try: + next(client.list_configuration_settings(key_filter="__rbac_readiness_probe__"), None) + return + except HttpResponseError as error: + if error.status_code != 403: + raise + + remaining_time = deadline - time.monotonic() + if remaining_time <= 0: + raise TimeoutError("App Configuration data-plane role assignment did not propagate in time.") from error + + sleep_time = min(retry_delay, remaining_time) + _LOGGER.info( + "Waiting %.0f seconds for the App Configuration data-plane role assignment to propagate.", + sleep_time, + ) + time.sleep(sleep_time) + retry_delay = min(retry_delay * 2, _MAX_RETRY_DELAY) + + +@pytest.fixture(scope="session", autouse=True) +def wait_for_data_plane_access(): + if not is_live(): + return + + endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") + if not endpoint: + pytest.fail("APPCONFIGURATION_ENDPOINT_STRING must be set when running live tests.") + + client = AzureAppConfigurationClient(endpoint, get_credential()) + try: + _wait_for_data_plane_access(client) + finally: + client.close() @pytest.fixture(scope="session", autouse=True) diff --git a/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py b/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py new file mode 100644 index 000000000000..5ecb25320f6e --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.exceptions import HttpResponseError + +from conftest import _wait_for_data_plane_access + + +def _http_error(status_code): + response = MagicMock() + response.status_code = status_code + response.reason = "Forbidden" + return HttpResponseError(response=response) + + +def test_wait_for_data_plane_access_succeeds_immediately(): + client = MagicMock() + client.list_configuration_settings.return_value = iter([]) + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_retries_forbidden_response(): + client = MagicMock() + client.list_configuration_settings.side_effect = [_http_error(403), iter([])] + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_called_once_with(1) + + +def test_wait_for_data_plane_access_does_not_retry_other_errors(): + client = MagicMock() + client.list_configuration_settings.side_effect = _http_error(500) + + with patch("conftest.time.sleep") as sleep, pytest.raises(HttpResponseError): + _wait_for_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_times_out(): + client = MagicMock() + client.list_configuration_settings.side_effect = _http_error(403) + + with patch("conftest.time.monotonic", side_effect=[0, 2]), patch( + "conftest.time.sleep" + ) as sleep, pytest.raises(TimeoutError): + _wait_for_data_plane_access(client, timeout=1) + + sleep.assert_not_called() diff --git a/sdk/appconfiguration/test-resources.json b/sdk/appconfiguration/test-resources.json index 9de238e15de7..cb154369fdf7 100644 --- a/sdk/appconfiguration/test-resources.json +++ b/sdk/appconfiguration/test-resources.json @@ -57,6 +57,7 @@ }, "variables": { "roleDefinitionId": "[format('/subscriptions/{0}/providers/Microsoft.Authorization/roleDefinitions/5ae67dd6-50cb-40e7-96ff-dc2bfa4b606b', subscription().subscriptionId)]", + "roleAssignmentName": "[guid(resourceGroup().id, parameters('testApplicationOid'), variables('roleDefinitionId'))]", "uniqueAzConfigName": "[format('{0}-{1}', parameters('baseName'), parameters('azConfigPrefix'))]", "endpointValue": "[format('https://{0}-{1}{2}', parameters('baseName'), parameters('azConfigPrefix'), parameters('azConfigEndpointSuffix'))]", "azureKeyVaultUrl": "[format('https://{0}{1}/', parameters('baseName'), parameters('keyVaultEndpointSuffix'))]", @@ -66,21 +67,24 @@ "resources": [ { "type": "Microsoft.AppConfiguration/configurationStores", - "apiVersion": "2019-10-01", + "apiVersion": "2024-06-01", "name": "[variables('uniqueAzConfigName')]", "location": "[parameters('location')]", "sku": { "name": "[parameters('sku')]" }, "properties": { - "endpoint": "[variables('endpointValue')]", - "disableLocalAuth": true + "disableLocalAuth": true, + "dataPlaneProxy": { + "authenticationMode": "Pass-through", + "privateLinkDelegation": "Disabled" + } } }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2018-09-01-preview", - "name": "[guid(resourceGroup().id)]", + "name": "[variables('roleAssignmentName')]", "properties": { "roleDefinitionId": "[variables('roleDefinitionId')]", "principalId": "[parameters('testApplicationOid')]" @@ -133,70 +137,9 @@ "properties": { "value": "Very secret value 2" } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'message')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "hi" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'message$dev')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "test" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'my_json')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "{\"key\": \"value\"}", - "contentType": "application/json" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'test.trimmed')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "key" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', '.appconfig.featureflag~2FAlpha')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "{\"id\":\"Alpha\",\"description\":\"\",\"enabled\":false,\"conditions\":{\"client_filters\":[]}}", - "contentType": "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" - } } ], "outputs": { - "APPCONFIGURATION_CONNECTION_STRING": { - "type": "string", - "value": "[listKeys(resourceId('Microsoft.AppConfiguration/configurationStores',variables('uniqueAzConfigName')), '2019-02-01-preview').value[0].connectionString]" - }, "APPCONFIGURATION_ENDPOINT_STRING": { "type": "string", "value": "[variables('endpointValue')]" From f2bbac1dd236b76894b2ccda118b085534983f84 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 28 Aug 2026 10:35:57 -0700 Subject: [PATCH 2/4] Update test_readiness.py --- .../azure-appconfiguration/tests/test_readiness.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py b/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py index 5ecb25320f6e..da0e9ec5a146 100644 --- a/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py +++ b/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py @@ -47,9 +47,9 @@ def test_wait_for_data_plane_access_times_out(): client = MagicMock() client.list_configuration_settings.side_effect = _http_error(403) - with patch("conftest.time.monotonic", side_effect=[0, 2]), patch( - "conftest.time.sleep" - ) as sleep, pytest.raises(TimeoutError): + with patch("conftest.time.monotonic", side_effect=[0, 2]), patch("conftest.time.sleep") as sleep, pytest.raises( + TimeoutError + ): _wait_for_data_plane_access(client, timeout=1) sleep.assert_not_called() From 420c359118ceaa87b030c3751dde395519ff2eb1 Mon Sep 17 00:00:00 2001 From: Matthew Metcalf Date: Fri, 28 Aug 2026 10:38:07 -0700 Subject: [PATCH 3/4] Apply batched suggestions from code review Co-authored-by: Yuan Qu --- .../azure-appconfiguration-provider/tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py index 0a7847a2201d..2ee9e2510f00 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -_RBAC_PROPAGATION_TIMEOUT = 15 * 60 +_RBAC_PROPAGATION_TIMEOUT = 15 * 60 + 5 _MAX_RETRY_DELAY = 30 # autouse=True will trigger this fixture on each pytest run, even if it's not explicitly used by a test method @@ -29,7 +29,7 @@ snapshot_names = {} -def _wait_for_data_plane_access(client, timeout=_RBAC_PROPAGATION_TIMEOUT): +def _wait_for_rbac_propagation(client, timeout=_RBAC_PROPAGATION_TIMEOUT): deadline = time.monotonic() + timeout retry_delay = 1 From 0556c41e923d5876699bb1bd5e51539a2df0297e Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 28 Aug 2026 10:41:32 -0700 Subject: [PATCH 4/4] updating from review comments --- .../azure-appconfiguration-provider/tests/conftest.py | 2 +- .../azure-appconfiguration/tests/conftest.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py index 2ee9e2510f00..c3fed36e0bfa 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py @@ -65,7 +65,7 @@ def wait_for_data_plane_access(): client = AzureAppConfigurationClient(endpoint, get_credential()) try: - _wait_for_data_plane_access(client) + _wait_for_rbac_propagation(client) finally: client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py index 519d53c2d843..5e012396c320 100644 --- a/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py @@ -41,11 +41,11 @@ _LOGGER = logging.getLogger(__name__) -_RBAC_PROPAGATION_TIMEOUT = 15 * 60 +_RBAC_PROPAGATION_TIMEOUT = 15 * 60 + 5 _MAX_RETRY_DELAY = 30 -def _wait_for_data_plane_access(client, timeout=_RBAC_PROPAGATION_TIMEOUT): +def _wait_for_rbac_propagation(client, timeout=_RBAC_PROPAGATION_TIMEOUT): deadline = time.monotonic() + timeout retry_delay = 1 @@ -81,7 +81,7 @@ def wait_for_data_plane_access(): client = AzureAppConfigurationClient(endpoint, get_credential()) try: - _wait_for_data_plane_access(client) + _wait_for_rbac_propagation(client) finally: client.close()