From c54f5f178d6e24fb6a24646f3f044e279f691d65 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 17 Sep 2026 16:59:34 -0700 Subject: [PATCH 01/15] Fix upload/download/list attachment 401s and job update 404 - upload_attachment, download_attachment, and list_attachments in base_job.py were reusing the unsigned container_uri returned by job creation instead of always fetching a fresh SAS-signed URI via workspace.get_container_uri(), causing 401 NoAuthenticationInformation errors. - build_services_jobs_update_request in the generated _operations.py used the wrong URL path segment 'jobUpdateOptions' (the request model type name) instead of 'jobs', causing workspace.update_job() to fail with 404 Not Found. --- .../quantum/_client/operations/_operations.py | 2 +- azure-quantum/azure/quantum/job/base_job.py | 26 +++++++------------ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/azure-quantum/azure/quantum/_client/operations/_operations.py b/azure-quantum/azure/quantum/_client/operations/_operations.py index 4f6b49cc..ab94da32 100644 --- a/azure-quantum/azure/quantum/_client/operations/_operations.py +++ b/azure-quantum/azure/quantum/_client/operations/_operations.py @@ -130,7 +130,7 @@ def build_services_jobs_update_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobUpdateOptions/{jobId}" + _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobs/{jobId}" path_format_arguments = { "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), "resourceGroupName": _SERIALIZER.url("resource_group_name", resource_group_name, "str"), diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 9ffbd9ff..b7630611 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -344,12 +344,10 @@ def upload_attachment( :rtype: str """ - # Use Job's default container if not specified + # Use Job's default container if not specified. self._details.container_uri is + # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - if self._details.container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) - else: - container_uri = self._details.container_uri + container_uri = self.workspace.get_container_uri(job_id=self.id) uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -377,13 +375,11 @@ def download_attachment( :rtype: bytes """ - # Use Job's default container if not specified + # Use Job's default container if not specified. self._details.container_uri is + # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - if self._details.container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) - else: - container_uri = self._details.container_uri - + container_uri = self.workspace.get_container_uri(job_id=self.id) + container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) response = blob_client.download_blob().readall() @@ -399,11 +395,9 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - # Use the job's linked storage container. - if self._details.container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) - else: - container_uri = self._details.container_uri + # Use the job's linked storage container. self._details.container_uri is unsigned, + # so always fetch a fresh SAS-signed URI instead of reusing it. + container_uri = self.workspace.get_container_uri(job_id=self.id) container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) From 9a57ae56a6220384eca3d981b99f099d715d28a5 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 18 Sep 2026 10:57:13 -0700 Subject: [PATCH 02/15] Update tests for fresh attachment SAS URIs --- azure-quantum/tests/test_job_attachments.py | 69 +++++++++++++++++++-- azure-quantum/tests/test_workspace.py | 17 +++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index d537f61b..78a87093 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -7,10 +7,11 @@ from azure.quantum import Job, JobDetails -CONTAINER_URI = "https://acct.blob.core.windows.net/job-id?sas" +UNSIGNED_CONTAINER_URI = "https://acct.blob.core.windows.net/job-id" +SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?sas" -def _job_with_container(container_uri=CONTAINER_URI, workspace=None) -> Job: +def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: job_details = JobDetails( id="job-id", name="", @@ -25,7 +26,9 @@ def _job_with_container(container_uri=CONTAINER_URI, workspace=None) -> Job: @patch("azure.quantum.job.base_job.ContainerClient") def test_list_attachments_returns_container_blobs(mock_container_client): - job = _job_with_container() + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) blob_a = Mock() blob_b = Mock() @@ -34,14 +37,15 @@ def test_list_attachments_returns_container_blobs(mock_container_client): result = job.list_attachments() - mock_container_client.from_container_url.assert_called_once_with(CONTAINER_URI) + workspace.get_container_uri.assert_called_once_with(job_id="job-id") + mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [blob_a, blob_b] @patch("azure.quantum.job.base_job.ContainerClient") def test_list_attachments_uses_workspace_container_when_unset(mock_container_client): workspace = Mock() - workspace.get_container_uri.return_value = CONTAINER_URI + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(container_uri=None, workspace=workspace) container = mock_container_client.from_container_url.return_value @@ -50,5 +54,58 @@ def test_list_attachments_uses_workspace_container_when_unset(mock_container_cli result = job.list_attachments() workspace.get_container_uri.assert_called_once_with(job_id="job-id") - mock_container_client.from_container_url.assert_called_once_with(CONTAINER_URI) + mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [] + + +def test_upload_attachment_uses_fresh_workspace_container_uri(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + result = job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with(job_id="job-id") + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + assert result == "uploaded-uri" + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_download_attachment_uses_fresh_workspace_container_uri(mock_container_client): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + blob_client = mock_container_client.from_container_url.return_value.get_blob_client.return_value + blob_client.download_blob.return_value.readall.return_value = b"data" + + result = job.download_attachment("attachment") + + workspace.get_container_uri.assert_called_once_with(job_id="job-id") + mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) + assert result == b"data" + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_attachment_methods_honor_explicit_container_uri(mock_container_client): + workspace = Mock() + job = _job_with_container(workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + blob_client = mock_container_client.from_container_url.return_value.get_blob_client.return_value + blob_client.download_blob.return_value.readall.return_value = b"data" + explicit_uri = "https://custom.blob.core.windows.net/container?sas" + + job.upload_attachment("upload", b"data", container_uri=explicit_uri) + job.download_attachment("download", container_uri=explicit_uri) + + workspace.get_container_uri.assert_not_called() + job.upload_input_data.assert_called_once_with( + container_uri=explicit_uri, + blob_name="upload", + input_data=b"data", + ) + mock_container_client.from_container_url.assert_called_once_with(explicit_uri) diff --git a/azure-quantum/tests/test_workspace.py b/azure-quantum/tests/test_workspace.py index 4fc0151f..00aa1f95 100644 --- a/azure-quantum/tests/test_workspace.py +++ b/azure-quantum/tests/test_workspace.py @@ -8,6 +8,7 @@ from unittest import mock from azure.quantum.job.job import Job from azure.quantum._client.models import JobDetails +from azure.quantum._client.operations._operations import build_services_jobs_update_request from azure.quantum import Priority from azure.quantum._constants import EnvironmentVariables, ConnectionConstants from azure.core.credentials import AzureKeyCredential @@ -475,6 +476,22 @@ def test_workspace_update_job_success(): assert result.details.tags == ["tag-a", "tag-b"] +def test_workspace_update_job_request_uses_jobs_resource_path(): + request = build_services_jobs_update_request( + subscription_id=SUBSCRIPTION_ID, + resource_group_name=RESOURCE_GROUP, + workspace_name=WORKSPACE, + job_id="test-update-route", + ) + + assert request.method == "PATCH" + assert request.url.split("?", maxsplit=1)[0] == ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}" + f"/providers/Microsoft.Quantum/workspaces/{WORKSPACE}" + "/jobs/test-update-route" + ) + + def test_workspace_update_job_partial_leaves_other_fields_unchanged(): ws = WorkspaceMock( subscription_id=SUBSCRIPTION_ID, From 36790fc06b3cd8bcc9ad1642ad2a006fb1fe310c Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 18 Sep 2026 11:33:22 -0700 Subject: [PATCH 03/15] Preserve custom attachment container names --- azure-quantum/azure/quantum/job/base_job.py | 15 ++++- azure-quantum/tests/test_job_attachments.py | 66 ++++++++++++++++++--- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index b7630611..f6abaee9 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -347,7 +347,10 @@ def upload_attachment( # Use Job's default container if not specified. self._details.container_uri is # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) + container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -378,7 +381,10 @@ def download_attachment( # Use Job's default container if not specified. self._details.container_uri is # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) + container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -397,7 +403,10 @@ def list_attachments(self) -> list[BlobProperties]: # Use the job's linked storage container. self._details.container_uri is unsigned, # so always fetch a fresh SAS-signed URI instead of reusing it. - container_uri = self.workspace.get_container_uri(job_id=self.id) + container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 78a87093..95ddda66 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -3,17 +3,19 @@ # Licensed under the MIT License. ## -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from azure.quantum import Job, JobDetails -UNSIGNED_CONTAINER_URI = "https://acct.blob.core.windows.net/job-id" +JOB_ID = "job-id" +DEFAULT_CONTAINER_NAME = f"job-{JOB_ID}" +UNSIGNED_CONTAINER_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?sas" def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: job_details = JobDetails( - id="job-id", + id=JOB_ID, name="", provider_id="", target="", @@ -37,7 +39,10 @@ def test_list_attachments_returns_container_blobs(mock_container_client): result = job.list_attachments() - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [blob_a, blob_b] @@ -53,7 +58,10 @@ def test_list_attachments_uses_workspace_container_when_unset(mock_container_cli result = job.list_attachments() - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [] @@ -66,7 +74,10 @@ def test_upload_attachment_uses_fresh_workspace_container_uri(): result = job.upload_attachment("attachment", b"data") - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) job.upload_input_data.assert_called_once_with( container_uri=SIGNED_CONTAINER_URI, blob_name="attachment", @@ -85,7 +96,10 @@ def test_download_attachment_uses_fresh_workspace_container_uri(mock_container_c result = job.download_attachment("attachment") - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == b"data" @@ -109,3 +123,41 @@ def test_attachment_methods_honor_explicit_container_uri(mock_container_client): input_data=b"data", ) mock_container_client.from_container_url.assert_called_once_with(explicit_uri) + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_attachment_methods_preserve_custom_container_name(mock_container_client): + custom_container_name = "custom-container" + custom_unsigned_uri = f"https://acct.blob.core.windows.net/{custom_container_name}" + custom_signed_uri = f"{custom_unsigned_uri}?sas" + workspace = Mock() + workspace.get_container_uri.return_value = custom_signed_uri + job = _job_with_container(container_uri=custom_unsigned_uri, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + container_client = mock_container_client.from_container_url.return_value + container_client.list_blobs.return_value = [] + container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" + + job.upload_attachment("upload", b"data") + attachments = job.list_attachments() + downloaded = job.download_attachment("download") + + workspace.get_container_uri.assert_has_calls( + [ + call(job_id=JOB_ID, container_name=custom_container_name), + call(job_id=JOB_ID, container_name=custom_container_name), + call(job_id=JOB_ID, container_name=custom_container_name), + ] + ) + assert workspace.get_container_uri.call_count == 3 + job.upload_input_data.assert_called_once_with( + container_uri=custom_signed_uri, + blob_name="upload", + input_data=b"data", + ) + assert mock_container_client.from_container_url.call_args_list == [ + call(custom_signed_uri), + call(custom_signed_uri), + ] + assert attachments == [] + assert downloaded == b"data" From 06992ac13bcc212d4bc523486134d6a992305c83 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 18 Sep 2026 11:42:27 -0700 Subject: [PATCH 04/15] Leave generated client updates to PR 772 --- .../quantum/_client/operations/_operations.py | 2 +- azure-quantum/tests/test_workspace.py | 17 ----------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/azure-quantum/azure/quantum/_client/operations/_operations.py b/azure-quantum/azure/quantum/_client/operations/_operations.py index ab94da32..4f6b49cc 100644 --- a/azure-quantum/azure/quantum/_client/operations/_operations.py +++ b/azure-quantum/azure/quantum/_client/operations/_operations.py @@ -130,7 +130,7 @@ def build_services_jobs_update_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobs/{jobId}" + _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobUpdateOptions/{jobId}" path_format_arguments = { "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), "resourceGroupName": _SERIALIZER.url("resource_group_name", resource_group_name, "str"), diff --git a/azure-quantum/tests/test_workspace.py b/azure-quantum/tests/test_workspace.py index 00aa1f95..4fc0151f 100644 --- a/azure-quantum/tests/test_workspace.py +++ b/azure-quantum/tests/test_workspace.py @@ -8,7 +8,6 @@ from unittest import mock from azure.quantum.job.job import Job from azure.quantum._client.models import JobDetails -from azure.quantum._client.operations._operations import build_services_jobs_update_request from azure.quantum import Priority from azure.quantum._constants import EnvironmentVariables, ConnectionConstants from azure.core.credentials import AzureKeyCredential @@ -476,22 +475,6 @@ def test_workspace_update_job_success(): assert result.details.tags == ["tag-a", "tag-b"] -def test_workspace_update_job_request_uses_jobs_resource_path(): - request = build_services_jobs_update_request( - subscription_id=SUBSCRIPTION_ID, - resource_group_name=RESOURCE_GROUP, - workspace_name=WORKSPACE, - job_id="test-update-route", - ) - - assert request.method == "PATCH" - assert request.url.split("?", maxsplit=1)[0] == ( - f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}" - f"/providers/Microsoft.Quantum/workspaces/{WORKSPACE}" - "/jobs/test-update-route" - ) - - def test_workspace_update_job_partial_leaves_other_fields_unchanged(): ws = WorkspaceMock( subscription_id=SUBSCRIPTION_ID, From 59a3d741a6bde5ec5a2305b1f6c7912933ea4aea Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 10:51:37 -0700 Subject: [PATCH 05/15] Safely select attachment container SAS URI --- azure-quantum/azure/quantum/job/base_job.py | 55 +++++++++++------ azure-quantum/tests/test_job_attachments.py | 68 +++++++++++++++++++-- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index f6abaee9..c968e987 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -344,13 +344,8 @@ def upload_attachment( :rtype: str """ - # Use Job's default container if not specified. self._details.container_uri is - # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) + container_uri = self._get_attachment_container_uri() uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -378,13 +373,8 @@ def download_attachment( :rtype: bytes """ - # Use Job's default container if not specified. self._details.container_uri is - # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) + container_uri = self._get_attachment_container_uri() container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -401,17 +391,46 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - # Use the job's linked storage container. self._details.container_uri is unsigned, - # so always fetch a fresh SAS-signed URI instead of reusing it. - container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) + container_uri = self._get_attachment_container_uri() container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) + def _get_attachment_container_uri(self) -> str: + container_uri = self._details.container_uri + if container_uri is None: + return self.workspace.get_container_uri(job_id=self.id) + + query_params = parse_qs(urlparse(container_uri).query) + token_expire_query_param = query_params.get("se") + if query_params.get("sig") and token_expire_query_param: + try: + token_expire_time = datetime.fromisoformat( + token_expire_query_param[0].replace("Z", "+00:00") + ) + if token_expire_time.tzinfo is None: + token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) + if datetime.now(tz=timezone.utc) < token_expire_time - timedelta(minutes=5): + return container_uri + except ValueError: + pass + + refreshed_container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) + stored_hostname = urlparse(container_uri).hostname + refreshed_hostname = urlparse(refreshed_container_uri).hostname + if stored_hostname != refreshed_hostname: + raise ValueError( + "Refreshed attachment container hostname " + f"'{refreshed_hostname}' does not match job container hostname " + f"'{stored_hostname}'." + ) + return refreshed_container_uri + + def _get_blob_uri_with_sas_token(self, blob_uri: str) -> str: """Get Blob URI with SAS-token if one was not specified in blob_uri parameter :param blob_uri: Blob URI diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 95ddda66..29b0bde0 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -4,13 +4,17 @@ ## from unittest.mock import Mock, call, patch + +import pytest + from azure.quantum import Job, JobDetails JOB_ID = "job-id" DEFAULT_CONTAINER_NAME = f"job-{JOB_ID}" UNSIGNED_CONTAINER_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" -SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?sas" +SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" +EXPIRED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2000-01-01T00%3A00%3A00Z&sig=signature" def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: @@ -58,10 +62,7 @@ def test_list_attachments_uses_workspace_container_when_unset(mock_container_cli result = job.list_attachments() - workspace.get_container_uri.assert_called_once_with( - job_id=JOB_ID, - container_name=DEFAULT_CONTAINER_NAME, - ) + workspace.get_container_uri.assert_called_once_with(job_id=JOB_ID) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [] @@ -125,6 +126,63 @@ def test_attachment_methods_honor_explicit_container_uri(mock_container_client): mock_container_client.from_container_url.assert_called_once_with(explicit_uri) +@patch("azure.quantum.job.base_job.ContainerClient") +def test_attachment_methods_reuse_valid_signed_job_uri(mock_container_client): + workspace = Mock() + job = _job_with_container(container_uri=SIGNED_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + container_client = mock_container_client.from_container_url.return_value + container_client.list_blobs.return_value = [] + container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" + + job.upload_attachment("upload", b"data") + attachments = job.list_attachments() + downloaded = job.download_attachment("download") + + workspace.get_container_uri.assert_not_called() + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="upload", + input_data=b"data", + ) + assert mock_container_client.from_container_url.call_args_list == [ + call(SIGNED_CONTAINER_URI), + call(SIGNED_CONTAINER_URI), + ] + assert attachments == [] + assert downloaded == b"data" + + +def test_upload_attachment_refreshes_expired_job_uri(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=EXPIRED_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + +def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): + workspace = Mock() + workspace.get_container_uri.return_value = ( + f"https://other-acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}?sas" + ) + job = _job_with_container(workspace=workspace) + + with pytest.raises(ValueError, match="does not match job container hostname"): + job.upload_attachment("attachment", b"data") + + @patch("azure.quantum.job.base_job.ContainerClient") def test_attachment_methods_preserve_custom_container_name(mock_container_client): custom_container_name = "custom-container" From bf19c4b68be0cadf1e81ca605fec00f39652cced Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 11:06:29 -0700 Subject: [PATCH 06/15] Refresh attachment SAS when permissions are insufficient --- azure-quantum/azure/quantum/job/base_job.py | 15 ++++--- azure-quantum/tests/test_job_attachments.py | 50 ++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index c968e987..8c52e1d6 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -345,7 +345,7 @@ def upload_attachment( """ if container_uri is None: - container_uri = self._get_attachment_container_uri() + container_uri = self._get_attachment_container_uri(required_permission="w") uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -374,7 +374,7 @@ def download_attachment( """ if container_uri is None: - container_uri = self._get_attachment_container_uri() + container_uri = self._get_attachment_container_uri(required_permission="r") container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -391,20 +391,25 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - container_uri = self._get_attachment_container_uri() + container_uri = self._get_attachment_container_uri(required_permission="l") container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) - def _get_attachment_container_uri(self) -> str: + def _get_attachment_container_uri(self, required_permission: str) -> str: container_uri = self._details.container_uri if container_uri is None: return self.workspace.get_container_uri(job_id=self.id) query_params = parse_qs(urlparse(container_uri).query) token_expire_query_param = query_params.get("se") - if query_params.get("sig") and token_expire_query_param: + token_permissions = query_params.get("sp", [""])[0] + if ( + query_params.get("sig") + and token_expire_query_param + and required_permission in token_permissions + ): try: token_expire_time = datetime.fromisoformat( token_expire_query_param[0].replace("Z", "+00:00") diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 29b0bde0..6e84a4b9 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -13,8 +13,15 @@ JOB_ID = "job-id" DEFAULT_CONTAINER_NAME = f"job-{JOB_ID}" UNSIGNED_CONTAINER_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" -SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" -EXPIRED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2000-01-01T00%3A00%3A00Z&sig=signature" +SIGNED_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +READ_ONLY_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=rl&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +EXPIRED_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2000-01-01T00%3A00%3A00Z&sig=signature" +) def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: @@ -172,6 +179,45 @@ def test_upload_attachment_refreshes_expired_job_uri(): ) +def test_upload_attachment_refreshes_job_uri_without_write_permission(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=READ_ONLY_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_read_only_job_uri_is_reused_for_list_and_download(mock_container_client): + workspace = Mock() + job = _job_with_container(container_uri=READ_ONLY_CONTAINER_URI, workspace=workspace) + container_client = mock_container_client.from_container_url.return_value + container_client.list_blobs.return_value = [] + container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" + + attachments = job.list_attachments() + downloaded = job.download_attachment("download") + + workspace.get_container_uri.assert_not_called() + assert mock_container_client.from_container_url.call_args_list == [ + call(READ_ONLY_CONTAINER_URI), + call(READ_ONLY_CONTAINER_URI), + ] + assert attachments == [] + assert downloaded == b"data" + + def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): workspace = Mock() workspace.get_container_uri.return_value = ( From 561104998d67c0e39e3da50eb0b7962df2cd52d2 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 11:29:06 -0700 Subject: [PATCH 07/15] Allow listing with connection-string container SAS --- azure-quantum/azure/quantum/storage.py | 5 +++-- azure-quantum/tests/test_storage.py | 28 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 azure-quantum/tests/test_storage.py diff --git a/azure-quantum/azure/quantum/storage.py b/azure-quantum/azure/quantum/storage.py index e68ea176..82cb348f 100644 --- a/azure-quantum/azure/quantum/storage.py +++ b/azure-quantum/azure/quantum/storage.py @@ -10,6 +10,7 @@ ContainerClient, BlobClient, BlobSasPermissions, + ContainerSasPermissions, ContentSettings, generate_blob_sas, generate_container_sas, @@ -68,8 +69,8 @@ def get_container_uri(connection_string: str, container_name: str) -> str: container.account_name, container.container_name, account_key=container.credential.account_key, - permission=BlobSasPermissions( - read=True, add=True, write=True, create=True + permission=ContainerSasPermissions( + read=True, add=True, write=True, create=True, list=True ), expiry=datetime.utcnow() + timedelta(days=14), ) diff --git a/azure-quantum/tests/test_storage.py b/azure-quantum/tests/test_storage.py new file mode 100644 index 00000000..6bedef26 --- /dev/null +++ b/azure-quantum/tests/test_storage.py @@ -0,0 +1,28 @@ +## +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +## + +from unittest.mock import Mock, patch + +from azure.storage.blob import ContainerSasPermissions + +from azure.quantum.storage import get_container_uri + + +@patch("azure.quantum.storage.generate_container_sas", return_value="sas-token") +@patch("azure.quantum.storage.create_container") +def test_get_container_uri_sas_allows_listing(mock_create_container, mock_generate_sas): + container = Mock() + container.account_name = "account" + container.container_name = "container" + container.url = "https://account.blob.core.windows.net/container" + container.credential.account_key = "account-key" + mock_create_container.return_value = container + + result = get_container_uri("connection-string", "container") + + permission = mock_generate_sas.call_args.kwargs["permission"] + assert isinstance(permission, ContainerSasPermissions) + assert str(permission) == "racwl" + assert result == "https://account.blob.core.windows.net/container?sas-token" \ No newline at end of file From a9c1506fb1b165bcf151be2a228daf74b9da3235 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 15:42:25 -0700 Subject: [PATCH 08/15] Limit attachment fix to linked storage --- azure-quantum/azure/quantum/storage.py | 5 ++--- azure-quantum/tests/test_storage.py | 28 -------------------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 azure-quantum/tests/test_storage.py diff --git a/azure-quantum/azure/quantum/storage.py b/azure-quantum/azure/quantum/storage.py index 82cb348f..e68ea176 100644 --- a/azure-quantum/azure/quantum/storage.py +++ b/azure-quantum/azure/quantum/storage.py @@ -10,7 +10,6 @@ ContainerClient, BlobClient, BlobSasPermissions, - ContainerSasPermissions, ContentSettings, generate_blob_sas, generate_container_sas, @@ -69,8 +68,8 @@ def get_container_uri(connection_string: str, container_name: str) -> str: container.account_name, container.container_name, account_key=container.credential.account_key, - permission=ContainerSasPermissions( - read=True, add=True, write=True, create=True, list=True + permission=BlobSasPermissions( + read=True, add=True, write=True, create=True ), expiry=datetime.utcnow() + timedelta(days=14), ) diff --git a/azure-quantum/tests/test_storage.py b/azure-quantum/tests/test_storage.py deleted file mode 100644 index 6bedef26..00000000 --- a/azure-quantum/tests/test_storage.py +++ /dev/null @@ -1,28 +0,0 @@ -## -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -## - -from unittest.mock import Mock, patch - -from azure.storage.blob import ContainerSasPermissions - -from azure.quantum.storage import get_container_uri - - -@patch("azure.quantum.storage.generate_container_sas", return_value="sas-token") -@patch("azure.quantum.storage.create_container") -def test_get_container_uri_sas_allows_listing(mock_create_container, mock_generate_sas): - container = Mock() - container.account_name = "account" - container.container_name = "container" - container.url = "https://account.blob.core.windows.net/container" - container.credential.account_key = "account-key" - mock_create_container.return_value = container - - result = get_container_uri("connection-string", "container") - - permission = mock_generate_sas.call_args.kwargs["permission"] - assert isinstance(permission, ContainerSasPermissions) - assert str(permission) == "racwl" - assert result == "https://account.blob.core.windows.net/container?sas-token" \ No newline at end of file From 90aa264c43138fe17200178a97c9d41138bb6024 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 22 Sep 2026 12:58:41 -0700 Subject: [PATCH 09/15] Cache and validate attachment SAS URIs --- azure-quantum/azure/quantum/job/base_job.py | 147 +++++++++++---- azure-quantum/tests/test_job_attachments.py | 198 +++++++++++++++++++- 2 files changed, 303 insertions(+), 42 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 8c52e1d6..97db4f91 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -9,7 +9,7 @@ from enum import Enum from datetime import datetime, timezone, timedelta from urllib.parse import urlparse, parse_qs -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, Dict, Literal, Optional, TYPE_CHECKING from azure.storage.blob import BlobClient, BlobProperties from azure.quantum.storage import upload_blob, download_blob, download_blob_properties, ContainerClient @@ -42,6 +42,11 @@ class BaseJob(WorkspaceItem): :type details: ItemDetails """ + def __init__(self, workspace: "Workspace", details: JobDetails, **kwargs): + self._attachment_container_uri_cache: Optional[str] = None + self._attachment_container_uri_cache_identity: Optional[tuple[Optional[str], str]] = None + super().__init__(workspace=workspace, details=details, **kwargs) + @staticmethod def create_job_id() -> str: """Create a unique id for a new job.""" @@ -55,17 +60,20 @@ def details(self) -> JobDetails: @details.setter def details(self, value: JobDetails): self._details = value + self._attachment_container_uri_cache = None + self._attachment_container_uri_cache_identity = None @property - def container_name(self): + def container_name(self) -> str: """Job input/output data container name""" if self._details.container_uri is None: return f"job-{self.id}" - else: - container_uri = self._details.container_uri - path = urlparse(container_uri).path - container_name = path.split("/")[1] + + path = urlparse(self._details.container_uri).path + container_name = path.lstrip("/").split("/", 1)[0] + if not container_name: + raise ValueError("Job container URI does not include a container name.") return container_name @classmethod @@ -397,43 +405,114 @@ def list_attachments(self) -> list[BlobProperties]: return list(container_client.list_blobs()) - def _get_attachment_container_uri(self, required_permission: str) -> str: + def _get_attachment_container_uri( + self, + required_permission: Literal["r", "w", "l"], + ) -> str: container_uri = self._details.container_uri + container_identity = self._get_attachment_container_identity(container_uri) + cached_container_uri = self._attachment_container_uri_cache + if cached_container_uri: + if self._attachment_container_uri_cache_identity != container_identity: + self._attachment_container_uri_cache = None + self._attachment_container_uri_cache_identity = None + elif self._is_attachment_container_uri_usable( + cached_container_uri, + required_permission, + ): + return cached_container_uri + + if container_uri is None: + refreshed_container_uri = self.workspace.get_container_uri(job_id=self.id) + else: + if self._is_attachment_container_uri_usable( + container_uri, + required_permission, + ): + return container_uri + + refreshed_container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) + stored_hostname = urlparse(container_uri).hostname + refreshed_hostname = urlparse(refreshed_container_uri).hostname + if stored_hostname != refreshed_hostname: + raise ValueError( + "Refreshed attachment container hostname " + f"'{refreshed_hostname}' does not match job container hostname " + f"'{stored_hostname}'." + ) + + if not self._is_attachment_container_uri_usable( + refreshed_container_uri, + required_permission, + ): + raise ValueError( + "Refreshed attachment container URI does not contain a usable SAS token " + f"with '{required_permission}' permission." + ) + + self._attachment_container_uri_cache = refreshed_container_uri + self._attachment_container_uri_cache_identity = container_identity + return refreshed_container_uri + + + def _get_attachment_container_identity( + self, + container_uri: Optional[str], + ) -> tuple[Optional[str], str]: if container_uri is None: - return self.workspace.get_container_uri(job_id=self.id) + return (None, f"/job-{self.id}") + + parsed_uri = urlparse(container_uri) + return (parsed_uri.hostname, parsed_uri.path.rstrip("/")) + + + def _is_attachment_container_uri_usable( + self, + container_uri: str, + required_permission: Literal["r", "w", "l"], + ) -> bool: - query_params = parse_qs(urlparse(container_uri).query) + parsed_uri = urlparse(container_uri) + if parsed_uri.scheme.lower() != "https": + return False + + query_params = parse_qs(parsed_uri.query) token_expire_query_param = query_params.get("se") + token_start_query_param = query_params.get("st") token_permissions = query_params.get("sp", [""])[0] if ( - query_params.get("sig") - and token_expire_query_param - and required_permission in token_permissions + not query_params.get("sig") + or not token_expire_query_param + or required_permission not in token_permissions ): - try: - token_expire_time = datetime.fromisoformat( - token_expire_query_param[0].replace("Z", "+00:00") + return False + + try: + token_expire_time = datetime.fromisoformat( + token_expire_query_param[0].replace("Z", "+00:00") + ) + if token_expire_time.tzinfo is None: + token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) + + token_start_time = None + if token_start_query_param: + token_start_time = datetime.fromisoformat( + token_start_query_param[0].replace("Z", "+00:00") ) - if token_expire_time.tzinfo is None: - token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) - if datetime.now(tz=timezone.utc) < token_expire_time - timedelta(minutes=5): - return container_uri - except ValueError: - pass - - refreshed_container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) - stored_hostname = urlparse(container_uri).hostname - refreshed_hostname = urlparse(refreshed_container_uri).hostname - if stored_hostname != refreshed_hostname: - raise ValueError( - "Refreshed attachment container hostname " - f"'{refreshed_hostname}' does not match job container hostname " - f"'{stored_hostname}'." + if token_start_time.tzinfo is None: + token_start_time = token_start_time.replace(tzinfo=timezone.utc) + + current_utc_time = datetime.now(tz=timezone.utc) + has_started = token_start_time is None or token_start_time <= current_utc_time + return has_started and current_utc_time < token_expire_time - timedelta(minutes=5) + except ValueError: + logger.debug( + "Unable to parse attachment SAS start or expiry time; requesting a fresh URI." ) - return refreshed_container_uri + return False def _get_blob_uri_with_sas_token(self, blob_uri: str) -> str: diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 6e84a4b9..586ce869 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -22,6 +22,14 @@ EXPIRED_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2000-01-01T00%3A00%3A00Z&sig=signature" ) +FUTURE_START_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&st=2098-01-01T00%3A00%3A00Z" + "&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +MALFORMED_EXPIRY_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=not-a-date&sig=signature" +) +HTTP_SIGNED_CONTAINER_URI = SIGNED_CONTAINER_URI.replace("https://", "http://") def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: @@ -179,6 +187,60 @@ def test_upload_attachment_refreshes_expired_job_uri(): ) +def test_upload_attachment_refreshes_job_uri_before_sas_start_time(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=FUTURE_START_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + +def test_upload_attachment_logs_and_refreshes_malformed_sas_expiry(caplog): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=MALFORMED_EXPIRY_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + with caplog.at_level("DEBUG", logger="azure.quantum.job.base_job"): + job.upload_attachment("attachment", b"data") + + assert "Unable to parse attachment SAS start or expiry time" in caplog.text + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + + +def test_upload_attachment_refreshes_signed_http_job_uri(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=HTTP_SIGNED_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + def test_upload_attachment_refreshes_job_uri_without_write_permission(): workspace = Mock() workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI @@ -198,6 +260,49 @@ def test_upload_attachment_refreshes_job_uri_without_write_permission(): ) +@patch("azure.quantum.job.base_job.ContainerClient") +def test_cached_read_only_uri_is_refreshed_before_upload(mock_container_client): + workspace = Mock() + workspace.get_container_uri.side_effect = [READ_ONLY_CONTAINER_URI, SIGNED_CONTAINER_URI] + job = _job_with_container(workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + mock_container_client.from_container_url.return_value.list_blobs.return_value = [] + + job.list_attachments() + job.upload_attachment("attachment", b"data") + + assert workspace.get_container_uri.call_count == 2 + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + +@pytest.mark.parametrize( + "cached_container_uri", + [EXPIRED_CONTAINER_URI, FUTURE_START_CONTAINER_URI], +) +def test_unusable_cached_uri_is_refreshed(cached_container_uri): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + job._attachment_container_uri_cache = cached_container_uri + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + @patch("azure.quantum.job.base_job.ContainerClient") def test_read_only_job_uri_is_reused_for_list_and_download(mock_container_client): workspace = Mock() @@ -229,11 +334,45 @@ def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): job.upload_attachment("attachment", b"data") +def test_upload_attachment_rejects_refreshed_uri_without_usable_sas(): + workspace = Mock() + workspace.get_container_uri.return_value = UNSIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + + with pytest.raises(ValueError, match="does not contain a usable SAS token"): + job.upload_attachment("attachment", b"data") + + +def test_upload_attachment_rejects_job_uri_without_container_name(): + pathless_uri = ( + "https://acct.blob.core.windows.net" + "?sp=racwdl&se=2000-01-01T00%3A00%3A00Z&sig=signature" + ) + workspace = Mock() + job = _job_with_container(container_uri=pathless_uri, workspace=workspace) + + with pytest.raises(ValueError, match="does not include a container name"): + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_not_called() + + +def test_upload_attachment_rejects_refreshed_signed_http_uri(): + workspace = Mock() + workspace.get_container_uri.return_value = HTTP_SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + + with pytest.raises(ValueError, match="does not contain a usable SAS token"): + job.upload_attachment("attachment", b"data") + + @patch("azure.quantum.job.base_job.ContainerClient") def test_attachment_methods_preserve_custom_container_name(mock_container_client): custom_container_name = "custom-container" custom_unsigned_uri = f"https://acct.blob.core.windows.net/{custom_container_name}" - custom_signed_uri = f"{custom_unsigned_uri}?sas" + custom_signed_uri = ( + f"{custom_unsigned_uri}?sp=racwdl&se=2099-01-01T00%3A00%3A00Z&sig=signature" + ) workspace = Mock() workspace.get_container_uri.return_value = custom_signed_uri job = _job_with_container(container_uri=custom_unsigned_uri, workspace=workspace) @@ -246,14 +385,10 @@ def test_attachment_methods_preserve_custom_container_name(mock_container_client attachments = job.list_attachments() downloaded = job.download_attachment("download") - workspace.get_container_uri.assert_has_calls( - [ - call(job_id=JOB_ID, container_name=custom_container_name), - call(job_id=JOB_ID, container_name=custom_container_name), - call(job_id=JOB_ID, container_name=custom_container_name), - ] + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=custom_container_name, ) - assert workspace.get_container_uri.call_count == 3 job.upload_input_data.assert_called_once_with( container_uri=custom_signed_uri, blob_name="upload", @@ -265,3 +400,50 @@ def test_attachment_methods_preserve_custom_container_name(mock_container_client ] assert attachments == [] assert downloaded == b"data" + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_replacing_job_details_invalidates_cached_container_uri(mock_container_client): + workspace = Mock() + workspace.get_container_uri.side_effect = [SIGNED_CONTAINER_URI, SIGNED_CONTAINER_URI] + job = _job_with_container(workspace=workspace) + mock_container_client.from_container_url.return_value.list_blobs.return_value = [] + + job.list_attachments() + job.details = JobDetails( + id=JOB_ID, + name="", + provider_id="", + target="", + container_uri=UNSIGNED_CONTAINER_URI, + input_data_format="", + output_data_format="", + ) + job.list_attachments() + + assert workspace.get_container_uri.call_count == 2 + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_mutating_job_container_uri_invalidates_cached_container_uri(mock_container_client): + other_container_uri = "https://acct.blob.core.windows.net/other-container" + other_signed_uri = ( + f"{other_container_uri}?sp=racwdl&se=2099-01-01T00%3A00%3A00Z&sig=signature" + ) + workspace = Mock() + workspace.get_container_uri.side_effect = [SIGNED_CONTAINER_URI, other_signed_uri] + job = _job_with_container(workspace=workspace) + mock_container_client.from_container_url.return_value.list_blobs.return_value = [] + + job.list_attachments() + job.details.container_uri = other_container_uri + job.list_attachments() + + assert workspace.get_container_uri.call_args_list == [ + call(job_id=JOB_ID, container_name=DEFAULT_CONTAINER_NAME), + call(job_id=JOB_ID, container_name="other-container"), + ] + assert mock_container_client.from_container_url.call_args_list == [ + call(SIGNED_CONTAINER_URI), + call(other_signed_uri), + ] From 37e716bd08d87398039ae7133e283af566702a5a Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 22 Sep 2026 13:53:22 -0700 Subject: [PATCH 10/15] Simplify attachment SAS cache validation --- azure-quantum/azure/quantum/job/base_job.py | 44 ++++------ azure-quantum/tests/test_job_attachments.py | 97 +++++++-------------- 2 files changed, 46 insertions(+), 95 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 97db4f91..a2d8463b 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -9,7 +9,7 @@ from enum import Enum from datetime import datetime, timezone, timedelta from urllib.parse import urlparse, parse_qs -from typing import Any, Dict, Literal, Optional, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING from azure.storage.blob import BlobClient, BlobProperties from azure.quantum.storage import upload_blob, download_blob, download_blob_properties, ContainerClient @@ -353,7 +353,7 @@ def upload_attachment( """ if container_uri is None: - container_uri = self._get_attachment_container_uri(required_permission="w") + container_uri = self._get_attachment_container_uri() uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -382,7 +382,7 @@ def download_attachment( """ if container_uri is None: - container_uri = self._get_attachment_container_uri(required_permission="r") + container_uri = self._get_attachment_container_uri() container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -399,16 +399,13 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - container_uri = self._get_attachment_container_uri(required_permission="l") + container_uri = self._get_attachment_container_uri() container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) - def _get_attachment_container_uri( - self, - required_permission: Literal["r", "w", "l"], - ) -> str: + def _get_attachment_container_uri(self) -> str: container_uri = self._details.container_uri container_identity = self._get_attachment_container_identity(container_uri) cached_container_uri = self._attachment_container_uri_cache @@ -416,21 +413,12 @@ def _get_attachment_container_uri( if self._attachment_container_uri_cache_identity != container_identity: self._attachment_container_uri_cache = None self._attachment_container_uri_cache_identity = None - elif self._is_attachment_container_uri_usable( - cached_container_uri, - required_permission, - ): + elif self._is_attachment_container_uri_usable(cached_container_uri): return cached_container_uri if container_uri is None: refreshed_container_uri = self.workspace.get_container_uri(job_id=self.id) else: - if self._is_attachment_container_uri_usable( - container_uri, - required_permission, - ): - return container_uri - refreshed_container_uri = self.workspace.get_container_uri( job_id=self.id, container_name=self.container_name, @@ -444,13 +432,9 @@ def _get_attachment_container_uri( f"'{stored_hostname}'." ) - if not self._is_attachment_container_uri_usable( - refreshed_container_uri, - required_permission, - ): + if not self._is_attachment_container_uri_usable(refreshed_container_uri): raise ValueError( - "Refreshed attachment container URI does not contain a usable SAS token " - f"with '{required_permission}' permission." + "Refreshed attachment container URI does not contain a usable SAS token." ) self._attachment_container_uri_cache = refreshed_container_uri @@ -472,21 +456,23 @@ def _get_attachment_container_identity( def _is_attachment_container_uri_usable( self, container_uri: str, - required_permission: Literal["r", "w", "l"], ) -> bool: parsed_uri = urlparse(container_uri) - if parsed_uri.scheme.lower() != "https": + if ( + parsed_uri.scheme.lower() != "https" + or parsed_uri.hostname is None + or not parsed_uri.path.strip("/") + ): return False query_params = parse_qs(parsed_uri.query) token_expire_query_param = query_params.get("se") token_start_query_param = query_params.get("st") - token_permissions = query_params.get("sp", [""])[0] if ( not query_params.get("sig") + or not query_params.get("sp") or not token_expire_query_param - or required_permission not in token_permissions ): return False @@ -507,7 +493,7 @@ def _is_attachment_container_uri_usable( current_utc_time = datetime.now(tz=timezone.utc) has_started = token_start_time is None or token_start_time <= current_utc_time - return has_started and current_utc_time < token_expire_time - timedelta(minutes=5) + return has_started and current_utc_time + timedelta(minutes=5) < token_expire_time except ValueError: logger.debug( "Unable to parse attachment SAS start or expiry time; requesting a fresh URI." diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 586ce869..06906181 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -16,9 +16,6 @@ SIGNED_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2099-01-01T00%3A00%3A00Z&sig=signature" ) -READ_ONLY_CONTAINER_URI = ( - f"{UNSIGNED_CONTAINER_URI}?sp=rl&se=2099-01-01T00%3A00%3A00Z&sig=signature" -) EXPIRED_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2000-01-01T00%3A00%3A00Z&sig=signature" ) @@ -29,6 +26,12 @@ MALFORMED_EXPIRY_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=not-a-date&sig=signature" ) +MINIMUM_EXPIRY_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=0001-01-01T00%3A00%3A00Z&sig=signature" +) +NO_PERMISSIONS_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" +) HTTP_SIGNED_CONTAINER_URI = SIGNED_CONTAINER_URI.replace("https://", "http://") @@ -142,8 +145,9 @@ def test_attachment_methods_honor_explicit_container_uri(mock_container_client): @patch("azure.quantum.job.base_job.ContainerClient") -def test_attachment_methods_reuse_valid_signed_job_uri(mock_container_client): +def test_attachment_methods_refresh_signed_job_uri_once(mock_container_client): workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(container_uri=SIGNED_CONTAINER_URI, workspace=workspace) job.upload_input_data = Mock(return_value="uploaded-uri") container_client = mock_container_client.from_container_url.return_value @@ -154,7 +158,10 @@ def test_attachment_methods_reuse_valid_signed_job_uri(mock_container_client): attachments = job.list_attachments() downloaded = job.download_attachment("download") - workspace.get_container_uri.assert_not_called() + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) job.upload_input_data.assert_called_once_with( container_uri=SIGNED_CONTAINER_URI, blob_name="upload", @@ -209,7 +216,11 @@ def test_upload_attachment_refreshes_job_uri_before_sas_start_time(): def test_upload_attachment_logs_and_refreshes_malformed_sas_expiry(caplog): workspace = Mock() workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI - job = _job_with_container(container_uri=MALFORMED_EXPIRY_CONTAINER_URI, workspace=workspace) + job = _job_with_container(workspace=workspace) + job._attachment_container_uri_cache = MALFORMED_EXPIRY_CONTAINER_URI + job._attachment_container_uri_cache_identity = job._get_attachment_container_identity( + job.details.container_uri + ) job.upload_input_data = Mock(return_value="uploaded-uri") with caplog.at_level("DEBUG", logger="azure.quantum.job.base_job"): @@ -241,53 +252,18 @@ def test_upload_attachment_refreshes_signed_http_job_uri(): ) -def test_upload_attachment_refreshes_job_uri_without_write_permission(): - workspace = Mock() - workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI - job = _job_with_container(container_uri=READ_ONLY_CONTAINER_URI, workspace=workspace) - job.upload_input_data = Mock(return_value="uploaded-uri") - - job.upload_attachment("attachment", b"data") - - workspace.get_container_uri.assert_called_once_with( - job_id=JOB_ID, - container_name=DEFAULT_CONTAINER_NAME, - ) - job.upload_input_data.assert_called_once_with( - container_uri=SIGNED_CONTAINER_URI, - blob_name="attachment", - input_data=b"data", - ) - - -@patch("azure.quantum.job.base_job.ContainerClient") -def test_cached_read_only_uri_is_refreshed_before_upload(mock_container_client): - workspace = Mock() - workspace.get_container_uri.side_effect = [READ_ONLY_CONTAINER_URI, SIGNED_CONTAINER_URI] - job = _job_with_container(workspace=workspace) - job.upload_input_data = Mock(return_value="uploaded-uri") - mock_container_client.from_container_url.return_value.list_blobs.return_value = [] - - job.list_attachments() - job.upload_attachment("attachment", b"data") - - assert workspace.get_container_uri.call_count == 2 - job.upload_input_data.assert_called_once_with( - container_uri=SIGNED_CONTAINER_URI, - blob_name="attachment", - input_data=b"data", - ) - - @pytest.mark.parametrize( "cached_container_uri", - [EXPIRED_CONTAINER_URI, FUTURE_START_CONTAINER_URI], + [EXPIRED_CONTAINER_URI, FUTURE_START_CONTAINER_URI, MINIMUM_EXPIRY_CONTAINER_URI], ) def test_unusable_cached_uri_is_refreshed(cached_container_uri): workspace = Mock() workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(workspace=workspace) job._attachment_container_uri_cache = cached_container_uri + job._attachment_container_uri_cache_identity = job._get_attachment_container_identity( + job.details.container_uri + ) job.upload_input_data = Mock(return_value="uploaded-uri") job.upload_attachment("attachment", b"data") @@ -303,26 +279,6 @@ def test_unusable_cached_uri_is_refreshed(cached_container_uri): ) -@patch("azure.quantum.job.base_job.ContainerClient") -def test_read_only_job_uri_is_reused_for_list_and_download(mock_container_client): - workspace = Mock() - job = _job_with_container(container_uri=READ_ONLY_CONTAINER_URI, workspace=workspace) - container_client = mock_container_client.from_container_url.return_value - container_client.list_blobs.return_value = [] - container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" - - attachments = job.list_attachments() - downloaded = job.download_attachment("download") - - workspace.get_container_uri.assert_not_called() - assert mock_container_client.from_container_url.call_args_list == [ - call(READ_ONLY_CONTAINER_URI), - call(READ_ONLY_CONTAINER_URI), - ] - assert attachments == [] - assert downloaded == b"data" - - def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): workspace = Mock() workspace.get_container_uri.return_value = ( @@ -346,7 +302,7 @@ def test_upload_attachment_rejects_refreshed_uri_without_usable_sas(): def test_upload_attachment_rejects_job_uri_without_container_name(): pathless_uri = ( "https://acct.blob.core.windows.net" - "?sp=racwdl&se=2000-01-01T00%3A00%3A00Z&sig=signature" + "?sp=racwdl&se=2099-01-01T00%3A00%3A00Z&sig=signature" ) workspace = Mock() job = _job_with_container(container_uri=pathless_uri, workspace=workspace) @@ -366,6 +322,15 @@ def test_upload_attachment_rejects_refreshed_signed_http_uri(): job.upload_attachment("attachment", b"data") +def test_upload_attachment_rejects_refreshed_uri_without_permissions(): + workspace = Mock() + workspace.get_container_uri.return_value = NO_PERMISSIONS_CONTAINER_URI + job = _job_with_container(workspace=workspace) + + with pytest.raises(ValueError, match="does not contain a usable SAS token"): + job.upload_attachment("attachment", b"data") + + @patch("azure.quantum.job.base_job.ContainerClient") def test_attachment_methods_preserve_custom_container_name(mock_container_client): custom_container_name = "custom-container" From d3b32bd12c73ecb2ced6644abdbdeb29100a0e7e Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 22 Sep 2026 14:39:21 -0700 Subject: [PATCH 11/15] Validate backend attachment SAS capabilities --- azure-quantum/azure/quantum/job/base_job.py | 4 +- azure-quantum/tests/test_job_attachments.py | 48 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index a2d8463b..5abe2c75 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 300 # Default timeout for waiting for job to complete +_ATTACHMENT_CONTAINER_SAS_PERMISSIONS = frozenset({"r", "w", "l"}) class ContentType(str, Enum): json = "application/json" @@ -469,9 +470,10 @@ def _is_attachment_container_uri_usable( query_params = parse_qs(parsed_uri.query) token_expire_query_param = query_params.get("se") token_start_query_param = query_params.get("st") + token_permissions = set(query_params.get("sp", [""])[0]) if ( not query_params.get("sig") - or not query_params.get("sp") + or not _ATTACHMENT_CONTAINER_SAS_PERMISSIONS.issubset(token_permissions) or not token_expire_query_param ): return False diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 06906181..f68fce2f 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -32,6 +32,12 @@ NO_PERMISSIONS_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" ) +WRITE_ONLY_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=w&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +READ_LIST_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=rl&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) HTTP_SIGNED_CONTAINER_URI = SIGNED_CONTAINER_URI.replace("https://", "http://") @@ -279,6 +285,33 @@ def test_unusable_cached_uri_is_refreshed(cached_container_uri): ) +@pytest.mark.parametrize( + "cached_container_uri", + [WRITE_ONLY_CONTAINER_URI, READ_LIST_CONTAINER_URI], +) +def test_cached_uri_without_full_attachment_permissions_is_refreshed(cached_container_uri): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + job._attachment_container_uri_cache = cached_container_uri + job._attachment_container_uri_cache_identity = job._get_attachment_container_identity( + job.details.container_uri + ) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): workspace = Mock() workspace.get_container_uri.return_value = ( @@ -331,6 +364,21 @@ def test_upload_attachment_rejects_refreshed_uri_without_permissions(): job.upload_attachment("attachment", b"data") +@pytest.mark.parametrize( + "refreshed_container_uri", + [WRITE_ONLY_CONTAINER_URI, READ_LIST_CONTAINER_URI], +) +def test_upload_attachment_rejects_refreshed_uri_without_full_attachment_permissions( + refreshed_container_uri, +): + workspace = Mock() + workspace.get_container_uri.return_value = refreshed_container_uri + job = _job_with_container(workspace=workspace) + + with pytest.raises(ValueError, match="does not contain a usable SAS token"): + job.upload_attachment("attachment", b"data") + + @patch("azure.quantum.job.base_job.ContainerClient") def test_attachment_methods_preserve_custom_container_name(mock_container_client): custom_container_name = "custom-container" From 7e7121ca4224d7c1f0fcd87bea3636e7762601f6 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 22 Sep 2026 15:11:08 -0700 Subject: [PATCH 12/15] Document attachment SAS cache behavior --- azure-quantum/azure/quantum/job/base_job.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 5abe2c75..4229c332 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 300 # Default timeout for waiting for job to complete +# The workspace storage endpoint issues one container SAS for upload, download, and list operations. _ATTACHMENT_CONTAINER_SAS_PERMISSIONS = frozenset({"r", "w", "l"}) class ContentType(str, Enum): @@ -407,6 +408,11 @@ def list_attachments(self) -> list[BlobProperties]: def _get_attachment_container_uri(self) -> str: + """Return a validated workspace-issued SAS URI for the job's attachment container. + + The first call refreshes the unsigned URI stored in job details. Later calls reuse the + job-scoped cache while its container identity, validity period, and capabilities remain valid. + """ container_uri = self._details.container_uri container_identity = self._get_attachment_container_identity(container_uri) cached_container_uri = self._attachment_container_uri_cache @@ -458,6 +464,7 @@ def _is_attachment_container_uri_usable( self, container_uri: str, ) -> bool: + """Check whether a workspace-issued container SAS can serve all attachment operations.""" parsed_uri = urlparse(container_uri) if ( From e1a232f0a9a66a0066e8023dfd3dade9b95c9e8a Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 24 Sep 2026 10:44:34 -0700 Subject: [PATCH 13/15] Simplify attachment SAS cache to expiry-only, trust workspace URIs --- azure-quantum/azure/quantum/job/base_job.py | 84 +++--------- azure-quantum/tests/test_job_attachments.py | 140 +++++++++----------- 2 files changed, 81 insertions(+), 143 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 4229c332..8ee5dcd4 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -24,8 +24,6 @@ logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 300 # Default timeout for waiting for job to complete -# The workspace storage endpoint issues one container SAS for upload, download, and list operations. -_ATTACHMENT_CONTAINER_SAS_PERMISSIONS = frozenset({"r", "w", "l"}) class ContentType(str, Enum): json = "application/json" @@ -46,7 +44,7 @@ class BaseJob(WorkspaceItem): def __init__(self, workspace: "Workspace", details: JobDetails, **kwargs): self._attachment_container_uri_cache: Optional[str] = None - self._attachment_container_uri_cache_identity: Optional[tuple[Optional[str], str]] = None + self._attachment_container_uri_cache_container_name: Optional[str] = None super().__init__(workspace=workspace, details=details, **kwargs) @staticmethod @@ -63,7 +61,7 @@ def details(self) -> JobDetails: def details(self, value: JobDetails): self._details = value self._attachment_container_uri_cache = None - self._attachment_container_uri_cache_identity = None + self._attachment_container_uri_cache_container_name = None @property def container_name(self) -> str: @@ -408,19 +406,20 @@ def list_attachments(self) -> list[BlobProperties]: def _get_attachment_container_uri(self) -> str: - """Return a validated workspace-issued SAS URI for the job's attachment container. + """Return a workspace-issued URI for the job's attachment container. - The first call refreshes the unsigned URI stored in job details. Later calls reuse the - job-scoped cache while its container identity, validity period, and capabilities remain valid. + Workspace-issued URIs are cached privately on the job and are never written back to job + details. A cached URI is reused while its container name is unchanged and its SAS expiry + remains outside the renewal window. """ container_uri = self._details.container_uri - container_identity = self._get_attachment_container_identity(container_uri) + container_name = self.container_name cached_container_uri = self._attachment_container_uri_cache if cached_container_uri: - if self._attachment_container_uri_cache_identity != container_identity: + if self._attachment_container_uri_cache_container_name != container_name: self._attachment_container_uri_cache = None - self._attachment_container_uri_cache_identity = None - elif self._is_attachment_container_uri_usable(cached_container_uri): + self._attachment_container_uri_cache_container_name = None + elif self._is_attachment_container_uri_unexpired(cached_container_uri): return cached_container_uri if container_uri is None: @@ -428,61 +427,23 @@ def _get_attachment_container_uri(self) -> str: else: refreshed_container_uri = self.workspace.get_container_uri( job_id=self.id, - container_name=self.container_name, - ) - stored_hostname = urlparse(container_uri).hostname - refreshed_hostname = urlparse(refreshed_container_uri).hostname - if stored_hostname != refreshed_hostname: - raise ValueError( - "Refreshed attachment container hostname " - f"'{refreshed_hostname}' does not match job container hostname " - f"'{stored_hostname}'." - ) - - if not self._is_attachment_container_uri_usable(refreshed_container_uri): - raise ValueError( - "Refreshed attachment container URI does not contain a usable SAS token." + container_name=container_name, ) self._attachment_container_uri_cache = refreshed_container_uri - self._attachment_container_uri_cache_identity = container_identity + self._attachment_container_uri_cache_container_name = container_name return refreshed_container_uri - def _get_attachment_container_identity( - self, - container_uri: Optional[str], - ) -> tuple[Optional[str], str]: - if container_uri is None: - return (None, f"/job-{self.id}") - - parsed_uri = urlparse(container_uri) - return (parsed_uri.hostname, parsed_uri.path.rstrip("/")) - - - def _is_attachment_container_uri_usable( + def _is_attachment_container_uri_unexpired( self, container_uri: str, ) -> bool: - """Check whether a workspace-issued container SAS can serve all attachment operations.""" - - parsed_uri = urlparse(container_uri) - if ( - parsed_uri.scheme.lower() != "https" - or parsed_uri.hostname is None - or not parsed_uri.path.strip("/") - ): - return False + """Check whether a cached container SAS remains outside the renewal window.""" - query_params = parse_qs(parsed_uri.query) + query_params = parse_qs(urlparse(container_uri).query) token_expire_query_param = query_params.get("se") - token_start_query_param = query_params.get("st") - token_permissions = set(query_params.get("sp", [""])[0]) - if ( - not query_params.get("sig") - or not _ATTACHMENT_CONTAINER_SAS_PERMISSIONS.issubset(token_permissions) - or not token_expire_query_param - ): + if not token_expire_query_param: return False try: @@ -492,20 +453,11 @@ def _is_attachment_container_uri_usable( if token_expire_time.tzinfo is None: token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) - token_start_time = None - if token_start_query_param: - token_start_time = datetime.fromisoformat( - token_start_query_param[0].replace("Z", "+00:00") - ) - if token_start_time.tzinfo is None: - token_start_time = token_start_time.replace(tzinfo=timezone.utc) - current_utc_time = datetime.now(tz=timezone.utc) - has_started = token_start_time is None or token_start_time <= current_utc_time - return has_started and current_utc_time + timedelta(minutes=5) < token_expire_time + return current_utc_time + timedelta(minutes=5) < token_expire_time except ValueError: logger.debug( - "Unable to parse attachment SAS start or expiry time; requesting a fresh URI." + "Unable to parse attachment SAS expiry time; requesting a fresh URI." ) return False diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index f68fce2f..b8dfc6ed 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. ## +from datetime import datetime, timedelta, timezone from unittest.mock import Mock, call, patch import pytest @@ -32,6 +33,9 @@ NO_PERMISSIONS_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" ) +NO_SIGNATURE_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z" +) WRITE_ONLY_CONTAINER_URI = ( f"{UNSIGNED_CONTAINER_URI}?sp=w&se=2099-01-01T00%3A00%3A00Z&sig=signature" ) @@ -200,20 +204,18 @@ def test_upload_attachment_refreshes_expired_job_uri(): ) -def test_upload_attachment_refreshes_job_uri_before_sas_start_time(): +def test_cached_uri_is_reused_regardless_of_sas_start_time(): workspace = Mock() - workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI - job = _job_with_container(container_uri=FUTURE_START_CONTAINER_URI, workspace=workspace) + job = _job_with_container(workspace=workspace) + job._attachment_container_uri_cache = FUTURE_START_CONTAINER_URI + job._attachment_container_uri_cache_container_name = DEFAULT_CONTAINER_NAME job.upload_input_data = Mock(return_value="uploaded-uri") job.upload_attachment("attachment", b"data") - workspace.get_container_uri.assert_called_once_with( - job_id=JOB_ID, - container_name=DEFAULT_CONTAINER_NAME, - ) + workspace.get_container_uri.assert_not_called() job.upload_input_data.assert_called_once_with( - container_uri=SIGNED_CONTAINER_URI, + container_uri=FUTURE_START_CONTAINER_URI, blob_name="attachment", input_data=b"data", ) @@ -224,25 +226,29 @@ def test_upload_attachment_logs_and_refreshes_malformed_sas_expiry(caplog): workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(workspace=workspace) job._attachment_container_uri_cache = MALFORMED_EXPIRY_CONTAINER_URI - job._attachment_container_uri_cache_identity = job._get_attachment_container_identity( - job.details.container_uri - ) + job._attachment_container_uri_cache_container_name = DEFAULT_CONTAINER_NAME job.upload_input_data = Mock(return_value="uploaded-uri") with caplog.at_level("DEBUG", logger="azure.quantum.job.base_job"): job.upload_attachment("attachment", b"data") - assert "Unable to parse attachment SAS start or expiry time" in caplog.text + assert "Unable to parse attachment SAS expiry time" in caplog.text workspace.get_container_uri.assert_called_once_with( job_id=JOB_ID, container_name=DEFAULT_CONTAINER_NAME, ) -def test_upload_attachment_refreshes_signed_http_job_uri(): +def test_cached_uri_inside_expiry_buffer_is_refreshed(): + near_expiry_container_uri = ( + f"{UNSIGNED_CONTAINER_URI}?se=" + f"{(datetime.now(timezone.utc) + timedelta(minutes=4)).isoformat().replace('+00:00', 'Z')}" + ) workspace = Mock() workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI - job = _job_with_container(container_uri=HTTP_SIGNED_CONTAINER_URI, workspace=workspace) + job = _job_with_container(workspace=workspace) + job._attachment_container_uri_cache = near_expiry_container_uri + job._attachment_container_uri_cache_container_name = DEFAULT_CONTAINER_NAME job.upload_input_data = Mock(return_value="uploaded-uri") job.upload_attachment("attachment", b"data") @@ -260,16 +266,14 @@ def test_upload_attachment_refreshes_signed_http_job_uri(): @pytest.mark.parametrize( "cached_container_uri", - [EXPIRED_CONTAINER_URI, FUTURE_START_CONTAINER_URI, MINIMUM_EXPIRY_CONTAINER_URI], + [UNSIGNED_CONTAINER_URI, EXPIRED_CONTAINER_URI, MINIMUM_EXPIRY_CONTAINER_URI], ) -def test_unusable_cached_uri_is_refreshed(cached_container_uri): +def test_cached_uri_without_reusable_expiry_is_refreshed(cached_container_uri): workspace = Mock() workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(workspace=workspace) job._attachment_container_uri_cache = cached_container_uri - job._attachment_container_uri_cache_identity = job._get_attachment_container_identity( - job.details.container_uri - ) + job._attachment_container_uri_cache_container_name = DEFAULT_CONTAINER_NAME job.upload_input_data = Mock(return_value="uploaded-uri") job.upload_attachment("attachment", b"data") @@ -287,49 +291,64 @@ def test_unusable_cached_uri_is_refreshed(cached_container_uri): @pytest.mark.parametrize( "cached_container_uri", - [WRITE_ONLY_CONTAINER_URI, READ_LIST_CONTAINER_URI], + [ + FUTURE_START_CONTAINER_URI, + HTTP_SIGNED_CONTAINER_URI, + NO_PERMISSIONS_CONTAINER_URI, + NO_SIGNATURE_CONTAINER_URI, + WRITE_ONLY_CONTAINER_URI, + READ_LIST_CONTAINER_URI, + ], ) -def test_cached_uri_without_full_attachment_permissions_is_refreshed(cached_container_uri): +def test_unexpired_cached_uri_is_reused_without_prevalidation(cached_container_uri): workspace = Mock() - workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(workspace=workspace) job._attachment_container_uri_cache = cached_container_uri - job._attachment_container_uri_cache_identity = job._get_attachment_container_identity( - job.details.container_uri - ) + job._attachment_container_uri_cache_container_name = DEFAULT_CONTAINER_NAME job.upload_input_data = Mock(return_value="uploaded-uri") job.upload_attachment("attachment", b"data") - workspace.get_container_uri.assert_called_once_with( - job_id=JOB_ID, - container_name=DEFAULT_CONTAINER_NAME, - ) + workspace.get_container_uri.assert_not_called() job.upload_input_data.assert_called_once_with( - container_uri=SIGNED_CONTAINER_URI, + container_uri=cached_container_uri, blob_name="attachment", input_data=b"data", ) -def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): +@pytest.mark.parametrize( + "refreshed_container_uri", + [ + UNSIGNED_CONTAINER_URI, + HTTP_SIGNED_CONTAINER_URI, + NO_PERMISSIONS_CONTAINER_URI, + WRITE_ONLY_CONTAINER_URI, + READ_LIST_CONTAINER_URI, + f"https://other-acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}?sas", + ], +) +def test_workspace_issued_uri_is_used_without_prevalidation(refreshed_container_uri): workspace = Mock() - workspace.get_container_uri.return_value = ( - f"https://other-acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}?sas" - ) + workspace.get_container_uri.return_value = refreshed_container_uri job = _job_with_container(workspace=workspace) + original_container_uri = job.details.container_uri + job.upload_input_data = Mock(return_value="uploaded-uri") - with pytest.raises(ValueError, match="does not match job container hostname"): - job.upload_attachment("attachment", b"data") - - -def test_upload_attachment_rejects_refreshed_uri_without_usable_sas(): - workspace = Mock() - workspace.get_container_uri.return_value = UNSIGNED_CONTAINER_URI - job = _job_with_container(workspace=workspace) + job.upload_attachment("attachment", b"data") - with pytest.raises(ValueError, match="does not contain a usable SAS token"): - job.upload_attachment("attachment", b"data") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=refreshed_container_uri, + blob_name="attachment", + input_data=b"data", + ) + assert job._attachment_container_uri_cache == refreshed_container_uri + assert job._attachment_container_uri_cache_container_name == DEFAULT_CONTAINER_NAME + assert job.details.container_uri == original_container_uri def test_upload_attachment_rejects_job_uri_without_container_name(): @@ -346,39 +365,6 @@ def test_upload_attachment_rejects_job_uri_without_container_name(): workspace.get_container_uri.assert_not_called() -def test_upload_attachment_rejects_refreshed_signed_http_uri(): - workspace = Mock() - workspace.get_container_uri.return_value = HTTP_SIGNED_CONTAINER_URI - job = _job_with_container(workspace=workspace) - - with pytest.raises(ValueError, match="does not contain a usable SAS token"): - job.upload_attachment("attachment", b"data") - - -def test_upload_attachment_rejects_refreshed_uri_without_permissions(): - workspace = Mock() - workspace.get_container_uri.return_value = NO_PERMISSIONS_CONTAINER_URI - job = _job_with_container(workspace=workspace) - - with pytest.raises(ValueError, match="does not contain a usable SAS token"): - job.upload_attachment("attachment", b"data") - - -@pytest.mark.parametrize( - "refreshed_container_uri", - [WRITE_ONLY_CONTAINER_URI, READ_LIST_CONTAINER_URI], -) -def test_upload_attachment_rejects_refreshed_uri_without_full_attachment_permissions( - refreshed_container_uri, -): - workspace = Mock() - workspace.get_container_uri.return_value = refreshed_container_uri - job = _job_with_container(workspace=workspace) - - with pytest.raises(ValueError, match="does not contain a usable SAS token"): - job.upload_attachment("attachment", b"data") - - @patch("azure.quantum.job.base_job.ContainerClient") def test_attachment_methods_preserve_custom_container_name(mock_container_client): custom_container_name = "custom-container" From 2d22bb75717563b07dc975ca3812623512bf56d8 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 24 Sep 2026 11:39:20 -0700 Subject: [PATCH 14/15] Add test proving cache key never queries by storage hostname --- azure-quantum/tests/test_job_attachments.py | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index b8dfc6ed..310158f3 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -423,6 +423,30 @@ def test_replacing_job_details_invalidates_cached_container_uri(mock_container_c assert workspace.get_container_uri.call_count == 2 +@patch("azure.quantum.job.base_job.ContainerClient") +def test_cache_reuse_after_hostname_mutation_never_queries_by_hostname(mock_container_client): + other_account_same_name_uri = f"https://other-acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + mock_container_client.from_container_url.return_value.list_blobs.return_value = [] + + job.list_attachments() + job.details.container_uri = other_account_same_name_uri + job.list_attachments() + + # container_name-only cache key reuses the cache despite the hostname mutation. + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + # get_container_uri never received a hostname, so both calls used the same real account SAS. + assert mock_container_client.from_container_url.call_args_list == [ + call(SIGNED_CONTAINER_URI), + call(SIGNED_CONTAINER_URI), + ] + + @patch("azure.quantum.job.base_job.ContainerClient") def test_mutating_job_container_uri_invalidates_cached_container_uri(mock_container_client): other_container_uri = "https://acct.blob.core.windows.net/other-container" From 30c94a2f0c44f5ca7b89ebc06abade8af2702584 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 24 Sep 2026 21:37:59 -0700 Subject: [PATCH 15/15] Extract shared SAS-expiry-parsing helper; add coverage for blob SAS refresh --- azure-quantum/azure/quantum/job/base_job.py | 65 ++++++++++----------- azure-quantum/tests/test_job_attachments.py | 54 ++++++++++++++++- 2 files changed, 84 insertions(+), 35 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 8ee5dcd4..41319784 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -435,32 +435,46 @@ def _get_attachment_container_uri(self) -> str: return refreshed_container_uri - def _is_attachment_container_uri_unexpired( - self, - container_uri: str, - ) -> bool: - """Check whether a cached container SAS remains outside the renewal window.""" + # Buffer so a SAS token is never used a few seconds before its actual expiry. + _SAS_RENEWAL_BUFFER = timedelta(minutes=5) + + @staticmethod + def _get_sas_expiry_time(uri: str) -> Optional[datetime]: + """Parse the `se` (SAS expiry) query parameter from a URI. Returns None if missing or malformed.""" - query_params = parse_qs(urlparse(container_uri).query) + query_params = parse_qs(urlparse(uri).query) token_expire_query_param = query_params.get("se") if not token_expire_query_param: - return False + return None try: + # Since python < 3.11 can not easily parse Z suffixed UTC timestamp and + # assuming that the timestamp is always UTC, we replace that suffix with UTC offset. token_expire_time = datetime.fromisoformat( token_expire_query_param[0].replace("Z", "+00:00") ) - if token_expire_time.tzinfo is None: - token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) - - current_utc_time = datetime.now(tz=timezone.utc) - return current_utc_time + timedelta(minutes=5) < token_expire_time except ValueError: - logger.debug( - "Unable to parse attachment SAS expiry time; requesting a fresh URI." - ) + logger.debug("Unable to parse SAS expiry time.") + return None + + if token_expire_time.tzinfo is None: + token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) + return token_expire_time + + + def _is_attachment_container_uri_unexpired( + self, + container_uri: str, + ) -> bool: + """Check whether a cached container SAS remains outside the renewal window.""" + + token_expire_time = self._get_sas_expiry_time(container_uri) + if token_expire_time is None: return False + current_utc_time = datetime.now(tz=timezone.utc) + return current_utc_time + self._SAS_RENEWAL_BUFFER < token_expire_time + def _get_blob_uri_with_sas_token(self, blob_uri: str) -> str: """Get Blob URI with SAS-token if one was not specified in blob_uri parameter @@ -469,27 +483,10 @@ def _get_blob_uri_with_sas_token(self, blob_uri: str) -> str: :return: Blob URI with SAS-token :rtype: str """ - url = urlparse(blob_uri) - query_params = parse_qs(url.query) - token_expire_query_param = query_params.get("se") - - token_expire_time = None - - if token_expire_query_param is not None: - token_expire_time_str = token_expire_query_param[0] - - # Since python < 3.11 can not easily parse Z suffixed UTC timestamp and - # assuming that the timestamp is always UTC, we replace that suffix with UTC offset. - token_expire_time = datetime.fromisoformat( - token_expire_time_str.replace('Z', '+00:00') - ) - - # Make an expiration time a little earlier, so there's no case where token is - # used a second or so before of its expiration. - token_expire_time = token_expire_time - timedelta(minutes=5) + token_expire_time = self._get_sas_expiry_time(blob_uri) current_utc_time = datetime.now(tz=timezone.utc) - if token_expire_time is None or current_utc_time >= token_expire_time: + if token_expire_time is None or current_utc_time + self._SAS_RENEWAL_BUFFER >= token_expire_time: # blob_uri does not contains SAS token or it is expired, # get sas url from service blob_client = BlobClient.from_blob_url( diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 310158f3..3fa6a034 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -232,7 +232,7 @@ def test_upload_attachment_logs_and_refreshes_malformed_sas_expiry(caplog): with caplog.at_level("DEBUG", logger="azure.quantum.job.base_job"): job.upload_attachment("attachment", b"data") - assert "Unable to parse attachment SAS expiry time" in caplog.text + assert "Unable to parse SAS expiry time" in caplog.text workspace.get_container_uri.assert_called_once_with( job_id=JOB_ID, container_name=DEFAULT_CONTAINER_NAME, @@ -470,3 +470,55 @@ def test_mutating_job_container_uri_invalidates_cached_container_uri(mock_contai call(SIGNED_CONTAINER_URI), call(other_signed_uri), ] + + +BLOB_NAME = "outputData" +UNSIGNED_BLOB_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}/{BLOB_NAME}" +SIGNED_BLOB_URI = ( + f"{UNSIGNED_BLOB_URI}?sp=r&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +EXPIRED_BLOB_URI = ( + f"{UNSIGNED_BLOB_URI}?sp=r&se=2000-01-01T00%3A00%3A00Z&sig=signature" +) +MALFORMED_EXPIRY_BLOB_URI = ( + f"{UNSIGNED_BLOB_URI}?sp=r&se=not-a-date&sig=signature" +) + + +def test_get_blob_uri_with_sas_token_reuses_unexpired_uri(): + workspace = Mock() + job = _job_with_container(workspace=workspace) + + result = job._get_blob_uri_with_sas_token(SIGNED_BLOB_URI) + + assert result == SIGNED_BLOB_URI + workspace._get_linked_storage_sas_uri.assert_not_called() + + +@pytest.mark.parametrize("blob_uri", [UNSIGNED_BLOB_URI, EXPIRED_BLOB_URI]) +def test_get_blob_uri_with_sas_token_refreshes_expired_or_unsigned_uri(blob_uri): + workspace = Mock() + workspace._get_linked_storage_sas_uri.return_value = SIGNED_BLOB_URI + job = _job_with_container(workspace=workspace) + + result = job._get_blob_uri_with_sas_token(blob_uri) + + assert result == SIGNED_BLOB_URI + workspace._get_linked_storage_sas_uri.assert_called_once_with( + DEFAULT_CONTAINER_NAME, BLOB_NAME + ) + + +def test_get_blob_uri_with_sas_token_logs_and_refreshes_malformed_expiry(caplog): + workspace = Mock() + workspace._get_linked_storage_sas_uri.return_value = SIGNED_BLOB_URI + job = _job_with_container(workspace=workspace) + + with caplog.at_level("DEBUG", logger="azure.quantum.job.base_job"): + result = job._get_blob_uri_with_sas_token(MALFORMED_EXPIRY_BLOB_URI) + + assert result == SIGNED_BLOB_URI + assert "Unable to parse SAS expiry time" in caplog.text + workspace._get_linked_storage_sas_uri.assert_called_once_with( + DEFAULT_CONTAINER_NAME, BLOB_NAME + )