From 5d23881a5e8ff1ec9fc45d9f95bbd58c6115a53c Mon Sep 17 00:00:00 2001
From: jimenezquesa <manuel.jimenez@eviden.com>
Date: Fri, 20 Jun 2025 11:23:48 +0000
Subject: [PATCH] add unitary test inside ci/cd pipeline

---
 .gitlab-ci.yml                                |   1 +
 src/ztp_server/.gitlab-ci.yml                 | 100 ++++++++++++++
 .../service/ZtpServerServiceServicerImpl.py   |   2 +-
 src/ztp_server/tests/Constants.py             |  28 ++++
 src/ztp_server/tests/PrepareTestScenario.py   | 125 ++++++++++++++++++
 src/ztp_server/tests/test_core.py             |  49 +++++++
 src/ztp_server/tests/test_unitary.py          |  38 ++++++
 7 files changed, 342 insertions(+), 1 deletion(-)
 create mode 100644 src/ztp_server/.gitlab-ci.yml
 create mode 100644 src/ztp_server/tests/Constants.py
 create mode 100644 src/ztp_server/tests/PrepareTestScenario.py
 create mode 100644 src/ztp_server/tests/test_core.py
 create mode 100644 src/ztp_server/tests/test_unitary.py

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1aa5e597d..0da5a7431 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -54,6 +54,7 @@ include:
   - local: '/src/qos_profile/.gitlab-ci.yml'
   - local: '/src/vnt_manager/.gitlab-ci.yml'
   - local: '/src/e2e_orchestrator/.gitlab-ci.yml'
+  - local: '/src/ztp_server/.gitlab-ci.yml'
 
   # This should be last one: end-to-end integration tests
   - local: '/src/tests/.gitlab-ci.yml'
diff --git a/src/ztp_server/.gitlab-ci.yml b/src/ztp_server/.gitlab-ci.yml
new file mode 100644
index 000000000..552163527
--- /dev/null
+++ b/src/ztp_server/.gitlab-ci.yml
@@ -0,0 +1,100 @@
+# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Build, tag, and push the Docker image to the GitLab Docker registry
+build ztp_server:
+  variables:
+    IMAGE_NAME: 'ztp_server' # name of the microservice
+    IMAGE_TAG: 'latest' # tag of the container image (production, development, etc)
+  stage: build
+  before_script:
+    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
+  script:
+    - docker buildx build -t "$IMAGE_NAME:$IMAGE_TAG" -f ./src/$IMAGE_NAME/Dockerfile .
+    - docker tag "$IMAGE_NAME:$IMAGE_TAG" "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
+    - docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
+  after_script:
+    - docker images --filter="dangling=true" --quiet | xargs -r docker rmi
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)'
+    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"'
+    - changes:
+      - src/common/**/*.py
+      - proto/*.proto
+      - src/$IMAGE_NAME/**/*.{py,in,yml}
+      - src/$IMAGE_NAME/Dockerfile
+      - src/$IMAGE_NAME/tests/*.py
+      - manifests/${IMAGE_NAME}service.yaml
+      - .gitlab-ci.yml
+
+# Apply unit test to the component
+unit_test ztp_server:
+  variables:
+    IMAGE_NAME: 'ztp_server' # name of the microservice
+    IMAGE_TAG: 'latest' # tag of the container image (production, development, etc)
+  stage: unit_test
+  needs:
+    - build ztp_server
+  before_script:
+    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
+    - docker ps -aq | xargs -r docker rm -f
+    - >
+      if docker network list | grep teraflowbridge; then
+        echo "teraflowbridge is already created";
+      else
+        docker network create -d bridge teraflowbridge;
+      fi
+    - >
+      if docker container ls | grep $IMAGE_NAME; then
+        docker rm -f $IMAGE_NAME;
+      else
+        echo "$IMAGE_NAME image is not in the system";
+      fi
+    - docker container prune -f
+  script:
+    - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
+    - docker images --filter="dangling=true" --quiet | xargs -r docker rmi
+    - >
+      docker run --name $IMAGE_NAME -d -v "$PWD/src/$IMAGE_NAME/tests:/opt/results" 
+      --network=teraflowbridge
+      --env LOG_LEVEL=DEBUG
+      --env FLASK_ENV=development"
+      $CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG
+    - while ! docker logs $IMAGE_NAME 2>&1 | grep -q 'Configured Resources:'; do sleep 1; done
+    - sleep 5 # Give extra time to container to get ready
+    - docker ps -a
+    - docker logs $IMAGE_NAME
+    - docker exec -i $IMAGE_NAME bash -c "coverage run --append -m pytest --log-level=INFO --verbose $IMAGE_NAME/tests/test_core.py --junitxml=/opt/results/${IMAGE_NAME}_report_core.xml"
+    - docker exec -i $IMAGE_NAME bash -c "coverage run --append -m pytest --log-level=INFO --verbose $IMAGE_NAME/tests/test_unitary.py --junitxml=/opt/results/${IMAGE_NAME}_report_unitary.xml"
+    - docker exec -i $IMAGE_NAME bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing"
+  coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
+  after_script:
+    - docker logs $IMAGE_NAME
+    - docker rm -f $IMAGE_NAME
+    - docker network rm teraflowbridge
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)'
+    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"'
+    - changes:
+      - src/common/**/*.py
+      - proto/*.proto
+      - src/$IMAGE_NAME/**/*.{py,in,yml}
+      - src/$IMAGE_NAME/Dockerfile
+      - src/$IMAGE_NAME/tests/*.py
+      - manifests/${IMAGE_NAME}service.yaml
+      - .gitlab-ci.yml
+  artifacts:
+      when: always
+      reports:
+        junit: src/$IMAGE_NAME/tests/${IMAGE_NAME}_report_*.xml
diff --git a/src/ztp_server/service/ZtpServerServiceServicerImpl.py b/src/ztp_server/service/ZtpServerServiceServicerImpl.py
index ef06cf9e4..aedfb515b 100755
--- a/src/ztp_server/service/ZtpServerServiceServicerImpl.py
+++ b/src/ztp_server/service/ZtpServerServiceServicerImpl.py
@@ -28,7 +28,7 @@ class ZtpServerServiceServicerImpl(ZtpServerServiceServicer):
         LOGGER.info('Servicer Created')
 
     @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
-    def GetZtpProvisioning(self, request : ProvisioningScriptName, context : grpc.ServicerContext) -> ProvisioningScript:
+    def GetProvisioningScript(self, request : ProvisioningScriptName, context : grpc.ServicerContext) -> ProvisioningScript:
         try:
             provisioningPath = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'data', request.scriptname)
             with open(provisioningPath, 'r') as provisioning_file:
diff --git a/src/ztp_server/tests/Constants.py b/src/ztp_server/tests/Constants.py
new file mode 100644
index 000000000..eb5f632d6
--- /dev/null
+++ b/src/ztp_server/tests/Constants.py
@@ -0,0 +1,28 @@
+# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from common.Constants import ServiceNameEnum
+from common.Settings import get_service_baseurl_http, get_service_port_http
+
+
+USERNAME               = 'admin'
+PASSWORD               = 'admin'
+LOCAL_HOST             = '127.0.0.1'
+MOCKSERVICE_PORT       = 10000
+ZTP_SERVICE_PORT       = get_service_port_http(ServiceNameEnum.ZTP_SERVER) + MOCKSERVICE_PORT # avoid privileged ports
+ZTP_SERVICE_PREFIX_URL = get_service_baseurl_http(ServiceNameEnum.ZTP_SERVER) or ''
+ZTP_SERVICE_BASE_URL   = 'http://{:s}:{:s}@{:s}:{:d}{:s}'.format(
+    USERNAME, PASSWORD, LOCAL_HOST, ZTP_SERVICE_PORT, ZTP_SERVICE_PREFIX_URL
+)
diff --git a/src/ztp_server/tests/PrepareTestScenario.py b/src/ztp_server/tests/PrepareTestScenario.py
new file mode 100644
index 000000000..66340fd0a
--- /dev/null
+++ b/src/ztp_server/tests/PrepareTestScenario.py
@@ -0,0 +1,125 @@
+# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import enum, logging, os, pytest, requests, time # subprocess, threading
+from typing import Any, Dict, List, Optional, Set, Union
+
+from common.Constants import ServiceNameEnum
+from common.Settings import (
+    ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC,
+    ENVVAR_SUFIX_SERVICE_PORT_HTTP, get_env_var_name
+)
+
+from ztp_server.service.rest_server import RestServer
+from ztp_server.service.rest_server.ztpServer_plugins.ztp_provisioning_api import register_ztp_provisioning
+
+from .Constants import (
+    LOCAL_HOST, MOCKSERVICE_PORT, ZTP_SERVICE_BASE_URL, ZTP_SERVICE_PORT,
+    USERNAME, PASSWORD
+)
+
+os.environ[get_env_var_name(ServiceNameEnum.ZTP_SERVER, ENVVAR_SUFIX_SERVICE_HOST     )] = str(LOCAL_HOST)
+os.environ[get_env_var_name(ServiceNameEnum.ZTP_SERVER, ENVVAR_SUFIX_SERVICE_PORT_HTTP)] = str(ZTP_SERVICE_PORT)
+
+@pytest.fixture(scope='session')
+def ztp_server_application():
+    _rest_server = RestServer()
+    register_ztp_provisioning(_rest_server)
+    _rest_server.start()
+    time.sleep(1)   # bring time for the server to start
+    yield _rest_server
+    _rest_server.shutdown()
+    _rest_server.join()
+    
+class RestRequestMethod(enum.Enum):
+    GET    = 'get'
+    POST   = 'post'
+    PUT    = 'put'
+    PATCH  = 'patch'
+    DELETE = 'delete'
+
+EXPECTED_STATUS_CODES : Set[int] = {
+    requests.codes['OK'        ],
+    requests.codes['CREATED'   ],
+    requests.codes['ACCEPTED'  ],
+    requests.codes['NO_CONTENT'],
+}
+
+def do_rest_request(
+    method : RestRequestMethod, url : str, body : Optional[Any] = None, timeout : int = 10,
+    allow_redirects : bool = True, expected_status_codes : Set[int] = EXPECTED_STATUS_CODES,
+    logger : Optional[logging.Logger] = None
+) -> Optional[Union[Dict, List]]:
+    request_url = ZTP_SERVICE_BASE_URL + url
+    if logger is not None:
+        msg = 'Request: {:s} {:s}'.format(str(method.value).upper(), str(request_url))
+        if body is not None: msg += ' body={:s}'.format(str(body))
+        logger.warning(msg)
+    reply = requests.request(method.value, request_url, timeout=timeout, json=body, allow_redirects=allow_redirects)
+    if logger is not None:
+        logger.warning('Reply: {:s}'.format(str(reply.text)))
+    assert reply.status_code in expected_status_codes, 'Reply failed with status code {:d}'.format(reply.status_code)
+
+    if reply.content and len(reply.content) > 0: return reply.json()
+    return None
+
+def do_rest_get_request(
+    url : str, body : Optional[Any] = None, timeout : int = 10,
+    allow_redirects : bool = True, expected_status_codes : Set[int] = EXPECTED_STATUS_CODES,
+    logger : Optional[logging.Logger] = None
+) -> Optional[Union[Dict, List]]:
+    return do_rest_request(
+        RestRequestMethod.GET, url, body=body, timeout=timeout, allow_redirects=allow_redirects,
+        expected_status_codes=expected_status_codes, logger=logger
+    )
+
+def do_rest_post_request(
+    url : str, body : Optional[Any] = None, timeout : int = 10,
+    allow_redirects : bool = True, expected_status_codes : Set[int] = EXPECTED_STATUS_CODES,
+    logger : Optional[logging.Logger] = None
+) -> Optional[Union[Dict, List]]:
+    return do_rest_request(
+        RestRequestMethod.POST, url, body=body, timeout=timeout, allow_redirects=allow_redirects,
+        expected_status_codes=expected_status_codes, logger=logger
+    )
+
+def do_rest_put_request(
+    url : str, body : Optional[Any] = None, timeout : int = 10,
+    allow_redirects : bool = True, expected_status_codes : Set[int] = EXPECTED_STATUS_CODES,
+    logger : Optional[logging.Logger] = None
+) -> Optional[Union[Dict, List]]:
+    return do_rest_request(
+        RestRequestMethod.PUT, url, body=body, timeout=timeout, allow_redirects=allow_redirects,
+        expected_status_codes=expected_status_codes, logger=logger
+    )
+
+def do_rest_patch_request(
+    url : str, body : Optional[Any] = None, timeout : int = 10,
+    allow_redirects : bool = True, expected_status_codes : Set[int] = EXPECTED_STATUS_CODES,
+    logger : Optional[logging.Logger] = None
+) -> Optional[Union[Dict, List]]:
+    return do_rest_request(
+        RestRequestMethod.PATCH, url, body=body, timeout=timeout, allow_redirects=allow_redirects,
+        expected_status_codes=expected_status_codes, logger=logger
+    )
+
+def do_rest_delete_request(
+    url : str, body : Optional[Any] = None, timeout : int = 10,
+    allow_redirects : bool = True, expected_status_codes : Set[int] = EXPECTED_STATUS_CODES,
+    logger : Optional[logging.Logger] = None
+) -> Optional[Union[Dict, List]]:
+    return do_rest_request(
+        RestRequestMethod.DELETE, url, body=body, timeout=timeout, allow_redirects=allow_redirects,
+        expected_status_codes=expected_status_codes, logger=logger
+    )
diff --git a/src/ztp_server/tests/test_core.py b/src/ztp_server/tests/test_core.py
new file mode 100644
index 000000000..42f1727b6
--- /dev/null
+++ b/src/ztp_server/tests/test_core.py
@@ -0,0 +1,49 @@
+# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import json
+
+from .PrepareTestScenario import ( # pylint: disable=unused-import
+    # be careful, order of symbols is important here!
+    ztp_server_application, do_rest_get_request
+)
+
+LOGGER = logging.getLogger(__name__)
+LOGGER.setLevel(logging.DEBUG)
+
+BASE_URL = '/provisioning'
+
+def test_get_config_file(ztp_server_application,   # pylint: disable=redefined-outer-name, unused-argument
+) -> None:
+    URL = BASE_URL + '/config/ztp.json'
+    data = {
+    "ztp": {
+      "01-provisioning-script": {
+        "plugin": {
+          "url": "http://localhost:9001/provisioning/provisioning_script_sonic.sh"
+        },
+        "reboot-on-success": True
+      }}
+    }
+    retrieved_data = do_rest_get_request(URL, body=data, logger=LOGGER, expected_status_codes={200})
+    LOGGER.debug('retrieved_data={:s}'.format(json.dumps(retrieved_data, sort_keys=True)))
+
+def test_get_wrong_config(
+    ztp_server_application   # pylint: disable=redefined-outer-name, unused-argument
+) -> None:
+    URL = BASE_URL + '/config/wrong.json'
+    retrieved_data = do_rest_get_request(URL, logger=LOGGER, expected_status_codes={503})
+    LOGGER.debug('retrieved_data={:s}'.format(json.dumps(retrieved_data, sort_keys=True)))
+    assert len(retrieved_data) == 0
diff --git a/src/ztp_server/tests/test_unitary.py b/src/ztp_server/tests/test_unitary.py
new file mode 100644
index 000000000..6d70852b5
--- /dev/null
+++ b/src/ztp_server/tests/test_unitary.py
@@ -0,0 +1,38 @@
+# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import grpc
+from ztp_server.client.ZtpClient import ZtpClient
+from common.proto.ztp_server_pb2 import ProvisioningScriptName, ProvisioningScript, ZtpFileName, ZtpFile
+
+
+def test_GetProvisioningScript(
+    ztp_client : ZtpClient,
+):  # pylint: disable=redefined-outer-name
+
+    ztp_provisioning_file_request = ZtpFileName()
+    ztp_provisioning_file_request.ProvisioningScriptName = "provisioning_script_sonic.sh" # pylint: disable=no-member
+    ztp_reply = ztp_client.GetProvisioningScript(ztp_provisioning_file_request)
+
+    assert ztp_reply.StatusCode == grpc.StatusCode.OK
+    assert ztp_reply.ProvisioningScript.script.startswith("#!/bin/bash")
+
+def test_GetProvisioningScript_wrong(
+    ztp_client : ZtpClient,
+):  # pylint: disable=redefined-outer-name
+    ztp_provisioning_file_request = ZtpFileName()
+    ztp_provisioning_file_request.filename = "wrong_file.sh" # pylint: disable=no-member
+    ztp_reply = ztp_client.GetProvisioningScript(ztp_provisioning_file_request)
+    assert ztp_reply.StatusCode == grpc.StatusCode.NOT_FOUND
+
-- 
GitLab