From 421a31d4a6479696da7a618b2bc10721e98205a7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 13 Apr 2026 17:44:42 +0200 Subject: [PATCH 01/15] add reports folder to the install script --- cmd/install | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/install b/cmd/install index baf0f5469..e7b0ca81a 100755 --- a/cmd/install +++ b/cmd/install @@ -68,7 +68,7 @@ deactivate cmd/build # Create local folders -mkdir -p local/{devices,root_certs,risk_profiles} +mkdir -p local/{devices,root_certs,risk_profiles,reports} # Set file permissions on local # This does not work on GitHub actions From 0851322aa18dc438c58ee02a837624c9712152c5 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Tue, 14 Apr 2026 00:46:47 +0200 Subject: [PATCH 02/15] add reports to device --- framework/python/src/common/device.py | 2 ++ framework/python/src/common/testreport.py | 1 + 2 files changed, 3 insertions(+) diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index 0b2ea3162..e0ffa7679 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -78,6 +78,7 @@ def to_dict(self): device_json['firmware'] = self.firmware device_json['test_modules'] = self.test_modules + device_json['reports'] = [report.to_json() for report in self.reports] if self.reports else [] return device_json def to_config_json(self): @@ -94,6 +95,7 @@ def to_config_json(self): device_json['additional_info'] = self.additional_info device_json['created_at'] = self.created_at.isoformat() device_json['modified_at'] = self.modified_at.isoformat() + device_json['reports'] = [report.to_json() for report in self.reports] if self.reports else [] return device_json diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 8f2c998c5..1d314bdf1 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -165,6 +165,7 @@ def to_json(self): report_json['tests'] = {'total': self._total_tests, 'results': test_results} report_json['report'] = self._report_url + report_json['export'] = self._export_url return report_json def from_json(self, json_file): From db28f15b45605608614ccd4b69f357c4a3ec5e85 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 15 Apr 2026 19:50:38 +0200 Subject: [PATCH 03/15] copy old reports to a new folder --- .gitignore | 1 + framework/python/src/common/device.py | 6 +- framework/python/src/common/testreport.py | 3 + framework/python/src/core/testrun.py | 69 +++++++++++++++++++++-- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 9d6cc2acc..772a5356a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ error pylint.out __pycache__/ build/ +local/reports/ # Ignore generated files from unit tests testing/unit_test/temp/ diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index e0ffa7679..899cd0c31 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -78,7 +78,8 @@ def to_dict(self): device_json['firmware'] = self.firmware device_json['test_modules'] = self.test_modules - device_json['reports'] = [report.to_json() for report in self.reports] if self.reports else [] + device_json['reports'] = [ + report.to_json() for report in self.reports] if self.reports else [] return device_json def to_config_json(self): @@ -95,7 +96,8 @@ def to_config_json(self): device_json['additional_info'] = self.additional_info device_json['created_at'] = self.created_at.isoformat() device_json['modified_at'] = self.modified_at.isoformat() - device_json['reports'] = [report.to_json() for report in self.reports] if self.reports else [] + device_json['reports'] = [ + report.to_json() for report in self.reports] if self.reports else [] return device_json diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 1d314bdf1..a19947817 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -117,6 +117,9 @@ def get_report_url(self): def get_export_url(self): return self._export_url + def set_export_url(self, url): + self._export_url = url + def set_mac_addr(self, mac_addr): self._mac_addr = mac_addr diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 2e81aa1c1..da51bc9b1 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -51,9 +51,13 @@ DEVICE_TECHNOLOGY_KEY = 'technology' DEVICE_TEST_PACK_KEY = 'test_pack' DEVICE_ADDITIONAL_INFO_KEY = 'additional_info' +DEVICE_REPORT_NAME_FORMAT = '{mac_addr}_{timestamp}' MAX_DEVICE_REPORTS_KEY = 'max_device_reports' +OLD_REPORTS_FOLDER = 'local/devices/{device_folder}/reports' +REPORTS_FOLDER = 'local/reports' + class Testrun: # pylint: disable=too-few-public-methods """Testrun controller. @@ -171,6 +175,45 @@ def load_all_devices(self): # self._load_devices(device_dir=RESOURCE_DEVICES_DIR) return self.get_session().get_device_repository() + + def _copy_existing_reports(self, device: Device): + old_reports = self._load_test_reports(device) + device.clear_reports() + if old_reports: + for report in old_reports: + timestamp = report.get_started().strftime('%Y-%m-%dT%H:%M:%S') + report_path = os.path.join(self.get_reports_folder(device), timestamp) + if os.path.exists(report_path) and os.path.isdir(report_path): + new_report_folder_name = DEVICE_REPORT_NAME_FORMAT.format( + mac_addr=device.mac_addr.replace(':', ''), + timestamp=timestamp + ) + new_report_path = os.path.join( + self.get_common_reports_folder(), new_report_folder_name + ) + try: + shutil.copytree( + report_path, + os.path.join(self.get_common_reports_folder(), new_report_path) + ) + except (FileExistsError, shutil.Error) as e: + LOGGER.error(f'Error occurred while copying report: {e}') + report.set_report_url(f'report/{new_report_folder_name}') + report.set_export_url(f'export/{new_report_folder_name}') + device.add_report(report) + self.save_device(device) + try: + shutil.rmtree( + OLD_REPORTS_FOLDER.format(device_folder=device.device_folder) + ) + except FileNotFoundError: + LOGGER.error( + f'Old reports folder not found for device {device.model}' + ) + else: + LOGGER.info('No existing reports to copy') + + def _load_devices(self, device_dir): LOGGER.debug('Loading devices from ' + device_dir) @@ -203,7 +246,7 @@ def _load_devices(self, device_dir): device_model = device_config_json.get(DEVICE_MODEL) mac_addr = device_config_json.get(DEVICE_MAC_ADDR) test_modules = device_config_json.get(DEVICE_TEST_MODULES) - + reports = device_config_json.get('reports', []) # Load max device reports max_device_reports = None if 'max_device_reports' in device_config_json: @@ -211,13 +254,24 @@ def _load_devices(self, device_dir): folder_url = os.path.join(device_dir, device_folder) + device_reports = [] + if reports: + for report in reports: + test_report = TestReport() + test_report.from_json(report) + device_reports.append(test_report) + device = Device(folder_url=folder_url, manufacturer=device_manufacturer, model=device_model, mac_addr=mac_addr, test_modules=test_modules, max_device_reports=max_device_reports, - device_folder=device_folder) + device_folder=device_folder, + reports=device_reports + ) + if not device.get_reports(): + self._copy_existing_reports(device) # Load in the additional fields if DEVICE_TYPE_KEY in device_config_json: @@ -238,7 +292,8 @@ def _load_devices(self, device_dir): 'Device is outdated and requires further configuration') device.status = 'Invalid' - self._load_test_reports(device) + + # self._load_test_reports(device) # Add device to device repository self.get_session().add_device(device) @@ -252,7 +307,7 @@ def _load_test_reports(self, device): # Remove the existing reports in memory device.clear_reports() - + reports = [] # Locate reports folder reports_folder = self.get_reports_folder(device) @@ -288,12 +343,18 @@ def _load_test_reports(self, device): test_report.from_json(report_json) test_report.set_mac_addr(device.mac_addr) device.add_report(test_report) + reports.append(test_report) + return reports def get_reports_folder(self, device): """Return the reports folder path for the device""" return os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder, 'reports') + def get_common_reports_folder(self): + """Return the common reports folder path for all devices""" + return os.path.join(self._root_dir, REPORTS_FOLDER) + def delete_report(self, device: Device, timestamp): LOGGER.debug(f'Deleting test report for device {device.model} ' + f'at {timestamp}') From 4c51cdd466d92b2d70457b8a5cb35ec2a1d4a978 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 15 Apr 2026 20:59:57 +0200 Subject: [PATCH 04/15] change device save order --- framework/python/src/core/testrun.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index da51bc9b1..251498d8e 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -270,8 +270,6 @@ def _load_devices(self, device_dir): device_folder=device_folder, reports=device_reports ) - if not device.get_reports(): - self._copy_existing_reports(device) # Load in the additional fields if DEVICE_TYPE_KEY in device_config_json: @@ -292,6 +290,8 @@ def _load_devices(self, device_dir): 'Device is outdated and requires further configuration') device.status = 'Invalid' + if not device.get_reports(): + self._copy_existing_reports(device) # self._load_test_reports(device) From 1cf1c6be204432e1f52ee48c9492736839080c6f Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 16 Apr 2026 13:36:28 +0200 Subject: [PATCH 05/15] save report when a test complited to a common folder --- framework/python/src/common/device.py | 21 ++++ framework/python/src/common/testreport.py | 34 +++++- .../python/src/test_orc/test_orchestrator.py | 102 ++++++------------ 3 files changed, 81 insertions(+), 76 deletions(-) diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index 899cd0c31..405988bd4 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -14,11 +14,16 @@ """Track device object information.""" +import json +import os from typing import List, Dict from dataclasses import dataclass, field from common.testreport import TestReport from datetime import datetime +_LOCAL_DEVICES_DIR = 'local/devices' +_DEVICE_CONFIG_FILE = 'device_config.json' + @dataclass class Device(): """Represents a physical device and it's configuration.""" @@ -50,6 +55,9 @@ def add_report(self, report): def get_reports(self): return self.reports + def sort_reports(self): + self.reports.sort(key=lambda r: r.get_started() or datetime.min) + def clear_reports(self): self.reports = [] @@ -57,6 +65,7 @@ def remove_report(self, timestamp: datetime): for report in self.reports: if report.get_started().strftime('%Y-%m-%dT%H:%M:%S') == timestamp: self.reports.remove(report) + report.delete_folder() return def to_dict(self): @@ -100,6 +109,18 @@ def to_config_json(self): report.to_json() for report in self.reports] if self.reports else [] return device_json + + def export_config_json(self): + """Exports the device config as a json file to the specified path.""" + # Locate parent directory + current_dir = os.path.dirname(os.path.realpath(__file__)) + root_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_dir)))) + config_file_path = os.path.join(root_dir, _LOCAL_DEVICES_DIR, + self.device_folder, _DEVICE_CONFIG_FILE) + + with open(config_file_path, 'w+', encoding='utf-8') as config_file: + config_file.writelines(json.dumps(self.to_config_json(), indent=4)) def __post_init__(self): # Store initial values after creation diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index a19947817..9e9406901 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -14,6 +14,7 @@ """Store previous Testrun information.""" from datetime import datetime +import shutil from weasyprint import HTML from io import BytesIO from common import util, logger @@ -39,6 +40,11 @@ RESULTS_SPACE_FIRST_PAGE = 440 RESULTS_SPACE = 800 +_REPORTS_FOLDER = 'local/reports' +_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT_DIR = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(_CURRENT_DIR)))) + LOGGER = logger.get_logger('REPORT') @@ -76,6 +82,7 @@ def __init__(self, self._report_url = '' self._export_url = '' self._cur_page = 0 + self._folder_name = '' def update_device_profile(self, additional_info): self._device['device_profile'] = additional_info @@ -108,8 +115,9 @@ def get_duration(self): def add_test(self, test): self._results.append(test) - def set_report_url(self, url): - self._report_url = url + def set_report_url(self, folder_name: str): + self._folder_name = folder_name + self._report_url = f'/report/{folder_name}' def get_report_url(self): return self._report_url @@ -117,8 +125,23 @@ def get_report_url(self): def get_export_url(self): return self._export_url - def set_export_url(self, url): - self._export_url = url + def set_export_url(self, folder_name: str): + self._export_url = f'/export/{folder_name}' + + def get_folder_name(self) -> str: + return self._folder_name + + def delete_folder(self): + # Delete report from disk + if self._folder_name: + report_path = os.path.join(_ROOT_DIR, _REPORTS_FOLDER, self._folder_name) + if os.path.exists(report_path): + try: + shutil.rmtree(report_path) + except FileNotFoundError: + LOGGER.error( + f'Report folder not found for deletion: {report_path}' + ) def set_mac_addr(self, mac_addr): self._mac_addr = mac_addr @@ -169,6 +192,7 @@ def to_json(self): 'results': test_results} report_json['report'] = self._report_url report_json['export'] = self._export_url + report_json['folder_name'] = self._folder_name return report_json def from_json(self, json_file): @@ -213,6 +237,8 @@ def from_json(self, json_file): self._report_url = json_file['report'] if 'export' in json_file: self._export_url = json_file['export'] + if 'folder_name' in json_file: + self._folder_name = json_file['folder_name'] self._total_tests = json_file['tests']['total'] diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 7da1d216d..f37a524f0 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -24,7 +24,9 @@ from common import logger, util from common.testreport import TestReport from common.statuses import TestrunStatus, TestrunResult, TestResult +from core.testrun import REPORTS_FOLDER, DEVICE_REPORT_NAME_FORMAT from core.docker.test_docker_module import TestModule +from common.device import Device from test_orc.test_case import TestCase from test_orc.test_pack import TestPack import threading @@ -181,12 +183,9 @@ def run_test_modules(self): report.from_json(generated_report_json) report.add_module_reports(self.get_session().get_module_reports()) report.add_module_templates(self.get_session().get_module_templates()) - device.add_report(report) self._write_reports(report) self._test_in_progress = False - self.get_session().set_report_url(report.get_report_url()) - self.get_session().set_export_url(report.get_export_url()) # Set testing description test_pack: TestPack = self.get_test_pack(device.test_pack) @@ -199,19 +198,26 @@ def run_test_modules(self): elif report.get_result() == TestrunResult.NON_COMPLIANT: message = test_pack.get_message("non_compliant_description") + # Move testing output from runtime to local device folder + report_folder_name = self._copy_report_to_common_folder(device) + report.set_report_url(report_folder_name) + report.set_export_url(report_folder_name) + + self.get_session().set_report_url(report.get_report_url()) + self.get_session().set_export_url(report.get_export_url()) + device.add_report(report) + self.get_session().set_description(message) # Set result and status at the end self.get_session().set_result(report.get_result()) self.get_session().set_status(report.get_status()) - # Move testing output from runtime to local device folder - self._timestamp_results(device) - LOGGER.debug("Cleaning old test results...") self._cleanup_old_test_results(device) - LOGGER.debug("Old test results cleaned") + LOGGER.debug("Saving device config...") + device.export_config_json() def _write_reports(self, test_report): @@ -265,12 +271,6 @@ def _generate_report(self): report["status"] = status report["tests"] = self.get_session().get_report_tests() - report["report"] = ( - self._api_url + "/" + SAVED_DEVICE_REPORTS.replace( - "{device_folder}", - device.device_folder) + - self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S")) - report["export"] = report["report"].replace("report", "export") return report @@ -294,7 +294,6 @@ def _cleanup_modules_html_reports(self, out_dir): except Exception as e: LOGGER.error(f"Error {module_template_path}: {e}") - def _cleanup_old_test_results(self, device): if device.max_device_reports is not None: @@ -303,78 +302,37 @@ def _cleanup_old_test_results(self, device): max_device_reports = self.get_session().get_max_device_reports() if max_device_reports > 0: - completed_results_dir = os.path.join( - self._root_path, - LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder)) - - completed_tests = os.listdir(completed_results_dir) - cur_test_count = len(completed_tests) - if cur_test_count > max_device_reports: - LOGGER.debug("Current device has more than max results allowed: " + - str(cur_test_count) + ">" + str(max_device_reports)) - - # 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[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 - and new_test_count > max_device_reports): - # Continue cleaning up until we're under the max - self._cleanup_old_test_results(device) - - def _find_oldest_test(self, completed_tests_dir): - oldest_timestamp = None - oldest_directory = None - for completed_test in os.listdir(completed_tests_dir): - try: - timestamp = datetime.strptime(str(completed_test), "%Y-%m-%dT%H:%M:%S") - - # Occurs when time does not match format - except ValueError as e: - LOGGER.error(e) - continue + device.sort_reports() + while len(device.get_reports()) > max_device_reports: + report = device.get_reports().pop(0) + report.delete_folder() - if oldest_timestamp is None or timestamp < oldest_timestamp: - oldest_timestamp = timestamp - oldest_directory = completed_test - - if oldest_directory: - return oldest_timestamp, os.path.join(completed_tests_dir, - oldest_directory) - else: - return None - - def _timestamp_results(self, device): + def _copy_report_to_common_folder(self, device: Device) -> str: # Define the current device results directory cur_results_dir = os.path.join(self._root_path, RUNTIME_DIR) # Define the directory - completed_results_dir = os.path.join( - self._root_path, - LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder), - self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S")) + report_folder_name = DEVICE_REPORT_NAME_FORMAT.format( + mac_addr=device.mac_addr.replace(':', ''), + timestamp=self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S") + ) + report_dir = os.path.join(self._root_path, REPORTS_FOLDER) + report_dir = os.path.join(report_dir, report_folder_name) + LOGGER.info(f"Copying test results from {cur_results_dir} to {report_dir}") # Copy the results to the timestamp directory # leave current copy in place for quick reference to # most recent test - shutil.copytree(cur_results_dir, completed_results_dir, dirs_exist_ok=True) - util.run_command(f"chown -R {self._host_user} '{completed_results_dir}'") + shutil.copytree(cur_results_dir, report_dir, dirs_exist_ok=True) + util.run_command(f"chown -R {self._host_user} '{report_dir}'") # Copy Testrun log to testing directory shutil.copy(os.path.join(self._root_path, "testrun.log"), - os.path.join(completed_results_dir, "testrun.log")) - - return completed_results_dir + os.path.join(report_dir, "testrun.log")) + return report_folder_name + def zip_results(self, device, timestamp: str, profile): try: From e311c0c372599f5ec40fbea562749037995bfd4d Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 16 Apr 2026 23:26:01 +0200 Subject: [PATCH 06/15] pdf report and export endpoint --- framework/python/src/api/api.py | 73 ++++---- framework/python/src/common/device.py | 13 +- framework/python/src/common/testreport.py | 15 +- framework/python/src/core/session.py | 7 + framework/python/src/core/testrun.py | 4 +- .../python/src/test_orc/test_orchestrator.py | 160 +++++++++++------- 6 files changed, 162 insertions(+), 110 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 56f93c781..36c4e860a 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -43,6 +43,7 @@ DEVICE_ADDITIONAL_INFO_KEY = "additional_info" DEVICES_PATH = "local/devices" +REPORTS_PATH = "local/reports" PROFILES_PATH = "local/risk_profiles" RESOURCES_PATH = "resources" @@ -99,9 +100,9 @@ def __init__(self, testrun): self._router.add_api_route("/report", self.delete_report, methods=["DELETE"]) - self._router.add_api_route("/report/{device_name}/{timestamp}", + self._router.add_api_route("/report/{report_name}", self.get_report) - self._router.add_api_route("/export/{device_name}/{timestamp}", + self._router.add_api_route("/export/{report_name}", self.get_results, methods=["POST"]) @@ -702,8 +703,10 @@ async def edit_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): - device = self._session.get_device_by_name(device_name) + async def get_report(self, response: Response, report_name): + """Serve report pdf file for a given report name""" + mac = report_name.split("_")[0] + device = self._session.get_device_by_mac_addr(mac) # If the device not found if device is None: @@ -711,23 +714,19 @@ async def get_report(self, response: Response, device_name, timestamp): response.status_code = 404 return self._generate_msg(False, "Device not found") + report = device.get_report_by_folder_name(report_name) + LOGGER.debug(f"Looking for report with name {report_name}") + if not report: + LOGGER.info("Report could not be found, returning 404") + response.status_code = 404 + return self._generate_msg(False, "Report could not be found") + + # Regenerate the pdf if the device profile has been updated - self._get_testrun().get_test_orc().regenerate_pdf(device, timestamp) - - # 1.3 file path - file_path = os.path.join( - DEVICES_PATH, - device_name, - "reports", - timestamp,"test", - device.mac_addr.replace(":",""), - "report.pdf") - if not os.path.isfile(file_path): - # pre 1.3 file path - file_path = os.path.join(DEVICES_PATH, device_name, "reports", timestamp, - "report.pdf") - - LOGGER.debug(f"Received get report request for {device_name} / {timestamp}") + test_orc = self._get_testrun().get_test_orc() + test_path = test_orc.regenerate_pdf(device, report) + file_path = os.path.join(test_path, "report.pdf") + LOGGER.debug(f"Received get report request for {device.model}") if os.path.isfile(file_path): return FileResponse(file_path) else: @@ -735,10 +734,13 @@ async def get_report(self, response: Response, device_name, timestamp): response.status_code = 404 return self._generate_msg(False, "Report could not be found") - async def get_results(self, request: Request, response: Response, device_name, - timestamp): - LOGGER.debug("Received get results " + - f"request for {device_name} / {timestamp}") + async def get_results( + self, + request: Request, + response: Response, + report_name: str + ): + LOGGER.debug(f"Received get results request for {report_name}") profile = None @@ -761,32 +763,21 @@ async def get_results(self, request: Request, response: Response, device_name, pass # Check if device exists - device = self.get_session().get_device_by_name(device_name) + mac = report_name.split("_")[0] + device = self._session.get_device_by_mac_addr(mac) if device is None: response.status_code = status.HTTP_404_NOT_FOUND return self._generate_msg(False, "A device with that name could not be found") - - # Check if report exists (1.3 file path) - report_file_path = os.path.join( - DEVICES_PATH, - device_name, - "reports", - timestamp,"test", - device.mac_addr.replace(":","")) - - if not os.path.isdir(report_file_path): - # pre 1.3 file path - report_file_path = os.path.join(DEVICES_PATH, device_name, "reports", - timestamp) - - if not os.path.isdir(report_file_path): + report = device.get_report_by_folder_name(report_name) + LOGGER.debug(f"Looking for report with name {report_name}") + if not report: LOGGER.info("Report could not be found, returning 404") response.status_code = 404 return self._generate_msg(False, "Report could not be found") zip_file_path = self._get_testrun().get_test_orc().zip_results( - device, timestamp, profile) + device, report, profile) if zip_file_path is None: response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index 405988bd4..ec6ad15a6 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -52,6 +52,17 @@ class Device(): def add_report(self, report): self.reports.append(report) + def get_report_by_folder_name(self, folder_name: str) -> TestReport | None: + for report in self.reports: + report_folder_name = report.get_folder_name() + if report_folder_name == folder_name: + return report + if report_folder_name is None or report_folder_name == '': + if report.get_report_url().split('/')[-1] == folder_name: + report.set_report_url(folder_name) + return report + return None + def get_reports(self): return self.reports @@ -109,7 +120,7 @@ def to_config_json(self): report.to_json() for report in self.reports] if self.reports else [] return device_json - + def export_config_json(self): """Exports the device config as a json file to the specified path.""" # Locate parent directory diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 9e9406901..6cfeef156 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -87,6 +87,11 @@ def __init__(self, def update_device_profile(self, additional_info): self._device['device_profile'] = additional_info + def update_device_info(self, device): + self._device['manufacturer'] = device.manufacturer + self._device['model'] = device.model + self._device['device_profile'] = device.additional_info + def add_module_reports(self, module_reports): self._module_reports = module_reports @@ -127,10 +132,10 @@ def get_export_url(self): def set_export_url(self, folder_name: str): self._export_url = f'/export/{folder_name}' - + def get_folder_name(self) -> str: return self._folder_name - + def delete_folder(self): # Delete report from disk if self._folder_name: @@ -274,6 +279,12 @@ def to_pdf(self): HTML(string=report_html).write_pdf(pdf_bytes) return pdf_bytes + def to_pdf_from_html(self, html_content): + # Convert HTML to PDF in memory using weasyprint + pdf_bytes = BytesIO() + HTML(string=html_content).write_pdf(pdf_bytes) + return pdf_bytes + def to_html(self): # Obtain test pack diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index 80f5db7ce..8a29e36e9 100644 --- a/framework/python/src/core/session.py +++ b/framework/python/src/core/session.py @@ -21,6 +21,7 @@ from common import util, logger, mqtt from common.risk_profile import RiskProfile from common.statuses import TestrunStatus, TestResult, TestrunResult +from common.device import Device from net_orc.ip_control import IPControl # Certificate dependencies @@ -383,6 +384,12 @@ def get_device_by_make_and_model(self, make, model): if device.manufacturer == make and device.model == model: return device + def get_device_by_mac_addr(self, mac_addr_simmplified: str) -> Device | None: + for device in self._device_repository: + if (device.mac_addr is not None + and device.mac_addr.replace(':', '') == mac_addr_simmplified): + return device + def get_device_repository(self): return self._device_repository diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 251498d8e..10f21cd1a 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -198,8 +198,8 @@ def _copy_existing_reports(self, device: Device): ) except (FileExistsError, shutil.Error) as e: LOGGER.error(f'Error occurred while copying report: {e}') - report.set_report_url(f'report/{new_report_folder_name}') - report.set_export_url(f'export/{new_report_folder_name}') + report.set_report_url(new_report_folder_name) + report.set_export_url(new_report_folder_name) device.add_report(report) self.save_device(device) try: diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index f37a524f0..eef767a0f 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -20,17 +20,17 @@ import time import shutil import docker -from datetime import datetime -from common import logger, util +from common import logger, util, risk_profile from common.testreport import TestReport from common.statuses import TestrunStatus, TestrunResult, TestResult +from common.device import Device from core.testrun import REPORTS_FOLDER, DEVICE_REPORT_NAME_FORMAT from core.docker.test_docker_module import TestModule -from common.device import Device from test_orc.test_case import TestCase from test_orc.test_pack import TestPack import threading from typing import List +from bs4 import BeautifulSoup LOG_NAME = "test_orc" LOGGER = logger.get_logger("test_orc") @@ -47,7 +47,7 @@ MODULE_CONFIG = "conf/module_config.json" SAVED_DEVICE_REPORTS = "report/{device_folder}/" -LOCAL_DEVICE_REPORTS = "local/devices/{device_folder}/reports" +LOCAL_DEVICE_REPORTS = "local/reports" DEVICE_ROOT_CERTS = "local/root_certs" LOG_REGEX = r"^[A-Z][a-z]{2} [0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} test_" @@ -314,7 +314,7 @@ def _copy_report_to_common_folder(self, device: Device) -> str: # Define the directory report_folder_name = DEVICE_REPORT_NAME_FORMAT.format( - mac_addr=device.mac_addr.replace(':', ''), + mac_addr=device.mac_addr.replace(":", ""), timestamp=self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S") ) report_dir = os.path.join(self._root_path, REPORTS_FOLDER) @@ -332,24 +332,26 @@ def _copy_report_to_common_folder(self, device: Device) -> str: os.path.join(report_dir, "testrun.log")) return report_folder_name - - def zip_results(self, device, timestamp: str, profile): + + def zip_results( + self, device: Device, + report: TestReport, + profile: risk_profile.RiskProfile) -> str: try: LOGGER.debug("Archiving test results") src_path = os.path.join( - LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder), - timestamp) + LOCAL_DEVICE_REPORTS, report.get_folder_name()) # Regenerate the report if the device profile has been updated - self._regenerate_report_files(device, timestamp) + self._regenerate_report_files(device, report) # Define temp directory to store files before zipping results_dir = os.path.join(f"/tmp/testrun/{time.time()}") # Define where to save the zip file - zip_location = os.path.join("/tmp/testrun", timestamp) + zip_location = os.path.join("/tmp/testrun", report.get_folder_name()) # Delete zip_temp if it already exists if os.path.exists(results_dir): @@ -389,83 +391,113 @@ def zip_results(self, device, timestamp: str, profile): LOGGER.debug(error) return None - def regenerate_pdf(self, device, timestamp): + def regenerate_pdf(self, device: Device, report: TestReport) -> str: """Regenerate the pdf report""" - self._regenerate_report_files(device, timestamp) + return self._regenerate_report_files(device, report) - def _regenerate_report_files(self, device, timestamp): + def _regenerate_report_files(self, device: Device, report: TestReport) -> str: '''Regenerate the report if the device profile has been updated''' - + # Report files path + report_dir = os.path.join(self._root_path, REPORTS_FOLDER) + report_dir = os.path.join(report_dir, report.get_folder_name()) + test_path = os.path.join( + report_dir, + f'test/{device.mac_addr.replace(":", "")}' + ) try: - - # Report files path - report_path = os.path.join( - LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder), - timestamp, "test", device.mac_addr.replace(":", "")) - - # Parse string timestamp - date_timestamp: datetime.datetime = datetime.strptime( - timestamp, "%Y-%m-%dT%H:%M:%S") - - # Find the report - test_report = None - for report in device.get_reports(): - if report.get_started() == date_timestamp: - test_report = report - - # This should not happen as the timestamp is checked in api.py first - if test_report is None: - return None - # Copy the original report for comparison - test_report_copy = copy.deepcopy(test_report) - + report_copy = copy.deepcopy(report) # Update the report with 'additional_info' field - test_report.update_device_profile(device.additional_info) - + report.update_device_info(device) + device.export_config_json() # Overwrite report only if additional_info has been changed - if test_report.to_json() != test_report_copy.to_json(): + if report.to_json() != report_copy.to_json(): LOGGER.debug("Device profile has been updated, regenerating the report") - # Store the jinja templates - reload_templates = [] - - # Load the jinja templates - if os.path.isdir(report_path): - for dir_path, _, filenames in os.walk(report_path): - for filename in filenames: - try: - if filename.endswith(".j2.html"): - with open(os.path.join(dir_path, filename), "r", - encoding="utf-8") as f: - reload_templates.append(f.read()) - except Exception as e: - LOGGER.debug(f"Could not read the file: {e}") - - # Add the jinja templates to the report - test_report.add_module_templates(reload_templates) - # Rewrite the json report - with open(os.path.join(report_path, "report.json"), + with open(os.path.join(test_path, "report.json"), "w", encoding="utf-8") as f: - json.dump(test_report.to_json(), f, indent=2) + json.dump(report.to_json(), f, indent=2) + with open(os.path.join(test_path, "report.html"), + "r", + encoding="utf-8") as f: + html = f.read() + html = self._update_html_report(report, html) + LOGGER.debug(f"{test_path}") # Rewrite the html report - with open(os.path.join(report_path, "report.html"), + with open(os.path.join(test_path, "report.html"), "w", encoding="utf-8") as f: - f.write(test_report.to_html()) + f.write(html) # Rewrite the pdf report - with open(os.path.join(report_path, "report.pdf"), "wb") as f: - f.write(test_report.to_pdf().getvalue()) + with open(os.path.join(test_path, "report.pdf"), "wb") as f: + f.write(report.to_pdf_from_html(html).getvalue()) LOGGER.debug("Report has been regenerated") except Exception as error: LOGGER.error("Failed to regenerate the report") LOGGER.debug(error) + return test_path + + def _update_html_report(self, report: TestReport, html: str): + """Update the HTML report with the new device information.""" + report_json = report.to_json() + manufacturer = report_json["device"].get("manufacturer", "Unknown") + model = report_json["device"].get("model", "Unknown") + title = f"{manufacturer} {model}" + bs = BeautifulSoup(html, "html.parser") + div_profile = bs.find("div", class_="device-profile-content") + if div_profile is not None: + for item in report_json["device"]["device_profile"]: + question = item["question"] + answer = item["answer"] + # Find the question div with exact text match for 'q' + question_div = div_profile.find( + "div", + class_="device-profile-question", + string=question + ) + if question_div: + # Find the next sibling answer div + answer_div = question_div.find_next_sibling( + "div", + class_="device-profile-answer" + ) + if answer_div: + # Update the answer text with 'a' + answer_div.string = answer + h3 = bs.find("h3", class_="header-info-device") + if h3: + h3.string = title + # Update manufacturer and model in summary section by finding labels + all_labels = bs.find_all("div", class_="summary-item-label") + for label_div in all_labels: + h4 = label_div.find("h4") + if h4 and h4.string: + # Find the next summary-item-value sibling + value_div = label_div.find_next_sibling( + "div", + class_="summary-item-value" + ) + if value_div: + if "Manufacturer" in h4.string: + value_div.string = manufacturer + LOGGER.debug(f"Updated manufacturer to '{value_div.string}'") + elif "Model" in h4.string: + value_div.string = model + LOGGER.debug(f"Updated model to '{value_div.string}'") + all_header_info_divs = bs.find_all("div", class_="header-info") + for header_info_div in all_header_info_divs: + header_span = header_info_div.find_next_sibling("span") + if header_span: + header_span.string = title + LOGGER.debug(f"Updated sibling span to '{header_span.string}'") + + return str(bs) def test_in_progress(self): return self._test_in_progress From 3b63ad31287fece486117267e1ff7c3302688ee2 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 01:27:22 +0200 Subject: [PATCH 07/15] delete report endpoint --- framework/python/src/api/api.py | 55 +++++++-------------------- framework/python/src/common/device.py | 12 +++--- framework/python/src/core/testrun.py | 17 ++------- 3 files changed, 23 insertions(+), 61 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 36c4e860a..37278e94d 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -16,7 +16,6 @@ from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware import asyncio -from datetime import datetime import json from json import JSONDecodeError import os @@ -97,7 +96,7 @@ def __init__(self, testrun): # Report endpoints self._router.add_api_route("/reports", self.get_reports) - self._router.add_api_route("/report", + self._router.add_api_route("/report/{report_name}", self.delete_report, methods=["DELETE"]) self._router.add_api_route("/report/{report_name}", @@ -455,53 +454,25 @@ async def get_reports(self, request: Request): report["export"] = report["report"].replace("report", "export") return reports - async def delete_report(self, request: Request, response: Response): - - body_raw = (await request.body()).decode("UTF-8") - - if len(body_raw) == 0: - response.status_code = 400 - return self._generate_msg(False, "Invalid request received, missing body") + async def delete_report(self, response: Response, report_name: str): - try: - body_json = json.loads(body_raw) - except JSONDecodeError as e: - LOGGER.error("An error occurred whilst decoding JSON") - LOGGER.debug(e) - response.status_code = status.HTTP_400_BAD_REQUEST - return self._generate_msg(False, "Invalid request received") - - if "mac_addr" not in body_json or "timestamp" not in body_json: - response.status_code = 400 - return self._generate_msg(False, "Missing mac address or timestamp") - - mac_addr = body_json.get("mac_addr").lower() - timestamp = body_json.get("timestamp") - - try: - parsed_timestamp = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S") - timestamp_formatted = parsed_timestamp.strftime("%Y-%m-%dT%H:%M:%S") - - except ValueError: - response.status_code = 400 - return self._generate_msg(False, "Incorrect timestamp format") - - # Get device from MAC address - device = self._session.get_device(mac_addr) + mac = report_name.split("_")[0] + device = self._session.get_device_by_mac_addr(mac) + # If the device not found if device is None: + LOGGER.info("Device not found, returning 404") response.status_code = 404 - return self._generate_msg(False, "Could not find device") - - # Assign the reports folder path from testrun - reports_folder = self._testrun.get_reports_folder(device) + return self._generate_msg(False, "Device not found") - # Check if reports folder exists - if not os.path.exists(reports_folder): + report = device.get_report_by_folder_name(report_name) + LOGGER.debug(f"Looking for report with name {report_name}") + if not report: + LOGGER.info("Report could not be found, returning 404") response.status_code = 404 - return self._generate_msg(False, "Report not found") + return self._generate_msg(False, "Report could not be found") - if self._testrun.delete_report(device, timestamp_formatted): + if self._testrun.delete_report(device, report): return self._generate_msg(True, "Deleted report") response.status_code = 500 diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index ec6ad15a6..3bed02cab 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -72,12 +72,12 @@ def sort_reports(self): def clear_reports(self): self.reports = [] - def remove_report(self, timestamp: datetime): - for report in self.reports: - if report.get_started().strftime('%Y-%m-%dT%H:%M:%S') == timestamp: - self.reports.remove(report) - report.delete_folder() - return + def remove_report(self, report: TestReport): + if report in self.reports: + self.reports.remove(report) + report.delete_folder() + self.export_config_json() + return def to_dict(self): """Returns the device as a python dictionary. This is used for the diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 10f21cd1a..05bd22674 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -355,21 +355,12 @@ def get_common_reports_folder(self): """Return the common reports folder path for all devices""" return os.path.join(self._root_dir, REPORTS_FOLDER) - def delete_report(self, device: Device, timestamp): + def delete_report(self, device: Device, report: TestReport) -> bool: LOGGER.debug(f'Deleting test report for device {device.model} ' + - f'at {timestamp}') + f'at {report.get_folder_name()}') - # Locate reports folder - reports_folder = self.get_reports_folder(device) - - for report_folder in os.listdir(reports_folder): - if report_folder == timestamp: - shutil.rmtree(os.path.join(reports_folder, report_folder)) - device.remove_report(timestamp) - LOGGER.debug('Successfully deleted the report') - return True - - return False + device.remove_report(report) + return True def create_device(self, device: Device): From 5ec24d673506546de9e8a3cae8a64f169db8b721 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 01:56:20 +0200 Subject: [PATCH 08/15] reports list endpoint --- framework/python/src/api/api.py | 17 +++++++++++------ framework/python/src/core/session.py | 5 +++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 37278e94d..d4de1fca1 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -441,17 +441,22 @@ async def get_version(self, response: Response): LOGGER.debug(e) return json_response - async def get_reports(self, request: Request): + async def get_reports(self): LOGGER.debug("Received reports list request") # Resolve the server IP from the request so we # can fix the report URL reports = self._session.get_all_reports() for report in reports: - # report URL is currently hard coded as localhost so we can - # replace that to fix the IP dynamically from the requester - report["report"] = report["report"].replace( - "localhost", request.client.host) - report["export"] = report["report"].replace("report", "export") + del report["mac_addr"] + del report["tests"] + del report["folder_name"] + report["device"] = { + "manufacturer": report["device"]["manufacturer"], + "model": report["device"]["model"], + "mac_addr": report["device"]["mac_addr"], + "firmware": report["device"]["firmware"] + } + report["delete"] = report["report"] return reports async def delete_report(self, response: Response, report_name: str): diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index 8a29e36e9..a01efcd9c 100644 --- a/framework/python/src/core/session.py +++ b/framework/python/src/core/session.py @@ -524,8 +524,9 @@ def get_all_reports(self): for device in self.get_device_repository(): device_reports = device.get_reports() - for device_report in device_reports: - reports.append(device_report.to_json()) + reports.extend( + [device_report.to_json() for device_report in device_reports] + ) return sorted(reports, key=lambda report: report['started'], reverse=True) def add_total_tests(self, no_tests): From 9503a80e3ae6d424d7e3ff70802eaebf014a325e Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 14:30:29 +0200 Subject: [PATCH 09/15] delete device --- framework/python/src/common/device.py | 6 +++++- framework/python/src/core/testrun.py | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/framework/python/src/common/device.py b/framework/python/src/common/device.py index 3bed02cab..b86dfebe0 100644 --- a/framework/python/src/common/device.py +++ b/framework/python/src/common/device.py @@ -77,7 +77,11 @@ def remove_report(self, report: TestReport): self.reports.remove(report) report.delete_folder() self.export_config_json() - return + + def remove_reports(self): + for report in self.reports: + report.delete_folder() + self.clear_reports() def to_dict(self): """Returns the device as a python dictionary. This is used for the diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 05bd22674..262713631 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -405,6 +405,9 @@ def delete_device(self, device: Device): device_folder = os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder) + # Remove device reports + device.remove_reports() + # Delete the device directory shutil.rmtree(device_folder) From c57f38f5f4a1f6349207fe7778308480901d9f28 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 14:44:36 +0200 Subject: [PATCH 10/15] fix reports list --- framework/python/src/api/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index d4de1fca1..75d765428 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -449,12 +449,12 @@ async def get_reports(self): for report in reports: del report["mac_addr"] del report["tests"] - del report["folder_name"] report["device"] = { "manufacturer": report["device"]["manufacturer"], "model": report["device"]["model"], "mac_addr": report["device"]["mac_addr"], - "firmware": report["device"]["firmware"] + "firmware": report["device"]["firmware"], + "test_pack": report["device"]["test_pack"], } report["delete"] = report["report"] return reports From 6cebb4c76ac09e1acac1a63cec3744bc73c23487 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 15:10:13 +0200 Subject: [PATCH 11/15] mocks --- docs/dev/mockoon.json | 201 ++++++++++-------------------------------- 1 file changed, 46 insertions(+), 155 deletions(-) diff --git a/docs/dev/mockoon.json b/docs/dev/mockoon.json index d8a753259..b35465c08 100644 --- a/docs/dev/mockoon.json +++ b/docs/dev/mockoon.json @@ -605,7 +605,7 @@ "responses": [ { "uuid": "6adc954a-55c9-40ed-8f49-cf38f659d883", - "body": "[\n {\n \"mac_addr\": \"00:1e:42:35:73:c6\",\n \"device\": {\n \"mac_addr\": \"00:1e:42:35:73:c4\",\n \"manufacturer\": \"Teltonika\",\n \"model\": \"TRB140\",\n \"firmware\": \"1.2.3\",\n \"test_modules\": {\n \"connection\": {\n \"enabled\": false\n },\n \"ntp\": {\n \"enabled\": true\n },\n \"dns\": {\n \"enabled\": true\n },\n \"services\": {\n \"enabled\": true\n },\n \"tls\": {\n \"enabled\": true\n },\n \"protocol\": {\n \"enabled\": true\n }\n }\n },\n \"status\": \"Non-Compliant\",\n \"started\": \"2024-05-03 12:09:59\",\n \"finished\": \"2024-05-03 12:15:51\",\n \"tests\": {\n \"total\": 20,\n \"results\": [\n {\n \"name\": \"protocol.valid_bacnet\",\n \"description\": \"BACnet discovery could not resolve any devices\",\n \"expected_behavior\": \"BACnet traffic can be seen on the network and packets are valid and not malformed\",\n \"required_result\": \"Recommended\",\n \"result\": \"Skipped\"\n },\n {\n \"name\": \"protocol.bacnet.version\",\n \"description\": \"No BACnet devices discovered.\",\n \"expected_behavior\": \"The BACnet client implements an up to date version of BACnet\",\n \"required_result\": \"Recommended\",\n \"result\": \"Skipped\"\n },\n {\n \"name\": \"protocol.valid_modbus\",\n \"description\": \"Failed to establish Modbus connection to device\",\n \"expected_behavior\": \"Any Modbus functionality works as expected and valid Modbus traffic can be observed\",\n \"required_result\": \"Recommended\",\n \"result\": \"Non-Compliant\"\n },\n {\n \"name\": \"ntp.network.ntp_support\",\n \"description\": \"Device sent NTPv3 packets. NTPv3 is not allowed.\",\n \"expected_behavior\": \"The device sends an NTPv4 request to the configured NTP server.\",\n \"required_result\": \"Required\",\n \"result\": \"Non-Compliant\"\n },\n {\n \"name\": \"ntp.network.ntp_dhcp\",\n \"description\": \"Device sent NTP request to non-DHCP provided server\",\n \"expected_behavior\": \"Device can accept NTP server address, provided by the DHCP server (DHCP OFFER PACKET)\",\n \"required_result\": \"Roadmap\",\n \"result\": \"Non-Compliant\"\n },\n {\n \"name\": \"security.services.ftp\",\n \"description\": \"No FTP server found\",\n \"expected_behavior\": \"There is no FTP service running on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.ssh.version\",\n \"description\": \"SSH server found running protocol 2.0\",\n \"expected_behavior\": \"SSH server is not running or server is SSHv2\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.telnet\",\n \"description\": \"No telnet server found\",\n \"expected_behavior\": \"There is no Telnet service running on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.smtp\",\n \"description\": \"No SMTP server found\",\n \"expected_behavior\": \"There is no SMTP service running on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.http\",\n \"description\": \"Found HTTP server running on port 80/tcp\",\n \"expected_behavior\": \"Device is unreachable on port 80 (or any other port) and only responds to HTTPS requests on port 443 (or any other port if HTTP is used at all)\",\n \"required_result\": \"Required\",\n \"result\": \"Non-Compliant\"\n },\n {\n \"name\": \"security.services.pop\",\n \"description\": \"No POP server found\",\n \"expected_behavior\": \"There is no POP service running on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.imap\",\n \"description\": \"No IMAP server found\",\n \"expected_behavior\": \"There is no IMAP service running on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.snmpv3\",\n \"description\": \"No SNMP server found\",\n \"expected_behavior\": \"Device is unreachable on port 161 (or any other port) and device is unreachable on port 162 (or any other port) unless SNMP is essential in which case it is SNMPv3 is used.\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.vnc\",\n \"description\": \"No VNC server found\",\n \"expected_behavior\": \"Device cannot be accessed / connected to via VNC on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.services.tftp\",\n \"description\": \"No TFTP server found\",\n \"expected_behavior\": \"There is no TFTP service running on any port\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"ntp.network.ntp_server\",\n \"description\": \"No NTP server found\",\n \"expected_behavior\": \"The device does not respond to NTP requests when it's IP is set as the NTP server on another device\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"dns.network.hostname_resolution\",\n \"description\": \"DNS traffic detected from device\",\n \"expected_behavior\": \"The device sends DNS requests.\",\n \"required_result\": \"Required\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"dns.network.from_dhcp\",\n \"description\": \"DNS traffic detected only to DHCP provided server\",\n \"expected_behavior\": \"The device sends DNS requests to the DNS server provided by the DHCP server\",\n \"required_result\": \"Roadmap\",\n \"result\": \"Compliant\"\n },\n {\n \"name\": \"security.tls.v1_2_server\",\n \"description\": \"TLS 1.2 not validated: Certificate has expired\\nEC key length passed: 256 >= 224\\nDevice certificate has not been signed\\nTLS 1.3 not validated: Certificate has expired\\nEC key length passed: 256 >= 224\\nDevice certificate has not been signed\",\n \"expected_behavior\": \"TLS 1.2 certificate is issued to the web browser client when accessed\",\n \"required_result\": \"Required\",\n \"result\": \"Non-Compliant\"\n },\n {\n \"name\": \"security.tls.v1_2_client\",\n \"description\": \"No outbound TLS connections were found.\",\n \"expected_behavior\": \"The packet indicates a TLS connection with at least TLS 1.2 and support for ECDH and ECDSA ciphers\",\n \"required_result\": \"Required\",\n \"result\": \"Skipped\"\n }\n ]\n },\n \"report\": \"http://localhost:8000/report/123 123/2024-05-03T12:09:59\",\n \"export\": \"http://localhost:8000/export/123 123/2024-05-03T12:09:59\"\n \n }\n]", + "body": "[\n {\n \"testrun\": {\n \"version\": \"2.2.2\"\n },\n \"device\": {\n \"manufacturer\": \"vagrant\",\n \"model\": \"vm-compliant\",\n \"mac_addr\": \"f0:d4:e2:f2:f5:41\",\n \"firmware\": \"123\",\n \"test_pack\": \"Device Qualification\"\n },\n \"status\": \"Complete\",\n \"result\": \"Non-Compliant\",\n \"started\": \"2026-02-02 17:24:52\",\n \"finished\": \"2026-02-02 17:34:58\",\n \"report\": \"report/f0d4e2f2f541_2026-02-02T17:24:52\",\n \"export\": \"export/f0d4e2f2f541_2026-02-02T17:24:52\",\n \"folder_name\": \"f0d4e2f2f541_2026-02-02T17:24:52\",\n \"delete\": \"report/f0d4e2f2f541_2026-02-02T17:24:52\"\n }\n]", "latency": 0, "statusCode": 200, "label": "", @@ -824,7 +824,7 @@ "type": "http", "documentation": "Delete a Testrun report", "method": "delete", - "endpoint": "report", + "endpoint": "report/{folder_name}", "responses": [ { "uuid": "35b1585b-13d0-4702-9733-9e2f7fc08ab9", @@ -910,7 +910,7 @@ "type": "http", "documentation": "Get a Testrun PDF report", "method": "get", - "endpoint": "report/{device_name}/{timestamp}", + "endpoint": "report/{folder_name}", "responses": [ { "uuid": "09092f2d-907b-452c-b47b-b2f3b9b47189", @@ -1220,52 +1220,52 @@ "responseMode": null }, { - "uuid": "6d6b9fa9-c961-4007-89f3-9ea9cccc05e7", - "type": "http", - "documentation": "List root CA certificates", - "method": "get", - "endpoint": "system/config/certs", - "responses": [ - { - "uuid": "581b126d-fadd-4461-bece-97b5b1fd97fd", - "body": "[\n {\n \"name\": \"iot.bms.google.com\",\n \"organisation\": \"Google, Inc.\",\n \"expires\": \"2024-09-01T09:00:12Z\",\n \"status\": \"Valid\",\n \"type\": \"root\"\n },\n {\n \"name\": \"sensor.bms.google.com\",\n \"organisation\": \"Google, Inc.\",\n \"expires\": \"2022-09-01T09:00:12Z\",\n \"status\": \"Expired\",\n \"type\": \"intermediate\"\n }\n]", - "latency": 0, - "statusCode": 200, - "label": "Listing 2x certificates", - "headers": [], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] + "uuid": "6d6b9fa9-c961-4007-89f3-9ea9cccc05e7", + "type": "http", + "documentation": "List root CA certificates", + "method": "get", + "endpoint": "system/config/certs", + "responses": [ + { + "uuid": "581b126d-fadd-4461-bece-97b5b1fd97fd", + "body": "[\n {\n \"name\": \"iot.bms.google.com\",\n \"organisation\": \"Google, Inc.\",\n \"expires\": \"2024-09-01T09:00:12Z\",\n \"status\": \"Valid\",\n \"type\": \"root\"\n },\n {\n \"name\": \"sensor.bms.google.com\",\n \"organisation\": \"Google, Inc.\",\n \"expires\": \"2022-09-01T09:00:12Z\",\n \"status\": \"Expired\",\n \"type\": \"intermediate\"\n }\n]", + "latency": 0, + "statusCode": 200, + "label": "Listing 2x certificates", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] }, { - "uuid": "b9fa58e6-95c8-45ea-b6fb-a16287a6e65b", - "body": "[]", - "latency": 0, - "statusCode": 200, - "label": "No certificates", - "headers": [], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": false, - "crudKey": "id", - "callbacks": [] + "uuid": "b9fa58e6-95c8-45ea-b6fb-a16287a6e65b", + "body": "[]", + "latency": 0, + "statusCode": 200, + "label": "No certificates", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] } - ], - "responseMode": null + ], + "responseMode": null }, { "uuid": "fb33213d-4448-431c-8733-1ce292644af6", @@ -1602,111 +1602,6 @@ } ], "responseMode": null - }, - { - "uuid": "af7fdcb0-721d-4198-a8ef-c6d8c4eba8c8", - "type": "http", - "documentation": "Get a Testrun PDF profile", - "method": "post", - "endpoint": "report/{profile_name}", - "responses": [ - { - "uuid": "9a759f46-4bc4-433a-be86-e456f069c217", - "body": "", - "latency": 0, - "statusCode": 200, - "label": "Profile found - no device selected", - "headers": [], - "bodyType": "FILE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": true, - "crudKey": "id", - "callbacks": [] - }, - { - "uuid": "c9a09ae7-3158-4956-93ac-4c8a90dfced8", - "body": "", - "latency": 0, - "statusCode": 200, - "label": "Profile found - device selected ", - "headers": [], - "bodyType": "FILE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": false, - "crudKey": "id", - "callbacks": [] - }, - { - "uuid": "5f98471e-15b6-47a4-a68d-e98c3a538b40", - "body": "{\n \"error\": \"Profile could not be found\"\n}", - "latency": 0, - "statusCode": 404, - "label": "Profile not found", - "headers": [], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": false, - "crudKey": "id", - "callbacks": [] - }, - { - "uuid": "767d9e78-386e-4bf7-bec8-71a005efdce9", - "body": "{\n \"error\": \"A device with that mac address could not be found\"\n}", - "latency": 0, - "statusCode": 404, - "label": "Device not found", - "headers": [], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": false, - "crudKey": "id", - "callbacks": [] - }, - { - "uuid": "5d76bea0-39c1-45f2-80f1-de6f770cb999", - "body": "{\n \"error\": \"Error retrieving the profile PDF\"\n}", - "latency": 0, - "statusCode": 500, - "label": "Error occurred", - "headers": [], - "bodyType": "INLINE", - "filePath": "", - "databucketID": "", - "sendFileAsBody": false, - "rules": [], - "rulesOperator": "OR", - "disableTemplating": false, - "fallbackTo404": false, - "default": false, - "crudKey": "id", - "callbacks": [] - } - ], - "responseMode": null } ], "rootChildren": [ @@ -1805,10 +1700,6 @@ { "type": "route", "uuid": "26f0f76f-e787-4ebe-a3f8-ea3a6004bc15" - }, - { - "type": "route", - "uuid": "af7fdcb0-721d-4198-a8ef-c6d8c4eba8c8" } ], "proxyMode": false, From 92d3e17b62b5c5f232c6c15835c8ddefda28d9cb Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 16:13:08 +0200 Subject: [PATCH 12/15] test api --- framework/python/src/api/api.py | 1 - testing/api/test_api.py | 155 ++++++++++++++------------------ 2 files changed, 68 insertions(+), 88 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 75d765428..98170fd5c 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -447,7 +447,6 @@ async def get_reports(self): # can fix the report URL reports = self._session.get_all_reports() for report in reports: - del report["mac_addr"] del report["tests"] report["device"] = { "manufacturer": report["device"]["manufacturer"], diff --git a/testing/api/test_api.py b/testing/api/test_api.py index 9ba6c3f08..b9bbc6b02 100644 --- a/testing/api/test_api.py +++ b/testing/api/test_api.py @@ -961,14 +961,13 @@ def test_delete_report(empty_devices_dir, add_devices, # pylint: disable=W0613 # Assign the device name device_name = f'{device["manufacturer"]} {device["model"]}' - # Payload - delete_data = { - "mac_addr": mac_addr, - "timestamp": get_timestamp() - } + # Construct report_name from mac_addr and timestamp + mac_no_colons = mac_addr.replace(":", "") + timestamp = get_timestamp(formatted=True) + report_name = f"{mac_no_colons}_{timestamp}" # Send a DELETE request to remove the report - r = requests.delete(f"{API}/report", data=json.dumps(delete_data), timeout=5) + r = requests.delete(f"{API}/report/{report_name}", timeout=5) # Check if status code is 200 (OK) assert r.status_code == 200 @@ -990,22 +989,13 @@ def test_delete_report(empty_devices_dir, add_devices, # pylint: disable=W0613 ],indirect=True) def test_delete_report_no_payload(empty_devices_dir, add_devices, # pylint: disable=W0613 create_report_folder, testrun): # pylint: disable=W0613 - """ Test delete report bad request when the payload is missing (400) """ - - # Send a DELETE request to remove the report without the payload - r = requests.delete(f"{API}/report", timeout=5) - - # Check if status code is 400 (bad request) - assert r.status_code == 400 + """ Test delete report with empty report name (404) """ - # Parse the json response - response = r.json() + # Send a DELETE request with empty report name + r = requests.delete(f"{API}/report/", timeout=5) - # Check if "error" in response - assert "error" in response - - # Check if the correct error message returned - assert "Invalid request received, missing body" in response["error"] + # Check if status code is 404 (not found) + assert r.status_code == 404 @pytest.mark.parametrize("add_devices", [ ["device_1"] @@ -1014,11 +1004,8 @@ def test_delete_report_invalid_payload(empty_devices_dir, add_devices, # pylint: create_report_folder, testrun): # pylint: disable=W0613 """ Test delete report bad request missing mac addr or timestamp (400) """ - # Empty payload - delete_data = {} - - # Send a DELETE request to remove the report - r = requests.delete(f"{API}/report", data=json.dumps(delete_data), timeout=5) + # Send a DELETE request with invalid report name + r = requests.delete(f"{API}/report/invalid_report_name", timeout=5) # Check if status code is 400 (bad request) assert r.status_code == 400 @@ -1045,17 +1032,12 @@ def test_delete_report_invalid_timestamp(empty_devices_dir, add_devices, # pylin # Assign the device mac address mac_addr = device["mac_addr"] - # Assign the incorrect timestamp format - invalid_timestamp = "2024-01-01 invalid" - - # Payload - delete_data = { - "mac_addr": mac_addr, - "timestamp": invalid_timestamp - } + # Construct report_name with invalid timestamp format + mac_no_colons = mac_addr.replace(":", "") + invalid_report_name = f"{mac_no_colons}_2024-01-01_invalid" # Send a DELETE request to remove the report - r = requests.delete(f"{API}/report", data=json.dumps(delete_data), timeout=5) + r = requests.delete(f"{API}/report/{invalid_report_name}", timeout=5) # Check if status code is 400 (bad request) assert r.status_code == 400 @@ -1072,14 +1054,13 @@ def test_delete_report_invalid_timestamp(empty_devices_dir, add_devices, # pylin def test_delete_report_no_device(empty_devices_dir, testrun): # pylint: disable=W0613 """ Test delete report when device does not exist (404) """ - # Payload to be deleted for a non existing device - delete_data = { - "mac_addr": "00:1e:42:35:73:c4", - "timestamp": get_timestamp() - } + # Construct report_name for non-existing device + mac_no_colons = "001e423573c4" + timestamp = get_timestamp(formatted=True) + report_name = f"{mac_no_colons}_{timestamp}" # Send the delete request to the endpoint - r = requests.delete(f"{API}/report", data=json.dumps(delete_data), timeout=5) + r = requests.delete(f"{API}/report/{report_name}", timeout=5) # Check if status is 404 (not found) assert r.status_code == 404 @@ -1105,16 +1086,13 @@ def test_delete_report_no_report(empty_devices_dir, add_devices, testrun): # pyl # Assign the device mac address mac_addr = device["mac_addr"] - # Prepare the payload for the DELETE request - delete_data = { - "mac_addr": mac_addr, - "timestamp": get_timestamp() - } + # Construct report_name for non-existent report + mac_no_colons = mac_addr.replace(":", "") + # Use different timestamp than what exists + invalid_report_name = f"{mac_no_colons}_2020-01-01T00:00:00" # Send the delete request to delete the report - r = requests.delete(f"{API}/report", - data=json.dumps(delete_data), - timeout=5) + r = requests.delete(f"{API}/report/{invalid_report_name}", timeout=5) # Check if status code is 404 (not found) assert r.status_code == 404 @@ -1138,14 +1116,16 @@ def test_get_report_success(empty_devices_dir, add_devices, # pylint: disable=W0 # Load the device using load_json utility method device = load_json("device_config.json", directory=DEVICE_1_PATH) - # Assign the device name - device_name = f'{device["manufacturer"]} {device["model"]}' + # Assign the device mac address + mac_addr = device["mac_addr"] - # Assign the timestamp and change the format + # Construct report_name + mac_no_colons = mac_addr.replace(":", "") timestamp = get_timestamp(formatted=True) + report_name = f"{mac_no_colons}_{timestamp}" # Send the get request - r = requests.get(f"{API}/report/{device_name}/{timestamp}", timeout=5) + r = requests.get(f"{API}/report/{report_name}", timeout=5) # Check if status code is 200 (ok) assert r.status_code == 200 @@ -1162,14 +1142,15 @@ def test_get_report_not_found(empty_devices_dir, add_devices, testrun): # pylint # Load the device using load_json utility method device = load_json("device_config.json", directory=DEVICE_1_PATH) - # Assign the device name - device_name = f'{device["manufacturer"]} {device["model"]}' + # Assign the device mac address + mac_addr = device["mac_addr"] - # Assign the timestamp - timestamp = get_timestamp() + # Construct report_name with non-existent timestamp + mac_no_colons = mac_addr.replace(":", "") + invalid_report_name = f"{mac_no_colons}_2020-01-01T00:00:00" # Send the get request - r = requests.get(f"{API}/report/{device_name}/{timestamp}", timeout=5) + r = requests.get(f"{API}/report/{invalid_report_name}", timeout=5) # Check if status code is 404 (not found) assert r.status_code == 404 @@ -1180,6 +1161,12 @@ def test_get_report_not_found(empty_devices_dir, add_devices, testrun): # pylint # Check if "error" in response assert "error" in response + # Check if the correct error message is returned + assert "Report could not be found" in response["error"] + + # Check if "error" in response + assert "error" in response + # Check if the correct error message returned assert "Report could not be found" in response["error"] @@ -1211,26 +1198,14 @@ def test_export_report_device_not_found(empty_devices_dir, create_report_folder, testrun): # pylint: disable=W0613 """Test for export the report result when the device could not be found""" - # Assign the non-existing device name - device_name = "non existing device" - + # Assign the non-existing device mac (no colons) + mac_no_colons = "001e423573c4" # Assign the timestamp - timestamp = get_timestamp() + timestamp = get_timestamp(formatted=True) + invalid_report_name = f"{mac_no_colons}_{timestamp}" # Send the post request - r = requests.post(f"{API}/export/{device_name}/{timestamp}", timeout=5) - - # Check if 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 - - # Check if the correct error message returned - assert "A device with that name could not be found" in response["error"] + r = requests.post(f"{API}/export/{invalid_report_name}", timeout=5) @pytest.mark.parametrize("add_devices", [ ["device_1"] @@ -1242,17 +1217,19 @@ def test_export_report_profile_not_found(empty_devices_dir, add_devices, # pylin # Load the device using load_json utility method device = load_json("device_config.json", directory=DEVICE_1_PATH) - # Assign the device name - device_name = f'{device["manufacturer"]} {device["model"]}' + # Assign the device mac address + mac_addr = device["mac_addr"] - # Assign the timestamp - timestamp = get_timestamp() + # Construct report_name + mac_no_colons = mac_addr.replace(":", "") + timestamp = get_timestamp(formatted=True) + report_name = f"{mac_no_colons}_{timestamp}" # Add a non existing profile into the payload payload = {"profile": "non_existent_profile"} # Send the post request - r = requests.post(f"{API}/export/{device_name}/{timestamp}", + r = requests.post(f"{API}/export/{report_name}", json=payload, timeout=5) @@ -1312,14 +1289,16 @@ def test_export_report_with_profile(empty_devices_dir, add_devices, # pylint: di # Load the device using load_json utility method device = load_json("device_config.json", directory=DEVICE_1_PATH) - # Assign the device name - device_name = f'{device["manufacturer"]} {device["model"]}' + # Assign the device mac address + mac_addr = device["mac_addr"] - # Assign the timestamp and change the format + # Construct report_name + mac_no_colons = mac_addr.replace(":", "") timestamp = get_timestamp(formatted=True) + report_name = f"{mac_no_colons}_{timestamp}" # Send the post request - r = requests.post(f"{API}/export/{device_name}/{timestamp}", + r = requests.post(f"{API}/export/{report_name}", json=profile, timeout=5) @@ -1339,14 +1318,16 @@ def test_export_results_with_no_profile(empty_devices_dir, add_devices, # pylint # Load the device using load_json utility method device = load_json("device_config.json", directory=DEVICE_1_PATH) - # Assign the device name - device_name = f'{device["manufacturer"]} {device["model"]}' + # Assign the device mac address + mac_addr = device["mac_addr"] - # Assign the timestamp and change the format + # Construct report_name + mac_no_colons = mac_addr.replace(":", "") timestamp = get_timestamp(formatted=True) + report_name = f"{mac_no_colons}_{timestamp}" # Send the post request - r = requests.post(f"{API}/export/{device_name}/{timestamp}", timeout=5) + r = requests.post(f"{API}/export/{report_name}", timeout=5) # Check if status code is 200 (OK) assert r.status_code == 200 From ac53b1833e2023df35d156751915f955462918ee Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Apr 2026 16:17:27 +0200 Subject: [PATCH 13/15] pylint --- testing/api/test_api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/testing/api/test_api.py b/testing/api/test_api.py index b9bbc6b02..1d3ef8c51 100644 --- a/testing/api/test_api.py +++ b/testing/api/test_api.py @@ -1207,6 +1207,18 @@ def test_export_report_device_not_found(empty_devices_dir, create_report_folder, # Send the post request r = requests.post(f"{API}/export/{invalid_report_name}", timeout=5) + # Check if 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 + + # Check if the correct error message is returned + assert "Device not found" in response["error"] + @pytest.mark.parametrize("add_devices", [ ["device_1"] ],indirect=True) From 18ababa390511a9b2a54f861fcddead05e0bf253 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Sat, 18 Apr 2026 16:57:20 +0200 Subject: [PATCH 14/15] test api --- framework/python/src/api/api.py | 4 +- .../api/devices/device_1/device_config.json | 118 ++++++++- .../test/001e42289e4a/report.json | 116 +++++++++ .../test/001e42289e4a/report.pdf | Bin 0 -> 59498 bytes testing/api/reports/report.json | 5 +- testing/api/test_api | 2 + testing/api/test_api.py | 245 +++++++----------- 7 files changed, 335 insertions(+), 155 deletions(-) create mode 100644 testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.json create mode 100644 testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.pdf diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 98170fd5c..f0910eb02 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -474,7 +474,7 @@ async def delete_report(self, response: Response, report_name: str): if not report: LOGGER.info("Report could not be found, returning 404") response.status_code = 404 - return self._generate_msg(False, "Report could not be found") + return self._generate_msg(False, "Report not found") if self._testrun.delete_report(device, report): return self._generate_msg(True, "Deleted report") @@ -743,7 +743,7 @@ async def get_results( if device is None: response.status_code = status.HTTP_404_NOT_FOUND return self._generate_msg(False, - "A device with that name could not be found") + "Device not found") report = device.get_report_by_folder_name(report_name) LOGGER.debug(f"Looking for report with name {report_name}") if not report: diff --git a/testing/api/devices/device_1/device_config.json b/testing/api/devices/device_1/device_config.json index 3be69a082..9e3f84328 100644 --- a/testing/api/devices/device_1/device_config.json +++ b/testing/api/devices/device_1/device_config.json @@ -50,5 +50,121 @@ "dns": { "enabled": true } - } + }, + "reports": [{ + "testrun": { + "version": "2.1" + }, + "mac_addr": null, + "device": { + "mac_addr": "00:1e:42:35:73:c4", + "manufacturer": "Teltonika", + "model": "TRB140", + "firmware": "1", + "test_modules": { + "protocol": { + "enabled": false + }, + "services": { + "enabled": false + }, + "connection": { + "enabled": false + }, + "tls": { + "enabled": true + }, + "ntp": { + "enabled": false + }, + "dns": { + "enabled": false + } + }, + "test_pack": "Device Qualification", + "device_profile": [ + { + "question": "What type of device is this?", + "answer": "Building Automation Gateway" + }, + { + "question": "Please select the technology this device falls into", + "answer": "Hardware - Access Control" + }, + { + "question": "Does your device process any sensitive information? ", + "answer": "No" + }, + { + "question": "Can all non-essential services be disabled on your device?", + "answer": "Yes" + }, + { + "question": "Is there a second IP port on the device?", + "answer": "Yes" + }, + { + "question": "Can the second IP port on your device be disabled?", + "answer": "No" + } + ] + }, + "status": "Non-Compliant", + "started": "2024-12-10 16:06:42", + "finished": "2024-12-10 16:08:12", + "tests": { + "total": 5, + "results": [ + { + "name": "security.tls.v1_0_client", + "description": "No outbound connections were found", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.0 and support", + "required_result": "Informational", + "result": "Feature Not Detected" + }, + { + "name": "security.tls.v1_2_server", + "description": "TLS 1.2 certificate is invalid", + "expected_behavior": "TLS 1.2 certificate is issued to the web browser client when accessed", + "required_result": "Required if Applicable", + "result": "Non-Compliant", + "recommendations": [ + "Enable TLS 1.2 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_2_client", + "description": "An error occurred whilst running this test", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.2 and support for ECDH and ECDSA ciphers", + "required_result": "Required if Applicable", + "result": "Error" + }, + { + "name": "security.tls.v1_3_server", + "description": "TLS 1.3 certificate is invalid", + "expected_behavior": "TLS 1.3 certificate is issued to the web browser client when accessed", + "required_result": "Informational", + "result": "Informational", + "optional_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", + "description": "An error occurred whilst running this test", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.3", + "required_result": "Informational", + "result": "Error" + } + ] + }, + "report": "/report/001e42289e4a_2024-12-10T16:06:42", + "export": "/export/001e42289e4a_2024-12-10T16:06:42", + "folder_name": "001e42289e4a_2024-12-10T16:06:42" +} +] } diff --git a/testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.json b/testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.json new file mode 100644 index 000000000..7dc70a7b5 --- /dev/null +++ b/testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.json @@ -0,0 +1,116 @@ +{ + "testrun": { + "version": "2.1" + }, + "mac_addr": null, + "device": { + "mac_addr": "00:1e:42:35:73:c4", + "manufacturer": "Teltonika", + "model": "TRB140", + "firmware": "1", + "test_modules": { + "protocol": { + "enabled": false + }, + "services": { + "enabled": false + }, + "connection": { + "enabled": false + }, + "tls": { + "enabled": true + }, + "ntp": { + "enabled": false + }, + "dns": { + "enabled": false + } + }, + "test_pack": "Device Qualification", + "device_profile": [ + { + "question": "What type of device is this?", + "answer": "Building Automation Gateway" + }, + { + "question": "Please select the technology this device falls into", + "answer": "Hardware - Access Control" + }, + { + "question": "Does your device process any sensitive information? ", + "answer": "No" + }, + { + "question": "Can all non-essential services be disabled on your device?", + "answer": "Yes" + }, + { + "question": "Is there a second IP port on the device?", + "answer": "Yes" + }, + { + "question": "Can the second IP port on your device be disabled?", + "answer": "No" + } + ] + }, + "status": "Non-Compliant", + "started": "2024-12-10 16:06:42", + "finished": "2024-12-10 16:08:12", + "tests": { + "total": 5, + "results": [ + { + "name": "security.tls.v1_0_client", + "description": "No outbound connections were found", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.0 and support", + "required_result": "Informational", + "result": "Feature Not Detected" + }, + { + "name": "security.tls.v1_2_server", + "description": "TLS 1.2 certificate is invalid", + "expected_behavior": "TLS 1.2 certificate is issued to the web browser client when accessed", + "required_result": "Required if Applicable", + "result": "Non-Compliant", + "recommendations": [ + "Enable TLS 1.2 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_2_client", + "description": "An error occurred whilst running this test", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.2 and support for ECDH and ECDSA ciphers", + "required_result": "Required if Applicable", + "result": "Error" + }, + { + "name": "security.tls.v1_3_server", + "description": "TLS 1.3 certificate is invalid", + "expected_behavior": "TLS 1.3 certificate is issued to the web browser client when accessed", + "required_result": "Informational", + "result": "Informational", + "optional_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", + "description": "An error occurred whilst running this test", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.3", + "required_result": "Informational", + "result": "Error" + } + ] + }, + "report": "/report/001e42289e4a_2024-12-10T16:06:42", + "export": "/export/001e42289e4a_2024-12-10T16:06:42", + "folder_name": "001e42289e4a_2024-12-10T16:06:42" + +} \ No newline at end of file diff --git a/testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.pdf b/testing/api/local/reports/001e42289e4a_2024-12-10T16:06:42/test/001e42289e4a/report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..87fc93be2a9c579e07b304b6a40cff89f1235976 GIT binary patch literal 59498 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 Date: Sat, 18 Apr 2026 18:38:20 +0200 Subject: [PATCH 15/15] test script --- testing/api/test_api | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testing/api/test_api b/testing/api/test_api index e77b67457..649720205 100755 --- a/testing/api/test_api +++ b/testing/api/test_api @@ -38,13 +38,12 @@ sudo chown -R $USER local # Copy configuration to testrun sudo cp testing/api/sys_config/system.json local/system.json +sudo cp testing/api/local/reports local/ -r # Needs to be sudo because this invokes bin/testrun sudo venv/bin/python3 -m pytest -v testing/api/test_api.py return_code=$? -cp testing/api/local/reports local/ -r - # Clean up network interfaces after use sudo docker network rm endev0 sudo ip link del dev endev0a