From 4a475f2d6a33d5eec288dab502abc7b035729021 Mon Sep 17 00:00:00 2001 From: Spencer Axelrod Date: Mon, 23 Mar 2026 00:04:57 -0500 Subject: [PATCH 01/19] initial drs 1.5 support --- gen3-integration-tests/conftest.py | 23 +++++++ gen3-integration-tests/pyproject.toml | 1 + gen3-integration-tests/services/drs.py | 87 ++++++++++++++++++++------ 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/gen3-integration-tests/conftest.py b/gen3-integration-tests/conftest.py index a7de46580..baf40bb47 100644 --- a/gen3-integration-tests/conftest.py +++ b/gen3-integration-tests/conftest.py @@ -95,6 +95,29 @@ def get_fence_clients(): setup.get_rotated_client_id_secret() +@pytest.fixture(scope="session") +def drs_version(): + """ + Fetch DRS version from service-info for conditional test gating. + + Returns the DRS spec version from the service-info endpoint. + Falls back to 1.2. + """ + from services.drs import Drs + + drs = Drs() + try: + resp = drs.get_service_info() + if resp.status_code == 200: + svc = resp.json() + return svc.get("type", {}).get("version", "1.2") + except Exception as e: + logger.error( + f"Could not fetch DRS service-info: {e}. Defaulting to DRS 1.2. DRS 1.5 tests will be skipped." + ) + return "1.2" + + def pytest_configure(config): # Compute hostname and namespace pytest.hostname = os.getenv("HOSTNAME") diff --git a/gen3-integration-tests/pyproject.toml b/gen3-integration-tests/pyproject.toml index fdc15bb7b..b97582151 100644 --- a/gen3-integration-tests/pyproject.toml +++ b/gen3-integration-tests/pyproject.toml @@ -81,6 +81,7 @@ markers = [ # features "agg_mds: tests for aggregate metadata service", "client_credentials: tests for fence client credentials", + "drs: tests for DRS (Data Repository Service) endpoints", "etl: tests for etl", "data_upload: tests for data upload", "gen3sdk: tests for gen3sdk", diff --git a/gen3-integration-tests/services/drs.py b/gen3-integration-tests/services/drs.py index 0c934d9eb..6a8556324 100644 --- a/gen3-integration-tests/services/drs.py +++ b/gen3-integration-tests/services/drs.py @@ -1,8 +1,9 @@ +import json +from uuid import uuid4 + import pytest import requests - from gen3.auth import Gen3Auth -from uuid import uuid4 from utils import logger @@ -10,40 +11,86 @@ class Drs(object): def __init__(self): self.BASE_URL = f"{pytest.root_url}" self.DRS_ENDPOINT = "/ga4gh/drs/v1/objects" + self.SERVICE_INFO_ENDPOINT = "/ga4gh/drs/v1/service-info" - def get_drs_object(self, file: dict, user="main_account"): - """Get Drs object""" - auth = Gen3Auth(refresh_token=pytest.api_keys[user], endpoint=self.BASE_URL) + def _auth(self, user: str = "main_account") -> Gen3Auth: + return Gen3Auth(refresh_token=pytest.api_keys[user], endpoint=self.BASE_URL) + + @staticmethod + def _extract_id(file: dict) -> str | None: try: - id = file.get("did") or file.get("id") + return file.get("did") or file.get("id") except Exception: - # id is set to None to test the negative test scenario - id = None + return None + + def get_drs_object(self, file: dict, user="main_account"): + """Get Drs object""" + auth = self._auth(user) + id = self._extract_id(file) response = auth.curl(path=f"{self.DRS_ENDPOINT}/{id}") return response def get_drs_signed_url(self, file, user="main_account"): """Get Drs signed url""" - auth = Gen3Auth(refresh_token=pytest.api_keys[user], endpoint=self.BASE_URL) - try: - id = file.get("did") or file.get("id") - except Exception: - # id is set to None to test the negative test scenario - id = None + auth = self._auth(user) + id = self._extract_id(file) access_id = file["urls"][0][:2] response = auth.curl(path=f"{self.DRS_ENDPOINT}/{id}/access/{access_id}") return response def get_drs_signed_url_without_header(self, file, user="main_account"): """Get Drs signed url without header""" - auth = Gen3Auth(refresh_token=pytest.api_keys[user], endpoint=self.BASE_URL) - try: - id = file.get("did") or file.get("id") - except Exception: - # id is set to None to test the negative test scenario - id = None + auth = self._auth(user) + id = self._extract_id(file) access_id = file["urls"][0][:2] response = auth.curl( path=f"{self.BASE_URL}{self.DRS_ENDPOINT}/{id}/access/{access_id}" ) return response + + def get_service_info(self, user: str = "main_account") -> requests.Response: + """Get DRS service info""" + auth = self._auth(user) + response = auth.curl(path=self.SERVICE_INFO_ENDPOINT) + return response + + def get_drs_object_authorizations( + self, file: dict, user: str = "main_account" + ) -> requests.Response: + """Get authorization info for a DRS object (OPTIONS /objects/{id})""" + auth = self._auth(user) + id = self._extract_id(file) + url = f"{self.BASE_URL}{self.DRS_ENDPOINT}/{id}" + response = requests.options(url, auth=auth) + return response + + def get_bulk_object_authorizations( + self, object_ids: list, user: str = "main_account" + ) -> requests.Response: + """Get bulk authorization info (OPTIONS /objects)""" + auth = self._auth(user) + url = f"{self.BASE_URL}{self.DRS_ENDPOINT}" + response = requests.options( + url, json={"bulk_object_ids": object_ids}, auth=auth + ) + return response + + def get_bulk_drs_objects( + self, object_ids: list, user: str = "main_account" + ) -> requests.Response: + """Get multiple DRS objects (POST /objects)""" + auth = self._auth(user) + body = json.dumps({"bulk_object_ids": object_ids}) + response = auth.curl(path=self.DRS_ENDPOINT, request="POST", data=body) + return response + + def get_bulk_signed_urls( + self, bulk_access_ids: list, user: str = "main_account" + ) -> requests.Response: + """Get bulk presigned URLs (POST /objects/access)""" + auth = self._auth(user) + body = json.dumps({"bulk_object_access_ids": bulk_access_ids}) + response = auth.curl( + path=f"{self.DRS_ENDPOINT}/access", request="POST", data=body + ) + return response From fdeabe3db8441dd54dbaf47e49628a28eb19e8a7 Mon Sep 17 00:00:00 2001 From: Spencer Axelrod Date: Mon, 23 Mar 2026 18:38:16 -0500 Subject: [PATCH 02/19] add drs metadata tests --- .secrets.baseline | 4 +- .../tests/test_drs_endpoint.py | 467 ++++++++++++++++++ 2 files changed, 469 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 15c1e52c6..268cee69b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -298,7 +298,7 @@ "filename": "gen3-integration-tests/tests/test_drs_endpoint.py", "hashed_secret": "62bd0c4d3a6b445b13212d23500a7f0916757c3e", "is_verified": false, - "line_number": 20, + "line_number": 23, "is_secret": false } ], @@ -384,5 +384,5 @@ } ] }, - "generated_at": "2025-12-18T18:30:59Z" + "generated_at": "2026-03-23T23:34:47Z" } diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index e20b46828..641d35586 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -3,9 +3,12 @@ """ import os +from uuid import uuid4 import pytest +import requests from cdislogging import get_logger +from gen3.auth import Gen3Auth from packaging.version import Version from services.drs import Drs from services.fence import Fence @@ -43,6 +46,7 @@ reason="fence service is not running on this environment", ) @pytest.mark.fence +@pytest.mark.drs class TestDrsEndpoints: @classmethod def setup_class(cls): @@ -119,3 +123,466 @@ def test_get_drs_invalid_access_id(self): assert ( expected_msg in signed_url_res.content.decode() ), f"{expected_msg} not found in {signed_url_res.content.decode()}" + + +# Separate indexd_files for DRS 1.5 tests +# Records include DRS 1.5 fields +drs_15_indexd_files = { + # S3 record with region and available=true + "s3_available": { + "file_name": "test_s3_available", + "urls": ["s3://cdis-presigned-url-test/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "authz": ["/programs/jenkins"], + "size": 9, + "available": True, + "urls_metadata": { + "s3://cdis-presigned-url-test/testdata": {"region": "us-east-1"} + }, + }, + # S3 record with available=false + "s3_unavailable": { + "file_name": "test_s3_cold_storage", + "urls": ["s3://cdis-presigned-url-test/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "authz": ["/programs/jenkins"], + "size": 9, + "available": False, + }, + # GCS record for cloud testing + "gs_record": { + "file_name": "test_gs_record", + "urls": ["gs://some-gs-bucket/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "authz": ["/programs/jenkins"], + "size": 9, + "available": True, + }, + # S3 record without available (should default to true) + "default_available": { + "file_name": "test_default_available", + "urls": ["s3://cdis-presigned-url-test/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "authz": ["/programs/jenkins"], + "size": 9, + }, + # Unknown protocol "s2" + "unknown_protocol": { + "file_name": "test_unknown_protocol_15", + "urls": ["s2://some-bucket/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "authz": ["/programs/jenkins"], + "size": 9, + }, + # Open access record + "open_access": { + "file_name": "test_open_access", + "urls": ["s3://cdis-presigned-url-test/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["*"], + "authz": ["/open"], + "size": 9, + }, +} + + +@pytest.mark.skipif( + "fence" not in pytest.deployed_services or "indexd" not in pytest.deployed_services, + reason="DRS 1.5 tests require both fence and indexd", +) +@pytest.mark.fence +@pytest.mark.drs +class TestDrsMetadata: + """DRS 1.5 metadata field tests""" + + @classmethod + def setup_class(cls): + cls.drs = Drs() + cls.indexd = Indexd() + cls.fence = Fence() + cls.variables = {"created_indexd_dids": []} + + # skip all these tests if DRS < 1.5 + try: + resp = cls.drs.get_service_info() + if resp.status_code == 200: + version_str = resp.json().get("type", {}).get("version", "1.2") + else: + version_str = "1.2" + except Exception: + version_str = "1.2" + if Version(version_str) < Version("1.5"): + pytest.skip("DRS 1.5 not deployed on this environment") + + # Create DRS 1.5 test records + auth = Gen3Auth( + refresh_token=pytest.api_keys["indexing_account"], + endpoint=pytest.root_url, + ) + access_token = auth.get_access_token() + headers = { + "Authorization": f"bearer {access_token}", + "Content-Type": "application/json", + } + for key, val in drs_15_indexd_files.items(): + val.setdefault("did", str(uuid4())) + resp = requests.post( + f"{pytest.root_url}/index/index/", + json=val, + headers=headers, + ) + assert resp.status_code == 200, ( + f"Failed to create indexd record '{key}': " + f"{resp.status_code} {resp.text}" + ) + cls.variables["created_indexd_dids"].append(resp.json()["did"]) + + @classmethod + def teardown_class(cls): + if hasattr(cls, "variables") and cls.variables.get("created_indexd_dids"): + cls.indexd.delete_records(cls.variables["created_indexd_dids"]) + + def test_drs_object_cloud_derivation(self): + """ + Scenario: Verify cloud field is correctly derived from URL protocols + Steps: + 1. Get DRS object for S3 record, verify cloud is 'aws'. + 2. Get DRS object for GCS record, verify cloud is 'gcp'. + 3. Get DRS object for unknown protocol (s2://), verify cloud is null. + """ + # S3 -> aws + s3_obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_available"]) + assert s3_obj.status_code == 200, f"Expected 200, got {s3_obj.status_code}" + for method in s3_obj.json().get("access_methods", []): + assert ( + method.get("cloud") == "aws" + ), f"Expected cloud='aws' for s3:// URL, got '{method.get('cloud')}'" + # GS -> gcp + gs_obj = self.drs.get_drs_object(file=drs_15_indexd_files["gs_record"]) + assert gs_obj.status_code == 200 + for method in gs_obj.json().get("access_methods", []): + assert ( + method.get("cloud") == "gcp" + ), f"Expected cloud='gcp' for gs:// URL, got '{method.get('cloud')}'" + # Unknown protocol -> null + unk_obj = self.drs.get_drs_object(file=drs_15_indexd_files["unknown_protocol"]) + assert unk_obj.status_code == 200 + for method in unk_obj.json().get("access_methods", []): + assert method.get("cloud") is None, ( + f"Expected cloud=null for unknown protocol, " + f"got '{method.get('cloud')}'" + ) + + def test_drs_object_region_value(self): + """ + Scenario: Verify region field is present and matches urls_metadata value + Steps: + 1. Get DRS object for S3 record with urls_metadata region 'us-east-1'. + 2. Verify each access method has a non-empty region equal to 'us-east-1'. + """ + obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_available"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + assert "region" in method, "access_method missing 'region' field" + assert ( + method["region"] == "us-east-1" + ), f"Expected region='us-east-1', got '{method.get('region')}'" + + def test_drs_object_region_from_bucket_cache(self): + """ + Scenario: Verify region from Fence bucket-to-region cache + Steps: + 1. Query Fence GET /data/buckets for bucket region info. + 2. Get DRS object for record without urls_metadata region. + 3. Verify region matches Fence bucket config. + NOTE: + To pass this, add cdis-presigned-url-test to fence config with region: us-east-1 + """ + auth = Gen3Auth( + refresh_token=pytest.api_keys["main_account"], + endpoint=pytest.root_url, + ) + buckets_resp = auth.curl(path="/data/buckets") + if buckets_resp.status_code != 200: + pytest.skip("Fence /data/buckets endpoint not available") + + buckets = buckets_resp.json() + expected_region = None + for bucket_name, info in buckets.items(): + if "cdis-presigned-url-test" in bucket_name: + expected_region = info.get("region") + break + if not expected_region: + pytest.skip("cdis-presigned-url-test bucket not in Fence bucket config") + + obj = self.drs.get_drs_object(file=drs_15_indexd_files["default_available"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + assert method.get("region") == expected_region, ( + f"Expected region='{expected_region}' from bucket cache, " + f"got '{method.get('region')}'" + ) + + def test_drs_object_available_true_by_default(self): + """ + Scenario: Verify available defaults to true when not explicitly set + Steps: + 1. Get DRS object for record created without 'available' field. + 2. Verify each access method has available=true. + """ + obj = self.drs.get_drs_object(file=drs_15_indexd_files["default_available"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + assert method.get("available") is True, ( + f"Expected available=true by default, " f"got {method.get('available')}" + ) + + def test_drs_object_available_explicit(self): + """ + Scenario: Verify available field reflects explicit true and false values + Steps: + 1. Get DRS object for record with available=true, verify true. + 2. Get DRS object for record with available=false, verify false. + """ + obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_available"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + assert ( + method.get("available") is True + ), f"Expected available=true, got {method.get('available')}" + + obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_unavailable"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + assert ( + method.get("available") is False + ), f"Expected available=false, got {method.get('available')}" + + def test_drs_authorizations_protected_record(self): + """ + Scenario: Verify authorizations for a protected record include bearer auth + Steps: + 1. Get DRS object for record with authz path. + 2. Verify each access method has 'authorizations' with 'supported_types'. + 3. Verify supported_types includes 'BearerAuth'. + 4. Verify bearer_auth_issuers contains the commons issuer URL. + """ + expected_issuer = f"{pytest.root_url}/user" + obj = self.drs.get_drs_object(file=drs_15_indexd_files["default_available"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + assert ( + "authorizations" in method + ), "access_method missing 'authorizations' for protected record" + authz = method["authorizations"] + assert ( + "supported_types" in authz + ), "authorizations missing 'supported_types'" + supported = authz.get("supported_types", []) + assert ( + "BearerAuth" in supported + ), f"Expected 'BearerAuth' in supported_types, got {supported}" + issuers = authz.get("bearer_auth_issuers", []) + assert expected_issuer in issuers, ( + f"Expected '{expected_issuer}' in bearer_auth_issuers, " + f"got {issuers}" + ) + + def test_drs_authorizations_open_data(self): + """ + Scenario: Verify open data has no auth required + Steps: + 1. Get DRS object for record with /open authz path. + 2. Verify supported_types is ['None'] or authorizations is absent. + """ + obj = self.drs.get_drs_object(file=drs_15_indexd_files["open_access"]) + assert obj.status_code == 200 + for method in obj.json().get("access_methods", []): + authz = method.get("authorizations") + if authz is not None: + supported = authz.get("supported_types", []) + assert supported == ["None"], ( + f"Expected supported_types=['None'] for open data, " + f"got {supported}" + ) + + def test_options_object_authorizations(self): + """ + Scenario: Verify OPTIONS endpoint returns correct authorization info + Steps: + 1. Send OPTIONS request for a protected DRS object. + 2. Verify response contains supported_types, drs_object_id, + and non-empty bearer_auth_issuers. + """ + resp = self.drs.get_drs_object_authorizations( + file=drs_15_indexd_files["s3_available"] + ) + assert ( + resp.status_code == 200 + ), f"Expected 200 from OPTIONS, got {resp.status_code}" + data = resp.json() + assert "supported_types" in data, "OPTIONS response missing 'supported_types'" + assert "drs_object_id" in data, "OPTIONS response missing 'drs_object_id'" + expected_did = drs_15_indexd_files["s3_available"]["did"] + assert data["drs_object_id"] == expected_did, ( + f"Expected drs_object_id='{expected_did}', " + f"got '{data['drs_object_id']}'" + ) + assert ( + "bearer_auth_issuers" in data + ), "OPTIONS response missing 'bearer_auth_issuers'" + assert ( + len(data["bearer_auth_issuers"]) > 0 + ), "bearer_auth_issuers should not be empty for protected record" + + def test_options_object_not_found(self): + """ + Scenario: Verify OPTIONS returns 404 for non-existent object + Steps: + 1. Send OPTIONS request for a non-existent DRS object ID. + 2. Verify response status is 404. + """ + fake_file = {"did": str(uuid4())} + resp = self.drs.get_drs_object_authorizations(file=fake_file) + assert ( + resp.status_code == 404 + ), f"Expected 404 for non-existent object, got {resp.status_code}" + + def test_drs_object_schema_completeness(self): + """ + Scenario: Validate complete DRS 1.5 object response schema + Steps: + 1. Get DRS object for a fully-configured record. + 2. Verify all required DRS fields and 1.5 AccessMethod fields. + """ + obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_available"]) + assert obj.status_code == 200 + data = obj.json() + + # Required DRS object fields + for field in ["id", "self_uri", "size", "checksums", "access_methods"]: + assert field in data, f"DRS object missing required field '{field}'" + + assert ( + isinstance(data["checksums"], list) and len(data["checksums"]) > 0 + ), "checksums must be a non-empty list" + assert ( + isinstance(data["access_methods"], list) and len(data["access_methods"]) > 0 + ), "access_methods must be a non-empty list" + + # DRS 1.5 AccessMethod from spec + valid_types = [ + "s3", + "gs", + "ftp", + "gsiftp", + "globus", + "htsget", + "https", + "file", + ] + for method in data["access_methods"]: + assert "type" in method, "access_method missing 'type'" + assert ( + method["type"] in valid_types + ), f"Unexpected access_method type: {method['type']}" + assert ( + "access_id" in method or "access_url" in method + ), "access_method must have 'access_id' or 'access_url'" + assert "cloud" in method, "access_method missing 'cloud'" + assert "region" in method, "access_method missing 'region'" + assert "available" in method, "access_method missing 'available'" + assert isinstance( + method["available"], bool + ), f"'available' must be boolean, got {type(method['available'])}" + assert "authorizations" in method, "access_method missing 'authorizations'" + authz = method["authorizations"] + assert ( + "supported_types" in authz + ), "authorizations missing 'supported_types'" + + +@pytest.mark.skipif( + "fence" not in pytest.deployed_services or "indexd" not in pytest.deployed_services, + reason="DRS service info tests require both fence and indexd", +) +@pytest.mark.fence +@pytest.mark.drs +class TestDrsServiceInfo: + """DRS 1.5 service-info endpoint tests.""" + + @classmethod + def setup_class(cls): + cls.drs = Drs() + + # Version gate and cache service-info response + try: + resp = cls.drs.get_service_info() + if resp.status_code == 200: + cls.service_info = resp.json() + version_str = cls.service_info.get("type", {}).get("version", "1.2") + else: + version_str = "1.2" + cls.service_info = {} + except Exception: + version_str = "1.2" + cls.service_info = {} + if Version(version_str) < Version("1.5"): + pytest.skip("DRS 1.5 not deployed on this environment") + + def test_service_info_returns_drs_version(self): + """ + Scenario: Verify service-info reports DRS 1.5 version + Steps: + 1. Check cached /service-info response. + 2. Verify type.artifact is 'drs' and type.version starts with '1.5'. + """ + svc_type = self.service_info.get("type", {}) + assert ( + svc_type.get("artifact") == "drs" + ), f"Expected type.artifact='drs', got '{svc_type.get('artifact')}'" + version = svc_type.get("version", "") + assert version.startswith( + "1.5" + ), f"Expected type.version to start with '1.5', got '{version}'" + + def test_service_info_drs_stats(self): + """ + Scenario: Verify DRS-specific stats and backward-compat fields + Steps: + 1. Check cached /service-info response for drs sub-object. + 2. Verify maxBulkRequestLength, objectCount, and totalObjectSize. + 3. Verify root-level maxBulkRequestLength matches drs.maxBulkRequestLength. + """ + drs_info = self.service_info.get("drs", {}) + assert ( + "maxBulkRequestLength" in drs_info + ), "service-info missing drs.maxBulkRequestLength" + assert isinstance( + drs_info["maxBulkRequestLength"], int + ), "drs.maxBulkRequestLength must be an integer" + assert ( + drs_info["maxBulkRequestLength"] > 0 + ), "drs.maxBulkRequestLength must be positive" + assert "objectCount" in drs_info, "service-info missing drs.objectCount" + assert isinstance( + drs_info["objectCount"], int + ), "drs.objectCount must be an integer" + assert "totalObjectSize" in drs_info, "service-info missing drs.totalObjectSize" + assert isinstance( + drs_info["totalObjectSize"], int + ), "drs.totalObjectSize must be an integer" + # Backward-compat: root-level maxBulkRequestLength + root_max = self.service_info.get("maxBulkRequestLength") + assert ( + root_max is not None + ), "service-info missing root-level maxBulkRequestLength" + assert root_max == drs_info["maxBulkRequestLength"], ( + f"Root maxBulkRequestLength ({root_max}) does not match " + f"drs.maxBulkRequestLength ({drs_info['maxBulkRequestLength']})" + ) From 7abe30c09e6e6c53e891ea9132c68a92c40610b5 Mon Sep 17 00:00:00 2001 From: Spencer Axelrod Date: Mon, 23 Mar 2026 22:00:23 -0500 Subject: [PATCH 03/19] add bulk endpoint tests --- .../tests/test_drs_endpoint.py | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index 641d35586..42a09374e 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -507,6 +507,385 @@ def test_drs_object_schema_completeness(self): ), "authorizations missing 'supported_types'" +@pytest.mark.skipif( + "fence" not in pytest.deployed_services or "indexd" not in pytest.deployed_services, + reason="DRS bulk endpoint tests require both fence and indexd", +) +@pytest.mark.fence +@pytest.mark.drs +class TestDrsBulkEndpoints: + """DRS 1.4/1.5 bulk endpoint tests.""" + + @classmethod + def setup_class(cls): + cls.drs = Drs() + cls.indexd = Indexd() + cls.variables = {"created_indexd_dids": []} + + # bulk endpoints require DRS >= 1.4 + try: + resp = cls.drs.get_service_info() + if resp.status_code == 200: + cls.service_info = resp.json() + version_str = cls.service_info.get("type", {}).get("version", "1.2") + else: + version_str = "1.2" + cls.service_info = {} + except Exception: + version_str = "1.2" + cls.service_info = {} + if Version(version_str) < Version("1.4"): + pytest.skip("DRS bulk endpoints not deployed (requires >= 1.4)") + + cls.max_bulk = cls.service_info.get("drs", {}).get( + "maxBulkRequestLength", + cls.service_info.get("maxBulkRequestLength", 100), + ) + + # Create DRS 1.5 test records + auth = Gen3Auth( + refresh_token=pytest.api_keys["indexing_account"], + endpoint=pytest.root_url, + ) + access_token = auth.get_access_token() + headers = { + "Authorization": f"bearer {access_token}", + "Content-Type": "application/json", + } + for key, val in drs_15_indexd_files.items(): + val.setdefault("did", str(uuid4())) + resp = requests.post( + f"{pytest.root_url}/index/index/", + json=val, + headers=headers, + ) + assert resp.status_code == 200, ( + f"Failed to create indexd record '{key}': " + f"{resp.status_code} {resp.text}" + ) + cls.variables["created_indexd_dids"].append(resp.json()["did"]) + + @classmethod + def teardown_class(cls): + if hasattr(cls, "variables") and cls.variables.get("created_indexd_dids"): + cls.indexd.delete_records(cls.variables["created_indexd_dids"]) + + def test_bulk_drs_objects_include_metadata(self): + """ + Scenario: Verify bulk DRS objects include DRS 1.5 metadata fields + Steps: + 1. POST /objects with multiple valid GUIDs. + 2. Verify each resolved DrsObject has cloud, region, available, + and authorizations on every access method. + """ + object_ids = [ + drs_15_indexd_files["s3_available"]["did"], + drs_15_indexd_files["gs_record"]["did"], + drs_15_indexd_files["default_available"]["did"], + ] + resp = self.drs.get_bulk_drs_objects(object_ids=object_ids) + assert ( + resp.status_code == 200 + ), f"Expected 200 from bulk objects, got {resp.status_code}" + data = resp.json() + + summary = data.get("summary", {}) + assert summary.get("requested") == len(object_ids), ( + f"Expected summary.requested={len(object_ids)}, " + f"got {summary.get('requested')}" + ) + assert summary.get("resolved") == len( + object_ids + ), f"Expected all objects resolved, got resolved={summary.get('resolved')}" + + resolved = data.get("resolved_drs_object", []) + assert len(resolved) == len( + object_ids + ), f"Expected {len(object_ids)} resolved objects, got {len(resolved)}" + + for drs_obj in resolved: + assert ( + "access_methods" in drs_obj + ), f"DrsObject {drs_obj.get('id')} missing access_methods" + for method in drs_obj["access_methods"]: + assert ( + "cloud" in method + ), f"access_method missing 'cloud' in object {drs_obj.get('id')}" + assert ( + "available" in method + ), f"access_method missing 'available' in object {drs_obj.get('id')}" + assert "authorizations" in method, ( + f"access_method missing 'authorizations' in object " + f"{drs_obj.get('id')}" + ) + + def test_bulk_drs_objects_partial_resolution(self): + """ + Scenario: Verify partial resolution with mix of valid and invalid GUIDs + Steps: + 1. POST /objects with valid GUIDs and a non-existent GUID. + 2. Verify summary.resolved + summary.unresolved == summary.requested. + 3. Verify unresolved list contains the invalid GUID. + """ + valid_id = drs_15_indexd_files["s3_available"]["did"] + fake_id = str(uuid4()) + object_ids = [valid_id, fake_id] + + resp = self.drs.get_bulk_drs_objects(object_ids=object_ids) + assert ( + resp.status_code == 200 + ), f"Expected 200 from bulk objects, got {resp.status_code}" + data = resp.json() + + summary = data.get("summary", {}) + assert ( + summary.get("requested") == 2 + ), f"Expected summary.requested=2, got {summary.get('requested')}" + assert summary.get("resolved", 0) + summary.get("unresolved", 0) == 2, ( + f"resolved ({summary.get('resolved')}) + " + f"unresolved ({summary.get('unresolved')}) != requested (2)" + ) + assert ( + summary.get("resolved") == 1 + ), f"Expected 1 resolved, got {summary.get('resolved')}" + assert ( + summary.get("unresolved") == 1 + ), f"Expected 1 unresolved, got {summary.get('unresolved')}" + + # Verify the fake ID appears in the unresolved list + unresolved_ids = [] + for entry in data.get("unresolved_drs_objects", []): + unresolved_ids.extend(entry.get("object_ids", [])) + assert ( + fake_id in unresolved_ids + ), f"Expected '{fake_id}' in unresolved object IDs, got {unresolved_ids}" + + def test_bulk_drs_objects_request_too_large(self): + """ + Scenario: Verify 413 when bulk request exceeds maxBulkRequestLength + Steps: + 1. Get maxBulkRequestLength from service-info. + 2. POST /objects with more GUIDs than the limit. + 3. Verify response status is 413. + """ + oversized_ids = [str(uuid4()) for _ in range(self.max_bulk + 1)] + resp = self.drs.get_bulk_drs_objects(object_ids=oversized_ids) + assert resp.status_code == 413, ( + f"Expected 413 for oversized bulk request " + f"({self.max_bulk + 1} IDs, limit {self.max_bulk}), " + f"got {resp.status_code}" + ) + + def test_bulk_authorizations_returns_per_object_auth(self): + """ + Scenario: Verify bulk OPTIONS returns per-object authorization info + Steps: + 1. OPTIONS /objects with multiple valid GUIDs. + 2. Verify each resolved entry has drs_object_id and supported_types. + """ + object_ids = [ + drs_15_indexd_files["s3_available"]["did"], + drs_15_indexd_files["default_available"]["did"], + ] + resp = self.drs.get_bulk_object_authorizations(object_ids=object_ids) + assert ( + resp.status_code == 200 + ), f"Expected 200 from bulk OPTIONS, got {resp.status_code}" + data = resp.json() + + summary = data.get("summary", {}) + assert summary.get("requested") == len(object_ids), ( + f"Expected summary.requested={len(object_ids)}, " + f"got {summary.get('requested')}" + ) + + resolved = data.get("resolved_drs_object", []) + assert len(resolved) == len( + object_ids + ), f"Expected {len(object_ids)} resolved auth entries, got {len(resolved)}" + + for auth_entry in resolved: + assert ( + "drs_object_id" in auth_entry + ), "Bulk auth entry missing 'drs_object_id'" + assert "supported_types" in auth_entry, ( + f"Bulk auth entry for '{auth_entry.get('drs_object_id')}' " + f"missing 'supported_types'" + ) + + def test_bulk_authorizations_mixed_auth_types(self): + """ + Scenario: Verify bulk OPTIONS returns different auth for different authz paths + Steps: + 1. OPTIONS /objects with a protected GUID and an open-access GUID. + 2. Verify protected record has BearerAuth in supported_types. + 3. Verify open-access record has 'None' in supported_types. + """ + protected_id = drs_15_indexd_files["s3_available"]["did"] + open_id = drs_15_indexd_files["open_access"]["did"] + object_ids = [protected_id, open_id] + + resp = self.drs.get_bulk_object_authorizations(object_ids=object_ids) + assert ( + resp.status_code == 200 + ), f"Expected 200 from bulk OPTIONS, got {resp.status_code}" + data = resp.json() + + resolved = data.get("resolved_drs_object", []) + + # Build a lookup by drs_object_id + auth_by_id = { + entry["drs_object_id"]: entry + for entry in resolved + if "drs_object_id" in entry + } + + # Protected record should have BearerAuth + assert ( + protected_id in auth_by_id + ), f"Protected record '{protected_id}' not found in resolved auth entries" + protected_types = auth_by_id[protected_id].get("supported_types", []) + assert ( + "BearerAuth" in protected_types + ), f"Expected 'BearerAuth' for protected record, got {protected_types}" + + # Open-access record should have 'None' + assert ( + open_id in auth_by_id + ), f"Open-access record '{open_id}' not found in resolved auth entries" + open_types = auth_by_id[open_id].get("supported_types", []) + assert ( + "None" in open_types + ), f"Expected 'None' for open-access record, got {open_types}" + + def test_bulk_authorizations_request_too_large(self): + """ + Scenario: Verify 413 when bulk OPTIONS exceeds maxBulkRequestLength + Steps: + 1. OPTIONS /objects with more GUIDs than the limit. + 2. Verify response status is 413. + """ + oversized_ids = [str(uuid4()) for _ in range(self.max_bulk + 1)] + resp = self.drs.get_bulk_object_authorizations(object_ids=oversized_ids) + assert resp.status_code == 413, ( + f"Expected 413 for oversized bulk OPTIONS " + f"({self.max_bulk + 1} IDs, limit {self.max_bulk}), " + f"got {resp.status_code}" + ) + + def test_bulk_signed_urls_success(self): + """ + Scenario: Verify bulk presigned URL generation for authorized GUIDs + Steps: + 1. Get DRS objects for authorized records to discover access IDs. + 2. POST /objects/access with the object_id/access_id pairs. + 3. Verify each resolved entry has a 'url' field. + """ + # Get access IDs from the DRS objects + s3_obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_available"]) + assert s3_obj.status_code == 200 + s3_methods = s3_obj.json().get("access_methods", []) + assert len(s3_methods) > 0, "s3_available has no access methods" + + default_obj = self.drs.get_drs_object( + file=drs_15_indexd_files["default_available"] + ) + assert default_obj.status_code == 200 + default_methods = default_obj.json().get("access_methods", []) + assert len(default_methods) > 0, "default_available has no access methods" + + bulk_access_ids = [ + { + "bulk_object_id": drs_15_indexd_files["s3_available"]["did"], + "bulk_access_ids": [s3_methods[0]["access_id"]], + }, + { + "bulk_object_id": drs_15_indexd_files["default_available"]["did"], + "bulk_access_ids": [default_methods[0]["access_id"]], + }, + ] + + resp = self.drs.get_bulk_signed_urls(bulk_access_ids=bulk_access_ids) + assert ( + resp.status_code == 200 + ), f"Expected 200 from bulk signed URLs, got {resp.status_code}" + data = resp.json() + + summary = data.get("summary", {}) + assert ( + summary.get("requested") == 2 + ), f"Expected summary.requested=2, got {summary.get('requested')}" + assert ( + summary.get("resolved") == 2 + ), f"Expected 2 resolved URLs, got {summary.get('resolved')}" + + resolved = data.get("resolved_drs_object_access_urls", []) + assert ( + len(resolved) == 2 + ), f"Expected 2 resolved access URL entries, got {len(resolved)}" + + for entry in resolved: + assert "url" in entry, ( + f"Bulk access URL entry for '{entry.get('drs_object_id')}' " + f"missing 'url'" + ) + assert entry[ + "url" + ], f"Bulk access URL for '{entry.get('drs_object_id')}' is empty" + + def test_bulk_signed_urls_partial_auth(self): + """ + Scenario: Verify partial auth — authorized and unauthorized GUIDs + Steps: + 1. Get access ID from an authorized record. + 2. POST /objects/access with authorized + unauthorized object/access pairs. + 3. Verify authorized GUID is resolved, unauthorized is in unresolved. + """ + # Get access ID from an authorized record + s3_obj = self.drs.get_drs_object(file=drs_15_indexd_files["s3_available"]) + assert s3_obj.status_code == 200 + s3_methods = s3_obj.json().get("access_methods", []) + assert len(s3_methods) > 0, "s3_available has no access methods" + + # Use a non-existent object ID for the unauthorized case + fake_id = str(uuid4()) + bulk_access_ids = [ + { + "bulk_object_id": drs_15_indexd_files["s3_available"]["did"], + "bulk_access_ids": [s3_methods[0]["access_id"]], + }, + { + "bulk_object_id": fake_id, + "bulk_access_ids": ["s3"], + }, + ] + + resp = self.drs.get_bulk_signed_urls(bulk_access_ids=bulk_access_ids) + assert ( + resp.status_code == 200 + ), f"Expected 200 from bulk signed URLs, got {resp.status_code}" + data = resp.json() + + summary = data.get("summary", {}) + assert ( + summary.get("requested") == 2 + ), f"Expected summary.requested=2, got {summary.get('requested')}" + assert ( + summary.get("resolved") == 1 + ), f"Expected 1 resolved, got {summary.get('resolved')}" + assert ( + summary.get("unresolved") == 1 + ), f"Expected 1 unresolved, got {summary.get('unresolved')}" + + # Verify the fake ID is in unresolved + unresolved_ids = [] + for entry in data.get("unresolved_drs_objects", []): + unresolved_ids.extend(entry.get("object_ids", [])) + assert ( + fake_id in unresolved_ids + ), f"Expected '{fake_id}' in unresolved, got {unresolved_ids}" + + @pytest.mark.skipif( "fence" not in pytest.deployed_services or "indexd" not in pytest.deployed_services, reason="DRS service info tests require both fence and indexd", From 826ca9031cb9060a9482702911bcd9734335fdbf Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Thu, 30 Apr 2026 14:34:05 -0500 Subject: [PATCH 04/19] changes for bulk_presigned_url in fence --- gen3-integration-tests/services/fence.py | 41 +++++++++++++++++++ .../tests/test_presigned_url.py | 39 ++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/gen3-integration-tests/services/fence.py b/gen3-integration-tests/services/fence.py index 169de84d6..1695d0a06 100644 --- a/gen3-integration-tests/services/fence.py +++ b/gen3-integration-tests/services/fence.py @@ -72,6 +72,47 @@ def create_signed_url( return response.json() return response + @retry(times=3, delay=20, exceptions=(AssertionError, Gen3AuthError)) + def create_bulk_signed_urls( + self, guids, user=None, expected_status=200, access_token=None + ): + """Creates presigned urls for multiple GUIDs""" + url = f"{self.DATA_DOWNLOAD_ENDPOINT}/bulk" + + payload = {"guids": guids} + + if user: + auth = Gen3Auth(refresh_token=pytest.api_keys[user], endpoint=self.BASE_URL) + response = requests.post( + self.BASE_URL + url, + json=payload, + auth=auth, + ) + elif access_token: + response = requests.post( + self.BASE_URL + url, + json=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"bearer {access_token}", + }, + ) + else: + response = requests.post( + self.BASE_URL + url, + json=payload, + ) + + logger.info("Status code : " + str(response.status_code)) + + assert ( + expected_status == response.status_code + ), f"Expected response {expected_status}, but got {response.status_code}" + + if response.status_code == 200: + return response.json() + return response + def get_url_for_data_upload(self, file_name: str, user: str) -> dict: """Generate the url for uploading the data""" auth = Gen3Auth(refresh_token=pytest.api_keys[user], endpoint=self.BASE_URL) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index 6ecc1c183..c794b67c1 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -226,3 +226,42 @@ def test_get_presigned_url_no_requested_protocol_no_data(self): logger.error(f"{msg} not found") logger.error(signed_url_res.content.decode()) raise + + def test_get_bulk_presigned_urls(self): + """ + Scenario: Get bulk presigned-urls + Steps: + 1. Use multiple indexd records created in setup + 2. Request bulk signed urls + 3. Validate urls are returned for each GUID + 4. Validate file contents match expected values + """ + allowed_record = indexd_files["allowed"] + not_allowed_record = indexd_files["not_allowed"] + + guids = [ + allowed_record["did"], + not_allowed_record["did"], + ] + + res = self.fence.create_bulk_signed_urls( + guids=guids, + user="main_account", + expected_status=200, + ) + + assert "urls" in res, f"'urls' missing in response: {res}" + assert "failed_file_ids" in res, f"'failed_file_ids' missing in response: {res}" + + # Validate success case + assert allowed_record["did"] in res["urls"], "Allowed GUID missing from urls" + + signed_url_res = res["urls"][allowed_record["did"]] + self.fence.check_file_equals( + signed_url_res, "Hi Zac!\ncdis-data-client uploaded this!\n" + ) + + # Validate failure case + assert ( + not_allowed_record["did"] in res["failed_file_ids"] + ), "Unauthorized GUID should be in failed_file_ids" From 0ca47dedefe1ea0b890c2d55506cdb5e9a5fc862 Mon Sep 17 00:00:00 2001 From: burtonk <117617405+k-burt-uch@users.noreply.github.com> Date: Fri, 1 May 2026 14:24:21 -0500 Subject: [PATCH 05/19] Add indexd marker to DRS metadata tests --- gen3-integration-tests/tests/test_drs_endpoint.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index 42a09374e..df60cb69b 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -197,6 +197,7 @@ def test_get_drs_invalid_access_id(self): ) @pytest.mark.fence @pytest.mark.drs +@pytest.mark.indexd class TestDrsMetadata: """DRS 1.5 metadata field tests""" From 6962ce8e376bc06371fd52f73352ee6219f70f6f Mon Sep 17 00:00:00 2001 From: burtonk <117617405+k-burt-uch@users.noreply.github.com> Date: Fri, 1 May 2026 14:28:40 -0500 Subject: [PATCH 06/19] Add indexd marker to remaining DRS endpoint tests --- gen3-integration-tests/tests/test_drs_endpoint.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index df60cb69b..0dc67136e 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -47,6 +47,7 @@ ) @pytest.mark.fence @pytest.mark.drs +@pytest.mark.indexd class TestDrsEndpoints: @classmethod def setup_class(cls): @@ -514,6 +515,7 @@ def test_drs_object_schema_completeness(self): ) @pytest.mark.fence @pytest.mark.drs +@pytest.mark.indexd class TestDrsBulkEndpoints: """DRS 1.4/1.5 bulk endpoint tests.""" From 13d009ce8c0adcefbf694bbeaee2eb685ce268b8 Mon Sep 17 00:00:00 2001 From: Kyle Burton Date: Sun, 3 May 2026 18:42:12 -0500 Subject: [PATCH 07/19] Fix available --- .python-version | 1 - gen3-integration-tests/tests/test_drs_endpoint.py | 12 ++++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) delete mode 100644 .python-version diff --git a/.python-version b/.python-version deleted file mode 100644 index 24ee5b1be..000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index 0dc67136e..401f2cf0d 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -137,9 +137,11 @@ def test_get_drs_invalid_access_id(self): "acl": ["jenkins"], "authz": ["/programs/jenkins"], "size": 9, - "available": True, "urls_metadata": { - "s3://cdis-presigned-url-test/testdata": {"region": "us-east-1"} + "s3://cdis-presigned-url-test/testdata": { + "region": "us-east-1", + "available": True, + } }, }, # S3 record with available=false @@ -150,7 +152,9 @@ def test_get_drs_invalid_access_id(self): "acl": ["jenkins"], "authz": ["/programs/jenkins"], "size": 9, - "available": False, + "urls_metadata": { + "s3://cdis-presigned-url-test/testdata": {"available": False} + }, }, # GCS record for cloud testing "gs_record": { @@ -160,7 +164,7 @@ def test_get_drs_invalid_access_id(self): "acl": ["jenkins"], "authz": ["/programs/jenkins"], "size": 9, - "available": True, + "urls_metadata": {"gs://some-gs-bucket/testdata": {"available": True}}, }, # S3 record without available (should default to true) "default_available": { From fdf7df85d6ee27895740e60d1185598a6bb7a120 Mon Sep 17 00:00:00 2001 From: Kyle Burton Date: Sun, 3 May 2026 20:45:54 -0500 Subject: [PATCH 08/19] Fix DRS 1.5 Record creation --- gen3-integration-tests/tests/test_drs_endpoint.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index 401f2cf0d..9a4c695e1 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -235,18 +235,10 @@ def setup_class(cls): "Authorization": f"bearer {access_token}", "Content-Type": "application/json", } + # Adding indexd files for key, val in drs_15_indexd_files.items(): - val.setdefault("did", str(uuid4())) - resp = requests.post( - f"{pytest.root_url}/index/index/", - json=val, - headers=headers, - ) - assert resp.status_code == 200, ( - f"Failed to create indexd record '{key}': " - f"{resp.status_code} {resp.text}" - ) - cls.variables["created_indexd_dids"].append(resp.json()["did"]) + indexd_record = cls.indexd.create_records(records={key: val}) + cls.variables["created_indexd_dids"].append(indexd_record[0]["did"]) @classmethod def teardown_class(cls): From 8cb094127a56ddf78ceb77b500db2236dbfdb1e7 Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Wed, 13 May 2026 19:46:58 -0500 Subject: [PATCH 09/19] integration test fixes --- .../tests/test_presigned_url.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index c794b67c1..234339984 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -20,6 +20,13 @@ "acl": ["jenkins"], "size": 9, }, + "allowed2": { + "file_name": "test_valid", + "urls": ["s3://cdis-presigned-url-test/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "size": 9, + }, "not_allowed": { "file_name": "test_not_allowed", "urls": ["s3://cdis-presigned-url-test/testdata"], @@ -237,10 +244,12 @@ def test_get_bulk_presigned_urls(self): 4. Validate file contents match expected values """ allowed_record = indexd_files["allowed"] + allowed_record2 = indexd_files["allowed2"] not_allowed_record = indexd_files["not_allowed"] guids = [ allowed_record["did"], + allowed_record2["did"], not_allowed_record["did"], ] @@ -253,15 +262,25 @@ def test_get_bulk_presigned_urls(self): assert "urls" in res, f"'urls' missing in response: {res}" assert "failed_file_ids" in res, f"'failed_file_ids' missing in response: {res}" + dids = [] + signed_url_res = None + for drs_obj in res.get("urls"): + dids.append(drs_obj["drs_object_id"]) + if drs_obj["drs_object_id"] == allowed_record["did"]: + signed_url_res = drs_obj["url"] # Validate success case - assert allowed_record["did"] in res["urls"], "Allowed GUID missing from urls" + assert allowed_record["did"] in dids, "Allowed GUID missing from urls" - signed_url_res = res["urls"][allowed_record["did"]] self.fence.check_file_equals( signed_url_res, "Hi Zac!\ncdis-data-client uploaded this!\n" ) + failed_ids = [] + for failure in res.get("failed_file_ids"): + for id in failure.get("object_ids", []): + failed_ids.append(id) + # Validate failure case assert ( - not_allowed_record["did"] in res["failed_file_ids"] + not_allowed_record["did"] in failed_ids ), "Unauthorized GUID should be in failed_file_ids" From 0a462ca9956e104e330667db6634b85847a9bf5e Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Thu, 14 May 2026 16:45:33 -0500 Subject: [PATCH 10/19] fix check_file_equals assert --- gen3-integration-tests/tests/test_presigned_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index 234339984..b30111849 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -267,7 +267,7 @@ def test_get_bulk_presigned_urls(self): for drs_obj in res.get("urls"): dids.append(drs_obj["drs_object_id"]) if drs_obj["drs_object_id"] == allowed_record["did"]: - signed_url_res = drs_obj["url"] + signed_url_res = {"url": drs_obj["url"]} # Validate success case assert allowed_record["did"] in dids, "Allowed GUID missing from urls" From 26e25c1878ab7db1ee835aa913493d4603c516e2 Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Fri, 15 May 2026 14:42:02 -0500 Subject: [PATCH 11/19] add another url --- gen3-integration-tests/tests/test_presigned_url.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index b30111849..8e08935ee 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -27,6 +27,14 @@ "acl": ["jenkins"], "size": 9, }, + "allowed_authz": { + "file_name": "test_valid", + "urls": ["s3://cdis-presigned-url-test/testdata"], + "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, + "acl": ["jenkins"], + "authz": ["programs/jenkins"], + "size": 9, + }, "not_allowed": { "file_name": "test_not_allowed", "urls": ["s3://cdis-presigned-url-test/testdata"], @@ -246,10 +254,12 @@ def test_get_bulk_presigned_urls(self): allowed_record = indexd_files["allowed"] allowed_record2 = indexd_files["allowed2"] not_allowed_record = indexd_files["not_allowed"] + allowed_authz_record = indexd_files["allowed_authz"] guids = [ allowed_record["did"], allowed_record2["did"], + allowed_authz_record["did"], not_allowed_record["did"], ] @@ -270,6 +280,10 @@ def test_get_bulk_presigned_urls(self): signed_url_res = {"url": drs_obj["url"]} # Validate success case assert allowed_record["did"] in dids, "Allowed GUID missing from urls" + assert allowed_record2["did"] in dids, "Allowed GUID missing from urls" + assert ( + allowed_authz_record["did"] in dids + ), "Allowed with authz GUID missing from urls" self.fence.check_file_equals( signed_url_res, "Hi Zac!\ncdis-data-client uploaded this!\n" From c094c151b2d97f034bad069fe5f6db393ff87cc2 Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Fri, 15 May 2026 15:16:50 -0500 Subject: [PATCH 12/19] add missing / to authz --- gen3-integration-tests/tests/test_presigned_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index 8e08935ee..e4f2c2aea 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -32,7 +32,7 @@ "urls": ["s3://cdis-presigned-url-test/testdata"], "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, "acl": ["jenkins"], - "authz": ["programs/jenkins"], + "authz": ["/programs/jenkins"], "size": 9, }, "not_allowed": { From 88fcd6b3aaac05853ce5bfd00cc7b270c8d6c8a4 Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Thu, 28 May 2026 11:21:13 -0500 Subject: [PATCH 13/19] change authz in test_presigned_url.py --- gen3-integration-tests/tests/test_presigned_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index e4f2c2aea..ebffcbef5 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -32,7 +32,7 @@ "urls": ["s3://cdis-presigned-url-test/testdata"], "hashes": {"md5": "73d643ec3f4beb9020eef0beed440ad0"}, "acl": ["jenkins"], - "authz": ["/programs/jenkins"], + "authz": ["/programs/jnkns/projects/jenkins"], "size": 9, }, "not_allowed": { From f7f99e9c4a50a21b4ebb049355531276def72185 Mon Sep 17 00:00:00 2001 From: Kyle Burton Date: Tue, 9 Jun 2026 11:53:47 -0500 Subject: [PATCH 14/19] Fix test_drs_object_region_from_bucket_cache to grab buckets from fence --- gen3-integration-tests/tests/test_drs_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index 9a4c695e1..0be863d3d 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -305,7 +305,7 @@ def test_drs_object_region_from_bucket_cache(self): refresh_token=pytest.api_keys["main_account"], endpoint=pytest.root_url, ) - buckets_resp = auth.curl(path="/data/buckets") + buckets_resp = auth.curl(path="user/data/buckets") if buckets_resp.status_code != 200: pytest.skip("Fence /data/buckets endpoint not available") From 3964f371a2b425d156e5782031c8f3135772a1ae Mon Sep 17 00:00:00 2001 From: burtonk <117617405+k-burt-uch@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:09:43 -0500 Subject: [PATCH 15/19] Apply suggestion from @SpencerAxelrod Co-authored-by: Spencer Myles Axelrod --- gen3-integration-tests/tests/test_drs_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index 0be863d3d..e657e146c 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -43,7 +43,7 @@ @pytest.mark.skipif( "fence" not in pytest.deployed_services, - reason="fence service is not running on this environment", + reason="DRS endpoint tests require both fence and indexd", ) @pytest.mark.fence @pytest.mark.drs From b6e69cf3e432d73dbc15d8223ed04540b7c97c9f Mon Sep 17 00:00:00 2001 From: burtonk <117617405+k-burt-uch@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:10:02 -0500 Subject: [PATCH 16/19] Apply suggestion from @SpencerAxelrod Co-authored-by: Spencer Myles Axelrod --- gen3-integration-tests/tests/test_drs_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen3-integration-tests/tests/test_drs_endpoint.py b/gen3-integration-tests/tests/test_drs_endpoint.py index e657e146c..f3ef67392 100644 --- a/gen3-integration-tests/tests/test_drs_endpoint.py +++ b/gen3-integration-tests/tests/test_drs_endpoint.py @@ -42,7 +42,7 @@ @pytest.mark.skipif( - "fence" not in pytest.deployed_services, + "fence" not in pytest.deployed_services or "indexd" not in pytest.deployed_services, reason="DRS endpoint tests require both fence and indexd", ) @pytest.mark.fence From 20e18501cad489325615c6f2d36869534c610ff0 Mon Sep 17 00:00:00 2001 From: Kyle Burton Date: Mon, 15 Jun 2026 16:11:31 -0500 Subject: [PATCH 17/19] Re-adding .python-version --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..24ee5b1be --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 From 064f14c5be3d091360a4e3190641fdff6b6b4380 Mon Sep 17 00:00:00 2001 From: Spencer Axelrod Date: Wed, 17 Jun 2026 00:48:55 -0500 Subject: [PATCH 18/19] remove unused version gating fixture --- gen3-integration-tests/conftest.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/gen3-integration-tests/conftest.py b/gen3-integration-tests/conftest.py index 6827a687d..c6e90d2c8 100644 --- a/gen3-integration-tests/conftest.py +++ b/gen3-integration-tests/conftest.py @@ -97,29 +97,6 @@ def get_fence_clients(): setup.get_rotated_client_id_secret() -@pytest.fixture(scope="session") -def drs_version(): - """ - Fetch DRS version from service-info for conditional test gating. - - Returns the DRS spec version from the service-info endpoint. - Falls back to 1.2. - """ - from services.drs import Drs - - drs = Drs() - try: - resp = drs.get_service_info() - if resp.status_code == 200: - svc = resp.json() - return svc.get("type", {}).get("version", "1.2") - except Exception as e: - logger.error( - f"Could not fetch DRS service-info: {e}. Defaulting to DRS 1.2. DRS 1.5 tests will be skipped." - ) - return "1.2" - - def pytest_configure(config): # generate api keys for test users for the ci env result = generate_api_keys_for_test_users() From b5e614b3c7b3e51cd260a5ae441dc7b330edcd27 Mon Sep 17 00:00:00 2001 From: jacob50231 Date: Tue, 23 Jun 2026 14:01:13 -0500 Subject: [PATCH 19/19] rename 'failed_file_ids' to 'failed_guids' --- gen3-integration-tests/tests/test_presigned_url.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gen3-integration-tests/tests/test_presigned_url.py b/gen3-integration-tests/tests/test_presigned_url.py index ebffcbef5..4dffee054 100644 --- a/gen3-integration-tests/tests/test_presigned_url.py +++ b/gen3-integration-tests/tests/test_presigned_url.py @@ -270,7 +270,7 @@ def test_get_bulk_presigned_urls(self): ) assert "urls" in res, f"'urls' missing in response: {res}" - assert "failed_file_ids" in res, f"'failed_file_ids' missing in response: {res}" + assert "failed_guids" in res, f"'failed_guids' missing in response: {res}" dids = [] signed_url_res = None @@ -290,11 +290,11 @@ def test_get_bulk_presigned_urls(self): ) failed_ids = [] - for failure in res.get("failed_file_ids"): + for failure in res.get("failed_guids"): for id in failure.get("object_ids", []): failed_ids.append(id) # Validate failure case assert ( not_allowed_record["did"] in failed_ids - ), "Unauthorized GUID should be in failed_file_ids" + ), "Unauthorized GUID should be in failed_guids"