From 252b4e286a510d9e3c39db2d66c42d9f6547ec4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McCoy=20Pati=C3=B1o?= Date: Fri, 15 Jul 2022 16:50:31 -0700 Subject: [PATCH 1/3] Align sanitizers/matchers/transforms --- .../devtools_testutils/__init__.py | 22 +- .../devtools_testutils/sanitizers.py | 255 ++++++++++++++++-- 2 files changed, 254 insertions(+), 23 deletions(-) diff --git a/tools/azure-sdk-tools/devtools_testutils/__init__.py b/tools/azure-sdk-tools/devtools_testutils/__init__.py index 02d2e7791178..24e61ebc1da2 100644 --- a/tools/azure-sdk-tools/devtools_testutils/__init__.py +++ b/tools/azure-sdk-tools/devtools_testutils/__init__.py @@ -21,32 +21,49 @@ from .proxy_startup import start_test_proxy, stop_test_proxy, test_proxy from .proxy_testcase import recorded_by_proxy from .sanitizers import ( + add_api_version_transform, add_body_key_sanitizer, add_body_regex_sanitizer, + add_body_string_sanitizer, + add_client_id_transform, add_continuation_sanitizer, add_general_regex_sanitizer, + add_general_string_sanitizer, add_header_regex_sanitizer, + add_header_string_sanitizer, + add_header_transform, add_oauth_response_sanitizer, add_remove_header_sanitizer, - add_request_subscription_id_sanitizer, + add_storage_request_id_transform, add_uri_regex_sanitizer, + add_uri_string_sanitizer, + add_uri_subscription_id_sanitizer, set_bodiless_matcher, set_custom_default_matcher, set_default_settings, + set_headerless_matcher, ) from .helpers import ResponseCallback, RetryCounter from .fake_credentials import FakeTokenCredential __all__ = [ + "add_api_version_transform", "add_body_key_sanitizer", "add_body_regex_sanitizer", + "add_body_string_sanitizer", + "add_client_id_transform", "add_continuation_sanitizer", "add_general_regex_sanitizer", + "add_general_string_sanitizer", "add_header_regex_sanitizer", + "add_header_string_sanitizer", + "add_header_transform", "add_oauth_response_sanitizer", "add_remove_header_sanitizer", - "add_request_subscription_id_sanitizer", + "add_storage_request_id_transform", "add_uri_regex_sanitizer", + "add_uri_string_sanitizer", + "add_uri_subscription_id_sanitizer", "AzureMgmtTestCase", "AzureMgmtPreparer", "AzureMgmtRecordedTestCase", @@ -70,6 +87,7 @@ "set_bodiless_matcher", "set_custom_default_matcher", "set_default_settings", + "set_headerless_matcher", "start_test_proxy", "stop_test_proxy", "ResponseCallback", diff --git a/tools/azure-sdk-tools/devtools_testutils/sanitizers.py b/tools/azure-sdk-tools/devtools_testutils/sanitizers.py index a06a5b01c69c..3b4209b15d4b 100644 --- a/tools/azure-sdk-tools/devtools_testutils/sanitizers.py +++ b/tools/azure-sdk-tools/devtools_testutils/sanitizers.py @@ -13,10 +13,33 @@ from typing import Any, Optional +def set_default_settings() -> None: + """Resets all active sanitizers, matchers, and transforms for the test proxy to their default settings. + + This will reset any setting customizations for a single test if it is called during test case execution, rather than + at a session, module, or class level. Otherwise, it will reset setting customizations at the session level (i.e. for + all tests). + """ + + x_recording_id = get_recording_id() + _send_reset_request({"x-recording-id": x_recording_id}) + + +# ----------MATCHERS---------- +# +# A matcher is applied during a playback session. The default matcher matches a request on headers, URI, and the body. +# +# This is the least used customization as most adjustments to matching really come down to sanitizing properly before +# storing the recording. Further, when using this customization, it is recommended that one registers matchers during +# individual test case execution so that the adjusting matching only occurs for a specific recording during playback. +# +# ---------------------------- + + def set_bodiless_matcher() -> None: """Adjusts the "match" operation to EXCLUDE the body when matching a request to a recording's entries. - This method must be called during test case execution, rather than at a session, module, or class level. + This method should be called during test case execution, rather than at a session, module, or class level. """ x_recording_id = get_recording_id() @@ -48,16 +71,35 @@ def set_custom_default_matcher(**kwargs: "Any") -> None: _send_matcher_request("CustomDefaultMatcher", {"x-recording-id": x_recording_id}, request_args) -def set_default_settings() -> None: - """Resets all active sanitizers, matchers, and transforms for the test proxy to their default settings. +def set_headerless_matcher() -> None: + """Adjusts the "match" operation to ignore header differences when matching a request. - This will reset any setting customizations for a single test if it is called during test case execution, rather than - at a session, module, or class level. Otherwise, it will reset setting customizations at the session level (i.e. for - all tests). + Be aware that wholly ignoring headers during matching might incur unexpected issues down the line. This method + should be called during test case execution, rather than at a session, module, or class level. """ x_recording_id = get_recording_id() - _send_reset_request({"x-recording-id": x_recording_id}) + _send_matcher_request("HeaderlessMatcher", {"x-recording-id": x_recording_id}) + + +# ----------SANITIZERS---------- +# +# A sanitizer is applied to recordings in two locations: +# +# - Before they are saved. (Affects the session as a whole as well as individual entries) +# - During playback, when a request comes in from a client. This means that only individual entry sanitizers apply in +# this case. +# +# Sanitizers are optionally prefixed with a title that indicates where each sanitizer applies. These prefixes are: +# +# - Uri +# - Header +# - Body +# +# For example, A sanitizer prefixed with Body will only ever operate on the request/response body. The target URI and +# request/response headers will be left unaffected. +# +# ------------------------------ def add_body_key_sanitizer(**kwargs: "Any") -> None: @@ -72,6 +114,10 @@ def add_body_key_sanitizer(**kwargs: "Any") -> None: operation. Defaults to replacing the entire string. :keyword str group_for_replace: The capture group that needs to be operated upon. Do not provide if you're invoking a simple replacement operation. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". """ request_args = _get_request_args(**kwargs) @@ -81,20 +127,43 @@ def add_body_key_sanitizer(**kwargs: "Any") -> None: def add_body_regex_sanitizer(**kwargs: "Any") -> None: """Registers a sanitizer that offers regex replace within a returned body. - Specifically, this means regex applying to the raw JSON. If you are attempting to simply replace a specific key, the - BodyKeySanitizer is probably the way to go. + Specifically, this regex applies to the raw JSON of a body. If you are attempting to simply replace a specific key, + add_body_key_sanitizer is probably more suitable. :keyword str value: The substitution value. :keyword str regex: A regex. Can be defined as a simple regex, or if a ``group_for_replace`` is provided, a substitution operation. :keyword str group_for_replace: The capture group that needs to be operated upon. Do not provide if you're invoking a simple replacement operation. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". """ request_args = _get_request_args(**kwargs) _send_sanitizer_request("BodyRegexSanitizer", request_args) +def add_body_string_sanitizer(**kwargs: "Any") -> None: + """Registers a sanitizer that cleans request and response bodies via straightforward string replacement. + + Specifically, this replacement applies to the raw JSON of a body. If you are attempting to simply replace a specific + key, add_body_key_sanitizer is probably more suitable. + + :keyword str value: The substitution value. + :keyword str target: A target string. This could contain special regex characters like "?()+*" but they will be + treated as a literal. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". + """ + + request_args = _get_request_args(**kwargs) + _send_sanitizer_request("BodyStringSanitizer", request_args) + + def add_continuation_sanitizer(**kwargs: "Any") -> None: """Registers a sanitizer that's used to anonymize private keys in response/request pairs. @@ -123,12 +192,35 @@ def add_general_regex_sanitizer(**kwargs: "Any") -> None: substitution operation. :keyword str group_for_replace: The capture group that needs to be operated upon. Do not provide if you're invoking a simple replacement operation. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". """ request_args = _get_request_args(**kwargs) _send_sanitizer_request("GeneralRegexSanitizer", request_args) +def add_general_string_sanitizer(**kwargs: "Any") -> None: + """Registers a santiizer that cleans request and response URIs, headers, and bodies via string replacement. + + This sanitizer offers a value replace across request/response bodies, headers, and URIs. For the body, this means a + string replacement applied directly to the raw JSON. + + :keyword str value: The substitution value. + :keyword str target: A target string. This could contain special regex characters like "?()+*" but they will be + treated as a literal. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". + """ + + request_args = _get_request_args(**kwargs) + _send_sanitizer_request("GeneralStringSanitizer", request_args) + + def add_header_regex_sanitizer(**kwargs: "Any") -> None: """Registers a sanitizer that offers regex replace on returned headers. @@ -142,12 +234,35 @@ def add_header_regex_sanitizer(**kwargs: "Any") -> None: substitution operation. :keyword str group_for_replace: The capture group that needs to be operated upon. Do not provide if you're invoking a simple replacement operation. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". """ request_args = _get_request_args(**kwargs) _send_sanitizer_request("HeaderRegexSanitizer", request_args) +def add_header_string_sanitizer(**kwargs: "Any") -> None: + """Registers a sanitizer that cleans headers in a recording via straightforward string replacement. + + This sanitizer ONLY applies to the request/response headers -- bodies and URIs are left untouched. + + :keyword str key: The name of the header we're operating against. + :keyword str target: A target string. This could contain special regex characters like "?()+*" but they will be + treated as a literal. + :keyword str value: The substitution value. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". + """ + + request_args = _get_request_args(**kwargs) + _send_sanitizer_request("HeaderStringSanitizer", request_args) + + def add_oauth_response_sanitizer() -> None: """Registers a sanitizer that cleans out all request/response pairs that match an oauth regex in their URI.""" @@ -159,24 +274,16 @@ def add_remove_header_sanitizer(**kwargs: "Any") -> None: :keyword str headers: A comma separated list. Should look like "Location, Transfer-Encoding" or something along those lines. Don't worry about whitespace between the commas separating each key. They will be ignored. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". """ request_args = _get_request_args(**kwargs) _send_sanitizer_request("RemoveHeaderSanitizer", request_args) -def add_request_subscription_id_sanitizer(**kwargs: "Any") -> None: - """Registers a sanitizer that replaces subscription IDs in requests. - - Subscription IDs are replaced with "00000000-0000-0000-0000-000000000000" by default. - - :keyword str value: The fake subscriptionId that will be placed where the real one is in the real request. - """ - - request_args = _get_request_args(**kwargs) - _send_sanitizer_request("ReplaceRequestSubscriptionId", request_args) - - def add_uri_regex_sanitizer(**kwargs: "Any") -> None: """Registers a sanitizer for cleaning URIs via regex. @@ -185,18 +292,103 @@ def add_uri_regex_sanitizer(**kwargs: "Any") -> None: substitution operation. :keyword str group_for_replace: The capture group that needs to be operated upon. Do not provide if you're invoking a simple replacement operation. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". """ request_args = _get_request_args(**kwargs) _send_sanitizer_request("UriRegexSanitizer", request_args) +def add_uri_string_sanitizer(**kwargs: "Any") -> None: + """Registers a sanitizer that cleans URIs via straightforward string replacement. + + Runs a simple string replacement against the request/response URIs. + + :keyword str value: The substitution value. + :keyword str target: A target string. This could contain special regex characters like "?()+*" but they will be + treated as a literal. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". + """ + + request_args = _get_request_args(**kwargs) + _send_sanitizer_request("UriStringSanitizer", request_args) + + +def add_uri_subscription_id_sanitizer(**kwargs: "Any") -> None: + """Registers a sanitizer that replaces subscription IDs in URIs. + + This sanitizer ONLY affects the URI of a request/response pair. Subscription IDs are replaced with + "00000000-0000-0000-0000-000000000000" by default. + + :keyword str value: The fake subscription ID that will be placed where the real one is in the real request. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". + """ + + request_args = _get_request_args(**kwargs) + _send_sanitizer_request("UriSubscriptionIdSanitizer", request_args) + + +# ----------TRANSFORMS---------- +# +# A transform extends functionality of the test proxy by applying to responses just before they are returned during +# playback mode. +# +# ------------------------------ + + +def add_api_version_transform() -> None: + """Registers a transform that copies a request's "api-version" header onto the response before returning it.""" + + _send_transform_request("ApiVersionTransform", {}) + + +def add_client_id_transform() -> None: + """Registers a transform that copies a request's "x-ms-client-id" header onto the response before returning it.""" + + _send_transform_request("ClientIdTransform", {}) + + +def add_header_transform(**kwargs: "Any") -> None: + """Registers a transform that sets a header in a response. + + :keyword str key: The key for the header. + :keyword str value: The value for the header. + :keyword str condition: A condition that dictates when this sanitizer applies to a request/response pair. The + content of this should be a JSON object that contains configuration keys. Currently, that only includes the key + "uriRegex". This translates to an object that looks like '{ "uriRegex": "when this regex matches, apply the + sanitizer" }'. Defaults to "apply always". + """ + + request_args = _get_request_args(**kwargs) + _send_transform_request("HeaderTransform", request_args) + + +def add_storage_request_id_transform() -> None: + """Registers a transform that ensures a response's "x-ms-client-request-id" header matches the request's.""" + + _send_transform_request("StorageRequestIdTransform", {}) + + +# ----------HELPERS---------- + + def _get_request_args(**kwargs: "Any") -> dict: """Returns a dictionary of sanitizer constructor headers""" request_args = {} if "compare_bodies" in kwargs: request_args["compareBodies"] = kwargs.get("compare_bodies") + if "condition" in kwargs: + request_args["condition"] = kwargs.get("condition") if "excluded_headers" in kwargs: request_args["excludedHeaders"] = kwargs.get("excluded_headers") if "group_for_replace" in kwargs: @@ -283,4 +475,25 @@ def _send_sanitizer_request(sanitizer: str, parameters: dict) -> None: headers={"x-abstraction-identifier": sanitizer, "Content-Type": "application/json"}, json=parameters ) + breakpoint() + response.raise_for_status() + + +def _send_transform_request(transform: str, parameters: dict) -> None: + """Sends a POST request to the test proxy endpoint to register the specified transform. + + If live tests are being run, no request will be sent. + + :param str transform: The name of the transform to add. + :param dict parameters: The transform constructor parameters, as a dictionary. + """ + + if is_live(): + return + + response = requests.post( + f"{PROXY_URL}/Admin/AddTransform", + headers={"x-abstraction-identifier": transform, "Content-Type": "application/json"}, + json=parameters + ) response.raise_for_status() From 8b8ea65c59976da0251d32a4035fc3e88e393759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McCoy=20Pati=C3=B1o?= Date: Thu, 21 Jul 2022 16:49:26 -0700 Subject: [PATCH 2/3] Function and session reset methods --- .../devtools_testutils/__init__.py | 6 +++-- .../devtools_testutils/sanitizers.py | 26 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tools/azure-sdk-tools/devtools_testutils/__init__.py b/tools/azure-sdk-tools/devtools_testutils/__init__.py index 24e61ebc1da2..dc7636d0a25a 100644 --- a/tools/azure-sdk-tools/devtools_testutils/__init__.py +++ b/tools/azure-sdk-tools/devtools_testutils/__init__.py @@ -40,7 +40,8 @@ add_uri_subscription_id_sanitizer, set_bodiless_matcher, set_custom_default_matcher, - set_default_settings, + set_default_function_settings, + set_default_session_settings, set_headerless_matcher, ) from .helpers import ResponseCallback, RetryCounter @@ -86,7 +87,8 @@ "test_proxy", "set_bodiless_matcher", "set_custom_default_matcher", - "set_default_settings", + "set_default_function_settings", + "set_default_session_settings", "set_headerless_matcher", "start_test_proxy", "stop_test_proxy", diff --git a/tools/azure-sdk-tools/devtools_testutils/sanitizers.py b/tools/azure-sdk-tools/devtools_testutils/sanitizers.py index 3b4209b15d4b..b830a767f840 100644 --- a/tools/azure-sdk-tools/devtools_testutils/sanitizers.py +++ b/tools/azure-sdk-tools/devtools_testutils/sanitizers.py @@ -13,18 +13,33 @@ from typing import Any, Optional -def set_default_settings() -> None: - """Resets all active sanitizers, matchers, and transforms for the test proxy to their default settings. +def set_default_function_settings() -> None: + """Resets sanitizers, matchers, and transforms for the test proxy to their default settings, for the current test. - This will reset any setting customizations for a single test if it is called during test case execution, rather than - at a session, module, or class level. Otherwise, it will reset setting customizations at the session level (i.e. for - all tests). + This will reset any setting customizations for a single test. This must be called during test case execution, rather + than at a session, module, or class level. To reset setting customizations for all tests, use + `set_default_session_settings` instead. """ x_recording_id = get_recording_id() + if x_recording_id is None: + raise RuntimeError( + "This method must be called during test case execution. To reset test proxy settings at a session level, " + "use `set_default_session_settings` instead." + ) _send_reset_request({"x-recording-id": x_recording_id}) +def set_default_session_settings() -> None: + """Resets sanitizers, matchers, and transforms for the test proxy to their default settings, for all tests. + + This will reset any setting customizations for an entire test session. To reset setting customizations for a single + test -- which is recommended -- use `set_default_session_settings` instead. + """ + + _send_reset_request({}) + + # ----------MATCHERS---------- # # A matcher is applied during a playback session. The default matcher matches a request on headers, URI, and the body. @@ -475,7 +490,6 @@ def _send_sanitizer_request(sanitizer: str, parameters: dict) -> None: headers={"x-abstraction-identifier": sanitizer, "Content-Type": "application/json"}, json=parameters ) - breakpoint() response.raise_for_status() From 928490f7e0e9586f4af32ff63e2a3ec084f268b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McCoy=20Pati=C3=B1o?= Date: Thu, 21 Jul 2022 16:58:15 -0700 Subject: [PATCH 3/3] Thanks, cspell! --- tools/azure-sdk-tools/devtools_testutils/sanitizers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/azure-sdk-tools/devtools_testutils/sanitizers.py b/tools/azure-sdk-tools/devtools_testutils/sanitizers.py index b830a767f840..27136491c8c9 100644 --- a/tools/azure-sdk-tools/devtools_testutils/sanitizers.py +++ b/tools/azure-sdk-tools/devtools_testutils/sanitizers.py @@ -218,7 +218,7 @@ def add_general_regex_sanitizer(**kwargs: "Any") -> None: def add_general_string_sanitizer(**kwargs: "Any") -> None: - """Registers a santiizer that cleans request and response URIs, headers, and bodies via string replacement. + """Registers a sanitizer that cleans request and response URIs, headers, and bodies via string replacement. This sanitizer offers a value replace across request/response bodies, headers, and URIs. For the body, this means a string replacement applied directly to the raw JSON.