diff --git a/.gitignore b/.gitignore index 87b0050a20cc..fa4e4fc71035 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,6 @@ sdk/cosmos/azure-cosmos/test/test_config.py # env vars .env + +# local SSL certificate folder +.certificate diff --git a/sdk/tables/azure-data-tables/tests/test_table_service_stats.py b/sdk/tables/azure-data-tables/tests/test_table_service_stats.py index 6658e702d677..3f623f1b6551 100644 --- a/sdk/tables/azure-data-tables/tests/test_table_service_stats.py +++ b/sdk/tables/azure-data-tables/tests/test_table_service_stats.py @@ -10,7 +10,7 @@ from preparers import tables_decorator # --Test Class ----------------------------------------------------------------- -class TableServiceStatsTest(AzureTestCase, TableTestCase): +class TestTableServiceStats(AzureTestCase, TableTestCase): # --Test cases per service --------------------------------------- @tables_decorator diff --git a/sdk/tables/azure-data-tables/tests/test_table_service_stats_async.py b/sdk/tables/azure-data-tables/tests/test_table_service_stats_async.py index 2aeffef1e965..e22d406ef8e4 100644 --- a/sdk/tables/azure-data-tables/tests/test_table_service_stats_async.py +++ b/sdk/tables/azure-data-tables/tests/test_table_service_stats_async.py @@ -19,7 +19,7 @@ '> ' -class TableServiceStatsTest(AzureTestCase, AsyncTableTestCase): +class TestTableServiceStats(AzureTestCase, AsyncTableTestCase): @staticmethod def override_response_body_with_unavailable_status(response): diff --git a/tools/azure-devtools/src/azure_devtools/scenario_tests/preparers.py b/tools/azure-devtools/src/azure_devtools/scenario_tests/preparers.py index 1bc992962882..733ad04a689a 100644 --- a/tools/azure-devtools/src/azure_devtools/scenario_tests/preparers.py +++ b/tools/azure-devtools/src/azure_devtools/scenario_tests/preparers.py @@ -6,7 +6,6 @@ import contextlib import functools import logging -import sys from collections import namedtuple from threading import Lock @@ -135,7 +134,14 @@ def _preparer_wrapper(test_class_instance, **kwargs): ) if test_class_instance.is_live: - test_class_instance.scrubber.register_name_pair(resource_name, self.moniker) + # Adding this for new proxy testcase + if hasattr(test_class_instance, "scrubber"): + test_class_instance.scrubber.register_name_pair(resource_name, self.moniker) + else: + _logger.info( + "This test class instance has no scrubber, so the AbstractPreparer will not scrub any values " + "in recordings." + ) # We shouldn't trim the same kwargs that we use for deletion, # we may remove some of the variables we needed to do the delete. diff --git a/tools/azure-sdk-tools/devtools_testutils/__init__.py b/tools/azure-sdk-tools/devtools_testutils/__init__.py index 640da9b62531..69db6f41dfb3 100644 --- a/tools/azure-sdk-tools/devtools_testutils/__init__.py +++ b/tools/azure-sdk-tools/devtools_testutils/__init__.py @@ -1,4 +1,5 @@ from .mgmt_testcase import AzureMgmtTestCase, AzureMgmtPreparer +from .azure_recorded_testcase import AzureRecordedTestCase from .azure_testcase import AzureTestCase, is_live, get_region_override from .resource_testcase import ( FakeResource, @@ -14,12 +15,15 @@ ) from .keyvault_preparer import KeyVaultPreparer from .powershell_preparer import PowerShellPreparer +from .proxy_testcase import RecordedByProxy +from .enums import ProxyRecordingSanitizer from .helpers import ResponseCallback, RetryCounter from .fake_credential import FakeTokenCredential __all__ = [ "AzureMgmtTestCase", "AzureMgmtPreparer", + "AzureRecordedTestCase", "FakeResource", "ResourceGroupPreparer", "StorageAccountPreparer", @@ -33,6 +37,8 @@ "RandomNameResourceGroupPreparer", "CachedResourceGroupPreparer", "PowerShellPreparer", + "ProxyRecordingSanitizer", + "RecordedByProxy", "ResponseCallback", "RetryCounter", "FakeTokenCredential", diff --git a/tools/azure-sdk-tools/devtools_testutils/aio/__init__.py b/tools/azure-sdk-tools/devtools_testutils/aio/__init__.py new file mode 100644 index 000000000000..5265d80fe58e --- /dev/null +++ b/tools/azure-sdk-tools/devtools_testutils/aio/__init__.py @@ -0,0 +1,3 @@ +from .proxy_testcase_async import RecordedByProxyAsync + +__all__ = ["RecordedByProxyAsync"] diff --git a/tools/azure-sdk-tools/devtools_testutils/aio/proxy_testcase_async.py b/tools/azure-sdk-tools/devtools_testutils/aio/proxy_testcase_async.py new file mode 100644 index 000000000000..11c8f9e44395 --- /dev/null +++ b/tools/azure-sdk-tools/devtools_testutils/aio/proxy_testcase_async.py @@ -0,0 +1,50 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from azure.core.pipeline.transport import AioHttpTransport + +from azure_devtools.scenario_tests.utilities import trim_kwargs_from_test_function +from ..proxy_testcase import ( + get_test_id, + start_record_or_playback, + transform_request, + stop_record_or_playback, +) + + +def RecordedByProxyAsync(func): + async def record_wrap(*args, **kwargs): + test_id = get_test_id() + recording_id = start_record_or_playback(test_id) + + def transform_args(*args, **kwargs): + copied_positional_args = list(args) + request = copied_positional_args[1] + + transform_request(request, recording_id) + + return tuple(copied_positional_args), kwargs + + trimmed_kwargs = {k: v for k, v in kwargs.items()} + trim_kwargs_from_test_function(func, trimmed_kwargs) + + original_func = AioHttpTransport.send + + async def combined_call(*args, **kwargs): + adjusted_args, adjusted_kwargs = transform_args(*args, **kwargs) + return await original_func(*adjusted_args, **adjusted_kwargs) + + AioHttpTransport.send = combined_call + + # call the modified function. + try: + value = await func(*args, **trimmed_kwargs) + finally: + AioHttpTransport.send = original_func + stop_record_or_playback(test_id, recording_id) + + return value + + return record_wrap diff --git a/tools/azure-sdk-tools/devtools_testutils/azure_recorded_testcase.py b/tools/azure-sdk-tools/devtools_testutils/azure_recorded_testcase.py new file mode 100644 index 000000000000..d39cfe79960c --- /dev/null +++ b/tools/azure-sdk-tools/devtools_testutils/azure_recorded_testcase.py @@ -0,0 +1,265 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import functools +import logging +import os +import os.path +import requests +import six +import sys +import time +from typing import TYPE_CHECKING + +from dotenv import load_dotenv, find_dotenv + +from azure_devtools.scenario_tests import AzureTestError +from azure_devtools.scenario_tests.config import TestConfig +from azure_devtools.scenario_tests.utilities import trim_kwargs_from_test_function + +from . import mgmt_settings_fake as fake_settings +from .azure_testcase import _is_autorest_v3, get_resource_name, get_qualified_method_name +from .config import PROXY_URL +from .enums import ProxyRecordingSanitizer + +try: + # Try to import the AsyncFakeCredential, if we cannot assume it is Python 2 + from .fake_async_credential import AsyncFakeCredential +except SyntaxError: + pass + +if TYPE_CHECKING: + from typing import Optional + + +load_dotenv(find_dotenv()) + + +def is_live(): + """A module version of is_live, that could be used in pytest marker.""" + if not hasattr(is_live, "_cache"): + is_live._cache = TestConfig().record_mode + return is_live._cache + + +class AzureRecordedTestCase(object): + @property + def settings(self): + if self.is_live: + if self._real_settings: + return self._real_settings + else: + raise AzureTestError("Need a mgmt_settings_real.py file to run tests live.") + else: + return self._fake_settings + + def _load_settings(self): + try: + from . import mgmt_settings_real as real_settings + + return fake_settings, real_settings + except ImportError: + return fake_settings, None + + @property + def is_live(self): + return is_live() + + @property + def qualified_test_name(self): + return get_qualified_method_name(self, "method_name") + + @property + def in_recording(self): + return self.is_live + + # TODO: This needs to be removed, recording processors are handled on the proxy side, but + # this is needed for the preparers + @property + def recording_processors(self): + return [] + + def add_sanitizer(self, sanitizer, regex=None, value=None): + # type: (ProxyRecordingSanitizer, Optional[str], Optional[str]) -> None + if sanitizer == ProxyRecordingSanitizer.URI: + requests.post( + "{}/Admin/AddSanitizer".format(PROXY_URL), + headers={"x-abstraction-identifier": ProxyRecordingSanitizer.URI.value}, + json={ + "regex": regex or "[a-z]+(?=(?:-secondary)\\.(?:table|blob|queue)\\.core\\.windows\\.net)", + "value": value or "fakevalue" + }, + ) + + def is_playback(self): + return not self.is_live + + def get_settings_value(self, key): + key_value = os.environ.get("AZURE_" + key, None) + + if key_value and self._real_settings and getattr(self._real_settings, key) != key_value: + raise ValueError( + "You have both AZURE_{key} env variable and mgmt_settings_real.py for {key} to different values".format( + key=key + ) + ) + + if not key_value: + try: + key_value = getattr(self.settings, key) + except Exception as ex: + six.raise_from(ValueError("Could not get {}".format(key)), ex) + return key_value + + def get_credential(self, client_class, **kwargs): + tenant_id = os.environ.get("AZURE_TENANT_ID", getattr(self._real_settings, "TENANT_ID", None)) + client_id = os.environ.get("AZURE_CLIENT_ID", getattr(self._real_settings, "CLIENT_ID", None)) + secret = os.environ.get("AZURE_CLIENT_SECRET", getattr(self._real_settings, "CLIENT_SECRET", None)) + is_async = kwargs.pop("is_async", False) + + if tenant_id and client_id and secret and self.is_live: + if _is_autorest_v3(client_class): + # Create azure-identity class + from azure.identity import ClientSecretCredential + + if is_async: + from azure.identity.aio import ClientSecretCredential + return ClientSecretCredential(tenant_id=tenant_id, client_id=client_id, client_secret=secret) + else: + # Create msrestazure class + from msrestazure.azure_active_directory import ( + ServicePrincipalCredentials, + ) + + return ServicePrincipalCredentials(tenant=tenant_id, client_id=client_id, secret=secret) + else: + if _is_autorest_v3(client_class): + if is_async: + if self.is_live: + raise ValueError( + "Async live doesn't support mgmt_setting_real, please set AZURE_TENANT_ID, " + "AZURE_CLIENT_ID, AZURE_CLIENT_SECRET" + ) + return AsyncFakeCredential() + else: + return self.settings.get_azure_core_credentials() + else: + return self.settings.get_credentials() + + def create_client_from_credential(self, client_class, credential, **kwargs): + + # Real client creation + # TODO decide what is the final argument for that + # if self.is_playback(): + # kwargs.setdefault("polling_interval", 0) + if _is_autorest_v3(client_class): + kwargs.setdefault("logging_enable", True) + client = client_class(credential=credential, **kwargs) + else: + client = client_class(credentials=credential, **kwargs) + + if self.is_playback(): + try: + client._config.polling_interval = 0 # FIXME in azure-mgmt-core, make this a kwargs + except AttributeError: + pass + + if hasattr(client, "config"): # Autorest v2 + if self.is_playback(): + client.config.long_running_operation_timeout = 0 + client.config.enable_http_logger = True + return client + + def create_basic_client(self, client_class, **kwargs): + """ DO NOT USE ME ANYMORE.""" + logger = logging.getLogger() + logger.warning( + "'create_basic_client' will be deprecated in the future. It is recommended that you use \ + 'get_credential' and 'create_client_from_credential' to create your client." + ) + + credentials = self.get_credential(client_class) + return self.create_client_from_credential(client_class, credentials, **kwargs) + + def create_random_name(self, name): + unique_test_name = os.getenv("PYTEST_CURRENT_TEST").encode("utf-8") + return get_resource_name(name, unique_test_name) + + def get_resource_name(self, name): + """Alias to create_random_name for back compatibility.""" + return self.create_random_name(name) + + def get_replayable_random_resource_name(self, name): + """In a replay scenario (not live), gives the static moniker. In the random scenario, gives generated name.""" + if self.is_live: + created_name = self.create_random_name(name) + self.scrubber.register_name_pair(created_name, name) + return name + + def get_preparer_resource_name(self, prefix): + """Random name generation for use by preparers. + + If prefix is a blank string, use the fully qualified test name instead. + This is what legacy tests do for resource groups.""" + return self.get_resource_name(prefix) + + @staticmethod + def await_prepared_test(test_fn): + """Synchronous wrapper for async test methods. Used to avoid making changes + upstream to AbstractPreparer, which only awaits async tests that use preparers. + (Add @AzureTestCase.await_prepared_test decorator to async tests without preparers) + + # Note: this will only be needed so long as we maintain unittest.TestCase in our + test-class inheritance chain. + """ + + if sys.version_info < (3, 5): + raise ImportError("Async wrapper is not needed for Python 2.7 code.") + + import asyncio + + @functools.wraps(test_fn) + def run(test_class_instance, *args, **kwargs): + trim_kwargs_from_test_function(test_fn, kwargs) + loop = asyncio.get_event_loop() + return loop.run_until_complete(test_fn(test_class_instance, **kwargs)) + + return run + + def sleep(self, seconds): + if self.is_live: + time.sleep(seconds) + + def generate_sas(self, *args, **kwargs): + sas_func = args[0] + sas_func_pos_args = args[1:] + + fake_value = kwargs.pop("fake_value", "fake_token_value") + token = sas_func(*sas_func_pos_args, **kwargs) + + fake_token = self._create_fake_token(token, fake_value) + + if self.is_live: + return token + return fake_token + + def _create_fake_token(self, token, fake_value): + parts = token.split("&") + + for idx, part in enumerate(parts): + if part.startswith("sig"): + key = part.split("=") + key[1] = fake_value + parts[idx] = "=".join(key) + elif part.startswith("st"): + key = part.split("=") + key[1] = "start" + parts[idx] = "=".join(key) + elif part.startswith("se"): + key = part.split("=") + key[1] = "end" + parts[idx] = "=".join(key) + + return "&".join(parts) diff --git a/tools/azure-sdk-tools/devtools_testutils/config.py b/tools/azure-sdk-tools/devtools_testutils/config.py index 117c3b38ca08..7b9bcfbb06aa 100644 --- a/tools/azure-sdk-tools/devtools_testutils/config.py +++ b/tools/azure-sdk-tools/devtools_testutils/config.py @@ -1 +1,9 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + + +PROXY_URL = "https://localhost:5001" TEST_SETTING_FILENAME = "testsettings_local.cfg" diff --git a/tools/azure-sdk-tools/devtools_testutils/enums.py b/tools/azure-sdk-tools/devtools_testutils/enums.py new file mode 100644 index 000000000000..f1456dbc8a1f --- /dev/null +++ b/tools/azure-sdk-tools/devtools_testutils/enums.py @@ -0,0 +1,11 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from enum import Enum + +class ProxyRecordingSanitizer(str, Enum): + """General-purpose sanitizers for sanitizing test proxy recordings""" + + URI = "UriRegexSanitizer" diff --git a/tools/azure-sdk-tools/devtools_testutils/powershell_preparer.py b/tools/azure-sdk-tools/devtools_testutils/powershell_preparer.py index 562bb825bec6..0ce216f416c1 100644 --- a/tools/azure-sdk-tools/devtools_testutils/powershell_preparer.py +++ b/tools/azure-sdk-tools/devtools_testutils/powershell_preparer.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +import logging import os from . import AzureMgmtPreparer @@ -65,9 +66,17 @@ def create_resource(self, name, **kwargs): scrubbed_value = self.fake_values[key] if scrubbed_value: self.real_values[key.lower()] = os.environ[key.upper()] - self.test_class_instance.scrubber.register_name_pair( - self.real_values[key.lower()], scrubbed_value - ) + # Adding this for new proxy testcase + if hasattr(self.test_class_instance, "scrubber"): + self.test_class_instance.scrubber.register_name_pair( + self.real_values[key.lower()], scrubbed_value + ) + else: + logger = logging.getLogger() + logger.info( + "This test class instance has no scrubber, so the PowerShellPreparer will not scrub " + "the value of {} in recordings.".format(key) + ) else: template = 'To pass a live ID you must provide the scrubbed value for recordings to \ prevent secrets from being written to files. {} was not given. For example: \ diff --git a/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py b/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py new file mode 100644 index 000000000000..53cc41bf9064 --- /dev/null +++ b/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py @@ -0,0 +1,136 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import os +import requests + +try: + # py3 + import urllib.parse as url_parse +except: + # py2 + import urlparse as url_parse + +import subprocess + +# the functions we patch +from azure.core.pipeline.transport import RequestsTransport + +# the trimming function to clean up incoming arguments to the test function we are wrapping +from azure_devtools.scenario_tests.utilities import trim_kwargs_from_test_function +from .azure_recorded_testcase import is_live +from .config import PROXY_URL + + +# defaults +RECORDING_START_URL = "{}/record/start".format(PROXY_URL) +RECORDING_STOP_URL = "{}/record/stop".format(PROXY_URL) +PLAYBACK_START_URL = "{}/playback/start".format(PROXY_URL) +PLAYBACK_STOP_URL = "{}/playback/stop".format(PROXY_URL) + +# TODO, create a pytest scope="session" implementation that can be added to a fixture such that unit tests can +# startup/shutdown the local test proxy +# this should also fire the admin mapping updates, and start/end the session for commiting recording updates + + +def get_test_id(): + # pytest sets the current running test in an environment variable + setting_value = os.getenv("PYTEST_CURRENT_TEST") + + path_to_test = os.path.normpath(setting_value.split(" ")[0]) + path_components = path_to_test.split(os.sep) + + for idx, val in enumerate(path_components): + if val.startswith("test"): + path_components.insert(idx + 1, "recordings") + break + + return os.sep.join(path_components).replace("::", "").replace("\\", "/") + + +def start_record_or_playback(test_id): + result = subprocess.check_output(["git", "rev-parse", "HEAD"]) + current_sha = result.decode("utf-8").strip() + + if is_live(): + result = requests.post( + RECORDING_START_URL, + headers={"x-recording-file": test_id, "x-recording-sha": current_sha}, + ) + recording_id = result.headers["x-recording-id"] + else: + result = requests.post( + PLAYBACK_START_URL, + headers={"x-recording-file": test_id, "x-recording-sha": current_sha}, + ) + recording_id = result.headers["x-recording-id"] + return recording_id + + +def stop_record_or_playback(test_id, recording_id): + if is_live(): + requests.post( + RECORDING_STOP_URL, + headers={"x-recording-file": test_id, "x-recording-id": recording_id, "x-recording-save": "true"}, + ) + else: + requests.post( + PLAYBACK_STOP_URL, + headers={"x-recording-file": test_id, "x-recording-id": recording_id}, + ) + + +def get_proxy_netloc(): + parsed_result = url_parse.urlparse(PROXY_URL) + return {"scheme": parsed_result.scheme, "netloc": parsed_result.netloc} + + +def transform_request(request, recording_id): + """Redirect the request to the test proxy, and store the original request URI in a header""" + headers = request.headers + + parsed_result = url_parse.urlparse(request.url) + updated_target = parsed_result._replace(**get_proxy_netloc()).geturl() + if headers.get("x-recording-upstream-base-uri", None) is None: + headers["x-recording-upstream-base-uri"] = "{}://{}".format(parsed_result.scheme, parsed_result.netloc) + headers["x-recording-id"] = recording_id + headers["x-recording-mode"] = "record" if is_live() else "playback" + request.url = updated_target + + +def RecordedByProxy(func): + def record_wrap(*args, **kwargs): + test_id = get_test_id() + recording_id = start_record_or_playback(test_id) + + def transform_args(*args, **kwargs): + copied_positional_args = list(args) + request = copied_positional_args[1] + + transform_request(request, recording_id) + + return tuple(copied_positional_args), kwargs + + trimmed_kwargs = {k: v for k, v in kwargs.items()} + trim_kwargs_from_test_function(func, trimmed_kwargs) + + original_transport_func = RequestsTransport.send + + def combined_call(*args, **kwargs): + adjusted_args, adjusted_kwargs = transform_args(*args, **kwargs) + return original_transport_func(*adjusted_args, **adjusted_kwargs) + + RequestsTransport.send = combined_call + + # call the modified function. + try: + value = func(*args, **trimmed_kwargs) + finally: + RequestsTransport.send = original_transport_func + stop_record_or_playback(test_id, recording_id) + + return value + + return record_wrap