From 908a2900d78fe13e9ed2cb16a97611da76535672 Mon Sep 17 00:00:00 2001 From: Carter Tinney Date: Tue, 1 Sep 2026 12:36:57 -0700 Subject: [PATCH 1/4] fix: avoid globally monkeypatching Requests for Edge HSM Give each Edge HSM signing mechanism its own Unix-socket session so importing the SDK no longer replaces process-wide Requests helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/iot/device/iothub/edge_hsm.py | 6 +-- tests/unit/iothub/test_edge_hsm.py | 38 +++++++++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/azure-iot-device/azure/iot/device/iothub/edge_hsm.py b/azure-iot-device/azure/iot/device/iothub/edge_hsm.py index 4c23beabb..b0c2145fb 100644 --- a/azure-iot-device/azure/iot/device/iothub/edge_hsm.py +++ b/azure-iot-device/azure/iot/device/iothub/edge_hsm.py @@ -13,7 +13,6 @@ from azure.iot.device.common.auth.signing_mechanism import SigningMechanism from azure.iot.device import user_agent -requests_unixsocket.monkeypatch() logger = logging.getLogger(__name__) @@ -47,6 +46,7 @@ def __init__(self, module_id, generation_id, workload_uri, api_version): self.api_version = api_version self.generation_id = generation_id self.workload_uri = _format_socket_uri(workload_uri) + self._session = requests_unixsocket.Session() def get_certificate(self): """ @@ -58,7 +58,7 @@ def get_certificate(self): :raises: IoTEdgeError if unable to retrieve the certificate. """ - r = requests.get( + r = self._session.get( self.workload_uri + "trust-bundle", params={"api-version": self.api_version}, headers={"User-Agent": urllib.parse.quote_plus(user_agent.get_iothub_user_agent())}, @@ -99,7 +99,7 @@ def sign(self, data_str): ) sign_request = {"keyId": "primary", "algo": "HMACSHA256", "data": encoded_data_str} - r = requests.post( # can we use json field instead of data? + r = self._session.post( # can we use json field instead of data? url=path, params={"api-version": self.api_version}, headers={"User-Agent": urllib.parse.quote(user_agent.get_iothub_user_agent(), safe="")}, diff --git a/tests/unit/iothub/test_edge_hsm.py b/tests/unit/iothub/test_edge_hsm.py index 8472ad11f..74e21d3b8 100644 --- a/tests/unit/iothub/test_edge_hsm.py +++ b/tests/unit/iothub/test_edge_hsm.py @@ -7,13 +7,13 @@ import pytest import logging import requests +import requests_unixsocket import json import base64 import urllib from azure.iot.device.iothub.edge_hsm import IoTEdgeHsm, IoTEdgeError from azure.iot.device import user_agent - logging.basicConfig(level=logging.DEBUG) @@ -29,6 +29,20 @@ def edge_hsm(): @pytest.mark.describe("IoTEdgeHsm - Instantiation") class TestIoTEdgeHsmInstantiation(object): + @pytest.mark.it("Creates a private Unix socket requests session") + def test_creates_unix_socket_session(self, mocker): + mock_session_constructor = mocker.patch.object(requests_unixsocket, "Session") + + edge_hsm = IoTEdgeHsm( + module_id="my_module_id", + generation_id="my_generation_id", + workload_uri="unix:///var/run/iotedge/workload.sock", + api_version="my_api_version", + ) + + assert edge_hsm._session is mock_session_constructor.return_value + assert mock_session_constructor.call_args == mocker.call() + @pytest.mark.it("URL encodes the provided module_id parameter and sets it as an attribute") def test_encode_and_set_module_id(self): module_id = "my_module_id" @@ -110,7 +124,7 @@ def test_set_api_version(self): class TestIoTEdgeHsmGetCertificate(object): @pytest.mark.it("Sends an HTTP GET request to retrieve the trust bundle from Edge") def test_requests_trust_bundle(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(requests, "get") + mock_request_get = mocker.patch.object(edge_hsm._session, "get") expected_url = edge_hsm.workload_uri + "trust-bundle" expected_params = {"api-version": edge_hsm.api_version} expected_headers = { @@ -126,7 +140,7 @@ def test_requests_trust_bundle(self, mocker, edge_hsm): @pytest.mark.it("Returns the certificate from the trust bundle received from Edge") def test_returns_certificate(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(requests, "get") + mock_request_get = mocker.patch.object(edge_hsm._session, "get") mock_response = mock_request_get.return_value certificate = "my certificate" mock_response.json.return_value = {"certificate": certificate} @@ -137,7 +151,7 @@ def test_returns_certificate(self, mocker, edge_hsm): @pytest.mark.it("Raises IoTEdgeError if a bad request is made to Edge") def test_bad_request(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(requests, "get") + mock_request_get = mocker.patch.object(edge_hsm._session, "get") mock_response = mock_request_get.return_value error = requests.exceptions.HTTPError() mock_response.raise_for_status.side_effect = error @@ -148,7 +162,7 @@ def test_bad_request(self, mocker, edge_hsm): @pytest.mark.it("Raises IoTEdgeError if there is an error in json decoding the trust bundle") def test_bad_json(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(requests, "get") + mock_request_get = mocker.patch.object(edge_hsm._session, "get") mock_response = mock_request_get.return_value error = ValueError() mock_response.json.side_effect = error @@ -159,7 +173,7 @@ def test_bad_json(self, mocker, edge_hsm): @pytest.mark.it("Raises IoTEdgeError if the certificate is missing from the trust bundle") def test_bad_trust_bundle(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(requests, "get") + mock_request_get = mocker.patch.object(edge_hsm._session, "get") mock_response = mock_request_get.return_value # Return an empty json dict with no 'certificate' key mock_response.json.return_value = {} @@ -176,7 +190,7 @@ class TestIoTEdgeHsmSign(object): def test_requests_data_signing(self, mocker, edge_hsm): data_str = "somedata" data_str_b64 = "c29tZWRhdGE=" - mock_request_post = mocker.patch.object(requests, "post") + mock_request_post = mocker.patch.object(edge_hsm._session, "post") mock_request_post.return_value.json.return_value = {"digest": "somedigest"} expected_url = "{workload_uri}modules/{module_id}/genid/{generation_id}/sign".format( workload_uri=edge_hsm.workload_uri, @@ -202,7 +216,7 @@ def test_b64_encodes_data(self, mocker, edge_hsm): # important to have an explicit test for it since it's a requirement data_str = "somedata" data_str_b64 = base64.b64encode(data_str.encode("utf-8")).decode() - mock_request_post = mocker.patch.object(requests, "post") + mock_request_post = mocker.patch.object(edge_hsm._session, "post") mock_request_post.return_value.json.return_value = {"digest": "somedigest"} edge_hsm.sign(data_str) @@ -215,7 +229,7 @@ def test_b64_encodes_data(self, mocker, edge_hsm): @pytest.mark.it("Returns the signed data received from Edge") def test_returns_signed_data(self, mocker, edge_hsm): expected_digest = "somedigest" - mock_request_post = mocker.patch.object(requests, "post") + mock_request_post = mocker.patch.object(edge_hsm._session, "post") mock_request_post.return_value.json.return_value = {"digest": expected_digest} signed_data = edge_hsm.sign("somedata") @@ -224,7 +238,7 @@ def test_returns_signed_data(self, mocker, edge_hsm): @pytest.mark.it("Raises IoTEdgeError if a bad request is made to EdgeHub") def test_bad_request(self, mocker, edge_hsm): - mock_request_post = mocker.patch.object(requests, "post") + mock_request_post = mocker.patch.object(edge_hsm._session, "post") mock_response = mock_request_post.return_value error = requests.exceptions.HTTPError() mock_response.raise_for_status.side_effect = error @@ -235,7 +249,7 @@ def test_bad_request(self, mocker, edge_hsm): @pytest.mark.it("Raises IoTEdgeError if there is an error in json decoding the signed response") def test_bad_json(self, mocker, edge_hsm): - mock_request_post = mocker.patch.object(requests, "post") + mock_request_post = mocker.patch.object(edge_hsm._session, "post") mock_response = mock_request_post.return_value error = ValueError() mock_response.json.side_effect = error @@ -245,7 +259,7 @@ def test_bad_json(self, mocker, edge_hsm): @pytest.mark.it("Raises IoTEdgeError if the signed data is missing from the response") def test_bad_response(self, mocker, edge_hsm): - mock_request_post = mocker.patch.object(requests, "post") + mock_request_post = mocker.patch.object(edge_hsm._session, "post") mock_response = mock_request_post.return_value mock_response.json.return_value = {} From 8632ef57d65b0ad619788b808e95c9ceefd7d622 Mon Sep 17 00:00:00 2001 From: Carter Tinney Date: Tue, 1 Sep 2026 12:51:43 -0700 Subject: [PATCH 2/4] fix: close Edge HSM sessions with client lifecycle Close private Unix-socket sessions during sync and async client shutdown and on Edge client construction failures. Add direct regression coverage ensuring module import never invokes the global Requests monkeypatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../iot/device/iothub/abstract_clients.py | 87 +++++++++++-------- .../iot/device/iothub/aio/async_clients.py | 2 + .../azure/iot/device/iothub/edge_hsm.py | 4 + .../azure/iot/device/iothub/sync_clients.py | 2 + tests/unit/iothub/aio/test_async_clients.py | 12 +++ tests/unit/iothub/shared_client_tests.py | 3 + tests/unit/iothub/test_edge_hsm.py | 48 +++++++--- tests/unit/iothub/test_sync_clients.py | 11 +++ 8 files changed, 121 insertions(+), 48 deletions(-) diff --git a/azure-iot-device/azure/iot/device/iothub/abstract_clients.py b/azure-iot-device/azure/iot/device/iothub/abstract_clients.py index a867dd614..351216696 100644 --- a/azure-iot-device/azure/iot/device/iothub/abstract_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/abstract_clients.py @@ -127,6 +127,12 @@ def __init__(self, mqtt_pipeline: MQTTPipeline, http_pipeline: HTTPPipeline) -> self._handler_manager = None # this will be overridden in child class self._receive_type = RECEIVE_TYPE_NONE_SET self._client_lock = threading.Lock() + self._edge_hsm = None + + def _close_edge_hsm(self) -> None: + if self._edge_hsm is not None: + self._edge_hsm.close() + self._edge_hsm = None def _on_connected(self) -> None: """Helper handler that is called upon an iothub pipeline connect""" @@ -804,6 +810,7 @@ def create_from_edge_environment(cls, **kwargs) -> Self: key=connection_string[cs.SHARED_ACCESS_KEY] ) + hsm = None else: # Use an HSM for authentication in the general case hsm = edge_hsm.IoTEdgeHsm( @@ -812,46 +819,56 @@ def create_from_edge_environment(cls, **kwargs) -> Self: workload_uri=workload_uri, api_version=api_version, ) - try: - server_verification_cert = hsm.get_certificate() - except edge_hsm.IoTEdgeError as e: - new_err = OSError("Unexpected failure in IoTEdge") - new_err.__cause__ = e - raise new_err + signing_mechanism = hsm - # Create SasToken - uri = _form_sas_uri(hostname=hostname, device_id=device_id, module_id=module_id) - token_ttl = kwargs.get("sastoken_ttl", 3600) try: - sastoken = st.RenewableSasToken(uri, signing_mechanism, ttl=token_ttl) - except st.SasTokenError as e: - new_val_err = ValueError( - "Could not create a SasToken using the values provided, or in the Edge environment" - ) - new_val_err.__cause__ = e - raise new_val_err - - # Pipeline Config setup - config_kwargs = _get_config_kwargs(**kwargs) - pipeline_configuration = pipeline.IoTHubPipelineConfig( - device_id=device_id, - module_id=module_id, - hostname=hostname, - gateway_hostname=gateway_hostname, - sastoken=sastoken, - server_verification_cert=server_verification_cert, - **config_kwargs, - ) - pipeline_configuration.method_invoke = ( - True # Method Invoke is allowed on modules created from edge environment - ) + if hsm is not None: + try: + server_verification_cert = hsm.get_certificate() + except edge_hsm.IoTEdgeError as e: + new_err = OSError("Unexpected failure in IoTEdge") + new_err.__cause__ = e + raise new_err + + # Create SasToken + uri = _form_sas_uri(hostname=hostname, device_id=device_id, module_id=module_id) + token_ttl = kwargs.get("sastoken_ttl", 3600) + try: + sastoken = st.RenewableSasToken(uri, signing_mechanism, ttl=token_ttl) + except st.SasTokenError as e: + new_val_err = ValueError( + "Could not create a SasToken using the values provided, or in the Edge environment" + ) + new_val_err.__cause__ = e + raise new_val_err - # Pipeline setup - http_pipeline = pipeline.HTTPPipeline(pipeline_configuration) - mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) + # Pipeline Config setup + config_kwargs = _get_config_kwargs(**kwargs) + pipeline_configuration = pipeline.IoTHubPipelineConfig( + device_id=device_id, + module_id=module_id, + hostname=hostname, + gateway_hostname=gateway_hostname, + sastoken=sastoken, + server_verification_cert=server_verification_cert, + **config_kwargs, + ) + pipeline_configuration.method_invoke = ( + True # Method Invoke is allowed on modules created from edge environment + ) - return cls(mqtt_pipeline, http_pipeline) + # Pipeline setup + http_pipeline = pipeline.HTTPPipeline(pipeline_configuration) + mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) + client = cls(mqtt_pipeline, http_pipeline) + except Exception: + if hsm is not None: + hsm.close() + raise + + client._edge_hsm = hsm + return client @classmethod def create_from_x509_certificate( diff --git a/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py b/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py index 6eaca9c1a..5ea1fa7a6 100644 --- a/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py @@ -197,6 +197,8 @@ async def shutdown(self) -> None: # Stop the Client Event handlers now that everything else is completed self._handler_manager.stop(receiver_handlers_only=False) + self._close_edge_hsm() + # Yes, that means the pipeline is disconnected twice (well, actually three times if you # consider that the client-level disconnect causes two pipeline-level disconnects for # reasons explained in comments in the client's .disconnect() method). diff --git a/azure-iot-device/azure/iot/device/iothub/edge_hsm.py b/azure-iot-device/azure/iot/device/iothub/edge_hsm.py index b0c2145fb..7166cd9f6 100644 --- a/azure-iot-device/azure/iot/device/iothub/edge_hsm.py +++ b/azure-iot-device/azure/iot/device/iothub/edge_hsm.py @@ -48,6 +48,10 @@ def __init__(self, module_id, generation_id, workload_uri, api_version): self.workload_uri = _format_socket_uri(workload_uri) self._session = requests_unixsocket.Session() + def close(self): + """Release resources held by the Unix socket session.""" + self._session.close() + def get_certificate(self): """ Return the server verification certificate from the trust bundle that can be used to diff --git a/azure-iot-device/azure/iot/device/iothub/sync_clients.py b/azure-iot-device/azure/iot/device/iothub/sync_clients.py index 4088e0b6f..8d04a77b6 100644 --- a/azure-iot-device/azure/iot/device/iothub/sync_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/sync_clients.py @@ -188,6 +188,8 @@ def shutdown(self) -> None: if self._handler_manager is not None: self._handler_manager.stop(receiver_handlers_only=False) + self._close_edge_hsm() + # Yes, that means the pipeline is disconnected twice (well, actually three times if you # consider that the client-level disconnect causes two pipeline-level disconnects for # reasons explained in comments in the client's .disconnect() method). diff --git a/tests/unit/iothub/aio/test_async_clients.py b/tests/unit/iothub/aio/test_async_clients.py index 90c4f34d5..69e45a0d5 100644 --- a/tests/unit/iothub/aio/test_async_clients.py +++ b/tests/unit/iothub/aio/test_async_clients.py @@ -173,6 +173,18 @@ def check_handlers_and_complete(callback): assert hm_stop_spy.call_count == 1 assert hm_stop_spy.call_args == mocker.call(receiver_handlers_only=False) + @pytest.mark.it("Closes the Edge HSM") + async def test_closes_edge_hsm(self, mocker, client): + client.disconnect = mocker.MagicMock() + client.disconnect.return_value = await create_completed_future(None) + mock_edge_hsm = mocker.MagicMock() + client._edge_hsm = mock_edge_hsm + + await client.shutdown() + + assert mock_edge_hsm.close.call_count == 1 + assert client._edge_hsm is None + class SharedClientConnectTests(object): @pytest.mark.it("Begins a 'connect' pipeline operation") diff --git a/tests/unit/iothub/shared_client_tests.py b/tests/unit/iothub/shared_client_tests.py index 1c0780b6c..2d2314173 100644 --- a/tests/unit/iothub/shared_client_tests.py +++ b/tests/unit/iothub/shared_client_tests.py @@ -1754,6 +1754,7 @@ def test_client_returns( assert isinstance(client, client_class) assert client._mqtt_pipeline is mock_mqtt_pipeline_init.return_value assert client._http_pipeline is mock_http_pipeline_init.return_value + assert client._edge_hsm is mock_edge_hsm.return_value @pytest.mark.it("Raises OSError if the environment is missing required variables") @pytest.mark.parametrize( @@ -1789,6 +1790,7 @@ def test_bad_edge_auth(self, mocker, client_class, edge_container_environment, m with pytest.raises(OSError) as e_info: client_class.create_from_edge_environment() assert e_info.value.__cause__ is my_edge_error + assert mock_edge_hsm.return_value.close.call_count == 1 @pytest.mark.it("Raises ValueError if a SasToken creation results in failure") def test_raises_value_error_on_sastoken_failure( @@ -1802,6 +1804,7 @@ def test_raises_value_error_on_sastoken_failure( with pytest.raises(ValueError) as e_info: client_class.create_from_edge_environment() assert e_info.value.__cause__ is token_err + assert mock_edge_hsm.return_value.close.call_count == 1 @pytest.mark.usefixtures("mock_mqtt_pipeline_init", "mock_http_pipeline_init") diff --git a/tests/unit/iothub/test_edge_hsm.py b/tests/unit/iothub/test_edge_hsm.py index 74e21d3b8..ca189f293 100644 --- a/tests/unit/iothub/test_edge_hsm.py +++ b/tests/unit/iothub/test_edge_hsm.py @@ -4,6 +4,7 @@ # license information. # -------------------------------------------------------------------------- +import importlib import pytest import logging import requests @@ -11,7 +12,7 @@ import json import base64 import urllib -from azure.iot.device.iothub.edge_hsm import IoTEdgeHsm, IoTEdgeError +from azure.iot.device.iothub import edge_hsm as edge_hsm_module from azure.iot.device import user_agent logging.basicConfig(level=logging.DEBUG) @@ -19,21 +20,31 @@ @pytest.fixture def edge_hsm(): - return IoTEdgeHsm( + hsm = edge_hsm_module.IoTEdgeHsm( module_id="my_module_id", generation_id="module_generation_id", workload_uri="unix:///var/run/iotedge/workload.sock", api_version="my_api_version", ) + yield hsm + hsm.close() @pytest.mark.describe("IoTEdgeHsm - Instantiation") class TestIoTEdgeHsmInstantiation(object): + @pytest.mark.it("Does not monkeypatch the global requests API when imported") + def test_does_not_monkeypatch_requests(self, mocker): + mock_monkeypatch = mocker.patch.object(requests_unixsocket, "monkeypatch") + + importlib.reload(edge_hsm_module) + + assert mock_monkeypatch.call_count == 0 + @pytest.mark.it("Creates a private Unix socket requests session") def test_creates_unix_socket_session(self, mocker): mock_session_constructor = mocker.patch.object(requests_unixsocket, "Session") - edge_hsm = IoTEdgeHsm( + edge_hsm = edge_hsm_module.IoTEdgeHsm( module_id="my_module_id", generation_id="my_generation_id", workload_uri="unix:///var/run/iotedge/workload.sock", @@ -50,7 +61,7 @@ def test_encode_and_set_module_id(self): api_version = "my_api_version" workload_uri = "unix:///var/run/iotedge/workload.sock" - edge_hsm = IoTEdgeHsm( + edge_hsm = edge_hsm_module.IoTEdgeHsm( module_id=module_id, generation_id=generation_id, workload_uri=workload_uri, @@ -78,7 +89,7 @@ def test_workload_uri_formatting(self, workload_uri, expected_formatted_uri): generation_id = "my_generation_id" api_version = "my_api_version" - edge_hsm = IoTEdgeHsm( + edge_hsm = edge_hsm_module.IoTEdgeHsm( module_id=module_id, generation_id=generation_id, workload_uri=workload_uri, @@ -94,7 +105,7 @@ def test_set_generation_id(self): api_version = "my_api_version" workload_uri = "unix:///var/run/iotedge/workload.sock" - edge_hsm = IoTEdgeHsm( + edge_hsm = edge_hsm_module.IoTEdgeHsm( module_id=module_id, generation_id=generation_id, workload_uri=workload_uri, @@ -110,7 +121,7 @@ def test_set_api_version(self): api_version = "my_api_version" workload_uri = "unix:///var/run/iotedge/workload.sock" - edge_hsm = IoTEdgeHsm( + edge_hsm = edge_hsm_module.IoTEdgeHsm( module_id=module_id, generation_id=generation_id, workload_uri=workload_uri, @@ -156,7 +167,7 @@ def test_bad_request(self, mocker, edge_hsm): error = requests.exceptions.HTTPError() mock_response.raise_for_status.side_effect = error - with pytest.raises(IoTEdgeError) as e_info: + with pytest.raises(edge_hsm_module.IoTEdgeError) as e_info: edge_hsm.get_certificate() assert e_info.value.__cause__ is error @@ -167,7 +178,7 @@ def test_bad_json(self, mocker, edge_hsm): error = ValueError() mock_response.json.side_effect = error - with pytest.raises(IoTEdgeError) as e_info: + with pytest.raises(edge_hsm_module.IoTEdgeError) as e_info: edge_hsm.get_certificate() assert e_info.value.__cause__ is error @@ -178,7 +189,7 @@ def test_bad_trust_bundle(self, mocker, edge_hsm): # Return an empty json dict with no 'certificate' key mock_response.json.return_value = {} - with pytest.raises(IoTEdgeError): + with pytest.raises(edge_hsm_module.IoTEdgeError): edge_hsm.get_certificate() @@ -243,7 +254,7 @@ def test_bad_request(self, mocker, edge_hsm): error = requests.exceptions.HTTPError() mock_response.raise_for_status.side_effect = error - with pytest.raises(IoTEdgeError) as e_info: + with pytest.raises(edge_hsm_module.IoTEdgeError) as e_info: edge_hsm.sign("somedata") assert e_info.value.__cause__ is error @@ -253,7 +264,7 @@ def test_bad_json(self, mocker, edge_hsm): mock_response = mock_request_post.return_value error = ValueError() mock_response.json.side_effect = error - with pytest.raises(IoTEdgeError) as e_info: + with pytest.raises(edge_hsm_module.IoTEdgeError) as e_info: edge_hsm.sign("somedata") assert e_info.value.__cause__ is error @@ -263,5 +274,16 @@ def test_bad_response(self, mocker, edge_hsm): mock_response = mock_request_post.return_value mock_response.json.return_value = {} - with pytest.raises(IoTEdgeError): + with pytest.raises(edge_hsm_module.IoTEdgeError): edge_hsm.sign("somedata") + + +@pytest.mark.describe("IoTEdgeHsm - .close()") +class TestIoTEdgeHsmClose(object): + @pytest.mark.it("Closes the private Unix socket session") + def test_closes_session(self, mocker, edge_hsm): + mock_close = mocker.patch.object(edge_hsm._session, "close") + + edge_hsm.close() + + assert mock_close.call_args == mocker.call() diff --git a/tests/unit/iothub/test_sync_clients.py b/tests/unit/iothub/test_sync_clients.py index e89922e4b..9837ee8d2 100644 --- a/tests/unit/iothub/test_sync_clients.py +++ b/tests/unit/iothub/test_sync_clients.py @@ -175,6 +175,17 @@ def check_handlers_and_complete(callback): assert hm_stop_spy.call_count == 1 assert hm_stop_spy.call_args == mocker.call(receiver_handlers_only=False) + @pytest.mark.it("Closes the Edge HSM") + def test_closes_edge_hsm(self, mocker, client): + client.disconnect = mocker.MagicMock() + mock_edge_hsm = mocker.MagicMock() + client._edge_hsm = mock_edge_hsm + + client.shutdown() + + assert mock_edge_hsm.close.call_count == 1 + assert client._edge_hsm is None + class SharedClientConnectTests(WaitsForEventCompletion): @pytest.mark.it("Begins a 'connect' pipeline operation") From be77e2dbb4f39be6e93106d0d29ec011dab60bc5 Mon Sep 17 00:00:00 2001 From: Carter Tinney Date: Wed, 2 Sep 2026 07:26:34 -0700 Subject: [PATCH 3/4] fix: close Edge HSM sessions per operation Use context-managed Unix-socket sessions for each trust-bundle and signing request so cleanup does not depend on the client shutdown lifecycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../iot/device/iothub/abstract_clients.py | 87 ++++++--------- .../iot/device/iothub/aio/async_clients.py | 2 - .../azure/iot/device/iothub/edge_hsm.py | 88 ++++++++------- .../azure/iot/device/iothub/sync_clients.py | 2 - tests/unit/iothub/aio/test_async_clients.py | 12 --- tests/unit/iothub/shared_client_tests.py | 3 - tests/unit/iothub/test_edge_hsm.py | 101 +++++++++--------- tests/unit/iothub/test_sync_clients.py | 11 -- 8 files changed, 128 insertions(+), 178 deletions(-) diff --git a/azure-iot-device/azure/iot/device/iothub/abstract_clients.py b/azure-iot-device/azure/iot/device/iothub/abstract_clients.py index 351216696..a867dd614 100644 --- a/azure-iot-device/azure/iot/device/iothub/abstract_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/abstract_clients.py @@ -127,12 +127,6 @@ def __init__(self, mqtt_pipeline: MQTTPipeline, http_pipeline: HTTPPipeline) -> self._handler_manager = None # this will be overridden in child class self._receive_type = RECEIVE_TYPE_NONE_SET self._client_lock = threading.Lock() - self._edge_hsm = None - - def _close_edge_hsm(self) -> None: - if self._edge_hsm is not None: - self._edge_hsm.close() - self._edge_hsm = None def _on_connected(self) -> None: """Helper handler that is called upon an iothub pipeline connect""" @@ -810,7 +804,6 @@ def create_from_edge_environment(cls, **kwargs) -> Self: key=connection_string[cs.SHARED_ACCESS_KEY] ) - hsm = None else: # Use an HSM for authentication in the general case hsm = edge_hsm.IoTEdgeHsm( @@ -819,56 +812,46 @@ def create_from_edge_environment(cls, **kwargs) -> Self: workload_uri=workload_uri, api_version=api_version, ) - + try: + server_verification_cert = hsm.get_certificate() + except edge_hsm.IoTEdgeError as e: + new_err = OSError("Unexpected failure in IoTEdge") + new_err.__cause__ = e + raise new_err signing_mechanism = hsm + # Create SasToken + uri = _form_sas_uri(hostname=hostname, device_id=device_id, module_id=module_id) + token_ttl = kwargs.get("sastoken_ttl", 3600) try: - if hsm is not None: - try: - server_verification_cert = hsm.get_certificate() - except edge_hsm.IoTEdgeError as e: - new_err = OSError("Unexpected failure in IoTEdge") - new_err.__cause__ = e - raise new_err - - # Create SasToken - uri = _form_sas_uri(hostname=hostname, device_id=device_id, module_id=module_id) - token_ttl = kwargs.get("sastoken_ttl", 3600) - try: - sastoken = st.RenewableSasToken(uri, signing_mechanism, ttl=token_ttl) - except st.SasTokenError as e: - new_val_err = ValueError( - "Could not create a SasToken using the values provided, or in the Edge environment" - ) - new_val_err.__cause__ = e - raise new_val_err - - # Pipeline Config setup - config_kwargs = _get_config_kwargs(**kwargs) - pipeline_configuration = pipeline.IoTHubPipelineConfig( - device_id=device_id, - module_id=module_id, - hostname=hostname, - gateway_hostname=gateway_hostname, - sastoken=sastoken, - server_verification_cert=server_verification_cert, - **config_kwargs, - ) - pipeline_configuration.method_invoke = ( - True # Method Invoke is allowed on modules created from edge environment + sastoken = st.RenewableSasToken(uri, signing_mechanism, ttl=token_ttl) + except st.SasTokenError as e: + new_val_err = ValueError( + "Could not create a SasToken using the values provided, or in the Edge environment" ) + new_val_err.__cause__ = e + raise new_val_err - # Pipeline setup - http_pipeline = pipeline.HTTPPipeline(pipeline_configuration) - mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) - client = cls(mqtt_pipeline, http_pipeline) - except Exception: - if hsm is not None: - hsm.close() - raise - - client._edge_hsm = hsm - return client + # Pipeline Config setup + config_kwargs = _get_config_kwargs(**kwargs) + pipeline_configuration = pipeline.IoTHubPipelineConfig( + device_id=device_id, + module_id=module_id, + hostname=hostname, + gateway_hostname=gateway_hostname, + sastoken=sastoken, + server_verification_cert=server_verification_cert, + **config_kwargs, + ) + pipeline_configuration.method_invoke = ( + True # Method Invoke is allowed on modules created from edge environment + ) + + # Pipeline setup + http_pipeline = pipeline.HTTPPipeline(pipeline_configuration) + mqtt_pipeline = pipeline.MQTTPipeline(pipeline_configuration) + + return cls(mqtt_pipeline, http_pipeline) @classmethod def create_from_x509_certificate( diff --git a/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py b/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py index bf1e92e25..959744ff7 100644 --- a/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/aio/async_clients.py @@ -198,8 +198,6 @@ async def shutdown(self) -> None: # Stop the Client Event handlers now that everything else is completed self._handler_manager.stop(receiver_handlers_only=False) - self._close_edge_hsm() - # All inbox consumers have stopped, so their Janus queues can now be closed permanently. await asyncio.gather(*(inbox.shutdown() for inbox in self._inbox_manager.get_all_inboxes())) diff --git a/azure-iot-device/azure/iot/device/iothub/edge_hsm.py b/azure-iot-device/azure/iot/device/iothub/edge_hsm.py index 7166cd9f6..8c01cbb0d 100644 --- a/azure-iot-device/azure/iot/device/iothub/edge_hsm.py +++ b/azure-iot-device/azure/iot/device/iothub/edge_hsm.py @@ -46,11 +46,6 @@ def __init__(self, module_id, generation_id, workload_uri, api_version): self.api_version = api_version self.generation_id = generation_id self.workload_uri = _format_socket_uri(workload_uri) - self._session = requests_unixsocket.Session() - - def close(self): - """Release resources held by the Unix socket session.""" - self._session.close() def get_certificate(self): """ @@ -62,27 +57,27 @@ def get_certificate(self): :raises: IoTEdgeError if unable to retrieve the certificate. """ - r = self._session.get( - self.workload_uri + "trust-bundle", - params={"api-version": self.api_version}, - headers={"User-Agent": urllib.parse.quote_plus(user_agent.get_iothub_user_agent())}, - ) - # Validate that the request was successful - try: - r.raise_for_status() - except requests.exceptions.HTTPError as e: - raise IoTEdgeError("Unable to get trust bundle from Edge") from e - # Decode the trust bundle - try: - bundle = r.json() - except ValueError as e: - raise IoTEdgeError("Unable to decode trust bundle") from e - # Retrieve the certificate - try: - cert = bundle["certificate"] - except KeyError as e: - raise IoTEdgeError("No certificate in trust bundle") from e - return cert + with requests_unixsocket.Session() as session: + r = session.get( + self.workload_uri + "trust-bundle", + params={"api-version": self.api_version}, + headers={"User-Agent": urllib.parse.quote_plus(user_agent.get_iothub_user_agent())}, + ) + # Validate that the request was successful + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + raise IoTEdgeError("Unable to get trust bundle from Edge") from e + # Decode the trust bundle + try: + bundle = r.json() + except ValueError as e: + raise IoTEdgeError("Unable to decode trust bundle") from e + # Retrieve the certificate + try: + return bundle["certificate"] + except KeyError as e: + raise IoTEdgeError("No certificate in trust bundle") from e def sign(self, data_str): """ @@ -103,26 +98,27 @@ def sign(self, data_str): ) sign_request = {"keyId": "primary", "algo": "HMACSHA256", "data": encoded_data_str} - r = self._session.post( # can we use json field instead of data? - url=path, - params={"api-version": self.api_version}, - headers={"User-Agent": urllib.parse.quote(user_agent.get_iothub_user_agent(), safe="")}, - data=json.dumps(sign_request), - ) - try: - r.raise_for_status() - except requests.exceptions.HTTPError as e: - raise IoTEdgeError("Unable to sign data") from e - try: - sign_response = r.json() - except ValueError as e: - raise IoTEdgeError("Unable to decode signed data") from e - try: - signed_data_str = sign_response["digest"] - except KeyError as e: - raise IoTEdgeError("No signed data received") from e - - return signed_data_str # what format is this? string? bytes? + with requests_unixsocket.Session() as session: + r = session.post( # can we use json field instead of data? + url=path, + params={"api-version": self.api_version}, + headers={ + "User-Agent": urllib.parse.quote(user_agent.get_iothub_user_agent(), safe="") + }, + data=json.dumps(sign_request), + ) + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + raise IoTEdgeError("Unable to sign data") from e + try: + sign_response = r.json() + except ValueError as e: + raise IoTEdgeError("Unable to decode signed data") from e + try: + return sign_response["digest"] + except KeyError as e: + raise IoTEdgeError("No signed data received") from e def _format_socket_uri(old_uri): diff --git a/azure-iot-device/azure/iot/device/iothub/sync_clients.py b/azure-iot-device/azure/iot/device/iothub/sync_clients.py index 8d04a77b6..4088e0b6f 100644 --- a/azure-iot-device/azure/iot/device/iothub/sync_clients.py +++ b/azure-iot-device/azure/iot/device/iothub/sync_clients.py @@ -188,8 +188,6 @@ def shutdown(self) -> None: if self._handler_manager is not None: self._handler_manager.stop(receiver_handlers_only=False) - self._close_edge_hsm() - # Yes, that means the pipeline is disconnected twice (well, actually three times if you # consider that the client-level disconnect causes two pipeline-level disconnects for # reasons explained in comments in the client's .disconnect() method). diff --git a/tests/unit/iothub/aio/test_async_clients.py b/tests/unit/iothub/aio/test_async_clients.py index 27c6f8cab..5c3d7eb6c 100644 --- a/tests/unit/iothub/aio/test_async_clients.py +++ b/tests/unit/iothub/aio/test_async_clients.py @@ -173,18 +173,6 @@ def check_handlers_and_complete(callback): assert hm_stop_spy.call_count == 1 assert hm_stop_spy.call_args == mocker.call(receiver_handlers_only=False) - @pytest.mark.it("Closes the Edge HSM") - async def test_closes_edge_hsm(self, mocker, client): - client.disconnect = mocker.MagicMock() - client.disconnect.return_value = await create_completed_future(None) - mock_edge_hsm = mocker.MagicMock() - client._edge_hsm = mock_edge_hsm - - await client.shutdown() - - assert mock_edge_hsm.close.call_count == 1 - assert client._edge_hsm is None - @pytest.mark.it("Shuts down all fixed and dynamically created inboxes") async def test_shuts_down_all_inboxes(self, mocker, client): client.disconnect = mocker.MagicMock() diff --git a/tests/unit/iothub/shared_client_tests.py b/tests/unit/iothub/shared_client_tests.py index 2d2314173..1c0780b6c 100644 --- a/tests/unit/iothub/shared_client_tests.py +++ b/tests/unit/iothub/shared_client_tests.py @@ -1754,7 +1754,6 @@ def test_client_returns( assert isinstance(client, client_class) assert client._mqtt_pipeline is mock_mqtt_pipeline_init.return_value assert client._http_pipeline is mock_http_pipeline_init.return_value - assert client._edge_hsm is mock_edge_hsm.return_value @pytest.mark.it("Raises OSError if the environment is missing required variables") @pytest.mark.parametrize( @@ -1790,7 +1789,6 @@ def test_bad_edge_auth(self, mocker, client_class, edge_container_environment, m with pytest.raises(OSError) as e_info: client_class.create_from_edge_environment() assert e_info.value.__cause__ is my_edge_error - assert mock_edge_hsm.return_value.close.call_count == 1 @pytest.mark.it("Raises ValueError if a SasToken creation results in failure") def test_raises_value_error_on_sastoken_failure( @@ -1804,7 +1802,6 @@ def test_raises_value_error_on_sastoken_failure( with pytest.raises(ValueError) as e_info: client_class.create_from_edge_environment() assert e_info.value.__cause__ is token_err - assert mock_edge_hsm.return_value.close.call_count == 1 @pytest.mark.usefixtures("mock_mqtt_pipeline_init", "mock_http_pipeline_init") diff --git a/tests/unit/iothub/test_edge_hsm.py b/tests/unit/iothub/test_edge_hsm.py index ca189f293..839ebe077 100644 --- a/tests/unit/iothub/test_edge_hsm.py +++ b/tests/unit/iothub/test_edge_hsm.py @@ -20,14 +20,20 @@ @pytest.fixture def edge_hsm(): - hsm = edge_hsm_module.IoTEdgeHsm( + return edge_hsm_module.IoTEdgeHsm( module_id="my_module_id", generation_id="module_generation_id", workload_uri="unix:///var/run/iotedge/workload.sock", api_version="my_api_version", ) - yield hsm - hsm.close() + + +@pytest.fixture +def mock_unix_session(mocker): + mock_session_constructor = mocker.patch.object(requests_unixsocket, "Session") + mock_session = mock_session_constructor.return_value + mock_session.__enter__.return_value = mock_session + return mock_session @pytest.mark.describe("IoTEdgeHsm - Instantiation") @@ -40,20 +46,6 @@ def test_does_not_monkeypatch_requests(self, mocker): assert mock_monkeypatch.call_count == 0 - @pytest.mark.it("Creates a private Unix socket requests session") - def test_creates_unix_socket_session(self, mocker): - mock_session_constructor = mocker.patch.object(requests_unixsocket, "Session") - - edge_hsm = edge_hsm_module.IoTEdgeHsm( - module_id="my_module_id", - generation_id="my_generation_id", - workload_uri="unix:///var/run/iotedge/workload.sock", - api_version="my_api_version", - ) - - assert edge_hsm._session is mock_session_constructor.return_value - assert mock_session_constructor.call_args == mocker.call() - @pytest.mark.it("URL encodes the provided module_id parameter and sets it as an attribute") def test_encode_and_set_module_id(self): module_id = "my_module_id" @@ -133,9 +125,19 @@ def test_set_api_version(self): @pytest.mark.describe("IoTEdgeHsm - .get_certificate()") class TestIoTEdgeHsmGetCertificate(object): + @pytest.mark.it("Closes the Unix socket session after the request") + def test_closes_session(self, mocker, edge_hsm, mock_unix_session): + mock_unix_session.get.return_value.json.return_value = {"certificate": "my certificate"} + + edge_hsm.get_certificate() + + assert requests_unixsocket.Session.call_args == mocker.call() + assert mock_unix_session.__enter__.call_args == mocker.call() + assert mock_unix_session.__exit__.call_count == 1 + @pytest.mark.it("Sends an HTTP GET request to retrieve the trust bundle from Edge") - def test_requests_trust_bundle(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(edge_hsm._session, "get") + def test_requests_trust_bundle(self, mocker, edge_hsm, mock_unix_session): + mock_request_get = mock_unix_session.get expected_url = edge_hsm.workload_uri + "trust-bundle" expected_params = {"api-version": edge_hsm.api_version} expected_headers = { @@ -150,8 +152,8 @@ def test_requests_trust_bundle(self, mocker, edge_hsm): ) @pytest.mark.it("Returns the certificate from the trust bundle received from Edge") - def test_returns_certificate(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(edge_hsm._session, "get") + def test_returns_certificate(self, edge_hsm, mock_unix_session): + mock_request_get = mock_unix_session.get mock_response = mock_request_get.return_value certificate = "my certificate" mock_response.json.return_value = {"certificate": certificate} @@ -161,8 +163,8 @@ def test_returns_certificate(self, mocker, edge_hsm): assert returned_cert is certificate @pytest.mark.it("Raises IoTEdgeError if a bad request is made to Edge") - def test_bad_request(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(edge_hsm._session, "get") + def test_bad_request(self, edge_hsm, mock_unix_session): + mock_request_get = mock_unix_session.get mock_response = mock_request_get.return_value error = requests.exceptions.HTTPError() mock_response.raise_for_status.side_effect = error @@ -172,8 +174,8 @@ def test_bad_request(self, mocker, edge_hsm): assert e_info.value.__cause__ is error @pytest.mark.it("Raises IoTEdgeError if there is an error in json decoding the trust bundle") - def test_bad_json(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(edge_hsm._session, "get") + def test_bad_json(self, edge_hsm, mock_unix_session): + mock_request_get = mock_unix_session.get mock_response = mock_request_get.return_value error = ValueError() mock_response.json.side_effect = error @@ -183,8 +185,8 @@ def test_bad_json(self, mocker, edge_hsm): assert e_info.value.__cause__ is error @pytest.mark.it("Raises IoTEdgeError if the certificate is missing from the trust bundle") - def test_bad_trust_bundle(self, mocker, edge_hsm): - mock_request_get = mocker.patch.object(edge_hsm._session, "get") + def test_bad_trust_bundle(self, edge_hsm, mock_unix_session): + mock_request_get = mock_unix_session.get mock_response = mock_request_get.return_value # Return an empty json dict with no 'certificate' key mock_response.json.return_value = {} @@ -195,13 +197,23 @@ def test_bad_trust_bundle(self, mocker, edge_hsm): @pytest.mark.describe("IoTEdgeHsm - .sign()") class TestIoTEdgeHsmSign(object): + @pytest.mark.it("Closes the Unix socket session after the request") + def test_closes_session(self, mocker, edge_hsm, mock_unix_session): + mock_unix_session.post.return_value.json.return_value = {"digest": "somedigest"} + + edge_hsm.sign("somedata") + + assert requests_unixsocket.Session.call_args == mocker.call() + assert mock_unix_session.__enter__.call_args == mocker.call() + assert mock_unix_session.__exit__.call_count == 1 + @pytest.mark.it( "Makes an HTTP request to Edge to sign a piece of string data using the HMAC-SHA256 algorithm" ) - def test_requests_data_signing(self, mocker, edge_hsm): + def test_requests_data_signing(self, mocker, edge_hsm, mock_unix_session): data_str = "somedata" data_str_b64 = "c29tZWRhdGE=" - mock_request_post = mocker.patch.object(edge_hsm._session, "post") + mock_request_post = mock_unix_session.post mock_request_post.return_value.json.return_value = {"digest": "somedigest"} expected_url = "{workload_uri}modules/{module_id}/genid/{generation_id}/sign".format( workload_uri=edge_hsm.workload_uri, @@ -222,12 +234,12 @@ def test_requests_data_signing(self, mocker, edge_hsm): ) @pytest.mark.it("Base64 encodes the string data in the request") - def test_b64_encodes_data(self, mocker, edge_hsm): + def test_b64_encodes_data(self, edge_hsm, mock_unix_session): # This test is actually implicitly tested in the first test, but it's # important to have an explicit test for it since it's a requirement data_str = "somedata" data_str_b64 = base64.b64encode(data_str.encode("utf-8")).decode() - mock_request_post = mocker.patch.object(edge_hsm._session, "post") + mock_request_post = mock_unix_session.post mock_request_post.return_value.json.return_value = {"digest": "somedigest"} edge_hsm.sign(data_str) @@ -238,9 +250,9 @@ def test_b64_encodes_data(self, mocker, edge_hsm): assert sent_data == data_str_b64 @pytest.mark.it("Returns the signed data received from Edge") - def test_returns_signed_data(self, mocker, edge_hsm): + def test_returns_signed_data(self, edge_hsm, mock_unix_session): expected_digest = "somedigest" - mock_request_post = mocker.patch.object(edge_hsm._session, "post") + mock_request_post = mock_unix_session.post mock_request_post.return_value.json.return_value = {"digest": expected_digest} signed_data = edge_hsm.sign("somedata") @@ -248,8 +260,8 @@ def test_returns_signed_data(self, mocker, edge_hsm): assert signed_data == expected_digest @pytest.mark.it("Raises IoTEdgeError if a bad request is made to EdgeHub") - def test_bad_request(self, mocker, edge_hsm): - mock_request_post = mocker.patch.object(edge_hsm._session, "post") + def test_bad_request(self, edge_hsm, mock_unix_session): + mock_request_post = mock_unix_session.post mock_response = mock_request_post.return_value error = requests.exceptions.HTTPError() mock_response.raise_for_status.side_effect = error @@ -259,8 +271,8 @@ def test_bad_request(self, mocker, edge_hsm): assert e_info.value.__cause__ is error @pytest.mark.it("Raises IoTEdgeError if there is an error in json decoding the signed response") - def test_bad_json(self, mocker, edge_hsm): - mock_request_post = mocker.patch.object(edge_hsm._session, "post") + def test_bad_json(self, edge_hsm, mock_unix_session): + mock_request_post = mock_unix_session.post mock_response = mock_request_post.return_value error = ValueError() mock_response.json.side_effect = error @@ -269,21 +281,10 @@ def test_bad_json(self, mocker, edge_hsm): assert e_info.value.__cause__ is error @pytest.mark.it("Raises IoTEdgeError if the signed data is missing from the response") - def test_bad_response(self, mocker, edge_hsm): - mock_request_post = mocker.patch.object(edge_hsm._session, "post") + def test_bad_response(self, edge_hsm, mock_unix_session): + mock_request_post = mock_unix_session.post mock_response = mock_request_post.return_value mock_response.json.return_value = {} with pytest.raises(edge_hsm_module.IoTEdgeError): edge_hsm.sign("somedata") - - -@pytest.mark.describe("IoTEdgeHsm - .close()") -class TestIoTEdgeHsmClose(object): - @pytest.mark.it("Closes the private Unix socket session") - def test_closes_session(self, mocker, edge_hsm): - mock_close = mocker.patch.object(edge_hsm._session, "close") - - edge_hsm.close() - - assert mock_close.call_args == mocker.call() diff --git a/tests/unit/iothub/test_sync_clients.py b/tests/unit/iothub/test_sync_clients.py index 9837ee8d2..e89922e4b 100644 --- a/tests/unit/iothub/test_sync_clients.py +++ b/tests/unit/iothub/test_sync_clients.py @@ -175,17 +175,6 @@ def check_handlers_and_complete(callback): assert hm_stop_spy.call_count == 1 assert hm_stop_spy.call_args == mocker.call(receiver_handlers_only=False) - @pytest.mark.it("Closes the Edge HSM") - def test_closes_edge_hsm(self, mocker, client): - client.disconnect = mocker.MagicMock() - mock_edge_hsm = mocker.MagicMock() - client._edge_hsm = mock_edge_hsm - - client.shutdown() - - assert mock_edge_hsm.close.call_count == 1 - assert client._edge_hsm is None - class SharedClientConnectTests(WaitsForEventCompletion): @pytest.mark.it("Begins a 'connect' pipeline operation") From 8a58d349217f6bcd2273424a3dee26fbf3f95f7f Mon Sep 17 00:00:00 2001 From: Carter Tinney Date: Wed, 2 Sep 2026 09:29:43 -0700 Subject: [PATCH 4/4] test: explain module-qualified Edge HSM imports Document why the import regression test must resolve classes through the reloaded module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/unit/iothub/test_edge_hsm.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/iothub/test_edge_hsm.py b/tests/unit/iothub/test_edge_hsm.py index 839ebe077..d0c97606e 100644 --- a/tests/unit/iothub/test_edge_hsm.py +++ b/tests/unit/iothub/test_edge_hsm.py @@ -12,6 +12,9 @@ import json import base64 import urllib + +# Keep module-qualified references because the import regression test reloads this module. +# Directly imported classes would retain their pre-reload identities. from azure.iot.device.iothub import edge_hsm as edge_hsm_module from azure.iot.device import user_agent