diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py
index e7f2a10b0b9..0b81e2c670e 100644
--- a/lib/galaxy/tool_util/parser/interface.py
+++ b/lib/galaxy/tool_util/parser/interface.py
@@ -117,6 +117,25 @@ class ToolSourceTestInput(TypedDict):
attributes: ToolSourceTestInputAttributes
+class DirectCredentialValue(TypedDict):
+ """Represents a credential value (variable or secret) provided directly."""
+
+ name: str
+ value: str
+
+
+class _DirectCredentialRequired(TypedDict):
+ name: str # Name of the credentials group
+ variables: List[DirectCredentialValue]
+ secrets: List[DirectCredentialValue]
+
+
+class DirectCredential(_DirectCredentialRequired, total=False):
+ """Represents a credential group with variables and secrets provided directly."""
+
+ version: str # Version of the credential definition (defaults to "1.0")
+
+
ToolSourceTestInputs = List[ToolSourceTestInput]
ToolSourceTestOutputs = List[ToolSourceTestOutput]
TestSourceTestOutputColllection = Any
@@ -136,6 +155,7 @@ class ToolSourceTest(TypedDict):
command: AssertionList
command_version: AssertionList
value_state_representation: Literal["test_case_xml", "test_case_json"]
+ credentials: Optional[List[DirectCredential]]
class ToolSourceTests(TypedDict):
diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py
index 9fb4540a016..a66fdd29296 100644
--- a/lib/galaxy/tool_util/parser/xml.py
+++ b/lib/galaxy/tool_util/parser/xml.py
@@ -18,6 +18,7 @@ from typing import (
)
from packaging.version import Version
+from pydantic import TypeAdapter
from galaxy.tool_util.deps import requirements
from galaxy.tool_util.parser.util import (
@@ -53,6 +54,7 @@ from galaxy.util import (
)
from .interface import (
AssertionList,
+ DirectCredential,
DrillDownDynamicOptions,
DynamicOptions,
InputSource,
@@ -819,6 +821,7 @@ def _test_elem_to_dict(test_elem, i, profile=None) -> ToolSourceTest:
expect_test_failure=string_as_bool(test_elem.get("expect_test_failure", False)),
maxseconds=test_elem.get("maxseconds", None),
value_state_representation="test_case_xml",
+ credentials=__parse_credentials_elems(test_elem),
)
_copy_to_dict_if_present(test_elem, rval, ["num_outputs"])
return rval
@@ -1093,6 +1096,29 @@ def __parse_inputs_elems(test_elem, i) -> ToolSourceTestInputs:
return raw_inputs
+_direct_credential_adapter: TypeAdapter = TypeAdapter(List[DirectCredential])
+
+
+def __parse_credentials_elems(test_elem):
+ """
+ Parse credential definitions from test element.
+ Returns a list of DirectCredential dictionaries or None if no credentials are defined.
+ """
+ raw_list = []
+ for cred_elem in test_elem.findall("credentials"):
+ variables = [{"name": v.get("name"), "value": v.get("value")} for v in cred_elem.findall("variable")]
+ secrets = [{"name": s.get("name"), "value": s.get("value")} for s in cred_elem.findall("secret")]
+ raw: dict = {"name": cred_elem.get("name"), "variables": variables, "secrets": secrets}
+ version = cred_elem.get("version")
+ if version is not None:
+ raw["version"] = version
+ raw_list.append(raw)
+
+ if not raw_list:
+ return None
+ return _direct_credential_adapter.validate_python(raw_list)
+
+
def _test_collection_def_dict(elem: Element) -> XmlTestCollectionDefDict:
elements: List[TestCollectionDefElementDict] = []
attrib: Dict[str, Any] = _element_to_dict(elem)
diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py
index 80abbc150d8..3116f25364c 100644
--- a/lib/galaxy/tool_util/parser/yaml.py
+++ b/lib/galaxy/tool_util/parser/yaml.py
@@ -12,6 +12,7 @@ from typing import (
)
import packaging.version
+from pydantic import TypeAdapter
from galaxy.tool_util.deps import requirements
from galaxy.tool_util.parameters.convert import _select_which_when
@@ -41,6 +42,7 @@ from galaxy.util import listify
from .interface import (
AssertionDict,
AssertionList,
+ DirectCredential,
InputSource,
PageSource,
PagesSource,
@@ -424,9 +426,34 @@ def _parse_test(i: int, test_dict: dict) -> ToolSourceTest:
test_dict["expect_failure"] = test_dict.get("expect_failure", False)
test_dict["expect_test_failure"] = test_dict.get("expect_test_failure", False)
test_dict["value_state_representation"] = "test_case_json"
+ test_dict["credentials"] = __parse_credentials_yaml(test_dict.get("credentials", None))
return cast(ToolSourceTest, test_dict)
+_direct_credential_adapter: TypeAdapter = TypeAdapter(List[DirectCredential])
+
+
+def __parse_credentials_yaml(credentials_data) -> Optional[List[DirectCredential]]:
+ """
+ Parse credentials from YAML test definition.
+
+ Supports both list and dict formats:
+ - List: [{name: "cred1", variables: [...], secrets: [...]}]
+ - Dict: {cred1: {variables: [...], secrets: []}}
+ """
+ if not credentials_data:
+ return None
+
+ # Normalise both dict and list formats into a flat list of raw dicts.
+ if is_dict(credentials_data):
+ # {name: {variables: [], secrets: []}} → [{name: ..., variables: [], secrets: []}]
+ raw_list = [{"name": name, **cred_data} for name, cred_data in credentials_data.items()]
+ else:
+ raw_list = list(credentials_data)
+
+ return _direct_credential_adapter.validate_python(raw_list)
+
+
def to_test_assert_list(assertions) -> AssertionList:
assertions = assertions or []
diff --git a/lib/galaxy/tool_util/verify/_types.py b/lib/galaxy/tool_util/verify/_types.py
index fd260f88a65..305d9592675 100644
--- a/lib/galaxy/tool_util/verify/_types.py
+++ b/lib/galaxy/tool_util/verify/_types.py
@@ -16,6 +16,7 @@ from typing_extensions import (
from galaxy.tool_util.parser.interface import (
AssertionList,
+ DirectCredential,
TestSourceTestOutputColllection,
ToolSourceTestOutputs,
)
@@ -63,3 +64,4 @@ class ToolTestDescriptionDict(TypedDict):
request_unavailable_reason: NotRequired[Optional[str]]
maxseconds: NotRequired[Optional[int]]
value_state_representation: NotRequired[ValueStateRepresentationT]
+ credentials: NotRequired[Optional[List[DirectCredential]]]
diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py
index ad9904fa314..5b216169458 100644
--- a/lib/galaxy/tool_util/verify/interactor.py
+++ b/lib/galaxy/tool_util/verify/interactor.py
@@ -21,6 +21,7 @@ from typing import (
List,
NamedTuple,
Optional,
+ Tuple,
Union,
)
@@ -46,6 +47,7 @@ from galaxy.tool_util.parameters import (
)
from galaxy.tool_util.parser.interface import (
AssertionList,
+ DirectCredential,
TestCollectionDef,
TestCollectionOutputDef,
TestSourceTestOutputColllection,
@@ -141,6 +143,7 @@ class ValidToolTestDict(TypedDict):
required_files: NotRequired[RequiredFilesT]
required_data_tables: NotRequired[RequiredDataTablesT]
required_loc_files: NotRequired[RequiredLocFileT]
+ credentials: NotRequired[Optional[List[DirectCredential]]]
error: Literal[False]
tool_id: str
tool_version: str
@@ -209,6 +212,14 @@ class RunToolResponse(NamedTuple):
jobs: List[Dict[str, Any]]
+class ToolSubmissionResponse(NamedTuple):
+ inputs: Dict[str, Any]
+ tool_request_id: Optional[str] # None for legacy submissions
+ submit_response_object: Dict[str, Any] # raw validated response
+ is_legacy: bool
+ cleanup: Optional[Callable[[], None]] = None
+
+
class InteractorStagingInterface(StagingInterface):
def __init__(self, galaxy_interactor: "GalaxyInteractorApi", maxseconds: Optional[int], upload_async: bool) -> None:
@@ -241,6 +252,17 @@ class InteractorStagingInterface(StagingInterface):
return True
+def raise_for_status(response: Response) -> None:
+ try:
+ response.raise_for_status()
+ except requests.exceptions.HTTPError as e:
+ try:
+ body = response.json()
+ except Exception:
+ body = response.text
+ raise requests.exceptions.HTTPError(f"{e} - Response body: {body}", response=response) from e
+
+
class GalaxyInteractorApi:
# api_key and cookies can also be manually set by UsesApiTestCaseMixin._different_user()
api_key: Optional[str]
@@ -692,13 +714,87 @@ class GalaxyInteractorApi:
raise ValueError(f"Invalid `location` URL: `{location}`")
return location
+ def _credential_api_call(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Any:
+ """Low-level helper: call a credential API endpoint, raise on error, return JSON."""
+ if method == "post":
+ response = self._post(path, data=data or {}, json=True)
+ elif method == "get":
+ response = self._get(path)
+ elif method == "delete":
+ response = self._delete(path)
+ else:
+ raise ValueError(f"Unsupported method: {method}")
+ raise_for_status(response)
+ return response.json()
+
+ def _create_test_credentials(
+ self, testdef: "ToolTestDescription"
+ ) -> Tuple[List[Dict[str, Any]], Optional[List[Dict[str, Any]]]]:
+ """Create vault credentials for a test and return (created_credentials, credentials_context)."""
+ if not testdef.credentials:
+ return [], None
+
+ user_id = self._credential_api_call("get", "whoami")["id"]
+ created_credentials = []
+ credentials_context_list = []
+
+ for cred in testdef.credentials:
+ credential_payload = {
+ "source_type": "tool",
+ "source_id": testdef.tool_id,
+ "source_version": testdef.tool_version or "1.0.0",
+ "service_credential": {
+ "name": cred["name"],
+ "version": cred.get("version", "1.0"),
+ "group": {
+ "name": f"test_group_{cred['name']}",
+ "variables": cred.get("variables", []),
+ "secrets": cred.get("secrets", []),
+ },
+ },
+ }
+ created_cred = self._credential_api_call("post", f"users/{user_id}/credentials", data=credential_payload)
+ all_credentials = self._credential_api_call("get", f"users/{user_id}/credentials")
+
+ # Find user_credentials_id by matching the newly-created group id.
+ user_credentials_id = None
+ for user_cred in all_credentials:
+ if (
+ user_cred["source_type"] == "tool"
+ and user_cred["source_id"] == testdef.tool_id
+ and user_cred.get("source_version") == (testdef.tool_version or "1.0.0")
+ ):
+ for group in user_cred["groups"]:
+ if group["id"] == created_cred["id"]:
+ user_credentials_id = user_cred["id"]
+ break
+ if user_credentials_id:
+ break
+
+ if not user_credentials_id:
+ raise RuntimeError(
+ f"Failed to find user_credentials_id for created credential group {created_cred['id']}"
+ )
+
+ created_credentials.append({"user_credentials_id": user_credentials_id, "user_id": user_id})
+ credentials_context_list.append(
+ {
+ "user_credentials_id": user_credentials_id,
+ "name": cred["name"],
+ "version": cred.get("version", "1.0"),
+ "selected_group": {"id": created_cred["id"], "name": created_cred["name"]},
+ }
+ )
+
+ return created_credentials, credentials_context_list
+
def run_tool(
self,
testdef: "ToolTestDescription",
history_id: str,
resource_parameters: Optional[Dict[str, Any]] = None,
use_legacy_api: UseLegacyApiT = DEFAULT_USE_LEGACY_API,
- ) -> RunToolResponse:
+ ) -> "ToolSubmissionResponse":
# We need to handle the case where we've uploaded a valid compressed file since the upload
# tool will have uncompressed it on the fly.
resource_parameters = resource_parameters or {}
@@ -708,7 +804,9 @@ class GalaxyInteractorApi:
if testdef.value_state_representation == "test_case_json":
# Don't submit user / YAML tools to the old endpoint.
submit_with_legacy_api = False
-
+ if testdef.credentials:
+ # Force legacy API for credential-bearing tests since /api/tools already supports credentials_context.
+ submit_with_legacy_api = True
if submit_with_legacy_api:
inputs_tree = testdef.inputs.copy()
for key, value in inputs_tree.items():
@@ -766,6 +864,12 @@ class GalaxyInteractorApi:
inputs_tree[f"__job_resource|{key}"] = value
submit_response = None
+
+ extra_data: Dict[str, Any] = {}
+ created_credentials, credentials_context = self._create_test_credentials(testdef)
+ if credentials_context is not None:
+ extra_data["credentials_context"] = dumps(credentials_context)
+
for _ in range(DEFAULT_TOOL_TEST_WAIT):
submit_response = self.__submit_tool(
history_id,
@@ -773,6 +877,7 @@ class GalaxyInteractorApi:
tool_input=inputs_tree,
tool_version=testdef.tool_version,
use_legacy_api=submit_with_legacy_api,
+ extra_data=extra_data,
)
if _are_tool_inputs_not_ready(submit_response):
print("Tool inputs not ready yet")
@@ -781,8 +886,36 @@ class GalaxyInteractorApi:
else:
break
submit_response_object = ensure_tool_run_response_okay(submit_response, "execute tool", inputs_tree)
- if not submit_with_legacy_api:
- tool_request_id = submit_response_object["tool_request_id"]
+ tool_request_id = None if submit_with_legacy_api else submit_response_object.get("tool_request_id")
+
+ cleanup: Optional[Callable[[], None]] = None
+ if created_credentials:
+
+ def _cleanup_credentials():
+ for cred_info in created_credentials:
+ try:
+ self._credential_api_call(
+ "delete", f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}"
+ )
+ except Exception as e:
+ print(f"Warning: Failed to delete test credentials: {e}")
+
+ cleanup = _cleanup_credentials
+
+ return ToolSubmissionResponse(
+ inputs=inputs_tree,
+ tool_request_id=tool_request_id,
+ submit_response_object=submit_response_object,
+ is_legacy=submit_with_legacy_api,
+ cleanup=cleanup,
+ )
+
+ def resolve_tool_submission(self, submission: "ToolSubmissionResponse") -> RunToolResponse:
+ inputs_tree = submission.inputs
+ submit_response_object = submission.submit_response_object
+ if not submission.is_legacy:
+ tool_request_id = submission.tool_request_id
+ assert tool_request_id is not None
successful = self.wait_on_tool_request(tool_request_id)
if not successful:
request = self.get_tool_request(tool_request_id) or {}
@@ -791,17 +924,15 @@ class GalaxyInteractorApi:
inputs_tree,
)
job_refs = self.jobs_for_tool_request(tool_request_id)
- outputs = OutputsDict()
- output_collections = {}
if len(job_refs) != 1:
raise Exception(
f"Found incorrect number of jobs for tool request - was expecting a single job {job_refs}"
)
- assert len(job_refs) == 1, job_refs
job_id = job_refs[0]["id"]
jobs = [self.__get_job(job_id).json()]
- job_outputs = self.job_outputs(job_id)
- for job_output in job_outputs:
+ outputs = OutputsDict()
+ output_collections: Dict[str, Any] = {}
+ for job_output in self.job_outputs(job_id):
if "dataset" in job_output:
outputs[job_output["name"]] = job_output["dataset"]
else:
@@ -1631,6 +1762,7 @@ def verify_tool(
tool_execution_exception: Optional[Exception] = None
input_staging_exc_info = None
expected_failure_occurred = False
+ credential_cleanup: Optional[Callable[[], None]] = None
begin_time = time.time()
try:
try:
@@ -1648,10 +1780,13 @@ def verify_tool(
input_staging_exc_info = sys.exc_info()
raise
try:
- tool_response = galaxy_interactor.run_tool(
+ submission = galaxy_interactor.run_tool(
testdef, test_history, resource_parameters=resource_parameters, use_legacy_api=use_legacy_api
)
- data_list, jobs, tool_inputs = tool_response.outputs, tool_response.jobs, tool_response.inputs
+ tool_inputs = submission.inputs
+ credential_cleanup = submission.cleanup
+ tool_response = galaxy_interactor.resolve_tool_submission(submission)
+ data_list, jobs = tool_response.outputs, tool_response.jobs
data_collection_list = tool_response.output_collections
except RunToolException as e:
tool_inputs = e.inputs
@@ -1677,6 +1812,8 @@ def verify_tool(
job_output_exceptions = [e]
raise e
finally:
+ if credential_cleanup:
+ credential_cleanup()
if register_job_data is not None:
end_time = time.time()
job_data["time_seconds"] = end_time - begin_time
@@ -1909,6 +2046,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD
request: Optional[Dict[str, Any]] = None
request_schema: Optional[Dict[str, Any]] = None
request_unavailable_reason: Optional[str] = None
+ credentials: Optional[List[DirectCredential]] = None
if not error_in_test_definition:
processed_test_dict = cast(ValidToolTestDict, processed_dict)
@@ -1937,6 +2075,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD
request = processed_test_dict.get("request", None)
request_schema = processed_test_dict.get("request_schema", None)
request_unavailable_reason = processed_test_dict.get("request_unavailable_reason", None)
+ credentials = processed_test_dict.get("credentials", None)
else:
invalid_test_dict = cast(InvalidToolTestDict, processed_dict)
maxseconds = DEFAULT_TOOL_TEST_WAIT
@@ -1970,6 +2109,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD
request_schema=request_schema,
request_unavailable_reason=request_unavailable_reason,
value_state_representation=value_state_representation,
+ credentials=credentials,
)
@@ -2036,6 +2176,7 @@ class ToolTestDescription:
output_collections: List[TestCollectionOutputDef]
maxseconds: Optional[int]
value_state_representation: ValueStateRepresentationT
+ credentials: Optional[List[DirectCredential]]
@staticmethod
def from_tool_source_dict(processed_test_dict: ToolTestDict) -> "ToolTestDescription":
@@ -2068,6 +2209,7 @@ class ToolTestDescription:
self.tool_version = json_dict.get("tool_version")
self.maxseconds = json_dict.get("maxseconds")
self.value_state_representation = json_dict.get("value_state_representation", "test_case_xml")
+ self.credentials = json_dict.get("credentials")
def test_data(self):
"""
@@ -2105,6 +2247,8 @@ class ToolTestDescription:
}
if self.maxseconds is not None:
test_description_def["maxseconds"] = self.maxseconds
+ if self.credentials is not None:
+ test_description_def["credentials"] = self.credentials
return ToolTestDescriptionDict(**test_description_def)
diff --git a/lib/galaxy/tool_util/verify/parse.py b/lib/galaxy/tool_util/verify/parse.py
index 264095e6bc9..29b7258cbbd 100644
--- a/lib/galaxy/tool_util/verify/parse.py
+++ b/lib/galaxy/tool_util/verify/parse.py
@@ -185,6 +185,7 @@ def _description_from_tool_source(
"maxseconds": maxseconds,
"error": False,
"value_state_representation": value_state_representation,
+ "credentials": raw_test_dict.get("credentials", None),
}
)
except Exception:
diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd
index bbac660864c..8939ec47b80 100644
--- a/lib/galaxy/tool_util/xsd/galaxy.xsd
+++ b/lib/galaxy/tool_util/xsd/galaxy.xsd
@@ -1399,6 +1399,7 @@ that at least one of the assumptions of the test is not met. This is most useful
+
@@ -1439,6 +1440,72 @@ $assertions
+
+
+
+
+
+
+
+
+
+
+```
+
+]]>
+
+
+
+
+
+
+ Name of the credential variable (must match a variable defined in the tool's requirements/credentials)
+
+
+
+
+ Test value for this variable
+
+
+
+
+
+
+
+
+ Name of the credential secret (must match a secret defined in the tool's requirements/credentials)
+
+
+
+
+ Test value for this secret (use public test credentials only)
+
+
+
+
+
+
+
+ Name of the credentials group (must match the name defined in tool's requirements/credentials)
+
+
+
+
+ Version of the credentials definition (must match the version defined in tool's requirements/credentials). Defaults to "1.0".
+
+
+
+ Test tool for validating credential injection
+
+
+
+
+
+
+ '$output' &&
+ echo "Password length: \${#TEST_PASSWORD}" >> '$output'
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml
index 5033f0778b6..334cec617dc 100644
--- a/test/functional/tools/sample_tool_conf.xml
+++ b/test/functional/tools/sample_tool_conf.xml
@@ -335,6 +335,7 @@
+
diff --git a/test/unit/tool_util/test_credential_parsing.py b/test/unit/tool_util/test_credential_parsing.py
new file mode 100644
index 00000000000..45fa9994450
--- /dev/null
+++ b/test/unit/tool_util/test_credential_parsing.py
@@ -0,0 +1,43 @@
+"""Unit tests for credential parsing from tool test definitions."""
+
+from galaxy.tool_util.unittest_utils import functional_test_tool_path
+from galaxy.util.unittest import TestCase
+from .test_parsing import FunctionalTestToolTestCase
+
+
+class TestCredentialParsing(FunctionalTestToolTestCase):
+ test_path = "credentials_test.xml"
+
+ def test_credentials_parsing(self):
+ tests_dict = self._tool_source.parse_tests_to_dict()
+ tests = tests_dict["tests"]
+ assert len(tests) == 2
+
+ # First test: test_user / test_password_123
+ creds = tests[0]["credentials"]
+ assert creds is not None
+ assert len(creds) == 1
+ cred = creds[0]
+ assert cred["name"] == "test_service"
+ assert cred["variables"] == [{"name": "TEST_USERNAME", "value": "test_user"}]
+ assert cred["secrets"] == [{"name": "TEST_PASSWORD", "value": "test_password_123"}]
+
+ # Second test: another_user / secret
+ creds2 = tests[1]["credentials"]
+ assert creds2 is not None
+ assert len(creds2) == 1
+ assert creds2[0]["variables"] == [{"name": "TEST_USERNAME", "value": "another_user"}]
+ assert creds2[0]["secrets"] == [{"name": "TEST_PASSWORD", "value": "secret"}]
+
+
+class TestNoCredentials(TestCase):
+ """Verify tools without credentials return None."""
+
+ def test_no_credentials_field(self):
+ from galaxy.tool_util.parser.factory import get_tool_source
+
+ path = functional_test_tool_path("simple_constructs.xml")
+ tool_source = get_tool_source(path)
+ tests = tool_source.parse_tests_to_dict()["tests"]
+ for test in tests:
+ assert test.get("credentials") is None