diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 0fdb8c379..8c4b5bcbe 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -7,9 +7,13 @@ on: push: branches: - 'dev' + - 'release/*' + +permissions: + contents: read jobs: - testrun_package: + create_package: permissions: {} name: Package runs-on: ubuntu-22.04 @@ -24,4 +28,64 @@ jobs: uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0 with: name: testrun_package - path: testrun*.deb \ No newline at end of file + path: testrun*.deb + + install_package_20: + permissions: {} + needs: create_package + name: Install on Ubuntu 20.04 + runs-on: ubuntu-20.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb + + install_package_22: + permissions: {} + needs: create_package + name: Install on Ubuntu 22.04 + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb + + install_package_24: + permissions: {} + needs: create_package + name: Install on Ubuntu 24.04 + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 12884c718..f0f89a631 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -7,10 +7,6 @@ on: # For Branch-Protection check. Only the default branch is supported. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection branch_protection_rule: - # To guarantee Maintained check is occasionally updated. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained - schedule: - - cron: '20 6 * * 4' push: branches: [ "main" ] @@ -70,4 +66,4 @@ jobs: - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 with: - sarif_file: results.sarif + sarif_file: results.sarif \ No newline at end of file diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0556a2189..d6deb1ab0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -39,7 +39,7 @@ jobs: run: cmd/prepare - name: Install Testrun shell: bash {0} - run: TESTRUN_DIR=. cmd/install + run: cmd/install -l timeout-minutes: 30 - name: Run tests shell: bash {0} @@ -55,6 +55,28 @@ jobs: name: runtime_api_${{ github.run_id }} path: runtime.tgz + testrun_unit: + permissions: {} + name: Unit + runs-on: ubuntu-20.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Install dependencies + shell: bash {0} + run: cmd/prepare + - name: Install Testrun + shell: bash {0} + run: cmd/install -l + - name: Build Testrun + shell: bash {0} + run: cmd/build + timeout-minutes: 10 + - name: Run tests + shell: bash {0} + run: bash testing/unit/run.sh + pylint: permissions: {} name: Pylint diff --git a/.gitignore b/.gitignore index 82b6bbf64..92779dc04 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ # Ignore generated files from unit tests testing/unit_test/temp/ +testing/unit/conn/output/ testing/unit/dns/output/ testing/unit/nmap/output/ testing/unit/ntp/output/ @@ -15,6 +16,7 @@ testing/unit/tls/output/ testing/unit/tls/tmp/ testing/unit/report/output/ testing/unit/risk_profile/output/ +testing/unit/services/output/ *.deb make/DEBIAN/postinst diff --git a/README.md b/README.md index 4a04e8885..23fd843ca 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ When manual testing or configuration changes are required, Testrun will provide - DHCP client - The device must be able to obtain an IP address via DHCP ## Get started ▶️ -Once you have met the hardware and software requirements, you can get started with Testrun by following the [Get started guide](docs/get_started.md). +Once you have met the hardware and software requirements, you can get started with Testrun by following the [Get started guide](docs/get_started.md). Further docs are available in the [docs directory](docs) ## Roadmap :chart_with_upwards_trend: Testrun will constantly evolve to further support end-users by automating device network behaviour against industry standards. For further information on upcoming features, check out the [Roadmap](docs/roadmap.pdf). @@ -59,7 +59,7 @@ We are proud of our tool and strive to provide an enjoyable experience for all o If the application has come across a problem at any point during setup or use, please raise an issue under the [issues tab](https://github.com/google/testrun/issues). Issue templates exist for both bug reports and feature requests. If neither of these are appropriate for your issue, raise a blank issue instead. ## Contributing :keyboard: -The contributing requirements can be found in [CONTRIBUTING.md](CONTRIBUTING.md). In short, checkout the [Google CLA](https://cla.developers.google.com/) site to get started. +The contributing requirements can be found in [CONTRIBUTING.md](CONTRIBUTING.md). In short, checkout the [Google CLA](https://cla.developers.google.com/) site to get started. After that, check out our [developer documentation](docs/dev/README.md). ## FAQ :raising_hand: 1) I have an issue whilst installing/upgrading Testrun, what do I do? diff --git a/cmd/build b/cmd/build index d15171f31..d3294a681 100755 --- a/cmd/build +++ b/cmd/build @@ -36,15 +36,28 @@ fi # Builds all docker images echo Building docker images -# Build user interface -echo Building user interface -if docker build -t test-run/ui -f modules/ui/ui.Dockerfile . ; then +# Check if UI has already been built (if -l was used during install) +if [ ! -d "modules/ui/dist" ]; then + cmd/build_ui +fi + +# Build UI image +if docker build -t testrun/ui -f modules/ui/ui.Dockerfile . ; then echo Successully built the user interface else echo An error occured whilst building the user interface exit 1 fi +# Build websockets server +echo Building websockets server +if docker build -t testrun/ws -f modules/ws/ws.Dockerfile . ; then + echo Successully built the web sockets server +else + echo An error occured whilst building the websockets server + exit 1 +fi + # Build network modules echo Building network modules mkdir -p build/network diff --git a/cmd/build_ui b/cmd/build_ui new file mode 100755 index 000000000..afb0d8827 --- /dev/null +++ b/cmd/build_ui @@ -0,0 +1,37 @@ +#!/bin/bash -e + +# Copyright 2023 Google LLC +# +# 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 +# +# https://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 the UI +echo Building the ui builder + +# Build UI builder image +if docker build -t testrun/build-ui -f modules/ui/build.Dockerfile . ; then + echo Successully built the ui builder +else + echo An error occured whilst building the ui builder + exit 1 +fi + +# Check that the container is not already running +docker kill tr-ui-build 2> /dev/null || true + +echo "Building the user interface" + +# Start build container and build the ui dist +docker run --rm -v $PWD/modules/ui:/modules/ui testrun/build-ui /bin/sh -c "npm install && npm run build" + +# Kill the container (Should not be running anymore) +docker kill tr-ui-build 2> /dev/null || true diff --git a/cmd/install b/cmd/install index 53d12b324..c350a969f 100755 --- a/cmd/install +++ b/cmd/install @@ -20,15 +20,29 @@ echo Installing application dependencies while getopts ":l" option; do case $option in l) # Install Testrun in local directory - TESTRUN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")"/.. && pwd) + TESTRUN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")"/.. && pwd) esac done # Check if TESTRUN_DIR has been set, otherwise install in /usr/local/testrun if [[ -z "${TESTRUN_DIR}" ]]; then TESTRUN_DIR=/usr/local/testrun + + # Check that user is sudo + if [[ "$EUID" -ne 0 ]]; then + echo "Installing Testrun in the default location requires sudo. Run using sudo cmd/install" + exit 1 + fi + else TESTRUN_DIR="${TESTRUN_DIR}" + + # Check that user is in docker group + if ! (id -nGz "$USER" | grep -qzxF "docker"); then + echo User is not in docker group. Follow https://docs.docker.com/engine/install/linux-postinstall/ to finish setting up docker. + exit 1 + fi + fi echo Installing Testrun at $TESTRUN_DIR @@ -51,7 +65,7 @@ cp -n local/system.json.example local/system.json deactivate # Build docker images -sudo cmd/build +cmd/build # Create local folders mkdir -p local/devices diff --git a/cmd/package b/cmd/package index fc418ab05..719258a83 100755 --- a/cmd/package +++ b/cmd/package @@ -16,6 +16,12 @@ # Creates a package for Testrun +# Check that user is not root +if [[ "$EUID" == 0 ]]; then + echo "Must not run as root. Use cmd/package as regular user" + exit 1 +fi + MAKE_SRC_DIR=make MAKE_CONTROL_DIR=make/DEBIAN/control @@ -25,10 +31,10 @@ version=$(grep -R "Version: " $MAKE_CONTROL_DIR | awk '{print $2}') # Replace invalid characters version="${version//./_}" -# Delete existing make files -rm -rf $MAKE_SRC_DIR/usr +echo Building package for testrun v${version} # Delete existing make files +echo Cleaning up previous build files rm -rf $MAKE_SRC_DIR/usr # Copy testrun script to /bin @@ -60,6 +66,9 @@ mkdir -p $MAKE_SRC_DIR/usr/local/testrun/local/risk_profiles mkdir -p local/root_certs cp -r local/root_certs $MAKE_SRC_DIR/usr/local/testrun/local/ +# Build the UI +cmd/build_ui + # Copy framework and modules into testrun folder cp -r {framework,modules} $MAKE_SRC_DIR/usr/local/testrun diff --git a/docs/README.md b/docs/README.md index 96eb32223..5f055dbb9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,3 +16,6 @@ - [Running on a virtual machine](virtual_machine.md) - [Accessibility](ui/accessibility.mp4) - [Roadmap](roadmap.pdf) + +## Something missing? +If you feel there is some documentation that you would find useful, or have found an issue with existing documentation, please raise an issue on GitHub by navigating [here](https://github.com/google/testrun/issues/new/choose) \ No newline at end of file diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 000000000..f11b1b092 --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,25 @@ +Testrun logo + +## Developer docs + +## Table of Contents +1) General guidelines (this page) +2) [Code quality](code_quality.md) + +## General guidelines +As an open source project, we absolutely encourage contributions from the community to help Testrun remain an expanding but stable product. However, before contributing there are a number of things to take into consideration. + +1) [Sign the Google CLA](https://cla.developers.google.com/): Whether you are an individual or contributing on behalf of your organisation, you must be covered by a Google CLA. + +2) Determine the scope of your contribution + + - Your contribution is more likely to be accepted if fewer files are changed (keep it simple) + - Are you going to be fixing a bug, dependency issue or a new framework capability? Whatever it is, ensure your pull request fixes or changes just one thing. + +3) Get in touch to discuss whether your proposed changes are likely to be accepted + + - It is best to get the opinion from the core maintainers whether your proposed changes meet our objectives and align with Testrun principles. + +4) Fork Testrun and get developing + + - We aim to provide thorough and easy to ready developer documentation to help you contribute successfully. \ No newline at end of file diff --git a/docs/dev/code_quality.md b/docs/dev/code_quality.md new file mode 100644 index 000000000..47eabcf95 --- /dev/null +++ b/docs/dev/code_quality.md @@ -0,0 +1,16 @@ +Testrun logo + +## Code quality + +Whilst developing code for Testrun, there are some style guides that you should follow. + + - Python: https://google.github.io/styleguide/pyguide.html + - Angular: https://google.github.io/styleguide/angularjs-google-style.html + - Shell: https://google.github.io/styleguide/shellguide.html + - HTML/CSS: https://google.github.io/styleguide/htmlcssguide.html + - JSON: https://google.github.io/styleguide/jsoncstyleguide.xml + - Markdown: https://google.github.io/styleguide/docguide/style.html + +### Automated actions + +The current code base has been able to achieve 0 code lint issues. To maintain this, all lint checks are enforced on pull requests to dev and main. Please ensure that these lint checks are passing before marking your pull requests as 'Ready for review'. \ No newline at end of file diff --git a/docs/network/README.md b/docs/network/README.md index b5536c30c..0f97ecd7b 100644 --- a/docs/network/README.md +++ b/docs/network/README.md @@ -1,10 +1,9 @@ Testrun logo - ## Network Overview ## Table of Contents -1) Network Overview (this page) +1) Network overview (this page) 2) [How to identify network interfaces](identify_interfaces.md) 3) [Addresses](addresses.md) 4) [Add a new network service](add_new_service.md) diff --git a/docs/network/add_new_service.md b/docs/network/add_new_service.md index 7a07e43be..b3fa22514 100644 --- a/docs/network/add_new_service.md +++ b/docs/network/add_new_service.md @@ -65,7 +65,7 @@ COPY $MODULE_DIR/bin /testrun/bin # Copy over all python files COPY $MODULE_DIR/python /testrun/python -# Do not specify a CMD or Entrypoint as Test Run will automatically start your service as required +# Do not specify a CMD or Entrypoint as Testrun will automatically start your service as required ``` ### Example of start_network_service script diff --git a/docs/test/README.md b/docs/test/README.md index 19aa691d8..3163b4c84 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -2,7 +2,6 @@ ## Testing - The test requirements that are investigated by Testrun can be found in the [test modules documentation](/docs/test/modules.md). To understand the testing results, various definitions of test results and requirements are specified in the [statuses documentation](/docs/test/statuses.md). \ No newline at end of file diff --git a/docs/test/modules.md b/docs/test/modules.md index 7c5851ba4..2fe5983b1 100644 --- a/docs/test/modules.md +++ b/docs/test/modules.md @@ -10,7 +10,7 @@ Testrun provides some pre-built test modules for you to use when testing your ow | Baseline | A sample test module | [Baseline module](/modules/test/baseline/README.md) | | Connection | Verify IP and DHCP based behavior | [Connection module](/modules/test/conn/README.md) | | DNS | Verify DNS functionality | [DNS module](/modules/test/dns/README.md) | -| NMAP | Ensure unsecure services are disabled | [NMAP module](/modules/test/nmap/README.md) | +| Services | Ensure unsecure services are disabled | [Services module](/modules/test/services/README.md) | | NTP | Verify NTP functionality | [NTP module](/modules/test/ntp/README.md) | | Protocol | Inspect BMS protocol implementation | [Protocol Module](/modules/test/protocol/README.md) | | TLS | Determine TLS client and server behavior | [TLS module](/modules/test/tls/README.md) | diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index aed663ab8..e8e87465d 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -26,7 +26,7 @@ import uvicorn from urllib.parse import urlparse -from common import logger +from common import logger, tasks from common.device import Device LOGGER = logger.get_logger("api") @@ -114,7 +114,10 @@ def __init__(self, test_run): # Allow all origins to access the API origins = ["*"] - self._app = FastAPI() + # Scheduler for background periodic tasks + self._scheduler = tasks.PeriodicTasks(self._test_run) + + self._app = FastAPI(lifespan=self._scheduler.start) self._app.include_router(self._router) self._app.add_middleware( CORSMiddleware, @@ -165,7 +168,19 @@ async def post_sys_config(self, request: Request, response: Response): try: config = (await request.body()).decode("UTF-8") config_json = json.loads(config) + + # Validate req fields + if ("network" not in config_json or + "device_intf" not in config_json.get("network") or + "internet_intf" not in config_json.get("network") or + "log_level" not in config_json): + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg( + False, + "Configuration is missing required fields") + self._session.set_config(config_json) + # Catch JSON Decode error etc except JSONDecodeError: response.status_code = status.HTTP_400_BAD_REQUEST @@ -231,7 +246,15 @@ async def start_test_run(self, request: Request, response: Response): False, "Configured interfaces are not " + "ready for use. Ensure required interfaces " + "are connected.") - device.test_modules = body_json["device"]["test_modules"] + # UI doesn't send individual test configs so we need to + # merge these manually until the UI is updated to handle + # the full config file + for module_name, module_config in device.test_modules.items(): + # Check if the module exists in UI test modules + if module_name in body_json["device"]["test_modules"]: + # Merge the enabled state + module_config["enabled"] = body_json[ + "device"]["test_modules"][module_name]["enabled"] LOGGER.info("Starting Testrun with device target " + f"{device.manufacturer} {device.model} with " + @@ -464,6 +487,19 @@ async def save_device(self, request: Request, response: Response): device_json.get(DEVICE_MODEL_KEY) ) + # Check if device folder exists + device_folder = os.path.join(self._test_run.get_root_dir(), + DEVICES_PATH, + device_json.get(DEVICE_MANUFACTURER_KEY) + + " " + + device_json.get(DEVICE_MODEL_KEY)) + + if os.path.exists(device_folder): + response.status_code = status.HTTP_409_CONFLICT + return self._generate_msg( + False, "A folder with that name already exists, " \ + "please rename the device or folder") + if device is None: # Create new device @@ -679,6 +715,11 @@ async def update_profile(self, request: Request, response: Response): response.status_code = status.HTTP_400_BAD_REQUEST return self._generate_msg(False, "Invalid request received") + # Validate json profile + if not self.get_session().validate_profile_json(req_json): + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid request received") + profile_name = req_json.get("name") # Check if profile exists diff --git a/framework/python/src/common/mqtt.py b/framework/python/src/common/mqtt.py new file mode 100644 index 000000000..c58d24d3f --- /dev/null +++ b/framework/python/src/common/mqtt.py @@ -0,0 +1,62 @@ +# Copyright 2024 Google LLC +# +# 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 +# +# https://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. + +"""MQTT client""" +import json +import typing as t +import paho.mqtt.client as mqtt_client +from common import logger + +LOGGER = logger.get_logger("mqtt") +WEBSOCKETS_HOST = "localhost" +WEBSOCKETS_PORT = 1883 + +class MQTTException(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class MQTT: + """ MQTT client class + """ + def __init__(self) -> None: + self._host = WEBSOCKETS_HOST + self._client = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2) + LOGGER.setLevel(logger.logging.INFO) + self._client.enable_logger(LOGGER) + + def _connect(self): + """Establish connection to Mosquitto server + + Raises: + MQTTException: Raises exception on connection error + """ + if not self._client.is_connected(): + try: + self._client.connect(self._host, WEBSOCKETS_PORT, 60) + except (ValueError, ConnectionRefusedError) as e: + LOGGER.error("Can't connect to host") + raise MQTTException("Connection to the Mosquitto server failed") from e + + def send_message(self, topic: str, message: t.Union[str, dict]) -> None: + """Send message to specific topic + + Args: + topic (str): mqtt topic + message (t.Union[str, dict]): message + """ + self._connect() + if isinstance(message, dict): + message = json.dumps(message) + self._client.publish(topic, str(message)) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index 6afb229ac..f50dffdde 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -96,11 +96,11 @@ def get_file_path(self): self.name + '.json') def _validate(self, profile_json, profile_format): - if self._valid(profile_json, profile_format): - if self._expired(): - self.status = 'Expired' + if self._expired(): + self.status = 'Expired' + elif self._valid(profile_json, profile_format): # User only wants to save a draft - elif 'status' in profile_json and profile_json['status'] == 'Draft': + if 'status' in profile_json and profile_json['status'] == 'Draft': self.status = 'Draft' else: self.status = 'Valid' @@ -409,6 +409,14 @@ def _generate_risk_questions(self): content += '' + # Question risk label + if 'risk' in question: + if question['risk'] == 'High': + content += '
HIGH RISK
' + elif question['risk'] == 'Limited': + content += '''
+ LIMITED RISK
''' + content += '''''' index += 1 @@ -635,6 +643,33 @@ def _generate_css(self): ul { margin-top: 0; } + + .risk-label{ + position: absolute; + top: 0px; + right: 0px; + width: 52px; + height: 16px; + font-family: 'Google Sans', sans-serif; + font-size: 8px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.64px; + text-align: center; + font-weight: bold; + border-radius: 3px; + } + + .risk-label-high{ + background-color: #FCE8E6; + color: #C5221F; + } + + .risk-label-limited{ + width: 65px; + background-color:#E4F7FB; + color: #007B83; + } ''' def to_pdf(self, device): diff --git a/framework/python/src/common/session.py b/framework/python/src/common/session.py index f555a9732..940fbe8f0 100644 --- a/framework/python/src/common/session.py +++ b/framework/python/src/common/session.py @@ -17,8 +17,10 @@ import pytz import json import os -from common import util, logger +from fastapi.encoders import jsonable_encoder +from common import util, logger, mqtt from common.risk_profile import RiskProfile +from net_orc.ip_control import IPControl # Certificate dependencies from cryptography import x509 @@ -36,7 +38,7 @@ MAX_DEVICE_REPORTS_KEY = 'max_device_reports' CERTS_PATH = 'local/root_certs' CONFIG_FILE_PATH = 'local/system.json' -SECONDS_IN_YEAR = 31536000 +STATUS_TOPIC = 'status' PROFILE_FORMAT_PATH = 'resources/risk_assessment.json' PROFILES_DIR = 'local/risk_profiles' @@ -44,8 +46,36 @@ LOGGER = logger.get_logger('session') +def session_tracker(method): + """Session changes tracker.""" + def wrapper(self, *args, **kwargs): + + result = method(self, *args, **kwargs) + + if self.get_status() != 'Idle': + self.get_mqtt_client().send_message( + STATUS_TOPIC, + jsonable_encoder(self.to_json()) + ) + + return result + return wrapper + +def apply_session_tracker(cls): + """Applies tracker decorator to class methods""" + for attr in dir(cls): + if (callable(getattr(cls, attr)) + and not attr.startswith('_') + and not attr.startswith('get') + and not attr == 'to_json' + ): + setattr(cls, attr, session_tracker(getattr(cls, attr))) + return cls + + +@apply_session_tracker class TestrunSession(): - """Represents the current session of Test Run.""" + """Represents the current session of Testrun.""" def __init__(self, root_dir): self._root_dir = root_dir @@ -93,6 +123,8 @@ def __init__(self, root_dir): self._config_file = os.path.join(root_dir, CONFIG_FILE_PATH) self._config = self._get_default_config() + # System network interfaces + self._ifaces = {} # Loading methods self._load_version() self._load_config() @@ -107,6 +139,9 @@ def __init__(self, root_dir): self._timezone = tz[0] LOGGER.debug(f'System timezone is {self._timezone}') + # MQTT client + self._mqtt_client = mqtt.MQTT() + def start(self): self.reset() self._status = 'Waiting for Device' @@ -332,6 +367,12 @@ def add_test_result(self, result): result.result = 'In Progress' self._results.append(result) + def set_test_result_error(self, result): + """Set test result error""" + result.result = 'Error' + result.recommendations = None + self._results.append(result) + def add_module_report(self, module_report): self._module_reports.append(module_report) @@ -399,17 +440,31 @@ def _load_profiles(self): try: for risk_profile_file in os.listdir( os.path.join(self._root_dir, PROFILES_DIR)): + LOGGER.debug(f'Discovered profile {risk_profile_file}') + # Open the risk profile file with open(os.path.join(self._root_dir, PROFILES_DIR, risk_profile_file), encoding='utf-8') as f: + + # Parse risk profile json json_data = json.load(f) + + # Validate profile JSON + if not self.validate_profile_json(json_data): + LOGGER.error('Profile failed validation') + continue + + # Instantiate a new risk profile risk_profile = RiskProfile() + + # Pass JSON to populate risk profile risk_profile.load( profile_json=json_data, profile_format=self._profile_format ) - risk_profile.status = self.check_profile_status(risk_profile) + + # Add risk profile to session self._profiles.append(risk_profile) except Exception as e: @@ -428,25 +483,6 @@ def get_profile(self, name): return profile return None - def validate_profile(self, profile_json): - - # Check name field is present - if 'name' not in profile_json: - return False - - # Check questions field is present - if 'questions' not in profile_json: - return False - - # Check all questions are present - for format_q in self.get_profiles_format(): - if self._get_profile_question(profile_json, - format_q.get('question')) is None: - LOGGER.error('Missing question: ' + format_q.get('question')) - return False - - return True - def _get_profile_question(self, profile_json, question): for q in profile_json.get('questions'): @@ -455,7 +491,14 @@ def _get_profile_question(self, profile_json, question): return None + def get_profile_format_question(self, question): + for q in self.get_profiles_format(): + if q.get('question') == question: + return q + def update_profile(self, profile_json): + """Update the risk profile with the provided JSON. + The content has already been validated in the API""" profile_name = profile_json['name'] @@ -463,39 +506,8 @@ def update_profile(self, profile_json): profile_json['version'] = self.get_version() profile_json['created'] = datetime.datetime.now().strftime('%Y-%m-%d') - if 'status' in profile_json and profile_json.get('status') == 'Valid': - # Attempting to submit a risk profile, we need to check it - - # Check all questions have been answered - all_questions_answered = True - - for question in self.get_profiles_format(): - - # Check question is present - profile_question = self._get_profile_question(profile_json, - question.get('question')) - - if profile_question is not None: - - # Check answer is present - if 'answer' not in profile_question: - LOGGER.error('Missing answer for question: ' + - question.get('question')) - all_questions_answered = False - - else: - LOGGER.error('Missing question: ' + question.get('question')) - all_questions_answered = False - - if not all_questions_answered: - LOGGER.error('Not all questions answered') - return None - - else: - profile_json['status'] = 'Draft' - + # Check if profile already exists risk_profile = self.get_profile(profile_name) - if risk_profile is None: # Create a new risk profile @@ -524,19 +536,105 @@ def update_profile(self, profile_json): return risk_profile - def check_profile_status(self, profile): + def validate_profile_json(self, profile_json): + """Validate properties in profile update requests""" + + # Get the status field + valid = False + if 'status' in profile_json and profile_json.get('status') == 'Valid': + valid = True + + # Check if 'name' exists in profile + if 'name' not in profile_json: + LOGGER.error('Missing "name" in profile') + return False + + # Check if 'name' field not empty + elif len(profile_json.get('name').strip()) == 0: + LOGGER.error('Name field left empty') + return False + + # Error handling if 'questions' not in request + if 'questions' not in profile_json and valid: + LOGGER.error('Missing "questions" field in profile') + return False + + # Validating the questions section + for question in profile_json.get('questions'): + + # Check if the question field is present + if 'question' not in question: + LOGGER.error('The "question" field is missing') + return False + + # Check if 'question' field not empty + elif len(question.get('question').strip()) == 0: + LOGGER.error('A question is missing from "question" field') + return False + + # Check if question is a recognized question + format_q = self.get_profile_format_question( + question.get('question')) + + if format_q is None: + LOGGER.error(f'Unrecognized question: {question.get("question")}') + return False + + # Error handling if 'answer' is missing + if 'answer' not in question and valid: + LOGGER.error('The answer field is missing') + return False + + # If answer is present, check the validation rules + else: + + # Extract the answer out of the profile + answer = question.get('answer') + + # Get the validation rules + field_type = format_q.get('type') - if profile.status == 'Valid': + # Check if type is string or single select, answer should be a string + if ((field_type in ['string', 'select']) + and not isinstance(answer, str)): + LOGGER.error(f'''Answer for question \ +{question.get('question')} is incorrect data type''') + return False - # Check expiry - created_date = profile.created.timestamp() + # Check if type is select, answer must be from list + if field_type == 'select' and valid: + possible_answers = format_q.get('options') + if answer not in possible_answers: + LOGGER.error(f'''Answer for question \ +{question.get('question')} is not valid''') + return False - today = datetime.datetime.now().timestamp() + # Validate select multiple field types + if field_type == 'select-multiple': - if created_date < (today - SECONDS_IN_YEAR): - profile.status = 'Expired' + if not isinstance(answer, list): + LOGGER.error(f'''Answer for question \ +{question.get('question')} is incorrect data type''') + return False - return profile.status + question_options_len = len(format_q.get('options')) + + # We know it is a list, now check the indexes + for index in answer: + + # Check if the index is an integer + if not isinstance(index, int): + LOGGER.error(f'''Answer for question \ +{question.get('question')} is incorrect data type''') + return False + + # Check if index is 0 or above and less than the num of options + if index < 0 or index >= question_options_len: + LOGGER.error(f'''Invalid index provided as answer for \ +question {question.get('question')}''') + return False + + return True def delete_profile(self, profile): @@ -565,6 +663,7 @@ def reset(self): self._results = [] self._started = None self._finished = None + self._ifaces = IPControl.get_sys_interfaces() def to_json(self): @@ -650,6 +749,11 @@ def load_certs(self): self._certs = [] for cert_file in os.listdir(CERTS_PATH): + + # Ignore directories + if os.path.isdir(os.path.join(CERTS_PATH, cert_file)): + continue + LOGGER.debug(f'Loading certificate {cert_file}') try: @@ -712,3 +816,25 @@ def delete_cert(self, filename): def get_certs(self): return self._certs + + def detect_network_adapters_change(self) -> dict: + adapters = {} + ifaces_new = IPControl.get_sys_interfaces() + + # Difference between stored and newly received network interfaces + diff = util.diff_dicts(self._ifaces, ifaces_new) + if diff: + if 'items_added' in diff: + adapters['adapters_added'] = diff['items_added'] + if 'items_removed' in diff: + adapters['adapters_removed'] = diff['items_removed'] + # Save new network interfaces to session + LOGGER.debug(f'Network adapters change detected: {adapters}') + self._ifaces = ifaces_new + return adapters + + def get_mqtt_client(self): + return self._mqtt_client + + def get_ifaces(self): + return self._ifaces diff --git a/framework/python/src/common/tasks.py b/framework/python/src/common/tasks.py new file mode 100644 index 000000000..5da0b40c9 --- /dev/null +++ b/framework/python/src/common/tasks.py @@ -0,0 +1,78 @@ +# Copyright 2024 Google LLC +# +# 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 +# +# https://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. +"""Periodic background tasks""" + +from contextlib import asynccontextmanager +import datetime +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI + +from common import logger + +# Check adapters period seconds +# Check adapters period seconds +CHECK_NETWORK_ADAPTERS_PERIOD = 5 +CHECK_INTERNET_PERIOD = 2 +INTERNET_CONNECTION_TOPIC = 'events/internet' +NETWORK_ADAPTERS_TOPIC = 'events/adapter' + +LOGGER = logger.get_logger('tasks') + + +class PeriodicTasks: + """Background periodic tasks + """ + def __init__( + self, testrun_obj, + ) -> None: + self._testrun = testrun_obj + self._mqtt_client = self._testrun.get_mqtt_client() + local_tz = datetime.datetime.now().astimezone().tzinfo + self._scheduler = AsyncIOScheduler(timezone=local_tz) + # Prevent scheduler warnings + self._scheduler._logger.setLevel(logging.ERROR) + + self.adapters_checker_job = self._scheduler.add_job( + func=self._testrun.get_net_orc().network_adapters_checker, + kwargs={ + 'mqtt_client': self._mqtt_client, + 'topic': NETWORK_ADAPTERS_TOPIC + }, + trigger='interval', + seconds=CHECK_NETWORK_ADAPTERS_PERIOD, + ) + # add internet connection cheking job only in single-intf mode + if 'single_intf' not in self._testrun.get_session().get_runtime_params(): + self.internet_shecker = self._scheduler.add_job( + func=self._testrun.get_net_orc().internet_conn_checker, + kwargs={ + 'mqtt_client': self._mqtt_client, + 'topic': INTERNET_CONNECTION_TOPIC + }, + trigger='interval', + seconds=CHECK_INTERNET_PERIOD, + ) + + @asynccontextmanager + async def start(self, app: FastAPI): # pylint: disable=unused-argument + """Start background tasks + + Args: + app (FastAPI): app instance + """ + # Job that checks for changes in network adapters + self._scheduler.start() + yield diff --git a/framework/python/src/common/util.py b/framework/python/src/common/util.py index 096aaf4df..7c31631fb 100644 --- a/framework/python/src/common/util.py +++ b/framework/python/src/common/util.py @@ -17,13 +17,14 @@ import os import subprocess import shlex -from common import logger +import typing as t import netifaces +from common import logger LOGGER = logger.get_logger('util') -def run_command(cmd, output=True): +def run_command(cmd, output=True, timeout=None): """Runs a process at the os level By default, returns the standard output and error output If the caller sets optional output parameter to False, @@ -35,7 +36,7 @@ def run_command(cmd, output=True): with subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: - stdout, stderr = process.communicate() + stdout, stderr = process.communicate(timeout) if process.returncode != 0 and output: err_msg = f'{stderr.strip()}. Code: {process.returncode}' @@ -113,3 +114,32 @@ def get_module_display_name(search): return module[1] return 'Unknown' + + +def diff_dicts(d1: t.Dict[t.Any, t.Any], d2: t.Dict[t.Any, t.Any]) -> t.Dict: + """Compares two dictionaries by keys + + Args: + d1 (t.Dict[t.Any, t.Any]): first dict to compare + d2 (t.Dict[t.Any, t.Any]): second dict to compare + + Returns: + t.Dict[t.Any, t.Any]: Returns an empty dictionary + if the compared dictionaries are equal, + otherwise returns a dictionary that contains + the removed items(if available) + and the added items(if available). + """ + diff = {} + if d1 != d2: + s1 = set(d1) + s2 = set(d2) + keys_removed = s1 - s2 + keys_added = s2 - s1 + items_removed = {k:d1[k] for k in keys_removed} + items_added = {k:d2[k] for k in keys_added} + if items_removed: + diff['items_removed'] = items_removed + if items_added: + diff['items_added'] = items_added + return diff diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 5b43cfd65..dccde6a35 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -15,7 +15,7 @@ """The overall control of the Test Run application. This file provides the integration between all of the -Test Run components, such as net_orc, test_orc and test_ui. +Testrun components, such as net_orc, test_orc and test_ui. Run using the provided command scripts in the cmd folder. E.g sudo cmd/start @@ -27,7 +27,7 @@ import signal import sys import time -from common import logger, util +from common import logger, util, mqtt from common.device import Device from common.session import TestrunSession from common.testreport import TestReport @@ -81,7 +81,9 @@ def __init__(self, self._net_only = net_only self._single_intf = single_intf - self._no_ui = no_ui + # Network only option only works if UI is also + # disbled so need to set no_ui if net_only is selected + self._no_ui = no_ui or net_only # Catch any exit signals self._register_exits() @@ -109,6 +111,12 @@ def __init__(self, # Load test modules self._test_orc.start() + # Start websockets server + self.start_ws() + + # Init MQTT client + self._mqtt_client = mqtt.MQTT() + if self._no_ui: # Check Testrun is able to start @@ -216,7 +224,14 @@ def _load_test_reports(self, device): 'test', device.mac_addr.replace(':',''), 'report.json') - + + if not os.path.isfile(report_json_file_path): + # Revert to pre 1.3 file path + report_json_file_path = os.path.join( + reports_folder, + report_folder, + 'report.json') + if not os.path.isfile(report_json_file_path): # Revert to pre 1.3 file path report_json_file_path = os.path.join( @@ -369,6 +384,7 @@ def shutdown(self): LOGGER.info('Shutting down Testrun') self.stop() self._stop_ui() + self._stop_ws() def _exit_handler(self, signum, arg): # pylint: disable=unused-argument LOGGER.debug('Exit signal received: ' + str(signum)) @@ -385,6 +401,9 @@ def _get_config_abs(self, config_file=None): # Expand the config file to absolute pathing return os.path.abspath(config_file) + def get_root_dir(self): + return root_dir + def get_config_file(self): return self._get_config_abs() @@ -406,6 +425,9 @@ def _stop_network(self, kill=True): def _stop_tests(self): self._test_orc.stop() + def get_mqtt_client(self): + return self._mqtt_client + def get_device(self, mac_addr): """Returns a loaded device object from the device mac address.""" for device in self.get_session().get_device_repository(): @@ -463,7 +485,7 @@ def start_ui(self): try: client.containers.run( - image='test-run/ui', + image='testrun/ui', auto_remove=True, name='tr-ui', hostname='testrun.io', @@ -489,4 +511,40 @@ def _stop_ui(self): if container is not None: container.kill() except docker.errors.NotFound: - return + pass + + + def start_ws(self): + + self._stop_ws() + + LOGGER.info('Starting WS server') + + client = docker.from_env() + + try: + client.containers.run( + image='testrun/ws', + auto_remove=True, + name='tr-ws', + detach=True, + ports={ + '9001': 9001, + '1883': 1883 + } + ) + except ImageNotFound as ie: + LOGGER.error('An error occured whilst starting the websockets server. ' + + 'Please investigate and try again.') + LOGGER.error(ie) + sys.exit(1) + + def _stop_ws(self): + LOGGER.info('Stopping websockets server') + client = docker.from_env() + try: + container = client.containers.get('tr-ws') + if container is not None: + container.kill() + except docker.errors.NotFound: + pass diff --git a/framework/python/src/net_orc/ip_control.py b/framework/python/src/net_orc/ip_control.py index 506b23a95..04686f0cd 100644 --- a/framework/python/src/net_orc/ip_control.py +++ b/framework/python/src/net_orc/ip_control.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. """IP Control Module""" +import psutil +import typing as t from common import logger from common import util import re @@ -43,10 +45,7 @@ def add_namespace(self, namespace): def check_interface_status(self, interface_name): output = util.run_command(cmd=f'ip link show {interface_name}', output=True) - if 'state DOWN ' in output[0]: - return False - else: - return True + return 'state UP ' in output[0] def delete_link(self, interface_name): """Delete an ip link""" @@ -99,7 +98,7 @@ def get_iface_port_stats(self, iface): def get_namespaces(self): result = util.run_command('ip netns list') - #Strip ID's from the namespace results + # Strip ID's from the namespace results namespaces = re.findall(r'(\S+)(?:\s+\(id: \d+\))?', result[0]) return namespaces @@ -237,3 +236,30 @@ def configure_container_interface(self, LOGGER.error(f'Failed to set interface up {namespace_intf}') return False return True + + def ping_via_gateway(self, host): + """Ping the host trough the gateway container""" + command = f'timeout 3 docker exec tr-ct-gateway ping -W 1 -c 1 {host}' + output = util.run_command(command) + if '0% packet loss' in output[0]: + return True + return False + + @staticmethod + def get_sys_interfaces() -> t.Dict[str, t.Dict[str, str]]: + """ Retrieves all Ethernet network interfaces from the host system + Returns: + t.Dict[str, str] + """ + addrs = psutil.net_if_addrs() + ifaces = {} + + for key in addrs: + nic = addrs[key] + # Ignore any interfaces that are not ethernet + if not (key.startswith('en') or key.startswith('eth')): + continue + + ifaces[key] = nic[0].address + + return ifaces diff --git a/framework/python/src/net_orc/network_orchestrator.py b/framework/python/src/net_orc/network_orchestrator.py index f20093a28..a94bca89b 100644 --- a/framework/python/src/net_orc/network_orchestrator.py +++ b/framework/python/src/net_orc/network_orchestrator.py @@ -22,8 +22,9 @@ import sys import docker import time +import traceback from docker.types import Mount -from common import logger, util +from common import logger, util, mqtt from net_orc.listener import Listener from net_orc.network_event import NetworkEvent from net_orc.network_validator import NetworkValidator @@ -223,7 +224,9 @@ def _device_discovered(self, mac_addr): #self._ovs.add_arp_inspection_filter(ip_address=device.ip_addr, # mac_address=device.mac_addr) - self._start_device_monitor(device) + # Don't monitor devices when in network only mode + if 'net_only' not in self._session.get_runtime_params(): + self._start_device_monitor(device) def _get_conn_stats(self): """ Extract information about the physical connection @@ -547,10 +550,6 @@ def _start_network_service(self, net_module): cap_add=['NET_ADMIN'], name=net_module.container_name, hostname=net_module.container_name, - # Undetermined version of docker seems to have broken - # DNS configuration (/etc/resolv.conf) Re-add when/if - # this network is utilized and DNS issue is resolved - #network=PRIVATE_DOCKER_NET, network_mode='none', privileged=True, detach=True, @@ -786,6 +785,48 @@ def restore_net(self): def get_session(self): return self._session + def network_adapters_checker(self, mqtt_client: mqtt.MQTT, topic: str): + """Checks for changes in network adapters + and sends a message to the frontend + """ + try: + adapters = self._session.detect_network_adapters_change() + if adapters: + mqtt_client.send_message(topic, adapters) + except Exception: + LOGGER.error(traceback.format_exc()) + + def is_device_connected(self): + """Check if device connected""" + return self._ip_ctrl.check_interface_status( + self._session.get_device_interface() + ) + + def internet_conn_checker(self, mqtt_client: mqtt.MQTT, topic: str): + """Checks internet connection and sends a status to frontend""" + + # Only check if Testrun is running not in single-intf mode + if (self.get_session().get_status() in [ + 'Waiting for Device', + 'Monitoring', + 'In Progress' + ]): + # Default message + message = {'connection': False} + iface = self._session.get_internet_interface() + + # Check that an internet intf has been selected + if iface and iface in self._ip_ctrl.get_sys_interfaces(): + + # Ping google.com from gateway container + internet_connection = self._ip_ctrl.ping_via_gateway( + 'google.com') + + if internet_connection: + message['connection'] = True + + # Broadcast via MQTT client + mqtt_client.send_message(topic, message) class NetworkModule: """Define all the properties of a Network Module""" diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index d38f888a1..a38371d07 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -60,6 +60,8 @@ def __init__(self, session, net_orc): os.path.dirname( os.path.dirname( os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))) + self._test_modules_running = [] + self._current_module = 0 def start(self): LOGGER.debug("Starting test orchestrator") @@ -102,7 +104,13 @@ def run_test_modules(self): test_modules.append(module) self.get_session().add_total_tests(len(module.tests)) - for module in test_modules: + # Store enabled test modules in the TestsOrchectrator object + self._test_modules_running = test_modules + self._current_module = 0 + + for index, module in enumerate(test_modules): + + self._current_module = index self._run_test_module(module) LOGGER.info("All tests complete") @@ -362,7 +370,14 @@ def _run_test_module(self, module): LOGGER.info(f"Running test module {module.name}") # Get all tests to be executed and set to in progress - for test in module.tests: + for current_test,test in enumerate(module.tests): + + # Check that device is connected + if not self._net_orc.is_device_connected(): + LOGGER.error("Device was disconnected") + self._set_test_modules_error(current_test) + self._session.set_status("Cancelled") + return test_copy = copy.deepcopy(test) test_copy.result = "In Progress" @@ -486,19 +501,25 @@ def _run_test_module(self, module): try: with open(results_file, "r", encoding="utf-8-sig") as f: + + # Load results from JSON file module_results_json = json.load(f) module_results = module_results_json["results"] for test_result in module_results: - # Convert dict into TestCase object + # Convert dict from json into TestCase object test_case = TestCase( name=test_result["name"], description=test_result["description"], expected_behavior=test_result["expected_behavior"], required_result=test_result["required_result"], result=test_result["result"]) - test_case.result=test_result["result"] + # Any informational test should always report informational + if test_case.required_result == "Informational": + test_case.result = "Informational" + + # Add steps to resolve if test is non-compliant if (test_case.result == "Non-Compliant" and "recommendations" in test_result): test_case.recommendations = test_result["recommendations"] @@ -729,3 +750,13 @@ def get_test_case(self, name): def get_session(self): return self._session + + def _set_test_modules_error(self, current_test): + """Set all remaining tests to error""" + for i in range(self._current_module, len(self._test_modules_running)): + start_idx = current_test if i == self._current_module else 0 + for j in range(start_idx, len(self._test_modules_running[i].tests)): + self.get_session().set_test_result_error( + self._test_modules_running[i].tests[j] + ) + diff --git a/framework/requirements.txt b/framework/requirements.txt index c31978d99..0484905ee 100644 --- a/framework/requirements.txt +++ b/framework/requirements.txt @@ -1,8 +1,8 @@ # Requirements for the core module -requests<2.32.0 +requests==2.32.3 # Requirements for the net_orc module -docker==7.0.0 +docker==7.1.0 ipaddress==1.0.23 netifaces==0.11.0 scapy==2.5.0 @@ -21,6 +21,8 @@ pydantic==2.7.1 # Requirements for testing pytest==7.4.4 pytest-timeout==2.2.0 +responses==0.25.3 + # Requirements for the report markdown==3.5.2 @@ -31,3 +33,9 @@ pytz==2024.1 # Requirements for the risk profile python-dateutil==2.9.0 + +# Requirements for MQTT client +paho-mqtt==2.1.0 + +# Requirements for background tasks +APScheduler==3.10.4 diff --git a/make/DEBIAN/control b/make/DEBIAN/control index 488f69458..cecad9d17 100644 --- a/make/DEBIAN/control +++ b/make/DEBIAN/control @@ -1,5 +1,5 @@ Package: Testrun -Version: 1.3.1 +Version: 1.4-a Architecture: amd64 Maintainer: Google Homepage: https://github.com/google/testrun diff --git a/modules/test/base/README.md b/modules/test/base/README.md index e7f05d80e..24a725607 100644 --- a/modules/test/base/README.md +++ b/modules/test/base/README.md @@ -14,6 +14,13 @@ The ```config/module_config.json``` provides the name and description of the mod Within the ```python/src``` directory, basic logging and environment variables are provided to the test module. +Within the ```usr/local/etc``` directory there is a local copy of the MAC OUI database. This is just in case a new copy is unable to be downloaded during the install or update process. + +## GRPC server +Within the python directory, GRPC client code is provided to allow test modules to programmatically modify the various network services provided by Testrun. + +These currently include obtaining information about and controlling the DHCP servers in failover configuration. + ## Tests covered No tests are run by this module \ No newline at end of file diff --git a/modules/test/base/bin/setup b/modules/test/base/bin/setup new file mode 100644 index 000000000..23c96c513 --- /dev/null +++ b/modules/test/base/bin/setup @@ -0,0 +1,71 @@ +#!/bin/bash + +# Copyright 2023 Google LLC +# +# 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 +# +# https://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. + +# Define the local mount point to store local files to +export OUTPUT_DIR="/runtime/output" + +# Directory where all binaries will be loaded +export BIN_DIR="/testrun/bin" + +# Default interface should be veth0 for all containers +export IFACE=veth0 + +# Create a local user that matches the same as the host +# to be used for correct file ownership for various logs +# HOST_USER mapped in via docker container environemnt variables +useradd $HOST_USER + +# Set permissions on the output files +chown -R $HOST_USER $OUTPUT_DIR + +# Enable IPv6 for all containers +sysctl net.ipv6.conf.all.disable_ipv6=0 +sysctl -p + +# Read in the config file +CONF_FILE="/testrun/conf/module_config.json" +CONF=`cat $CONF_FILE` + +if [[ -z $CONF ]] +then + echo "No config file present at $CONF_FILE. Exiting startup." + exit 1 +fi + +# Extract the necessary config parameters +export MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') +export NETWORK_REQUIRED=$(echo "$CONF" | jq -r '.config.network') +export GRPC=$(echo "$CONF" | jq -r '.config.grpc') + +# Validate the module name is present +if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] +then + echo "No module name present in $CONF_FILE. Exiting startup." + exit 1 +fi + +# Setup the PYTHONPATH so all imports work as expected +echo "Setting up PYTHONPATH..." +export PYTHONPATH=$($BIN_DIR/setup_python_path) +echo "PYTHONPATH: $PYTHONPATH" + +echo "Configuring binary files..." +$BIN_DIR/setup_binaries $BIN_DIR + +# Build all gRPC files from the proto for use in +# gRPC clients for communications to network modules +echo "Building gRPC files from available proto files..." +$BIN_DIR/setup_grpc_clients \ No newline at end of file diff --git a/modules/test/base/bin/start b/modules/test/base/bin/start index 37902b868..d1f29989f 100755 --- a/modules/test/base/bin/start +++ b/modules/test/base/bin/start @@ -14,4 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -/testrun/bin/start_module \ No newline at end of file +# Allow one argument which is the unit test file to run +# instead of running the test module +UNIT_TEST_FILE=$1 + +source /testrun/bin/setup + +# Conditionally run start_module based on RUN +if [[ -z "$UNIT_TEST_FILE" ]];then + /testrun/bin/start_module +else + python3 $UNIT_TEST_FILE +fi diff --git a/modules/test/base/bin/start_module b/modules/test/base/bin/start_module index 0ee68fa6a..fb79cc018 100644 --- a/modules/test/base/bin/start_module +++ b/modules/test/base/bin/start_module @@ -1,102 +1,46 @@ -#!/bin/bash - -# Copyright 2023 Google LLC -# -# 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 -# -# https://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. - -# Define the local mount point to store local files to -OUTPUT_DIR="/runtime/output" - -# Directory where all binaries will be loaded -BIN_DIR="/testrun/bin" - -# Default interface should be veth0 for all containers -IFACE=veth0 - -# Create a local user that matches the same as the host -# to be used for correct file ownership for various logs -# HOST_USER mapped in via docker container environemnt variables -useradd $HOST_USER - -# Set permissions on the output files -chown -R $HOST_USER $OUTPUT_DIR - -# Enable IPv6 for all containers -sysctl net.ipv6.conf.all.disable_ipv6=0 -sysctl -p - -# Read in the config file -CONF_FILE="/testrun/conf/module_config.json" -CONF=`cat $CONF_FILE` - -if [[ -z $CONF ]] -then - echo "No config file present at $CONF_FILE. Exiting startup." - exit 1 -fi - -# Extract the necessary config parameters -MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') -NETWORK_REQUIRED=$(echo "$CONF" | jq -r '.config.network') -GRPC=$(echo "$CONF" | jq -r '.config.grpc') - -# Validate the module name is present -if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] -then - echo "No module name present in $CONF_FILE. Exiting startup." - exit 1 -fi - -# Setup the PYTHONPATH so all imports work as expected -echo "Setting up PYTHONPATH..." -export PYTHONPATH=$($BIN_DIR/setup_python_path) -echo "PYTHONPATH: $PYTHONPATH" - -# Build all gRPC files from the proto for use in -# gRPC clients for communications to network modules -echo "Building gRPC files from available proto files..." -$BIN_DIR/setup_grpc_clients - -echo "Configuring binary files..." -$BIN_DIR/setup_binaries $BIN_DIR - -echo "Starting module $MODULE_NAME..." - -# Only start network services if the test container needs -# a network connection to run its tests -if [ $NETWORK_REQUIRED == "true" ];then - # Wait for interface to become ready - $BIN_DIR/wait_for_interface $IFACE - - # Start network capture - $BIN_DIR/capture $MODULE_NAME $IFACE -fi - -# Start the grpc server -if [[ ! -z $GRPC && ! $GRPC == "null" ]] -then - GRPC_PORT=$(echo "$GRPC" | jq -r '.port') - if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] - then - echo "gRPC port resolved from config: $GRPC_PORT" - $BIN_DIR/start_grpc "-p $GRPC_PORT" - else - $BIN_DIR/start_grpc - fi -fi - -# Small pause to let all core services stabalize -sleep 3 - -# Start the test module +#!/bin/bash + +# Copyright 2023 Google LLC +# +# 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 +# +# https://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. + +echo "Starting module $MODULE_NAME..." + +# Only start network services if the test container needs +# a network connection to run its tests +if [ $NETWORK_REQUIRED == "true" ];then + # Wait for interface to become ready + $BIN_DIR/wait_for_interface $IFACE + + # Start network capture + $BIN_DIR/capture $MODULE_NAME $IFACE +fi + +# Start the grpc server +if [[ ! -z $GRPC && ! $GRPC == "null" ]] +then + GRPC_PORT=$(echo "$GRPC" | jq -r '.port') + if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] + then + echo "gRPC port resolved from config: $GRPC_PORT" + $BIN_DIR/start_grpc "-p $GRPC_PORT" + else + $BIN_DIR/start_grpc + fi +fi + +# Small pause to let all core services stabalize +sleep 3 + +# Start the test module $BIN_DIR/start_test_module $MODULE_NAME $IFACE \ No newline at end of file diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index 00f74df82..deed0d978 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -17,6 +17,7 @@ import os import util from datetime import datetime +import traceback LOGGER = None RESULTS_DIR = '/runtime/output/' @@ -48,9 +49,9 @@ def __init__(self, def _add_logger(self, log_name, module_name, log_dir=None): global LOGGER - LOGGER = logger.get_logger(name=log_name, + LOGGER = logger.get_logger(name=log_name, # pylint: disable=E1123 log_file=module_name, - log_dir=log_dir) # pylint: disable=E1123 + log_dir=log_dir) def generate_module_report(self): pass @@ -113,11 +114,17 @@ def run_tests(self): except Exception as e: # pylint: disable=W0718 LOGGER.error(f'An error occurred whilst running {test["name"]}') LOGGER.error(e) + traceback.print_exc() else: LOGGER.info(f'Test {test["name"]} not implemented. Skipping') + test['result'] = 'Error' + test['description'] = 'This test could not be found' else: LOGGER.debug(f'Test {test["name"]} is disabled') + # To be added in v1.3.2 + # result = 'Disabled', 'This test is disabled and did not run' + if result is not None: # Compliant or non-compliant as a boolean only if isinstance(result, bool): @@ -182,7 +189,7 @@ def _write_results(self, results): def _get_device_ipv4(self): command = f"""/testrun/bin/get_ipv4_addr {self._ipv4_subnet} {self._device_mac.upper()}""" - text = util.run_command(command)[0] + text = util.run_command(command)[0] # pylint: disable=E1120 if text: return text.split('\n')[0] return None diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 5e8b78ec3..88dd40393 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -15,7 +15,7 @@ import util import time import traceback -from scapy.all import rdpcap, DHCP, ARP, Ether, IPv6, ICMPv6ND_NS +from scapy.all import rdpcap, DHCP, ARP, Ether, ICMP, IPv6, ICMPv6ND_NS from test_module import TestModule from dhcp1.client import Client as DHCPClient1 from dhcp2.client import Client as DHCPClient2 @@ -39,7 +39,14 @@ class ConnectionModule(TestModule): """Connection Test module""" - def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): + def __init__(self, + module, + log_dir=None, + conf_file=None, + results_dir=None, + startup_capture_file=STARTUP_CAPTURE_FILE, + monitor_capture_file=MONITOR_CAPTURE_FILE): + super().__init__(module_name=module, log_name=LOG_NAME, log_dir=log_dir, @@ -47,6 +54,8 @@ def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): results_dir=results_dir) global LOGGER LOGGER = self._get_logger() + self.startup_capture_file = startup_capture_file + self.monitor_capture_file = monitor_capture_file self._port_stats = PortStatsUtil(logger=LOGGER) self.dhcp1_client = DHCPClient1() self.dhcp2_client = DHCPClient2() @@ -106,7 +115,8 @@ def _connection_switch_arp_inspection(self): no_arp = True # Read all the pcap files - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) for packet in packets: # We are not interested in packets unless they are ARP packets @@ -123,12 +133,8 @@ def _connection_switch_arp_inspection(self): # Check MAC address matches IP address if (arp_packet.hwsrc == self._device_mac - and (arp_packet.psrc not in ( - self._device_ipv4_addr, - '0.0.0.0' - )) and not arp_packet.psrc.startswith( - '169.254' - )): + and (arp_packet.psrc not in (self._device_ipv4_addr, '0.0.0.0')) + and not arp_packet.psrc.startswith('169.254')): LOGGER.info(f'Bad ARP packet detected for MAC: {self._device_mac}') LOGGER.info(f'''ARP packet from IP {arp_packet.psrc} does not match {self._device_ipv4_addr}''') @@ -145,7 +151,8 @@ def _connection_switch_dhcp_snooping(self): disallowed_dhcp_types = [2, 4, 5, 6, 9, 10, 12, 13, 15, 17] # Read all the pcap files - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) for packet in packets: # We are not interested in packets unless they are DHCP packets @@ -158,6 +165,11 @@ def _connection_switch_dhcp_snooping(self): dhcp_type = self._get_dhcp_type(packet) if dhcp_type in disallowed_dhcp_types: + + # Check if packet is responding with port unreachable + if ICMP in packet and packet[ICMP].type == 3: + continue + return False, 'Device has sent disallowed DHCP message' return True, 'Device does not act as a DHCP server' @@ -220,7 +232,8 @@ def _connection_single_ip(self): return result, 'No MAC address found.' # Read all the pcap files containing DHCP packet information - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) # Extract MAC addresses from DHCP packets mac_addresses = set() @@ -394,8 +407,9 @@ def _connection_ipv6_slaac(self): return result def _has_slaac_addres(self): - packet_capture = (rdpcap(STARTUP_CAPTURE_FILE) + - rdpcap(MONITOR_CAPTURE_FILE) + rdpcap(DHCP_CAPTURE_FILE)) + packet_capture = (rdpcap(self.startup_capture_file) + + rdpcap(self.monitor_capture_file) + + rdpcap(DHCP_CAPTURE_FILE)) sends_ipv6 = False for packet_number, packet in enumerate(packet_capture, start=1): if IPv6 in packet and packet.src == self._device_mac: @@ -432,7 +446,7 @@ def _ping(self, host, ipv6=False): cmd += ' -6 ' if ipv6 else '' cmd += str(host) #cmd = 'ping -c 1 ' + str(host) - success = util.run_command(cmd, output=False) + success = util.run_command(cmd, output=False) # pylint: disable=E1120 return success def restore_failover_dhcp_server(self, subnet): diff --git a/modules/test/conn/python/src/dhcp_util.py b/modules/test/conn/python/src/dhcp_util.py index be5f0cac2..3654d0401 100644 --- a/modules/test/conn/python/src/dhcp_util.py +++ b/modules/test/conn/python/src/dhcp_util.py @@ -207,7 +207,7 @@ def is_lease_active(self, lease): def ping(self, host): cmd = 'ping -c 1 ' + str(host) - success = util.run_command(cmd, output=False) + success = util.run_command(cmd, output=False) # pylint: disable=E1120 return success def add_reserved_lease(self, diff --git a/modules/test/dns/README.md b/modules/test/dns/README.md index 13f0df5fd..79bce57f7 100644 --- a/modules/test/dns/README.md +++ b/modules/test/dns/README.md @@ -15,4 +15,5 @@ Within the ```python/src``` directory, the below tests are executed. | ID | Description | Expected behavior | Required result |---|---|---|---| | dns.network.hostname_resolution | Verifies that the device resolves hostnames | The device sends DNS requests | Required | -| dns.network.from_dhcp | Verifies that the device allows for a DNS server to be provided by the DHCP server | The device sends DNS requests to the DNS server provided by the DHCP server | Roadmap | \ No newline at end of file +| dns.network.from_dhcp | Verifies that the device allows for a DNS server to be provided by the DHCP server | The device sends DNS requests to the DNS server provided by the DHCP server | Roadmap | +| dns.mdns | Does the device has MDNS (or any kind of IP multicast) | Device may send MDNS requests | Informational | \ No newline at end of file diff --git a/modules/test/dns/conf/module_config.json b/modules/test/dns/conf/module_config.json index 13c9b3236..f048d5deb 100644 --- a/modules/test/dns/conf/module_config.json +++ b/modules/test/dns/conf/module_config.json @@ -31,6 +31,12 @@ "recommendations": [ "Install a DNS client that supports fetching DNS servers from DHCP options" ] + }, + { + "name": "dns.mdns", + "test_description": "Does the device has MDNS (or any kind of IP multicast)", + "expected_behavior": "Device may send MDNS requests", + "required_result": "Informational" } ] } diff --git a/modules/test/dns/python/src/dns_module.py b/modules/test/dns/python/src/dns_module.py index 607a026b5..c04e289d3 100644 --- a/modules/test/dns/python/src/dns_module.py +++ b/modules/test/dns/python/src/dns_module.py @@ -13,12 +13,13 @@ # limitations under the License. """DNS test module""" import subprocess -from scapy.all import rdpcap, DNS, IP +from scapy.all import rdpcap, DNS, IP, Ether from test_module import TestModule import os +from collections import Counter LOG_NAME = 'test_dns' -MODULE_REPORT_FILE_NAME='dns_report.html' +MODULE_REPORT_FILE_NAME = 'dns_report.html' DNS_SERVER_CAPTURE_FILE = '/runtime/network/dns.pcap' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' @@ -41,9 +42,9 @@ def __init__(self, log_dir=log_dir, conf_file=conf_file, results_dir=results_dir) - self.dns_server_capture_file=dns_server_capture_file - self.startup_capture_file=startup_capture_file - self.monitor_capture_file=monitor_capture_file + self.dns_server_capture_file = dns_server_capture_file + self.startup_capture_file = startup_capture_file + self.monitor_capture_file = monitor_capture_file self._dns_server = '10.10.10.4' global LOGGER LOGGER = self._get_logger() @@ -55,18 +56,17 @@ def generate_module_report(self): html_content = '

DNS Module

' # Set the summary variables - local_requests = sum(1 for row in dns_table_data - if row['Destination'] == - self._dns_server and row['Type'] == 'Query') - external_requests = sum(1 for row in dns_table_data - if row['Destination'] != - self._dns_server and row['Type'] == 'Query') + local_requests = sum( + 1 for row in dns_table_data + if row['Destination'] == self._dns_server and row['Type'] == 'Query') + external_requests = sum( + 1 for row in dns_table_data + if row['Destination'] != self._dns_server and row['Type'] == 'Query') - total_requests = sum(1 for row in dns_table_data - if row['Type'] == 'Query') + total_requests = sum(1 for row in dns_table_data if row['Type'] == 'Query') total_responses = sum(1 for row in dns_table_data - if row['Type'] == 'Response') + if row['Type'] == 'Response') # Add summary table html_content += (f''' @@ -99,18 +99,26 @@ def generate_module_report(self): Destination Type URL + Count ''' - for row in dns_table_data: - table_content += (f''' - - {row['Source']} - {row['Destination']} - {row['Type']} - {row['Data']} - ''') + # Count unique combinations + counter = Counter( + (row['Source'], row['Destination'], row['Type'], row['Data']) + for row in dns_table_data) + + # Generate the HTML table with the count column + for (src, dst, typ, dat), count in counter.items(): + table_content += f''' + + {src} + {dst} + {typ} + {dat} + {count} + ''' table_content += ''' @@ -149,26 +157,28 @@ def extract_dns_data(self): # Iterate through DNS packets for packet in packets: if DNS in packet and packet.haslayer(IP): - source_ip = packet[IP].src - destination_ip = packet[IP].dst - dns_layer = packet[DNS] - - # 'qr' field indicates query (0) or response (1) - dns_type = 'Query' if dns_layer.qr == 0 else 'Response' - - # Check for the presence of DNS query name - if hasattr(dns_layer, 'qd') and dns_layer.qd is not None: + + # Check if either source or destination MAC matches the device + if self._device_mac in (packet[Ether].src, packet[Ether].dst): + source_ip = packet[IP].src + destination_ip = packet[IP].dst + dns_layer = packet[DNS] + # 'qr' field indicates query (0) or response (1) + dns_type = 'Query' if dns_layer.qr == 0 else 'Response' + + # Check for the presence of DNS query name + if hasattr(dns_layer, 'qd') and dns_layer.qd is not None: qname = dns_layer.qd.qname.decode() if dns_layer.qd.qname else 'N/A' - else: + else: qname = 'N/A' - dns_data.append({ - 'Timestamp': float(packet.time), # Timestamp of the DNS packet - 'Source': source_ip, - 'Destination': destination_ip, - 'Type': dns_type, - 'Data': qname[:-1] - }) + dns_data.append({ + 'Timestamp': float(packet.time), # Timestamp of the DNS packet + 'Source': source_ip, + 'Destination': destination_ip, + 'Type': dns_type, + 'Data': qname[:-1] + }) # Filter unique entries based on 'Timestamp' # DNS Server will duplicate messages caught by @@ -273,10 +283,10 @@ def _exec_tcpdump(self, tcpdump_filter, capture_file): LOGGER.debug('tcpdump command: ' + command) with subprocess.Popen(command, - universal_newlines=True, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) as process: + universal_newlines=True, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) as process: text = str(process.stdout.read()).rstrip() LOGGER.debug('tcpdump response: ' + text) diff --git a/modules/test/ntp/python/src/ntp_module.py b/modules/test/ntp/python/src/ntp_module.py index 453c992e6..be27abbad 100644 --- a/modules/test/ntp/python/src/ntp_module.py +++ b/modules/test/ntp/python/src/ntp_module.py @@ -14,8 +14,8 @@ """NTP test module""" from test_module import TestModule from scapy.all import rdpcap, IP, IPv6, NTP, UDP, Ether -from datetime import datetime import os +from collections import defaultdict LOG_NAME = 'test_ntp' MODULE_REPORT_FILE_NAME = 'ntp_report.html' @@ -69,6 +69,33 @@ def generate_module_report(self): total_responses = sum(1 for row in ntp_table_data if row['Type'] == 'Server') + # Initialize a dictionary to store timestamps for each unique combination + timestamps = defaultdict(list) + + # Collect timestamps for each unique combination + for row in ntp_table_data: + # Add the timestamp to the corresponding combination + key = (row['Source'], row['Destination'], row['Type'], row['Version']) + timestamps[key].append(row['Timestamp']) + + # Calculate the average time between requests for each unique combination + average_time_between_requests = {} + + for key, times in timestamps.items(): + # Sort the timestamps + times.sort() + + # Calculate the time differences between consecutive timestamps + time_diffs = [t2 - t1 for t1, t2 in zip(times[:-1], times[1:])] + + # Calculate the average of the time differences + if time_diffs: + avg_diff = sum(time_diffs) / len(time_diffs) + else: + avg_diff = 0 # one timestamp, the average difference is 0 + + average_time_between_requests[key] = avg_diff + # Add summary table html_content += (f''' @@ -92,7 +119,6 @@ def generate_module_report(self): ''') if total_requests + total_responses > 0: - table_content = '''
@@ -101,37 +127,39 @@ def generate_module_report(self): - + + ''' - for row in ntp_table_data: - - # Timestamp of the NTP packet - dt_object = datetime.utcfromtimestamp(row['Timestamp']) - - # Extract milliseconds from the fractional part of the timestamp - milliseconds = int((row['Timestamp'] % 1) * 1000) + # Generate the HTML table with the count column + for (src, dst, typ, + version), avg_diff in average_time_between_requests.items(): + cnt = len(timestamps[(src, dst, typ, version)]) - # Format the datetime object with milliseconds - formatted_time = dt_object.strftime( - '%b %d, %Y %H:%M:%S.') + f'{milliseconds:03d}' + # Sync Average only applies to client requests + if 'Client' in typ: + # Convert avg_diff to seconds and format it + avg_diff_seconds = avg_diff + avg_formatted_time = f'{avg_diff_seconds:.3f} seconds' + else: + avg_formatted_time = 'N/A' - table_content += (f''' + table_content += f''' - - - - - - ''') + + + + + + + ''' table_content += '''
Destination Type VersionTimestampCountSync Request Average
{row['Source']}{row['Destination']}{row['Type']}{row['Version']}{formatted_time}
{src}{dst}{typ}{version}{cnt}{avg_formatted_time}
''' - html_content += table_content else: @@ -159,8 +187,8 @@ def extract_ntp_data(self): # Read the pcap files packets = (rdpcap(self.startup_capture_file) + - rdpcap(self.monitor_capture_file) + - rdpcap(self.ntp_server_capture_file)) + rdpcap(self.monitor_capture_file) + + rdpcap(self.ntp_server_capture_file)) # Iterate through NTP packets for packet in packets: @@ -171,6 +199,10 @@ def extract_ntp_data(self): # Local NTP server syncs to external servers so we need to filter only # for traffic to/from the device if self._device_mac in (source_mac, destination_mac): + + source_ip = None + dest_ip = None + if IP in packet: source_ip = packet[IP].src dest_ip = packet[IP].dst @@ -218,6 +250,9 @@ def _ntp_network_ntp_support(self): for packet in packet_capture: if NTP in packet and packet.src == self._device_mac: + + dest_ip = None + if IP in packet: dest_ip = packet[IP].dst elif IPv6 in packet: @@ -229,16 +264,17 @@ def _ntp_network_ntp_support(self): device_sends_ntp3 = True LOGGER.info(f'Device sent NTPv3 request to {dest_ip}') - if not (device_sends_ntp3 or device_sends_ntp4): - result = False, 'Device has not sent any NTP requests' - elif device_sends_ntp3 and device_sends_ntp4: + result = False, 'Device has not sent any NTP requests' + + if device_sends_ntp3 and device_sends_ntp4: result = False, ('Device sent NTPv3 and NTPv4 packets. ' + - 'NTPv3 is not allowed.') + 'NTPv3 is not allowed') elif device_sends_ntp3: result = False, ('Device sent NTPv3 packets. ' - 'NTPv3 is not allowed.') + 'NTPv3 is not allowed') elif device_sends_ntp4: - result = True, 'Device sent NTPv4 packets.' + result = True, 'Device sent NTPv4 packets' + LOGGER.info(result[1]) return result @@ -255,6 +291,7 @@ def _ntp_network_ntp_dhcp(self): for packet in packet_capture: if NTP in packet and packet.src == self._device_mac: device_sends_ntp = True + dest_ip = None if IP in packet: dest_ip = packet[IP].dst elif IPv6 in packet: @@ -266,17 +303,17 @@ def _ntp_network_ntp_dhcp(self): LOGGER.info('Device sent NTP request to non-DHCP provided NTP server') ntp_to_remote = True + result = 'Feature Not Detected', 'Device has not sent any NTP requests' + if device_sends_ntp: if ntp_to_local and ntp_to_remote: result = False, ('Device sent NTP request to DHCP provided ' + 'server and non-DHCP provided server') elif ntp_to_remote: result = ('Feature Not Detected', - 'Device sent NTP request to non-DHCP provided server') + 'Device sent NTP request to non-DHCP provided server') elif ntp_to_local: result = True, 'Device sent NTP request to DHCP provided server' - else: - result = 'Feature Not Detected', 'Device has not sent any NTP requests' LOGGER.info(result[1]) return result diff --git a/modules/test/protocol/bin/start_test_module b/modules/test/protocol/bin/start_test_module index a0754836c..d85ae7d6b 100644 --- a/modules/test/protocol/bin/start_test_module +++ b/modules/test/protocol/bin/start_test_module @@ -1,53 +1,53 @@ -#!/bin/bash - -# Copyright 2023 Google LLC -# -# 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 -# -# https://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. - -# Setup and start the connection test module - -# Define where the python source files are located -PYTHON_SRC_DIR=/testrun/python/src - -# Fetch module name -MODULE_NAME=$1 - -# Default interface should be veth0 for all containers -DEFAULT_IFACE=veth0 - -# Allow a user to define an interface by passing it into this script -DEFINED_IFACE=$2 - -# Select which interace to use -if [[ -z $DEFINED_IFACE || "$DEFINED_IFACE" == "null" ]] -then - echo "No interface defined, defaulting to veth0" - INTF=$DEFAULT_IFACE -else - INTF=$DEFINED_IFACE -fi - -# Create and set permissions on the log files -LOG_FILE=/runtime/output/$MODULE_NAME.log -RESULT_FILE=/runtime/output/$MODULE_NAME-result.json -touch $LOG_FILE -touch $RESULT_FILE -chown $HOST_USER $LOG_FILE -chown $HOST_USER $RESULT_FILE - -# Run the python script that will execute the tests for this module -# -u flag allows python print statements -# to be logged by docker by running unbuffered -python3 -u $PYTHON_SRC_DIR/run.py "-m $MODULE_NAME" - +#!/bin/bash + +# Copyright 2023 Google LLC +# +# 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 +# +# https://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. + +# Setup and start the connection test module + +# Define where the python source files are located +PYTHON_SRC_DIR=/testrun/python/src + +# Fetch module name +MODULE_NAME=$1 + +# Default interface should be veth0 for all containers +DEFAULT_IFACE=veth0 + +# Allow a user to define an interface by passing it into this script +DEFINED_IFACE=$2 + +# Select which interace to use +if [[ -z $DEFINED_IFACE || "$DEFINED_IFACE" == "null" ]] +then + echo "No interface defined, defaulting to veth0" + INTF=$DEFAULT_IFACE +else + INTF=$DEFINED_IFACE +fi + +# Create and set permissions on the log files +LOG_FILE=/runtime/output/$MODULE_NAME.log +RESULT_FILE=/runtime/output/$MODULE_NAME-result.json +touch $LOG_FILE +touch $RESULT_FILE +chown $HOST_USER $LOG_FILE +chown $HOST_USER $RESULT_FILE + +# Run the python script that will execute the tests for this module +# -u flag allows python print statements +# to be logged by docker by running unbuffered +python3 -u $PYTHON_SRC_DIR/run.py "-m $MODULE_NAME" + echo Module has finished \ No newline at end of file diff --git a/modules/test/protocol/python/requirements.txt b/modules/test/protocol/python/requirements.txt index 57917735d..5b54a724d 100644 --- a/modules/test/protocol/python/requirements.txt +++ b/modules/test/protocol/python/requirements.txt @@ -1,7 +1,7 @@ # Required for BACnet protocol tests -netifaces -BAC0 -pytz +netifaces==0.11.0 +BAC0==23.7.3 +pytz==2024.1 # Required for Modbus protocol tests -pymodbus \ No newline at end of file +pymodbus==3.7.0 \ No newline at end of file diff --git a/modules/test/protocol/python/src/protocol_modbus.py b/modules/test/protocol/python/src/protocol_modbus.py index 925e9517a..a722f928e 100644 --- a/modules/test/protocol/python/src/protocol_modbus.py +++ b/modules/test/protocol/python/src/protocol_modbus.py @@ -103,7 +103,7 @@ def __init__(self, log, device_ip, config): self._discrete_input_enabled = False # Initialize the modbus client - self.client = ModbusClient(device_ip, self._port) + self.client = ModbusClient(host=device_ip, port=self._port) # Connections created from this method are simple socket connections # and aren't indicative of valid modbus diff --git a/modules/test/services/python/src/services_module.py b/modules/test/services/python/src/services_module.py index bfa232c87..b14c74234 100644 --- a/modules/test/services/python/src/services_module.py +++ b/modules/test/services/python/src/services_module.py @@ -200,7 +200,7 @@ def _process_port_results(self): def _scan_tcp_ports(self): max_port = 1000 LOGGER.info('Running nmap TCP port scan') - nmap_results = util.run_command( + nmap_results = util.run_command( # pylint: disable=E1120 f'''nmap --open -sT -sV -Pn -v -p 1-{max_port} --version-intensity 7 -T4 -oX - {self._ipv4_addr}''')[0] @@ -228,7 +228,7 @@ def _scan_udp_ports(self): port_list = ','.join(ports) LOGGER.info('Running nmap UDP port scan') LOGGER.debug('UDP ports: ' + str(port_list)) - nmap_results = util.run_command( + nmap_results = util.run_command( # pylint: disable=E1120 f'nmap -sU -sV -p {port_list} -oX - {self._ipv4_addr}')[0] LOGGER.info('UDP port scan complete') nmap_results_json = self._nmap_results_to_json(nmap_results) diff --git a/modules/test/tls/bin/get_tls_client_connections.sh b/modules/test/tls/bin/get_tls_client_connections.sh index e2e6da91b..7335cac80 100755 --- a/modules/test/tls/bin/get_tls_client_connections.sh +++ b/modules/test/tls/bin/get_tls_client_connections.sh @@ -1,32 +1,32 @@ -#!/bin/bash - -# Copyright 2023 Google LLC -# -# 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 -# -# https://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. - -CAPTURE_FILE="$1" -SRC_IP="$2" -PROTOCOL=$3 - -TSHARK_OUTPUT="-T json -e ip.src -e tcp.dstport -e ip.dst" -TSHARK_FILTER="ip.src == $SRC_IP and tls" - -# Add a protocol filter if defined -if [ -n "$PROTOCOL" ];then - TSHARK_FILTER="$TSHARK_FILTER and $PROTOCOL" -fi - -response=$(tshark -r "$CAPTURE_FILE" $TSHARK_OUTPUT $TSHARK_FILTER) - -echo "$response" +#!/bin/bash + +# Copyright 2023 Google LLC +# +# 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 +# +# https://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. + +CAPTURE_FILE="$1" +SRC_IP="$2" +PROTOCOL=$3 + +TSHARK_OUTPUT="-T json -e ip.src -e tcp.dstport -e ip.dst" +TSHARK_FILTER="ip.src == $SRC_IP and tls" + +# Add a protocol filter if defined +if [ -n "$PROTOCOL" ];then + TSHARK_FILTER="$TSHARK_FILTER and $PROTOCOL" +fi + +response=$(tshark -r "$CAPTURE_FILE" $TSHARK_OUTPUT $TSHARK_FILTER) + +echo "$response" \ No newline at end of file diff --git a/modules/test/tls/conf/module_config.json b/modules/test/tls/conf/module_config.json index cd77f8299..c74bfd667 100644 --- a/modules/test/tls/conf/module_config.json +++ b/modules/test/tls/conf/module_config.json @@ -32,6 +32,27 @@ "Disable connections to unsecure services", "Ensure any URLs connected to are secure (https)" ] + }, + { + "name": "security.tls.v1_3_server", + "test_description": "Check the device web server TLS 1.3 & certificate is valid", + "expected_behavior": "TLS 1.3 certificate is issued to the web browser client when accessed", + "required_result": "Informational", + "recommendations": [ + "Enable TLS 1.3 support in the web server configuration", + "Disable TLS 1.0 and 1.1", + "Sign the certificate used by the web server" + ] + }, + { + "name": "security.tls.v1_3_client", + "test_description": "Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS)", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.3", + "required_result": "Informational", + "recommendations": [ + "Disable connections to unsecure services", + "Ensure any URLs connected to are secure (https)" + ] } ] } diff --git a/modules/test/tls/python/requirements-test.txt b/modules/test/tls/python/requirements-test.txt new file mode 100644 index 000000000..93b351f44 --- /dev/null +++ b/modules/test/tls/python/requirements-test.txt @@ -0,0 +1 @@ +scapy \ No newline at end of file diff --git a/modules/test/tls/python/requirements.txt b/modules/test/tls/python/requirements.txt index 7624a2c68..846a224f3 100644 --- a/modules/test/tls/python/requirements.txt +++ b/modules/test/tls/python/requirements.txt @@ -1,5 +1,5 @@ -cryptography==42.0.4 # Do not upgrade until TLS module can be fixed to account for removed x509 property in version 39 -pyOpenSSL==24.1.0 +cryptography==38.0.0 # Do not upgrade until TLS module can be fixed to account for removed x509 property in version 39 +pyOpenSSL==23.0.0 lxml==5.1.0 # Requirement of pyshark but if upgraded automatically above 5.1 will cause a python crash pyshark==0.6 -requests==2.32.0 +requests==2.32.3 diff --git a/modules/test/tls/python/src/tls_util.py b/modules/test/tls/python/src/tls_util.py index d8c1d7a16..0364479c6 100644 --- a/modules/test/tls/python/src/tls_util.py +++ b/modules/test/tls/python/src/tls_util.py @@ -372,6 +372,8 @@ def validate_tls_server(self, host, tls_version): public_key = self.get_public_key(public_cert) if public_key: key_valid = self.verify_public_key(public_key) + else: + key_valid = [0] sig_valid = self.validate_signature(host) @@ -527,7 +529,7 @@ def process_hello_packets(self, LOGGER.info('Checking client ciphers: ' + str(packet)) if packet['cipher_support']['ecdh'] and packet['cipher_support'][ 'ecdsa']: - LOGGER.info('Valid ciphers detected') + LOGGER.info('Required ciphers detected') client_hello_results['valid'].append(packet) # If a previous hello packet to the same destination failed, # we can now remove it as it has passed on a different attempt @@ -537,7 +539,7 @@ def process_hello_packets(self, if packet['dst_ip'] in str(invalid_packet): client_hello_results['invalid'].remove(invalid_packet) else: - LOGGER.info('Invalid ciphers detected') + LOGGER.info('Required ciphers not detected') if packet['dst_ip'] not in allowed_protocol_client_ips: if packet['dst_ip'] not in str(client_hello_results['invalid']): client_hello_results['invalid'].append(packet) diff --git a/modules/test/tls/tls.Dockerfile b/modules/test/tls/tls.Dockerfile index cedf9531b..987ede591 100644 --- a/modules/test/tls/tls.Dockerfile +++ b/modules/test/tls/tls.Dockerfile @@ -31,14 +31,20 @@ COPY $MODULE_DIR/conf /testrun/conf # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin +# Remove incorrect line endings +RUN dos2unix /testrun/bin/* + +# Make sure all the bin files are executable +RUN chmod u+x /testrun/bin/* + # Copy over all python files COPY $MODULE_DIR/python /testrun/python -#Install all python requirements for the module +# Install all python requirements for the module RUN pip3 install -r /testrun/python/requirements.txt +# Install all python requirements for the modules unit test +RUN pip3 install -r /testrun/python/requirements-test.txt + # Create a directory inside the container to store the root certificates RUN mkdir -p /testrun/root_certs - - - diff --git a/modules/ui/angular.json b/modules/ui/angular.json index d72fee51f..0bf42377f 100644 --- a/modules/ui/angular.json +++ b/modules/ui/angular.json @@ -25,14 +25,15 @@ "inlineStyleLanguage": "scss", "assets": ["src/favicon.ico", "src/assets"], "styles": ["src/styles.scss"], - "scripts": [] + "scripts": [], + "allowedCommonJsDependencies": ["mqtt-browser"] }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "1000kb", + "maximumWarning": "1500kb", "maximumError": "3000kb" }, { @@ -93,6 +94,7 @@ } }, "cli": { - "schematicCollections": ["@angular-eslint/schematics"] + "schematicCollections": ["@angular-eslint/schematics"], + "analytics": false } } diff --git a/testing/unit/build.sh b/modules/ui/build.Dockerfile similarity index 77% rename from testing/unit/build.sh rename to modules/ui/build.Dockerfile index db84e0299..180ad9747 100644 --- a/testing/unit/build.sh +++ b/modules/ui/build.Dockerfile @@ -1,5 +1,3 @@ -#!/bin/bash -e - # Copyright 2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,4 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -sudo docker build -f testing/unit/unit_test.Dockerfile -t testrun/unit-test . \ No newline at end of file +# Image name: testrun/build-ui +FROM node@sha256:ffebb4405810c92d267a764b21975fb2d96772e41877248a37bf3abaa0d3b590 as build + +# Set the working directory +WORKDIR /modules/ui + diff --git a/modules/ui/package-lock.json b/modules/ui/package-lock.json index e6903631a..637dc47e4 100644 --- a/modules/ui/package-lock.json +++ b/modules/ui/package-lock.json @@ -22,6 +22,7 @@ "@ngrx/effects": "^17.1.1", "@ngrx/store": "^17.0.1", "ngx-mask": "^16.4.2", + "ngx-mqtt": "^17.0.0", "rxjs": "~7.8.0", "tslib": "^2.6.2", "zone.js": "^0.14.4" @@ -6531,14 +6532,12 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6594,7 +6593,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -6713,7 +6711,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -6736,8 +6733,7 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/bytes": { "version": "3.1.2", @@ -7089,6 +7085,15 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/commist": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-1.1.0.tgz", + "integrity": "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg==", + "dependencies": { + "leven": "^2.1.0", + "minimist": "^1.1.0" + } + }, "node_modules/common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", @@ -7167,8 +7172,21 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } }, "node_modules/connect": { "version": "3.7.0", @@ -7599,7 +7617,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -7862,6 +7879,17 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7946,7 +7974,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "dependencies": { "once": "^1.4.0" } @@ -9156,8 +9183,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -9244,7 +9270,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -9282,7 +9307,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -9292,7 +9316,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -9437,6 +9460,15 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-3.0.0.tgz", + "integrity": "sha512-hx73jClhyk910sidBB7ERlnhMlFsJJIBqSVMFDwPN8o2v9nmp5KgLq1Xz1Bf1fCMMZ6mPrX159iG0VLy/fPMtQ==", + "dependencies": { + "glob": "^7.1.6", + "readable-stream": "^3.6.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -9685,7 +9717,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -9788,7 +9819,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -9797,8 +9827,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "4.1.1", @@ -10462,6 +10491,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10947,6 +10985,14 @@ "node": ">=0.10.0" } }, + "node_modules/leven": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", + "integrity": "sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -11445,7 +11491,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11649,6 +11694,92 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mqtt": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.3.7.tgz", + "integrity": "sha512-ew3qwG/TJRorTz47eW46vZ5oBw5MEYbQZVaEji44j5lAUSQSqIEoul7Kua/BatBW0H0kKQcC9kwUHa1qzaWHSw==", + "dependencies": { + "commist": "^1.0.0", + "concat-stream": "^2.0.0", + "debug": "^4.1.1", + "duplexify": "^4.1.1", + "help-me": "^3.0.0", + "inherits": "^2.0.3", + "lru-cache": "^6.0.0", + "minimist": "^1.2.5", + "mqtt-packet": "^6.8.0", + "number-allocator": "^1.0.9", + "pump": "^3.0.0", + "readable-stream": "^3.6.0", + "reinterval": "^1.1.0", + "rfdc": "^1.3.0", + "split2": "^3.1.0", + "ws": "^7.5.5", + "xtend": "^4.0.2" + }, + "bin": { + "mqtt": "bin/mqtt.js", + "mqtt_pub": "bin/pub.js", + "mqtt_sub": "bin/sub.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mqtt-browser": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/mqtt-browser/-/mqtt-browser-4.3.7.tgz", + "integrity": "sha512-4pxHxa3avIILr2CXhTKlArVpATqfyTu4zr5u2PoUwzgw0GDr5dpzZ0pmPgZyOoQBVgrVDEboCzb/b1Q0yWOm7g==", + "dependencies": { + "mqtt": "4.3.7" + } + }, + "node_modules/mqtt-packet": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.10.0.tgz", + "integrity": "sha512-ja8+mFKIHdB1Tpl6vac+sktqy3gA8t9Mduom1BA75cI+R9AHnZOiaBQwpGiWnaVJLDGRdNhQmFaAqd7tkKSMGA==", + "dependencies": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mqtt/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/mqtt/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -11661,8 +11792,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -11768,6 +11898,20 @@ "@angular/forms": ">=14.0.0" } }, + "node_modules/ngx-mqtt": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/ngx-mqtt/-/ngx-mqtt-17.0.0.tgz", + "integrity": "sha512-54wVMyDOZkpTZEs0rTMWPP1Yz+6q3rRnHzIBnpqnBkDcyMfNrti45C7ijwnEIaPDzQHMOqVrDgh/6C4ocPPLJQ==", + "dependencies": { + "mqtt-browser": "4.3.7", + "mqtt-packet": "^6.10.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=14", + "@angular/core": ">=14" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", @@ -12068,6 +12212,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, "node_modules/nx": { "version": "17.2.8", "resolved": "https://registry.npmjs.org/nx/-/nx-17.2.8.tgz", @@ -12354,7 +12507,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -12726,7 +12878,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13172,8 +13323,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/promise-inflight": { "version": "1.0.1", @@ -13229,6 +13379,15 @@ "dev": true, "optional": true }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -13375,7 +13534,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -13492,6 +13650,11 @@ "jsesc": "bin/jsesc" } }, + "node_modules/reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13622,8 +13785,7 @@ "node_modules/rfdc": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", - "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", - "dev": true + "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==" }, "node_modules/rimraf": { "version": "3.0.2", @@ -13719,7 +13881,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -14376,6 +14537,14 @@ "wbuf": "^1.7.3" } }, + "node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -14403,6 +14572,11 @@ "node": ">= 0.6" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" + }, "node_modules/streamroller": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", @@ -14453,7 +14627,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -14995,6 +15168,11 @@ "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", "dev": true }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -15170,8 +15348,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -16192,8 +16369,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "8.17.1", @@ -16216,6 +16392,14 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/modules/ui/package.json b/modules/ui/package.json index 7f83fc5f7..aceb9c389 100644 --- a/modules/ui/package.json +++ b/modules/ui/package.json @@ -31,6 +31,7 @@ "@ngrx/effects": "^17.1.1", "@ngrx/store": "^17.0.1", "ngx-mask": "^16.4.2", + "ngx-mqtt": "^17.0.0", "rxjs": "~7.8.0", "tslib": "^2.6.2", "zone.js": "^0.14.4" diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 38c210251..b1341a58d 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -116,6 +116,14 @@

Testrun

"> tune + + + @@ -127,7 +135,8 @@

Testrun

error.devicePortMissed && error.internetPortMissed; else onePortMissed "> - No ports are detected. Please define a valid ones using + No ports detected. Please connect and configure network and device + connections in the Selected port is missing! Please define a valid one using @@ -187,7 +196,8 @@

Testrun

vm.hasConnectionSettings === true && vm.hasDevices && (!vm.systemStatus || vm.systemStatus === StatusOfTestrun.Idle) && - vm.isStatusLoaded === true + vm.isStatusLoaded === true && + !vm.reports.length "> Step 3: Once device is created, you are able to Testrun vm.systemStatus === StatusOfTestrun.InProgress && isRiskAssessmentRoute === false "> - Congratulations, the device is under test now! Do not forget to fill + The device is now being tested. Why not take the time to complete the + device Testrun role="link" class="message-link" >Risk Assessment questionnaire. It is required to complete verification process. + >? @@ -265,6 +276,7 @@

Testrun

mat-button routerLink="{{ route }}" routerLinkActive="app-sidebar-button-active" + [matTooltip]="label" (keydown.enter)="onNavigationClick()"> {{ icon }} diff --git a/modules/ui/src/app/app.component.scss b/modules/ui/src/app/app.component.scss index 20e81c53e..9639f5cc0 100644 --- a/modules/ui/src/app/app.component.scss +++ b/modules/ui/src/app/app.component.scss @@ -208,3 +208,9 @@ app-version { display: flex; justify-content: center; } + +.separator { + width: 1px; + height: 28px; + background-color: $light-grey; +} diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts index 81e93b4b6..df531c8b7 100644 --- a/modules/ui/src/app/app.component.spec.ts +++ b/modules/ui/src/app/app.component.spec.ts @@ -56,9 +56,11 @@ import { selectHasDevices, selectHasRiskProfiles, selectInterfaces, + selectInternetConnection, selectIsOpenStartTestrun, selectIsOpenWaitSnackBar, selectMenuOpened, + selectReports, selectStatus, selectSystemStatus, } from './store/selectors'; @@ -67,6 +69,11 @@ import { CertificatesComponent } from './pages/certificates/certificates.compone import { of } from 'rxjs'; import { WINDOW } from './providers/window.provider'; import { LiveAnnouncer } from '@angular/cdk/a11y'; +import { HISTORY } from './mocks/reports.mock'; +import { TestRunMqttService } from './services/test-run-mqtt.service'; +import { MOCK_ADAPTERS } from './mocks/settings.mock'; +import { WifiComponent } from './components/wifi/wifi.component'; +import { MatTooltipModule } from '@angular/material/tooltip'; const windowMock = { location: { @@ -84,6 +91,7 @@ describe('AppComponent', () => { let focusNavigation = true; let mockFocusManagerService: SpyObj; let mockLiveAnnouncer: SpyObj; + let mockMqttService: SpyObj; const enterKeyEvent = new KeyboardEvent('keydown', { key: 'Enter', @@ -109,6 +117,7 @@ describe('AppComponent', () => { 'testrunInProgress', 'fetchProfiles', 'fetchCertificates', + 'getHistory', ]); mockService.fetchCertificates.and.returnValue(of([])); @@ -116,6 +125,7 @@ describe('AppComponent', () => { 'focusFirstElementInContainer', ]); mockLiveAnnouncer = jasmine.createSpyObj('mockLiveAnnouncer', ['announce']); + mockMqttService = jasmine.createSpyObj(['getNetworkAdapters']); TestBed.configureTestingModule({ imports: [ @@ -131,10 +141,13 @@ describe('AppComponent', () => { CalloutComponent, MatIconTestingModule, CertificatesComponent, + WifiComponent, + MatTooltipModule, ], providers: [ { provide: TestRunService, useValue: mockService }, { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, + { provide: TestRunMqttService, useValue: mockMqttService }, { provide: State, useValue: { @@ -151,6 +164,7 @@ describe('AppComponent', () => { selectors: [ { selector: selectInterfaces, value: {} }, { selector: selectHasConnectionSettings, value: true }, + { selector: selectInternetConnection, value: true }, { selector: selectError, value: null }, { selector: selectMenuOpened, value: false }, { selector: selectHasDevices, value: false }, @@ -159,6 +173,7 @@ describe('AppComponent', () => { { selector: selectSystemStatus, value: null }, { selector: selectIsOpenStartTestrun, value: false }, { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectReports, value: [] }, ], }), { provide: FocusManagerService, useValue: mockFocusManagerService }, @@ -173,6 +188,7 @@ describe('AppComponent', () => { ], }); + mockMqttService.getNetworkAdapters.and.returnValue(of(MOCK_ADAPTERS)); store = TestBed.inject(MockStore); fixture = TestBed.createComponent(AppComponent); component = fixture.componentInstance; @@ -429,6 +445,13 @@ describe('AppComponent', () => { expect(version).toBeTruthy(); }); + it('should internet icon', () => { + fixture.detectChanges(); + const internet = compiled.querySelector('app-wifi'); + + expect(internet).toBeTruthy(); + }); + describe('Callout component visibility', () => { describe('with no connection settings', () => { beforeEach(() => { @@ -486,6 +509,48 @@ describe('AppComponent', () => { expect(callout).toBeTruthy(); expect(calloutContent).toContain('Step 3'); }); + + it('should NOT have callout component with "Step 3" if has reports', () => { + store.overrideSelector(selectReports, [...HISTORY]); + store.refreshState(); + fixture.detectChanges(); + + const callout = compiled.querySelector('app-callout'); + + expect(callout).toBeFalsy(); + }); + }); + + describe('with systemStatus data IN Progress and without riskProfiles', () => { + beforeEach(() => { + store.overrideSelector(selectHasConnectionSettings, true); + store.overrideSelector(selectHasDevices, true); + store.overrideSelector(selectHasRiskProfiles, false); + store.overrideSelector( + selectStatus, + MOCK_PROGRESS_DATA_IN_PROGRESS.status + ); + fixture.detectChanges(); + }); + + it('should have callout component with "The device is now being tested" text', () => { + const callout = compiled.querySelector('app-callout'); + const calloutContent = callout?.innerHTML.trim(); + + expect(callout).toBeTruthy(); + expect(calloutContent).toContain('The device is now being tested'); + }); + + it('should have callout component with "Risk Assessment" link', () => { + const callout = compiled.querySelector('app-callout'); + const calloutLinkEl = compiled.querySelector( + '.message-link' + ) as HTMLAnchorElement; + const calloutLinkContent = calloutLinkEl.innerHTML.trim(); + + expect(callout).toBeTruthy(); + expect(calloutLinkContent).toContain('Risk Assessment'); + }); }); describe('with systemStatus data IN Progress and without riskProfiles', () => { @@ -500,12 +565,12 @@ describe('AppComponent', () => { fixture.detectChanges(); }); - it('should have callout component with "Congratulations" text', () => { + it('should have callout component with "The device is now being tested" text', () => { const callout = compiled.querySelector('app-callout'); const calloutContent = callout?.innerHTML.trim(); expect(callout).toBeTruthy(); - expect(calloutContent).toContain('Congratulations'); + expect(calloutContent).toContain('The device is now being tested'); }); it('should have callout component with "Risk Assessment" link', () => { @@ -686,7 +751,7 @@ describe('AppComponent', () => { const calloutContent = callout?.innerHTML.trim(); expect(callout).toBeTruthy(); - expect(calloutContent).toContain('No ports are detected.'); + expect(calloutContent).toContain('No ports detected.'); }); }); diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index 341f6bab5..2214b8927 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -80,6 +80,9 @@ export class AppComponent { this.appStore.getDevices(); this.appStore.getRiskProfiles(); this.appStore.getSystemStatus(); + this.appStore.getReports(); + this.appStore.getTestModules(); + this.appStore.getNetworkAdapters(); this.matIconRegistry.addSvgIcon( 'devices', this.domSanitizer.bypassSecurityTrustResourceUrl(DEVICES_LOGO_URL) diff --git a/modules/ui/src/app/app.module.ts b/modules/ui/src/app/app.module.ts index 78621a464..795d4e0d8 100644 --- a/modules/ui/src/app/app.module.ts +++ b/modules/ui/src/app/app.module.ts @@ -49,6 +49,14 @@ import { ShutdownAppComponent } from './components/shutdown-app/shutdown-app.com import { WindowProvider } from './providers/window.provider'; import { CertificatesComponent } from './pages/certificates/certificates.component'; import { LOADER_TIMEOUT_CONFIG_TOKEN } from './services/loaderConfig'; +import { WifiComponent } from './components/wifi/wifi.component'; + +import { MqttModule, IMqttServiceOptions } from 'ngx-mqtt'; + +export const MQTT_SERVICE_OPTIONS: IMqttServiceOptions = { + hostname: 'localhost', + port: 9001, +}; @NgModule({ declarations: [AppComponent, SettingsComponent], @@ -79,6 +87,8 @@ import { LOADER_TIMEOUT_CONFIG_TOKEN } from './services/loaderConfig'; SettingsDropdownComponent, ShutdownAppComponent, CertificatesComponent, + MqttModule.forRoot(MQTT_SERVICE_OPTIONS), + WifiComponent, ], providers: [ WindowProvider, diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 2bdf63195..e26db7eb3 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -24,22 +24,30 @@ import { selectHasDevices, selectHasRiskProfiles, selectInterfaces, + selectInternetConnection, selectIsOpenWaitSnackBar, selectMenuOpened, + selectReports, selectStatus, + selectTestModules, } from './store/selectors'; import { TestRunService } from './services/test-run.service'; import SpyObj = jasmine.SpyObj; -import { device } from './mocks/device.mock'; +import { device, MOCK_MODULES, MOCK_TEST_MODULES } from './mocks/device.mock'; import { + fetchReports, fetchRiskProfiles, fetchSystemStatus, setDevices, + updateAdapters, + setTestModules, } from './store/actions'; import { MOCK_PROGRESS_DATA_IN_PROGRESS } from './mocks/testrun.mock'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { NotificationService } from './services/notification.service'; import { FocusManagerService } from './services/focus-manager.service'; +import { TestRunMqttService } from './services/test-run-mqtt.service'; +import { MOCK_ADAPTERS } from './mocks/settings.mock'; const mock = (() => { let store: { [key: string]: string } = {}; @@ -65,15 +73,20 @@ describe('AppStore', () => { let mockService: SpyObj; let mockNotificationService: SpyObj; let mockFocusManagerService: SpyObj; + let mockMqttService: SpyObj; beforeEach(() => { - mockService = jasmine.createSpyObj('mockService', ['fetchDevices']); + mockService = jasmine.createSpyObj('mockService', [ + 'fetchDevices', + 'getTestModules', + ]); mockNotificationService = jasmine.createSpyObj('mockNotificationService', [ 'notify', ]); mockFocusManagerService = jasmine.createSpyObj([ 'focusFirstElementInContainer', ]); + mockMqttService = jasmine.createSpyObj(['getNetworkAdapters']); TestBed.configureTestingModule({ providers: [ @@ -82,11 +95,14 @@ describe('AppStore', () => { selectors: [ { selector: selectStatus, value: null }, { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectTestModules, value: MOCK_TEST_MODULES }, + { selector: selectInternetConnection, value: false }, ], }), { provide: TestRunService, useValue: mockService }, { provide: NotificationService, useValue: mockNotificationService }, { provide: FocusManagerService, useValue: mockFocusManagerService }, + { provide: TestRunMqttService, useValue: mockMqttService }, ], imports: [BrowserAnimationsModule], }); @@ -96,6 +112,7 @@ describe('AppStore', () => { store.overrideSelector(selectHasDevices, true); store.overrideSelector(selectHasRiskProfiles, false); + store.overrideSelector(selectReports, []); store.overrideSelector(selectHasConnectionSettings, true); store.overrideSelector(selectMenuOpened, true); store.overrideSelector(selectInterfaces, {}); @@ -140,12 +157,14 @@ describe('AppStore', () => { consentShown: false, hasDevices: true, hasRiskProfiles: false, + reports: [], isStatusLoaded: false, systemStatus: null, hasConnectionSettings: true, isMenuOpen: true, interfaces: {}, settingMissedError: null, + hasInternetConnection: false, }); done(); }); @@ -226,5 +245,66 @@ describe('AppStore', () => { ).toHaveBeenCalled(); })); }); + + describe('getReports', () => { + it('should dispatch fetchReports', () => { + appStore.getReports(); + + expect(store.dispatch).toHaveBeenCalledWith(fetchReports()); + }); + }); + + describe('getTestModules', () => { + const modules = [...MOCK_MODULES]; + + beforeEach(() => { + mockService.getTestModules.and.returnValue(of(modules)); + }); + + it('should dispatch action setDevices', () => { + appStore.getTestModules(); + + expect(store.dispatch).toHaveBeenCalledWith( + setTestModules({ + testModules: [ + { + displayName: 'Connection', + name: 'connection', + enabled: true, + }, + { + displayName: 'Udmi', + name: 'udmi', + enabled: true, + }, + ], + }) + ); + }); + }); + + describe('getNetworkAdapters', () => { + const adapters = MOCK_ADAPTERS; + + beforeEach(() => { + mockMqttService.getNetworkAdapters.and.returnValue(of(adapters)); + }); + + it('should dispatch action setDevices', () => { + appStore.getNetworkAdapters(); + + expect(store.dispatch).toHaveBeenCalledWith( + updateAdapters({ adapters }) + ); + }); + + it('should notify about new adapters', () => { + appStore.getNetworkAdapters(); + + expect(mockNotificationService.notify).toHaveBeenCalledWith( + 'New network adapter(s) mockNewInternetKey has been detected. You can switch to using it in the System settings menu' + ); + }); + }); }); }); diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index 9bd8dcff4..6e338968f 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -23,23 +23,34 @@ import { selectHasDevices, selectHasRiskProfiles, selectInterfaces, + selectInternetConnection, selectMenuOpened, + selectReports, selectStatus, } from './store/selectors'; import { Store } from '@ngrx/store'; import { AppState } from './store/state'; import { TestRunService } from './services/test-run.service'; import { delay, exhaustMap, Observable, skip } from 'rxjs'; -import { Device } from './model/device'; +import { Device, TestModule } from './model/device'; import { setDevices, setIsOpenStartTestrun, fetchSystemStatus, fetchRiskProfiles, + fetchReports, + setTestModules, + updateAdapters, } from './store/actions'; import { TestrunStatus } from './model/testrun-status'; -import { SettingMissedError, SystemInterfaces } from './model/setting'; +import { + Adapters, + SettingMissedError, + SystemInterfaces, +} from './model/setting'; import { FocusManagerService } from './services/focus-manager.service'; +import { TestRunMqttService } from './services/test-run-mqtt.service'; +import { NotificationService } from './services/notification.service'; export const CONSENT_SHOWN_KEY = 'CONSENT_SHOWN'; export interface AppComponentState { @@ -51,8 +62,10 @@ export interface AppComponentState { export class AppStore extends ComponentStore { private consentShown$ = this.select(state => state.consentShown); private isStatusLoaded$ = this.select(state => state.isStatusLoaded); + private hasInternetConnection$ = this.store.select(selectInternetConnection); private hasDevices$ = this.store.select(selectHasDevices); private hasRiskProfiles$ = this.store.select(selectHasRiskProfiles); + private reports$ = this.store.select(selectReports); private hasConnectionSetting$ = this.store.select( selectHasConnectionSettings ); @@ -67,12 +80,14 @@ export class AppStore extends ComponentStore { consentShown: this.consentShown$, hasDevices: this.hasDevices$, hasRiskProfiles: this.hasRiskProfiles$, + reports: this.reports$, isStatusLoaded: this.isStatusLoaded$, systemStatus: this.systemStatus$, hasConnectionSettings: this.hasConnectionSetting$, isMenuOpen: this.isMenuOpen$, interfaces: this.interfaces$, settingMissedError: this.settingMissedError$, + hasInternetConnection: this.hasInternetConnection$, }); updateConsent = this.updater((state, consentShown: boolean) => ({ @@ -131,6 +146,27 @@ export class AppStore extends ComponentStore { ); }); + getNetworkAdapters = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunMqttService.getNetworkAdapters().pipe( + tap((adapters: Adapters) => { + if (adapters.adapters_added) { + this.notifyAboutTheAdapters(adapters.adapters_added); + } + this.store.dispatch(updateAdapters({ adapters })); + }) + ); + }) + ); + }); + + private notifyAboutTheAdapters(adapters: SystemInterfaces) { + this.notificationService.notify( + `New network adapter(s) ${Object.keys(adapters).join(', ')} has been detected. You can switch to using it in the System settings menu` + ); + } + setIsOpenStartTestrun = this.effect(trigger$ => { return trigger$.pipe( tap(() => { @@ -150,10 +186,43 @@ export class AppStore extends ComponentStore { ); }); + getReports = this.effect(trigger$ => { + return trigger$.pipe( + tap(() => { + this.store.dispatch(fetchReports()); + }) + ); + }); + + getTestModules = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.getTestModules().pipe( + tap((testModules: string[]) => { + this.store.dispatch( + setTestModules({ + testModules: testModules.map( + module => + ({ + displayName: module, + name: module.toLowerCase(), + enabled: true, + }) as TestModule + ), + }) + ); + }) + ); + }) + ); + }); + constructor( private store: Store, private testRunService: TestRunService, - private focusManagerService: FocusManagerService + private testRunMqttService: TestRunMqttService, + private focusManagerService: FocusManagerService, + private notificationService: NotificationService ) { super({ consentShown: sessionStorage.getItem(CONSENT_SHOWN_KEY) !== null, diff --git a/modules/ui/src/app/components/download-report-zip/download-report-zip.component.spec.ts b/modules/ui/src/app/components/download-report-zip/download-report-zip.component.spec.ts index d87200987..2b2fe7994 100644 --- a/modules/ui/src/app/components/download-report-zip/download-report-zip.component.spec.ts +++ b/modules/ui/src/app/components/download-report-zip/download-report-zip.component.spec.ts @@ -171,9 +171,9 @@ describe('DownloadReportZipComponent', () => { expect(spyOnShow).toHaveBeenCalled(); }); - it('should be shown on focusin', () => { + it('should be shown on keyup', () => { const spyOnShow = spyOn(component.tooltip, 'show'); - fixture.nativeElement.dispatchEvent(new Event('focusin')); + fixture.nativeElement.dispatchEvent(new Event('keyup')); expect(spyOnShow).toHaveBeenCalled(); }); @@ -185,9 +185,9 @@ describe('DownloadReportZipComponent', () => { expect(spyOnHide).toHaveBeenCalled(); }); - it('should be hidden on focusout', () => { + it('should be hidden on keydown', () => { const spyOnHide = spyOn(component.tooltip, 'hide'); - fixture.nativeElement.dispatchEvent(new Event('focusout')); + fixture.nativeElement.dispatchEvent(new Event('keydown')); expect(spyOnHide).toHaveBeenCalled(); }); diff --git a/modules/ui/src/app/components/download-report-zip/download-report-zip.component.ts b/modules/ui/src/app/components/download-report-zip/download-report-zip.component.ts index e7b106f05..d5b4b41ce 100644 --- a/modules/ui/src/app/components/download-report-zip/download-report-zip.component.ts +++ b/modules/ui/src/app/components/download-report-zip/download-report-zip.component.ts @@ -87,13 +87,13 @@ export class DownloadReportZipComponent readonly tabIndex = 0; @HostListener('mouseenter') - @HostListener('focusin', ['$event']) + @HostListener('keyup', ['$event']) onEvent(): void { this.tooltip.show(); } @HostListener('mouseleave') - @HostListener('focusout', ['$event']) + @HostListener('keydown', ['$event']) outEvent(): void { this.tooltip.hide(); } diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html index b3dfb77f4..2e9446cfa 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html @@ -14,21 +14,59 @@ limitations under the License. --> Download ZIP file -

- Risk profile is required for device verification. Please, consider creating a - Risk assessment profile for your ZIP report. +

+ Risk Profile is required for device verification. Please consider going to + Risk Assessment + and creating a profile to attach to your report.

-
+

+ Risk Profile is required for device verification. Please select a profile from + the list, or go to + Risk Assessment + and create a new one to attach to your report. +

+ +
+ aria-label="Please choose a Risk Profile from the list"> - {{ selectedProfile }} + {{ selectedProfile.name }} + + {{ selectedProfile.risk }} risk + - +
- Please choose risk assessment profile + Please choose a Risk Profile from the list -
- - - - - - diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss index 3524cb936..0a92617c1 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss @@ -53,10 +53,6 @@ padding: 16px 0 0; } -.risk-profile-select-form-actions button:first-child { - margin-right: auto; -} - .profile-select { width: 100%; } @@ -69,3 +65,22 @@ font-size: 12px; color: $grey-700; } + +.redirect-link { + cursor: pointer; + color: $primary; + display: inline-block; + width: fit-content; +} + +::ng-deep mat-select-trigger { + display: inline-flex; + width: 100%; + justify-content: space-between; +} + +::ng-deep mat-select-trigger .profile-item-risk { + vertical-align: middle; + align-self: center; + margin-right: 16px; +} diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.spec.ts b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.spec.ts index cdd4c665f..728590ef8 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.spec.ts +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.spec.ts @@ -59,23 +59,19 @@ describe('DownloadZipModalComponent', () => { expect(select).toBeTruthy(); }); - it('should preselect first profile', async () => { - const select = fixture.nativeElement.querySelector( - 'mat-select' - ) as HTMLElement; - - expect(select.getAttribute('ng-reflect-value')).toEqual( - 'Primary profile' + it('should preselect "no profile" option', async () => { + expect(component.selectedProfile.name).toEqual( + 'No Risk Profile selected' ); }); it('should close with null on redirect button click', async () => { const closeSpy = spyOn(component.dialogRef, 'close'); - const redirectButton = fixture.nativeElement.querySelector( - '.redirect-button' - ) as HTMLButtonElement; + const redirectLink = fixture.nativeElement.querySelector( + '.redirect-link' + ) as HTMLAnchorElement; - redirectButton.click(); + redirectLink.click(); expect(closeSpy).toHaveBeenCalledWith(null); @@ -103,13 +99,17 @@ describe('DownloadZipModalComponent', () => { downloadButton.click(); - expect(closeSpy).toHaveBeenCalledWith('Primary profile'); + expect(closeSpy).toHaveBeenCalledWith(''); closeSpy.calls.reset(); }); it('should have filtered and sorted profiles', async () => { - expect(component.profiles).toEqual([PROFILE_MOCK, PROFILE_MOCK_2]); + expect(component.profiles).toEqual([ + component.NO_PROFILE, + PROFILE_MOCK, + PROFILE_MOCK_2, + ]); }); it('#getRiskClass should call the service method getRiskClass"', () => { @@ -141,19 +141,19 @@ describe('DownloadZipModalComponent', () => { fixture.detectChanges(); }); - it('should have no dropdown with profiles', async () => { + it('should have disabled dropdown', async () => { const select = fixture.nativeElement.querySelector('mat-select'); - expect(select).toEqual(null); + expect(select.classList.contains('mat-mdc-select-disabled')).toBeTruthy(); }); it('should close with null on redirect button click', async () => { const closeSpy = spyOn(component.dialogRef, 'close'); - const redirectButton = fixture.nativeElement.querySelector( - '.redirect-button' - ) as HTMLButtonElement; + const redirectLink = fixture.nativeElement.querySelector( + '.redirect-link' + ) as HTMLAnchorElement; - redirectButton.click(); + redirectLink.click(); expect(closeSpy).toHaveBeenCalledWith(null); diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts index 395bcb480..b042bdabf 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts @@ -17,6 +17,9 @@ import { MatFormField } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatOptionModule } from '@angular/material/core'; import { TestRunService } from '../../services/test-run.service'; +import { Routes } from '../../model/routes'; +import { RouterLink } from '@angular/router'; +import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip'; interface DialogData { profiles: Profile[]; @@ -35,14 +38,22 @@ interface DialogData { MatFormField, MatSelectModule, MatOptionModule, + RouterLink, + MatTooltip, + MatTooltipModule, ], templateUrl: './download-zip-modal.component.html', styleUrl: './download-zip-modal.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DownloadZipModalComponent extends EscapableDialogComponent { + readonly NO_PROFILE = { + name: 'No Risk Profile selected', + questions: [], + } as Profile; + public readonly Routes = Routes; profiles: Profile[] = []; - selectedProfile: string = ''; + selectedProfile: Profile; constructor( private readonly testRunService: TestRunService, public override dialogRef: MatDialogRef, @@ -56,12 +67,20 @@ export class DownloadZipModalComponent extends EscapableDialogComponent { this.profiles.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) ); - this.selectedProfile = this.profiles[0].name; } + this.profiles.unshift(this.NO_PROFILE); + this.selectedProfile = this.profiles[0]; } - cancel(profile?: string | null) { - this.dialogRef.close(profile); + cancel(profile?: Profile | null) { + if (profile === null) { + this.dialogRef.close(null); + } + let value = profile?.name; + if (profile && profile?.name === this.NO_PROFILE.name) { + value = ''; + } + this.dialogRef.close(value); } public getRiskClass(riskResult: string): RiskResultClassName { diff --git a/modules/ui/src/app/components/snack-bar/snack-bar.component.html b/modules/ui/src/app/components/snack-bar/snack-bar.component.html index 716198299..539623d4b 100644 --- a/modules/ui/src/app/components/snack-bar/snack-bar.component.html +++ b/modules/ui/src/app/components/snack-bar/snack-bar.component.html @@ -15,9 +15,10 @@ -->
-

The Waiting for Device stage is taking more than one minute.

+

It is taking longer than expected to find your device on the network.

- Please check device connection or stop and update system configuration. + Please check the connection to the device or stop and update your system + configuration.

diff --git a/modules/ui/src/app/components/wifi/wifi.component.html b/modules/ui/src/app/components/wifi/wifi.component.html new file mode 100644 index 000000000..c93d05f7e --- /dev/null +++ b/modules/ui/src/app/components/wifi/wifi.component.html @@ -0,0 +1,25 @@ + + diff --git a/modules/ui/src/app/components/wifi/wifi.component.scss b/modules/ui/src/app/components/wifi/wifi.component.scss new file mode 100644 index 000000000..bc0ac542e --- /dev/null +++ b/modules/ui/src/app/components/wifi/wifi.component.scss @@ -0,0 +1,40 @@ +/** + * Copyright 2023 Google LLC + * + * 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 + * + * https://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 '../../../theming/colors'; + +$icon-size: 24px; + +.app-toolbar-button { + border-radius: 20px; + border: 1px solid transparent; + min-width: 48px; + padding: 0; + box-sizing: border-box; + height: 34px; + margin: 11px 0; + line-height: 50% !important; + &.disabled { + opacity: 0.6; + } +} + +.wifi-icon { + margin-right: 0; + width: $icon-size; + font-size: $icon-size; + color: $dark-grey; + height: $icon-size; +} diff --git a/modules/ui/src/app/components/wifi/wifi.component.spec.ts b/modules/ui/src/app/components/wifi/wifi.component.spec.ts new file mode 100644 index 000000000..55e85a6a7 --- /dev/null +++ b/modules/ui/src/app/components/wifi/wifi.component.spec.ts @@ -0,0 +1,100 @@ +/** + * Copyright 2023 Google LLC + * + * 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 + * + * https://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 { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WifiComponent } from './wifi.component'; + +describe('WifiComponent', () => { + let component: WifiComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WifiComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(WifiComponent); + component = fixture.componentInstance; + compiled = fixture.nativeElement as HTMLElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Class tests', () => { + describe('with internet connection', () => { + it('should return label', () => { + expect(component.getLabel(true)).toEqual( + 'Testrun detects a working internet connection for the device under test.' + ); + }); + }); + + describe('with no internet connection', () => { + it('should return label', () => { + expect(component.getLabel(false)).toEqual( + 'No internet connection detected for the device under test.' + ); + }); + }); + + describe('with N/A internet connection', () => { + it('should return label', () => { + expect(component.getLabel(false, true)).toEqual( + 'Internet connection is not being monitored.' + ); + }); + }); + }); + + describe('DOM tests', () => { + describe('with internet connection', () => { + it('should have wifi icon', () => { + component.on = true; + fixture.detectChanges(); + + const icon = compiled.querySelector('mat-icon')?.textContent?.trim(); + + expect(icon).toEqual('wifi'); + }); + }); + + describe('should have no wifi icon', () => { + it('should have no wifi icon', () => { + component.on = false; + fixture.detectChanges(); + + const icon = compiled.querySelector('mat-icon')?.textContent?.trim(); + + expect(icon).toEqual('wifi_off'); + }); + }); + + it('button should be disabled', () => { + component.disable = true; + fixture.detectChanges(); + + const shutdownButton = compiled.querySelector( + '.wifi-button' + ) as HTMLButtonElement; + + expect(shutdownButton?.classList.contains('disabled')).toBeTrue(); + }); + }); +}); diff --git a/modules/ui/src/app/components/wifi/wifi.component.ts b/modules/ui/src/app/components/wifi/wifi.component.ts new file mode 100644 index 000000000..e7e28e8f9 --- /dev/null +++ b/modules/ui/src/app/components/wifi/wifi.component.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2023 Google LLC + * + * 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 + * + * https://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 { Component, Input } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatButton, MatIconButton } from '@angular/material/button'; + +@Component({ + selector: 'app-wifi', + standalone: true, + imports: [MatIcon, MatTooltip, MatButton, MatIconButton], + templateUrl: './wifi.component.html', + styleUrl: './wifi.component.scss', +}) +export class WifiComponent { + @Input() on: boolean | null = null; + @Input() disable: boolean = false; + + getLabel(on: boolean | null, disable: boolean = false) { + if (disable) { + return 'Internet connection is not being monitored.'; + } + return on + ? 'Testrun detects a working internet connection for the device under test.' + : 'No internet connection detected for the device under test.'; + } +} diff --git a/modules/ui/src/app/interceptors/error.interceptor.spec.ts b/modules/ui/src/app/interceptors/error.interceptor.spec.ts index 9fff32863..7271223a2 100644 --- a/modules/ui/src/app/interceptors/error.interceptor.spec.ts +++ b/modules/ui/src/app/interceptors/error.interceptor.spec.ts @@ -42,11 +42,15 @@ describe('ErrorInterceptor', () => { interceptor = TestBed.inject(ErrorInterceptor); }); + afterEach(() => { + notificationServiceMock.notify.calls.reset(); + }); + it('should be created', () => { expect(interceptor).toBeTruthy(); }); - it('should notify about backend errors', done => { + it('should notify about backend errors with message if exist', done => { const next: HttpHandler = { handle: () => { return throwError( @@ -66,6 +70,26 @@ describe('ErrorInterceptor', () => { ); }); + it('should notify about backend errors with default message', done => { + const next: HttpHandler = { + handle: () => { + return throwError(new HttpErrorResponse({ status: 500 })); + }, + }; + + const requestMock = new HttpRequest('GET', '/test'); + + interceptor.intercept(requestMock, next).subscribe( + () => ({}), + () => { + expect(notificationServiceMock.notify).toHaveBeenCalledWith( + 'Something went wrong. Check the Terminal for details.' + ); + done(); + } + ); + }); + it('should notify about other errors', done => { const next: HttpHandler = { handle: () => { @@ -79,7 +103,7 @@ describe('ErrorInterceptor', () => { () => ({}), () => { expect(notificationServiceMock.notify).toHaveBeenCalledWith( - 'Back End is not responding. Please, try again later.' + 'Testrun is not responding. Please try again in a moment.' ); done(); } @@ -99,7 +123,7 @@ describe('ErrorInterceptor', () => { () => ({}), () => { expect(notificationServiceMock.notify).toHaveBeenCalledWith( - 'Back End is not responding. Please, try again later.' + 'Testrun is not responding. Please try again in a moment.' ); done(); } diff --git a/modules/ui/src/app/interceptors/error.interceptor.ts b/modules/ui/src/app/interceptors/error.interceptor.ts index 924cbde02..9e653895a 100644 --- a/modules/ui/src/app/interceptors/error.interceptor.ts +++ b/modules/ui/src/app/interceptors/error.interceptor.ts @@ -57,17 +57,19 @@ export class ErrorInterceptor implements HttpInterceptor { catchError((error: HttpErrorResponse | TimeoutError) => { if (error instanceof TimeoutError) { this.notificationService.notify( - 'Back End is not responding. Please, try again later.' + 'Testrun is not responding. Please try again in a moment.' ); } else { if (error.status === 0) { this.notificationService.notify( - 'Back End is not responding. Please, try again later.' + 'Testrun is not responding. Please try again in a moment.' ); } else { this.notificationService.notify( - error.error?.error || error.message + error.error?.error || + 'Something went wrong. Check the Terminal for details.' ); + console.error(error.error?.error || error.message); } } return throwError(error); diff --git a/modules/ui/src/app/mocks/device.mock.ts b/modules/ui/src/app/mocks/device.mock.ts index 6066593e6..8bbfb56ea 100644 --- a/modules/ui/src/app/mocks/device.mock.ts +++ b/modules/ui/src/app/mocks/device.mock.ts @@ -43,8 +43,10 @@ export const MOCK_TEST_MODULES = [ enabled: true, }, { - displayName: 'Smart Ready', + displayName: 'Udmi', name: 'udmi', enabled: false, }, ]; + +export const MOCK_MODULES = ['Connection', 'Udmi']; diff --git a/modules/ui/src/app/mocks/profile.mock.ts b/modules/ui/src/app/mocks/profile.mock.ts index 3715685cd..d53703809 100644 --- a/modules/ui/src/app/mocks/profile.mock.ts +++ b/modules/ui/src/app/mocks/profile.mock.ts @@ -155,3 +155,57 @@ export const RENAME_PROFILE_MOCK = { name: 'Primary profile', rename: 'New profile', }; + +export const COPY_PROFILE_MOCK: Profile = { + name: 'Copy of Primary profile', + status: ProfileStatus.VALID, + questions: [ + { + question: 'What is the email of the device owner(s)?', + answer: 'boddey@google.com, cmeredith@google.com', + }, + { + question: 'What type of device do you need reviewed?', + answer: 'IoT Sensor', + }, + { + question: 'Are any of the following statements true about your device?', + answer: 'First', + }, + { + question: 'What features does the device have?', + answer: [0, 1, 2], + }, + { + question: 'Comments', + answer: 'Yes', + }, + ], +}; + +export const OUTDATED_DRAFT_PROFILE_MOCK: Profile = { + name: 'Outdated profile', + status: ProfileStatus.DRAFT, + questions: [ + { + question: 'Old question', + answer: 'qwerty', + }, + { + question: 'What is the email of the device owner(s)?', + answer: 'boddey@google.com, cmeredith@google.com', + }, + { + question: 'What type of device do you need reviewed?', + answer: 'IoT Sensor', + }, + { + question: 'Another old question', + answer: 'qwerty', + }, + ], +}; + +export const EXPIRED_PROFILE_MOCK: Profile = Object.assign({}, PROFILE_MOCK, { + status: ProfileStatus.EXPIRED, +}); diff --git a/modules/ui/src/app/mocks/reports.mock.ts b/modules/ui/src/app/mocks/reports.mock.ts index 0cfb39420..e1422a36c 100644 --- a/modules/ui/src/app/mocks/reports.mock.ts +++ b/modules/ui/src/app/mocks/reports.mock.ts @@ -28,6 +28,19 @@ export const HISTORY = [ started: '2023-07-23T10:11:00.123Z', finished: '2023-07-23T10:17:10.123Z', }, + { + mac_addr: null, + status: 'compliant', + device: { + manufacturer: 'Delta', + model: '03-DIN-SRC', + mac_addr: '01:02:03:04:05:08', + firmware: '1.2.2', + }, + report: 'https://api.testrun.io/report.pdf', + started: '2023-06-23T10:11:00.123Z', + finished: '2023-06-23T10:17:10.123Z', + }, ] as TestrunStatus[]; export const HISTORY_AFTER_REMOVE = [ @@ -43,9 +56,19 @@ export const HISTORY_AFTER_REMOVE = [ report: 'https://api.testrun.io/report.pdf', started: '2023-06-23T10:11:00.123Z', finished: '2023-06-23T10:17:10.123Z', - deviceFirmware: '1.2.2', - deviceInfo: 'Delta 03-DIN-SRC', - duration: '06m 10s', + }, + { + mac_addr: null, + status: 'compliant', + device: { + manufacturer: 'Delta', + model: '03-DIN-SRC', + mac_addr: '01:02:03:04:05:08', + firmware: '1.2.2', + }, + report: 'https://api.testrun.io/report.pdf', + started: '2023-06-23T10:11:00.123Z', + finished: '2023-06-23T10:17:10.123Z', }, ]; @@ -82,6 +105,22 @@ export const FORMATTED_HISTORY = [ deviceInfo: 'Delta 03-DIN-SRC', duration: '06m 10s', }, + { + mac_addr: null, + status: 'compliant', + device: { + manufacturer: 'Delta', + model: '03-DIN-SRC', + mac_addr: '01:02:03:04:05:08', + firmware: '1.2.2', + }, + report: 'https://api.testrun.io/report.pdf', + started: '2023-06-23T10:11:00.123Z', + finished: '2023-06-23T10:17:10.123Z', + deviceFirmware: '1.2.2', + deviceInfo: 'Delta 03-DIN-SRC', + duration: '06m 10s', + }, ]; export const FILTERS = { diff --git a/modules/ui/src/app/mocks/settings.mock.ts b/modules/ui/src/app/mocks/settings.mock.ts index baab9a2c0..49a11a895 100644 --- a/modules/ui/src/app/mocks/settings.mock.ts +++ b/modules/ui/src/app/mocks/settings.mock.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SystemConfig, SystemInterfaces } from '../model/setting'; +import { Adapters, SystemConfig, SystemInterfaces } from '../model/setting'; export const MOCK_SYSTEM_CONFIG_WITH_NO_DATA: SystemConfig = { network: { @@ -60,3 +60,8 @@ export const MOCK_PERIOD_VALUE: SystemInterfaces = { key: '600', value: 'Very slow device', }; + +export const MOCK_ADAPTERS: Adapters = { + adapters_added: { mockNewInternetKey: 'mockNewInternetValue' }, + adapters_removed: { mockInternetKey: 'mockInternetValue' }, +}; diff --git a/modules/ui/src/app/mocks/testrun.mock.ts b/modules/ui/src/app/mocks/testrun.mock.ts index 0572e79c0..bb588634c 100644 --- a/modules/ui/src/app/mocks/testrun.mock.ts +++ b/modules/ui/src/app/mocks/testrun.mock.ts @@ -65,7 +65,7 @@ const PROGRESS_DATA_RESPONSE = ( status: string, finished: string | null, tests: TestsData | IResult[], - report?: string + report: string = '' ) => { return { status, diff --git a/modules/ui/src/app/mocks/topic.mock.ts b/modules/ui/src/app/mocks/topic.mock.ts new file mode 100644 index 000000000..4309ae84f --- /dev/null +++ b/modules/ui/src/app/mocks/topic.mock.ts @@ -0,0 +1,5 @@ +import { InternetConnection } from '../model/topic'; + +export const MOCK_INTERNET: InternetConnection = { + connection: false, +}; diff --git a/modules/ui/src/app/model/profile.ts b/modules/ui/src/app/model/profile.ts index efdb779e6..059b3cafe 100644 --- a/modules/ui/src/app/model/profile.ts +++ b/modules/ui/src/app/model/profile.ts @@ -22,11 +22,6 @@ export interface Profile { created?: string; } -export interface Question { - question?: string; - answer?: string | number[]; -} - export enum FormControlType { SELECT = 'select', TEXTAREA = 'text-long', @@ -62,6 +57,7 @@ export enum ProfileRisk { export enum ProfileStatus { VALID = 'Valid', DRAFT = 'Draft', + EXPIRED = 'Expired', } export interface RiskResultClassName { diff --git a/modules/ui/src/app/model/setting.ts b/modules/ui/src/app/model/setting.ts index 5e71052f3..708dcfc94 100644 --- a/modules/ui/src/app/model/setting.ts +++ b/modules/ui/src/app/model/setting.ts @@ -48,3 +48,8 @@ export enum FormKey { export type SystemInterfaces = { [key: string]: string; }; + +export type Adapters = { + adapters_added?: SystemInterfaces; + adapters_removed?: SystemInterfaces; +}; diff --git a/modules/ui/src/app/model/testrun-status.ts b/modules/ui/src/app/model/testrun-status.ts index 2ac908185..3bc63804c 100644 --- a/modules/ui/src/app/model/testrun-status.ts +++ b/modules/ui/src/app/model/testrun-status.ts @@ -16,13 +16,13 @@ import { Device } from './device'; export interface TestrunStatus { - mac_addr: string; + mac_addr: string | null; status: string; device: IDevice; started: string | null; finished: string | null; tests?: TestsResponse; - report?: string; + report: string; } export interface HistoryTestrun extends TestrunStatus { @@ -75,7 +75,9 @@ export enum StatusOfTestResult { NotStarted = 'Not Started', InProgress = 'In Progress', Error = 'Error', // test failed to run - Info = 'Informational', // nice to know information, not necessarily compliant/non-compliant + Info = 'Informational', // nice to know information, not necessarily compliant/non-compliant, + Skipped = 'Skipped', + Disabled = 'Disabled', } export interface StatusResultClassName { @@ -85,6 +87,19 @@ export interface StatusResultClassName { grey: boolean; } +export const IDLE_STATUS = { + status: StatusOfTestrun.Idle, + device: {} as IDevice, + started: null, + finished: null, + report: '', + mac_addr: '', + tests: { + total: 0, + results: [], + }, +} as TestrunStatus; + export type TestrunStatusKey = keyof typeof StatusOfTestrun; export type TestrunStatusValue = (typeof StatusOfTestrun)[TestrunStatusKey]; export type TestResultKey = keyof typeof StatusOfTestResult; diff --git a/modules/ui/src/app/model/topic.ts b/modules/ui/src/app/model/topic.ts new file mode 100644 index 000000000..d330dbb82 --- /dev/null +++ b/modules/ui/src/app/model/topic.ts @@ -0,0 +1,9 @@ +export enum Topic { + NetworkAdapters = 'events/adapter', + InternetConnection = 'events/internet', + Status = 'status', +} + +export interface InternetConnection { + connection: boolean | null; +} diff --git a/modules/ui/src/app/pages/certificates/certificates.store.spec.ts b/modules/ui/src/app/pages/certificates/certificates.store.spec.ts index 06e3accf6..5e66104e6 100644 --- a/modules/ui/src/app/pages/certificates/certificates.store.spec.ts +++ b/modules/ui/src/app/pages/certificates/certificates.store.spec.ts @@ -42,6 +42,8 @@ describe('CertificatesStore', () => { 'uploadCertificate', 'deleteCertificate', ]); + // @ts-expect-error data layer should be defined + window.dataLayer = window.dataLayer || []; TestBed.configureTestingModule({ imports: [NoopAnimationsModule], @@ -152,6 +154,21 @@ describe('CertificatesStore', () => { container ); }); + + it('should send GA event "successful_saving_certificate"', () => { + const container = document.createElement('DIV'); + container.classList.add('certificates-drawer-content'); + document.querySelector('body')?.appendChild(container); + certificateStore.uploadCertificate(FILE); + + expect( + // @ts-expect-error data layer should be defined + window.dataLayer.some( + (item: { event: string }) => + item.event === 'successful_saving_certificate' + ) + ).toBeTruthy(); + }); }); describe('with invalid certificate file', () => { diff --git a/modules/ui/src/app/pages/certificates/certificates.store.ts b/modules/ui/src/app/pages/certificates/certificates.store.ts index 21f96eed0..610daeffb 100644 --- a/modules/ui/src/app/pages/certificates/certificates.store.ts +++ b/modules/ui/src/app/pages/certificates/certificates.store.ts @@ -97,6 +97,10 @@ export class CertificatesStore extends ComponentStore { !certificates.some(cert => cert.name === certificate.name) )[0]; this.updateCertificates(newCertificates); + // @ts-expect-error data layer is not null + window.dataLayer.push({ + event: 'successful_saving_certificate', + }); this.notify( `Certificate successfully added.\n${uploadedCertificate.name} by ${uploadedCertificate.organisation} valid until ${this.datePipe.transform(uploadedCertificate.expires, 'dd MMM yyyy')}` ); diff --git a/modules/ui/src/app/pages/devices/components/device-form/device.validators.ts b/modules/ui/src/app/pages/devices/components/device-form/device.validators.ts index 2b7b23ae1..60ceb5d48 100644 --- a/modules/ui/src/app/pages/devices/components/device-form/device.validators.ts +++ b/modules/ui/src/app/pages/devices/components/device-form/device.validators.ts @@ -22,8 +22,11 @@ import { Device } from '../../../../model/device'; * Validator uses for Device Name and Device Manufacturer inputs */ export class DeviceValidators { + static readonly STRING_FORMAT_MAX_LENGTH = 28; readonly STRING_FORMAT_REGEXP = new RegExp( - "^([a-z0-9\\p{L}\\p{M}.',-_ ]{1,28})$", + "^([a-z0-9\\p{L}\\p{M}.',-_ ]{1," + + DeviceValidators.STRING_FORMAT_MAX_LENGTH + + '})$', 'u' ); diff --git a/modules/ui/src/app/pages/devices/devices.component.html b/modules/ui/src/app/pages/devices/devices.component.html index c9f5d3aee..aef3730c5 100644 --- a/modules/ui/src/app/pages/devices/devices.component.html +++ b/modules/ui/src/app/pages/devices/devices.component.html @@ -22,8 +22,10 @@

Devices

Devices + +
{ name.dispatchEvent(new Event('input')); component.nameControl.markAsTouched(); - fixture.detectChanges(); fixture.detectChanges(); const nameError = compiled.querySelector('mat-error')?.innerHTML; @@ -388,9 +389,52 @@ describe('ProfileFormComponent', () => { }); }); }); + + describe('Discard button', () => { + beforeEach(() => { + fillForm(component); + fixture.detectChanges(); + }); + + it('should be enabled when form is filled', () => { + const discardButton = compiled.querySelector( + '.discard-button' + ) as HTMLButtonElement; + + expect(discardButton.disabled).toBeFalse(); + }); + + it('should emit discard', () => { + const emitSpy = spyOn(component.discard, 'emit'); + const discardButton = compiled.querySelector( + '.discard-button' + ) as HTMLButtonElement; + discardButton.click(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); }); describe('Class tests', () => { + describe('with outdated draft profile', () => { + beforeEach(() => { + component.selectedProfile = OUTDATED_DRAFT_PROFILE_MOCK; + fixture.detectChanges(); + }); + + it('should have an error when uses the name of copy profile', () => { + expect(component.profileForm.value).toEqual({ + 0: '', + 1: 'IoT Sensor', + 2: '', + 3: { 0: false, 1: false, 2: false }, + 4: '', + name: 'Outdated profile', + }); + }); + }); + describe('with profile', () => { beforeEach(() => { component.selectedProfile = PROFILE_MOCK; @@ -432,6 +476,15 @@ describe('ProfileFormComponent', () => { component.nameControl.hasError('has_same_profile_name') ).toBeTrue(); }); + + it('should have an error when uses the name of copy profile', () => { + component.selectedProfile = COPY_PROFILE_MOCK; + component.profiles = [PROFILE_MOCK, PROFILE_MOCK_2, COPY_PROFILE_MOCK]; + + expect( + component.nameControl.hasError('has_same_profile_name') + ).toBeTrue(); + }); }); describe('with no profile', () => { diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts index a15867ae7..567eb6c34 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts @@ -105,6 +105,7 @@ export class ProfileFormComponent implements OnInit { } @Output() saveProfile = new EventEmitter(); + @Output() discard = new EventEmitter(); constructor( private deviceValidators: DeviceValidators, private profileValidators: ProfileValidators, @@ -206,18 +207,22 @@ export class ProfileFormComponent implements OnInit { fillProfileForm(profileFormat: ProfileFormat[], profile: Profile): void { this.nameControl.setValue(profile.name); profileFormat.forEach((question, index) => { + const answer = profile.questions.find( + answers => answers.question === question.question + ); if (question.type === FormControlType.SELECT_MULTIPLE) { question.options?.forEach((item, idx) => { - if ((profile.questions[index].answer as number[])?.includes(idx)) { + if ((answer?.answer as number[])?.includes(idx)) { this.getFormGroup(index).controls[idx].setValue(true); } else { this.getFormGroup(index).controls[idx].setValue(false); } }); } else { - this.getControl(index).setValue(profile.questions[index].answer); + this.getControl(index).setValue(answer?.answer || ''); } }); + this.nameControl.markAsTouched(); this.triggerResize(); } @@ -241,6 +246,10 @@ export class ProfileFormComponent implements OnInit { } } + onDiscardClick() { + this.discard.emit(); + } + private buildResponseFromForm( initialQuestions: ProfileFormat[], profileForm: FormGroup, diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts index dcad4b397..34bac3ebf 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts @@ -37,7 +37,13 @@ export class ProfileValidators { ): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const value = control.value?.trim(); - if (value && profiles.length && (!profile || profile?.name !== value)) { + if ( + value && + profiles.length && + (!profile || + !profile.created || + (profile.created && profile?.name !== value)) + ) { const isSameProfileName = this.hasSameProfileName(value, profiles); return isSameProfileName ? { has_same_profile_name: true } : null; } diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html index 35850f0ed..31049cd93 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html @@ -13,17 +13,30 @@ See the License for the specific language governing permissions and limitations under the License. --> -
+
+ (keydown.enter)="enterProfileItem(profile)"> + [attr.aria-label]=" + profile.status === ProfileStatus.EXPIRED + ? EXPIRED_TOOLTIP + : profile.status + "> + +

- {{ profile.created | date: 'dd MMM yyyy' }} + + Outdated ({{ profile.created | date: 'dd MMM yyyy' }}) + + + {{ profile.created | date: 'dd MMM yyyy' }} +

+ diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss index a9a22b9e4..739a7bd14 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss @@ -28,15 +28,28 @@ $profile-item-container-gap: 16px; .profile-item-container { display: grid; - grid-template-columns: minmax(160px, 1fr) $profile-icon-container-size; + grid-template-columns: minmax(160px, 1fr) repeat( + 2, + $profile-icon-container-size + ); gap: $profile-item-container-gap; box-sizing: border-box; padding: 12px 16px; border-bottom: 1px solid $lighter-grey; align-items: center; - height: 92px; + min-height: 92px; + &-expired { + grid-template-columns: minmax(160px, 1fr) $profile-icon-container-size; + } } +.profile-item-container-expired .profile-item-info { + .profile-item-icon, + .profile-item-name, + .profile-item-created { + color: $red-800; + } +} .profile-item-icon-container { grid-area: icon; display: inline-block; diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts index ae48e64ec..56f9ad6a4 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts @@ -16,8 +16,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ProfileItemComponent } from './profile-item.component'; -import { PROFILE_MOCK } from '../../../mocks/profile.mock'; +import { + EXPIRED_PROFILE_MOCK, + PROFILE_MOCK, +} from '../../../mocks/profile.mock'; import { TestRunService } from '../../../services/test-run.service'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; describe('ProfileItemComponent', () => { let component: ProfileItemComponent; @@ -25,11 +29,16 @@ describe('ProfileItemComponent', () => { let compiled: HTMLElement; const testRunServiceMock = jasmine.createSpyObj(['getRiskClass']); - + const mockLiveAnnouncer = jasmine.createSpyObj('mockLiveAnnouncer', [ + 'announce', + ]); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ProfileItemComponent], - providers: [{ provide: TestRunService, useValue: testRunServiceMock }], + providers: [ + { provide: TestRunService, useValue: testRunServiceMock }, + { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, + ], }).compileComponents(); fixture = TestBed.createComponent(ProfileItemComponent); @@ -59,8 +68,12 @@ describe('ProfileItemComponent', () => { const deleteButton = fixture.nativeElement.querySelector( '.profile-item-button.delete' ); + const copyButton = fixture.nativeElement.querySelector( + '.profile-item-button.copy' + ); expect(deleteButton?.ariaLabel?.trim()).toContain(PROFILE_MOCK.name); + expect(copyButton?.ariaLabel?.trim()).toContain(PROFILE_MOCK.name); }); it('should emit delete event on delete button clicked', () => { @@ -84,4 +97,22 @@ describe('ProfileItemComponent', () => { expect(profileClickedSpy).toHaveBeenCalledWith(PROFILE_MOCK); }); + + describe('with Expired profile', () => { + beforeEach(() => { + component.enterProfileItem(EXPIRED_PROFILE_MOCK); + }); + + it('should change tooltip on enterProfileItem', () => { + expect(component.tooltip.message).toEqual( + 'This risk profile is outdated. Please create a new risk profile.' + ); + }); + + it('should announce', () => { + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + 'This risk profile is outdated. Please create a new risk profile.' + ); + }); + }); }); diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts index 79bd08833..514cbd46e 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts @@ -17,8 +17,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, + HostListener, Input, Output, + ViewChild, } from '@angular/core'; import { Profile, @@ -29,25 +31,55 @@ import { MatIcon } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { CommonModule } from '@angular/common'; import { TestRunService } from '../../../services/test-run.service'; -import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; @Component({ selector: 'app-profile-item', standalone: true, imports: [MatIcon, MatButtonModule, CommonModule, MatTooltipModule], + providers: [MatTooltip], templateUrl: './profile-item.component.html', styleUrl: './profile-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProfileItemComponent { public readonly ProfileStatus = ProfileStatus; + public readonly EXPIRED_TOOLTIP = + 'Expired. Please, create a new Risk profile.'; @Input() profile!: Profile; @Output() deleteButtonClicked = new EventEmitter(); @Output() profileClicked = new EventEmitter(); + @Output() copyProfileClicked = new EventEmitter(); - constructor(private readonly testRunService: TestRunService) {} + @ViewChild('tooltip') tooltip!: MatTooltip; + + @HostListener('focusout', ['$event']) + outEvent(): void { + if (this.profile.status === ProfileStatus.EXPIRED) { + this.tooltip.message = this.EXPIRED_TOOLTIP; + } + } + + constructor( + private readonly testRunService: TestRunService, + private liveAnnouncer: LiveAnnouncer + ) {} public getRiskClass(riskResult: string): RiskResultClassName { return this.testRunService.getRiskClass(riskResult); } + + public async enterProfileItem(profile: Profile) { + if (profile.status === ProfileStatus.EXPIRED) { + this.tooltip.message = + 'This risk profile is outdated. Please create a new risk profile.'; + this.tooltip.show(); + await this.liveAnnouncer.announce( + 'This risk profile is outdated. Please create a new risk profile.' + ); + } else { + this.profileClicked.emit(profile); + } + } } diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html index 2f11ea76b..c5e38e360 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html @@ -25,9 +25,8 @@

Risk assessment

[selectedProfile]="vm.selectedProfile" [profiles]="vm.profiles" [profileFormat]="vm.profileFormat" - (saveProfile)=" - saveProfileClicked($event, vm.selectedProfile) - "> + (saveProfile)="saveProfileClicked($event, vm.selectedProfile)" + (discard)="discard(vm.selectedProfile)">
@@ -43,16 +42,13 @@

Saved profiles

+ (profileClicked)="profileClicked($event)" + (copyProfileClicked)="copyProfileAndOpenForm($event)">
diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss index c4ef49782..c7241c6c4 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.scss @@ -61,7 +61,7 @@ .main-content { padding: 16px 32px; - overflow: scroll; + overflow: hidden; width: calc(100% - $profiles-drawer-width); } diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts index e2aa6332e..8e792ff83 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts @@ -26,7 +26,12 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestRunService } from '../../services/test-run.service'; import SpyObj = jasmine.SpyObj; import { MatSidenavModule } from '@angular/material/sidenav'; -import { NEW_PROFILE_MOCK, PROFILE_MOCK } from '../../mocks/profile.mock'; +import { + COPY_PROFILE_MOCK, + NEW_PROFILE_MOCK, + NEW_PROFILE_MOCK_DRAFT, + PROFILE_MOCK, +} from '../../mocks/profile.mock'; import { of } from 'rxjs'; import { Component, Input } from '@angular/core'; import { Profile, ProfileFormat } from '../../model/profile'; @@ -217,6 +222,13 @@ describe('RiskAssessmentComponent', () => { }); }); + describe('#getCopyOfProfile', () => { + it('should open the form with copy of profile', () => { + const copy = component.getCopyOfProfile(PROFILE_MOCK); + expect(copy).toEqual(COPY_PROFILE_MOCK); + }); + }); + describe('#saveProfile', () => { describe('with no profile selected', () => { beforeEach(() => { @@ -236,7 +248,7 @@ describe('RiskAssessmentComponent', () => { }); describe('with profile selected', () => { - it('should open save profile modal', fakeAsync(() => { + it('should open save profile modal for valid profile', fakeAsync(() => { const openSpy = spyOn(component.dialog, 'open').and.returnValue({ afterClosed: () => of(true), } as MatDialogRef); @@ -244,9 +256,31 @@ describe('RiskAssessmentComponent', () => { component.saveProfileClicked(NEW_PROFILE_MOCK, PROFILE_MOCK); expect(openSpy).toHaveBeenCalledWith(SimpleDialogComponent, { - ariaLabel: 'Save changes', + ariaLabel: 'Save profile', data: { - title: 'Save changes', + title: 'Save profile', + content: `You are about to save changes in Primary profile. Are you sure?`, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'simple-dialog', + }); + + openSpy.calls.reset(); + })); + + it('should open save draft profile modal', fakeAsync(() => { + const openSpy = spyOn(component.dialog, 'open').and.returnValue({ + afterClosed: () => of(true), + } as MatDialogRef); + + component.saveProfileClicked(NEW_PROFILE_MOCK_DRAFT, PROFILE_MOCK); + + expect(openSpy).toHaveBeenCalledWith(SimpleDialogComponent, { + ariaLabel: 'Save draft profile', + data: { + title: 'Save draft profile', content: `You are about to save changes in Primary profile. Are you sure?`, }, autoFocus: true, @@ -284,6 +318,42 @@ describe('RiskAssessmentComponent', () => { })); }); }); + + describe('#discard', () => { + describe('with no selected profile', () => { + beforeEach(() => { + component.discard(null); + }); + + it('should call setFocusOnCreateButton', () => { + expect( + mockRiskAssessmentStore.setFocusOnCreateButton + ).toHaveBeenCalled(); + }); + + it('should close the form', () => { + expect(component.isOpenProfileForm).toBeFalse(); + }); + }); + + describe('with selected profile', () => { + beforeEach(() => { + component.discard(PROFILE_MOCK); + }); + + it('should call setFocusOnCreateButton', () => { + expect( + mockRiskAssessmentStore.setFocusOnSelectedProfile + ).toHaveBeenCalled(); + }); + + it('should update selected profile', () => { + expect( + mockRiskAssessmentStore.updateSelectedProfile + ).toHaveBeenCalledWith(null); + }); + }); + }); }); }); diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts index 503d87a52..dd3d33d9d 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts @@ -24,8 +24,9 @@ import { SimpleDialogComponent } from '../../components/simple-dialog/simple-dia import { Subject, takeUntil } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { LiveAnnouncer } from '@angular/cdk/a11y'; -import { Profile } from '../../model/profile'; +import { Profile, ProfileStatus } from '../../model/profile'; import { Observable } from 'rxjs/internal/Observable'; +import { DeviceValidators } from '../devices/components/device-form/device.validators'; @Component({ selector: 'app-risk-assessment', @@ -53,6 +54,12 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { this.destroy$.unsubscribe(); } + async profileClicked(profile: Profile | null = null) { + if (profile === null || profile.status !== ProfileStatus.EXPIRED) { + await this.openForm(profile); + } + } + async openForm(profile: Profile | null = null) { this.isOpenProfileForm = true; this.store.updateSelectedProfile(profile); @@ -60,6 +67,27 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { this.store.setFocusOnProfileForm(); } + async copyProfileAndOpenForm(profile: Profile) { + await this.openForm(this.getCopyOfProfile(profile)); + } + + getCopyOfProfile(profile: Profile): Profile { + const copyOfProfile = { ...profile }; + copyOfProfile.name = this.getCopiedProfileName(profile.name); + delete copyOfProfile.created; // new profile is not create yet + return copyOfProfile; + } + + private getCopiedProfileName(name: string): string { + name = `Copy of ${name}`; + if (name.length > DeviceValidators.STRING_FORMAT_MAX_LENGTH) { + name = + name.substring(0, DeviceValidators.STRING_FORMAT_MAX_LENGTH - 3) + + '...'; + } + return name; + } + deleteProfile( profileName: string, index: number, @@ -94,7 +122,10 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { this.saveProfile(profile); this.store.setFocusOnCreateButton(); } else { - this.openSaveDialog(selectedProfile.name) + this.openSaveDialog( + selectedProfile.name, + profile.status === ProfileStatus.DRAFT + ) .pipe(takeUntil(this.destroy$)) .subscribe(saveProfile => { if (saveProfile) { @@ -105,8 +136,18 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { } } - trackByIndex = (index: number): number => { - return index; + discard(selectedProfile: Profile | null) { + this.isOpenProfileForm = false; + if (selectedProfile) { + this.store.setFocusOnSelectedProfile(); + this.store.updateSelectedProfile(null); + } else { + this.store.setFocusOnCreateButton(); + } + } + + trackByName = (index: number, item: Profile): string => { + return item.name; }; private closeFormAfterDelete(name: string, selectedProfile: Profile | null) { @@ -132,11 +173,14 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { this.store.setFocus({ nextItem, firstItem }); } - private openSaveDialog(profileName: string): Observable { + private openSaveDialog( + profileName: string, + draft: boolean = false + ): Observable { const dialogRef = this.dialog.open(SimpleDialogComponent, { - ariaLabel: 'Save changes', + ariaLabel: `Save ${draft ? 'draft profile' : 'profile'}`, data: { - title: 'Save changes', + title: `Save ${draft ? 'draft profile' : 'profile'}`, content: `You are about to save changes in ${profileName}. Are you sure?`, }, autoFocus: true, diff --git a/modules/ui/src/app/pages/settings/settings.component.html b/modules/ui/src/app/pages/settings/settings.component.html index 36849b42e..089ebd5eb 100644 --- a/modules/ui/src/app/pages/settings/settings.component.html +++ b/modules/ui/src/app/pages/settings/settings.component.html @@ -116,7 +116,7 @@

System settings

- Warning! No ports is detected. + Warning! No ports detected.
diff --git a/modules/ui/src/app/pages/settings/settings.store.spec.ts b/modules/ui/src/app/pages/settings/settings.store.spec.ts index 669faef98..b51e1f2a6 100644 --- a/modules/ui/src/app/pages/settings/settings.store.spec.ts +++ b/modules/ui/src/app/pages/settings/settings.store.spec.ts @@ -25,13 +25,17 @@ import { TestBed } from '@angular/core/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../../store/state'; import { skip, take } from 'rxjs'; -import { selectHasConnectionSettings } from '../../store/selectors'; +import { + selectAdapters, + selectHasConnectionSettings, +} from '../../store/selectors'; import { of } from 'rxjs/internal/observable/of'; import { fetchSystemConfigSuccess } from '../../store/actions'; import { fetchInterfacesSuccess } from '../../store/actions'; import { FormBuilder, FormControl } from '@angular/forms'; import { FormKey, SystemConfig } from '../../model/setting'; import { + MOCK_ADAPTERS, MOCK_DEVICE_VALUE, MOCK_INTERFACE_VALUE, MOCK_INTERFACES, @@ -60,7 +64,10 @@ describe('SettingsStore', () => { SettingsStore, { provide: TestRunService, useValue: mockService }, provideMockStore({ - selectors: [{ selector: selectHasConnectionSettings, value: true }], + selectors: [ + { selector: selectHasConnectionSettings, value: true }, + { selector: selectAdapters, value: {} }, + ], }), FormBuilder, ], @@ -308,5 +315,39 @@ describe('SettingsStore', () => { }); }); }); + + describe('adaptersUpdate', () => { + const updateInterfaces = { + mockDeviceKey: 'mockDeviceValue', + mockNewInternetKey: 'mockNewInternetValue', + }; + const updateInternetOptions = { + '': 'Not specified', + mockDeviceKey: 'mockDeviceValue', + mockNewInternetKey: 'mockNewInternetValue', + }; + + beforeEach(() => { + settingsStore.setInterfaces(MOCK_INTERFACES); + }); + + it('should update store', done => { + settingsStore.viewModel$ + .pipe(skip(3), take(1)) + .subscribe(storeValue => { + expect(storeValue.interfaces).toEqual(updateInterfaces); + expect(storeValue.deviceOptions).toEqual(updateInterfaces); + expect(storeValue.internetOptions).toEqual(updateInternetOptions); + + expect(store.dispatch).toHaveBeenCalledWith( + fetchInterfacesSuccess({ interfaces: updateInterfaces }) + ); + done(); + }); + + store.overrideSelector(selectAdapters, MOCK_ADAPTERS); + store.refreshState(); + }); + }); }); }); diff --git a/modules/ui/src/app/pages/settings/settings.store.ts b/modules/ui/src/app/pages/settings/settings.store.ts index f489228a9..fc4a00dc9 100644 --- a/modules/ui/src/app/pages/settings/settings.store.ts +++ b/modules/ui/src/app/pages/settings/settings.store.ts @@ -23,12 +23,15 @@ import { SystemConfig, SystemInterfaces, } from '../../model/setting'; -import { exhaustMap, switchMap, Observable } from 'rxjs'; +import { exhaustMap, switchMap, Observable, skip } from 'rxjs'; import { tap, withLatestFrom } from 'rxjs/operators'; import * as AppActions from '../../store/actions'; import { Store } from '@ngrx/store'; import { AppState } from '../../store/state'; -import { selectHasConnectionSettings } from '../../store/selectors'; +import { + selectAdapters, + selectHasConnectionSettings, +} from '../../store/selectors'; import { FormControl, FormGroup } from '@angular/forms'; export interface SettingsComponentState { @@ -75,6 +78,8 @@ export class SettingsStore extends ComponentStore { private hasConnectionSettings$ = this.store.select( selectHasConnectionSettings ); + + private adapters$ = this.store.select(selectAdapters); private isSubmitting$ = this.select(state => state.isSubmitting); private isLessThanOneInterfaces$ = this.select( state => state.isLessThanOneInterface @@ -108,26 +113,25 @@ export class SettingsStore extends ComponentStore { isSubmitting, })); - setInterfaces = this.updater((state, interfaces: SystemInterfaces) => ({ - ...state, - interfaces, - deviceOptions: interfaces, - internetOptions: { - ...DEFAULT_INTERNET_OPTION, - ...interfaces, - }, - isLessThanOneInterface: Object.keys(interfaces).length < 1, - })); + setInterfaces = this.updater((state, interfaces: SystemInterfaces) => { + return { + ...state, + interfaces, + deviceOptions: interfaces, + internetOptions: { + ...DEFAULT_INTERNET_OPTION, + ...interfaces, + }, + isLessThanOneInterface: Object.keys(interfaces).length < 1, + }; + }); getInterfaces = this.effect(trigger$ => { return trigger$.pipe( exhaustMap(() => { return this.testRunService.getSystemInterfaces().pipe( tap((interfaces: SystemInterfaces) => { - this.store.dispatch( - AppActions.fetchInterfacesSuccess({ interfaces }) - ); - this.setInterfaces(interfaces); + this.updateInterfaces(interfaces); }) ); }) @@ -202,6 +206,48 @@ export class SettingsStore extends ComponentStore { ); }); + adaptersUpdate = this.effect(() => { + return this.adapters$.pipe( + skip(1), + withLatestFrom(this.interfaces$), + tap(([adapters, interfaces]) => { + const updatedInterfaces = { ...interfaces }; + if (adapters.adapters_added) { + this.addInterfaces(adapters.adapters_added, updatedInterfaces); + } + if (adapters.adapters_removed) { + this.removeInterfaces(adapters.adapters_removed, updatedInterfaces); + } + this.updateInterfaces(updatedInterfaces); + }) + ); + }); + + private updateInterfaces(interfaces: SystemInterfaces) { + this.store.dispatch( + AppActions.fetchInterfacesSuccess({ interfaces: interfaces }) + ); + this.setInterfaces(interfaces); + } + + private addInterfaces( + newInterfaces: SystemInterfaces, + interfaces: SystemInterfaces + ): void { + for (const [key, value] of Object.entries(newInterfaces)) { + interfaces[key] = value; + } + } + + private removeInterfaces( + interfacesToDelete: SystemInterfaces, + interfaces: SystemInterfaces + ): void { + for (const key of Object.keys(interfacesToDelete)) { + delete interfaces[key]; + } + } + private setDefaultDeviceInterfaceValue( value: string | undefined, options: { [key: string]: string }, diff --git a/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts b/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts index dd614c9d1..2a99f17b0 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts @@ -29,7 +29,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DeviceTestsComponent } from '../../../../components/device-tests/device-tests.component'; -import { device } from '../../../../mocks/device.mock'; +import { device, MOCK_TEST_MODULES } from '../../../../mocks/device.mock'; import { of } from 'rxjs'; import { MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE } from '../../../../mocks/testrun.mock'; import { SpinnerComponent } from '../../../../components/spinner/spinner.component'; @@ -44,25 +44,12 @@ describe('ProgressInitiateFormComponent', () => { const testRunServiceMock = jasmine.createSpyObj([ 'getDevices', 'fetchDevices', - 'getTestModules', 'startTestrun', 'systemStatus$', 'getSystemStatus', 'fetchVersion', 'setIsOpenStartTestrun', ]); - testRunServiceMock.getTestModules.and.returnValue([ - { - displayName: 'Connection', - name: 'connection', - enabled: true, - }, - { - displayName: 'DNS', - name: 'dns', - enabled: false, - }, - ]); testRunServiceMock.getDevices.and.returnValue( new BehaviorSubject([device, device]) ); @@ -81,7 +68,10 @@ describe('ProgressInitiateFormComponent', () => { close: () => ({}), }, }, - { provide: MAT_DIALOG_DATA, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { testModules: MOCK_TEST_MODULES }, + }, provideMockStore({ selectors: [{ selector: selectDevices, value: [device, device] }], }), @@ -214,7 +204,7 @@ describe('ProgressInitiateFormComponent', () => { connection: { enabled: true, }, - dns: { + udmi: { enabled: true, }, }, diff --git a/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.ts b/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.ts index c1a2afb92..a526e0973 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.ts @@ -42,6 +42,7 @@ import { TestrunStatus } from '../../../../model/testrun-status'; interface DialogData { device?: Device; + testModules: TestModule[]; } @Component({ @@ -91,7 +92,7 @@ export class TestrunInitiateFormComponent ngOnInit() { this.createInitiateForm(); - this.testModules = this.testRunService.getTestModules(); + this.testModules = this.data?.testModules; if (this.data?.device) { this.deviceSelected(this.data.device); diff --git a/modules/ui/src/app/pages/testrun/testrun.component.html b/modules/ui/src/app/pages/testrun/testrun.component.html index d485d1926..74b414abf 100644 --- a/modules/ui/src/app/pages/testrun/testrun.component.html +++ b/modules/ui/src/app/pages/testrun/testrun.component.html @@ -99,7 +99,7 @@

isTestrunInProgress(systemStatus?.status) || systemStatus?.status === StatusOfTestrun.Cancelling " - (click)="openTestRunModal()" + (click)="openTestRunModal(vm.testModules)" mat-flat-button> Start New Testrun diff --git a/modules/ui/src/app/pages/testrun/testrun.component.spec.ts b/modules/ui/src/app/pages/testrun/testrun.component.spec.ts index 98d1b986f..5867bc313 100644 --- a/modules/ui/src/app/pages/testrun/testrun.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/testrun.component.spec.ts @@ -53,6 +53,7 @@ import { selectIsOpenWaitSnackBar, selectRiskProfiles, selectSystemStatus, + selectTestModules, } from '../../store/selectors'; import { TestrunStore } from './testrun.store'; import { @@ -123,6 +124,7 @@ describe('TestrunComponent', () => { { selector: selectIsOpenWaitSnackBar, value: false }, { selector: selectHasRiskProfiles, value: false }, { selector: selectRiskProfiles, value: [] }, + { selector: selectTestModules, value: [] }, { selector: selectSystemStatus, value: MOCK_PROGRESS_DATA_IN_PROGRESS, @@ -234,9 +236,17 @@ describe('TestrunComponent', () => { }, provideMockStore({ selectors: [ - { selector: selectHasDevices, value: false }, { selector: selectDevices, value: [] }, + { selector: selectHasDevices, value: false }, + { selector: selectIsOpenStartTestrun, value: false }, { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectHasRiskProfiles, value: false }, + { selector: selectRiskProfiles, value: [] }, + { selector: selectTestModules, value: [] }, + { + selector: selectSystemStatus, + value: MOCK_PROGRESS_DATA_IN_PROGRESS, + }, ], }), ], @@ -324,6 +334,9 @@ describe('TestrunComponent', () => { hasBackdrop: true, disableClose: true, panelClass: 'initiate-test-run-dialog', + data: { + testModules: [], + }, }); expect(store.dispatch).toHaveBeenCalledWith( fetchSystemStatusSuccess({ @@ -405,6 +418,7 @@ describe('TestrunComponent', () => { MOCK_PROGRESS_DATA_COMPLIANT ); store.overrideSelector(selectHasDevices, true); + store.refreshState(); fixture.detectChanges(); }); diff --git a/modules/ui/src/app/pages/testrun/testrun.component.ts b/modules/ui/src/app/pages/testrun/testrun.component.ts index b861ef953..ce5c104d5 100644 --- a/modules/ui/src/app/pages/testrun/testrun.component.ts +++ b/modules/ui/src/app/pages/testrun/testrun.component.ts @@ -34,6 +34,8 @@ import { FocusManagerService } from '../../services/focus-manager.service'; import { TestrunStore } from './testrun.store'; import { TestRunService } from '../../services/test-run.service'; import { NotificationService } from '../../services/notification.service'; +import { TestModule } from '../../model/device'; +import { combineLatest } from 'rxjs/internal/observable/combineLatest'; @Component({ selector: 'app-progress', @@ -60,11 +62,14 @@ export class TestrunComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - this.testrunStore.isOpenStartTestrun$ + combineLatest([ + this.testrunStore.isOpenStartTestrun$, + this.testrunStore.testModules$, + ]) .pipe(takeUntil(this.destroy$)) - .subscribe(isOpenStartTestrun => { + .subscribe(([isOpenStartTestrun, testModules]) => { if (isOpenStartTestrun) { - this.openTestRunModal(); + this.openTestRunModal(testModules); } }); } @@ -126,13 +131,16 @@ export class TestrunComponent implements OnInit, OnDestroy { this.destroy$.unsubscribe(); } - openTestRunModal(): void { + openTestRunModal(testModules: TestModule[]): void { const dialogRef = this.dialog.open(TestrunInitiateFormComponent, { ariaLabel: 'Initiate testrun', autoFocus: true, hasBackdrop: true, disableClose: true, panelClass: 'initiate-test-run-dialog', + data: { + testModules, + }, }); dialogRef diff --git a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts index 03f7817af..e8be0f93d 100644 --- a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts +++ b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts @@ -23,6 +23,7 @@ import { selectIsOpenStartTestrun, selectRiskProfiles, selectSystemStatus, + selectTestModules, } from '../../store/selectors'; import { fetchSystemStatus, @@ -66,6 +67,7 @@ describe('TestrunStore', () => { { selector: selectHasConnectionSettings, value: true }, { selector: selectIsOpenStartTestrun, value: false }, { selector: selectRiskProfiles, value: [] }, + { selector: selectTestModules, value: [] }, ], }), ], @@ -89,6 +91,7 @@ describe('TestrunStore', () => { dataSource: [], stepsToResolveCount: 0, profiles: [], + testModules: [], }); done(); }); diff --git a/modules/ui/src/app/pages/testrun/testrun.store.ts b/modules/ui/src/app/pages/testrun/testrun.store.ts index eacad9959..4f4edabd2 100644 --- a/modules/ui/src/app/pages/testrun/testrun.store.ts +++ b/modules/ui/src/app/pages/testrun/testrun.store.ts @@ -24,6 +24,7 @@ import { selectIsOpenStartTestrun, selectRiskProfiles, selectSystemStatus, + selectTestModules, } from '../../store/selectors'; import { fetchSystemStatus, @@ -41,12 +42,14 @@ import { } from '../../model/testrun-status'; import { FocusManagerService } from '../../services/focus-manager.service'; import { LoaderService } from '../../services/loader.service'; +import { TestModule } from '../../model/device'; const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); export interface TestrunComponentState { dataSource: IResult[] | undefined; stepsToResolveCount: number; + testModules: TestModule[]; } @Injectable() @@ -59,12 +62,15 @@ export class TestrunStore extends ComponentStore { private profiles$ = this.store.select(selectRiskProfiles); private systemStatus$ = this.store.select(selectSystemStatus); isOpenStartTestrun$ = this.store.select(selectIsOpenStartTestrun); + testModules$ = this.store.select(selectTestModules); + viewModel$ = this.select({ hasDevices: this.hasDevices$, systemStatus: this.systemStatus$, dataSource: this.dataSource$, stepsToResolveCount: this.stepsToResolveCount$, profiles: this.profiles$, + testModules: this.testModules$, }); setDataSource = this.updater((state, dataSource: IResult[] | undefined) => { @@ -215,6 +221,7 @@ export class TestrunStore extends ComponentStore { super({ dataSource: undefined, stepsToResolveCount: 0, + testModules: [], }); } } diff --git a/modules/ui/src/app/services/test-run-mqtt.service.spec.ts b/modules/ui/src/app/services/test-run-mqtt.service.spec.ts new file mode 100644 index 000000000..19bda437a --- /dev/null +++ b/modules/ui/src/app/services/test-run-mqtt.service.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from '@angular/core/testing'; + +import { TestRunMqttService } from './test-run-mqtt.service'; +import { IMqttMessage, MqttModule, MqttService } from 'ngx-mqtt'; +import { MQTT_SERVICE_OPTIONS } from '../app.module'; +import SpyObj = jasmine.SpyObj; +import { of } from 'rxjs'; +import { MOCK_ADAPTERS } from '../mocks/settings.mock'; +import { Topic } from '../model/topic'; +import { MOCK_INTERNET } from '../mocks/topic.mock'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../mocks/testrun.mock'; + +describe('TestRunMqttService', () => { + let service: TestRunMqttService; + let mockService: SpyObj; + + beforeEach(() => { + mockService = jasmine.createSpyObj(['observe']); + + TestBed.configureTestingModule({ + imports: [MqttModule.forRoot(MQTT_SERVICE_OPTIONS)], + providers: [{ provide: MqttService, useValue: mockService }], + }); + service = TestBed.inject(TestRunMqttService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getNetworkAdapters', () => { + beforeEach(() => { + mockService.observe.and.returnValue(of(getResponse(MOCK_ADAPTERS))); + }); + + it('should subscribe the topic', done => { + service.getNetworkAdapters().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith(Topic.NetworkAdapters); + done(); + }); + }); + + it('should return object of type', done => { + service.getNetworkAdapters().subscribe(res => { + expect(res).toEqual(MOCK_ADAPTERS); + done(); + }); + }); + }); + + describe('getInternetConnection', () => { + beforeEach(() => { + mockService.observe.and.returnValue(of(getResponse(MOCK_INTERNET))); + }); + + it('should subscribe the topic', done => { + service.getInternetConnection().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith( + Topic.InternetConnection + ); + done(); + }); + }); + + it('should return object of type', done => { + service.getInternetConnection().subscribe(res => { + expect(res).toEqual(MOCK_INTERNET); + done(); + }); + }); + }); + + describe('getStatus', () => { + beforeEach(() => { + mockService.observe.and.returnValue( + of(getResponse(MOCK_PROGRESS_DATA_IN_PROGRESS)) + ); + }); + + it('should subscribe the topic', done => { + service.getStatus().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith(Topic.Status); + done(); + }); + }); + + it('should return object of type', done => { + service.getStatus().subscribe(res => { + expect(res).toEqual(MOCK_PROGRESS_DATA_IN_PROGRESS); + done(); + }); + }); + }); + + function getResponse(response: Type): IMqttMessage { + const enc = new TextEncoder(); + const message = enc.encode(JSON.stringify(response)); + return { + payload: message, + } as IMqttMessage; + } +}); diff --git a/modules/ui/src/app/services/test-run-mqtt.service.ts b/modules/ui/src/app/services/test-run-mqtt.service.ts new file mode 100644 index 000000000..d5e805da6 --- /dev/null +++ b/modules/ui/src/app/services/test-run-mqtt.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { IMqttMessage, MqttService } from 'ngx-mqtt'; +import { catchError, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Adapters } from '../model/setting'; +import { TestrunStatus } from '../model/testrun-status'; +import { InternetConnection, Topic } from '../model/topic'; + +@Injectable({ + providedIn: 'root', +}) +export class TestRunMqttService { + constructor(private mqttService: MqttService) {} + + getNetworkAdapters(): Observable { + return this.topic(Topic.NetworkAdapters); + } + + getInternetConnection(): Observable { + return this.topic(Topic.InternetConnection); + } + + getStatus(): Observable { + return this.topic(Topic.Status); + } + + private topic(topicName: string): Observable { + return this.mqttService.observe(topicName).pipe( + map( + (res: IMqttMessage) => + JSON.parse(new TextDecoder().decode(res.payload)) as Type + ), + catchError(() => { + return of({} as Type); + }) + ); + } +} diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index 069c94c0a..c3c40185e 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -18,7 +18,7 @@ import { HttpTestingController, } from '@angular/common/http/testing'; import { fakeAsync, getTestBed, TestBed, tick } from '@angular/core/testing'; -import { Device, TestModule } from '../model/device'; +import { Device } from '../model/device'; import { TestRunService, UNAVAILABLE_VERSION } from './test-run.service'; import { SystemConfig, SystemInterfaces } from '../model/setting'; @@ -28,7 +28,7 @@ import { StatusOfTestrun, TestrunStatus, } from '../model/testrun-status'; -import { device } from '../mocks/device.mock'; +import { device, MOCK_MODULES } from '../mocks/device.mock'; import { NEW_VERSION, VERSION } from '../mocks/version.mock'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../store/state'; @@ -74,39 +74,23 @@ describe('TestRunService', () => { expect(service).toBeTruthy(); }); - it('should have test modules', () => { - expect(service.getTestModules()).toEqual([ - { - displayName: 'Connection', - name: 'connection', - enabled: true, - }, - { - displayName: 'NTP', - name: 'ntp', - enabled: true, - }, - { - displayName: 'DNS', - name: 'dns', - enabled: true, - }, - { - displayName: 'Services', - name: 'services', - enabled: true, - }, - { - displayName: 'TLS', - name: 'tls', - enabled: true, - }, - { - displayName: 'Protocol', - name: 'protocol', - enabled: true, - }, - ] as TestModule[]); + it('getTestModules should return modules', () => { + let result: string[] = []; + const testModules = MOCK_MODULES; + + service.getTestModules().subscribe(res => { + expect(res).toEqual(result); + }); + + result = testModules; + service.getTestModules(); + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/modules' + ); + + expect(req.request.method).toBe('GET'); + + req.flush(testModules); }); it('fetchDevices should return devices', () => { @@ -284,6 +268,8 @@ describe('TestRunService', () => { const statusesForGreyRes = [ StatusOfTestResult.NotDetected, StatusOfTestResult.NotStarted, + StatusOfTestResult.Skipped, + StatusOfTestResult.Disabled, ]; statusesForGreenRes.forEach(testCase => { diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index 5620f9404..8d913ba61 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -123,8 +123,10 @@ export class TestRunService { .pipe(map(() => true)); } - getTestModules(): TestModule[] { - return this.testModules; + getTestModules(): Observable { + return this.http + .get(`${API_URL}/system/modules`) + .pipe(catchError(() => of([]))); } saveDevice(device: Device): Observable { @@ -183,7 +185,9 @@ export class TestRunService { result === StatusOfTestResult.InProgress, grey: result === StatusOfTestResult.NotDetected || - result === StatusOfTestResult.NotStarted, + result === StatusOfTestResult.NotStarted || + result === StatusOfTestResult.Skipped || + result === StatusOfTestResult.Disabled, }; } diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index 3ca38d16f..806618932 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -16,12 +16,13 @@ import { createAction, props } from '@ngrx/store'; import { + Adapters, InterfacesValidation, SettingMissedError, SystemConfig, } from '../model/setting'; import { SystemInterfaces } from '../model/setting'; -import { Device } from '../model/device'; +import { Device, TestModule } from '../model/device'; import { TestrunStatus } from '../model/testrun-status'; import { Profile } from '../model/profile'; @@ -124,3 +125,25 @@ export const setStatus = createAction( export const stopInterval = createAction('[Shared] Stop Interval'); export const fetchRiskProfiles = createAction('[Shared] Fetch risk profiles'); + +export const updateAdapters = createAction( + '[Shared] Update Adapters', + props<{ adapters: Adapters }>() +); + +export const fetchReports = createAction('[Shared] Fetch reports'); + +export const setReports = createAction( + '[Shared] Set Reports', + props<{ reports: TestrunStatus[] }>() +); + +export const setTestModules = createAction( + '[Shared] Set Test Modules', + props<{ testModules: TestModule[] }>() +); + +export const updateInternetConnection = createAction( + '[Shared] Fetch internet connection', + props<{ internetConnection: boolean | null }>() +); diff --git a/modules/ui/src/app/store/effects.spec.ts b/modules/ui/src/app/store/effects.spec.ts index 024782c63..7d33cc209 100644 --- a/modules/ui/src/app/store/effects.spec.ts +++ b/modules/ui/src/app/store/effects.spec.ts @@ -36,12 +36,24 @@ import { import { device } from '../mocks/device.mock'; import { MOCK_PROGRESS_DATA_CANCELLING, + MOCK_PROGRESS_DATA_COMPLIANT, MOCK_PROGRESS_DATA_IN_PROGRESS, MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE, } from '../mocks/testrun.mock'; -import { fetchSystemStatus, setStatus, setTestrunStatus } from './actions'; +import { + fetchSystemStatus, + fetchSystemStatusSuccess, + setReports, + setStatus, + setTestrunStatus, +} from './actions'; import { NotificationService } from '../services/notification.service'; import { PROFILE_MOCK } from '../mocks/profile.mock'; +import { throwError } from 'rxjs/internal/observable/throwError'; +import { HttpErrorResponse } from '@angular/common/http'; +import { IDLE_STATUS } from '../model/testrun-status'; +import { HISTORY } from '../mocks/reports.mock'; +import { TestRunMqttService } from '../services/test-run-mqtt.service'; describe('Effects', () => { let actions$ = new Observable(); @@ -54,6 +66,11 @@ describe('Effects', () => { 'dismissWithTimout', 'openSnackBar', ]); + const mockMqttService: jasmine.SpyObj = + jasmine.createSpyObj('mockMqttService', [ + 'getStatus', + 'getInternetConnection', + ]); beforeEach(() => { testRunServiceMock = jasmine.createSpyObj('testRunServiceMock', [ @@ -64,6 +81,7 @@ describe('Effects', () => { 'testrunInProgress', 'stopTestrun', 'fetchProfiles', + 'getHistory', ]); testRunServiceMock.getSystemInterfaces.and.returnValue(of({})); testRunServiceMock.getSystemConfig.and.returnValue(of({ network: {} })); @@ -72,12 +90,21 @@ describe('Effects', () => { of(MOCK_PROGRESS_DATA_IN_PROGRESS) ); testRunServiceMock.fetchProfiles.and.returnValue(of([])); + testRunServiceMock.getHistory.and.returnValue(of([])); + mockMqttService.getInternetConnection.and.returnValue( + of({ connection: false }) + ); + + mockMqttService.getStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); TestBed.configureTestingModule({ providers: [ AppEffects, { provide: TestRunService, useValue: testRunServiceMock }, { provide: NotificationService, useValue: notificationServiceMock }, + { provide: TestRunMqttService, useValue: mockMqttService }, provideMockActions(() => actions$), provideMockStore({}), ], @@ -387,14 +414,15 @@ describe('Effects', () => { ); }); - it('should call fetchSystemStatus for status "in progress"', fakeAsync(() => { + it('should call fetchSystemStatus for status "in progress"', () => { effects.onFetchSystemStatusSuccess$.subscribe(() => { - tick(5000); - - expect(dispatchSpy).toHaveBeenCalledWith(fetchSystemStatus()); - discardPeriodicTasks(); + expect(dispatchSpy).toHaveBeenCalledWith( + fetchSystemStatusSuccess({ + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + }) + ); }); - })); + }); it('should dispatch status and systemStatus', done => { effects.onFetchSystemStatusSuccess$.subscribe(() => { @@ -423,6 +451,12 @@ describe('Effects', () => { done(); }); }); + + it('should call fetchInternetConnection for status "in progress"', () => { + effects.onFetchSystemStatusSuccess$.subscribe(() => { + expect(mockMqttService.getInternetConnection).toHaveBeenCalled(); + }); + }); }); describe('with status "waiting for device"', () => { @@ -439,14 +473,15 @@ describe('Effects', () => { ); }); - it('should call fetchSystemStatus for status "waiting for device"', fakeAsync(() => { + it('should call fetchSystemStatus for status "waiting for device"', () => { effects.onFetchSystemStatusSuccess$.subscribe(() => { - tick(5000); - - expect(dispatchSpy).toHaveBeenCalledWith(fetchSystemStatus()); - discardPeriodicTasks(); + expect(dispatchSpy).toHaveBeenCalledWith( + fetchSystemStatusSuccess({ + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + }) + ); }); - })); + }); it('should open snackbar when waiting for device is too long', fakeAsync(() => { effects.onFetchSystemStatusSuccess$.subscribe(() => { @@ -487,4 +522,78 @@ describe('Effects', () => { done(); }); }); + + describe('onFetchReports$', () => { + it(' should call setReports on success', done => { + testRunServiceMock.getHistory.and.returnValue(of([])); + actions$ = of(actions.fetchReports()); + + effects.onFetchReports$.subscribe(action => { + expect(action).toEqual( + actions.setReports({ + reports: [], + }) + ); + done(); + }); + }); + + it('should call setReports with empty array if null is returned', done => { + testRunServiceMock.getHistory.and.returnValue(of(null)); + actions$ = of(actions.fetchReports()); + + effects.onFetchReports$.subscribe(action => { + expect(action).toEqual( + actions.setReports({ + reports: [], + }) + ); + done(); + }); + }); + + it('should call setReports with empty array if error happens', done => { + testRunServiceMock.getHistory.and.returnValue( + throwError( + new HttpErrorResponse({ error: { error: 'error' }, status: 500 }) + ) + ); + actions$ = of(actions.fetchReports()); + + effects.onFetchReports$.subscribe({ + complete: () => { + expect(dispatchSpy).toHaveBeenCalledWith( + setReports({ + reports: [], + }) + ); + done(); + }, + }); + }); + }); + + describe('checkStatusInReports$', () => { + it('should call setTestrunStatus if current test run is completed and not present in reports', done => { + store.overrideSelector( + selectSystemStatus, + Object.assign({}, MOCK_PROGRESS_DATA_COMPLIANT, { + mac_addr: '01:02:03:04:05:07', + report: 'http://localhost:8000/report/1234 1234/2024-07-17T15:33:40', + }) + ); + actions$ = of( + actions.setReports({ + reports: HISTORY, + }) + ); + + effects.checkStatusInReports$.subscribe(action => { + expect(action).toEqual( + actions.setTestrunStatus({ systemStatus: IDLE_STATUS }) + ); + done(); + }); + }); + }); }); diff --git a/modules/ui/src/app/store/effects.ts b/modules/ui/src/app/store/effects.ts index b6cdfcc71..5fdb4d461 100644 --- a/modules/ui/src/app/store/effects.ts +++ b/modules/ui/src/app/store/effects.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Injectable, NgZone } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; @@ -22,29 +22,49 @@ import { map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import * as AppActions from './actions'; import { AppState } from './state'; import { TestRunService } from '../services/test-run.service'; -import { filter, combineLatest, interval, Subject, timer, take } from 'rxjs'; +import { + filter, + combineLatest, + Subject, + timer, + take, + catchError, + EMPTY, + Subscription, +} from 'rxjs'; import { selectIsOpenWaitSnackBar, selectMenuOpened, selectSystemStatus, } from './selectors'; -import { IResult, StatusOfTestrun, TestsData } from '../model/testrun-status'; +import { + IDLE_STATUS, + IResult, + StatusOfTestrun, + TestrunStatus, + TestsData, +} from '../model/testrun-status'; import { fetchSystemStatus, + fetchSystemStatusSuccess, + setReports, setStatus, setTestrunStatus, stopInterval, + updateInternetConnection, } from './actions'; import { takeUntil } from 'rxjs/internal/operators/takeUntil'; import { NotificationService } from '../services/notification.service'; import { Profile } from '../model/profile'; +import { TestRunMqttService } from '../services/test-run-mqtt.service'; +import { InternetConnection } from '../model/topic'; const WAIT_TO_OPEN_SNACKBAR_MS = 60 * 1000; @Injectable() export class AppEffects { - private startInterval = false; - private destroyInterval$: Subject = new Subject(); + private statusSubscription: Subscription | undefined; + private internetSubscription: Subscription | undefined; private destroyWaitDeviceInterval$: Subject = new Subject(); checkInterfacesInConfig$ = createEffect(() => @@ -190,8 +210,8 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.stopInterval), tap(() => { - this.startInterval = false; - this.destroyInterval$.next(true); + this.statusSubscription?.unsubscribe(); + this.internetSubscription?.unsubscribe(); }) ); }, @@ -203,11 +223,9 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.fetchSystemStatusSuccess), tap(({ systemStatus }) => { - if ( - this.testrunService.testrunInProgress(systemStatus.status) && - !this.startInterval - ) { + if (this.testrunService.testrunInProgress(systemStatus.status)) { this.pullingSystemStatusData(); + this.fetchInternetConnection(); } else if ( !this.testrunService.testrunInProgress(systemStatus.status) ) { @@ -235,12 +253,10 @@ export class AppEffects { tap(([{ systemStatus }, , status]) => { // for app - requires only status if (systemStatus.status !== status?.status) { - this.ngZone.run(() => { - this.store.dispatch(setStatus({ status: systemStatus.status })); - this.store.dispatch( - setTestrunStatus({ systemStatus: systemStatus }) - ); - }); + this.store.dispatch(setStatus({ status: systemStatus.status })); + this.store.dispatch( + setTestrunStatus({ systemStatus: systemStatus }) + ); } else if ( systemStatus.finished !== status?.finished || (systemStatus.tests as TestsData)?.results?.length !== @@ -248,11 +264,9 @@ export class AppEffects { (systemStatus.tests as IResult[])?.length !== (status?.tests as IResult[])?.length ) { - this.ngZone.run(() => { - this.store.dispatch( - setTestrunStatus({ systemStatus: systemStatus }) - ); - }); + this.store.dispatch( + setTestrunStatus({ systemStatus: systemStatus }) + ); } }) ); @@ -273,6 +287,53 @@ export class AppEffects { ); }); + onFetchReports$ = createEffect(() => { + return this.actions$.pipe( + ofType(AppActions.fetchReports), + switchMap(() => + this.testrunService.getHistory().pipe( + map((reports: TestrunStatus[] | null) => { + if (reports !== null) { + return AppActions.setReports({ reports }); + } + return AppActions.setReports({ reports: [] }); + }), + catchError(() => { + this.store.dispatch(setReports({ reports: [] })); + return EMPTY; + }) + ) + ) + ); + }); + + checkStatusInReports$ = createEffect(() => { + return this.actions$.pipe( + ofType(AppActions.setReports), + withLatestFrom(this.store.select(selectSystemStatus)), + filter(([, systemStatus]) => { + return ( + systemStatus != null && this.isTestrunFinished(systemStatus.status) + ); + }), + filter(([{ reports }, systemStatus]) => { + return ( + !reports?.some(report => report.report === systemStatus!.report) || + false + ); + }), + map(() => AppActions.setTestrunStatus({ systemStatus: IDLE_STATUS })) + ); + }); + + private isTestrunFinished(status: string) { + return ( + status === StatusOfTestrun.Compliant || + status === StatusOfTestrun.NonCompliant || + status === StatusOfTestrun.Error + ); + } + private showSnackBar() { timer(WAIT_TO_OPEN_SNACKBAR_MS) .pipe( @@ -290,22 +351,40 @@ export class AppEffects { } private pullingSystemStatusData(): void { - this.ngZone.runOutsideAngular(() => { - this.startInterval = true; - interval(5000) - .pipe( - takeUntil(this.destroyInterval$), - tap(() => this.store.dispatch(fetchSystemStatus())) - ) - .subscribe(); - }); + if ( + this.statusSubscription === undefined || + this.statusSubscription?.closed + ) { + this.statusSubscription = this.testrunMqttService + .getStatus() + .subscribe(systemStatus => { + this.store.dispatch(fetchSystemStatusSuccess({ systemStatus })); + }); + } + } + + private fetchInternetConnection() { + if ( + this.internetSubscription === undefined || + this.internetSubscription?.closed + ) { + this.internetSubscription = this.testrunMqttService + .getInternetConnection() + .subscribe((internetConnection: InternetConnection) => { + this.store.dispatch( + updateInternetConnection({ + internetConnection: internetConnection.connection, + }) + ); + }); + } } constructor( private actions$: Actions, private testrunService: TestRunService, + private testrunMqttService: TestRunMqttService, private store: Store, - private ngZone: NgZone, private notificationService: NotificationService ) {} } diff --git a/modules/ui/src/app/store/reducers.spec.ts b/modules/ui/src/app/store/reducers.spec.ts index ad611e9f9..b6fe9d675 100644 --- a/modules/ui/src/app/store/reducers.spec.ts +++ b/modules/ui/src/app/store/reducers.spec.ts @@ -25,16 +25,22 @@ import { setIsOpenAddDevice, setIsOpenStartTestrun, setIsOpenWaitSnackBar, + setReports, setRiskProfiles, setStatus, + setTestModules, setTestrunStatus, toggleMenu, + updateAdapters, updateError, updateFocusNavigation, + updateInternetConnection, } from './actions'; -import { device } from '../mocks/device.mock'; +import { device, MOCK_TEST_MODULES } from '../mocks/device.mock'; import { MOCK_PROGRESS_DATA_CANCELLING } from '../mocks/testrun.mock'; import { PROFILE_MOCK } from '../mocks/profile.mock'; +import { HISTORY } from '../mocks/reports.mock'; +import { MOCK_ADAPTERS } from '../mocks/settings.mock'; describe('Reducer', () => { describe('unknown action', () => { @@ -258,4 +264,67 @@ describe('Reducer', () => { expect(state).not.toBe(initialState); }); }); + + describe('setReports action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setReports({ + reports: HISTORY, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ reports: HISTORY }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setTestModules action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setTestModules({ + testModules: MOCK_TEST_MODULES, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ testModules: MOCK_TEST_MODULES }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('updateAdapters action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = updateAdapters({ + adapters: MOCK_ADAPTERS, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ adapters: MOCK_ADAPTERS }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('updateInternetConnection action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = updateInternetConnection({ internetConnection: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ internetConnection: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); }); diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index 501c231a5..dfc54b11f 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -106,6 +106,30 @@ export const sharedReducer = createReducer( ...state, status, }; + }), + on(Actions.setReports, (state, { reports }) => { + return { + ...state, + reports, + }; + }), + on(Actions.setTestModules, (state, { testModules }) => { + return { + ...state, + testModules, + }; + }), + on(Actions.updateAdapters, (state, { adapters }) => { + return { + ...state, + adapters, + }; + }), + on(Actions.updateInternetConnection, (state, { internetConnection }) => { + return { + ...state, + internetConnection, + }; }) ); diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index e8d31efc8..facc8bb74 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -16,6 +16,7 @@ import { AppState } from './state'; import { + selectAdapters, selectDeviceInProgress, selectDevices, selectError, @@ -27,9 +28,12 @@ import { selectIsOpenStartTestrun, selectIsOpenWaitSnackBar, selectMenuOpened, + selectReports, selectRiskProfiles, selectStatus, selectSystemStatus, + selectTestModules, + selectInternetConnection, } from './selectors'; describe('Selectors', () => { @@ -55,6 +59,10 @@ describe('Selectors', () => { systemStatus: null, deviceInProgress: null, status: null, + reports: [], + testModules: [], + adapters: {}, + internetConnection: null, }, }; @@ -127,4 +135,24 @@ describe('Selectors', () => { const result = selectStatus.projector(initialState); expect(result).toEqual(null); }); + + it('should select status', () => { + const result = selectReports.projector(initialState); + expect(result).toEqual([]); + }); + + it('should select testModules', () => { + const result = selectTestModules.projector(initialState); + expect(result).toEqual([]); + }); + + it('should select adapters', () => { + const result = selectAdapters.projector(initialState); + expect(result).toEqual({}); + }); + + it('should select internetConnection', () => { + const result = selectInternetConnection.projector(initialState); + expect(result).toEqual(null); + }); }); diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 2f42db3d6..383fee1b9 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -93,3 +93,23 @@ export const selectStatus = createSelector( selectAppState, (state: AppState) => state.shared.status ); + +export const selectReports = createSelector( + selectAppState, + (state: AppState) => state.shared.reports +); + +export const selectTestModules = createSelector( + selectAppState, + (state: AppState) => state.shared.testModules +); + +export const selectAdapters = createSelector( + selectAppState, + (state: AppState) => state.shared.adapters +); + +export const selectInternetConnection = createSelector( + selectAppState, + (state: AppState) => state.shared.internetConnection +); diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index e2528c5a0..76e2d3254 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -14,8 +14,12 @@ * limitations under the License. */ import { TestrunStatus } from '../model/testrun-status'; -import { SettingMissedError, SystemInterfaces } from '../model/setting'; -import { Device } from '../model/device'; +import { Device, TestModule } from '../model/device'; +import { + Adapters, + SettingMissedError, + SystemInterfaces, +} from '../model/setting'; import { Profile } from '../model/profile'; export interface AppState { @@ -54,6 +58,10 @@ export interface SharedState { isStopTestrun: boolean; isOpenWaitSnackBar: boolean; deviceInProgress: Device | null; + reports: TestrunStatus[]; + testModules: TestModule[]; + adapters: Adapters; + internetConnection: boolean | null; } export const initialAppComponentState: AppComponentState = { @@ -78,4 +86,8 @@ export const initialSharedState: SharedState = { isOpenStartTestrun: false, systemStatus: null, status: null, + reports: [], + testModules: [], + adapters: {}, + internetConnection: null, }; diff --git a/modules/ui/ui.Dockerfile b/modules/ui/ui.Dockerfile index da56be93e..7ecb32dbd 100644 --- a/modules/ui/ui.Dockerfile +++ b/modules/ui/ui.Dockerfile @@ -12,17 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Image name: test-run/ui -FROM node@sha256:ffebb4405810c92d267a764b21975fb2d96772e41877248a37bf3abaa0d3b590 as build - -WORKDIR /modules/ui -COPY modules/ui/ /modules/ui -RUN npm install -RUN npm run build - +# Image name: testrun/ui FROM nginx@sha256:4c0fdaa8b6341bfdeca5f18f7837462c80cff90527ee35ef185571e1c327beac -COPY --from=build /modules/ui/dist/ /usr/share/nginx/html +COPY modules/ui/dist/ /usr/share/nginx/html EXPOSE 8080 diff --git a/modules/ws/conf/mosquitto.conf b/modules/ws/conf/mosquitto.conf new file mode 100644 index 000000000..9027ba814 --- /dev/null +++ b/modules/ws/conf/mosquitto.conf @@ -0,0 +1,22 @@ +## Logging + +log_dest stdout +log_type all +log_timestamp true +connection_messages true + +## MQTT Listener + +listener 1883 +protocol mqtt + +## WebSockets Listener + +listener 9001 +protocol websockets + +allow_anonymous true + +## Persistence + +persistence false \ No newline at end of file diff --git a/modules/ws/ws.Dockerfile b/modules/ws/ws.Dockerfile new file mode 100644 index 000000000..7e9408a47 --- /dev/null +++ b/modules/ws/ws.Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-mosquitto:2.0.18 +RUN mkdir -p /mosquitto/data/ +COPY modules/ws/conf/mosquitto.conf /mosquitto/config/mosquitto.conf +VOLUME /mosquitto/data/ \ No newline at end of file diff --git a/testing/api/profiles/new_profile.json b/testing/api/profiles/new_profile.json new file mode 100644 index 000000000..d63ecd17c --- /dev/null +++ b/testing/api/profiles/new_profile.json @@ -0,0 +1,54 @@ +{ + "name": "New Profile", + "status": "Valid", + "questions": [ + { + "question": "What type of device is this?", + "answer": "IoT Gateway" + }, + { + "question": "How will this device be used at Google?", + "answer": "Monitoring" + }, + { + "question": "Is this device going to be managed by Google or a third party?", + "answer": "Google" + }, + { + "question": "Will the third-party device administrator be able to grant access to authorized Google personnel upon request?", + "answer": "N/A" + }, + { + "question": "Are any of the following statements true about your device?", + "answer": [ + 0 + ] + }, + { + "question": "Which of the following statements are true about this device?", + "answer": [ + 0 + ] + }, + { + "question": "Does the network protocol assure server-to-client identity verification?", + "answer": "Yes" + }, + { + "question": "Click the statements that best describe the characteristics of this device.", + "answer": [ + 0 + ] + }, + { + "question": "Are any of the following statements true about this device?", + "answer": [ + 0 + ] + }, + { + "question": "Comments", + "answer": "" + } + ] + } \ No newline at end of file diff --git a/testing/api/profiles/new_profile_2.json b/testing/api/profiles/new_profile_2.json new file mode 100644 index 000000000..2ac93dc17 --- /dev/null +++ b/testing/api/profiles/new_profile_2.json @@ -0,0 +1,56 @@ +{ + "name": "New Profile 2", + "status": "Draft", + "questions": [ + { + "question": "What type of device is this?", + "answer": "IoT Gateway" + }, + { + "question": "How will this device be used at Google?", + "answer": "Installed in a building" + }, + { + "question": "Is this device going to be managed by Google or a third party?", + "answer": "Google" + }, + { + "question": "Will the third-party device administrator be able to grant access to authorized Google personnel upon request?", + "answer": "Yes" + }, + { + "question": "Are any of the following statements true about your device?", + "answer": [ + 0, + 2 + ] + }, + { + "question": "Which of the following statements are true about this device?", + "answer": [ + 0, + 1, + 5 + ] + }, + { + "question": "Does the network protocol assure server-to-client identity verification?", + "answer": "Yes" + }, + { + "question": "Click the statements that best describe the characteristics of this device.", + "answer": [ + 0, + 1, + 2 + ] + }, + { + "question": "Are any of the following statements true about this device?", + "answer": [ + 2, + 3 + ] + } + ] +} \ No newline at end of file diff --git a/testing/api/profiles/updated_profile.json b/testing/api/profiles/updated_profile.json new file mode 100644 index 000000000..91714bcfa --- /dev/null +++ b/testing/api/profiles/updated_profile.json @@ -0,0 +1,57 @@ +{ + "name": "New Profile", + "rename": "Updated Profile", + "status": "Draft", + "questions": [ + { + "question": "What type of device is this?", + "answer": "IoT Gateway" + }, + { + "question": "How will this device be used at Google?", + "answer": "Installed in a building" + }, + { + "question": "Is this device going to be managed by Google or a third party?", + "answer": "Google" + }, + { + "question": "Will the third-party device administrator be able to grant access to authorized Google personnel upon request?", + "answer": "Yes" + }, + { + "question": "Are any of the following statements true about your device?", + "answer": [ + 0, + 2 + ] + }, + { + "question": "Which of the following statements are true about this device?", + "answer": [ + 0, + 1, + 5 + ] + }, + { + "question": "Does the network protocol assure server-to-client identity verification?", + "answer": "Yes" + }, + { + "question": "Click the statements that best describe the characteristics of this device.", + "answer": [ + 0, + 1, + 2 + ] + }, + { + "question": "Are any of the following statements true about this device?", + "answer": [ + 2, + 3 + ] + } + ] +} \ No newline at end of file diff --git a/testing/api/test_api.py b/testing/api/test_api.py index 75811e3bb..70c1a617f 100644 --- a/testing/api/test_api.py +++ b/testing/api/test_api.py @@ -29,6 +29,7 @@ import pytest import requests + ALL_DEVICES = "*" API = "http://127.0.0.1:8000" LOG_PATH = "/tmp/testrun.log" @@ -36,7 +37,10 @@ DEVICES_DIRECTORY = "local/devices" TESTING_DEVICES = "../device_configs" +PROFILES_DIRECTORY = "local/risk_profiles" SYSTEM_CONFIG_PATH = "local/system.json" +SYSTEM_CONFIG_RESTORE_PATH = "testing/api/system.json" +PROFILES_PATH = "testing/api/profiles" BASELINE_MAC_ADDR = "02:42:aa:00:01:01" ALL_MAC_ADDR = "02:42:aa:00:00:01" @@ -45,21 +49,18 @@ def pretty_print(dictionary: dict): """ Pretty print dictionary """ print(json.dumps(dictionary, indent=4)) - def query_system_status() -> str: """Query system status from API and returns this""" r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) + response = r.json() return response["status"] - def query_test_count() -> int: """Queries status and returns number of test results""" r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) + response = r.json() return len(response["tests"]["results"]) - def start_test_device( device_name, mac_address, image_name="test-run/ci_device_1", args="" ): @@ -74,7 +75,6 @@ def start_test_device( ) print(cmd.stdout) - def stop_test_device(device_name): """ Stop docker container with given name """ cmd = subprocess.run( @@ -88,7 +88,6 @@ def stop_test_device(device_name): ) print(cmd.stdout) - def docker_logs(device_name): """ Print docker logs from given docker container name """ cmd = subprocess.run( @@ -97,13 +96,23 @@ def docker_logs(device_name): ) print(cmd.stdout) +def load_json(file_name, directory): + """Utility method to load json files' """ + # Construct the base path relative to the main folder + base_path = Path(__file__).resolve().parent.parent.parent + # Construct the full file path + file_path = base_path / directory / file_name + + # Open the file in read mode + with open(file_path, "r", encoding="utf-8") as file: + # Return the file content + return json.load(file) @pytest.fixture def empty_devices_dir(): """ Use e,pty devices directory """ local_delete_devices(ALL_DEVICES) - @pytest.fixture def testing_devices(): """ Use devices from the testing/device_configs directory """ @@ -115,10 +124,10 @@ def testing_devices(): ) return local_get_devices() - @pytest.fixture def testrun(request): # pylint: disable=W0613 """ Start intstance of testrun """ + # pylint: disable=W1509 with subprocess.Popen( "bin/testrun", stdout=subprocess.PIPE, @@ -165,7 +174,6 @@ def testrun(request): # pylint: disable=W0613 ) print(cmd.stdout) - def until_true(func: Callable, message: str, timeout: int): """ Blocks until given func returns True @@ -179,7 +187,6 @@ def until_true(func: Callable, message: str, timeout: int): time.sleep(1) raise TimeoutError(f"Timed out waiting {timeout}s for {message}") - def dict_paths(thing: dict, stem: str = "") -> Iterator[str]: """Returns json paths (in dot notation) from a given dictionary""" for k, v in thing.items(): @@ -189,7 +196,6 @@ def dict_paths(thing: dict, stem: str = "") -> Iterator[str]: else: yield path - def get_network_interfaces(): """return list of network interfaces on machine @@ -204,7 +210,6 @@ def get_network_interfaces(): ifaces.append(i.stem) return ifaces - def local_delete_devices(path): """ Deletes all local devices """ @@ -214,7 +219,6 @@ def local_delete_devices(path): else: shutil.rmtree(thing) - def local_get_devices(): """ Returns path to device configs of devices in local/devices directory""" return sorted( @@ -223,25 +227,240 @@ def local_get_devices(): ) ) +# Tests for system endpoints + +@pytest.fixture() +def restore_config(): + """Restore the original configuration (system.json) after the test""" + yield + + # Restore system.json from 'testing/api/' after the test + if os.path.exists(SYSTEM_CONFIG_RESTORE_PATH): + shutil.copy(SYSTEM_CONFIG_RESTORE_PATH, SYSTEM_CONFIG_PATH) def test_get_system_interfaces(testrun): # pylint: disable=W0613 """Tests API system interfaces against actual local interfaces""" + + # Send a GET request to the API to retrieve system interfaces r = requests.get(f"{API}/system/interfaces", timeout=5) - response = json.loads(r.text) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Retrieve the actual network interfaces local_interfaces = get_network_interfaces() - assert set(response.keys()) == set(local_interfaces) - # schema expects a flat list + # Check if the key are in the response + assert set(response.keys()) == set(local_interfaces) + # Ensure that all values in the response are strings assert all(isinstance(x, str) for x in response) +def test_update_system_config(testrun, restore_config): # pylint: disable=W0613 + """Test update system configuration endpoint ('/system/config')""" -def test_status_idle(testrun): # pylint: disable=W0613 - until_true( - lambda: query_system_status().lower() == "idle", - "system status is `idle`", - 30, + # Configuration data to update + updated_system_config = { + "network": { + "device_intf": "updated_endev0a", + "internet_intf": "updated_wlan1" + }, + "log_level": "DEBUG" + } + + # Send the post request to update the system configuration + r = requests.post(f"{API}/system/config", + data=json.dumps(updated_system_config), + timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Check if the response["network"]["device_intf"] has been updated + assert ( + response["network"]["device_intf"] + == updated_system_config["network"]["device_intf"] + ) + + # Check if the response["network"]["internet_intf"] has been updated + assert ( + response["network"]["internet_intf"] + == updated_system_config["network"]["internet_intf"] + ) + + # Check if the response["log_level"] has been updated + assert ( + response["log_level"] + == updated_system_config["log_level"] + ) + +def test_update_system_config_invalid_config(testrun, restore_config): # pylint: disable=W0613 + """Test invalid configuration file for update system configuration""" + + # Configuration data to update with missing "log_level" field + updated_system_config = { + "network": { + "device_intf": "updated_endev0a", + "internet_intf": "updated_wlan1" + } + } + + # Send the post request to update the system configuration + r = requests.post(f"{API}/system/config", + data=json.dumps(updated_system_config), + timeout=5) + + # Check if status code is 400 (Invalid config) + assert r.status_code == 400 + +def test_get_system_config(testrun): # pylint: disable=W0613 + """Tests get system configuration endpoint ('/system/config')""" + + # Send a GET request to the API to retrieve system configuration + r = requests.get(f"{API}/system/config", timeout=5) + + # Load system configuration file + local_config = load_json("system.json", directory="local") + + # Parse the JSON response + api_config = r.json() + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Validate structure + assert set(dict_paths(api_config)) | set(dict_paths(local_config)) == set( + dict_paths(api_config) + ) + + # Check if the device interface in the local config matches the API config + assert ( + local_config["network"]["device_intf"] + == api_config["network"]["device_intf"] + ) + + # Check if the internet interface in the local config matches the API config + assert ( + local_config["network"]["internet_intf"] + == api_config["network"]["internet_intf"] ) +def test_start_testrun_started_successfully(testing_devices, testrun): # pylint: disable=W0613 + """Test for testrun started successfully """ + + # Payload with device details + payload = {"device": { + "mac_addr": BASELINE_MAC_ADDR, + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + }}} + + # Send the post request + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 200 (OK) + assert r.status_code == 200 + + # Parse the json response + response = r.json() + + # Check that device is in response + assert "device" in response + + # Check that mac_addr in response + assert "mac_addr" in response["device"] + + # Check that firmware in response + assert "firmware" in response["device"] + +def test_start_testrun_missing_device(testing_devices, testrun): # pylint: disable=W0613 + """Test for missing device when testrun is started """ + + # Payload empty dict (no device) + payload = {} + + # Send the post request + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 400 (bad request) + assert r.status_code == 400 + + # Parse the json response + response = r.json() + + # Check if 'error' in response + assert "error" in response + +def test_start_testrun_already_started(testing_devices, testrun): # pylint: disable=W0613 + """Test for testrun already started """ + + # Payload with device details + payload = {"device": { + "mac_addr": BASELINE_MAC_ADDR, + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + }}} + + # Send the post request (start test) + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 200 (OK) + assert r.status_code == 200 + + # Send the second post request (start test again) + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Parse the json response + response = r.json() + + # Check if the response status code is 409 (Conflict) + assert r.status_code == 409 + + # Check if 'error' in response + assert "error" in response + +def test_start_testrun_device_not_found(testing_devices, testrun): # pylint: disable=W0613 + """Test for start testrun device not found """ + + # Payload with device details with no mac address assigned + payload = {"device": { + "mac_addr": "", + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + }}} + + # Send the post request + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 404 (not found) + assert r.status_code == 404 + + # Parse the json response + response = r.json() + + # Check if 'error' in response + assert "error" in response + # Currently not working due to blocking during monitoring period @pytest.mark.skip() def test_status_in_progress(testing_devices, testrun): # pylint: disable=W0613 @@ -264,22 +483,35 @@ def test_status_in_progress(testing_devices, testrun): # pylint: disable=W0613 600, ) - +# Currently not working due to blocking during monitoring period @pytest.mark.skip() -def test_status_non_compliant(testing_devices, testrun): # pylint: disable=W0613 +def test_start_testrun_already_in_progress( + testing_devices, # pylint: disable=W0613 + testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) - payload = { - "device": { - "mac_addr": all_devices[0]["mac_addr"], - "firmware": "asd" - } - } - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", BASELINE_MAC_ADDR) + + until_true( + lambda: query_system_status().lower() == "in progress", + "system status is `in progress`", + 600, + ) + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + assert r.status_code == 409 + +@pytest.mark.skip() +def test_trigger_run(testing_devices, testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) assert r.status_code == 200 - print(r.text) until_true( lambda: query_system_status().lower() == "waiting for device", @@ -287,102 +519,350 @@ def test_status_non_compliant(testing_devices, testrun): # pylint: disable=W0613 30, ) - start_test_device("x123", all_devices[0]["mac_addr"]) + start_test_device("x123", BASELINE_MAC_ADDR) until_true( - lambda: query_system_status().lower() == "non-compliant", - "system status is `complete", + lambda: query_system_status().lower() == "compliant", + "system status is `complete`", 600, ) stop_test_device("x123") -def test_create_get_devices(empty_devices_dir, testrun): # pylint: disable=W0613 - device_1 = { - "manufacturer": "Google", - "model": "First", - "mac_addr": "00:1e:42:35:73:c4", - "test_modules": { - "dns": {"enabled": True}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } - - r = requests.post(f"{API}/device", data=json.dumps(device_1), - timeout=5) - print(r.text) - assert r.status_code == 201 - assert len(local_get_devices()) == 1 - - device_2 = { - "manufacturer": "Google", - "model": "Second", - "mac_addr": "00:1e:42:35:73:c6", - "test_modules": { - "dns": {"enabled": True}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } - r = requests.post(f"{API}/device", data=json.dumps(device_2), - timeout=5) - assert r.status_code == 201 - assert len(local_get_devices()) == 2 + # Validate response + r = requests.get(f"{API}/system/status", timeout=5) + response = r.json() + pretty_print(response) - # Test that returned devices API endpoint matches expected structure - r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) - pretty_print(all_devices) + # Validate results + results = {x["name"]: x for x in response["tests"]["results"]} + print(results) + # there are only 3 baseline tests + assert len(results) == 3 + # Validate structure with open( - os.path.join(os.path.dirname(__file__), "mockito/get_devices.json"), - encoding="utf-8" + os.path.join( + os.path.dirname(__file__), "mockito/running_system_status.json" + ), encoding="utf-8" ) as f: mockito = json.load(f) - print(mockito) - - # Validate structure - assert all(isinstance(x, dict) for x in all_devices) + # validate structure + assert set(dict_paths(mockito)).issubset(set(dict_paths(response))) - # TOOO uncomment when is done - # assert set(dict_paths(mockito[0])) == set(dict_paths(all_devices[0])) + # Validate results structure + assert set(dict_paths(mockito["tests"]["results"][0])).issubset( + set(dict_paths(response["tests"]["results"][0])) + ) - # Validate contents of given keys matches - for key in ["mac_addr", "manufacturer", "model"]: - assert set([all_devices[0][key], all_devices[1][key]]) == set( - [device_1[key], device_2[key]] - ) + # Validate a result + assert results["baseline.compliant"]["result"] == "Compliant" +@pytest.mark.skip() +def test_stop_running_test(testing_devices, testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": ALL_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + assert r.status_code == 200 -def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0613 - device_1 = { - "manufacturer": "Google", - "model": "First", - "mac_addr": "00:1e:42:35:73:c4", - "test_modules": { - "dns": {"enabled": True}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) - # Send create device request - r = requests.post(f"{API}/device", - data=json.dumps(device_1), - timeout=5) - print(r.text) + start_test_device("x12345", ALL_MAC_ADDR) - # Check device has been created - assert r.status_code == 201 - assert len(local_get_devices()) == 1 + until_true( + lambda: query_test_count() > 1, + "system status is `complete`", + 1000, + ) + + stop_test_device("x12345") + + # Validate response + r = requests.post(f"{API}/system/stop", timeout=5) + response = r.json() + pretty_print(response) + assert response == {"success": "Testrun stopped"} + time.sleep(1) + + # Validate response + r = requests.get(f"{API}/system/status", timeout=5) + response = r.json() + pretty_print(response) + + assert response["status"] == "Cancelled" + +def test_stop_running_not_running(testrun): # pylint: disable=W0613 + # Validate response + r = requests.post(f"{API}/system/stop", + timeout=10) + response = r.json() + pretty_print(response) + + assert r.status_code == 404 + assert response["error"] == "Testrun is not currently running" + +@pytest.mark.skip() +def test_multiple_runs(testing_devices, testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + assert r.status_code == 200 + print(r.text) + + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", BASELINE_MAC_ADDR) + + until_true( + lambda: query_system_status().lower() == "compliant", + "system status is `complete`", + 900, + ) + + stop_test_device("x123") + + # Validate response + r = requests.get(f"{API}/system/status", timeout=5) + response = r.json() + pretty_print(response) + + # Validate results + results = {x["name"]: x for x in response["tests"]["results"]} + print(results) + # there are only 3 baseline tests + assert len(results) == 3 + + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + # assert r.status_code == 200 + # returns 409 + print(r.text) + + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", BASELINE_MAC_ADDR) + + until_true( + lambda: query_system_status().lower() == "compliant", + "system status is `complete`", + 900, + ) + + stop_test_device("x123") + +def test_status_idle(testrun): # pylint: disable=W0613 + """Test system status 'idle' endpoint (/system/status)""" + until_true( + lambda: query_system_status().lower() == "idle", + "system status is `idle`", + 30, + ) + +def test_system_shutdown(testrun): # pylint: disable=W0613 + """Test the shutdown system endpoint""" + # Send a POST request to initiate the system shutdown + r = requests.post(f"{API}/system/shutdown", timeout=5) + + # Check if the response status code is 200 (OK) + assert r.status_code == 200, f"Expected status code 200, got {r.status_code}" + +def test_system_shutdown_in_progress(testrun): # pylint: disable=W0613 + """Test system shutdown during an in-progress test""" + # Payload with device details to start a test + payload = { + "device": { + "mac_addr": BASELINE_MAC_ADDR, + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + } + } + } + + # Start a test + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if status code is not 200 (OK) + if r.status_code != 200: + raise ValueError(f"Api request failed with code: {r.status_code}") + + # Attempt to shutdown while the test is running + r = requests.post(f"{API}/system/shutdown", timeout=5) + + # Check if the response status code is 400 (test in progress) + assert r.status_code == 400 + +def test_system_latest_version(testrun): # pylint: disable=W0613 + """Test for testrun version when the latest version is installed""" + + # Send the get request to the API + r = requests.get(f"{API}/system/version", timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 200 (update available) + assert r.status_code == 200 + # Check if an update is available + assert response["update_available"] is False + +# Tests for reports endpoints + +def test_get_reports_no_reports(testrun): # pylint: disable=W0613 + """Test get reports when no reports exist.""" + + # Send a GET request to the /reports endpoint + r = requests.get(f"{API}/reports", timeout=5) + + # Check if the status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Check if the response is a list + assert isinstance(response, list) + + # Check if the response is an empty list + assert response == [] + +# Tests for device endpoints + +@pytest.mark.skip() +def test_status_non_compliant(testing_devices, testrun): # pylint: disable=W0613 + + r = requests.get(f"{API}/devices", timeout=5) + all_devices = r.json() + payload = { + "device": { + "mac_addr": all_devices[0]["mac_addr"], + "firmware": "asd" + } + } + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + assert r.status_code == 200 + print(r.text) + + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", all_devices[0]["mac_addr"]) + + until_true( + lambda: query_system_status().lower() == "non-compliant", + "system status is `complete", + 600, + ) + + stop_test_device("x123") + +def test_create_get_devices(empty_devices_dir, testrun): # pylint: disable=W0613 + device_1 = { + "manufacturer": "Google", + "model": "First", + "mac_addr": "00:1e:42:35:73:c4", + "test_modules": { + "dns": {"enabled": True}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + + r = requests.post(f"{API}/device", data=json.dumps(device_1), + timeout=5) + print(r.text) + assert r.status_code == 201 + assert len(local_get_devices()) == 1 + + device_2 = { + "manufacturer": "Google", + "model": "Second", + "mac_addr": "00:1e:42:35:73:c6", + "test_modules": { + "dns": {"enabled": True}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + r = requests.post(f"{API}/device", data=json.dumps(device_2), + timeout=5) + assert r.status_code == 201 + assert len(local_get_devices()) == 2 + + # Test that returned devices API endpoint matches expected structure + r = requests.get(f"{API}/devices", timeout=5) + all_devices = r.json() + pretty_print(all_devices) + + with open( + os.path.join(os.path.dirname(__file__), "mockito/get_devices.json"), + encoding="utf-8" + ) as f: + mockito = json.load(f) + + print(mockito) + + # Validate structure + assert all(isinstance(x, dict) for x in all_devices) + + # TOOO uncomment when is done + # assert set(dict_paths(mockito[0])) == set(dict_paths(all_devices[0])) + + # Validate contents of given keys matches + for key in ["mac_addr", "manufacturer", "model"]: + assert set([all_devices[0][key], all_devices[1][key]]) == set( + [device_1[key], device_2[key]] + ) + +def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0613 + device_1 = { + "manufacturer": "Google", + "model": "First", + "mac_addr": "00:1e:42:35:73:c4", + "test_modules": { + "dns": {"enabled": True}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + + # Send create device request + r = requests.post(f"{API}/device", + data=json.dumps(device_1), + timeout=5) + print(r.text) + + # Check device has been created + assert r.status_code == 201 + assert len(local_get_devices()) == 1 device_2 = { "manufacturer": "Google", @@ -413,7 +893,7 @@ def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0 # Test that returned devices API endpoint matches expected structure r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) + all_devices = r.json() pretty_print(all_devices) with open( @@ -437,7 +917,6 @@ def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0 [device_2[key]] ) - def test_delete_device_not_found(empty_devices_dir, testrun): # pylint: disable=W0613 device_1 = { "manufacturer": "Google", @@ -476,7 +955,6 @@ def test_delete_device_not_found(empty_devices_dir, testrun): # pylint: disable= assert r.status_code == 404 assert len(local_get_devices()) == 0 - def test_delete_device_no_mac(empty_devices_dir, testrun): # pylint: disable=W0613 device_1 = { "manufacturer": "Google", @@ -510,7 +988,6 @@ def test_delete_device_no_mac(empty_devices_dir, testrun): # pylint: disable=W06 assert r.status_code == 400 assert len(local_get_devices()) == 1 - # Currently not working due to blocking during monitoring period @pytest.mark.skip() def test_delete_device_testrun_running(testing_devices, testrun): # pylint: disable=W0613 @@ -550,41 +1027,8 @@ def test_delete_device_testrun_running(testing_devices, testrun): # pylint: disa timeout=5) assert r.status_code == 403 - -def test_start_testrun_started_successfully( - testing_devices, # pylint: disable=W0613 - testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - assert r.status_code == 200 - - -# Currently not working due to blocking during monitoring period -@pytest.mark.skip() -def test_start_testrun_already_in_progress( - testing_devices, # pylint: disable=W0613 - testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) - - start_test_device("x123", BASELINE_MAC_ADDR) - - until_true( - lambda: query_system_status().lower() == "in progress", - "system status is `in progress`", - 600, - ) - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - assert r.status_code == 409 - -def test_start_system_not_configured_correctly( - empty_devices_dir, # pylint: disable=W0613 +def test_start_system_not_configured_correctly( + empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 device_1 = { "manufacturer": "Google", @@ -611,7 +1055,6 @@ def test_start_system_not_configured_correctly( timeout=10) assert r.status_code == 500 - def test_start_device_not_found(empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 device_1 = { @@ -644,7 +1087,6 @@ def test_start_device_not_found(empty_devices_dir, # pylint: disable=W0613 timeout=10) assert r.status_code == 404 - def test_start_missing_device_information( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -673,7 +1115,6 @@ def test_start_missing_device_information( timeout=10) assert r.status_code == 400 - def test_create_device_already_exists( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -703,7 +1144,6 @@ def test_create_device_already_exists( print(r.text) assert r.status_code == 409 - def test_create_device_invalid_json( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -716,7 +1156,6 @@ def test_create_device_invalid_json( print(r.text) assert r.status_code == 400 - def test_create_device_invalid_request( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -727,7 +1166,6 @@ def test_create_device_invalid_request( print(r.text) assert r.status_code == 400 - def test_device_edit_device( testing_devices, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -740,7 +1178,7 @@ def test_device_edit_device( new_model = "Alphabet" r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) + all_devices = r.json() api_device = next(x for x in all_devices if x["mac_addr"] == mac_addr) @@ -770,13 +1208,12 @@ def test_device_edit_device( assert r.status_code == 200 r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) + all_devices = r.json() updated_device_api = next(x for x in all_devices if x["mac_addr"] == mac_addr) assert updated_device_api["model"] == new_model assert updated_device_api["test_modules"] == new_test_modules - def test_device_edit_device_not_found( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -814,7 +1251,6 @@ def test_device_edit_device_not_found( assert r.status_code == 404 - def test_device_edit_device_incorrect_json_format( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -847,7 +1283,6 @@ def test_device_edit_device_incorrect_json_format( assert r.status_code == 400 - def test_device_edit_device_with_mac_already_exists( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -904,42 +1339,9 @@ def test_device_edit_device_with_mac_already_exists( assert r.status_code == 409 - -def test_system_latest_version(testrun): # pylint: disable=W0613 - r = requests.get(f"{API}/system/version", timeout=5) - assert r.status_code == 200 - updated_system_version = json.loads(r.text)["update_available"] - assert updated_system_version is False - -def test_get_system_config(testrun): # pylint: disable=W0613 - r = requests.get(f"{API}/system/config", timeout=5) - - with open( - SYSTEM_CONFIG_PATH, - encoding="utf-8" - ) as f: - local_config = json.load(f) - - api_config = json.loads(r.text) - - # validate structure - assert set(dict_paths(api_config)) | set(dict_paths(local_config)) == set( - dict_paths(api_config) - ) - - assert ( - local_config["network"]["device_intf"] - == api_config["network"]["device_intf"] - ) - assert ( - local_config["network"]["internet_intf"] - == api_config["network"]["internet_intf"] - ) - - def test_invalid_path_get(testrun): # pylint: disable=W0613 r = requests.get(f"{API}/blah/blah", timeout=5) - response = json.loads(r.text) + response = r.json() assert r.status_code == 404 with open( os.path.join(os.path.dirname(__file__), "mockito/invalid_request.json"), @@ -950,188 +1352,484 @@ def test_invalid_path_get(testrun): # pylint: disable=W0613 # validate structure assert set(dict_paths(mockito)) == set(dict_paths(response)) +def test_create_invalid_chars(empty_devices_dir, testrun): # pylint: disable=W0613 + # local_delete_devices(ALL_DEVICES) + # We must start test run with no devices in local/devices for this test + # to function as expected + assert len(local_get_devices()) == 0 -@pytest.mark.skip() -def test_trigger_run(testing_devices, testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + # Test adding device + device_1 = { + "manufacturer": "/'disallowed characters///", + "model": "First", + "mac_addr": BASELINE_MAC_ADDR, + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + + r = requests.post(f"{API}/device", data=json.dumps(device_1), + timeout=5) + print(r.text) + print(r.status_code) + +def test_get_test_modules(testrun): # pylint: disable=W0613 + """Test the /system/modules endpoint to get the test modules""" + + # Send a GET request to the API endpoint + r = requests.get(f"{API}/system/modules", timeout=5) + + # Check if status code is 200 (OK) assert r.status_code == 200 - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) + # Parse the JSON response + response = r.json() + + # Check if the response is a list + assert isinstance(response, list) + +# Tests for profile endpoints +def delete_all_profiles(): + """Utility method to delete all profiles from risk_profiles folder""" + + # Assign the profiles directory + profiles_path = Path(PROFILES_DIRECTORY) + + try: + # Check if the profile_path (local/risk_profiles) exists and is a folder + if profiles_path.exists() and profiles_path.is_dir(): + # Iterate over all profiles from risk_profiles folder + for item in profiles_path.iterdir(): + # Check if item is a file + if item.is_file(): + #If True remove file + item.unlink() + else: + # If item is a folder remove it + shutil.rmtree(item) + + except PermissionError: + # Permission related issues + print(f"Permission Denied: {item}") + except OSError as err: + # System related issues + print(f"Error removing {item}: {err}") + +def create_profile(file_name): + """Utility method to create the profile""" + + # Load the profile + new_profile = load_json(file_name, directory=PROFILES_PATH) + + # Assign the profile name to profile_name + profile_name = new_profile["name"] + + # Exception if the profile already exists + if profile_exists(profile_name): + raise ValueError(f"Profile: {profile_name} exists") + + # Send the post request + r = requests.post(f"{API}/profiles", data=json.dumps(new_profile), timeout=5) + + # Exception if status code is not 201 + if r.status_code != 201: + raise ValueError(f"API request failed with code: {r.status_code}") + + # Return the profile + return new_profile + +@pytest.fixture() +def reset_profiles(): + """Delete the profiles before and after each test""" + + # Delete before the test + delete_all_profiles() + + yield + + # Delete after the test + delete_all_profiles() + +@pytest.fixture() +def add_profile(): + """Fixture to create profiles during tests.""" + # Returning the reference to create_profile + return create_profile + +def profile_exists(profile_name): + """Utility method to check if profile exists""" + # Send the get request + r = requests.get(f"{API}/profiles", timeout=5) + # Check if status code is not 200 (OK) + if r.status_code != 200: + raise ValueError(f"Api request failed with code: {r.status_code}") + # Parse the JSON response to get the list of profiles + profiles = r.json() + # Return if name is in the list of profiles + return any(p["name"] == profile_name for p in profiles) + +def test_get_profiles_format(testrun): # pylint: disable=W0613 + """Test profiles format""" + + # Send the get request + r = requests.get(f"{API}/profiles/format", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 - start_test_device("x123", BASELINE_MAC_ADDR) + # Parse the response + response = r.json() - until_true( - lambda: query_system_status().lower() == "compliant", - "system status is `complete`", - 600, - ) + # Check if the response is a list + assert isinstance(response, list) - stop_test_device("x123") + # Check that each item in the response has keys "questions" and "type" + for item in response: + assert "question" in item + assert "type" in item - # Validate response - r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) - pretty_print(response) +def test_get_profiles(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for get profiles (no profile, one profile, two profiles)""" - # Validate results - results = {x["name"]: x for x in response["tests"]["results"]} - print(results) - # there are only 3 baseline tests - assert len(results) == 3 + # Test for no profiles - # Validate structure - with open( - os.path.join( - os.path.dirname(__file__), "mockito/running_system_status.json" - ), encoding="utf-8" - ) as f: - mockito = json.load(f) + # Send the get request to "/profiles" endpoint + r = requests.get(f"{API}/profiles", timeout=5) - # validate structure - assert set(dict_paths(mockito)).issubset(set(dict_paths(response))) + # Check if status code is 200 (OK) + assert r.status_code == 200 - # Validate results structure - assert set(dict_paths(mockito["tests"]["results"][0])).issubset( - set(dict_paths(response["tests"]["results"][0])) - ) + # Parse the response (profiles) + response = r.json() - # Validate a result - assert results["baseline.compliant"]["result"] == "Compliant" + # Check if response is a list + assert isinstance(response, list) + # Check if the list is empty + assert len(response) == 0 -@pytest.mark.skip() -def test_stop_running_test(testing_devices, testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": ALL_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) + # Test for one profile + + # Load the profile using add_profile fixture + add_profile("new_profile.json") + + # Send get request to the "/profiles" endpoint + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) assert r.status_code == 200 - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) + # Parse the response (profiles) + response = r.json() - start_test_device("x12345", ALL_MAC_ADDR) + # Check if response is a list + assert isinstance(response, list) - until_true( - lambda: query_test_count() > 1, - "system status is `complete`", - 1000, - ) + # Check if response contains one profile + assert len(response) == 1 - stop_test_device("x12345") + # Check that each profile has the expected fields + for profile in response: + for field in ["name", "status", "created", "version", "questions", "risk"]: + assert field in profile - # Validate response - r = requests.post(f"{API}/system/stop", timeout=5) - response = json.loads(r.text) - pretty_print(response) - assert response == {"success": "Testrun stopped"} - time.sleep(1) + # Check if "questions" value is a list + assert isinstance(profile["questions"], list) - # Validate response - r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) - pretty_print(response) + # Check that "questions" value has the expected fields + for element in profile["questions"]: + # Check if each element is dict + assert isinstance(element, dict) - assert response["status"] == "Cancelled" + # Check if "question" key is in dict element + assert "question" in element + # Check if "asnswer" key is in dict element + assert "answer" in element -def test_stop_running_not_running(testrun): # pylint: disable=W0613 - # Validate response - r = requests.post(f"{API}/system/stop", - timeout=10) - response = json.loads(r.text) - pretty_print(response) + # Test for two profiles - assert r.status_code == 404 - assert response["error"] == "Testrun is not currently running" + # Load the profile using add_profile fixture + add_profile("new_profile_2.json") -@pytest.mark.skip() -def test_multiple_runs(testing_devices, testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) + # Send the get request to "/profiles" endpoint + r = requests.get(f"{API}/profiles", timeout=5) + + # Parse the response (profiles) + response = r.json() + + # Check if status code is 200 (OK) assert r.status_code == 200 - print(r.text) - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) + # Check if response is a list + assert isinstance(response, list) - start_test_device("x123", BASELINE_MAC_ADDR) + # Check if response contains two profiles + assert len(response) == 2 - until_true( - lambda: query_system_status().lower() == "compliant", - "system status is `complete`", - 900, +def test_create_profile(testrun, reset_profiles): # pylint: disable=W0613 + """Test for create profile if not exists""" + + # Load the profile + new_profile = load_json("new_profile.json", directory=PROFILES_PATH) + + # Assign the profile name to profile_name + profile_name = new_profile["name"] + + # Check if the profile already exists + if profile_exists(profile_name): + raise ValueError(f"Profile: {profile_name} exists") + + # Send the post request + r = requests.post(f"{API}/profiles", data=json.dumps(new_profile), timeout=5) + + # Check if status code is 201 (Created) + assert r.status_code == 201 + + # Parse the response + response = r.json() + + # Check if "success" key in response + assert "success" in response + + # Verify profile creation + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the response + profiles = r.json() + + # Iterate through all the profiles to find the profile based on the "name" + created_profile = next( + (p for p in profiles if p["name"] == profile_name), None ) - stop_test_device("x123") + # Check if profile was created + assert created_profile is not None - # Validate response - r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) - pretty_print(response) +def test_update_profile(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for update profile when exists""" - # Validate results - results = {x["name"]: x for x in response["tests"]["results"]} - print(results) - # there are only 3 baseline tests - assert len(results) == 3 + # Load the new profile using add_profile fixture + new_profile = add_profile("new_profile.json") - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) - # assert r.status_code == 200 - # returns 409 - print(r.text) + # Load the updated profile using load_json utility method + updated_profile = load_json("updated_profile.json", + directory=PROFILES_PATH) - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, + # Assign the new_profile name + profile_name = new_profile["name"] + + # Assign the updated_profile name + updated_profile_name = updated_profile["rename"] + + # Exception if the profile does't exists + if not profile_exists(profile_name): + raise ValueError(f"Profile: {profile_name} exists") + + # Send the post request to update the profile + r = requests.post( + f"{API}/profiles", + data=json.dumps(updated_profile), + timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the response + response = r.json() + + # Check if "success" key in response + assert "success" in response + + # Get request to verify profile update + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the response + profiles = r.json() + + # Iterate through the profiles to find the profile based on the updated "name" + updated_profile_check = next( + (p for p in profiles if p["name"] == updated_profile_name), + None ) + # Check if profile was updated + assert updated_profile_check is not None - start_test_device("x123", BASELINE_MAC_ADDR) +def test_update_profile_invalid_json(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for update profile invalid JSON payload (no 'name')""" - until_true( - lambda: query_system_status().lower() == "compliant", - "system status is `complete`", - 900, + # Load the new profile using add_profile fixture + add_profile("new_profile.json") + + # invalid JSON + updated_profile = {} + + # Send the post request to update the profile + r = requests.post( + f"{API}/profiles", + data=json.dumps(updated_profile), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (Bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + +def test_create_profile_invalid_json(testrun, reset_profiles): # pylint: disable=W0613 + """Test for create profile invalid JSON payload """ + + # invalid JSON + new_profile = {} + + # Send the post request to update the profile + r = requests.post( + f"{API}/profiles", + data=json.dumps(new_profile), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (Bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + +def test_delete_profile(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for delete profile""" + + # Assign the profile from the fixture + profile_to_delete = add_profile("new_profile.json") + + # Assign the profile name + profile_name = profile_to_delete["name"] + + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete), + timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Check if the response contains "success" key + assert "success" in response + + # Check if the profile has been deleted + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + profiles = r.json() + + # Iterate through the profiles to find the profile based on the "name" + deleted_profile = next( + (p for p in profiles if p["name"] == profile_name), + None ) + # Check if profile was deleted + assert deleted_profile is None - stop_test_device("x123") +def test_delete_profile_no_profile(testrun, reset_profiles): # pylint: disable=W0613 + """Test delete profile if the profile does not exists""" + # Assign the profile to delete + profile_to_delete = {"name": "New Profile"} -def test_create_invalid_chars(empty_devices_dir, testrun): # pylint: disable=W0613 - # local_delete_devices(ALL_DEVICES) - # We must start test run with no devices in local/devices for this test - # to function as expected - assert len(local_get_devices()) == 0 + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete), + timeout=5) - # Test adding device - device_1 = { - "manufacturer": "/'disallowed characters///", - "model": "First", - "mac_addr": BASELINE_MAC_ADDR, - "test_modules": { - "dns": {"enabled": False}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } + # Check if status code is 404 (Profile does not exist) + assert r.status_code == 404 - r = requests.post(f"{API}/device", data=json.dumps(device_1), - timeout=5) - print(r.text) - print(r.status_code) +def test_delete_profile_invalid_json(testrun, reset_profiles): # pylint: disable=W0613 + """Test for delete profile wrong JSON payload""" + + profile_to_delete = {} + + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + + profile_to_delete_2 = {"status": "Draft"} + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete_2), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + +def test_delete_profile_internal_server_error(testrun, # pylint: disable=W0613 + reset_profiles, # pylint: disable=W0613 + add_profile ): + """Test for delete profile causing internal server error""" + + # Assign the profile from the fixture + profile_to_delete = add_profile("new_profile.json") + + # Assign the profile name to profile_name + profile_name = profile_to_delete["name"] + + # Construct the path to the profile JSON file in local/risk_profiles + risk_profile_path = os.path.join(PROFILES_DIRECTORY, f"{profile_name}.json") + + # Delete the profile JSON file before making the DELETE request + if os.path.exists(risk_profile_path): + os.remove(risk_profile_path) + + # Send the DELETE request to delete the profile + r = requests.delete(f"{API}/profiles", + json={"name": profile_to_delete["name"]}, + timeout=5) + + # Check if status code is 500 (Internal Server Error) + assert r.status_code == 500 + + # Parse the json response + response = r.json() + + # Check if error in response + assert "error" in response diff --git a/testing/pylint/test_pylint b/testing/pylint/test_pylint index 1f71482e5..7e102c7f8 100755 --- a/testing/pylint/test_pylint +++ b/testing/pylint/test_pylint @@ -14,27 +14,35 @@ # See the License for the specific language governing permissions and # limitations under the License. -ERROR_LIMIT=25 - -sudo cmd/install +# Install python venv +python3 -m venv venv +# Activate the venv source venv/bin/activate -sudo pip3 install pylint==3.0.3 +# Install pylint +pip install pylint==3.2.6 + +# Declare the applicable files files=$(find . -path ./venv -prune -o -name '*.py' -print) +# Define the pylint output file OUT=pylint.out -rm -f $OUT && touch $OUT +# Remove it if it already exists +rm -f $OUT +# Run pylint against the target files +# Change the evaluation to total the number of errors +# Output to the specified output file pylint $files -ry --extension-pkg-allow-list=docker --evaluation="error + warning + refactor + convention" 2>/dev/null | tee -a $OUT -new_errors=$(cat $OUT | grep -oP "(?!=^Your code has been rated at)([0-9]+)(?=\.00/10[ \(]?)" ) +# Obtain the total number of errors from the pylint out file +errors=$(cat $OUT | grep -oP "(?!=^Your code has been rated at)([0-9]+)(?=\.00/10[ \(]?)" ) -echo "$new_errors > $ERROR_LIMIT?" -if (( $new_errors > $ERROR_LIMIT)); then - echo new errors $new_errors > error limit $ERROR_LIMIT - echo failing .. +# Check if any errors exist +if (( $errors > 0 )); then + echo "$errors pylint issues have been identified. These must be resolved before merging." exit 1 fi diff --git a/testing/tests/test_tests.py b/testing/tests/test_tests.py index aaae1a09d..21be6b7de 100644 --- a/testing/tests/test_tests.py +++ b/testing/tests/test_tests.py @@ -96,7 +96,7 @@ def test_list_tests(capsys, results, test_matrix): print('============') print('============') print('tests seen:') - print('\n'.join(set([x.name for x in all_tests]))) + print('\n'.join(set(x.name for x in all_tests))) print('\ntesting for pass:') print('\n'.join(ci_pass)) print('\ntesting for fail:') diff --git a/testing/unit/conn/captures/monitor.pcap b/testing/unit/conn/captures/monitor.pcap new file mode 100644 index 000000000..0dfb85ff4 Binary files /dev/null and b/testing/unit/conn/captures/monitor.pcap differ diff --git a/testing/unit/conn/captures/startup.pcap b/testing/unit/conn/captures/startup.pcap new file mode 100644 index 000000000..dadd2edbc Binary files /dev/null and b/testing/unit/conn/captures/startup.pcap differ diff --git a/testing/unit/conn/conn_module_test.py b/testing/unit/conn/conn_module_test.py index d31a8051f..906abb754 100644 --- a/testing/unit/conn/conn_module_test.py +++ b/testing/unit/conn/conn_module_test.py @@ -13,13 +13,17 @@ # limitations under the License. """Module run all the Connection module related unit tests""" from port_stats_util import PortStatsUtil +from connection_module import ConnectionModule import os import unittest from common import logger MODULE = 'conn' -# Define the file paths -TEST_FILES_DIR = 'testing/unit/' + MODULE +# Define the directories +TEST_FILES_DIR = '/testing/unit/' + MODULE +OUTPUT_DIR = os.path.join(TEST_FILES_DIR, 'output/') +CAPTURES_DIR = os.path.join(TEST_FILES_DIR, 'captures/') + ETHTOOL_RESULTS_COMPLIANT_FILE = os.path.join(TEST_FILES_DIR, 'ethtool', 'ethtool_results_compliant.txt') ETHTOOL_RESULTS_NONCOMPLIANT_FILE = os.path.join( @@ -34,8 +38,12 @@ ETHTOOL_PORT_STATS_POST_NONCOMPLIANT_FILE = os.path.join( TEST_FILES_DIR, 'ethtool', 'ethtool_port_stats_post_monitor_noncompliant.txt') -LOGGER = None +# Define the capture files to be used for the test +STARTUP_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'startup.pcap') +MONITOR_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'monitor.pcap') + +LOGGER = None class ConnectionModuleTest(unittest.TestCase): """Contains and runs all the unit tests concerning Connection @@ -46,6 +54,9 @@ def setUpClass(cls): global LOGGER LOGGER = logger.get_logger('unit_test_' + MODULE) + # Set the MAC address for device in capture files + os.environ['DEVICE_MAC'] = '98:f0:7b:d1:87:06' + # Test the port link status def connection_port_link_compliant_test(self): LOGGER.info('connection_port_link_compliant_test') @@ -117,6 +128,17 @@ def connection_port_speed_autonegotiation_fail_test(self): LOGGER.info(result) self.assertEqual(result[0], False) + # Test proper filtering for ICMP protocol in DHCP packets + def connection_switch_dhcp_snooping_icmp_test(self): + LOGGER.info('connection_switch_dhcp_snooping_icmp_test') + conn_module = ConnectionModule(module=MODULE, + log_dir=OUTPUT_DIR, + results_dir=OUTPUT_DIR, + startup_capture_file=STARTUP_CAPTURE_FILE, + monitor_capture_file=MONITOR_CAPTURE_FILE) + result = conn_module._connection_switch_dhcp_snooping() # pylint: disable=W0212 + LOGGER.info(result) + self.assertEqual(result[0], True) if __name__ == '__main__': suite = unittest.TestSuite() @@ -136,5 +158,9 @@ def connection_port_speed_autonegotiation_fail_test(self): suite.addTest( ConnectionModuleTest('connection_port_speed_autonegotiation_fail_test')) + # DHCP Snooping related tests + suite.addTest( + ConnectionModuleTest('connection_switch_dhcp_snooping_icmp_test')) + runner = unittest.TextTestRunner() runner.run(suite) diff --git a/testing/unit/dns/dns_module_test.py b/testing/unit/dns/dns_module_test.py index 6c3dec74d..a4b7a81e9 100644 --- a/testing/unit/dns/dns_module_test.py +++ b/testing/unit/dns/dns_module_test.py @@ -16,7 +16,6 @@ import unittest from scapy.all import rdpcap, DNS, wrpcap import os -from testreport import TestReport MODULE = 'dns' @@ -28,7 +27,6 @@ LOCAL_REPORT = os.path.join(REPORTS_DIR, 'dns_report_local.html') LOCAL_REPORT_NO_DNS = os.path.join(REPORTS_DIR, 'dns_report_local_no_dns.html') -CONF_FILE = 'modules/test/' + MODULE + '/conf/module_config.json' # Define the capture files to be used for the test DNS_SERVER_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'dns.pcap') @@ -44,11 +42,13 @@ def setUpClass(cls): # Create the output directories and ignore errors if it already exists os.makedirs(OUTPUT_DIR, exist_ok=True) + # Set the MAC address for device in capture files + os.environ['DEVICE_MAC'] = '38:d1:35:01:17:fe' + # Test the module report generation def dns_module_report_test(self): dns_module = DNSModule(module=MODULE, log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, dns_server_capture_file=DNS_SERVER_CAPTURE_FILE, startup_capture_file=STARTUP_CAPTURE_FILE, @@ -59,12 +59,6 @@ def dns_module_report_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join(OUTPUT_DIR, 'dns_report_with_dns.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT, 'r', encoding='utf-8') as file: @@ -104,7 +98,6 @@ def dns_module_report_no_dns_test(self): dns_module = DNSModule(module='dns', log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, dns_server_capture_file=dns_server_cap_file, startup_capture_file=startup_cap_file, @@ -115,12 +108,6 @@ def dns_module_report_no_dns_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join(OUTPUT_DIR, 'dns_report_no_dns.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT_NO_DNS, 'r', encoding='utf-8') as file: @@ -128,17 +115,6 @@ def dns_module_report_no_dns_test(self): self.assertEqual(report_out, report_local) - def add_formatting(self, body): - return f''' - - - {TestReport().generate_head()} - - {body} - - DNS Module

Requests to local DNS server Requests to external DNS servers Total DNS requests Total DNS responses
71 6 77 91
Source Destination Type URL
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 8.8.8.8 Query mqtt.googleapis.com
10.10.10.4 8.8.8.8 Query mqtt.googleapis.com
8.8.8.8 10.10.10.4 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
8.8.8.8 10.10.10.4 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.4 8.8.8.8 Query pool.ntp.org
10.10.10.4 8.8.8.8 Query pool.ntp.org
8.8.8.8 10.10.10.4 Response pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
8.8.8.8 10.10.10.4 Response pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.4 8.8.8.8 Query pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
8.8.8.8 10.10.10.4 Response pool.ntp.org
10.10.10.4 8.8.8.8 Response pool.ntp.org
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 8.8.8.8 Query mqtt.googleapis.com
8.8.8.8 10.10.10.4 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
10.10.10.4 10.10.10.14 Response pool.ntp.org
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com
\ No newline at end of file +

DNS Module

Requests to local DNS server Requests to external DNS servers Total DNS requests Total DNS responses
71 0 71 84
Source Destination Type URL Count
10.10.10.14 10.10.10.4 Query mqtt.googleapis.com 64
10.10.10.4 10.10.10.14 Response mqtt.googleapis.com 76
10.10.10.14 10.10.10.4 Query pool.ntp.org 7
10.10.10.4 10.10.10.14 Response pool.ntp.org 8
\ No newline at end of file diff --git a/testing/unit/framework/session_test.py b/testing/unit/framework/session_test.py new file mode 100644 index 000000000..1045457f3 --- /dev/null +++ b/testing/unit/framework/session_test.py @@ -0,0 +1,57 @@ +# Copyright 2024 Google LLC +# +# 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 +# +# https://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. + +"""Session methods tests""" + +from unittest.mock import patch +from common import session + + +class MockUtil: + """mock util functions""" + + @staticmethod + def get_sys_interfaces(): + return {"eth0": "00:1A:2B:3C:4D:5E", "eth1": "66:77:88:99:AA:BB"} + + @staticmethod + def diff_dicts(d1, d2): # pylint: disable=W0613 + return { + "items_added": {"eth1": "66:77:88:99:AA:BB"}, + "items_removed": {"eth2": "00:1B:2C:3D:4E:5F"}, + } + + +class TestrunSessionMock(session.TestrunSession): + def __init__(self): # pylint: disable=W0231 + self._ifaces = {"eth0": "00:1A:2B:3C:4D:5E", "eth2": "66:77:88:99:AA:BB"} + + +util = MockUtil() + + +@patch("common.util.get_sys_interfaces", side_effect=util.get_sys_interfaces) +@patch("common.util.diff_dicts", side_effect=util.diff_dicts) +def test_detect_network_adapters_change( + mock_get_sys_interfaces, # pylint: disable=W0613 + mock_diff_dicts, # pylint: disable=W0613 +): + testrun_session = TestrunSessionMock() + + # Test added and removed + result = testrun_session.detect_network_adapters_change() + assert result == { + "adapters_added": {"eth1": "66:77:88:99:AA:BB"}, + "adapters_removed": {"eth2": "00:1B:2C:3D:4E:5F"}, + } diff --git a/testing/unit/framework/util_test.py b/testing/unit/framework/util_test.py new file mode 100644 index 000000000..ec8fd48fc --- /dev/null +++ b/testing/unit/framework/util_test.py @@ -0,0 +1,61 @@ +# Copyright 2024 Google LLC +# +# 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 +# +# https://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. + +"""Util tests""" + +from collections import namedtuple +from unittest.mock import patch +from common import util +from net_orc import ip_control + +Snicaddr = namedtuple('snicaddr', + ['family', 'address']) + +mock_addrs = { + 'eth0': [Snicaddr(17, '00:1A:2B:3C:4D:5E')], + 'wlan0': [Snicaddr(17, '66:77:88:99:AA:BB')], + 'enp0s3': [Snicaddr(17, '11:22:33:44:55:66')] +} + +@patch('psutil.net_if_addrs') +def test_get_sys_interfaces(mock_net_if_addrs): + mock_net_if_addrs.return_value = mock_addrs + # Expected result + expected = { + 'eth0': '00:1A:2B:3C:4D:5E', + 'enp0s3': '11:22:33:44:55:66' + } + + result = ip_control.IPControl.get_sys_interfaces() + # Assert the result + assert result == expected + + +def test_diff_dicts(): + d1 = {'a': 1, 'b': 2} + d2 = {'a': 1, 'b': 2} + #Assert equal dicts + assert not util.diff_dicts(d1, d2) + d2 = {'a': 1, 'c': 3} + expected = {'items_removed': {'b': 2},'items_added': {'c': 3}} + #Assert items added adn removed + assert util.diff_dicts(d1, d2) == expected + d1 = {'a': 1} + d2 = {'b': 2} + expected = { + 'items_removed': {'a': 1}, + 'items_added': {'b': 2} + } + #Assert completely different dicts + assert util.diff_dicts(d1, d2) == expected diff --git a/testing/unit/ntp/ntp_module_test.py b/testing/unit/ntp/ntp_module_test.py index 20dd88ef1..ac10bb46c 100644 --- a/testing/unit/ntp/ntp_module_test.py +++ b/testing/unit/ntp/ntp_module_test.py @@ -11,12 +11,11 @@ # 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. -"""Module run all the DNS related unit tests""" +"""Module run all the NTP related unit tests""" from ntp_module import NTPModule import unittest from scapy.all import rdpcap, NTP, wrpcap import os -from testreport import TestReport MODULE = 'ntp' @@ -28,7 +27,6 @@ LOCAL_REPORT = os.path.join(REPORTS_DIR,'ntp_report_local.html') LOCAL_REPORT_NO_NTP = os.path.join(REPORTS_DIR,'ntp_report_local_no_ntp.html') -CONF_FILE = 'modules/test/' + MODULE + '/conf/module_config.json' # Define the capture files to be used for the test NTP_SERVER_CAPTURE_FILE = os.path.join(CAPTURES_DIR,'ntp.pcap') @@ -49,7 +47,6 @@ def setUpClass(cls): def ntp_module_report_test(self): ntp_module = NTPModule(module=MODULE, log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, ntp_server_capture_file=NTP_SERVER_CAPTURE_FILE, startup_capture_file=STARTUP_CAPTURE_FILE, @@ -60,12 +57,6 @@ def ntp_module_report_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join(OUTPUT_DIR, 'ntp_report_with_ntp.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT, 'r', encoding='utf-8') as file: @@ -105,7 +96,6 @@ def ntp_module_report_no_ntp_test(self): ntp_module = NTPModule(module='dns', log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, ntp_server_capture_file=ntp_server_cap_file, startup_capture_file=startup_cap_file, @@ -116,12 +106,6 @@ def ntp_module_report_no_ntp_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join(OUTPUT_DIR,'ntp_report_no_ntp.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT_NO_NTP, 'r', encoding='utf-8') as file: @@ -129,16 +113,6 @@ def ntp_module_report_no_ntp_test(self): self.assertEqual(report_out, report_local) - def add_formatting(self,body): - return f''' - - - {TestReport().generate_head()} - - {body} - - NTP Module 101 104 + @@ -24,1444 +25,90 @@

NTP Module

- + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + + - + + - + + - - - - - - - - - - - - - - - + + - + + - + +
Destination Type VersionTimestampCountSync Request Average
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:29.447
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:29.448
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:31.577
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:31.577
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:33.694
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:33.694
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:35.785
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:35.786
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:37.806
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:37.806
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:39.856
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:39.856
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:41.931
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:41.932
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:43.954
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:43.956
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:06.439
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:06.439
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:08.492
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:08.494
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:40.536
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:40.541
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:48.274
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:48.277
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:14:12.619
10.10.10.510.10.10.15Server4Feb 15, 2024 22:14:12.624
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:14:44.702
10.10.10.510.10.10.15Server4Feb 15, 2024 22:14:44.703
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:14:53.026
10.10.10.510.10.10.15Server4Feb 15, 2024 22:14:53.029
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:15:16.786
10.10.10.510.10.10.15Server4Feb 15, 2024 22:15:16.791
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:15:48.884
10.10.10.510.10.10.15Server4Feb 15, 2024 22:15:48.887
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:15:57.829
10.10.10.510.10.10.15Server4Feb 15, 2024 22:15:57.829
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:16:20.970
10.10.10.510.10.10.15Server4Feb 15, 2024 22:16:20.970
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:16:54.054
10.10.10.510.10.10.15Server4Feb 15, 2024 22:16:54.054
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:17:02.738
10.10.10.510.10.10.15Server4Feb 15, 2024 22:17:02.740
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:17:26.136
10.10.10.510.10.10.15Server4Feb 15, 2024 22:17:26.139
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:17:59.293
10.10.10.510.10.10.15Server4Feb 15, 2024 22:17:59.293
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:18:07.242
10.10.10.510.10.10.15Server4Feb 15, 2024 22:18:07.242
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:18:32.379
10.10.10.510.10.10.15Server4Feb 15, 2024 22:18:32.379
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:20:06.908
10.10.10.510.10.10.15Server4Feb 15, 2024 22:20:06.908
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:20:08.936
10.10.10.510.10.10.15Server4Feb 15, 2024 22:20:08.937
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:20:10.974
10.10.10.510.10.10.15Server4Feb 15, 2024 22:20:10.974
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:20:12.998
10.10.10.510.10.10.15Server4Feb 15, 2024 22:20:12.999
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:20:59.581
10.10.10.510.10.10.15Server4Feb 15, 2024 22:20:59.582
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:21:34.063
10.10.10.510.10.10.15Server4Feb 15, 2024 22:21:34.063
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:21:36.121
10.10.10.510.10.10.15Server4Feb 15, 2024 22:21:36.121
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:21:38.176
10.10.10.510.10.10.15Server4Feb 15, 2024 22:21:38.176
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:21:40.277
10.10.10.510.10.10.15Server4Feb 15, 2024 22:21:40.277
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:22:05.704
10.10.10.510.10.10.15Server4Feb 15, 2024 22:22:05.706
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:22:45.469
10.10.10.510.10.10.15Server4Feb 15, 2024 22:22:45.470
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:23:09.826
10.10.10.510.10.10.15Server4Feb 15, 2024 22:23:09.828
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:23:50.337
10.10.10.510.10.10.15Server4Feb 15, 2024 22:23:50.343
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:24:13.945
10.10.10.510.10.10.15Server4Feb 15, 2024 22:24:13.946
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:24:54.876
10.10.10.510.10.10.15Server4Feb 15, 2024 22:24:54.877
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:25:59.000
10.10.10.510.10.10.15Server4Feb 15, 2024 22:25:59.001
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:12:28.681
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:12:28.728
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:12:28.842
216.239.35.410.10.10.15Server4Feb 15, 2024 22:12:28.888
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:12:29.042
216.239.35.810.10.10.15Server4Feb 15, 2024 22:12:29.089
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:12:29.243
216.239.35.010.10.10.15Server4Feb 15, 2024 22:12:29.290
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:29.447
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:29.448
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:12:30.802
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:12:30.850
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:12:30.973
216.239.35.410.10.10.15Server4Feb 15, 2024 22:12:31.032
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:12:31.173
216.239.35.810.10.10.15Server4Feb 15, 2024 22:12:31.220
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:12:31.376
216.239.35.010.10.10.15Server4Feb 15, 2024 22:12:31.423
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:31.577
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:31.577
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:12:32.867
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:12:32.914
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:12:33.112
216.239.35.410.10.10.15Server4Feb 15, 2024 22:12:33.159
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:12:33.271
216.239.35.810.10.10.15Server4Feb 15, 2024 22:12:33.318
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:12:33.475
216.239.35.010.10.10.15Server4Feb 15, 2024 22:12:33.522
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:33.694
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:33.694
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:12:34.956
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:12:35.002
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:12:35.182
216.239.35.410.10.10.15Server4Feb 15, 2024 22:12:35.228
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:12:35.398
216.239.35.810.10.10.15Server4Feb 15, 2024 22:12:35.445
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:12:35.625
216.239.35.010.10.10.15Server4Feb 15, 2024 22:12:35.673
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:35.785
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:35.786
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:37.806
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:37.806
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:39.856
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:39.856
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:41.931
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:41.932
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:12:43.954
10.10.10.510.10.10.15Server4Feb 15, 2024 22:12:43.956
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:06.439
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:13:06.439
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:06.439
216.239.35.010.10.10.15Server4Feb 15, 2024 22:13:06.489
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:08.492
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:08.494
216.239.35.010.10.10.15Server4Feb 15, 2024 22:13:08.543
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:13:40.310
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:13:40.357
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:13:40.512
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:40.536
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:40.542
216.239.35.410.10.10.15Server4Feb 15, 2024 22:13:40.574
216.239.35.010.10.10.15Server4Feb 15, 2024 22:13:40.583
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:13:40.714
216.239.35.810.10.10.15Server4Feb 15, 2024 22:13:40.764
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:13:40.917
216.239.35.010.10.10.15Server4Feb 15, 2024 22:13:40.965
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:13:48.274
10.10.10.510.10.10.15Server4Feb 15, 2024 22:13:48.277
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:14:12.619
10.10.10.510.10.10.15Server4Feb 15, 2024 22:14:12.624
216.239.35.010.10.10.15Server4Feb 15, 2024 22:14:12.668
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:14:44.515
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:14:44.562
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:14:44.702
10.10.10.510.10.10.15Server4Feb 15, 2024 22:14:44.704
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:14:45.158
216.239.35.410.10.10.15Server4Feb 15, 2024 22:14:45.219
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:14:45.359
216.239.35.010.10.10.15Server4Feb 15, 2024 22:14:45.406
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:14:45.707
216.239.35.010.10.10.15Server4Feb 15, 2024 22:14:45.755
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:14:45.980
216.239.35.810.10.10.15Server4Feb 15, 2024 22:14:46.027
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:14:53.026
10.10.10.510.10.10.15Server4Feb 15, 2024 22:14:53.029
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:15:16.786
10.10.10.510.10.10.15Server4Feb 15, 2024 22:15:16.791
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:15:18.794
216.239.35.010.10.10.15Server4Feb 15, 2024 22:15:18.843
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:15:48.884
10.10.10.510.10.10.15Server4Feb 15, 2024 22:15:48.887
10.10.10.15 216.239.35.12 Client 4Feb 15, 2024 22:15:49.063837.942 seconds
216.239.35.12 10.10.10.15 Server 4Feb 15, 2024 22:15:49.110
10.10.10.15216.239.35.4Client4Feb 15, 2024 22:15:49.462
216.239.35.410.10.10.15Server4Feb 15, 2024 22:15:49.509
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:15:50.127
216.239.35.010.10.10.15Server4Feb 15, 2024 22:15:50.175
10.10.10.15216.239.35.8Client4Feb 15, 2024 22:15:51.107
216.239.35.810.10.10.15Server4Feb 15, 2024 22:15:51.154
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:15:51.890
216.239.35.010.10.10.15Server4Feb 15, 2024 22:15:51.938
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:15:57.829
10.10.10.510.10.10.15Server4Feb 15, 2024 22:15:57.829
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:16:20.970
10.10.10.510.10.10.15Server4Feb 15, 2024 22:16:20.971
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:16:24.975
216.239.35.010.10.10.15Server4Feb 15, 2024 22:16:25.0238N/A
10.10.10.15 216.239.35.4 Client 4Feb 15, 2024 22:16:53.677837.834 seconds
216.239.35.4 10.10.10.15 Server 4Feb 15, 2024 22:16:53.739
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:16:54.054
10.10.10.510.10.10.15Server4Feb 15, 2024 22:16:54.054
10.10.10.15216.239.35.12Client4Feb 15, 2024 22:16:54.276
216.239.35.1210.10.10.15Server4Feb 15, 2024 22:16:54.322
10.10.10.15216.239.35.0Client4Feb 15, 2024 22:16:54.593
216.239.35.010.10.10.15Server4Feb 15, 2024 22:16:54.6488N/A
10.10.10.15 216.239.35.8 Client 4Feb 15, 2024 22:16:55.435838.056 seconds
216.239.35.8 10.10.10.15 Server 4Feb 15, 2024 22:16:55.4818N/A
10.10.10.15 216.239.35.0 Client 4Feb 15, 2024 22:16:57.0591420.601 seconds
216.239.35.0 10.10.10.15 Server 4Feb 15, 2024 22:16:57.107
10.10.10.1510.10.10.5Client4Feb 15, 2024 22:17:02.738
10.10.10.510.10.10.15Server4Feb 15, 2024 22:17:02.74017N/A
10.10.10.15 10.10.10.5 Client 4Feb 15, 2024 22:17:26.1366313.057 seconds
10.10.10.5 10.10.10.15 Server 4Feb 15, 2024 22:17:26.13963N/A
diff --git a/testing/unit/ntp/reports/ntp_report_local_no_ntp.html b/testing/unit/ntp/reports/ntp_report_local_no_ntp.html index 7df0fbd87..7fe2e6ab5 100644 --- a/testing/unit/ntp/reports/ntp_report_local_no_ntp.html +++ b/testing/unit/ntp/reports/ntp_report_local_no_ntp.html @@ -15,6 +15,7 @@

NTP Module

0 0 +
diff --git a/testing/unit/protocol/protocol_module_test.py b/testing/unit/protocol/protocol_module_test.py index 32a0021cd..6ba3143c0 100644 --- a/testing/unit/protocol/protocol_module_test.py +++ b/testing/unit/protocol/protocol_module_test.py @@ -46,7 +46,6 @@ def setUpClass(cls): BACNET = BACnet(log=LOGGER, captures_dir=CAPTURES_DIR, capture_file='bacnet.pcap', - bin_dir='modules/test/protocol/bin', device_hw_addr=HW_ADDR) # Test the BACNet traffic for a matching Object ID and HW address diff --git a/testing/unit/report/report_test.py b/testing/unit/report/report_test.py index f92666b2c..c67d81b1e 100644 --- a/testing/unit/report/report_test.py +++ b/testing/unit/report/report_test.py @@ -16,6 +16,7 @@ from testreport import TestReport import os import json +import shutil MODULE = 'report' @@ -30,6 +31,10 @@ class ReportTest(unittest.TestCase): @classmethod def setUpClass(cls): + # Delete old files + if os.path.exists(OUTPUT_DIR) and os.path.isdir(OUTPUT_DIR): + shutil.rmtree(OUTPUT_DIR) + # Create the output directories and ignore errors if it already exists os.makedirs(OUTPUT_DIR, exist_ok=True) @@ -59,6 +64,47 @@ def report_compliant_test(self): def report_noncompliant_test(self): self.create_report(os.path.join(TEST_FILES_DIR, 'report_noncompliant.json')) + # Generate formatted reports for each report generated from + # the test containers. + # Not a unit test but can't run from within the test module container and must + # be done through the venv. Useful for doing visual inspections + # of report formatting changes without having to re-run a new device test. + def report_formatting(self): + test_modules = ['conn','dns','ntp','protocol','services','tls'] + unit_tests = os.listdir(UNIT_TEST_DIR) + for test in unit_tests: + if test in test_modules: + output_dir = os.path.join(UNIT_TEST_DIR,test,'output') + if os.path.isdir(output_dir): + output_files = os.listdir(output_dir) + for file in output_files: + if file.endswith('.html'): + + # Read the generated report and add formatting + report_out_path = os.path.join(output_dir,file) + with open(report_out_path, 'r', encoding='utf-8') as f: + report_out = f.read() + formatted_report = self.add_formatting(report_out) + + # Write back the new formatted_report value + out_report_dir = os.path.join(OUTPUT_DIR, test) + os.makedirs(out_report_dir, exist_ok=True) + + with open(os.path.join( + out_report_dir,file), 'w', + encoding='utf-8') as f: + f.write(formatted_report) + + def add_formatting(self, body): + return f''' + + + {TestReport().generate_head()} + + {body} + + /dev/null 2>&1 - -echo "Root dir: $PWD" - -# Add the framework sources -PYTHONPATH="$PWD/framework/python/src:$PWD/framework/python/src/common" - -# Add the test module sources -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/base/python/src" -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/conn/python/src" -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/tls/python/src" -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/dns/python/src" -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/services/python/src" -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/ntp/python/src" -PYTHONPATH="$PYTHONPATH:$PWD/modules/test/protocol/python/src" - - -# Set the python path with all sources -export PYTHONPATH - -# Run the DHCP Unit tests -python3 -u $PWD/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py -python3 -u $PWD/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py - -# Run the Conn Module Unit Tests -python3 -u $PWD/testing/unit/conn/conn_module_test.py - -# Run the TLS Module Unit Tests -python3 -u $PWD/testing/unit/tls/tls_module_test.py - -# Run the DNS Module Unit Tests -python3 -u $PWD/testing/unit/dns/dns_module_test.py - -# Run the NMAP Module Unit Tests -python3 -u $PWD/testing/unit/services/services_module_test.py - -# Run the NTP Module Unit Tests -python3 -u $PWD/testing/unit/ntp/ntp_module_test.py - -# Run the Report Unit Tests -python3 -u $PWD/testing/unit/report/report_test.py - -# Run the RiskProfile Unit Tests -python3 -u $PWD/testing/unit/risk_profile/risk_profile_test.py - -# Run the RiskProfile Unit Tests -python3 -u $PWD/testing/unit/protocol/protocol_module_test.py - -popd >/dev/null 2>&1 diff --git a/testing/unit/services/output/services.log b/testing/unit/services/output/services.log deleted file mode 100644 index 7df3f745b..000000000 --- a/testing/unit/services/output/services.log +++ /dev/null @@ -1,6 +0,0 @@ -Jun 17 09:23:01 test_services INFO Module report generated at: testing/unit/services/output/services_report.html -Jun 17 09:23:01 test_services INFO Module report generated at: testing/unit/services/output/services_report.html -Jun 17 09:23:01 test_services INFO Module report generated at: testing/unit/services/output/services_report.html -Jun 17 09:32:48 test_services INFO Module report generated at: testing/unit/services/output/services_report.html -Jun 17 09:32:48 test_services INFO Module report generated at: testing/unit/services/output/services_report.html -Jun 17 09:32:48 test_services INFO Module report generated at: testing/unit/services/output/services_report.html diff --git a/testing/unit/services/services_module_test.py b/testing/unit/services/services_module_test.py index 30c4928bf..a8c60262b 100644 --- a/testing/unit/services/services_module_test.py +++ b/testing/unit/services/services_module_test.py @@ -16,7 +16,7 @@ import unittest import os import shutil -from testreport import TestReport +# from testreport import TestReport MODULE = 'services' @@ -29,8 +29,6 @@ LOCAL_REPORT = os.path.join(REPORTS_DIR, 'services_report_local.html') LOCAL_REPORT_ALL_CLOSED = os.path.join(REPORTS_DIR, 'services_report_all_closed_local.html') -CONF_FILE = 'modules/test/' + MODULE + '/conf/module_config.json' - class ServicesTest(unittest.TestCase): """Contains and runs all the unit tests concerning DNS behaviors""" @@ -51,7 +49,6 @@ def services_module_ports_open_report_test(self): services_module = ServicesModule(module=MODULE, log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, run=False, nmap_scan_results_path=OUTPUT_DIR) @@ -61,13 +58,6 @@ def services_module_ports_open_report_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join( - OUTPUT_DIR, 'services_report_ports_open.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT, 'r', encoding='utf-8') as file: @@ -85,7 +75,6 @@ def services_module_report_all_closed_test(self): services_module = ServicesModule(module=MODULE, log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, run=False, nmap_scan_results_path=OUTPUT_DIR) @@ -95,13 +84,6 @@ def services_module_report_all_closed_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join( - OUTPUT_DIR, 'services_report_all_closed.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT_ALL_CLOSED, 'r', encoding='utf-8') as file: @@ -109,17 +91,6 @@ def services_module_report_all_closed_test(self): self.assertEqual(report_out, report_local) - def add_formatting(self, body): - return f''' - - - {TestReport().generate_head()} - - {body} - -