From 3baf7ac547336a6f5ff494ad1ea007bd9ebd98b4 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 3 Oct 2025 06:36:48 +0200 Subject: [PATCH 1/5] basic artifact identifier validation Change-Id: Ie3ce15c2aedba33643f74066eb872fb5269261ec --- python/src/etos_api/library/validator.py | 49 +++++ python/src/etos_api/routers/v0/router.py | 8 +- python/src/etos_api/routers/v0/utilities.py | 22 ++- python/src/etos_api/routers/v1alpha/router.py | 7 +- .../src/etos_api/routers/v1alpha/utilities.py | 22 ++- python/tests/library/test_validator.py | 174 +++++++++++++++++- 6 files changed, 276 insertions(+), 6 deletions(-) diff --git a/python/src/etos_api/library/validator.py b/python/src/etos_api/library/validator.py index 7672299..ec2de91 100644 --- a/python/src/etos_api/library/validator.py +++ b/python/src/etos_api/library/validator.py @@ -22,6 +22,7 @@ from pydantic import BaseModel # pylint:disable=no-name-in-module from pydantic import ValidationError, conlist, constr, field_validator from pydantic.fields import PrivateAttr +from packageurl import PackageURL from etos_api.library.docker import Docker @@ -176,3 +177,51 @@ async def validate(self, test_suite): assert ( await docker.digest(test_runner) is not None ), f"Test runner {test_runner} not found" + + +class ArtifactValidator: + """Validator for artifact identities and IDs.""" + + def validate_artifact_identity_or_id( + self, artifact_identity: str = None, artifact_id: str = None + ) -> None: + """Validate that artifact_identity or artifact_id is a valid PURL or UUID. + + :param artifact_identity: The artifact identity to validate (should be PURL if provided). + :param artifact_id: The artifact ID to validate (should be UUID if provided). + :raises ValueError: If validation fails. + """ + if artifact_identity: + if not self.validate_purl(artifact_identity): + raise ValueError( + f"Invalid artifact_identity: '{artifact_identity}' is not a valid PURL. " + "PURL must start with 'pkg:'" + ) + + if artifact_id: + if not self.validate_uuid(artifact_id): + raise ValueError(f"Invalid artifact_id: '{artifact_id}' is not a valid UUID.") + + def validate_purl(self, purl_string: str) -> bool: + """Validate if a string is a valid Package URL (PURL). + + :param purl_string: The string to validate as a PURL. + :return: True if valid PURL, False otherwise. + """ + try: + PackageURL.from_string(purl_string) + return True + except (ValueError, TypeError): + return False + + def validate_uuid(self, uuid_string: str) -> bool: + """Validate if a string is a valid UUID. + + :param uuid_string: The string to validate as a UUID. + :return: True if valid UUID, False otherwise. + """ + try: + UUID(str(uuid_string)) + return True + except (ValueError, TypeError): + return False diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index 53ed8f0..627d8de 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -30,9 +30,8 @@ from etos_api.library.environment import Configuration, configure_testrun from etos_api.library.utilities import sync_to_async - from .schemas import AbortEtosResponse, StartEtosRequest, StartEtosResponse -from .utilities import wait_for_artifact_created, validate_suite +from .utilities import wait_for_artifact_created, validate_suite, validate_artifact ETOSv0 = FastAPI( title="ETOS", @@ -108,6 +107,11 @@ async def _start(etos: StartEtosRequest, span: Span) -> dict: # pylint:disable= await validate_suite(etos.test_suite_url) LOGGER.info("Test suite validated.") + # Validate artifact identity and ID before proceeding + LOGGER.info("Validating artifact identity and ID.") + await validate_artifact(artifact_identity=etos.artifact_identity, artifact_id=etos.artifact_id) + LOGGER.info("Artifact identity and ID validated.") + etos_library = ETOS("ETOS API", os.getenv("HOSTNAME"), "ETOS API") await sync_to_async(etos_library.config.rabbitmq_publisher_from_environment) diff --git a/python/src/etos_api/routers/v0/utilities.py b/python/src/etos_api/routers/v0/utilities.py index 0033ba8..7335fc1 100644 --- a/python/src/etos_api/routers/v0/utilities.py +++ b/python/src/etos_api/routers/v0/utilities.py @@ -27,7 +27,7 @@ ARTIFACT_IDENTITY_QUERY, VERIFY_ARTIFACT_ID_EXISTS, ) -from etos_api.library.validator import SuiteValidator +from etos_api.library.validator import SuiteValidator, ArtifactValidator LOGGER = logging.getLogger(__name__) @@ -106,3 +106,23 @@ async def validate_suite(test_suite_url: str) -> None: raise HTTPException( status_code=400, detail=f"Test suite validation failed. {exception}" ) from exception + + +async def validate_artifact(artifact_identity: str = None, artifact_id=None) -> None: + """Validate the artifact identity and ID through the ArtifactValidator. + + :param artifact_identity: The artifact identity to validate (should be PURL if provided). + :param artifact_id: The artifact ID to validate (can be UUID object or string). + """ + span = trace.get_current_span() + + try: + # Convert artifact_id to string if it's not None + artifact_id_str = str(artifact_id) if artifact_id is not None else None + ArtifactValidator().validate_artifact_identity_or_id(artifact_identity, artifact_id_str) + except ValueError as exception: + msg = "Artifact validation failed!" + LOGGER.error(msg) + LOGGER.error(exception) + span.add_event(msg) + raise HTTPException(status_code=400, detail=msg) from exception diff --git a/python/src/etos_api/routers/v1alpha/router.py b/python/src/etos_api/routers/v1alpha/router.py index 2570f5e..3e04a3e 100644 --- a/python/src/etos_api/routers/v1alpha/router.py +++ b/python/src/etos_api/routers/v1alpha/router.py @@ -35,12 +35,12 @@ from opentelemetry import trace from opentelemetry.trace import Span - from .schemas import AbortTestrunResponse, StartTestrunRequest, StartTestrunResponse from .utilities import ( wait_for_artifact_created, download_suite, validate_suite, + validate_artifact, convert_to_rfc1123, recipes_from_tests, ) @@ -124,6 +124,11 @@ async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict: await validate_suite(test_suite) LOGGER.info("Test suite validated.") + # Validate artifact identity and ID before proceeding + LOGGER.info("Validating artifact identity and ID.") + await validate_artifact(artifact_identity=etos.artifact_identity, artifact_id=etos.artifact_id) + LOGGER.info("Artifact identity and ID validated.") + etos_library = ETOS("ETOS API", os.getenv("HOSTNAME", "localhost"), "ETOS API") LOGGER.info("Get artifact created %r", (etos.artifact_identity or str(etos.artifact_id))) diff --git a/python/src/etos_api/routers/v1alpha/utilities.py b/python/src/etos_api/routers/v1alpha/utilities.py index 48e67a7..2bd37ee 100644 --- a/python/src/etos_api/routers/v1alpha/utilities.py +++ b/python/src/etos_api/routers/v1alpha/utilities.py @@ -28,7 +28,7 @@ ARTIFACT_IDENTITY_QUERY, VERIFY_ARTIFACT_ID_EXISTS, ) -from etos_api.library.validator import SuiteValidator +from etos_api.library.validator import SuiteValidator, ArtifactValidator LOGGER = logging.getLogger(__name__) @@ -136,6 +136,26 @@ def convert_to_rfc1123(value: str) -> str: return result.lower() +async def validate_artifact(artifact_identity: str = None, artifact_id=None) -> None: + """Validate the artifact identity and ID through the ArtifactValidator. + + :param artifact_identity: The artifact identity to validate (should be PURL if provided). + :param artifact_id: The artifact ID to validate (can be UUID object or string). + """ + span = trace.get_current_span() + + try: + # Convert artifact_id to string if it's not None + artifact_id_str = str(artifact_id) if artifact_id is not None else None + ArtifactValidator().validate_artifact_identity_or_id(artifact_identity, artifact_id_str) + except ValueError as exception: + msg = "Failed to validate artifact identifier" + LOGGER.error(msg) + LOGGER.error(exception) + span.add_event(msg) + raise HTTPException(status_code=400, detail=msg) from exception + + async def recipes_from_tests(tests: list[dict]) -> list[dict]: """Load Eiffel TERCC recipes from test. diff --git a/python/tests/library/test_validator.py b/python/tests/library/test_validator.py index 50961d8..8135702 100644 --- a/python/tests/library/test_validator.py +++ b/python/tests/library/test_validator.py @@ -20,7 +20,11 @@ import pytest -from etos_api.library.validator import SuiteValidator, ValidationError +from etos_api.library.validator import ( + ArtifactValidator, + SuiteValidator, + ValidationError, +) logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) @@ -356,3 +360,171 @@ async def test_validate_empty_constraints(self): self.logger.info("STEP: Validate a suite without the required key.") with pytest.raises(ValidationError): await validator.validate([base_suite]) + + +class TestArtifactValidator: + """Test the artifact validation functions.""" + + def setup_method(self): + """Set up test fixtures.""" + self.validator = ArtifactValidator() + + def test_validate_purl_valid(self): + """Test that valid PURL strings are accepted.""" + valid_purls = [ + "pkg:npm/lodash@4.17.21", + "pkg:pypi/requests@2.25.1", + "pkg:maven/org.apache.commons/commons-lang3@3.12.0", + "pkg:golang/github.com/gorilla/mux@v1.8.0", + "pkg:docker/nginx@latest", + "pkg:generic/openssl@1.1.1k", + ] + + for purl in valid_purls: + assert self.validator.validate_purl(purl) is True + + def test_validate_purl_invalid(self): + """Test that invalid PURL strings are rejected.""" + invalid_purls = [ + "", + None, + "not-a-purl", + "http://example.com", + "pkg:", # Missing parts + "pkg:npm/", # Incomplete + "npm/lodash@4.17.21", # Missing pkg: prefix + "PKG:npm/lodash@4.17.21", # Wrong case + ] + + for purl in invalid_purls: + assert self.validator.validate_purl(purl) is False + + def test_validate_uuid_valid(self): + """Test that valid UUID strings are accepted.""" + valid_uuids = [ + "550e8400-e29b-41d4-a716-446655440000", + "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "6ba7b811-9dad-11d1-80b4-00c04fd430c8", + "6ba7b812-9dad-11d1-80b4-00c04fd430c8", + "6ba7b814-9dad-11d1-80b4-00c04fd430c8", + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ] + + for uuid_str in valid_uuids: + assert self.validator.validate_uuid(uuid_str) is True + + def test_validate_uuid_invalid(self): + """Test that invalid UUID strings are rejected.""" + invalid_uuids = [ + "", + None, + "not-a-uuid", + "550e8400-e29b-41d4-a716", # Too short + "550e8400-e29b-41d4-a716-446655440000-extra", # Too long + "550e8400-e29b-41d4-a716-44665544000g", # Invalid character (g) + ] + + for uuid_str in invalid_uuids: + assert self.validator.validate_uuid(uuid_str) is False + + def test_validate_artifact_identity_or_id_valid_purl(self): + """Test validation with valid PURL artifact_identity.""" + # Should not raise any exception + self.validator.validate_artifact_identity_or_id( + artifact_identity="pkg:npm/lodash@4.17.21", artifact_id=None + ) + + def test_validate_artifact_identity_or_id_valid_uuid(self): + """Test validation with valid UUID artifact_id.""" + # Should not raise any exception + self.validator.validate_artifact_identity_or_id( + artifact_identity=None, artifact_id="550e8400-e29b-41d4-a716-446655440000" + ) + + def test_validate_artifact_identity_or_id_invalid_purl(self): + """Test validation with invalid PURL artifact_identity raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + self.validator.validate_artifact_identity_or_id( + artifact_identity="not-a-purl", artifact_id=None + ) + assert "Invalid artifact_identity" in str(exc_info.value) + assert "is not a valid PURL" in str(exc_info.value) + + def test_validate_artifact_identity_or_id_invalid_uuid(self): + """Test validation with invalid UUID artifact_id raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + self.validator.validate_artifact_identity_or_id( + artifact_identity=None, artifact_id="not-a-uuid" + ) + assert "Invalid artifact_id" in str(exc_info.value) + assert "is not a valid UUID" in str(exc_info.value) + + def test_validate_artifact_identity_or_id_both_provided(self): + """Test validation with both valid values provided.""" + # Should not raise any exception when both are valid + self.validator.validate_artifact_identity_or_id( + artifact_identity="pkg:npm/lodash@4.17.21", + artifact_id="550e8400-e29b-41d4-a716-446655440000", + ) + + def test_validate_artifact_identity_or_id_neither_provided(self): + """Test validation with neither value provided.""" + # Should not raise any exception - no validation is performed when both are None + self.validator.validate_artifact_identity_or_id(artifact_identity=None, artifact_id=None) + + # Tests for ArtifactValidator class methods (should raise ValueError) + def test_artifact_validator_validate_purl_valid(self): + """Test ArtifactValidator.validate_purl with valid PURL strings.""" + valid_purls = [ + "pkg:npm/lodash@4.17.21", + "pkg:pypi/requests@2.25.1", + "pkg:maven/org.apache.commons/commons-lang3@3.12.0", + ] + + for purl in valid_purls: + assert self.validator.validate_purl(purl) is True + + def test_artifact_validator_validate_uuid_valid(self): + """Test ArtifactValidator.validate_uuid with valid UUID strings.""" + valid_uuids = [ + "550e8400-e29b-41d4-a716-446655440000", + "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + ] + + for uuid_str in valid_uuids: + assert self.validator.validate_uuid(uuid_str) is True + + def test_artifact_validator_validate_artifact_identity_or_id_valid(self): + """Test ArtifactValidator.validate_artifact_identity_or_id with valid inputs.""" + # Should not raise any exception + self.validator.validate_artifact_identity_or_id( + artifact_identity="pkg:npm/lodash@4.17.21", artifact_id=None + ) + + self.validator.validate_artifact_identity_or_id( + artifact_identity=None, artifact_id="550e8400-e29b-41d4-a716-446655440000" + ) + + def test_artifact_validator_validate_identity_or_id_invalid_purl(self): + """Test ArtifactValidator.validate_artifact_identity_or_id with invalid PURL. + + Should raise ValueError. + """ + with pytest.raises(ValueError) as exc_info: + self.validator.validate_artifact_identity_or_id( + artifact_identity="not-a-purl", artifact_id=None + ) + assert "Invalid artifact_identity" in str(exc_info.value) + assert "is not a valid PURL" in str(exc_info.value) + + def test_artifact_validator_validate_identity_or_id_invalid_uuid(self): + """Test ArtifactValidator.validate_artifact_identity_or_id with invalid UUID. + + Should raise ValueError. + """ + with pytest.raises(ValueError) as exc_info: + self.validator.validate_artifact_identity_or_id( + artifact_identity=None, artifact_id="not-a-uuid" + ) + assert "Invalid artifact_id" in str(exc_info.value) + assert "is not a valid UUID" in str(exc_info.value) From 36e9c9257776bed91db2e68472ab21d499973e5e Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 3 Oct 2025 08:21:38 +0200 Subject: [PATCH 2/5] Revert "basic artifact identifier validation" This reverts commit 3baf7ac547336a6f5ff494ad1ea007bd9ebd98b4. --- python/src/etos_api/library/validator.py | 49 ----- python/src/etos_api/routers/v0/router.py | 8 +- python/src/etos_api/routers/v0/utilities.py | 22 +-- python/src/etos_api/routers/v1alpha/router.py | 7 +- .../src/etos_api/routers/v1alpha/utilities.py | 22 +-- python/tests/library/test_validator.py | 174 +----------------- 6 files changed, 6 insertions(+), 276 deletions(-) diff --git a/python/src/etos_api/library/validator.py b/python/src/etos_api/library/validator.py index ec2de91..7672299 100644 --- a/python/src/etos_api/library/validator.py +++ b/python/src/etos_api/library/validator.py @@ -22,7 +22,6 @@ from pydantic import BaseModel # pylint:disable=no-name-in-module from pydantic import ValidationError, conlist, constr, field_validator from pydantic.fields import PrivateAttr -from packageurl import PackageURL from etos_api.library.docker import Docker @@ -177,51 +176,3 @@ async def validate(self, test_suite): assert ( await docker.digest(test_runner) is not None ), f"Test runner {test_runner} not found" - - -class ArtifactValidator: - """Validator for artifact identities and IDs.""" - - def validate_artifact_identity_or_id( - self, artifact_identity: str = None, artifact_id: str = None - ) -> None: - """Validate that artifact_identity or artifact_id is a valid PURL or UUID. - - :param artifact_identity: The artifact identity to validate (should be PURL if provided). - :param artifact_id: The artifact ID to validate (should be UUID if provided). - :raises ValueError: If validation fails. - """ - if artifact_identity: - if not self.validate_purl(artifact_identity): - raise ValueError( - f"Invalid artifact_identity: '{artifact_identity}' is not a valid PURL. " - "PURL must start with 'pkg:'" - ) - - if artifact_id: - if not self.validate_uuid(artifact_id): - raise ValueError(f"Invalid artifact_id: '{artifact_id}' is not a valid UUID.") - - def validate_purl(self, purl_string: str) -> bool: - """Validate if a string is a valid Package URL (PURL). - - :param purl_string: The string to validate as a PURL. - :return: True if valid PURL, False otherwise. - """ - try: - PackageURL.from_string(purl_string) - return True - except (ValueError, TypeError): - return False - - def validate_uuid(self, uuid_string: str) -> bool: - """Validate if a string is a valid UUID. - - :param uuid_string: The string to validate as a UUID. - :return: True if valid UUID, False otherwise. - """ - try: - UUID(str(uuid_string)) - return True - except (ValueError, TypeError): - return False diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index 627d8de..53ed8f0 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -30,8 +30,9 @@ from etos_api.library.environment import Configuration, configure_testrun from etos_api.library.utilities import sync_to_async + from .schemas import AbortEtosResponse, StartEtosRequest, StartEtosResponse -from .utilities import wait_for_artifact_created, validate_suite, validate_artifact +from .utilities import wait_for_artifact_created, validate_suite ETOSv0 = FastAPI( title="ETOS", @@ -107,11 +108,6 @@ async def _start(etos: StartEtosRequest, span: Span) -> dict: # pylint:disable= await validate_suite(etos.test_suite_url) LOGGER.info("Test suite validated.") - # Validate artifact identity and ID before proceeding - LOGGER.info("Validating artifact identity and ID.") - await validate_artifact(artifact_identity=etos.artifact_identity, artifact_id=etos.artifact_id) - LOGGER.info("Artifact identity and ID validated.") - etos_library = ETOS("ETOS API", os.getenv("HOSTNAME"), "ETOS API") await sync_to_async(etos_library.config.rabbitmq_publisher_from_environment) diff --git a/python/src/etos_api/routers/v0/utilities.py b/python/src/etos_api/routers/v0/utilities.py index 7335fc1..0033ba8 100644 --- a/python/src/etos_api/routers/v0/utilities.py +++ b/python/src/etos_api/routers/v0/utilities.py @@ -27,7 +27,7 @@ ARTIFACT_IDENTITY_QUERY, VERIFY_ARTIFACT_ID_EXISTS, ) -from etos_api.library.validator import SuiteValidator, ArtifactValidator +from etos_api.library.validator import SuiteValidator LOGGER = logging.getLogger(__name__) @@ -106,23 +106,3 @@ async def validate_suite(test_suite_url: str) -> None: raise HTTPException( status_code=400, detail=f"Test suite validation failed. {exception}" ) from exception - - -async def validate_artifact(artifact_identity: str = None, artifact_id=None) -> None: - """Validate the artifact identity and ID through the ArtifactValidator. - - :param artifact_identity: The artifact identity to validate (should be PURL if provided). - :param artifact_id: The artifact ID to validate (can be UUID object or string). - """ - span = trace.get_current_span() - - try: - # Convert artifact_id to string if it's not None - artifact_id_str = str(artifact_id) if artifact_id is not None else None - ArtifactValidator().validate_artifact_identity_or_id(artifact_identity, artifact_id_str) - except ValueError as exception: - msg = "Artifact validation failed!" - LOGGER.error(msg) - LOGGER.error(exception) - span.add_event(msg) - raise HTTPException(status_code=400, detail=msg) from exception diff --git a/python/src/etos_api/routers/v1alpha/router.py b/python/src/etos_api/routers/v1alpha/router.py index 3e04a3e..2570f5e 100644 --- a/python/src/etos_api/routers/v1alpha/router.py +++ b/python/src/etos_api/routers/v1alpha/router.py @@ -35,12 +35,12 @@ from opentelemetry import trace from opentelemetry.trace import Span + from .schemas import AbortTestrunResponse, StartTestrunRequest, StartTestrunResponse from .utilities import ( wait_for_artifact_created, download_suite, validate_suite, - validate_artifact, convert_to_rfc1123, recipes_from_tests, ) @@ -124,11 +124,6 @@ async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict: await validate_suite(test_suite) LOGGER.info("Test suite validated.") - # Validate artifact identity and ID before proceeding - LOGGER.info("Validating artifact identity and ID.") - await validate_artifact(artifact_identity=etos.artifact_identity, artifact_id=etos.artifact_id) - LOGGER.info("Artifact identity and ID validated.") - etos_library = ETOS("ETOS API", os.getenv("HOSTNAME", "localhost"), "ETOS API") LOGGER.info("Get artifact created %r", (etos.artifact_identity or str(etos.artifact_id))) diff --git a/python/src/etos_api/routers/v1alpha/utilities.py b/python/src/etos_api/routers/v1alpha/utilities.py index 2bd37ee..48e67a7 100644 --- a/python/src/etos_api/routers/v1alpha/utilities.py +++ b/python/src/etos_api/routers/v1alpha/utilities.py @@ -28,7 +28,7 @@ ARTIFACT_IDENTITY_QUERY, VERIFY_ARTIFACT_ID_EXISTS, ) -from etos_api.library.validator import SuiteValidator, ArtifactValidator +from etos_api.library.validator import SuiteValidator LOGGER = logging.getLogger(__name__) @@ -136,26 +136,6 @@ def convert_to_rfc1123(value: str) -> str: return result.lower() -async def validate_artifact(artifact_identity: str = None, artifact_id=None) -> None: - """Validate the artifact identity and ID through the ArtifactValidator. - - :param artifact_identity: The artifact identity to validate (should be PURL if provided). - :param artifact_id: The artifact ID to validate (can be UUID object or string). - """ - span = trace.get_current_span() - - try: - # Convert artifact_id to string if it's not None - artifact_id_str = str(artifact_id) if artifact_id is not None else None - ArtifactValidator().validate_artifact_identity_or_id(artifact_identity, artifact_id_str) - except ValueError as exception: - msg = "Failed to validate artifact identifier" - LOGGER.error(msg) - LOGGER.error(exception) - span.add_event(msg) - raise HTTPException(status_code=400, detail=msg) from exception - - async def recipes_from_tests(tests: list[dict]) -> list[dict]: """Load Eiffel TERCC recipes from test. diff --git a/python/tests/library/test_validator.py b/python/tests/library/test_validator.py index 8135702..50961d8 100644 --- a/python/tests/library/test_validator.py +++ b/python/tests/library/test_validator.py @@ -20,11 +20,7 @@ import pytest -from etos_api.library.validator import ( - ArtifactValidator, - SuiteValidator, - ValidationError, -) +from etos_api.library.validator import SuiteValidator, ValidationError logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) @@ -360,171 +356,3 @@ async def test_validate_empty_constraints(self): self.logger.info("STEP: Validate a suite without the required key.") with pytest.raises(ValidationError): await validator.validate([base_suite]) - - -class TestArtifactValidator: - """Test the artifact validation functions.""" - - def setup_method(self): - """Set up test fixtures.""" - self.validator = ArtifactValidator() - - def test_validate_purl_valid(self): - """Test that valid PURL strings are accepted.""" - valid_purls = [ - "pkg:npm/lodash@4.17.21", - "pkg:pypi/requests@2.25.1", - "pkg:maven/org.apache.commons/commons-lang3@3.12.0", - "pkg:golang/github.com/gorilla/mux@v1.8.0", - "pkg:docker/nginx@latest", - "pkg:generic/openssl@1.1.1k", - ] - - for purl in valid_purls: - assert self.validator.validate_purl(purl) is True - - def test_validate_purl_invalid(self): - """Test that invalid PURL strings are rejected.""" - invalid_purls = [ - "", - None, - "not-a-purl", - "http://example.com", - "pkg:", # Missing parts - "pkg:npm/", # Incomplete - "npm/lodash@4.17.21", # Missing pkg: prefix - "PKG:npm/lodash@4.17.21", # Wrong case - ] - - for purl in invalid_purls: - assert self.validator.validate_purl(purl) is False - - def test_validate_uuid_valid(self): - """Test that valid UUID strings are accepted.""" - valid_uuids = [ - "550e8400-e29b-41d4-a716-446655440000", - "6ba7b810-9dad-11d1-80b4-00c04fd430c8", - "6ba7b811-9dad-11d1-80b4-00c04fd430c8", - "6ba7b812-9dad-11d1-80b4-00c04fd430c8", - "6ba7b814-9dad-11d1-80b4-00c04fd430c8", - "f47ac10b-58cc-4372-a567-0e02b2c3d479", - ] - - for uuid_str in valid_uuids: - assert self.validator.validate_uuid(uuid_str) is True - - def test_validate_uuid_invalid(self): - """Test that invalid UUID strings are rejected.""" - invalid_uuids = [ - "", - None, - "not-a-uuid", - "550e8400-e29b-41d4-a716", # Too short - "550e8400-e29b-41d4-a716-446655440000-extra", # Too long - "550e8400-e29b-41d4-a716-44665544000g", # Invalid character (g) - ] - - for uuid_str in invalid_uuids: - assert self.validator.validate_uuid(uuid_str) is False - - def test_validate_artifact_identity_or_id_valid_purl(self): - """Test validation with valid PURL artifact_identity.""" - # Should not raise any exception - self.validator.validate_artifact_identity_or_id( - artifact_identity="pkg:npm/lodash@4.17.21", artifact_id=None - ) - - def test_validate_artifact_identity_or_id_valid_uuid(self): - """Test validation with valid UUID artifact_id.""" - # Should not raise any exception - self.validator.validate_artifact_identity_or_id( - artifact_identity=None, artifact_id="550e8400-e29b-41d4-a716-446655440000" - ) - - def test_validate_artifact_identity_or_id_invalid_purl(self): - """Test validation with invalid PURL artifact_identity raises ValueError.""" - with pytest.raises(ValueError) as exc_info: - self.validator.validate_artifact_identity_or_id( - artifact_identity="not-a-purl", artifact_id=None - ) - assert "Invalid artifact_identity" in str(exc_info.value) - assert "is not a valid PURL" in str(exc_info.value) - - def test_validate_artifact_identity_or_id_invalid_uuid(self): - """Test validation with invalid UUID artifact_id raises ValueError.""" - with pytest.raises(ValueError) as exc_info: - self.validator.validate_artifact_identity_or_id( - artifact_identity=None, artifact_id="not-a-uuid" - ) - assert "Invalid artifact_id" in str(exc_info.value) - assert "is not a valid UUID" in str(exc_info.value) - - def test_validate_artifact_identity_or_id_both_provided(self): - """Test validation with both valid values provided.""" - # Should not raise any exception when both are valid - self.validator.validate_artifact_identity_or_id( - artifact_identity="pkg:npm/lodash@4.17.21", - artifact_id="550e8400-e29b-41d4-a716-446655440000", - ) - - def test_validate_artifact_identity_or_id_neither_provided(self): - """Test validation with neither value provided.""" - # Should not raise any exception - no validation is performed when both are None - self.validator.validate_artifact_identity_or_id(artifact_identity=None, artifact_id=None) - - # Tests for ArtifactValidator class methods (should raise ValueError) - def test_artifact_validator_validate_purl_valid(self): - """Test ArtifactValidator.validate_purl with valid PURL strings.""" - valid_purls = [ - "pkg:npm/lodash@4.17.21", - "pkg:pypi/requests@2.25.1", - "pkg:maven/org.apache.commons/commons-lang3@3.12.0", - ] - - for purl in valid_purls: - assert self.validator.validate_purl(purl) is True - - def test_artifact_validator_validate_uuid_valid(self): - """Test ArtifactValidator.validate_uuid with valid UUID strings.""" - valid_uuids = [ - "550e8400-e29b-41d4-a716-446655440000", - "6ba7b810-9dad-11d1-80b4-00c04fd430c8", - ] - - for uuid_str in valid_uuids: - assert self.validator.validate_uuid(uuid_str) is True - - def test_artifact_validator_validate_artifact_identity_or_id_valid(self): - """Test ArtifactValidator.validate_artifact_identity_or_id with valid inputs.""" - # Should not raise any exception - self.validator.validate_artifact_identity_or_id( - artifact_identity="pkg:npm/lodash@4.17.21", artifact_id=None - ) - - self.validator.validate_artifact_identity_or_id( - artifact_identity=None, artifact_id="550e8400-e29b-41d4-a716-446655440000" - ) - - def test_artifact_validator_validate_identity_or_id_invalid_purl(self): - """Test ArtifactValidator.validate_artifact_identity_or_id with invalid PURL. - - Should raise ValueError. - """ - with pytest.raises(ValueError) as exc_info: - self.validator.validate_artifact_identity_or_id( - artifact_identity="not-a-purl", artifact_id=None - ) - assert "Invalid artifact_identity" in str(exc_info.value) - assert "is not a valid PURL" in str(exc_info.value) - - def test_artifact_validator_validate_identity_or_id_invalid_uuid(self): - """Test ArtifactValidator.validate_artifact_identity_or_id with invalid UUID. - - Should raise ValueError. - """ - with pytest.raises(ValueError) as exc_info: - self.validator.validate_artifact_identity_or_id( - artifact_identity=None, artifact_id="not-a-uuid" - ) - assert "Invalid artifact_id" in str(exc_info.value) - assert "is not a valid UUID" in str(exc_info.value) From 2aed79fff6ba9ab80ce9e96dbc7951f07d5e6f11 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 3 Oct 2025 08:34:01 +0200 Subject: [PATCH 3/5] add tests for current behavior Change-Id: I9d0bc8d32acf8b87c13933aee3511461ad8aef75 --- python/tests/test_routers.py | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/python/tests/test_routers.py b/python/tests/test_routers.py index 89448c8..4547a19 100644 --- a/python/tests/test_routers.py +++ b/python/tests/test_routers.py @@ -232,6 +232,96 @@ def test_start_etos_empty_suite(self, download_suite_mock, digest_mock): break assert tercc is None + def test_start_etos_missing_artifact_identity_and_id(self): + """Test that POST requests to /etos with missing artifact identity and ID fail validation. + + Approval criteria: + - POST requests to ETOS with missing artifact_identity and artifact_id shall return 422. + - The error message shall indicate that at least one is required. + + Test steps:: + 1. Send a POST request to etos without artifact_identity or artifact_id. + 2. Verify that the status code is 422. + 3. Verify that the error message indicates missing required field. + """ + self.logger.info("STEP: Send a POST request to etos without artifact_identity or artifact_id.") + response = self.client.post( + "/api/etos", + json={ + "test_suite_url": "http://localhost/my_test.json", + "artifact_id": None, # Explicitly set to None to trigger validation + }, + ) + self.logger.info("STEP: Verify that the status code is 422.") + assert response.status_code == 422 + + self.logger.info("STEP: Verify that the error message indicates missing required field.") + error_detail = response.json() + assert "detail" in error_detail + error_messages = [error["msg"] for error in error_detail["detail"]] + expected_message = "At least one of 'artifact_identity' or 'artifact_id' is required." + assert any(expected_message in msg for msg in error_messages) + + def test_start_etos_empty_artifact_identity_and_none_artifact_id(self): + """Test that POST requests to /etos with empty artifact_identity fail during processing. + + Approval criteria: + - POST requests to ETOS with empty artifact_identity shall return 400. + - The error should occur during suite validation or artifact processing. + + Test steps:: + 1. Send a POST request to etos with empty artifact_identity and None artifact_id. + 2. Verify that the status code is 400. + 3. Verify that the request fails (empty identity is treated as provided but invalid). + """ + self.logger.info("STEP: Send a POST request to etos with empty artifact_identity and None artifact_id.") + response = self.client.post( + "/api/etos", + json={ + "artifact_identity": "", + "artifact_id": None, + "test_suite_url": "http://localhost/my_test.json", + }, + ) + self.logger.info("STEP: Verify that the status code is 400.") + assert response.status_code == 400 + + self.logger.info("STEP: Verify that the request fails during processing.") + error_detail = response.json() + assert "detail" in error_detail + # Empty string is considered "provided" by validation, so it fails later in processing + + def test_start_etos_both_artifact_identity_and_id_provided(self): + """Test that POST requests to /etos with both artifact_identity and artifact_id fail validation. + + Approval criteria: + - POST requests to ETOS with both artifact_identity and artifact_id shall return 422. + - The error message shall indicate that only one is required. + + Test steps:: + 1. Send a POST request to etos with both artifact_identity and artifact_id. + 2. Verify that the status code is 422. + 3. Verify that the error message indicates only one is required. + """ + self.logger.info("STEP: Send a POST request to etos with both artifact_identity and artifact_id.") + response = self.client.post( + "/api/etos", + json={ + "artifact_identity": "pkg:testing/etos", + "artifact_id": "123e4567-e89b-12d3-a456-426614174000", + "test_suite_url": "http://localhost/my_test.json", + }, + ) + self.logger.info("STEP: Verify that the status code is 422.") + assert response.status_code == 422 + + self.logger.info("STEP: Verify that the error message indicates only one is required.") + error_detail = response.json() + assert "detail" in error_detail + error_messages = [error["msg"] for error in error_detail["detail"]] + expected_message = "Only one of 'artifact_identity' or 'artifact_id' is required." + assert any(expected_message in msg for msg in error_messages) + def test_selftest_get_ping(self): """Test that selftest ping with HTTP GET pings the system. From bdb256842be77918fcd9b38778211244f90367ba Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 3 Oct 2025 09:14:19 +0200 Subject: [PATCH 4/5] behavior/tests update for invalid identity/id Change-Id: Ide38ae0b52e2658aad55b913223eb9675c2ae5a1 --- python/src/etos_api/routers/v0/schemas.py | 21 ++++++++++- .../src/etos_api/routers/v1alpha/schemas.py | 21 ++++++++++- python/tests/test_routers.py | 37 +++++++++++-------- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/python/src/etos_api/routers/v0/schemas.py b/python/src/etos_api/routers/v0/schemas.py index 19817c2..c72402e 100644 --- a/python/src/etos_api/routers/v0/schemas.py +++ b/python/src/etos_api/routers/v0/schemas.py @@ -51,6 +51,10 @@ class StartEtosRequest(EtosRequest): def validate_id_or_identity(cls, artifact_id, info): """Validate that at least one and only one of id and identity are set. + Also validates that: + - artifact_identity must start with "pkg:" if provided + - artifact_id UUID validation is handled by Pydantic's built-in UUID type + :param artifact_id: The value of 'artifact_id' to validate. :value artifact_id: str or None :param info: The information about the model. @@ -59,10 +63,23 @@ def validate_id_or_identity(cls, artifact_id, info): :rtype: str or None """ values = info.data - if values.get("artifact_identity") is None and not artifact_id: + artifact_identity = values.get("artifact_identity") + + # Check that at least one is provided + if artifact_identity is None and not artifact_id: raise ValueError("At least one of 'artifact_identity' or 'artifact_id' is required.") - if values.get("artifact_identity") is not None and artifact_id: + + # Check that only one is provided + if artifact_identity is not None and artifact_id: raise ValueError("Only one of 'artifact_identity' or 'artifact_id' is required.") + + # Validate artifact_identity format if provided + if artifact_identity is not None: + if not isinstance(artifact_identity, str) or not artifact_identity.startswith("pkg:"): + raise ValueError("artifact_identity must be a string starting with 'pkg:'") + + # Note: artifact_id UUID validation is handled by Pydantic's built-in UUID type validation + return artifact_id diff --git a/python/src/etos_api/routers/v1alpha/schemas.py b/python/src/etos_api/routers/v1alpha/schemas.py index 9741603..4c93d48 100644 --- a/python/src/etos_api/routers/v1alpha/schemas.py +++ b/python/src/etos_api/routers/v1alpha/schemas.py @@ -50,6 +50,10 @@ class StartTestrunRequest(TestrunRequest): def validate_id_or_identity(cls, artifact_id, info): """Validate that at least one and only one of id and identity are set. + Also validates that: + - artifact_identity must start with "pkg:" if provided + - artifact_id UUID validation is handled by Pydantic's built-in UUID type + :param artifact_id: The value of 'artifact_id' to validate. :value artifact_id: str or None :param info: The information about the model. @@ -58,10 +62,23 @@ def validate_id_or_identity(cls, artifact_id, info): :rtype: str or None """ values = info.data - if values.get("artifact_identity") is None and not artifact_id: + artifact_identity = values.get("artifact_identity") + + # Check that at least one is provided + if artifact_identity is None and not artifact_id: raise ValueError("At least one of 'artifact_identity' or 'artifact_id' is required.") - if values.get("artifact_identity") is not None and artifact_id: + + # Check that only one is provided + if artifact_identity is not None and artifact_id: raise ValueError("Only one of 'artifact_identity' or 'artifact_id' is required.") + + # Validate artifact_identity format if provided + if artifact_identity is not None: + if not isinstance(artifact_identity, str) or not artifact_identity.startswith("pkg:"): + raise ValueError("artifact_identity must be a string starting with 'pkg:'") + + # Note: artifact_id UUID validation is handled by Pydantic's built-in UUID type validation + return artifact_id diff --git a/python/tests/test_routers.py b/python/tests/test_routers.py index 4547a19..ced2169 100644 --- a/python/tests/test_routers.py +++ b/python/tests/test_routers.py @@ -244,7 +244,9 @@ def test_start_etos_missing_artifact_identity_and_id(self): 2. Verify that the status code is 422. 3. Verify that the error message indicates missing required field. """ - self.logger.info("STEP: Send a POST request to etos without artifact_identity or artifact_id.") + self.logger.info( + "STEP: Send a POST request to etos without artifact_identity or artifact_id." + ) response = self.client.post( "/api/etos", json={ @@ -263,18 +265,20 @@ def test_start_etos_missing_artifact_identity_and_id(self): assert any(expected_message in msg for msg in error_messages) def test_start_etos_empty_artifact_identity_and_none_artifact_id(self): - """Test that POST requests to /etos with empty artifact_identity fail during processing. + """Test that POST requests to /etos with empty artifact_identity fail validation. Approval criteria: - - POST requests to ETOS with empty artifact_identity shall return 400. - - The error should occur during suite validation or artifact processing. + - POST requests to ETOS with empty artifact_identity shall return 422. + - The error message shall indicate invalid format (empty doesn't start with 'pkg:'). Test steps:: 1. Send a POST request to etos with empty artifact_identity and None artifact_id. - 2. Verify that the status code is 400. - 3. Verify that the request fails (empty identity is treated as provided but invalid). + 2. Verify that the status code is 422. + 3. Verify that the error message indicates invalid format. """ - self.logger.info("STEP: Send a POST request to etos with empty artifact_identity and None artifact_id.") + self.logger.info( + "STEP: Send a POST request to etos with empty artifact_identity and None artifact_id." + ) response = self.client.post( "/api/etos", json={ @@ -283,19 +287,20 @@ def test_start_etos_empty_artifact_identity_and_none_artifact_id(self): "test_suite_url": "http://localhost/my_test.json", }, ) - self.logger.info("STEP: Verify that the status code is 400.") - assert response.status_code == 400 + self.logger.info("STEP: Verify that the status code is 422.") + assert response.status_code == 422 - self.logger.info("STEP: Verify that the request fails during processing.") + self.logger.info("STEP: Verify that the error message indicates invalid format.") error_detail = response.json() assert "detail" in error_detail - # Empty string is considered "provided" by validation, so it fails later in processing + error_messages = [error["msg"] for error in error_detail["detail"]] + expected_message = "artifact_identity must be a string starting with 'pkg:'" + assert any(expected_message in msg for msg in error_messages) def test_start_etos_both_artifact_identity_and_id_provided(self): - """Test that POST requests to /etos with both artifact_identity and artifact_id fail validation. - + """ Approval criteria: - - POST requests to ETOS with both artifact_identity and artifact_id shall return 422. + - POST requests to ETOS with both artifact_identity and artifact_id returns 422. - The error message shall indicate that only one is required. Test steps:: @@ -303,7 +308,9 @@ def test_start_etos_both_artifact_identity_and_id_provided(self): 2. Verify that the status code is 422. 3. Verify that the error message indicates only one is required. """ - self.logger.info("STEP: Send a POST request to etos with both artifact_identity and artifact_id.") + self.logger.info( + "STEP: Send a POST request to etos with both artifact_identity and artifact_id." + ) response = self.client.post( "/api/etos", json={ From 9259d35d056c502b6948f5cbe5cc2a707adcf13f Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 3 Oct 2025 09:18:33 +0200 Subject: [PATCH 5/5] docstring fix Change-Id: I9e89919fe2f8b5eb38fe4afad76929539ac2e546 --- python/src/etos_api/routers/v0/schemas.py | 6 +----- python/src/etos_api/routers/v1alpha/schemas.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/python/src/etos_api/routers/v0/schemas.py b/python/src/etos_api/routers/v0/schemas.py index c72402e..5864992 100644 --- a/python/src/etos_api/routers/v0/schemas.py +++ b/python/src/etos_api/routers/v0/schemas.py @@ -49,11 +49,7 @@ class StartEtosRequest(EtosRequest): @field_validator("artifact_id") def validate_id_or_identity(cls, artifact_id, info): - """Validate that at least one and only one of id and identity are set. - - Also validates that: - - artifact_identity must start with "pkg:" if provided - - artifact_id UUID validation is handled by Pydantic's built-in UUID type + """Validate that id/identity is set correctly. :param artifact_id: The value of 'artifact_id' to validate. :value artifact_id: str or None diff --git a/python/src/etos_api/routers/v1alpha/schemas.py b/python/src/etos_api/routers/v1alpha/schemas.py index 4c93d48..17a24ce 100644 --- a/python/src/etos_api/routers/v1alpha/schemas.py +++ b/python/src/etos_api/routers/v1alpha/schemas.py @@ -48,11 +48,7 @@ class StartTestrunRequest(TestrunRequest): @field_validator("artifact_id") def validate_id_or_identity(cls, artifact_id, info): - """Validate that at least one and only one of id and identity are set. - - Also validates that: - - artifact_identity must start with "pkg:" if provided - - artifact_id UUID validation is handled by Pydantic's built-in UUID type + """Validate that id/identity is set correctly. :param artifact_id: The value of 'artifact_id' to validate. :value artifact_id: str or None