diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 25b3a394a..b01bc5448 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -21,6 +21,7 @@ jobs: run: cmd/package - name: Install Testrun shell: bash {0} + timeout-minutes: 10 run: sudo dpkg -i testrun*.deb - name: Run baseline tests shell: bash {0} @@ -67,15 +68,26 @@ jobs: - name: Install dependencies shell: bash {0} run: cmd/prepare - - name: Package Testrun + - name: Package Testrun shell: bash {0} run: cmd/package - name: Install Testrun shell: bash {0} run: sudo dpkg -i testrun*.deb + timeout-minutes: 30 - name: Run tests shell: bash {0} run: testing/api/test_api + - name: Archive runtime results + if: ${{ always() }} + run: sudo tar --exclude-vcs -czf runtime.tgz /usr/local/testrun/runtime/ /usr/local/testrun/local/ + - name: Upload runtime results + uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + if-no-files-found: error + name: runtime_${{ github.workflow }}_${{ github.run_id }} + path: runtime.tgz pylint: name: Pylint diff --git a/README.md b/README.md index 404de4915..7b026c2e9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Testrun cannot automate everything, and so additional manual testing may be requ - Internet connection ### Software - Docker - installation guide: [https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository) +### Device + - 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). diff --git a/bin/testrun b/bin/testrun index 41af70ffe..b58313bf9 100755 --- a/bin/testrun +++ b/bin/testrun @@ -19,10 +19,6 @@ if [[ "$EUID" -ne 0 ]]; then exit 1 fi -# TODO: Obtain TESTRUNPATH from user environment variables -# TESTRUNPATH="/home/boddey/Desktop/test-run" -# cd $TESTRUNPATH - # Ensure that /var/run/netns folder exists sudo mkdir -p /var/run/netns diff --git a/cmd/package b/cmd/package index 24d0cf98e..ac047cf41 100755 --- a/cmd/package +++ b/cmd/package @@ -53,4 +53,4 @@ cp -r {framework,modules} $MAKE_SRC_DIR/usr/local/testrun dpkg-deb --build --root-owner-group make # Rename the .deb file -mv make.deb testrun_1-0-1_amd64.deb +mv make.deb testrun_1-0-2_amd64.deb diff --git a/docs/get_started.md b/docs/get_started.md index f201ee8b4..18c8ecd46 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -24,6 +24,11 @@ Ensure the following software is installed on your Ubuntu LTS PC: - Build Essential - Net Tools +### Device +Any device with an ethernet connection, and support for IPv4 DHCP can be tested. + +However, to achieve a compliant test outcome, your device must be configured correctly and implement the required security features. These standards are outlined in the [Application Security Requirements for IoT Devices](https://partner-security.withgoogle.com/docs/iot_requirements). but further detail is available in [documentation for each test module](/docs/test/modules.md). + ## Installation 1. Download the latest version of the Testrun installer from the [releases page](https://github.com/google/test-run/releases) @@ -42,6 +47,8 @@ Ensure the following software is installed on your Ubuntu LTS PC: - Connect one USB Ethernet adapter to the internet source (e.g., router or switch) using an ethernet cable. - Connect the other USB Ethernet adapter directly to the IoT device you want to test using an ethernet cable. + **NOTE: The device under test should be powered off until prompted** + **NOTE: Both adapters should be disabled in the host system (IPv4, IPv6 and general). You can do this by going to Settings > Network** 2. Start Testrun. @@ -82,7 +89,9 @@ Start Testrun with the command `sudo testrun` - During testing, if you would like to stop Testrun, click 'Stop' next to the test name. -11. On completion of the test sequence, a report will appear under the history icon. +11. Once the notification 'Waiting for Device' appears, power on the device under test. + +12. On completion of the test sequence, a report will appear under the history icon. ![](/docs/ui/history_icon.png) @@ -94,3 +103,6 @@ If you encounter any issues or need assistance, consider the following: - Verify that the network interfaces are connected correctly. - Check the configuration settings. - Refer to the Testrun documentation or ask for assistance in the issues page: https://github.com/google/testrun/issues + +# Uninstall +To uninstall Testrun, use the built-in dpkg uninstall command to remove Testrun correctly. For Testrun, this would be: ```sudo apt-get remove testrun``` diff --git a/docs/network/addresses.md b/docs/network/addresses.md index ecaacfd36..cbefc84a0 100644 --- a/docs/network/addresses.md +++ b/docs/network/addresses.md @@ -4,13 +4,13 @@ Each network service is configured with an IPv4 and IPv6 address. For IPv4 addre | Name | Mac address | IPv4 address | IPv6 address | |---------------------|----------------------|--------------|------------------------------| -| Internet gateway | 9a:02:57:1e:8f:01 | 10.10.10.1 | fd10:77be:4186::1 | -| DHCP primary | 9a:02:57:1e:8f:02 | 10.10.10.2 | fd10:77be:4186::2 | -| DHCP secondary | 9a:02:57:1e:8f:03 | 10.10.10.3 | fd10:77be:4186::3 | -| DNS server | 9a:02:57:1e:8f:04 | 10.10.10.4 | fd10:77be:4186::4 | -| NTP server | 9a:02:57:1e:8f:05 | 10.10.10.5 | fd10:77be:4186::5 | -| Radius authenticator| 9a:02:57:1e:8f:07 | 10.10.10.7 | fd10:77be:4186::7 | -| Active test module | 9a:02:57:1e:8f:09 | 10.10.10.9 | fd10:77be:4186::9 | +| Internet gateway | 9a:02:57:1e:8f:01 | 10.10.10.1 | fd10:77be:4186::1 | +| DHCP primary | 9a:02:57:1e:8f:02 | 10.10.10.2 | fd10:77be:4186::2 | +| DHCP secondary | 9a:02:57:1e:8f:03 | 10.10.10.3 | fd10:77be:4186::3 | +| DNS server | 9a:02:57:1e:8f:04 | 10.10.10.4 | fd10:77be:4186::4 | +| NTP server | 9a:02:57:1e:8f:05 | 10.10.10.5 | fd10:77be:4186::5 | +| Radius authenticator| 9a:02:57:1e:8f:07 | 10.10.10.7 | fd10:77be:4186::7 | +| Active test module | 9a:02:57:1e:8f:09 | 10.10.10.9 | fd10:77be:4186::9 | The default network range is 10.10.10.0/24 and devices will be assigned addresses in that range via DHCP. The range may change when requested by a test module. In which case, network services will be restarted and accessible on the new range, with the same final host ID. The default IPv6 network is fd10:77be:4186::/64 and addresses will be assigned to devices on the network using IPv6 SLAAC. diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 5964b6244..1f7fcbb18 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -13,10 +13,12 @@ # limitations under the License. from fastapi import FastAPI, APIRouter, Response, Request, status +from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from datetime import datetime import json from json import JSONDecodeError +import os import psutil import threading import uvicorn @@ -30,6 +32,7 @@ DEVICE_MANUFACTURER_KEY = "manufacturer" DEVICE_MODEL_KEY = "model" DEVICE_TEST_MODULES_KEY = "test_modules" +DEVICES_PATH = "/usr/local/testrun/local/devices" class Api: """Provide REST endpoints to manage Testrun""" @@ -59,6 +62,8 @@ def __init__(self, test_run): self._router.add_api_route("/report", self.delete_report, methods=["DELETE"]) + self._router.add_api_route("/report/{device_name}/{timestamp}", + self.get_report) self._router.add_api_route("/devices", self.get_devices) self._router.add_api_route("/device", @@ -152,23 +157,24 @@ async def start_test_run(self, request: Request, response: Response): # Check if requested device is known in the device repository if device is None: response.status_code = status.HTTP_404_NOT_FOUND - return self._generate_msg(False, - "A device with that MAC address " + - "could not be found") + return self._generate_msg( + False, + "A device with that MAC address could not be found") device.firmware = body_json["device"]["firmware"] # Check Testrun is able to start if self._test_run.get_net_orc().check_config() is False: response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - return self._generate_msg(False,"Configured interfaces are " + - "not ready for use. Ensure required " + - "interfaces are connected.") + return self._generate_msg(False,"Configured interfaces are not " + + "ready for use. Ensure required interfaces " + + "are connected.") self._test_run.get_session().reset() self._test_run.get_session().set_target_device(device) - LOGGER.info(f"Starting Testrun with device target {device.manufacturer} " + - f"{device.model} with MAC address {device.mac_addr}") + LOGGER.info("Starting Testrun with device target " + + f"{device.manufacturer} {device.model} with " + + f"MAC address {device.mac_addr}") thread = threading.Thread(target=self._start_test_run, name="Testrun") @@ -186,7 +192,10 @@ def _start_test_run(self): async def stop_test_run(self): LOGGER.debug("Received stop command. Stopping Testrun") + + # TODO: Set status of 'Stopping'? self._test_run.stop() + return self._generate_msg(True, "Testrun stopped") async def get_status(self): @@ -309,6 +318,19 @@ async def save_device(self, request: Request, response: Response): response.status_code = status.HTTP_400_BAD_REQUEST return self._generate_msg(False, "Invalid JSON received") + async def get_report(self, response: Response, + device_name, timestamp): + + file_path = os.path.join(DEVICES_PATH, device_name, "reports", + timestamp, "report.pdf") + LOGGER.debug(f"Received get report request for {device_name} / {timestamp}") + if os.path.isfile(file_path): + return FileResponse(file_path) + else: + LOGGER.info("Report could not be found, returning 404") + response.status_code = 404 + return self._generate_msg(False, "Report could not be found") + def _validate_device_json(self, json_obj): # Check all required properties are present diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index 5d41fbef1..47bd895bb 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -38,7 +38,15 @@ def add_report(self, report): def get_reports(self): return self.reports - # TODO: Add ability to remove reports once test reports have been cleaned up + def remove_report(self, timestamp): + + remove_report_target = None + for report in self.reports: + if report.get_started() == timestamp: + remove_report_target = report + + if remove_report_target is not None: + self.reports.remove(remove_report_target) def to_dict(self): """Returns the device as a python dictionary. This is used for the diff --git a/framework/python/src/common/session.py b/framework/python/src/common/session.py index 14594f658..16210a022 100644 --- a/framework/python/src/common/session.py +++ b/framework/python/src/common/session.py @@ -22,7 +22,6 @@ NETWORK_KEY = 'network' DEVICE_INTF_KEY = 'device_intf' INTERNET_INTF_KEY = 'internet_intf' -RUNTIME_KEY = 'runtime' MONITOR_PERIOD_KEY = 'monitor_period' STARTUP_TIMEOUT_KEY = 'startup_timeout' LOG_LEVEL_KEY = 'log_level' @@ -47,6 +46,11 @@ def __init__(self, config_file): self._config = self._get_default_config() self._load_config() + tz = util.run_command('cat /etc/timezone') + # TODO: Check if timezone is fetched successfully + self._timezone = tz[0] + LOGGER.info(f'System timezone is {self._timezone}') + def start(self): self.reset() self._status = 'Waiting for Device' @@ -70,7 +74,6 @@ def _get_default_config(self): 'log_level': 'INFO', 'startup_timeout': 60, 'monitor_period': 30, - 'runtime': 120, 'max_device_reports': 5, 'api_port': 8000 } @@ -98,9 +101,6 @@ def _load_config(self): self._config[NETWORK_KEY][INTERNET_INTF_KEY] = config_file_json.get( NETWORK_KEY, {}).get(INTERNET_INTF_KEY) - if RUNTIME_KEY in config_file_json: - self._config[RUNTIME_KEY] = config_file_json.get(RUNTIME_KEY) - if STARTUP_TIMEOUT_KEY in config_file_json: self._config[STARTUP_TIMEOUT_KEY] = config_file_json.get( STARTUP_TIMEOUT_KEY) @@ -126,9 +126,6 @@ def _save_config(self): f.write(json.dumps(self._config, indent=2)) util.set_file_owner(owner=util.get_host_user(), path=self._config_file) - def get_runtime(self): - return self._config.get(RUNTIME_KEY) - def get_log_level(self): return self._config.get(LOG_LEVEL_KEY) @@ -244,3 +241,6 @@ def to_json(self): } return session_json + + def get_timezone(self): + return self._timezone diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 792ddd22b..561e81c78 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -22,6 +22,8 @@ DATE_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' RESOURCES_DIR = 'resources/report' +TESTS_FIRST_PAGE = 12 +TESTS_PER_PAGE = 20 # Locate parent directory current_dir = os.path.dirname(os.path.realpath(__file__)) @@ -129,45 +131,57 @@ def to_html(self): ''' def generate_test_sections(self,json_data): - results = json_data["tests"]["results"] - sections = "" + results = json_data['tests']['results'] + sections = '' for result in results: - sections += self.generate_test_section(result) + sections += self.generate_test_section(result) return sections def generate_test_section(self, result): section_content = '
\n' for key, value in result.items(): - if value is not None: # Check if the value is not None - formatted_key = key.replace('_', ' ').title() # Replace underscores and capitalize - section_content += f'

{formatted_key}: {value}

\n' + if value is not None: # Check if the value is not None + # Replace underscores and capitalize + formatted_key = key.replace('_', ' ').title() + section_content += f'

{formatted_key}: {value}

\n' section_content += '
\n
\n' return section_content - def generate_pages(self,json_data): - max_page = 1 - reports_per_page = 25 # figure out how many can fit on other pages + def generate_pages(self, json_data): # Calculate pages test_count = len(json_data['tests']['results']) - # 10 tests can fit on the first page - if test_count > 10: - test_count -= 10 + # Multiple pages required + if test_count > TESTS_FIRST_PAGE: + # First page + full_page = 1 + + # Remaining tests + test_count -= TESTS_FIRST_PAGE + full_page += (int)(test_count / TESTS_PER_PAGE) + partial_page = 1 if test_count % TESTS_PER_PAGE > 0 else 0 + + # 1 page required + elif test_count == TESTS_FIRST_PAGE: + full_page = 1 + partial_page = 0 + # Less than 1 page required + else: + full_page = 0 + partial_page = 1 - full_page = (int)(test_count / reports_per_page) - partial_page = 1 if test_count % reports_per_page > 0 else 0 - if partial_page > 0: - max_page += full_page + partial_page + max_page = full_page + partial_page pages = '' for i in range(max_page): pages += self.generate_page(json_data, i+1, max_page) return pages - def generate_page(self,json_data, page_num, max_page): + def generate_page(self, json_data, page_num, max_page): # Placeholder until available in json report - version = 'v1.0.1 (2023-10-02)' + + version = 'v1.0.2 (2023-10-25)' page = '
' page += self.generate_header(json_data) if page_num == 1: @@ -177,17 +191,16 @@ def generate_page(self,json_data, page_num, max_page): page += '
' if page_num < max_page: page += '
' - #page += f'''

''' return page - def generate_body(self,json_data, page_num=1, max_page=1): + def generate_body(self, json_data, page_num=1, max_page=1): return f''' {self.generate_pages(json_data)} ''' - def generate_footer(self,page_num, max_page, version): + def generate_footer(self, page_num, max_page, version): footer = f''' ''' if page_num == 1: start = 0 + elif page_num == 2: + start = TESTS_FIRST_PAGE else: - start = 10 * (page_num - 1) + (page_num-2) * 25 - results_on_page = 10 if page_num == 1 else 25 - result_end = min(results_on_page,len(json_data['tests']['results'])) + start = (page_num-2) * TESTS_PER_PAGE + TESTS_FIRST_PAGE + results_on_page = TESTS_FIRST_PAGE if page_num == 1 else TESTS_PER_PAGE + result_end = min(start+results_on_page, len(json_data['tests']['results'])) for ix in range(result_end-start): result = json_data['tests']['results'][ix+start] result_list += self.generate_result(result) @@ -231,7 +246,7 @@ def generate_result(self,result): result_html = f'''
{result['name']}
-
{result['test_description']}
+
{result['description']}
{result['result']}
''' @@ -264,7 +279,10 @@ def generate_summary(self, json_data): summary += self.generate_device_summary_label('Manufacturer',manufacturer) summary += self.generate_device_summary_label('Model',model) summary += self.generate_device_summary_label('Firmware',fw) - summary += self.generate_device_summary_label('MAC Address',mac,trailing_space=False) + summary += self.generate_device_summary_label( + 'MAC Address', + mac, + trailing_space=False) # Add the result summary summary += self.generate_result_summary(json_data) @@ -282,11 +300,13 @@ def generate_result_summary(self,json_data): result_summary += self.generate_result_summary_item('Started', json_data['started']) # Convert the timestamp strings to datetime objects - start_time = datetime.strptime(json_data['started'], "%Y-%m-%d %H:%M:%S") - end_time = datetime.strptime(json_data['finished'], "%Y-%m-%d %H:%M:%S") + start_time = datetime.strptime(json_data['started'], '%Y-%m-%d %H:%M:%S') + end_time = datetime.strptime(json_data['finished'], '%Y-%m-%d %H:%M:%S') # Calculate the duration duration = end_time - start_time - result_summary += self.generate_result_summary_item('Duration',str(duration)) + result_summary += self.generate_result_summary_item( + 'Duration', + str(duration)) result_summary += '\n' return result_summary @@ -305,7 +325,7 @@ def generate_device_summary_label(self, key, value, trailing_space=True):
{value}
''' if trailing_space: - label += '''
''' + label += '''
''' return label def generate_head(self): diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 8fb522542..7fda45ed7 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -299,20 +299,13 @@ def start(self): self._start_network() + self.get_net_orc().get_listener().register_callback( + self._device_discovered, + [NetworkEvent.DEVICE_DISCOVERED] + ) + if self._net_only: LOGGER.info('Network only option configured, no tests will be run') - - self.get_net_orc().get_listener().register_callback( - self._device_discovered, - [NetworkEvent.DEVICE_DISCOVERED] - ) - - self.get_net_orc().start_listener() - LOGGER.info('Waiting for devices on the network...') - - while True: - time.sleep(self._session.get_runtime()) - else: self._test_orc.start() @@ -321,27 +314,13 @@ def start(self): [NetworkEvent.DEVICE_STABLE] ) - self.get_net_orc().get_listener().register_callback( - self._device_discovered, - [NetworkEvent.DEVICE_DISCOVERED] - ) - - self.get_net_orc().start_listener() - self._set_status('Waiting for Device') - LOGGER.info('Waiting for devices on the network...') - - time.sleep(self.get_session().get_runtime()) - - if not (self._test_orc.test_in_progress() or - self.get_net_orc().monitor_in_progress()): - LOGGER.info('''Timed out whilst waiting for - device or stopping due to test completion''') - else: - while (self._test_orc.test_in_progress() or - self.get_net_orc().monitor_in_progress()): - time.sleep(5) + self.get_net_orc().start_listener() + LOGGER.info('Waiting for devices on the network...') + self._set_status('Waiting for Device') - self.stop() + # Keep application running until stopped + while True: + time.sleep(5) def stop(self, kill=False): @@ -352,6 +331,7 @@ def stop(self, kill=False): self._stop_tests() self._stop_network(kill=kill) self._stop_ui() + self.get_session().set_status('Cancelled') def _register_exits(self): signal.signal(signal.SIGINT, self._exit_handler) diff --git a/framework/python/src/net_orc/network_orchestrator.py b/framework/python/src/net_orc/network_orchestrator.py index 05733dfe0..64eb1eaaf 100644 --- a/framework/python/src/net_orc/network_orchestrator.py +++ b/framework/python/src/net_orc/network_orchestrator.py @@ -130,7 +130,7 @@ def get_listener(self): return self._listener def start_listener(self): - LOGGER.debug("Starting network listener") + LOGGER.debug('Starting network listener') self.get_listener().start_listener() def stop(self, kill=False): @@ -186,6 +186,7 @@ def _device_discovered(self, mac_addr): if device.ip_addr is None: LOGGER.info( f'Timed out whilst waiting for {mac_addr} to obtain an IP address') + self._session.set_status('Cancelled') return LOGGER.info( f'Device with mac addr {device.mac_addr} has obtained IP address ' @@ -455,16 +456,20 @@ def _start_network_service(self, net_module): try: client = docker.from_env() net_module.container = client.containers.run( - net_module.image_name, - auto_remove=True, - cap_add=['NET_ADMIN'], - name=net_module.container_name, - hostname=net_module.container_name, - network=PRIVATE_DOCKER_NET, - privileged=True, - detach=True, - mounts=net_module.mounts, - environment={'HOST_USER': util.get_host_user()}) + net_module.image_name, + auto_remove=True, + cap_add=['NET_ADMIN'], + name=net_module.container_name, + hostname=net_module.container_name, + network=PRIVATE_DOCKER_NET, + privileged=True, + detach=True, + mounts=net_module.mounts, + environment={ + 'TZ': self.get_session().get_timezone(), + 'HOST_USER': util.get_host_user() + } + ) except docker.errors.ContainerError as error: LOGGER.error('Container run error') LOGGER.error(error) @@ -685,6 +690,8 @@ def restore_net(self): LOGGER.info('Network is restored') + def get_session(self): + return self._session 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 6ab246b5c..0950a61a6 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -31,9 +31,11 @@ TEST_MODULES_DIR = "modules/test" MODULE_CONFIG = "conf/module_config.json" LOG_REGEX = r"^[A-Z][a-z]{2} [0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} test_" -SAVED_DEVICE_REPORTS = "local/devices/{device_folder}/reports" +SAVED_DEVICE_REPORTS = "report/{device_folder}/" +LOCAL_DEVICE_REPORTS = "local/devices/{device_folder}/reports" DEVICE_ROOT_CERTS = "local/root_certs" TESTRUN_DIR = "/usr/local/testrun" +API_URL = "http://localhost:8000" class TestOrchestrator: @@ -109,7 +111,6 @@ def run_test_modules(self): self._cleanup_old_test_results(device) LOGGER.debug("Old test results cleaned") - self._test_in_progress = False return report.get_status() @@ -119,6 +120,8 @@ def _write_reports(self, test_report): self._root_path, RUNTIME_DIR, self._session.get_target_device().mac_addr.replace(":", "")) + LOGGER.debug(f"Writing reports to {out_dir}") + # Write the json report with open(os.path.join(out_dir,"report.json"),"w", encoding="utf-8") as f: json.dump(test_report.to_json(), f, indent=2) @@ -143,28 +146,13 @@ def _generate_report(self): "%Y-%m-%d %H:%M:%S") report["status"] = self._calculate_result() report["tests"] = self.get_session().get_report_tests() - report["report"] = "file://" + os.path.join( - TESTRUN_DIR, - SAVED_DEVICE_REPORTS.replace( - "{device_folder}", - self.get_session().get_target_device().device_folder), - self.get_session().get_finished().strftime( - "%Y-%m-%dT%H:%M:%S"), - "report.pdf" + report["report"] = ( + API_URL + "/" + + SAVED_DEVICE_REPORTS.replace("{device_folder}", + self.get_session().get_target_device().device_folder) + + self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S") ) - out_file = os.path.join( - self._root_path, RUNTIME_DIR, - self._session.get_target_device().mac_addr.replace(":", ""), - "report.json") - - LOGGER.debug(f"Saving report to {out_file}") - - # Write report to runtime directory - with open(out_file, "w", encoding="utf-8") as f: - json.dump(report, f, indent=2) - util.run_command(f"chown -R {self._host_user} {out_file}") - return report def _calculate_result(self): @@ -189,7 +177,7 @@ def _cleanup_old_test_results(self, device): completed_results_dir = os.path.join( self._root_path, - SAVED_DEVICE_REPORTS.replace("{device_folder}", device.device_folder)) + LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder)) completed_tests = os.listdir(completed_results_dir) cur_test_count = len(completed_tests) @@ -200,8 +188,13 @@ def _cleanup_old_test_results(self, device): # Find and delete the oldest test oldest_test = self._find_oldest_test(completed_results_dir) if oldest_test is not None: - LOGGER.debug("Oldest test found, removing: " + str(oldest_test)) - shutil.rmtree(oldest_test, ignore_errors=True) + LOGGER.debug("Oldest test found, removing: " + str(oldest_test[1])) + shutil.rmtree(oldest_test[1], ignore_errors=True) + + # Remove oldest test from session + oldest_timestamp = oldest_test[0] + self.get_session().get_target_device().remove_report(oldest_timestamp) + # Confirm the delete was succesful new_test_count = len(os.listdir(completed_results_dir)) if (new_test_count != cur_test_count @@ -218,21 +211,24 @@ def _find_oldest_test(self, completed_tests_dir): oldest_timestamp = timestamp oldest_directory = completed_test if oldest_directory: - return os.path.join(completed_tests_dir, oldest_directory) + return oldest_timestamp, os.path.join(completed_tests_dir, oldest_directory) else: return None def _timestamp_results(self, device): # Define the current device results directory - cur_results_dir = os.path.join(self._root_path, RUNTIME_DIR, - device.mac_addr.replace(":", "")) + cur_results_dir = os.path.join( + self._root_path, + RUNTIME_DIR, + device.mac_addr.replace(":", "") + ) - # Define the destination results directory with timestamp - cur_time = self.get_session().get_finished().strftime("%Y-%m-%dT%H:%M:%S") completed_results_dir = os.path.join( - SAVED_DEVICE_REPORTS.replace("{device_folder}", device.device_folder), - cur_time) + self._root_path, + LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder), + self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S") + ) # Copy the results to the timestamp directory # leave current copy in place for quick reference to @@ -310,12 +306,13 @@ def _run_test_module(self, module): read_only=True) ], environment={ - "HOST_USER": self._host_user, - "DEVICE_MAC": device.mac_addr, - "IPV4_ADDR": device.ip_addr, - "DEVICE_TEST_MODULES": json.dumps(device.test_modules), - "IPV4_SUBNET": self._net_orc.network_config.ipv4_network, - "IPV6_SUBNET": self._net_orc.network_config.ipv6_network + "TZ": self.get_session().get_timezone(), + "HOST_USER": self._host_user, + "DEVICE_MAC": device.mac_addr, + "IPV4_ADDR": device.ip_addr, + "DEVICE_TEST_MODULES": json.dumps(device.test_modules), + "IPV4_SUBNET": self._net_orc.network_config.ipv4_network, + "IPV6_SUBNET": self._net_orc.network_config.ipv6_network }) except (docker.errors.APIError, docker.errors.ContainerError) as container_error: diff --git a/make/DEBIAN/control b/make/DEBIAN/control index ae56b91c0..5ff2cf117 100644 --- a/make/DEBIAN/control +++ b/make/DEBIAN/control @@ -1,5 +1,5 @@ Package: Testrun -Version: 1.0.1 +Version: 1.0.2 Architecture: amd64 Maintainer: Google Homepage: https://github.com/google/testrun diff --git a/make/DEBIAN/postrm b/make/DEBIAN/postrm new file mode 100755 index 000000000..b9e03bddb --- /dev/null +++ b/make/DEBIAN/postrm @@ -0,0 +1,17 @@ +#!/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. + +echo Finished uninstalling Testrun \ No newline at end of file diff --git a/make/DEBIAN/prerm b/make/DEBIAN/prerm new file mode 100755 index 000000000..8f0054282 --- /dev/null +++ b/make/DEBIAN/prerm @@ -0,0 +1,25 @@ +#!/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. + +# Remove docker images +echo Removing docker images +docker_images=$(sudo docker images --filter=reference="test-run/*" -q) + +if [ -z "$docker_images" ]; then + echo No docker images to delete +else + sudo docker rmi $docker_images > /dev/null +fi \ No newline at end of file diff --git a/modules/network/base/base.Dockerfile b/modules/network/base/base.Dockerfile index ac964a99d..a9317e85c 100644 --- a/modules/network/base/base.Dockerfile +++ b/modules/network/base/base.Dockerfile @@ -15,12 +15,14 @@ # Image name: test-run/base FROM ubuntu:jammy +RUN apt-get update + ARG MODULE_NAME=base ARG MODULE_DIR=modules/network/$MODULE_NAME ARG COMMON_DIR=framework/python/src/common # Install common software -RUN apt-get update && apt-get install -y net-tools iputils-ping tcpdump iproute2 jq python3 python3-pip dos2unix +RUN DEBIAN_FRONTEND=noninteractive apt-get install -yq net-tools iputils-ping tzdata tcpdump iproute2 jq python3 python3-pip dos2unix # Install common python modules COPY $COMMON_DIR/ /testrun/python/src/common diff --git a/modules/test/base/base.Dockerfile b/modules/test/base/base.Dockerfile index 707136f6d..340d0e983 100644 --- a/modules/test/base/base.Dockerfile +++ b/modules/test/base/base.Dockerfile @@ -19,8 +19,10 @@ ARG MODULE_NAME=base ARG MODULE_DIR=modules/test/$MODULE_NAME ARG COMMON_DIR=framework/python/src/common +RUN apt-get update + # Install common software -RUN apt-get update && apt-get install -y net-tools iputils-ping tcpdump iproute2 jq python3 python3-pip dos2unix nmap --fix-missing +RUN DEBIAN_FRONTEND=noninteractive apt-get install -yq net-tools iputils-ping tzdata tcpdump iproute2 jq python3 python3-pip dos2unix nmap --fix-missing # Install common python modules COPY $COMMON_DIR/ /testrun/python/src/common diff --git a/modules/test/conn/README.md b/modules/test/conn/README.md index 48729f388..cf79c3efb 100644 --- a/modules/test/conn/README.md +++ b/modules/test/conn/README.md @@ -14,14 +14,11 @@ Within the ```python/src``` directory, the below tests are executed. A few dhcp | ID | Description | Expected Behavior | Required Result | |------------------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------| -| connection.dhcp.disconnect | The device under test has received an IP address from the DHCP server and responds to an ICMP echo (ping) request | The device is not set up with a static IP address. The device accepts an IP address from a DHCP server (RFC 2131) and responds successfully to an ICMP echo (ping) request. | Required | -| connection.dhcp.disconnect_ip_change | Update device IP on the DHCP server and reconnect the device. Does the device receive the new IP address? | Device receives a new IP address within the range specified on the DHCP server. Device should respond to a ping on this new address. | Required | | connection.dhcp_address | The device under test has received an IP address from the DHCP server and responds to an ICMP echo (ping) request | The device is not set up with a static IP address. The device accepts an IP address from a DHCP server (RFC 2131) and responds successfully to an ICMP echo (ping) request. | Required | | connection.mac_address | Check and note device physical address. | N/A | Required | | connection.mac_oui | The device under test has a MAC address prefix that is registered against a known manufacturer. | The MAC address prefix is registered in the IEEE Organizationally Unique Identifier database. | Required | | connection.private_address | The device under test accepts an IP address that is compliant with RFC 1918 Address Allocation for Private Internets. | The device under test accepts IP addresses within all ranges specified in RFC 1918 and communicates using these addresses. The Internet Assigned Numbers Authority (IANA) has reserved the following three blocks of the IP address space for private internets: 10.0.0.0 - 10.255.255.255 (10/8 prefix), 172.16.0.0 - 172.31.255.255 (172.16/12 prefix), 192.168.0.0 - 192.168.255.255 (192.168/16 prefix). | Required | | connection.shared_address | Ensure the device supports RFC 6598 IANA-Reserved IPv4 Prefix for Shared Address Space | The device under test accepts IP addresses within the range specified in RFC 6598 and communicates using these addresses. | Required | -| connection.private_address | The device under test accepts an IP address that is compliant with RFC 1918 Address Allocation for Private Internets. | The device under test accepts IP addresses within all ranges specified in RFC 1918 and communicates using these addresses. The Internet Assigned Numbers Authority (IANA) has reserved the following three blocks of the IP address space for private internets: 10.0.0.0 - 10.255.255.255.255 (10/8 prefix), 172.16.0.0 - 172.31.255.255 (172.16/12 prefix), 192.168.0.0 - 192.168.255.255 (192.168/16 prefix). | Required | | connection.single_ip | The network switch port connected to the device reports only one IP address for the device under test. | The device under test does not behave as a network switch and only requests one IP address. This test is to avoid that devices implement network switches that allow connecting strings of daisy-chained devices to one single network port, as this would not make 802.1x port-based authentication possible. | Required | | connection.target_ping | The device under test responds to an ICMP echo (ping) request. | The device under test responds to an ICMP echo (ping) request. | Required | | connection.ipaddr.ip_change | The device responds to a ping (ICMP echo request) to the new IP address it has received after the initial DHCP lease has expired. | If the lease expires before the client receives a DHCPACK, the client moves to the INIT state, MUST immediately stop any other network processing, and requires network initialization parameters as if the client were uninitialized. If the client then receives a DHCPACK allocating the client its previous network address, the client SHOULD continue network processing. If the client is given a new network address, it MUST NOT continue using the previous network address and SHOULD notify the local users of the problem. | Required | diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 48575905b..1557d9903 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -13,7 +13,6 @@ # limitations under the License. """Connection test module""" import util -import sys import time from datetime import datetime from scapy.all import rdpcap, DHCP, Ether, IPv6, ICMPv6ND_NS @@ -129,8 +128,7 @@ 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) - packets.append(rdpcap(MONITOR_CAPTURE_FILE)) + packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) # Extract MAC addresses from DHCP packets mac_addresses = set() @@ -139,7 +137,7 @@ def _connection_single_ip(self): if DHCP in packet: for option in packet[DHCP].options: # message-type, option 3 = DHCPREQUEST - if 'message-type' in option and option[1] == 3: + if 'message-type' in option and option[1] == 3: mac_address = packet[Ether].src LOGGER.info('DHCPREQUEST detected MAC addres: ' + mac_address) if not mac_address.startswith(TR_CONTAINER_MAC_PREFIX): @@ -306,7 +304,8 @@ def _connection_ipv6_ping(self): else: if self._ping(self._device_ipv6_addr, ipv6=True): LOGGER.info(f'Device responds to IPv6 ping on {self._device_ipv6_addr}') - result = True, f'Device responds to IPv6 ping on {self._device_ipv6_addr}' + result = True, ('Device responds to IPv6 ping on ' + + f'{self._device_ipv6_addr}') else: LOGGER.info('Device does not respond to IPv6 ping') result = False, 'Device does not respond to IPv6 ping' @@ -356,16 +355,6 @@ def setup_single_dhcp_server(self): else: return False, 'DHCP server stop command failed' - - # TODO: This code is unreachable. - # Move primary DHCP server from failover into a single DHCP server config - LOGGER.info('Configuring primary DHCP server') - response = self.dhcp1_client.disable_failover() - if response.code == 200: - LOGGER.info('Primary DHCP server failover disabled') - else: - return False, 'Failed to disable primary DHCP server failover' - def enable_failover(self): # Move primary DHCP server to primary failover LOGGER.info('Configuring primary failover DHCP server') diff --git a/modules/test/nmap/python/src/nmap_module.py b/modules/test/nmap/python/src/nmap_module.py index 517dc94f9..a55c1b0aa 100644 --- a/modules/test/nmap/python/src/nmap_module.py +++ b/modules/test/nmap/python/src/nmap_module.py @@ -153,7 +153,7 @@ def _check_results(self, ports, services): for open_port, open_port_info in self._scan_results.items(): for port in ports: - allowed = True if 'allowed' in port and port['allowed'] else False + allowed = True if "allowed" in port and port["allowed"] else False if (int(open_port_info["number"]) == int(port["number"]) and open_port_info["tcp_udp"] == port["type"] and open_port_info["state"] == "open"): @@ -170,7 +170,6 @@ def _check_results(self, ports, services): LOGGER.debug("Found service " + open_port_info["service"] + " on port " + str(open_port) + "/" + open_port_info["tcp_udp"]) - if not allowed: match_ports.append(open_port_info["number"] + "/" + open_port_info["tcp_udp"]) diff --git a/modules/ui/package-lock.json b/modules/ui/package-lock.json index 7847f2b25..fe2f681a4 100644 --- a/modules/ui/package-lock.json +++ b/modules/ui/package-lock.json @@ -461,11 +461,7 @@ "peerDependencies": { "@angular/core": "16.2.10" }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true } - } }, "node_modules/@angular/compiler-cli": { "version": "16.2.10", diff --git a/modules/ui/src/app/app-routing.module.ts b/modules/ui/src/app/app-routing.module.ts index 390633e32..7624dca4b 100644 --- a/modules/ui/src/app/app-routing.module.ts +++ b/modules/ui/src/app/app-routing.module.ts @@ -16,6 +16,7 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {allowToRunTestGuard} from './guards/allow-to-run-test.guard'; +import { HashLocationStrategy, LocationStrategy } from '@angular/common'; const routes: Routes = [ { @@ -41,7 +42,8 @@ const routes: Routes = [ @NgModule({ imports: [RouterModule.forRoot(routes)], - exports: [RouterModule] + exports: [RouterModule], + providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}] }) export class AppRoutingModule { } diff --git a/modules/ui/src/app/device-repository/device-repository.component.ts b/modules/ui/src/app/device-repository/device-repository.component.ts index bac3aa08f..5680e7cef 100644 --- a/modules/ui/src/app/device-repository/device-repository.component.ts +++ b/modules/ui/src/app/device-repository/device-repository.component.ts @@ -44,7 +44,6 @@ export class DeviceRepositoryComponent implements OnInit { } openDialog(selectedDevice?: Device): void { - const dialogRef = this.dialog.open(DeviceFormComponent, { data: { device: selectedDevice || null, diff --git a/modules/ui/src/app/notification.service.spec.ts b/modules/ui/src/app/notification.service.spec.ts new file mode 100644 index 000000000..b10bf36a8 --- /dev/null +++ b/modules/ui/src/app/notification.service.spec.ts @@ -0,0 +1,61 @@ +/** + * 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 {TestBed} from '@angular/core/testing'; + +import {NotificationService} from './notification.service'; +import {MatSnackBar} from '@angular/material/snack-bar'; + +describe('NotificationService', () => { + let service: NotificationService; + + const mockMatSnackBar = { + open: () => { + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + {provide: MatSnackBar, useValue: mockMatSnackBar}, + ] + }); + service = TestBed.inject(NotificationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('notify', () => { + it('should open snackbar with message', () => { + const matSnackBarSpy = spyOn(mockMatSnackBar, 'open').and.stub(); + + service.notify('something good happened'); + + expect(matSnackBarSpy).toHaveBeenCalled(); + + const args = matSnackBarSpy.calls.argsFor(0); + expect(args.length).toBe(3); + expect(args[0]).toBe('something good happened'); + expect(args[1]).toBe('x'); + expect(args[2]).toEqual({ + horizontalPosition: 'right', + panelClass: 'test-run-notification', + }); + }); + }); + +}); diff --git a/modules/ui/src/app/notification.service.ts b/modules/ui/src/app/notification.service.ts new file mode 100644 index 000000000..1e1f4a43c --- /dev/null +++ b/modules/ui/src/app/notification.service.ts @@ -0,0 +1,32 @@ +/** + * 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 {Injectable} from '@angular/core'; +import {MatSnackBar} from '@angular/material/snack-bar'; + +@Injectable({ + providedIn: 'root' +}) +export class NotificationService { + constructor(private snackBar: MatSnackBar) { + } + + notify(message: string) { + this.snackBar.open(message, 'x', { + horizontalPosition: 'right', + panelClass: 'test-run-notification' + }) + } +} diff --git a/modules/ui/src/app/test-run.service.spec.ts b/modules/ui/src/app/test-run.service.spec.ts new file mode 100644 index 000000000..fd0c3b690 --- /dev/null +++ b/modules/ui/src/app/test-run.service.spec.ts @@ -0,0 +1,309 @@ +/** + * 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 {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; +import {fakeAsync, getTestBed, TestBed, tick} from '@angular/core/testing'; +import {Device, TestModule} from './model/device'; + +import {TestRunService} from './test-run.service'; +import {SystemConfig} from './model/setting'; +import {MOCK_PROGRESS_DATA_IN_PROGRESS} from './mocks/progress.mock'; +import {StatusOfTestResult, TestrunStatus} from './model/testrun-status'; +import {device} from './mocks/device.mock'; + +const MOCK_SYSTEM_CONFIG: SystemConfig = { + network: { + device_intf: 'mockDeviceValue', + internet_intf: 'mockInternetValue' + } +} + +describe('TestRunService', () => { + let injector: TestBed; + let httpTestingController: HttpTestingController; + let service: TestRunService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [TestRunService] + }); + injector = getTestBed(); + httpTestingController = injector.get(HttpTestingController); + service = injector.get(TestRunService); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it('should be created', () => { + 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: "DHCP", + name: "dhcp", + enabled: true + }, + { + displayName: "DNS", + name: "dns", + enabled: true + }, + { + displayName: "Services", + name: "nmap", + enabled: true + }, + { + displayName: "Security", + name: "security", + enabled: true + }, + { + displayName: "TLS", + name: "tls", + enabled: true + }, + ] as TestModule[]); + }); + + it('getDevices should return devices', () => { + let result: Device[] | null = null; + const deviceArray = [device] as Device[]; + + service.getDevices().subscribe((res) => { + expect(res).toEqual(result); + }); + + result = deviceArray; + service.fetchDevices(); + const req = httpTestingController.expectOne('http://localhost:8000/devices'); + + expect(req.request.method).toBe('GET'); + + req.flush(deviceArray); + }); + + it('setSystemConfig should update the systemConfig data', () => { + service.setSystemConfig(MOCK_SYSTEM_CONFIG); + + service.systemConfig$.subscribe(data => { + expect(data).toEqual(MOCK_SYSTEM_CONFIG); + }) + + }) + + it('getSystemConfig should return systemConfig data', () => { + const apiUrl = 'http://localhost:8000/system/config' + + service.getSystemConfig().subscribe((res) => { + expect(res).toEqual(MOCK_SYSTEM_CONFIG); + }); + + const req = httpTestingController.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + req.flush(MOCK_SYSTEM_CONFIG); + }); + + it('createSystemConfig should call systemConfig data', () => { + const apiUrl = 'http://localhost:8000/system/config' + + service.createSystemConfig(MOCK_SYSTEM_CONFIG).subscribe((res) => { + expect(res).toEqual({}); + }); + + const req = httpTestingController.expectOne(apiUrl); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(MOCK_SYSTEM_CONFIG); + req.flush({}); + }); + + it('getSystemInterfaces should return array of interfaces', () => { + const apiUrl = 'http://localhost:8000/system/interfaces' + const mockSystemInterfaces: string[] = ['mockValue', 'mockValue']; + + service.getSystemInterfaces().subscribe((res) => { + expect(res).toEqual(mockSystemInterfaces); + }); + + const req = httpTestingController.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + req.flush(mockSystemInterfaces); + }); + + it('hasDevice should return true if device with mac address already exist', fakeAsync(() => { + const deviceArray = [device] as Device[]; + service.setDevices(deviceArray); + tick(); + + expect(service.hasDevice("00:1e:42:35:73:c4")).toEqual(true); + expect(service.hasDevice(" 00:1e:42:35:73:c4 ")).toEqual(true); + })); + + it('getSystemStatus should get system status data', () => { + const result = MOCK_PROGRESS_DATA_IN_PROGRESS; + + service.systemStatus$.subscribe((res) => { + expect(res).toEqual(result); + }); + + service.getSystemStatus(); + const req = httpTestingController.expectOne('http://localhost:8000/system/status'); + expect(req.request.method).toBe('GET'); + req.flush(result); + }); + + it('stopTestrun should have necessary request data', () => { + const apiUrl = 'http://localhost:8000/system/stop' + + service.stopTestrun().subscribe((res) => { + expect(res).toEqual(true); + }); + + const req = httpTestingController.expectOne(apiUrl); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({}); + req.flush({}); + }); + + describe('#startTestRun', () => { + it('should have necessary request data', () => { + const apiUrl = 'http://localhost:8000/system/start' + + service.startTestrun(device).subscribe((res) => { + expect(res).toEqual(true); + }); + + const req = httpTestingController.expectOne(apiUrl); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(JSON.stringify({device})); + req.flush({}); + }); + + it('should have error when timeout exceeded', fakeAsync(() => { + const apiUrl = 'http://localhost:8000/system/start' + + service.startTestrun(device, 1000).subscribe(() => { + }, (error) => { + expect(error.toString()).toEqual('Timeout has occurred'); + }); + + httpTestingController.expectOne(apiUrl); + tick(1001); + })); + }); + + it('getHistory should return history', () => { + let result: TestrunStatus[] = []; + + const history = [{ + "status": "Completed", + "device": device, + "report": "https://api.testrun.io/report.pdf", + "started": "2023-06-22T10:11:00.123Z", + "finished": "2023-06-22T10:17:00.123Z", + }] as TestrunStatus[]; + + service.getHistory().subscribe((res) => { + expect(res).toEqual(result); + }); + + result = history; + service.fetchHistory(); + const req = httpTestingController.expectOne('http://localhost:8000/history'); + + expect(req.request.method).toBe('GET'); + + req.flush(history); + }); + + describe('#getResultClass', () => { + it('should return class "green" if test result is "Compliant" or "Smart Ready"', () => { + const expectedResult = { + green: true, red: false, blue: false, grey: false + }; + + const result1 = service.getResultClass(StatusOfTestResult.Compliant); + + expect(result1).toEqual(expectedResult); + }); + + it('should return class "blue" if test result is "Smart Ready" or "Informational"', () => { + const expectedResult = { + green: false, red: false, blue: true, grey: false + }; + + const result1 = service.getResultClass(StatusOfTestResult.SmartReady); + const result2 = service.getResultClass(StatusOfTestResult.Info); + + expect(result1).toEqual(expectedResult); + expect(result2).toEqual(expectedResult); + }); + + it('should return class "read" if test result is "Non Compliant" or "Error"', () => { + const expectedResult = { + green: false, red: true, blue: false, grey: false + }; + + const result = service.getResultClass(StatusOfTestResult.NonCompliant); + const result2 = service.getResultClass(StatusOfTestResult.Error); + + expect(result).toEqual(expectedResult); + expect(result2).toEqual(expectedResult); + }); + + it('should return class "grey" if test result is "Skipped" or "Not Started"', () => { + const expectedResult = { + green: false, red: false, blue: false, grey: true + }; + + const result1 = service.getResultClass(StatusOfTestResult.Skipped); + const result2 = service.getResultClass(StatusOfTestResult.NotStarted); + + expect(result1).toEqual(expectedResult); + expect(result2).toEqual(expectedResult); + }); + }); + + describe('#addDevice', () => { + it('should create array with new value if previous value is null', function () { + service.addDevice(device); + + expect(service.getDevices().value).toEqual([device]); + }); + + it('should add new value if previous value is array', function () { + service.setDevices([device, device]); + service.addDevice(device); + + expect(service.getDevices().value).toEqual([device, device, device]); + }); + + }); +}); diff --git a/modules/ui/src/app/test-run.service.ts b/modules/ui/src/app/test-run.service.ts new file mode 100644 index 000000000..a17062708 --- /dev/null +++ b/modules/ui/src/app/test-run.service.ts @@ -0,0 +1,193 @@ +/** + * 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 {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs/internal/BehaviorSubject'; +import {Observable} from 'rxjs/internal/Observable'; +import {Device, TestModule} from './model/device'; +import {map, ReplaySubject, retry, timeout} from 'rxjs'; +import {SystemConfig} from './model/setting'; +import {StatusOfTestResult, StatusResultClassName, TestrunStatus} from './model/testrun-status'; +import {catchError} from 'rxjs/internal/operators/catchError'; +import {throwError} from 'rxjs/internal/observable/throwError'; + +const API_URL = 'http://localhost:8000' + +@Injectable({ + providedIn: 'root' +}) +export class TestRunService { + private readonly testModules: TestModule[] = [ + { + displayName: "Connection", + name: "connection", + enabled: true + }, + { + displayName: "NTP", + name: "ntp", + enabled: true + }, + { + displayName: "DHCP", + name: "dhcp", + enabled: true + }, + { + displayName: "DNS", + name: "dns", + enabled: true + }, + { + displayName: "Services", + name: "nmap", + enabled: true + }, + { + displayName: "Security", + name: "security", + enabled: true + }, + { + displayName: "TLS", + name: "tls", + enabled: true + }, + ]; + + private devices = new BehaviorSubject(null); + private _systemConfig = new BehaviorSubject({network: {}}); + public systemConfig$ = this._systemConfig.asObservable(); + private systemStatusSubject = new ReplaySubject(1); + public systemStatus$ = this.systemStatusSubject.asObservable(); + private history = new BehaviorSubject([]); + + constructor(private http: HttpClient) { + } + + getDevices(): BehaviorSubject { + return this.devices; + } + + setDevices(devices: Device[]): void { + this.devices.next(devices); + } + + setSystemConfig(config: SystemConfig): void { + this._systemConfig.next(config); + } + + setSystemStatus(status: TestrunStatus): void { + this.systemStatusSubject.next(status); + } + + fetchDevices(): void { + this.http.get(`${API_URL}/devices`).subscribe((devices: Device[]) => { + this.setDevices(devices); + }); + } + + getSystemConfig(): Observable { + return this.http + .get(`${API_URL}/system/config`) + .pipe(retry(1)) + } + + createSystemConfig(data: SystemConfig): Observable { + return this.http + .post(`${API_URL}/system/config`, data) + .pipe(retry(1)); + } + + getSystemInterfaces(): Observable { + return this.http + .get(`${API_URL}/system/interfaces`) + .pipe(retry(1)); + } + + getSystemStatus(): void { + this.http + .get(`${API_URL}/system/status`) + .subscribe((res: TestrunStatus) => { + this.setSystemStatus(res); + }); + } + + stopTestrun(): Observable { + return this.http + .post(`${API_URL}/system/stop`, {}) + .pipe(retry(1), map(() => true)); + } + + getTestModules(): TestModule[] { + return this.testModules; + } + + saveDevice(device: Device): Observable { + return this.http + .post(`${API_URL}/device`, JSON.stringify(device)) + .pipe(retry(1), map(() => true)); + } + + hasDevice(macAddress: string): boolean { + return this.devices.value?.some(device => device.mac_addr === macAddress.trim()) || false; + } + + addDevice(device: Device): void { + this.devices.next(this.devices.value ? this.devices.value.concat([device]) : [device]); + } + + updateDevice(deviceToUpdate: Device, update: Device): void { + const device = this.devices.value?.find(device => update.mac_addr === device.mac_addr)!; + device.model = update.model + device.manufacturer = update.manufacturer + device.test_modules = update.test_modules; + + this.devices.next(this.devices.value); + } + + fetchHistory(): void { + this.http + .get(`${API_URL}/history`) + .pipe(retry(1)) + .subscribe(data => { + this.history.next(data) + }); + } + + getHistory(): Observable { + return this.history; + } + + public getResultClass(result: string): StatusResultClassName { + return { + 'green': result === StatusOfTestResult.Compliant, + 'red': result === StatusOfTestResult.NonCompliant || result === StatusOfTestResult.Error, + 'blue': result === StatusOfTestResult.SmartReady || result === StatusOfTestResult.Info, + 'grey': result === StatusOfTestResult.Skipped || result === StatusOfTestResult.NotStarted + } + } + + startTestrun(device: Device, timeoutMs = 120000): Observable { + return this.http + .post(`${API_URL}/system/start`, JSON.stringify({device})) + .pipe( + timeout(timeoutMs), + map(() => true), + catchError(err => throwError(err.error?.error || err.message)) + ); + } +} diff --git a/modules/ui/src/index.html b/modules/ui/src/index.html index f82d5ba39..666c51bba 100644 --- a/modules/ui/src/index.html +++ b/modules/ui/src/index.html @@ -20,7 +20,7 @@ - TestRunUi + Testrun diff --git a/modules/ui/ui.Dockerfile b/modules/ui/ui.Dockerfile index d19ed619a..2b4081470 100644 --- a/modules/ui/ui.Dockerfile +++ b/modules/ui/ui.Dockerfile @@ -16,12 +16,13 @@ FROM node:20 as build WORKDIR /modules/ui -COPY modules/ui/ . -RUN npm install && npm run build +COPY modules/ui/ /modules/ui +RUN npm install +RUN npm run build FROM nginx:1.25.1 -COPY --from=build modules/ui/dist/ /usr/share/nginx/html +COPY --from=build /modules/ui/dist/ /usr/share/nginx/html EXPOSE 8080 diff --git a/testing/device_configs/tester1/device_config.json b/testing/device_configs/tester1/device_config.json index b979b2e26..f9eeccb6a 100644 --- a/testing/device_configs/tester1/device_config.json +++ b/testing/device_configs/tester1/device_config.json @@ -17,6 +17,12 @@ }, "nmap": { "enabled": true + }, + "protocol": { + "enabled": false + }, + "tls": { + "enabled": false } - } + } } diff --git a/testing/device_configs/tester2/device_config.json b/testing/device_configs/tester2/device_config.json index b037feb6d..9682dbcd0 100644 --- a/testing/device_configs/tester2/device_config.json +++ b/testing/device_configs/tester2/device_config.json @@ -17,6 +17,12 @@ }, "nmap": { "enabled": true + }, + "protocol": { + "enabled": false + }, + "tls": { + "enabled": false } } } diff --git a/testing/tests/test_tests.json b/testing/tests/test_tests.json index 6b3251702..51c775b0b 100644 --- a/testing/tests/test_tests.json +++ b/testing/tests/test_tests.json @@ -38,13 +38,6 @@ "connection.single_ip": "Compliant", "connection.ipaddr.ip_change": "Compliant" } - }, - "tester3": { - "description": "", - "image": "test-run/ci_test1", - "args": "kill_dhcp", - "ethmac": "02:42:aa:00:00:03", - "expected_results": {} } } \ No newline at end of file