|
25 | 25 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
26 | 26 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
27 | 27 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 28 | +import json |
| 29 | +import os |
28 | 30 | from datetime import datetime, timezone |
29 | | -from unittest.mock import ANY |
| 31 | +from unittest.mock import ANY, patch |
30 | 32 |
|
| 33 | +import pytest |
31 | 34 | from celery import Celery |
32 | 35 | from pytest import fixture |
| 36 | +from werkzeug.exceptions import BadRequest |
33 | 37 |
|
34 | 38 | from servicex_app import LookupResultProcessor |
35 | 39 | from servicex_app.code_gen_adapter import CodeGenAdapter |
36 | 40 | from servicex_app.dataset_manager import DatasetManager |
37 | 41 | from servicex_app.models import Dataset |
38 | 42 | from servicex_app.models import TransformRequest, DatasetStatus, TransformStatus |
| 43 | +from servicex_app.resources.transformation.submit import validate_custom_docker_image |
39 | 44 | from servicex_app.transformer_manager import TransformerManager |
40 | 45 | from servicex_app_test.resource_test_base import ResourceTestBase |
41 | 46 |
|
@@ -588,3 +593,199 @@ def test_submit_transformation_with_title( |
588 | 593 | saved_obj = TransformRequest.lookup(request_id) |
589 | 594 | assert saved_obj |
590 | 595 | assert saved_obj.title == title |
| 596 | + |
| 597 | + |
| 598 | +class TestValidateCustomDockerImage: |
| 599 | + """Tests for the validate_custom_docker_image function""" |
| 600 | + |
| 601 | + def test_validate_with_matching_prefix(self): |
| 602 | + """Test validation succeeds when image matches an allowed prefix""" |
| 603 | + with patch.dict( |
| 604 | + os.environ, |
| 605 | + {"TOPCP_ALLOWED_IMAGES": '["sslhep/servicex_science_image_topcp:"]'}, |
| 606 | + ): |
| 607 | + result = validate_custom_docker_image( |
| 608 | + "sslhep/servicex_science_image_topcp:2.17.0" |
| 609 | + ) |
| 610 | + assert result is True |
| 611 | + |
| 612 | + def test_validate_with_multiple_prefixes(self): |
| 613 | + """Test validation with multiple allowed prefixes""" |
| 614 | + with patch.dict( |
| 615 | + os.environ, |
| 616 | + { |
| 617 | + "TOPCP_ALLOWED_IMAGES": '["sslhep/servicex_science_image_topcp:", "docker.io/ssl-hep/"]' |
| 618 | + }, |
| 619 | + ): |
| 620 | + assert ( |
| 621 | + validate_custom_docker_image( |
| 622 | + "sslhep/servicex_science_image_topcp:latest" |
| 623 | + ) |
| 624 | + is True |
| 625 | + ) |
| 626 | + assert ( |
| 627 | + validate_custom_docker_image("docker.io/ssl-hep/custom:v1") is True |
| 628 | + ) |
| 629 | + |
| 630 | + def test_validate_with_no_matching_prefix(self): |
| 631 | + """Test validation fails when image doesn't match any allowed prefix""" |
| 632 | + with patch.dict( |
| 633 | + os.environ, |
| 634 | + {"TOPCP_ALLOWED_IMAGES": '["sslhep/servicex_science_image_topcp:"]'}, |
| 635 | + ): |
| 636 | + with pytest.raises(BadRequest, match="not allowed"): |
| 637 | + validate_custom_docker_image("unauthorized/image:latest") |
| 638 | + |
| 639 | + def test_validate_with_no_env_variable(self): |
| 640 | + """Test validation fails when TOPCP_ALLOWED_IMAGES is not set""" |
| 641 | + with patch.dict(os.environ, {}, clear=True): |
| 642 | + with pytest.raises(BadRequest, match="Custom Docker images are not allowed"): |
| 643 | + validate_custom_docker_image("sslhep/servicex_science_image_topcp:2.17.0") |
| 644 | + |
| 645 | + def test_validate_with_invalid_json(self): |
| 646 | + """Test validation fails with invalid JSON in env variable""" |
| 647 | + with patch.dict(os.environ, {"TOPCP_ALLOWED_IMAGES": "not-valid-json"}): |
| 648 | + with pytest.raises(BadRequest, match="improperly configured"): |
| 649 | + validate_custom_docker_image("sslhep/servicex_science_image_topcp:2.17.0") |
| 650 | + |
| 651 | + def test_validate_with_non_list_json(self): |
| 652 | + """Test validation fails when JSON is not a list""" |
| 653 | + with patch.dict(os.environ, {"TOPCP_ALLOWED_IMAGES": '{"key": "value"}'}): |
| 654 | + with pytest.raises(BadRequest, match="improperly configured"): |
| 655 | + validate_custom_docker_image("sslhep/servicex_science_image_topcp:2.17.0") |
| 656 | + |
| 657 | + def test_validate_with_empty_list(self): |
| 658 | + """Test validation fails when allowed list is empty""" |
| 659 | + with patch.dict(os.environ, {"TOPCP_ALLOWED_IMAGES": "[]"}): |
| 660 | + with pytest.raises(BadRequest, match="not allowed"): |
| 661 | + validate_custom_docker_image("sslhep/servicex_science_image_topcp:2.17.0") |
| 662 | + |
| 663 | + |
| 664 | +class TestSubmitTransformationRequestCustomImage(ResourceTestBase): |
| 665 | + """Tests for custom Docker image feature in transformation requests""" |
| 666 | + |
| 667 | + @staticmethod |
| 668 | + def _generate_transformation_request(**kwargs): |
| 669 | + request = { |
| 670 | + "did": "123-45-678", |
| 671 | + "selection": "test-string", |
| 672 | + "result-destination": "object-store", |
| 673 | + "result-format": "root-file", |
| 674 | + "workers": 10, |
| 675 | + "codegen": "topcp", |
| 676 | + } |
| 677 | + request.update(kwargs) |
| 678 | + return request |
| 679 | + |
| 680 | + @fixture |
| 681 | + def mock_dataset_manager_from_did(self, mocker): |
| 682 | + dm = mocker.Mock() |
| 683 | + dm.dataset = Dataset( |
| 684 | + name="rucio://123-45-678", |
| 685 | + did_finder="rucio", |
| 686 | + lookup_status=DatasetStatus.looking, |
| 687 | + last_used=datetime.now(tz=timezone.utc), |
| 688 | + last_updated=datetime.fromtimestamp(0), |
| 689 | + ) |
| 690 | + dm.name = "rucio://123-45-678" |
| 691 | + dm.id = 42 |
| 692 | + mock_from_did = mocker.patch.object(DatasetManager, "from_did", return_value=dm) |
| 693 | + return mock_from_did |
| 694 | + |
| 695 | + @fixture |
| 696 | + def mock_codegen(self, mocker): |
| 697 | + mock_code_gen = mocker.MagicMock(CodeGenAdapter) |
| 698 | + mock_code_gen.generate_code_for_selection.return_value = ( |
| 699 | + "my-cm", |
| 700 | + "sslhep/servicex_science_image_topcp:2.17.0", |
| 701 | + "bash", |
| 702 | + "echo", |
| 703 | + ) |
| 704 | + return mock_code_gen |
| 705 | + |
| 706 | + def test_submit_topcp_with_custom_docker_image( |
| 707 | + self, mock_dataset_manager_from_did, mock_codegen, mock_app_version |
| 708 | + ): |
| 709 | + """Test submitting a TopCP transformation with a valid custom docker image""" |
| 710 | + extra_config = { |
| 711 | + "CODE_GEN_IMAGES": {"topcp": "sslhep/servicex_code_gen_topcp:develop"} |
| 712 | + } |
| 713 | + with patch.dict( |
| 714 | + os.environ, |
| 715 | + {"TOPCP_ALLOWED_IMAGES": '["sslhep/servicex_science_image_topcp:"]'}, |
| 716 | + ): |
| 717 | + client = self._test_client(code_gen_service=mock_codegen, extra_config=extra_config) |
| 718 | + with client.application.app_context(): |
| 719 | + selection_dict = {"docker_image": "sslhep/servicex_science_image_topcp:custom"} |
| 720 | + request = self._generate_transformation_request( |
| 721 | + selection=json.dumps(selection_dict) |
| 722 | + ) |
| 723 | + |
| 724 | + response = client.post( |
| 725 | + "/servicex/transformation", json=request, headers=self.fake_header() |
| 726 | + ) |
| 727 | + assert response.status_code == 200 |
| 728 | + request_id = response.json["request_id"] |
| 729 | + |
| 730 | + saved_obj = TransformRequest.lookup(request_id) |
| 731 | + assert saved_obj |
| 732 | + assert saved_obj.image == "sslhep/servicex_science_image_topcp:custom" |
| 733 | + |
| 734 | + def test_submit_topcp_with_invalid_custom_docker_image( |
| 735 | + self, mock_dataset_manager_from_did, mock_codegen |
| 736 | + ): |
| 737 | + """Test submitting a TopCP transformation with an invalid custom docker image returns 400""" |
| 738 | + extra_config = { |
| 739 | + "CODE_GEN_IMAGES": {"topcp": "sslhep/servicex_code_gen_topcp:develop"} |
| 740 | + } |
| 741 | + with patch.dict( |
| 742 | + os.environ, |
| 743 | + {"TOPCP_ALLOWED_IMAGES": '["sslhep/servicex_science_image_topcp:"]'}, |
| 744 | + ): |
| 745 | + client = self._test_client(code_gen_service=mock_codegen, extra_config=extra_config) |
| 746 | + with client.application.app_context(): |
| 747 | + selection_dict = {"docker_image": "unauthorized/image:latest"} |
| 748 | + request = self._generate_transformation_request( |
| 749 | + selection=json.dumps(selection_dict) |
| 750 | + ) |
| 751 | + |
| 752 | + response = client.post( |
| 753 | + "/servicex/transformation", json=request, headers=self.fake_header() |
| 754 | + ) |
| 755 | + assert response.status_code == 400 |
| 756 | + |
| 757 | + def test_submit_topcp_with_non_json_selection( |
| 758 | + self, mock_dataset_manager_from_did, mock_codegen, mock_app_version |
| 759 | + ): |
| 760 | + """Test submitting a TopCP transformation with non-JSON selection string""" |
| 761 | + extra_config = { |
| 762 | + "CODE_GEN_IMAGES": {"topcp": "sslhep/servicex_code_gen_topcp:develop"} |
| 763 | + } |
| 764 | + client = self._test_client(code_gen_service=mock_codegen, extra_config=extra_config) |
| 765 | + with client.application.app_context(): |
| 766 | + request = self._generate_transformation_request(selection="not-json-string") |
| 767 | + |
| 768 | + response = client.post( |
| 769 | + "/servicex/transformation", json=request, headers=self.fake_header() |
| 770 | + ) |
| 771 | + assert response.status_code == 400 |
| 772 | + |
| 773 | + def test_submit_custom_topcp_without_env_var( |
| 774 | + self, mock_dataset_manager_from_did, mock_codegen |
| 775 | + ): |
| 776 | + """Test submitting TopCP with custom image when TOPCP_ALLOWED_IMAGES is not set""" |
| 777 | + extra_config = { |
| 778 | + "CODE_GEN_IMAGES": {"topcp": "sslhep/servicex_code_gen_topcp:develop"} |
| 779 | + } |
| 780 | + with patch.dict(os.environ, {}, clear=True): |
| 781 | + client = self._test_client(code_gen_service=mock_codegen, extra_config=extra_config) |
| 782 | + with client.application.app_context(): |
| 783 | + selection_dict = {"docker_image": "sslhep/servicex_science_image_topcp:custom"} |
| 784 | + request = self._generate_transformation_request( |
| 785 | + selection=json.dumps(selection_dict) |
| 786 | + ) |
| 787 | + |
| 788 | + response = client.post( |
| 789 | + "/servicex/transformation", json=request, headers=self.fake_header() |
| 790 | + ) |
| 791 | + assert response.status_code == 400 |
0 commit comments