From 6e96845494352261372aaae5ee628f2df6a74c8f Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 23 Dec 2024 15:15:23 +0000 Subject: [PATCH 01/47] WIP: Renaming to pilot recommendation --- framework/python/src/common/statuses.py | 2 ++ framework/python/src/test_orc/test_orchestrator.py | 8 ++++++++ resources/report/test_report_template.html | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/framework/python/src/common/statuses.py b/framework/python/src/common/statuses.py index c7487868a..e4bb629ac 100644 --- a/framework/python/src/common/statuses.py +++ b/framework/python/src/common/statuses.py @@ -30,6 +30,8 @@ class TestResult: IN_PROGRESS = "In Progress" COMPLIANT = "Compliant" NON_COMPLIANT = "Non-Compliant" + PROCEED = "Proceed" + DO_NOT_PROCEED = "Do Not Proceed" ERROR = "Error" FEATURE_NOT_DETECTED = "Feature Not Detected" INFORMATIONAL = "Informational" diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 8e275b2cf..62f672918 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -266,6 +266,14 @@ def _calculate_result(self): and test_result.result == TestResult.NON_COMPLIANT): result = TestResult.NON_COMPLIANT + # Change the result if pilot assessment used + if (self.get_session().get_target_device().test_pack.name == + "Pilot Assessment"): + if result == TestResult.COMPLIANT: + result = TestResult.PROCEED + elif result == TestResult.NON_COMPLIANT: + result = TestResult.DO_NOT_PROCEED + return result def _cleanup_old_test_results(self, device): diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index fbd8d1c68..931432cc2 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -55,7 +55,11 @@

Device Configuration

{% endif %}
Test Status
Complete
-
Test Result
+ {% if json_data['device']['test_pack'] == 'Pilot Assessment' %} +
Pilot Recommendation
+ {% else %} +
Test Result
+ {% endif %}
{{ test_status }}
Started
{{ json_data['started']}}
From 0d4ca597e2d5fab51253de44bd6953a5c98dfe1a Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 2 Jan 2025 13:50:08 +0000 Subject: [PATCH 02/47] Fix color formatting on proceed result --- framework/python/src/test_orc/test_orchestrator.py | 2 +- resources/report/test_report_template.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 62f672918..6b49a6fb4 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -267,7 +267,7 @@ def _calculate_result(self): result = TestResult.NON_COMPLIANT # Change the result if pilot assessment used - if (self.get_session().get_target_device().test_pack.name == + if (self.get_session().get_target_device().test_pack == "Pilot Assessment"): if result == TestResult.COMPLIANT: result = TestResult.PROCEED diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 931432cc2..0304bb312 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -48,10 +48,10 @@

Device Configuration

{% endfor %} - {% if test_status == 'Compliant' %} -
+ {% if test_status in ['Compliant', 'Proceed'] %} +
{% else %} -
+
{% endif %}
Test Status
Complete
From f9bf09ff1bf8d679ee44a0dfac4d29a0c5fba480 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 16 Jan 2025 18:40:20 +0100 Subject: [PATCH 03/47] replace steps to resolve --- resources/report/test_report_template.html | 68 +++++++++++----------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 0304bb312..fbd63fab6 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -160,40 +160,6 @@

Non-compliant tests and suggested steps to resolve

{% endfor %} - {# Device profile #} - {% set page_index.value = page_index.value+1 %} -
- {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} -

Device profile

-
-
-
Question
-
Answer
-
- {% for question in json_data['device']['device_profile'] %} -
-
{{loop.index}}.
-
{{ question['question'] }}
-
- {% if question['answer'] is string %} - {{ question['answer'] }} - {% elif question['answer'] is sequence %} -
    - {% for answer in question['answer'] %} -
  • {{ answer }}
  • - {% endfor %} -
- {% endif %} -
-
- {% endfor %} -
- -
-
{# Pilot steps to resolve#} {% if json_data['device']['test_pack'] == 'Pilot Assessment' and optional_steps_to_resolve|length > 0 %} {% set page_index.value = page_index.value + 1 %} @@ -241,5 +207,39 @@

Recommendations for Device Qualification

{% endif %} + {# Device profile #} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} +

Device profile

+
+
+
Question
+
Answer
+
+ {% for question in json_data['device']['device_profile'] %} +
+
{{loop.index}}.
+
{{ question['question'] }}
+
+ {% if question['answer'] is string %} + {{ question['answer'] }} + {% elif question['answer'] is sequence %} +
    + {% for answer in question['answer'] %} +
  • {{ answer }}
  • + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+ +
+
From 30ca0d0947e20a0ec6055b9d643d7127c2f1966c Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 16 Jan 2025 20:53:46 +0100 Subject: [PATCH 04/47] split optional recomendations to pages --- framework/python/src/common/testreport.py | 17 +++- resources/report/test_report_template.html | 100 +++++++++++---------- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 3f1c902c9..c30d56a46 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -334,7 +334,22 @@ def _get_optional_steps_to_resolve(self, json_data): if 'optional_recommendations' in test: tests_with_recommendations.append(test) - return tests_with_recommendations + return self._split_steps_to_resolve_to_pages(tests_with_recommendations) + + def _split_steps_to_resolve_to_pages(self, steps): + # Split steps to resolve to pages. + # First page 3 steps, 4 steps on other pages. + if len(steps) < 3: + return [steps] + + splitted = [steps[:3]] + + index = 3 + while index < len(steps): + splitted.append(steps[index:index + 4]) + index += 4 + + return splitted def _split_module_report_to_pages(self, reports): diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index fbd63fab6..4f7c6c264 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -11,6 +11,7 @@ {% set page_index = namespace(value=0) %} + {% set opt_step_index = namespace(value=0) %} {# Test Results #} {% for page in range(pages_num) %} {% set page_index.value = page_index.value+1 %} @@ -145,6 +146,58 @@

Non-compliant tests and suggested steps to resolve

{% endif %} + {# Pilot steps to resolve#} + {% if json_data['device']['test_pack'] == 'Pilot Assessment' and optional_steps_to_resolve|length > 0 %} + {% for step in optional_steps_to_resolve%} + {% set page_index.value = page_index.value + 1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} + {% if loop.first %} +

Recommendations for Device Qualification

+
+

Attention

+

+ The following recommendations are required solely for full device qualification. + They are optional for the pilot assessment. + But you may find it valuable to understand what will be required in the future + and our recommendations for your device. +

+
+ {% endif %} + {% for line in step %} + {% set opt_step_index.value = opt_step_index.value + 1 %} +
+
+ + {{ opt_step_index.value }}. + +
+ Name
+ {{ line['name'] }} +
+
+ Description
+ {{ line["description"] }} +
+
+
+ Steps to resolve + {% for recommedtation in line['optional_recommendations'] %} +
+ + {{ loop.index }}. {{ recommedtation }} + + {% endfor %} +
+
+ {% endfor %} + +
+ {% endfor %} + {% endif %} {# Modules reports #} {% for module in module_reports %} {% set page_index.value = page_index.value+1 %} @@ -160,53 +213,6 @@

Non-compliant tests and suggested steps to resolve

{% endfor %} - {# Pilot steps to resolve#} - {% if json_data['device']['test_pack'] == 'Pilot Assessment' and optional_steps_to_resolve|length > 0 %} - {% set page_index.value = page_index.value + 1 %} -
- {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} -

Recommendations for Device Qualification

-
-

Attention

-

- The following recommendations are required solely for full device qualification. - They are optional for the pilot assessment. - But you may find it valuable to understand what will be required in the future - and our recommendations for your device. -

-
- {% for step in optional_steps_to_resolve %} -
-
- - {{ loop.index }}. - -
- Name
- {{ step['name'] }} -
-
- Description
- {{ step["description"] }} -
-
-
- Steps to resolve - {% for recommedtation in step['optional_recommendations'] %} -
- - {{ loop.index }}. {{ recommedtation }} - - {% endfor %} -
-
- {% endfor %} - -
- {% endif %} {# Device profile #} {% set page_index.value = page_index.value+1 %}
From 21d4bfbb30522976e84e81d289aa98cab04fa59e Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 16 Jan 2025 20:56:05 +0100 Subject: [PATCH 05/47] pages num --- framework/python/src/common/testreport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index c30d56a46..befd463b7 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -253,7 +253,7 @@ def to_html(self): if (len(optional_steps_to_resolve) > 0 and json_data['device']['test_pack'] == 'Pilot Assessment' ): - total_pages += 1 + total_pages += len(optional_steps_to_resolve) return template.render(styles=styles, logo=logo, From da0155ad1a5f0c18724308341012e701670918e9 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 17 Jan 2025 16:53:02 +0100 Subject: [PATCH 06/47] required result table column --- framework/python/src/common/testreport.py | 2 ++ resources/report/test_report_styles.css | 3 ++ resources/report/test_report_template.html | 38 +++++++++++++++------- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index befd463b7..b7ba898b4 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -255,6 +255,7 @@ def to_html(self): ): total_pages += len(optional_steps_to_resolve) + is_pilot = json_data['device']['test_pack'] == 'Pilot Assessment' return template.render(styles=styles, logo=logo, icon_qualification=icon_qualification, @@ -275,6 +276,7 @@ def to_html(self): total_pages=total_pages, tests_first_page=TESTS_FIRST_PAGE, tests_per_page=TESTS_PER_PAGE, + is_pilot = is_pilot, ) def _pages_num(self, json_data): diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index b1ed9d33c..d2ef769d6 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -470,6 +470,9 @@ left: 6.85in; } + .result-test-result-pilot{ + left: 5.4in; + } .result-test-result-compliant { background-color: #E6F4EA; color: #137333; diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 4f7c6c264..c9d6e4311 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -56,7 +56,7 @@

Device Configuration

{% endif %}
Test Status
Complete
- {% if json_data['device']['test_pack'] == 'Pilot Assessment' %} + {% if is_pilot %}
Pilot Recommendation
{% else %}
Test Result
@@ -84,27 +84,41 @@

Device Configuration

Results List ({{ successful_tests }}/{{ total_tests }})

Name
-
Description
-
Result
+
Description
+
Result
+ {% if is_pilot %} +
Required result
+ {% endif %}
{% for i in range(results_from, results_to) %}
{{ test_results[i]['name'] }}
-
{{ test_results[i]['description'] }}
+
{{ test_results[i]['description'] }}
+
+ result-test-result-non-compliant"> {% elif test_results[i]['result'] == 'Compliant' %} -
+ result-test-result-compliant"> {% elif test_results[i]['result'] == 'Error' %} -
+ result-test-result-error"> {% elif test_results[i]['result'] == 'Feature Not Detected' %} -
+ result-test-result-feature-not-detected"> {% elif test_results[i]['result'] == 'Informational' %} -
+ result-test-result-informational"> {% else %} -
+ result-test-result-skipped"> {% endif %} {{ test_results[i]['result'] }}
+ {# Required resul badges #} + {% if is_pilot %} + {% if test_results[i]['required_result'] == "Required" %} +
{{ test_results[i]['required_result'] }}
+ {% elif test_results[i]['required_result'] == "Required if Applicable" %} +
{{ test_results[i]['required_result'] }}
+ {% else %} +
{{ test_results[i]['required_result'] }}
+ {% endif %} + {% endif %}
{% endfor %}
@@ -116,7 +130,7 @@

Results List ({{ successful_tests }}/{{ tot
{% endfor %} {# Steps to resolve Device qualification #} - {% if steps_to_resolve|length > 0 and json_data['device']['test_pack'] == 'Device Qualification' %} + {% if steps_to_resolve|length > 0 and not is_pilot %} {% set page_index.value = page_index.value+1 %}
{{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} @@ -147,7 +161,7 @@

Non-compliant tests and suggested steps to resolve

{% endif %} {# Pilot steps to resolve#} - {% if json_data['device']['test_pack'] == 'Pilot Assessment' and optional_steps_to_resolve|length > 0 %} + {% if is_pilot and optional_steps_to_resolve|length > 0 %} {% for step in optional_steps_to_resolve%} {% set page_index.value = page_index.value + 1 %}
From cf2023e7452fba4350e774f40867a3404b21d658 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Fri, 17 Jan 2025 19:02:25 +0000 Subject: [PATCH 07/47] Fix pylint issues --- framework/python/src/common/statuses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/framework/python/src/common/statuses.py b/framework/python/src/common/statuses.py index e4bb629ac..568e1416d 100644 --- a/framework/python/src/common/statuses.py +++ b/framework/python/src/common/statuses.py @@ -15,6 +15,7 @@ class TestrunStatus: + """Statuses for overall testing""" IDLE = "Idle" WAITING_FOR_DEVICE = "Waiting for Device" MONITORING = "Monitoring" @@ -27,6 +28,7 @@ class TestrunStatus: class TestResult: + """Statuses for test results""" IN_PROGRESS = "In Progress" COMPLIANT = "Compliant" NON_COMPLIANT = "Non-Compliant" From b7cf45da09c48346cbb8308c9e8536fff06073ff Mon Sep 17 00:00:00 2001 From: MariusBaldovin Date: Mon, 20 Jan 2025 10:40:13 +0000 Subject: [PATCH 08/47] fix failing tests --- testing/api/reports/report.json | 123 +++++++++++++------------------- testing/api/reports/report.pdf | Bin 29659 -> 33682 bytes 2 files changed, 51 insertions(+), 72 deletions(-) diff --git a/testing/api/reports/report.json b/testing/api/reports/report.json index bd697654d..df7ac0d50 100644 --- a/testing/api/reports/report.json +++ b/testing/api/reports/report.json @@ -1,16 +1,16 @@ { "testrun": { - "version": "1.3.1" + "version": "2.1" }, "mac_addr": null, "device": { "mac_addr": "00:1e:42:35:73:c4", "manufacturer": "Teltonika", "model": "TRB140", - "firmware": "1.2.3", + "firmware": "1", "test_modules": { "protocol": { - "enabled": true + "enabled": false }, "services": { "enabled": false @@ -22,38 +22,51 @@ "enabled": true }, "ntp": { - "enabled": true + "enabled": false }, "dns": { - "enabled": true + "enabled": false } - } - }, - "status": "Non-Compliant", - "started": "2024-08-05 13:37:53", - "finished": "2024-08-05 13:39:35", - "tests": { - "total": 12, - "results": [ + }, + "test_pack": "Device Qualification", + "device_profile": [ { - "name": "protocol.valid_bacnet", - "description": "BACnet device could not be discovered", - "expected_behavior": "BACnet traffic can be seen on the network and packets are valid and not malformed", - "required_result": "Recommended", - "result": "Feature Not Detected" + "question": "What type of device is this?", + "answer": "Building Automation Gateway" }, { - "name": "protocol.bacnet.version", - "description": "Device did not respond to BACnet discovery", - "expected_behavior": "The BACnet client implements an up to date version of BACnet", - "required_result": "Recommended", - "result": "Feature Not Detected" + "question": "Please select the technology this device falls into", + "answer": "Hardware - Access Control" }, { - "name": "protocol.valid_modbus", - "description": "Device did not respond to Modbus connection", - "expected_behavior": "Any Modbus functionality works as expected and valid Modbus traffic can be observed", - "required_result": "Recommended", + "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" }, { @@ -70,65 +83,31 @@ }, { "name": "security.tls.v1_2_client", - "description": "TLS 1.2 client connections valid", + "description": "An error occured 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": "Compliant" + "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" + "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": "TLS 1.3 client connections valid", + "description": "An error occured whilst running this test", "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.3", "required_result": "Informational", - "result": "Informational" - }, - { - "name": "ntp.network.ntp_support", - "description": "Device sent NTPv3 packets. NTPv3 is not allowed", - "expected_behavior": "The device sends an NTPv4 request to the configured NTP server.", - "required_result": "Required", - "result": "Non-Compliant", - "recommendations": [ - "Set the NTP version to v4 in the NTP client", - "Install an NTP client that supports NTPv4" - ] - }, - { - "name": "ntp.network.ntp_dhcp", - "description": "Device sent NTP request to non-DHCP provided server", - "expected_behavior": "Device can accept NTP server address, provided by the DHCP server (DHCP OFFER PACKET)", - "required_result": "Roadmap", - "result": "Feature Not Detected" - }, - { - "name": "dns.network.hostname_resolution", - "description": "DNS traffic detected from device", - "expected_behavior": "The device sends DNS requests.", - "required_result": "Required", - "result": "Compliant" - }, - { - "name": "dns.network.from_dhcp", - "description": "DNS traffic detected only to DHCP provided server", - "expected_behavior": "The device sends DNS requests to the DNS server provided by the DHCP server", - "required_result": "Informational", - "result": "Informational" - }, - { - "name": "dns.mdns", - "description": "No MDNS traffic detected from the device", - "expected_behavior": "Device may send MDNS requests", - "required_result": "Informational", - "result": "Informational" + "result": "Error" } ] }, - "report": "http://localhost:8000/report/Teltonika TRB140/2024-08-05T13:37:53" + "report": "http://localhost:8000/report/Teltonika TRB140/2024-12-10T16:06:42" } \ No newline at end of file diff --git a/testing/api/reports/report.pdf b/testing/api/reports/report.pdf index 0e449f19679ca495eadec409fd554cd8a40a631a..1a3dc27f9955e2568fe10be6e2068b1a153e06b9 100644 GIT binary patch delta 28278 zcmZs?V{qV2&^;PwW7`|s*w`D}wr&4n+u2wf+qP}n8{5f!-hbT>@5OwYs_8k?)irf$ z`tXQ7EkLrz5g46+c ztABr%gAF_Db4VJ!l!t}1=;zl9Qbe|oR+_#7zHOgB3sC{5?+w6>09SNC_`ZV$`7{n-vgX(I&EpCqH zkNzKly~IE`Jd>2*wi}HjL@lv@#{C0_WbzUJW%W)%I~B2@pvAR~&(82S&~xFp_32!a z=vC&cPhN(nIzEn5^MHiX^F4A_FnRWG`uF+N0Y*$4l*5xz7C5i&rP$^7P0beLgL`h| z*G-v&pt2xIOoND0PFG@g{au9D=gaF!F7R=#_X>QLoL|`!6(yexfF~Ah0UZ3Tgo|Mz z=Iaas%2#=qMB@i{QPu={Q=phc{hlGpP4j!>tgf1A4!0Sea3+g=>tZlR!-PVNJwG6( zcAj(ntY}R0rrPw1zdmkDW&}P)RU)~(zJ3h2hAgq;?A6;o2vwDD{QXjc<|I7nM@fYpd|Kg&7_d&QVwLtIxC^|!e}N3rHZh(>V!c_u5jbn= zqe@V0O^lL$-cy+}JvcusoOznkYt>!Y@x@Ed6O#olgz`S>4Xpad{v(6Kt#?6E;ps}G zD}AsyB-P_>HRdXJ;B7xBWaiJ~$!+q*rvof5b7w(pL#Dhg#!!QvO^)EenQ?!xcvy}O zLb22i4`tf8bT}AvWv7g+K#3->;xMd(q9!Mya0tk~CNdz~*f=M3osC1gVhpT63_F!s=z^%x7zd3;0qGs#1WbT@yL~T4t`hFw+RJfG3T@gju#|b2ajdSV~tNim(q+oKNTV z@A)`8IO*|;&G$5We3Q@}|2td}?otKLj4J3Ua8(eSdBpl#*XVHJRqEKYHxOV|lsA}7 zDkHy(nwUqLX+HGRhhuNC#)(ePc2(@%3$#D%IGXI*|s#jxjek=NFVnm zz@H0pnp;h`@NeDOXT2cr_d@3=NAAq7^~FLnG&VvVzPg$x!^d}ZJZO)7RCtHH!xO_4 zE9A7N@Y@-1DOGiJ97!7!(gRequy}k}h29wc9(YX8M^8?{Z7SClaqjbZPDAx2-&ojH zA3_N5)cTNr!btkg51C_y(wwWsLR&YcgXcxOrL2rNX;lQDff=F=$`I}r!E%Uizy$e- zD289pwwBasyH@MY%HqhzJ9hf2(&oPF<3gM(-(!Xd!*%tsEHE_}xdSEOsk_`@L{47_ zri5XLE9m)YX~PEPt(&em_x>>T-;!gC%s}uM*8>iMjd-T(}(3xPXL6^2d ze?+J$G_FRif@4v<$AdgaN=cn?1tk@Tk_l4~R+_p} zUVZZd!IgU2-{*bt1Sy2nulId1#NIL8a7PLqRv?Q*egF%ms8H=6Hscj%MjRlKB9c9M zF>7oJWQK?+KX}%66LJeK(N$n>K4}!(hU`K^)tFl;DbXd<0GLeb3c3GMaQAyLUxqx? zxF0nsd8OUnB~QdGHJ0^@odxrmvQ1(m49qEjaZyvrib=7TGkL*>QXnP-&7mwuS!x#n zU9>eR^BsKHt97`X8m(m6rKSVqx5l!*Clgt`Od_@I-FJ=ej~%Tark+);R3&_8Jf6Eb z*7D=kB7Va7zz53&_??p)^7bAt^s`(z7E`1$Kaa^p;|HM)YR;kK7|k8?C5S9Y?2p)e z*`Ac8<<)f6uR}z>U7<>XY*=>>-$3WsJ*->(G)pu$S3qe+rNhh{$jQ(u&GDl!~n+ z9t|69%d;LGSa>#BzU3i6Dd(PzrzOTT{aV;fZrsJT) zEcYJ9ABHW147-V%DIdSjc;!50IZ2hZsBj+pLqcqHO zbw$6{{8!p$7ggu3C?^ z+%^+U{4e)f#VoYqqX3Z%K)tEJG|~!=?il=Kr;?O$T$r1HHz(d z-R(+g35^sfABrU0VG#?hHdcy(2Tw#%LJK%#vBHKV0w*>FS5By_E75-co1fRfQr)9M z3HwnJgEUNg`R%3FW8+kdqwj)am+AW!L8xlRmxXW-D09y0CvK}D?VV4@SafQtcgK9v zK%%|lfLBU^!$g86;D)Cg^$fAm(T+K}vaH+^rW+eInOjSv7m$ylED;BLDGu!n9{c4X zin(9ZfIR67&+;%gUTdyVUe|QTa@B^hI2BVQqLbFSaVf)dI@)zpFykn*eE$M{T&iQv~S$IYv3KZFg~bJgx;8!2(q;-G9uH$ zO3Cx&i8zF}r%Zx+l^Q?@g^8Uq4{j&fLNq_#TEoem+eS*pHzlEDFm9V4HTJ<yZ?bW zdJkX_8cNp#ef}V(kOgj1w)c=N{_*8GWaUsjJPw#r`+x4^k8wA-TuQsU{~|2Xpm*py za^D#Id~~)Qle|TKrIRXC3JBfKhz5_&KZ1l7UTqrkKB)r9jP+sRp5nX?&Y2xUZ(K(& zm{bUJ)%-IYT^-OebxIFm!e_PMW%I#q#m*kBB8vb3oNMsgVk98;lnKelmnKR{jGU7O z*axj+0kzN69L<`eb>Nz1i#+l6g>T}8?P~#>Z0sw2Su%8!iNEti;j|)mQ4s3trbsoU zow_T2*D_9_aN(mTp5~!A7V5Wxju^!n^bF(ocp%6`B^W}U0(Gam6 zwfqbRxG#DX*v3u~JvO4Zjg*#2A>BpdVtT|0qJ>BihVv4}D~}BgDgMVjg?l#^%lI0$ zO@s8^(dCqX%(4aA4)?i|ON6-9w-$=1PDcA_v@tRtEsDOGiZMGE=WkPZF-5}e6U{qJ zSmOwkk!yb(*q=QJwF8b)PI!;fK7^QDMd$#t9}Gl{TZc%UWN9C&FF~?6!r=SGIQ{g* zUqmdy#2exQaZ9*eapZW}zXOC2e({$edbjG*ou%yCvT{dBgc0(ExVfQ9vskgSMXMkn z0Gyit6HTgZxq2E<51x$0!->$)KBi$u?aZdwmuX`?mkyP?PPLNeZ-4KG(;0#6#=90 z{_$(lqR6yQ?&kPgr2LJ=mW3c59!dnB}tBZPvN%9sT*J7%`@6BHawTuzIPn*fnB4~2j?d;RtfQp8!{^+a~i z?ozXT=LIdq&f@!K)=*K&!XJk>UNIl=_;=;^^|?@z>vxIqfF%7q!UwX4-CBo?#|obH zTCrtOzQ*eQ8BY8e(L+;Ru8A03WJ>vrMSm$VYV3=Pl+e%i_vjWmZiD(#+T2rr^u%ME zA)&Ib9q^Pa&F{(X(TGLOj5OA)j~^*f6q?wLcjeHOFZn&m^06lHG5`63ECLFpnpN3J zIfTM$;h;zBC~6C!L$5OV-z_U%f-I(hD_~jY;F(o8>(4&OECe8R=Uc)}6-1pc&r7 z%}c{bh|IO*_8HjI6-0RIX;g7)5uhSN5X=xN&M?QmJ&Sjn4oLT4ChiM~!iv${WhN>( z4La@3mIqkdCn8$%8;iWD1lAXDDW&s`k4NRgPl(C>nalpOd$Mv~`Zu(&;DN#{>VW_? zaCX4=oZ5fsU2(nRmXd4+lcIPmb%DO~!&*a%Ecv0t){Ohl(p@2}{f(oPhvG&$@QG`& zf2UBqc-XOoP0eO`-@s}-I@zS#uy@{~#MyEumNSzuc$>oN@GzAajc8kWsgtnW>+@@l z`(WQRoPs??a)w+1t?!zx+^eg+=+T7(I9&-o$czl2ZPW~DFqR5Su#RTiOT}Psy^gY( zF?ya&w_>;1cD>v`=GLIRZC|YOoab}WV_@5?X7-#IRvlMsRd0?3e8fK4#kDA`(FTE*&zZtUjg%$hh8}JmF3Q zt&usq;Nu?lHFKX}nql$jk?Bar6h%U=X-K>D`R%7b)Ew)3=qYW}HS8_gh?!6>;m>3V zbV%3VUa=|`9ukuP46gs^l%4f|^XZcIR@?yxA~2($Z~}Jl?;V`#uD10lvcRHaD9=}8 zb6`+160Cu*@%bD4w^E|mTK&kFn`0}1iSZoj7!h?sa@A;|e-9@0A2s>M?E#6_vX88+dgfwBGXQ+<-$0N4SQgC}6n7ZMi^Af^rjG+Y z-Cpj*5cgj;fOw2jK5RL+Uw)PXVpZFwm)vU2sL;DKRXp!P}8K`f|NwfDhnBXGd`s76}#3U&=PN? zfQZ=Jp-@8+g7PP+9aHQNBnwnCBgA9tI_-IGcq=Pf%5u}t*+v*ITh3XWHH7ZGEUuB+ zAVRIv0HNP&DInD~Hg$ao8_}B-F_u)sk{-Xahyg;f$zkF+ChU{R8YT>KS;zFkGsLOK zbWA8eu_B+>caIda&ew@G$a|-T;mv|O;L55~-L12&g7@oShv|F!It#eYY0%N|QL5;p zCHh%P9y>#PxuM_v{gs(3_;7!Dvq#G`fAmmvseY4-=W;)@w}rRKWbNLx(N6n$CRw$O zaZ**c;bedOK&F0OKJjE=twQx-zi@Ajs_jDx788 z6fkweMVR#3{gto4%L4dAgj$l&+p4Nh5ln&!Wo`dC%lRfx`--AIJl+SeK+C!Dk1$h~ z{&*dP)&2p7U&UJ<2kbjA(3O&yI1^a~=yfew;w7GZE9#A(`>sJw3>>>pa4YGnBNou~4_MXwx57ez3%&Q~d3~z~EIIT%SKh_17NUD9 z);(EUh5gUzZ`s?%#+;oaY-`tDP;x}a+|i*kK{s!m?jCWL9%0Y;7phe;BsF}?a@+R> z&97W?JuBX>7pc$X6#p3voDJxN^}X`K*35XvJ(q`OZf}%@B`GN3!eq454;TEf#lXxD zwuEI$7nCg)mIc%pi2E{rxH2bKu}Q}p4DF(kFuSOw5>h3y|E|U>>;cmi^5iH{u2U#Y zLYJ!=C$`sgY#7ham)}hnH4Esz9A5rwWv9HfzSxu!VIx(yXcJ9yA}-6J;>bed;G))~ zrb?@(x>;^8&Q+pJplzEyF3 ztsJ#{nT*Q)B8Kr7#G>}?;CL_ayq2RXSn3CT2&kg{gUzea{FpKRqaz1d)PcUY+p5zY znwV(t9N&&!?}W`oZHCQ``bF7 z|C;a1q5(j2;qel<{76kW~ z{|~VC*XB3Gk~9ELo(SJ}2$qB*H8(BsV?!FwmEOADi4)_J1lJ+_x!;T@!~#0$M}%Q7 zrBw*BM^esVotq!-ICN`h?!o@N^;o&Q7vI>Tm>#|?xVy~Lth4v&GNA$?EAH~lD0r%d zEeBMBbC3iWNQeXT4+r>{w`A?X8M=MEs8hFKTdwsC$qQhTtXVNV-BA$XP=~{s9y*nB74^(GHUT_{(o$fxqYxHNj)10; z?->svwr(Ugba9AUgy-|HuZdJ7{SJ;{hvY#9&|Oo;mtRmcUfYt2mqb1lNiJ(GNp#{8 z6VSN6vP#}mSES!ibe(xY$Z|BFL9X5JmNeXw+K8_@J_PS$-F}xdN1J}v{|kJQx{l{u z6_zC{ev8hRyisH=CNb`pYG9P}i6{D=kw=SO|9(=UbqAQS$XM!<&U@F))ca;oDC!0T zz&g-gKz6?6eclJ%Clje9(xip20<$K>p(4zayw%Ezb<>yWSGm4<@Eg$z@)9aFbt#Bg zz4iLmSk-AFZsE1y%8;9P1dL~rIC&|7h;6=rkgZ?Y$YYv1*Alfv!aO<{#HWAj^dq6T zP~uSq%|`295|04v(4v(IY;PuxTXHWBc+vX3Pfa_f8o!`P=}4MDXwoiNaHEpMlbe$) z+MY|WF)xI;{i{^eyi%YD&0Z-rNO@&YHb;h=Uu>kb^3QDpn89f{l-}LKzKJAH+`1~Q zOksBKBCN8IyLiRDWTuNrR3&Ws>HDAP7|Pbsek_BF#Cbc0-hob-?LFZ znT;&#lpoz_Qxe8(q)FNGkY=^oy5#18Tp;ytWJSS{u}N?UgV4zYdp2FviO=P{B^$46 z=zbZI4}%BT)ZcsxPRG?$cRX7OKMCQ3*1drts&oSr(7L0JV!B)by8)6Y#rq)dzkID& z8_wP?D%acxNEK=#>CSRms~ZJGfZxkghyJ#&bMgzFsTTW55?!E!Ucy|-FT@KGnTh3v z3utQQ3Z+zNuisu;6h*0AmSZP0(-Ui}lUdmE78)A<3V8(q7I}0puszBSS@P_VN1c(i znZZ9ch_eC%rD|1R#U%|&E!y~_KtR9J_2QkWiRNJpv=OD(QPT9$HnlLifI)JOM>C*1EMr)CC=K>&L>Xe|N&dmSLBJncRUDHF;o-IY zC$vPj+F68bz*L>5-e6QpT(6D|Ygd-Lso+2j`b)v(4u@hI&%jEb#U%G5kZlo4H*6j` z7=E-FdQe!cLA-1X(PXON5J(@ycsiwq2w;w>tg`5?4$^R-GCA}Ea`HAPg2 zaU?tO-xp`V@Sf1kN9z6k5FV1NrsvX&Md#k1ZWOoVjrg7)j2a{sJpe-$k>Uf?@N{^( z`78ZM9`guM3yFK3sG(fd#6?(?w<*I)*?N52#icV_XG)5WX{NQ_tzeq5R+`J@-AVMG zwQh?GjQSE`tlvjn`Hs`z##`6x0K}>;{4{srqj@#2$I^^lA5BRub51)7@s4-t{!Q&# z;&R#HOb;i-u3a5(Fkm~lsqOrdXV5qV&Ac@f2ZLSG*0<v1z}Q}C?v9t zb7*5VUY~zw04`PtodVj8iObe5F+_&0T%QkVum%?XdVm@u6_(~BumA^^aZb38qI4$s zIg;EP9w(bIXtK?AoQ0-4x?IJYX{HzYk^>)T#sTO^ z49~C-u-ywaP#UTCzWYG`BO&^H(i~%Sy{!Yd`gmi=)*7|KGEGd_2GlPP>&OU_Pmiea zart<2oT=!_e-lpOHKTBDs%hKkAu&wRk$>glFS6=a1YMaV(K$@T*7pbN%J_LgagNw3Mov?Ny@69Q`Oj=+S-eJz5qaOP#kFw5Bh#Y^7DG6=-0rxbU zq7jtJCf`z<(Q>Nrj8%MztmQSfL0|XLTgu2q<&9KwEYGXy#MtV1~*ozv{V6M@2*IIo0y`_IdWr|Vc4*&mn3xM`*&~YZ#rt% z`64Fth-3+0GV&2B|6J!oBBXXo=59BD=ahaZ!V-Du=$6c*KOe=yre@AlcHef1&ka~z zO?V<+Tg9>T41`{`3<35pi#t5}2?r;7rg;owJTB4C2-#6YJqK7Gof0uJST^3p-Ha}1 zG--Os*Vw!e_{e>aw5>L)tAEg3V*jA^TzCahuUo+K*uk} zatfvD;>qVp6bzPs1Hs97xOd&1zo^Fudd;h^MJpl0}pYs5@LlsrhQCloU3*pC< ziWW=|(mbp|73eJ3=+TWfogG~GgBy1if;{o=k`&hOP$8f7zpki-bCFdkY7FA{k0;MV zN2(YI3}~P~TLkZuM`AO}9)#SWsWN>FPU@`85T`7=UGo&=C=Ah3IJW{7mX#sVdJ&5+ zx=!K@ih#q0XiVL`6`a4*(;8zF1jHRg7Q^rGvrBE>UDQ zh<-vSKsKuAo9Gl?G9q~evq{f3x^i&EVKn19lY|^ksHdw)fXc=C&)56ah+N}zaOfGi z%89;(wV&Tc*RN4Eww9PNd5Ih)X+00u z#wOGLA=x9QJKiPTp7sxiR2!gA4lGX;4vi>uYD}Xj?Z>l-bFUcq)Fe-CR32`^6l^_y zkB!cZimS75W`3AHV(hv2%D3#Aqw@cI@5t@tsJGxX?kS1xnVFXQMF}bpR*&g)`I?rO zV?iE&8k$>5K11A;JUtC*EW*CyE*LpyHz)4&nlRpo$eP->yZBGb)C|B_eds8NhQKHh zGKx>Eu!Tb|hnqVc?jHJRt2cjiN@!-1$+4crbZX0s%1>4GhM#f7{w!V^WQX2s^u2G& z%xkhYqzS_CG}jWK4J$smvpExMvfXMOmt=XfmVUl3em6Sfb+0B~Ft5|0Qn#BQmtEw7 zFHRYJe7YbOa7*l*Lj#EB;(U(b+Kfc8UM*~38beVWKLo2i@7)C?wsN=5EV}r!VwTMz z%%Lyw)4yz>*FN6t!4`49GZSJ1mjmu4tRfQe>hgxWSnSW@Y?m%_kCs{4PqnMY8JeV4 zF89nTxENw|bDk?Zx;ocjOT@B|_B*4hoJy*d5!pl3rc

UT$qQLI};&2;3telcw0KxO7yZdJJLw)Dl5bzJuthe?@nHexc!0xjN(! zE52~akVJ{LGD|38@05hY#tV1;s7o%S{ghv*AmRGcFRbf`3?`}S@}M8`#IieNGX%aT zhfHF9-N$-GRSM`K_lxC9z&pBn|04*6utP?l80>>5+(d~(jr;A+YjHW(x*)kWFo>WG zLhkk0EG7xtjy%fd$n#POKEmu0Au+4Tg5KkikIX=7BDUxh=d5HEBPFnG4wtc6WSPuX zr4}llnmRKEt6iZkU+FSpqLvh;d8M7HoZyaq>f%svDg(GloS8};WF4q9Nbo4h%;gKrN zTLKM1FB{<%E3Y{9wi+P*2Ak`b)qo&-2bDN;dF2%Lw#!e6P zI^`D$!O|V5W3EglqQmP1Sjoc?N2XQuEjUbacp=Pe*?aw^!L~>`Y8a;J6Y7TK^QL;m7-$vw~@1wpE`{{8u?4_+ggMTcP~ByF#>45?F_@J7~8Qaw{L=&oMc%6p!L%}Y9?pY2c=^f8@I=pZkG)a4(M~^VL6S1_f<~-3V}tF>Wg*+5fyxSlDSF`qf>1i!*B-) zZXnFl(n$fOX|s?xvRvx`D12F`K0fNDn`~FztPa;%aYj>Fp8U{JVfn8>ZU(Nx5T$jcSWy3W zzN@rRsWH`OYD<{hE^d51Yo*|Mso|5B)G?@`@AI|`?BoI|v(HoWustWUx9ymScHZG@ zS~gyuXz=xtxohl})x(FN$G^c29ypjtgX0ZTj20xHU%4jcT^v1|c<#c6*(bB5lnS$J z_V9SKq|mK=%FekWpGUC0+i__r5o=x90U5|)f*$gK%iksA##3>Y zx54)N20xaxKL6j8TFfb%I zU}PkqQkK*WA#%`E4Hwfc9oh98^s_U~bUmL%9ZV2V>B+@=$_8d@{Wd(wvANHi&t$J7 zsaG=Q{|(oVE8t|yd4}&~(|MZjnvXOZ*-Eg57z)Ast@$~MD491d{z|s>y{o@;qTw3u z;QD7UWqyyj3myC4D}Hwfl26~+9><-%wnBtIp*6&00Ss?1yPI`|3etvO6$&eaye|Lz zPbd4Mek}QdlMD$kOwL?#@{+%!M(c*q)`D=7!A&j*>I0yk)gc2T_MPUA*4S_I#3~I3 zv-90;6}mOGg5LtjKLIz7tKmWqEN+kklHL`<3?5!0%j`Btk}0rgBm!M-Hy=|X+@QzY zyo2y@wJ)dRqhoo`7pLftEq#7HA?8AWY9^V~d z{JQ@I5116;EoK+s6}}e4f$qwPw;13G=nmKE(-&u-ZA+S6suzwipTmC2LpWS^V+j$4 z`b2LPAiq&CMuVi~N+(V89BWSCS0P3!pm_Ktz!DWY#xvU20tAM>{=xX?>fyvTNtF=v z0W$2@<=Hv@ubyf60OtVf^UE%tZ7rlqO+bdVaiz-CNrSf^rq zR8De5VLouJ4mQFJbvgg71lNEy84nJ)PjoTpna6xE{5{RUV;tFl z_vttAJs|Kw*$F7{80;zuM!aeTZ~M1{%dtT5Dn`+bhV)Z`5%T#{%%ZQyV9qJJSeb^z z4Vm3U+xfADIDMSrw-fq;iq{Zx3C!{XE?6KRYTKeG%VHm?PBl2w$Zv4@1QrngIA<45 zA$!Y29vwJ1L4Qm2Dw42Q{uO~T3Bt(kRpxXm1X=aLe=LN*Ne$uBhJY=!z`Yb^wZOa- zg6o|iae<)EKRAVQ0n4VVv0Q~{IOX>r0o5z*??@k_&eDhO0GZyc0sXK6_X!pNeloC# zbc^}P_$h|SW77*3!J2dSBBeiPJd5dqu&TDIKFAluS>tm0cTuP~Hy-Qxn{$Bt7OF(K z25;5*6~b2J?h19UMaE~?aDzsHVDi4>3SC$0r};012%NBJc{N=K0o6Gu$A<((UpNK&a@rECm7^#02( zo-<^if-2YXhq<`W5LZ7p3cUwbs?));3HT4(0VCKlc0<;-i<}?tVdy`BH4`@O%Yjd^ zpqj2Na}B>C>?@V9_R=sOH9s*wiRwV}eB7?27hlKykQ7x^O6JocqvC?L3uWwdoiMQwN5|mjtj_Fe z=5!|`l@}-gL4z93qarUwptD+|jCcUuHgXH%j>M2SF?6IJ$;B^|Rykc7KtMBNK=Wb- z`U-}qpd&;?n};XqyBPWR&Sj^Q6-0_ z-wO#`VE18Ktt25u&^9_ zZCD_D3kIjthu&ntfa&6GfnOXTXZ>m}5VeKoXc+zUZ>CydbM{Tn4eI126zJUY zcM&{BMI2ZSG4cd38)90xxM`r{aZH53!SfOka{aU)p3ayPPJ_OXM1Q8=|3{P){D9>v zvr_9|NXY9U*QEXKi7_LbG&WOqhd)xJXtCrKqTjKz`~G!cQ`*(xM=f5S;a@^FI(>U* zD`D3$HM)Eo?-#s~jc3T9cRNOvfB6kys zJ=y^l)C4Ljy+f&%xqWC;HQR^cgCLz7@!O>Ha;Y3v6Tbulv>WBVH<%bl`I7kB5nP_? z$hfp=j_M*$i~X+F5X8~&%)tlk&h;n%Gn8rP_*a1R`RetcEaKJ9!g(-qCj~4^W8SnX zHSU-f^&ueCHqZ3o=)%7|$C~|8k&ByTMl*3Z+4R}T``2buWA?YE9&u-3sJD*jVpKBd+AMfa}vPROZU1L6y<_Z!)O8uIJYUma1yN11N; zQ-pG!+&RC*d{sv(E8c86cE6;}!D9S88+PxcZS`~we%3O03G?+w>@jwY&Jn_R7qsNNR4}22l?8r9p|2(d^P$E1vlDl4nw$_kddu~F21+$ zD!-x;?mQWq0?&>9h=S65p5i*BRn-l8a9_zXzH7vzwKs|gih1)z*NRK}-)5~uNNn2c z^-n?_Dkx3bzAj0pgb*G?#uI8L3jHv}$sQqv!R?zAEmutso_JzIi4u#amkkd@k^n^< z`h{m2t$oW$N5}mELr=10UGw9P3f1OfuDk4$D5Xwy1XXe^v~H~x!sw3Xwx^I+ ziis;iZD1ZOU2k$%e#>*{#Y-X$W~7p|d_MO+kZEW}>C{r(PzI*mgN}QhK7ha(q1% zt0QW>c`EZF7@Ng@ulY(eo5`g(Zy*bj@Yc7Z_PlAB_Hb5h!7O&Z$zbX$UnJ9}u_S zDHGmP$z5UVWlxKRs+(dai~-UvVwLFBoMu-AcOq^alZKI)M(R2CqAM!tr2Ro@{sxsX zTaO_xYkSKYIDhtDs04eX{@&!f-1-Ig%&~;-`+P@m7a4zfYhIrNE2b+$GM#KwrK?+I z3k816CG2)P)x6Bm`&Z{9^!alA?iaQ3=W2z??x+HVl%EZ6<4MFRyBDCxh(C;pLOHzQ zH9nWoo!Msps3NZ-=h`~s64WdkXcCxP=5Y&g2C=864Z|evE{sLEw#ZnjZqX7FE2EeJZ5#`V_e_C^G@Kp8@n(gTW@DH^sl(h?}XSBN;|B zNmp6Yw z0KB9@;W!(uZZ%k$Dn!C0RAP~ew@gljofQAgD_okU*k?d0V&4knU)-fqq-b0?Wz5?9>aU7BH@*>H{R>_=qfV zlPNW=H?8p}kHI>MhJzKaAJU^~WbZL8b=6n*FW4K~J02*S6f2gN$Bxzn-FqU215@O0 zKweQbGVzT-$;JSuFSg=7@v-w7@Qv2A5-IMa%EE4;g;e~0ogD9sMJEI^?`#tly0Jhu zKH{m7^dP>x`4?KUm-}?2%v}GBhj;ugU1SoxPU^FSEv_Nm>0+HAv&y4UgQ((^e;9JU zA3S-aDAO8V_}w2f3rLA!C3+x4JpMh?D4g8;p?D9-g5(gvxMHX+a%TpWx7~yp!;P{8%^5-e{&Z>_5S+?g-k$T>BFN>AQ1zXm z8q3{avV8={UqKUMYr=d&M8xUWO{!F-EE0cg|4l4cb;zlgO-L<*4`s3 z`3OLmo5gsZ>^67Oofs=uwykU)T>qOxdXdSI8&k+OZ=`JIS+%pCduzIdEaS&p5l?In zucNJ`b$Nlmaww54Q!dHcD)y%~+NQB`D6XvA0u{m&g?=j+IVBTy{}BTPH8?z`Yg_;f zJ^Qdn=xY;_Zf)wT=P=*07@V)8iPa0iC6V8O7*z@2U1LMos5vdfNna>~x>&jDwLTXi z2tnpv;u;dOQxteguOsZZ+4?oHPo?k4938TfCe4T(X-pJYNvVxEUz5I=&bf<~OF-;u z!OAN>PYFJQD;uV2(i@a*<0^r!4CY$n(ELPw7pC-|Dv57I38Y`o5Elxw2yHR-37*6! zJy%`=&$1uEt}Z4jxUzE1|tOULurPe`<1vWD%|t#7Nin zZ65b&e5A@j-Hl9MX-v8dNfn0)i&!Jq&feU6zhUn7LY>;R)fF3mh_4xcXK?OWux)aY5}7!53mjOuUSc` zGS6$))8R<@fs0!#mr=#~E;Z>=+?Yql$k;n`ZgttN$mhOQ6r%Npn4=vk^EEmn1unOS ztqo1p2x3j0e@MRXzBuRKE?JMMPu}WbPK;<-a%KKE5{ueGW!i|aE}`qCLYH|FCC4Q@ zm+5${IG!%Fnn&5juB$6eJ3wb!UAkU2Z~4~#-$iO#Ay0A7(5|Yf8V^3(aJHd0S90Kw zUkTGIX+sy?Af^dyTrgtx!j!#{Pv$hjYDmcmKzjg9cSjR$5qnJF-di~HfpOjZQ@6&l zEFH?nsWWNDxLrG9@SN2Hc)Wbw6XxZC!2ZmujoESKr>hqtk%~(;2ZpCeNr+v_xTmf< z&l#VUrI-X_k6zev3OdL8SogU?k3K&?HeN7TyW5?DW2p=w^nFvdvFcwaU+DCxO>XI? zrk0Oy_Yps0o~4&~^$eBfM86*HQkG=&`tIyE@#fr5v7k_RVG1?BE-7q{i$Zc{Dg2$C z+otmPABLQ?gWKoefFn1AyWMde=6d6}+2IWZIEIBjC$eG6aa;2B$p55*sfq_j+VX+4 z@k=++o30}_%H5r?d5Z|YJwbiJs8eDs!I1!Ir0=diq$>DmhQ3b`3qr47{%jign8mx( zB&EO08)9#}JWEdkmSL!#!H?fw3oQ@{_L#S*ASwt?%<)Azf!-JVNvDB!xz^Kvd`vAM z@q5}8Dl}{~==gXRKHHJ&2rmb`JtKm#m@qJ%aRO?|YqQs1~^&GDZS z?7wP?a)OEzzHtWDOsYSofnNNYUE{4}0r8ELKY`J&JmoCrd-x{cKR>|i-wl%eNTYaT ze>LP^u*`p+0Q6ItOe_%;IBqpy!zHnQmj7vyAV_}5L>A`&@q+EhFWULTBq6h-FbvA{ zy#)hq4z6IPm>3pFs1kEBPk06jhbiA^4 zLFpB|AUF>gNlA=QYCXP)P(gP6j-oaJFa`2=zW*P6kLCZR@BJt5ad5G6{3q`b0Bjup z&vxlwmNuCy3HRH-6S(Oj*u)eIT;tzFl~5UPDaZ*E$&(5^BHC>y>P;n6gEgRyM$+In zVlH(gGC2^u@?AF!y8dfFVxx~QREKSpj8f~cKp8o|Fx#jOY(&7kY=*3t#hNY2e!g9> z%kFM4nGG3Ac+e# z1UQ&he`&0Exq=E}^}n@^y+#xlj6`;W<@M6|*qiMyq8>}(0|@v18A&JdCZs1UAVVdf zPn+r4IqCa+<;Ul&9$urLo}O!0!S4hq7foT6J z)tu%glDh5hUiB!*jjRBlN!E$;%L5^6h=g-F#!gcy|!U zT@Ft0Ni2HZ&j)9EEh&wFaJ>fnC};%5b#H8QUk1cv8APVcOfh($$(q}{Y4*PmUwtqN zHhm>FGEOoOw`H9VSsyM`Ua@`we3l(fLScja&9HaT#1;I6s3 z#)hic2OT+H-rZ;7I)5Yx!0qLJ>$U*5O&{r}M0Ee0Un+W&!`t9<{`-5$gA?{1M%dLC zfdcG_O1=fjjv5>P8Q8SPXT`T}<>s$PD0bBAM4+WAa9|CV`xPx3eF#*U~Rv z59wpD1z=ntCq4xjQT1)L;=}g_L0WF~1w3T$ic~}i;U{8CLhlmeLp3AC4lt*Oyzi3H z^%jVx^uP`8Q3s!(_|?K~w?cnl`bpb#5CdU15UR%-#A3P$Mg2!}ka8SAx1yQ0QBW#(pZxs5{zCk?B2ybF}zn8vLg(h|&OvLO5 zoZ)=IW>~~5E@}Lg2W35`CuzBQR;t)vw*EL3@QNq@)~ilf>il?{(%Lh+lx?p+&Ii_A zye==(-fu*DhLj;6NihDezP>W3?%>%ngai#P!QEXi1PJc#?(XjPOK`V~dw}2&+}+*X zxwr*)+wAU#egAr|cB-aoYHGgpOwV+6*EwgfQsYBLwo1$AREMzeSi#qsi)UAlZA9Sk z1GS8Pe&H910RFOj!9!2S+vH&logG&HOG=g8O8zBLjZAVOheF{(JfvV`A4!HmpS!c` z3!BF1?v)ln@@u8lr$2`pmwiJ!%jMnj?b&c{J7e0xSyup(b8}%{O@iG1VzSaz^J(ww z&#s$Zt@mkuKTiYhjRb!duouhOko&yovBZ92_mM=cU#u)_e5kvKjM(J72<)HRLv18d zlMJDR3#r5^91dCGMj7<+HcG9{tlvcv5;^)U-fkDh9{2s-Wv+V{vo0<<#G`Kd1cdEN zX+^^R;i>>8j$Udu2h0oFAE8+Zd$>t((8Z_W>7=!lGlK9NR@2M25CjXS=?dqvr;yaE z>$4kO({0@hLVLeV82$K^5B zbR)%HBIr^cyR*xoRIhKtp*8qk`_6Q3-@qLS(t3bW!#H2&FdyG+L;QjFACy?#le2aZ zn{T@YB=b042pIPkvhkEV%u`wg|Z6Ye!s zbkbdxn=QB*eqGd__yg)7J|On8e|PDL#o5U?&G9saeXcK9qk2(TL?*o|BNE0f&2^WF zU~2(X5b6yowx9hm(vcVIh-3VZG6rI(s74Rt=V_IQRM?EiKtW@9f(1o_p85kfDxnMX zBF%iZl(JN{0uB{X#mlw_ZA3W^DV#R-ZUhi!$9T9B z-eH^VAqgQ8gYJR|pDG)~Wo!kl2>-H(!q@=Na?woJ`est?jMkT5d@}Vok??fw%weG8_kkI*{I5b2?Nq!nx z_ngs3Gmr4BM;aRMIg!LBX?9XzhZ=(nw{lngSOh!BgJ78kPm4l{9n8V0zO)!PG| zD{@0bMlZ{BTDOrqJ@3D;-kFGs5bAsokWMWlSlo0&bUws!kD`GbbC8FdN;-u3Oz4Q{rS8|y&-evzL}WmyQ#2V zR`eds`uVV0?A(|7goW!F8!R1{Erf^qVn3CupRdovlE}=~v&RLC14FC6xNbnHw-l~{ zyaiKVEn0i;GSAEgXyv%(Wc8Wycu#VD=*3p1ks%qs4TiVTCnjV=C2PCi)Vd=Jl~gDO zJ&AP*BAu$IB}1aLtkPHzr8h*uMAbS`fNA!bHF~yY@;lxkf&FFrV*}l?pMo(ShieIFvD#Ngi5SiP$+!7#U1ge%4diD z#@%1S1T@OYa+gM{IvF+ZwGQ>8!E_|TZyKP2%_m%^*Qjeo zG_*hI5Krd*BRAcWq$^+$nF{C7E`wLmW#<+|*&vU%g`g0RWT zjBGWzZqa-aO|-R1pJdRRwv3xlx2BPiOu3jR2JInBvL9A+!yb8B9ke9=hxL^YppU8Q z87YU0-SLK6rDS`qqHdJ~k|v?cT*ozqt0O-1g!{>1t4kTOj{}JV#ns(^*>LM`lf*_K z1BSJJ0tRQcwgAgK?ya|9wRYA`{ebKk?BXUR{1a&%i1UerNP5ng!9uYN(v?U3wVrGnlU9N-=ro;%yc`TYhZ|6mwaoL_JP zWs{)89V^Q#+gDYSL;)euS2xup`A4?+8=(Y!-=%dUu>>7=<3h#$xbKnlv_xSC{rm2; z376?++DlLXj=@*|J8ZNK%nj}SYM-fy4}+ynouO#Wy4^e5tit)2Tm@{$7Gp1Q^c4A5 zHO5{}R)CW1YYFSNURtTHm&K_|@TOw1rInwV)m}%00{76>ibdqu06^-1xCnKgi=%Tf z;I3m%_RTQ14%$?y?VM+t3+8C?t?PQ}?NBv#c#a(_TE5noX9Z+gi_}n$v}6k{{eB4x z9)^v5=DN~7|LwZ3Oy)!j?x4sXoEixWe`6m?1KR1v{2Px$56U;Ml+GOwc5DLCVK_;| zS-Z5NR1f$0ET)zu2bXeB2Y16`-QyM)VXVq7%q~swNUdvRk!x^%4>S>y8s)|GgN&aL zjb#^iOWz29l76FnZ#4a(=z(}{W?jzeWqMB%)$Q%i^ML`YBU0kGcvvVYW zNr>@PnCb>>A|Gv%4ca^HVNdHjdS>Xe5wf`7*)O&{*9?FG6)*Nwd3zjV5v8)|f!0;oss4Vp-$Awu)7_R4sXKKepjDz}}*zt`R zlUg{Z>N+vAzyX-gt4w(3T>1WA->QbrX%kJ*SZ`ZBxucp5Y1)zpd>v)nQtPrl?tk9IOuFXot>c82?W7ljgX02se+ zhAfSq7l|^@*SH_ag&79X_Sfn5%|!UYd?M?(K&>J{z08UTo5{=hHy95SL{ET>7o*HV zefkKC26t>uFlz$$i88f6v7Hj_k~2IPX2ST1=A+mNUE}h7K3_Pf4QUpCLMw9O|-pL_qFJI=)(l8Hl8MU^HOZaGpm~S zd_@wuitK{6po+-H-j7RsI)|Dj|7=r;$%E1kbdCXB)D^;@2KRRhLGelhKNY%U`ucNL zhUdxS>#2KdzQg{mKZ`mqOf1^zgWLp%n*yz#f% zNe;fY5w$8uPCa{z#+bj3gv#A1s8$_g6no{^1rz2Ty3jzH zydag-9;YbsSOi&DBh1zU1rjDk+Uc+{7AL`dRmJ2*%$h32E+Sk%I56A0eE6M-aC$iT zxV9PZsB}&e=d3dUIPGk)+Q!3Wk#yNg`;LdyhRUp=0~y>Y4g*l|keR>}PM1CDbadRas1PMvFV&nrvo2U6~ly4wlrb zFRdQfdoi)uJleEsq7nh*3)E{ZR&wCA-fp^D-3{K?VOke0K8|BCwU$gRPicM2M$D?6 zJkj7yu8x4Z`2=h|cW1wR+?x21Jvw1b*l4}`#Km;_aQW^JF3FwC>WJ~!sbXL3xEEhj z-YLD@PWH9_4*CoI=A{P7-k)J=9(6AY<9|VHnZ0@VAMQHGf3N3oacnmLm& zi`yE3^|;UgF3^7sS+yO>e4%!QmoTXSja>987%4yM2G2 zOgrDWe%grj`AaDX=k?PR<7W`eXGS1X#qBT|gERt`o7Z%p?nr1(w3E8ei6lnlAlU1f z>7CLInH2Ts&c)Jhs;W~{s#sWV-(|NMt5r!K48GD@-({DX6fS)8J%%nE8X}z6rLD2A z>{{5ZPL|Ud{-;jSOdM`4I3aHY0u%*V{q}~F#1^E%P&8$*vaty$p0SBaw+FbLOfXp} zOu&Hz+L$<%>tcZk1a{*$?S%=1o$TGLrB{x#PfX#j1bP?Y7o)|G@R+fH`+XSZz?mOQV1FVCF_;-|pxP#mg{fT-^g7n~m;o#vYlOM@~bb}a9{1N5*YWc#-_VjOT z-q6QG_+P0`^n-2=?ykN$$O!z1doR#A&l>f{`-Mz8`_awsuLNog`5GWnEGpSB=Hg|| z=&tqCFHmf=MgOUmQs=sOOJ98ptDsfVeS7`09|fc|!4s94Ek>3?Wk?l0O7nV17+rpY zqNLbFTp<=Sl6*)q3i~=c%@jLLrl;UL>#ki*VNp@h*}Huwn$i3FreYnn1LcxP}0zEa1JI8)15v@?bmL$eKNGq&UW>^Q6R-kBgnu$> z(%l-rCx~4{0Lv)MHoo@;o0N|Lu@soaNN)}OVU#ywX439lU~6v-q&Gij?;~oX+mYCNARAH#03EkRN*=2bF4dCbvFVqm+5$Pa)t|F?*{gA)Q zI$$3+CisK_DXjrp>2bR2UVJqo;QjRLYuBV_hqi1^1tM_7rg~i(5YJGmv_R_HnJ;}J z@$&joXRlr~Z~f+YOi8?X2Z-iqf8PTsGmFYRkl&&Wjlj#1Ss`Bkpi&!bc0nriT~^%D+pbZn z4OJ6dld_LSPxQbvHa_zZv{`aW3(F7SqX$W^N&>`b6P`?XUa&CL4b(Qr2jWDr%lAj& ze%GVZ5A(JsxhYGpF+Qh1mA#f14;Gb{3D(z|?!RFW)mc-De^h$p z`5C#U^iMabPIiUwU~YMOzO3VId%AJ#0d+1HZh4?%e7S+qLPY52DR`&yZ+WmEabv>w z$0LB$=gUzQ)`HL_i^|O9cb}N|*FT{Ty@?tYH$gv#D97W%N+r21kDH|J%0ixhJ@3veSd9_EYR9>1oy{n09UbJjLD}Z6;#w zaa+B>w`~H{ye4|?>gM90ip^#xljRAy;8p;;lz*5fZ;^QTN%S|*`&)2^9a0ifmFwBl zt<9p@L2iAGVmgTwo;@QyMxtb#uaN`wU?PN4IKS>7*~KXz(M4Kjn&Gix&H8p@&04?@ z79w)T*oxm4{kJN;Pc_UO8tY4p4*VB|aa`HbT#CXPPt9`y})aXr~XR<2GY%CP^dIm_7-6VQf)&t*SE&r%=#avp@}vBEr% zA^#}^evwL1N-c6&LY@t^``>LsuPDVn{srIOiT z(=l#nlX{G^CdwH88v6_eKRDkh3+d}CDe3F~%q>y@nl%U-;06N8mwvvJfwr`-xeRbC zxiAo4;SyJs^3IEEB#$lUq3ldd?m}^lY4r~ zo?DzF>1cm*^XVSa8@Ax#nV>kkSeX7XP*^xH0Dx*8qQ?O4-K2bd@-pPr4ty;5oh}$# zBi#o`h5DmG^-!#F#)N@Zoe;My#LdND95IPo9ZR zGmJ#s07t>J0*}zm0xutBY4s9^P>mB7MXfqc;cWP{_;~_4X`K9CERu6(Fk&F;^=ISI zbN@9f!%FdZHNxm5XYh zbnY{!p4)F@N8Ss@+9EFglA~>ABYEFYlppn?VzOCihmMhUujw7Q-(VfWrk|VHi(Nx9 zX~n?TwbTh62Nvl~R-?&s{W=YpjRIb4({B&eQ!~Efj;}4-+Ag1;6J8M+J+Q>PSv;}Y z;sA3-#IIk;~lPwS?($n#>i#9i#;}l^~uEzVRI+x~1&JEBT!m`W=HFfZ? zg^GS}Z2ffke4H6M$NI7B>|1bF|JvnA01A;85Ap&@;bATgj~`m2S2M?w!5)ZZ#0s1Mx8kA=Ilrtqnc_xvt0q?+W8zvCpxf z`7f{1i9z(DuaI-PjddU4ipqIwlg**KU(Cq#<4>cTs(b;?O6EGt_O&^lJpvfw?i^61 znyRYaCN?IVlRnG#cV6KoSFtiO_xc6Xlou`ar5$AAPI$mzgbAYftc zfxz=*%YSJyYg|u`y`}7Ku&-Xb!zs+0HT?@kpZv~N*Sn`C`kcfG-Mg-uN1_|y+ryC2 zSQ975lqx-@yT;5Xm2Z%7@ixA`-nJu&c|IG@zhO!YpIu>mgBEcF2bGzA!Zt-t7xGdO zW}1T!WuMq|;8)QE&~`{+FV7xZseumg2qW#u7I{eaLCxU&={Fy9Uso)%OpC6SnX3S68cGx4iW6DDRbT(z1 zx5rM#AVz6qNHsxNSqjE0gMoTfl1z)yPc5t;uZ*I@lmmoxmkdFsgzx)a21w!ctUh!Q z2QKrYTLcc%t(^zA(^9}^BrqKQPNIs2ToT1{pKoy@d)!gaRyBU44FbEap+)+b#Er07 z6)8V?qs89uaTg_acrb{yj5_6@U#i48MAhB#WwC@K6tTd1R0zX~N|Mq$WnC+Xlw*b$ zGE4s=eVg3Usjg_+cW^8?0Bq{`9>p*T`}v$3?@;SPOyrt|KzFCqOIn&x4?A;oulvOQ{q$EEbHX#qrK;0(}~U-E3K z6Zg|*50+T}hRfeX*mD4mQcC3^JFhMM|Ca7sH)7mQVz$`>CwhE-$k(*~rN`$sVJF;K z{j=wbCXgLFt+zgzOwhFIb32@qWN^pbXyY5YSeDsjXdGn69gt6tjWW2WL`Z8k!x0Ge z)bl1@^yeA0UM#R+b{Fx!Q_JiDoJ``fVBQ!0eEVK-Q+U+kR|qwemN;A>SGa)gVwz2o zWOc|kOa%rC3hhf~gy(E?V>IzeQIvk?=U7j%rm6P96sv}#rs)Yv$cez}g~KE!Ke&rxcCz6OWVMdUM`G&ypdc#l5J?<_F4zh{zfroMHn*8RP z&_6OKP3?@MC~~HtWKp+q4kV^&nZ`Eve`wxS`Dpj2rZeh;$H<4Zuf}NynB&D-vn{R> zwicC`YT-WC>PUY}$l0E9CITyn=R7Q8FJ9mj(7SCMi1?hgX`#ajM03YfvIB%E!oWp8 zeMZnNzmq}lg%R*MS)ywN0Uv1<&~Fi3siup96a>M=)Nv}wxpB1;Iizg;zJ#SFA?SYl zQzIXPyeXac54Fi<3gbUxao>?~&}>RaM0Zs z<6cIc%Vr(z8D-Gm+^I4D*K=e2*yuKof9GP{b`_XZ1p|sb9+k3IQUe9*BL`PJDir|w z4~b)_pnIbP`RktQZckC^83 z_1tgm=$LalV_<0~=0-+*!PHb%JnNHIT)>fGhcY-9Z7x6<5NGs@=nyxfz@Pfy{ZmR# ziG8j`xi_*$Ca^_R@0VdjEzvDDc zidL%D04qZ}093JDhg^&@)>lNQvz>KxHL;Xq2H-JcGbhld)Bd0CTe`dBS9-;NX?!uW zr43oRV04q?54>tWhppZ`8?0~ki8`9Paymdzw}u z!=~!xXhs4i&?frA3SyQtv$C*sC1K?Pi)*_8|0YBnY#p3kNd75CGONfLx!90!vi}PS z%Kw+yYDvPz@?ZTXGde*jqL<~1Xifrwsr&lIyP%yAEN#}S<1x4Wp*T*|IG7|YFO}AAACvGo_i*ZFf`fqofw7HhmK$hV1{?a%>GMAuLUk z&ZT$*DosnTaxC7vF7dIeQua?W(-W;5G6twgYZK?bdG)2CVWO4Cq^XhPA5#H%nmSMV zkm&_mIED}U#0s#{?VUNUXWb?spnX7V`vO&Xma^_^&r7WruJJxQrom2rF~GEbVo3Y` zVd$^M+5g=!u#6@HIK*1$D=X-q`5IQHu9hVKu}2%$A^_v*JYm(%VJ!g~0K2!z@1u!=q+H?sUj$KLh|2mf^W6%zv!M!vr@OjNGcaEvo1d zc5{Qr@CsAoGOehPT4ZI0`PkLjy3=+%bN^gUG2EeY%EFbTRi(0I!Vd;H9|As-+})yI z(28(45Kj2OIL;evmRPK?aB`QaLcy5W3W2l!g_4yhhfdKvg5gTW9@v;4$xxS z74UyyZE4Se#nz()SB1T${RR*h8Z~Qd%8~s{+>S$SiPDJz5B;jg=*>6U$vRJn*sAn#Y#iQoowjZ?-LLbtsgYYRlsJQc3!`HkTJ_ z#Z;Dd=U-FgRJF&QOXMWrhFo5;ax6rf7j$aKeeF~m{>RGI0?b@)Mgib$l;D(Q%sMvo zUQ?VOr>*RLCtD9K4?g(1+&3^;Nj|| zwt|s3nUE4Xp~q_;*8_Sqa9LK%=fxyzcZ(l-o_6cNEfzQ*t1K+RR|o-!=GMa}RW!+a zaAj(}5{+h{)mO+gN095moIzv1pF~67W;~;?ufjla=W6OWpCZUB*Zh)aea=>`=ces$ z<68)Wa@LQ>pU_aw}zU1(_U7r|!++&Mt zxyNN%Wu-rM%RTx`d#NUxT6Pq@KG-jQchrfkG}(;CW6OKC7k)43REq%i<_#}R;KP!{ zhwVnF2o70ZP95&T0+?B9-?k0P+xa!YZ$V85~M$9KR_mju@H!Z!+ssTe06aPmC9`E{-H zcX~jTai&`RwEJ|8Ow@s5mK0r!M)+v)*hKSBy{bLmS#inDs{D$VyZNeO8FH4?sRg?2 z)%a28^d0uooHe24Eq?~m;>yrpEiR>$i}pMAhQGA&A;F1o ziM{Iw@rnS_y&|I2GFgRYaGX`B;@Zmm1AUrb|CX0l)V~VCQA*JCj(J1E@d#6`{PP*= zwL&CY>HH6Fx>_%_M7NYuW$dhttehO(*NbWzs#!-z@NT5{6-AoatWU#Y6g%E#tbF0{ zrvqC?$2uM<6bgGxR_W&@NYrTp1JatP1q@4ekAsq%zQ|2!YX8E`BIF{Vp^N-33oNj_weC| zJsIFP%IKo~rC9igw-Tm~)6aw@V0V@4V)!w28E28s4hECnPq%g|PSOIK*(DZv<5C$V zH3WO-^hc!}M*w;Mf`>5lM{Vd^n)zoE%CnC=$Pm^!;eaX_MG26Qz6g9};G9sPZH?!u z30|EsmAhhOS#FRf!w{8mfT3$YJ9dPzOew(VRidH&$>Ro;&`B;Z>}RF{5su^gxb{nV zrbz)$imenBryX|D5v+kgg`>FCWihs8_ky1gs@7zXi*$qov!gA|*k~>Fk9jS}h+-N; z7?&5{Yfbkw2#4J8?;nSsHkQ-c(w`U#b`39ra9vCMKoH9)0b=XM{?2RLAnj04JU8%2 zDiKR|LTZjIuMs8bv=N{8nbE}0!4J(wAnVQ5!*xddJ2ze75q_~u>$6=k{if(T(_{zA zn{mOm?e|-%ba%FEc+zPS4rB5fv3G|8%6)x7DQBJX0UgX^ikSUB-JBLWnR|qOK7f!nCBZvkXR#gyd zPLPn}$LJ0-+T`CJ2%SN>e$P|IW_s>tzKxcadLdX>ulw0?*zZ#ZucD0~zI35RJ+P#V w4t6@Oz#W95@&}J5(7+^a5IX#KOI=)zoLxPg&CC(l|H+Yq5GX0d6vPq!7cuG!1poj5 delta 24491 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 From 2057010e34a2703cc70dbcc6cea1a76110eef979 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 20 Jan 2025 12:15:57 +0100 Subject: [PATCH 09/47] required result field for device report --- resources/report/test_report_styles.css | 5 +---- resources/report/test_report_template.html | 24 ++++++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index d2ef769d6..1e3a2d62a 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -467,12 +467,9 @@ margin-top: 8px; padding: 4px 4px 7px 5px; border-radius: 2px; - left: 6.85in; - } - - .result-test-result-pilot{ left: 5.4in; } + .result-test-result-compliant { background-color: #E6F4EA; color: #137333; diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index c9d6e4311..794d954ee 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -84,17 +84,17 @@

Device Configuration

Results List ({{ successful_tests }}/{{ total_tests }})

Name
-
Description
-
Result
+
Description
+
Result
{% if is_pilot %} -
Required result
+
Required result
{% endif %}
{% for i in range(results_from, results_to) %}
{{ test_results[i]['name'] }}
-
{{ test_results[i]['description'] }}
-
{{ test_results[i]['description'] }}
+
{% elif test_results[i]['result'] == 'Compliant' %} @@ -110,14 +110,12 @@

Results List ({{ successful_tests }}/{{ tot {% endif %} {{ test_results[i]['result'] }}

{# Required resul badges #} - {% if is_pilot %} - {% if test_results[i]['required_result'] == "Required" %} -
{{ test_results[i]['required_result'] }}
- {% elif test_results[i]['required_result'] == "Required if Applicable" %} -
{{ test_results[i]['required_result'] }}
- {% else %} -
{{ test_results[i]['required_result'] }}
- {% endif %} + {% if test_results[i]['required_result'] == "Required" %} +
{{ test_results[i]['required_result'] }}
+ {% elif test_results[i]['required_result'] == "Required if Applicable" %} +
{{ test_results[i]['required_result'] }}
+ {% else %} +
{{ test_results[i]['required_result'] }}
{% endif %}
{% endfor %} From c05a2623e5bacba39ce499bdec60cabe80995662 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 20 Jan 2025 20:25:50 +0100 Subject: [PATCH 10/47] split steps to resolve to pages --- framework/python/src/common/testreport.py | 18 ++++++++-------- resources/report/test_report_template.html | 24 ++++++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index b7ba898b4..70df7a9fd 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -249,7 +249,7 @@ def to_html(self): pages_num = self._pages_num(json_data) total_pages = pages_num + len(module_reports) + 1 if len(steps_to_resolve) > 0: - total_pages += 1 + total_pages += len(steps_to_resolve) if (len(optional_steps_to_resolve) > 0 and json_data['device']['test_pack'] == 'Pilot Assessment' ): @@ -326,7 +326,7 @@ def _get_steps_to_resolve(self, json_data): if 'recommendations' in test: tests_with_recommendations.append(test) - return tests_with_recommendations + return self._split_steps_to_resolve_to_pages(tests_with_recommendations, 4, 4) def _get_optional_steps_to_resolve(self, json_data): tests_with_recommendations = [] @@ -336,20 +336,20 @@ def _get_optional_steps_to_resolve(self, json_data): if 'optional_recommendations' in test: tests_with_recommendations.append(test) - return self._split_steps_to_resolve_to_pages(tests_with_recommendations) + return self._split_steps_to_resolve_to_pages(tests_with_recommendations, 3, 4) - def _split_steps_to_resolve_to_pages(self, steps): + def _split_steps_to_resolve_to_pages(self, steps, start_page=4, page=4): # Split steps to resolve to pages. - # First page 3 steps, 4 steps on other pages. - if len(steps) < 3: + # First steps, steps on other pages. + if len(steps) < start_page: return [steps] splitted = [steps[:3]] - index = 3 + index = start_page while index < len(steps): - splitted.append(steps[index:index + 4]) - index += 4 + splitted.append(steps[index:index + page]) + index += page return splitted diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 794d954ee..1810fb6fe 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -11,6 +11,7 @@ {% set page_index = namespace(value=0) %} + {% set step_index = namespace(value=0) %} {% set opt_step_index = namespace(value=0) %} {# Test Results #} {% for page in range(pages_num) %} @@ -129,24 +130,28 @@

Results List ({{ successful_tests }}/{{ tot {% endfor %} {# Steps to resolve Device qualification #} {% if steps_to_resolve|length > 0 and not is_pilot %} - {% set page_index.value = page_index.value+1 %} + {% for step in steps_to_resolve%} + {% set page_index.value = page_index.value + 1 %}
{{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} -

Non-compliant tests and suggested steps to resolve

- {% for step in steps_to_resolve %} + {% if loop.first %} +

Non-compliant tests and suggested steps to resolve

+ {% endif %} + {% for line in step %} + {% set step_index.value = step_index.value + 1 %}
- {{ loop.index }}. + {{ step_index.value }}.
- Name
{{ step['name'] }} + Name
{{ line['name'] }}
- Description
{{ step["description"] }} + Description
{{ line["description"] }}
Steps to resolve - {% for recommedtation in step['recommendations'] %} + {% for recommedtation in line['recommendations'] %}
{{ loop.index }}. {{ recommedtation }} {% endfor %}
@@ -157,11 +162,12 @@

Non-compliant tests and suggested steps to resolve

+ {% endfor %} {% endif %} {# Pilot steps to resolve#} {% if is_pilot and optional_steps_to_resolve|length > 0 %} - {% for step in optional_steps_to_resolve%} - {% set page_index.value = page_index.value + 1 %} + {% for step in optional_steps_to_resolve%} + {% set page_index.value = page_index.value + 1 %}
{{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} {% if loop.first %} From a79887a4027d3a451c659c082057be5d368f33ee Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 20 Jan 2025 20:42:46 +0100 Subject: [PATCH 11/47] fix pylint --- framework/python/src/common/testreport.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 70df7a9fd..690ba0385 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -326,7 +326,8 @@ def _get_steps_to_resolve(self, json_data): if 'recommendations' in test: tests_with_recommendations.append(test) - return self._split_steps_to_resolve_to_pages(tests_with_recommendations, 4, 4) + return self._split_steps_to_resolve_to_pages( + tests_with_recommendations, 4, 4) def _get_optional_steps_to_resolve(self, json_data): tests_with_recommendations = [] @@ -336,7 +337,8 @@ def _get_optional_steps_to_resolve(self, json_data): if 'optional_recommendations' in test: tests_with_recommendations.append(test) - return self._split_steps_to_resolve_to_pages(tests_with_recommendations, 3, 4) + return self._split_steps_to_resolve_to_pages( + tests_with_recommendations, 3, 4) def _split_steps_to_resolve_to_pages(self, steps, start_page=4, page=4): # Split steps to resolve to pages. From d59f69edb1e13b14b02dddd11cee0757cccb185b Mon Sep 17 00:00:00 2001 From: kurilova Date: Tue, 21 Jan 2025 09:32:35 +0000 Subject: [PATCH 12/47] Adds icon for Required result; adds background for Non-Compliant and required test result --- resources/report/test_report_styles.css | 68 ++++++++++++++++++++++ resources/report/test_report_template.html | 17 +++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index 1e3a2d62a..96d256153 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -24,6 +24,13 @@ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + @font-face { + font-family: 'Material Symbols Outlined'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/icon/font?kit=kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejHdIa31RJq7xr9O779sC3DdMx&skey=b8dc2088854b122f&v=v222) format('woff2'); + } + /* Define some common body formatting*/ body { font-family: 'Google Sans', sans-serif; @@ -415,6 +422,10 @@ border-top: 0; } + .result-line-result-non-compliant-required { + background: #FCE8E6; + } + .result-list-header-label { position: absolute; font-size: 12px; @@ -480,6 +491,63 @@ color: #393939; } + .material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + text-align: center; + font-size: 12px; + display: inline-flex; + width: 14px; + height: 14px; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + border-radius: 100%; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; + } + + .result-test-required-result { + display: grid; + grid-template-columns: auto auto; + gap: 6px; + } + + .result-test-required-result-informational{ + color: #0D652D; + } + + .result-test-required-result-informational .material-symbols-outlined { + background: #0D652D; + color: #ffffff; + } + + .result-test-required-result-required { + color: #000000; + } + + .result-test-required-result-required .material-symbols-outlined { + background: #000000; + color: #ffffff; + } + + .result-test-required-result-required-if-applicable { + color: #174EA6; + } + + .result-test-required-result-required-if-applicable .material-symbols-outlined { + background: #174EA6; + color: #ffffff; + } + /* CSS for the footer */ .footer { position: absolute; diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 1810fb6fe..fcb3ce28a 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -92,6 +92,11 @@

Results List ({{ successful_tests }}/{{ tot {% endif %}

{% for i in range(results_from, results_to) %} + {% if test_results[i]['result'] == 'Non-Compliant' and test_results[i]['required_result'] == "Required" %} +
+ {% else %} +
+ {% endif %}
{{ test_results[i]['name'] }}
{{ test_results[i]['description'] }}
@@ -112,11 +117,17 @@

Results List ({{ successful_tests }}/{{ tot {{ test_results[i]['result'] }}

{# Required resul badges #} {% if test_results[i]['required_result'] == "Required" %} -
{{ test_results[i]['required_result'] }}
+
+ asterisk + {{ test_results[i]['required_result'] }}
{% elif test_results[i]['required_result'] == "Required if Applicable" %} -
{{ test_results[i]['required_result'] }}
+
+ asterisk + {{ test_results[i]['required_result'] }}
{% else %} -
{{ test_results[i]['required_result'] }}
+
+ info_i + {{ test_results[i]['required_result'] }}
{% endif %}
{% endfor %} From 060a2558eba095d5dd33c5105dd1f4bbd3f2a5df Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Tue, 21 Jan 2025 12:55:10 +0100 Subject: [PATCH 13/47] fix pages num --- framework/python/src/common/testreport.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 690ba0385..d572fdb61 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -248,14 +248,14 @@ def to_html(self): module_reports = self._get_module_pages() pages_num = self._pages_num(json_data) total_pages = pages_num + len(module_reports) + 1 - if len(steps_to_resolve) > 0: - total_pages += len(steps_to_resolve) - if (len(optional_steps_to_resolve) > 0 + is_pilot = json_data['device']['test_pack'] == 'Pilot Assessment' + if is_pilot and (len(optional_steps_to_resolve) > 0 and json_data['device']['test_pack'] == 'Pilot Assessment' ): total_pages += len(optional_steps_to_resolve) + elif len(steps_to_resolve) > 0: + total_pages += len(steps_to_resolve) - is_pilot = json_data['device']['test_pack'] == 'Pilot Assessment' return template.render(styles=styles, logo=logo, icon_qualification=icon_qualification, From 832004edf1b1c0b18e7ea79c8588bdb725a3b81e Mon Sep 17 00:00:00 2001 From: kurilova Date: Tue, 21 Jan 2025 12:13:18 +0000 Subject: [PATCH 14/47] Remove extra div --- resources/report/test_report_template.html | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index fcb3ce28a..408000aba 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -97,7 +97,6 @@

Results List ({{ successful_tests }}/{{ tot {% else %}
{% endif %} -
{{ test_results[i]['name'] }}
{{ test_results[i]['description'] }}
Results List ({{ successful_tests }}/{{ tot
Description
Result
{% if is_pilot %} -
Required result
+
Required result
{% endif %}
{% for i in range(results_from, results_to) %} @@ -116,17 +116,20 @@

Results List ({{ successful_tests }}/{{ tot {{ test_results[i]['result'] }}

{# Required resul badges #} {% if test_results[i]['required_result'] == "Required" %} -
+
asterisk - {{ test_results[i]['required_result'] }}
+ {{ test_results[i]['required_result'] }} +
{% elif test_results[i]['required_result'] == "Required if Applicable" %} -
+
asterisk - {{ test_results[i]['required_result'] }}
+ {{ test_results[i]['required_result'] }} +
{% else %} -
+
info_i - {{ test_results[i]['required_result'] }}
+ {{ test_results[i]['required_result'] }} +
{% endif %}
{% endfor %} From f8556e991c26303b6c4ef72251da93fe6a1ac51c Mon Sep 17 00:00:00 2001 From: Volha Mardvilka Date: Wed, 22 Jan 2025 10:52:41 +0000 Subject: [PATCH 20/47] change distance for required result --- resources/report/test_report_styles.css | 2 +- resources/report/test_report_template.html | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index 29481b80d..1826ff635 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -515,13 +515,13 @@ -webkit-font-feature-settings: 'liga'; -webkit-font-smoothing: antialiased; vertical-align: bottom; - margin-right: 6px; } .result-test-required-result { display: flex; align-items: center; overflow: visible; + padding-left: 6px; } .result-test-required-result-text { diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 749befaf9..5977ab25d 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -88,7 +88,7 @@

Results List ({{ successful_tests }}/{{ tot
Description
Result
{% if is_pilot %} -
Required result
+
Required result
{% endif %}

{% for i in range(results_from, results_to) %} @@ -116,17 +116,17 @@

Results List ({{ successful_tests }}/{{ tot {{ test_results[i]['result'] }}

{# Required resul badges #} {% if test_results[i]['required_result'] == "Required" %} -
+
asterisk {{ test_results[i]['required_result'] }}
{% elif test_results[i]['required_result'] == "Required if Applicable" %} -
+
asterisk {{ test_results[i]['required_result'] }}
{% else %} -
+
info_i {{ test_results[i]['required_result'] }}
From e63eaa4211d336daa0ceee3a69e51b3749664fa7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 22 Jan 2025 12:16:57 +0100 Subject: [PATCH 21/47] fix device qualification required result column title --- resources/report/test_report_template.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 5977ab25d..19765f894 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -87,9 +87,7 @@

Results List ({{ successful_tests }}/{{ tot
Name
Description
Result
- {% if is_pilot %}
Required result
- {% endif %}

{% for i in range(results_from, results_to) %} {% if test_results[i]['result'] == 'Non-Compliant' and test_results[i]['required_result'] == "Required" %} @@ -125,11 +123,15 @@

Results List ({{ successful_tests }}/{{ tot asterisk {{ test_results[i]['required_result'] }}

- {% else %} + {% elif test_results[i]['required_result'] == "Informational" %}
info_i {{ test_results[i]['required_result'] }}
+ {% else %} +
+ {{ test_results[i]['required_result'] }} +
{% endif %}
{% endfor %} From 164830d6f29e7224455090bd59c7233edc0bd941 Mon Sep 17 00:00:00 2001 From: Volha Mardvilka Date: Wed, 22 Jan 2025 11:28:12 +0000 Subject: [PATCH 22/47] fix distance for required result --- resources/report/test_report_styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index 1826ff635..5bd30fa10 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -521,12 +521,12 @@ display: flex; align-items: center; overflow: visible; - padding-left: 6px; } .result-test-required-result-text { line-height: 14px; flex: 0 0 120px; + padding-left: 6px; } .result-test-required-result-informational{ From f7b1175531f8be695be7f197fba8de188b6fd95c Mon Sep 17 00:00:00 2001 From: Volha Mardvilka Date: Wed, 22 Jan 2025 11:46:17 +0000 Subject: [PATCH 23/47] change padding for required result --- resources/report/test_report_styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css index 5bd30fa10..f0f8d45e1 100644 --- a/resources/report/test_report_styles.css +++ b/resources/report/test_report_styles.css @@ -526,7 +526,7 @@ .result-test-required-result-text { line-height: 14px; flex: 0 0 120px; - padding-left: 6px; + padding-left: 8px; } .result-test-required-result-informational{ From 7d64a43c74a6e558635380754f4c35f68d25a757 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 24 Jan 2025 18:29:26 +0100 Subject: [PATCH 24/47] fix device edit after pilot testrun --- framework/python/src/core/session.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index 144b333f4..fd67dd0a7 100644 --- a/framework/python/src/core/session.py +++ b/framework/python/src/core/session.py @@ -387,7 +387,12 @@ def get_status(self): return self._status def set_status(self, status): - self._status = status + if status == TestResult.PROCEED: + self._status = TestrunStatus.COMPLIANT + elif status == TestResult.DO_NOT_PROCEED: + self._status = TestrunStatus.NON_COMPLIANT + else: + self._status = status def set_description(self, desc: str): self._description = desc From 6474ece58f6ba59ad5d0c9c536690a1f0ac26e6d Mon Sep 17 00:00:00 2001 From: J Boddey Date: Mon, 27 Jan 2025 10:26:47 +0000 Subject: [PATCH 25/47] Generate pilot reports in unit testing (#1076) --- .../unit/report/report_compliant_pilot.json | 297 +++++++++++++++ .../report/report_noncompliant_pilot.json | 345 ++++++++++++++++++ testing/unit/report/report_test.py | 19 + 3 files changed, 661 insertions(+) create mode 100644 testing/unit/report/report_compliant_pilot.json create mode 100644 testing/unit/report/report_noncompliant_pilot.json diff --git a/testing/unit/report/report_compliant_pilot.json b/testing/unit/report/report_compliant_pilot.json new file mode 100644 index 000000000..54eb39262 --- /dev/null +++ b/testing/unit/report/report_compliant_pilot.json @@ -0,0 +1,297 @@ +{ + "device": { + "mac_addr": "aa:bb:cc:dd:ee:ff", + "manufacturer": "Testrun", + "model": "Faux", + "firmware": "1.0.0", + "type": "Controller - FCU", + "technology": "Hardware - Fitness", + "test_pack": "Pilot Assessment", + "test_modules": { + "connection": { + "enabled": true + }, + "ntp": { + "enabled": true + }, + "dns": { + "enabled": true + }, + "services": { + "enabled": true + }, + "tls": { + "enabled": true + }, + "protocol": { + "enabled": true + } + }, + "additional_info": [ + { + "question": "What type of device is this?", + "answer": "Controller - FCU" + }, + { + "question": "Please select the technology this device falls into", + "answer": "Hardware - Fitness" + }, + { + "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": "No" + }, + { + "question": "Can the second IP port on your device be disabled?", + "answer": "N/A" + } + ] + }, + "status": "Proceed", + "started": "2024-04-10 21:21:47", + "finished": "2024-04-10 21:35:43", + "tests": { + "total": 33, + "results": [ + { + "name": "protocol.valid_bacnet", + "description": "Device IP matches discovered device", + "expected_behavior": "BACnet traffic can be seen on the network and packets are valid and not malformed", + "required_result": "Recommended", + "result": "Compliant" + }, + { + "name": "protocol.bacnet.version", + "description": "BACnet protocol version detected: 1.15", + "expected_behavior": "The BACnet client implements an up to date version of BACnet", + "required_result": "Recommended", + "result": "Compliant" + }, + { + "name": "protocol.valid_modbus", + "description": "Established connection to modbus port: 502\nHolding registers succesfully read: 0:5\nInput registers succesfully read: 0:5\nCoil registers succesfully read: 0:1\nDiscrete inputs succesfully read: 0:1", + "expected_behavior": "Any Modbus functionality works as expected and valid Modbus traffic can be observed", + "required_result": "Recommended", + "result": "Compliant" + }, + { + "name": "security.tls.v1_2_server", + "description": "TLS 1.2 validated: Certificate has a valid time range\nRSA key length passed: 2048 >= 2048\nDevice signed by cert:/testrun/root_certs/myrootca4132.pem\nTLS 1.3 validated: Certificate has a valid time range\nRSA key length passed: 2048 >= 2048\nDevice signed by cert:/testrun/root_certs/myrootca4132.pem", + "expected_behavior": "TLS 1.2 certificate is issued to the web browser client when accessed", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.tls.v1_2_client", + "description": "TLS 1.2 client connections valid", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.2 and support for ECDH and ECDSA ciphers", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.switch.arp_inspection", + "description": "Device uses ARP correctly", + "expected_behavior": "Device continues to operate correctly when ARP inspection is enabled on the switch. No functionality is lost with ARP inspection enabled.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.switch.dhcp_snooping", + "description": "Device does not act as a DHCP server", + "expected_behavior": "Device continues to operate correctly when DHCP snooping is enabled on the switch.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.dhcp_address", + "description": "Device responded to leased ip address", + "expected_behavior": "The device is not setup with a static IP address. The device accepts an IP address from a DHCP server (RFC 2131) and responds succesfully to an ICMP echo (ping) request.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.mac_address", + "description": "MAC address found: aa:bb:cc:dd:ee:ff", + "expected_behavior": "N/A", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.mac_oui", + "description": "OUI Manufacturer found: Texas Instruments", + "expected_behavior": "The MAC address prefix is registered in the IEEE Organizationally Unique Identifier database.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.private_address", + "description": "All subnets are supported", + "expected_behavior": "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_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.shared_address", + "description": "All subnets are supported", + "expected_behavior": "The device under test accepts IP addresses within the ranges specified in RFC 6598 and communicates using these addresses", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.single_ip", + "description": "Device is using a single IP address", + "expected_behavior": "The device under test does not behave as a network switch and only requets 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_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.target_ping", + "description": "Device responds to ping", + "expected_behavior": "The device under test responds to an ICMP echo (ping) request.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.ipaddr.ip_change", + "description": "Device has accepted an IP address change", + "expected_behavior": "If the lease expires before the client receiveds a DHCPACK, the client moves to 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 addres, 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_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.ipaddr.dhcp_failover", + "description": "Secondary DHCP server lease confirmed active in device", + "expected_behavior": "", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.ipv6_slaac", + "description": "Device has formed SLAAC address fd10:77be:4186:0:1a62:e4ff:fed7:430a", + "expected_behavior": "The device under test complies with RFC4862 and forms a valid IPv6 SLAAC address", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.ipv6_ping", + "description": "Device responds to IPv6 ping on fd10:77be:4186:0:1a62:e4ff:fed7:430a", + "expected_behavior": "The device responds to the ping as per RFC4443", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "dns.network.hostname_resolution", + "description": "DNS traffic detected from device", + "expected_behavior": "The device sends DNS requests.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "dns.network.from_dhcp", + "description": "DNS traffic detected only to DHCP provided server", + "expected_behavior": "The device sends DNS requests to the DNS server provided by the DHCP server", + "required_result": "Roadmap", + "result": "Compliant" + }, + { + "name": "security.services.ftp", + "description": "No FTP server found", + "expected_behavior": "There is no FTP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.ssh.version", + "description": "No SSH server found", + "expected_behavior": "SSH server is not running or server is SSHv2", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.telnet", + "description": "No telnet server found", + "expected_behavior": "There is no Telnet service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.smtp", + "description": "No SMTP server found", + "expected_behavior": "There is no SMTP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.http", + "description": "No HTTP server found", + "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)", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.pop", + "description": "No POP server found", + "expected_behavior": "There is no POP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.imap", + "description": "No IMAP server found", + "expected_behavior": "There is no IMAP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.snmpv3", + "description": "No SNMP server found", + "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.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.vnc", + "description": "No VNC server found", + "expected_behavior": "Device cannot be accessed / connected to via VNC on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.tftp", + "description": "No TFTP server found", + "expected_behavior": "There is no TFTP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "ntp.network.ntp_server", + "description": "No NTP server found", + "expected_behavior": "The device does not respond to NTP requests when it's IP is set as the NTP server on another device", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "ntp.network.ntp_support", + "description": "Device sent NTPv4 packets.", + "expected_behavior": "The device sends an NTPv4 request to the configured NTP server.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "ntp.network.ntp_dhcp", + "description": "Device sent NTP request to non-DHCP provided server", + "expected_behavior": "Device can accept NTP server address, provided by the DHCP server (DHCP OFFER PACKET)", + "required_result": "Roadmap", + "result": "Non-Compliant" + } + ] + }, + "report": "http://localhost:8000/report/Testrun Faux/2024-04-10T21:21:47" + } \ No newline at end of file diff --git a/testing/unit/report/report_noncompliant_pilot.json b/testing/unit/report/report_noncompliant_pilot.json new file mode 100644 index 000000000..7d36c9dcc --- /dev/null +++ b/testing/unit/report/report_noncompliant_pilot.json @@ -0,0 +1,345 @@ +{ + "device": { + "mac_addr": "aa:bb:cc:dd:ee:ff", + "manufacturer": "Testrun", + "model": "Faux", + "firmware": "1.0.0", + "type": "Controller - FCU", + "technology": "Hardware - Fitness", + "test_pack": "Pilot Assessment", + "test_modules": { + "connection": { + "enabled": true + }, + "ntp": { + "enabled": true + }, + "dns": { + "enabled": true + }, + "services": { + "enabled": true + }, + "tls": { + "enabled": true + }, + "protocol": { + "enabled": true + } + }, + "additional_info": [ + { + "question": "What type of device is this?", + "answer": "Controller - FCU" + }, + { + "question": "Please select the technology this device falls into", + "answer": "Hardware - Fitness" + }, + { + "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": "No" + }, + { + "question": "Can the second IP port on your device be disabled?", + "answer": "N/A" + } + ] + }, + "status": "Do Not Proceed", + "started": "2024-04-10 21:21:47", + "finished": "2024-04-10 21:35:43", + "tests": { + "total": 33, + "results": [ + { + "name": "protocol.valid_bacnet", + "description": "Device IP matches discovered device", + "expected_behavior": "BACnet traffic can be seen on the network and packets are valid and not malformed", + "required_result": "Recommended", + "result": "Compliant" + }, + { + "name": "protocol.bacnet.version", + "description": "BACnet protocol version detected: 1.15", + "expected_behavior": "The BACnet client implements an up to date version of BACnet", + "required_result": "Recommended", + "result": "Compliant" + }, + { + "name": "protocol.valid_modbus", + "description": "Established connection to modbus port: 502\nHolding registers succesfully read: 0:5\nInput registers succesfully read: 0:5\nCoil registers succesfully read: 0:1\nDiscrete inputs succesfully read: 0:1", + "expected_behavior": "Any Modbus functionality works as expected and valid Modbus traffic can be observed", + "required_result": "Recommended", + "result": "Compliant" + }, + { + "name": "security.tls.v1_2_server", + "description": "TLS 1.2 certificate could not be validated", + "expected_behavior": "TLS 1.2 certificate is issued to the web browser client when accessed", + "required_result": "Required", + "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": "TLS 1.2 client connections invalid", + "expected_behavior": "The packet indicates a TLS connection with at least TLS 1.2 and support for ECDH and ECDSA ciphers", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Disable connections to unsecure services", + "Ensure any URLs connected to are secure (https)" + ] + }, + { + "name": "connection.switch.arp_inspection", + "description": "Device uses ARP correctly", + "expected_behavior": "Device continues to operate correctly when ARP inspection is enabled on the switch. No functionality is lost with ARP inspection enabled.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.switch.dhcp_snooping", + "description": "Device does not act as a DHCP server", + "expected_behavior": "Device continues to operate correctly when DHCP snooping is enabled on the switch.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "connection.dhcp_address", + "description": "No DHCP lease could be found", + "expected_behavior": "The device is not setup with a static IP address. The device accepts an IP address from a DHCP server (RFC 2131) and responds succesfully to an ICMP echo (ping) request.", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Enable DHCP", + "Install a DHCP client" + ] + }, + { + "name": "connection.mac_address", + "description": "No MAC address found", + "expected_behavior": "N/A", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Ensure that the MAC address is set by hardware only" + ] + }, + { + "name": "connection.mac_oui", + "description": "OUI Manufacturer found: Texas Instruments", + "expected_behavior": "The MAC address prefix is registered in the IEEE Organizationally Unique Identifier database.", + "required_result": "Required", + "result": "Compliant", + "recommendations": [ + "No OUI Manufacturer found for aa:bb:cc:dd:ee:ff" + ] + }, + { + "name": "connection.private_address", + "description": "Subnet 10.0.0.100-10.0.0.200 failed", + "expected_behavior": "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_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Install a DHCP client that supports private address space" + ] + }, + { + "name": "connection.shared_address", + "description": "Subnet 100.64.0.1-100.64.255.254 failed", + "expected_behavior": "The device under test accepts IP addresses within the ranges specified in RFC 6598 and communicates using these addresses", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Install a DHCP client that supports shared address space", + "Enable shared address space support in the DHCP client" + ] + }, + { + "name": "connection.single_ip", + "description": "Device is using multiple IP addresses", + "expected_behavior": "The device under test does not behave as a network switch and only requets 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_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Ensure that all ports on the device are isolated", + "Ensure only one DHCP client is running" + ] + }, + { + "name": "connection.target_ping", + "description": "Device does not respond to ping", + "expected_behavior": "The device under test responds to an ICMP echo (ping) request.", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Configure device to allow ICMP requests (ping)", + "Create a firewall exception to allow ICMP requests from LAN" + ] + }, + { + "name": "connection.ipaddr.ip_change", + "description": "Device did not accept IP address change", + "expected_behavior": "If the lease expires before the client receiveds a DHCPACK, the client moves to 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 addres, 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_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Install a compliant DHCP client" + ] + }, + { + "name": "connection.ipaddr.dhcp_failover", + "description": "Device did not recieve a new lease from secondary DHCP server'", + "expected_behavior": "", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Install a compliant DHCP client" + ] + }, + { + "name": "connection.ipv6_slaac", + "description": "Device does not support IPv6 SLAAC", + "expected_behavior": "The device under test complies with RFC4862 and forms a valid IPv6 SLAAC address", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Install a network manager that supports IPv6", + "Disable DHCPv6" + ] + }, + { + "name": "connection.ipv6_ping", + "description": "No IPv6 SLAAC address found. Cannot ping", + "expected_behavior": "The device responds to the ping as per RFC4443", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "dns.network.hostname_resolution", + "description": "DNS traffic detected from device", + "expected_behavior": "The device sends DNS requests.", + "required_result": "Required", + "result": "Non-Compliant", + "recommendations": [ + "Enable ping response to IPv6 ICMP requests in network manager settings", + "Create a firewall exception to allow ICMPv6 via LAN" + ] + }, + { + "name": "dns.network.from_dhcp", + "description": "DNS traffic detected only to DHCP provided server", + "expected_behavior": "The device sends DNS requests to the DNS server provided by the DHCP server", + "required_result": "Roadmap", + "result": "Compliant" + }, + { + "name": "security.services.ftp", + "description": "No FTP server found", + "expected_behavior": "There is no FTP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.ssh.version", + "description": "No SSH server found", + "expected_behavior": "SSH server is not running or server is SSHv2", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.telnet", + "description": "No telnet server found", + "expected_behavior": "There is no Telnet service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.smtp", + "description": "No SMTP server found", + "expected_behavior": "There is no SMTP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.http", + "description": "No HTTP server found", + "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)", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.pop", + "description": "No POP server found", + "expected_behavior": "There is no POP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.imap", + "description": "No IMAP server found", + "expected_behavior": "There is no IMAP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.snmpv3", + "description": "No SNMP server found", + "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.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.vnc", + "description": "No VNC server found", + "expected_behavior": "Device cannot be accessed / connected to via VNC on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "security.services.tftp", + "description": "No TFTP server found", + "expected_behavior": "There is no TFTP service running on any port", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "ntp.network.ntp_server", + "description": "No NTP server found", + "expected_behavior": "The device does not respond to NTP requests when it's IP is set as the NTP server on another device", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "ntp.network.ntp_support", + "description": "Device sent NTPv4 packets.", + "expected_behavior": "The device sends an NTPv4 request to the configured NTP server.", + "required_result": "Required", + "result": "Compliant" + }, + { + "name": "ntp.network.ntp_dhcp", + "description": "Device sent NTP request to non-DHCP provided server", + "expected_behavior": "Device can accept NTP server address, provided by the DHCP server (DHCP OFFER PACKET)", + "required_result": "Roadmap", + "result": "Non-Compliant" + } + ] + }, + "report": "http://localhost:8000/report/Testrun Faux/2024-04-10T21:21:47" + } \ No newline at end of file diff --git a/testing/unit/report/report_test.py b/testing/unit/report/report_test.py index f706059b6..48a815114 100644 --- a/testing/unit/report/report_test.py +++ b/testing/unit/report/report_test.py @@ -87,12 +87,29 @@ def report_compliant_test(self): # Generate a compliant report based on the 'report_compliant.json' file self.create_report(os.path.join(TEST_FILES_DIR, 'report_compliant.json')) + def report_compliant_pilot_test(self): + """Generate a report for the compliant test with a pilot device""" + + # Generate a compliant report based on the + # 'report_compliant_pilot.json' file + self.create_report(os.path.join(TEST_FILES_DIR, + 'report_compliant_pilot.json')) + def report_noncompliant_test(self): """Generate a report for the non-compliant test""" # Generate non-compliant report based on the 'report_noncompliant.json' file self.create_report(os.path.join(TEST_FILES_DIR, 'report_noncompliant.json')) + + def report_noncompliant_pilot_test(self): + """Generate a report for the non-compliant test with a pilot device""" + + # Generate a compliant report based on the + # 'report_noncompliant_pilot.json' file + self.create_report(os.path.join(TEST_FILES_DIR, + 'report_noncompliant_pilot.json')) + # Generate formatted reports for each report generated from # the test containers. # Not a unit test but can't run from within the test module container and must @@ -194,6 +211,8 @@ def get_module_html_report(self, module): suite = unittest.TestSuite() suite.addTest(ReportTest('report_compliant_test')) suite.addTest(ReportTest('report_noncompliant_test')) + suite.addTest(ReportTest('report_compliant_pilot_test')) + suite.addTest(ReportTest('report_noncompliant_pilot_test')) # Create html test reports for each module in 'output' dir suite.addTest(ReportTest('report_formatting')) From 43f710988f79fc57e52f5031fb17da0ea27ec98f Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 27 Jan 2025 10:36:40 +0000 Subject: [PATCH 26/47] Report proceed status --- framework/python/src/api/api.py | 8 +++-- framework/python/src/common/statuses.py | 4 +-- framework/python/src/core/session.py | 7 +---- .../python/src/test_orc/test_orchestrator.py | 29 ++++++++++--------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 5799fd478..e2e265500 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -524,7 +524,9 @@ async def delete_device(self, request: Request, response: Response): and self._session.get_status() not in [TestrunStatus.CANCELLED, TestrunStatus.COMPLIANT, - TestrunStatus.NON_COMPLIANT + TestrunStatus.NON_COMPLIANT, + TestrunStatus.PROCEED, + TestrunStatus.DO_NOT_PROCEED ]): response.status_code = 403 return self._generate_msg( @@ -647,7 +649,9 @@ async def edit_device(self, request: Request, response: Response): and self._session.get_status() not in [TestrunStatus.CANCELLED, TestrunStatus.COMPLIANT, - TestrunStatus.NON_COMPLIANT + TestrunStatus.NON_COMPLIANT, + TestrunStatus.PROCEED, + TestrunStatus.DO_NOT_PROCEED ]): response.status_code = 403 return self._generate_msg( diff --git a/framework/python/src/common/statuses.py b/framework/python/src/common/statuses.py index cca43586a..c71102be4 100644 --- a/framework/python/src/common/statuses.py +++ b/framework/python/src/common/statuses.py @@ -26,6 +26,8 @@ class TestrunStatus: NON_COMPLIANT = "Non-Compliant" STOPPING = "Stopping" VALIDATING = "Validating Network" + PROCEED = "Proceed" + DO_NOT_PROCEED = "Do Not Proceed" class TestResult: @@ -33,8 +35,6 @@ class TestResult: IN_PROGRESS = "In Progress" COMPLIANT = "Compliant" NON_COMPLIANT = "Non-Compliant" - PROCEED = "Proceed" - DO_NOT_PROCEED = "Do Not Proceed" ERROR = "Error" FEATURE_NOT_DETECTED = "Feature Not Detected" INFORMATIONAL = "Informational" diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index fd67dd0a7..144b333f4 100644 --- a/framework/python/src/core/session.py +++ b/framework/python/src/core/session.py @@ -387,12 +387,7 @@ def get_status(self): return self._status def set_status(self, status): - if status == TestResult.PROCEED: - self._status = TestrunStatus.COMPLIANT - elif status == TestResult.DO_NOT_PROCEED: - self._status = TestrunStatus.NON_COMPLIANT - else: - self._status = status + self._status = status def set_description(self, desc: str): self._description = desc diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 5270ccd54..6eb30d2c0 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -189,9 +189,11 @@ def run_test_modules(self): # Default message is empty (better than an error message). # This should never be shown message: str = "" - if report.get_status() == TestrunStatus.COMPLIANT: + if report.get_status() in [TestrunStatus.COMPLIANT, + TestrunStatus.PROCEED]: message = test_pack.get_message("compliant_description") - elif report.get_status() == TestrunStatus.NON_COMPLIANT: + elif report.get_status() in [TestrunStatus.NON_COMPLIANT, + TestrunStatus.DO_NOT_PROCEED]: message = test_pack.get_message("non_compliant_description") self.get_session().set_description(message) @@ -250,7 +252,8 @@ def _generate_report(self): return report def _calculate_result(self): - result = TestResult.COMPLIANT + result = TestrunStatus.COMPLIANT + for test_result in self.get_session().get_test_results(): # Check Required tests @@ -259,20 +262,20 @@ def _calculate_result(self): TestResult.COMPLIANT, TestResult.ERROR ]): - result = TestResult.NON_COMPLIANT + result = TestrunStatus.NON_COMPLIANT # Check Required if Applicable tests elif (test_result.required_result.lower() == "required if applicable" and test_result.result == TestResult.NON_COMPLIANT): - result = TestResult.NON_COMPLIANT - - # Change the result if pilot assessment used - if (self.get_session().get_target_device().test_pack == - "Pilot Assessment"): - if result == TestResult.COMPLIANT: - result = TestResult.PROCEED - elif result == TestResult.NON_COMPLIANT: - result = TestResult.DO_NOT_PROCEED + result = TestrunStatus.NON_COMPLIANT + + # Change the result if pilot assessment used + if (self.get_session().get_target_device().test_pack == + "Pilot Assessment"): + if result == TestrunStatus.COMPLIANT: + result = TestrunStatus.PROCEED + elif result == TestrunStatus.NON_COMPLIANT: + result = TestrunStatus.DO_NOT_PROCEED return result From f235c0b0f2ba69e11398b63b9a1e095aec6061df Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 27 Jan 2025 10:55:10 +0000 Subject: [PATCH 27/47] Update message --- framework/python/src/api/api.py | 4 ++-- resources/report/test_report_template.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index e2e265500..3efe8dc0f 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -530,7 +530,7 @@ async def delete_device(self, request: Request, response: Response): ]): response.status_code = 403 return self._generate_msg( - False, "Cannot delete this device whilst " + "it is being tested") + False, "Cannot delete this device whilst it is being tested") # Delete device self._testrun.delete_device(device) @@ -544,7 +544,7 @@ async def delete_device(self, request: Request, response: Response): LOGGER.error(e) response.status_code = 500 return self._generate_msg( - False, "An error occured whilst deleting " + "the device") + False, "An error occured whilst deleting the device") async def save_device(self, request: Request, response: Response): LOGGER.debug("Received device post request") diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 19765f894..795f51260 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -191,8 +191,8 @@

Recommendations for Device Qualification

Attention

The following recommendations are required solely for full device qualification. - They are optional for the pilot assessment. - But you may find it valuable to understand what will be required in the future + They are optional for the pilot assessment + but you may find it valuable to understand what will be required in the future and our recommendations for your device.

From 4d1c656786025a02b4511c56c357e8648a4e0ef1 Mon Sep 17 00:00:00 2001 From: Olga Mardvilko Date: Tue, 28 Jan 2025 19:58:12 +0300 Subject: [PATCH 28/47] 392112885: (feat) [2.1.1 release] rename results modals for Pilot (#1085) * 392112885: (feat) [2.1.1 release] rename results modals for Pilot * 392112885: (feat) [2.1.1 release] rename results modals change word --- resources/test_packs/pilot.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/test_packs/pilot.json b/resources/test_packs/pilot.json index 587d0a25a..79f6a4f1c 100644 --- a/resources/test_packs/pilot.json +++ b/resources/test_packs/pilot.json @@ -1,8 +1,8 @@ { "name": "Pilot Assessment", "language": { - "compliant_description": "Your device has met the initial pilot assessment requirements. Please send your Testrun ZIP file to the qualification lab for verification. The lab will then contact you with further instructions.", - "non_compliant_description": "Your device didn't quite meet the initial pilot assessment requirements. The Testrun report will provide guidance on how to resolve any issues. If you require further support, please get in touch with the qualification lab." + "compliant_description": "This is a preliminary test result. Please attach the Risk Profile and download the test results. A full evaluation and recommendation will be provided by the lab team.", + "non_compliant_description": "This is a preliminary test result. Please attach the Risk Profile and download the test results. A full evaluation and recommendation will be provided by the lab team." }, "tests": [ { From 5e9e56b7044dbc91262aa220d6aebfddc500142f Mon Sep 17 00:00:00 2001 From: kurilova Date: Tue, 28 Jan 2025 11:51:11 +0000 Subject: [PATCH 29/47] Adds column order in testing table; adds missing icons for statuses --- .../testrun-table.component.html | 6 ++-- .../testrun-table.component.scss | 36 ++++++++++++++++--- modules/ui/src/index.html | 4 +-- resources/report/test_report_styles.css | 24 +++++++++---- resources/report/test_report_template.html | 10 ++++++ 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/modules/ui/src/app/pages/testrun/components/testrun-table/testrun-table.component.html b/modules/ui/src/app/pages/testrun/components/testrun-table/testrun-table.component.html index 5cc7e479f..52105603e 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-table/testrun-table.component.html +++ b/modules/ui/src/app/pages/testrun/components/testrun-table/testrun-table.component.html @@ -16,9 +16,9 @@
- Result Name Description + Result Required result
+ {% elif test_results[i]['required_result'] == "Roadmap" %} +
+ road + {{ test_results[i]['required_result'] }} +
+ {% elif test_results[i]['required_result'] == "Recommended" %} + {% else %}
{{ test_results[i]['required_result'] }} From 03c9f14956e60b97eb60c3047d395ec84ddc25a9 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 30 Jan 2025 17:37:25 +0000 Subject: [PATCH 30/47] Update result and status --- framework/python/src/common/statuses.py | 7 +- framework/python/src/core/session.py | 18 +- .../python/src/test_orc/test_orchestrator.py | 42 ++- .../{pilot.json => pilot/config.json} | 80 +++--- .../test_packs/pilot/report_template.html | 244 ++++++++++++++++++ resources/test_packs/pilot/test_pack.py | 0 .../config.json} | 0 .../qualification/report_template.html | 228 ++++++++++++++++ 8 files changed, 567 insertions(+), 52 deletions(-) rename resources/test_packs/{pilot.json => pilot/config.json} (59%) create mode 100644 resources/test_packs/pilot/report_template.html create mode 100644 resources/test_packs/pilot/test_pack.py rename resources/test_packs/{qualification.json => qualification/config.json} (100%) create mode 100644 resources/test_packs/qualification/report_template.html diff --git a/framework/python/src/common/statuses.py b/framework/python/src/common/statuses.py index c71102be4..33516390e 100644 --- a/framework/python/src/common/statuses.py +++ b/framework/python/src/common/statuses.py @@ -22,13 +22,16 @@ class TestrunStatus: MONITORING = "Monitoring" IN_PROGRESS = "In Progress" CANCELLED = "Cancelled" - COMPLIANT = "Compliant" - NON_COMPLIANT = "Non-Compliant" STOPPING = "Stopping" VALIDATING = "Validating Network" + COMPLETE = "Complete" PROCEED = "Proceed" DO_NOT_PROCEED = "Do Not Proceed" +class TestrunResult: + """Statuses for the Testrun result""" + COMPLIANT = "Compliant" + NON_COMPLIANT = "Non-Compliant" class TestResult: """Statuses for test results""" diff --git a/framework/python/src/core/session.py b/framework/python/src/core/session.py index 144b333f4..b6bcabd12 100644 --- a/framework/python/src/core/session.py +++ b/framework/python/src/core/session.py @@ -20,7 +20,7 @@ from fastapi.encoders import jsonable_encoder from common import util, logger, mqtt from common.risk_profile import RiskProfile -from common.statuses import TestrunStatus, TestResult +from common.statuses import TestrunStatus, TestResult, TestrunResult from net_orc.ip_control import IPControl # Certificate dependencies @@ -85,6 +85,7 @@ def __init__(self, root_dir): self._root_dir = root_dir self._status = TestrunStatus.IDLE + self._result = None self._description = None # Target test device @@ -130,6 +131,7 @@ def __init__(self, root_dir): # System network interfaces self._ifaces = {} + # Loading methods self._load_version() self._load_config() @@ -383,12 +385,18 @@ def get_ipv4_subnet(self): def get_ipv6_subnet(self): return self._ipv6_subnet - def get_status(self): + def get_status(self) -> TestrunStatus: return self._status - def set_status(self, status): + def set_status(self, status: TestrunStatus): self._status = status + def get_result(self) -> TestrunResult: + return self._result + + def set_result(self, result: TestrunResult): + self._result = result + def set_description(self, desc: str): self._description = desc @@ -795,6 +803,7 @@ def delete_profile(self, profile): def reset(self): self.set_status(TestrunStatus.IDLE) + self.set_result(None) self.set_description(None) self.set_target_device(None) self._report_url = None @@ -825,6 +834,9 @@ def to_json(self): 'tests': results } + if self.get_result() is not None: + session_json['result'] = self.get_result() + if self._report_url is not None: session_json['report'] = self.get_report_url() diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 6eb30d2c0..9807202fd 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -22,7 +22,7 @@ from datetime import datetime from common import logger, util from common.testreport import TestReport -from common.statuses import TestrunStatus, TestResult +from common.statuses import TestrunStatus, TestrunResult, TestResult from core.docker.test_docker_module import TestModule from test_orc.test_case import TestCase from test_orc.test_pack import TestPack @@ -37,6 +37,7 @@ RUNTIME_TEST_DIR = os.path.join(RUNTIME_DIR, "test") TEST_PACKS_DIR = os.path.join(RESOURCES_DIR, "test_packs") +TEST_PACK_CONFIG_FILE = "config.json" TEST_MODULES_DIR = "modules/test" MODULE_CONFIG = "conf/module_config.json" @@ -241,7 +242,8 @@ def _generate_report(self): "%Y-%m-%d %H:%M:%S") report["finished"] = self.get_session().get_finished().strftime( "%Y-%m-%d %H:%M:%S") - report["status"] = self._calculate_result() + report["result"] = self._calculate_result() + report["status"] = self._calculate_status() report["tests"] = self.get_session().get_report_tests() report["report"] = ( self._api_url + "/" + SAVED_DEVICE_REPORTS.replace( @@ -252,7 +254,7 @@ def _generate_report(self): return report def _calculate_result(self): - result = TestrunStatus.COMPLIANT + result = TestrunResult.COMPLIANT for test_result in self.get_session().get_test_results(): @@ -262,22 +264,39 @@ def _calculate_result(self): TestResult.COMPLIANT, TestResult.ERROR ]): - result = TestrunStatus.NON_COMPLIANT + result = TestrunResult.NON_COMPLIANT # Check Required if Applicable tests elif (test_result.required_result.lower() == "required if applicable" and test_result.result == TestResult.NON_COMPLIANT): - result = TestrunStatus.NON_COMPLIANT + result = TestrunResult.NON_COMPLIANT + + self.get_session().set_result(result) + + return result + + def _calculate_status(self): + + result = self.get_session.get_result() + status = TestrunStatus.COMPLETE + + if result in [ + TestrunResult.COMPLIANT, + TestrunResult.NON_COMPLIANT + ]: + status = TestrunStatus.COMPLETE # Change the result if pilot assessment used if (self.get_session().get_target_device().test_pack == "Pilot Assessment"): - if result == TestrunStatus.COMPLIANT: + if result == TestrunResult.COMPLIANT: result = TestrunStatus.PROCEED - elif result == TestrunStatus.NON_COMPLIANT: + elif result == TestrunResult.NON_COMPLIANT: result = TestrunStatus.DO_NOT_PROCEED - return result + self.get_session().set_status(status) + + return status def _cleanup_old_test_results(self, device): @@ -649,14 +668,15 @@ def _get_module_container(self, module): def _load_test_packs(self): - for test_pack_file in os.listdir(TEST_PACKS_DIR): + for test_pack_folder in os.listdir(TEST_PACKS_DIR): - LOGGER.debug(f"Loading test pack {test_pack_file}") + LOGGER.debug(f"Loading test pack {test_pack_folder}") with open(os.path.join( self._root_path, TEST_PACKS_DIR, - test_pack_file), encoding="utf-8") as f: + test_pack_folder, + TEST_PACK_CONFIG_FILE), encoding="utf-8") as f: test_pack_json = json.load(f) test_pack: TestPack = TestPack( diff --git a/resources/test_packs/pilot.json b/resources/test_packs/pilot/config.json similarity index 59% rename from resources/test_packs/pilot.json rename to resources/test_packs/pilot/config.json index 79f6a4f1c..0ba0ae62d 100644 --- a/resources/test_packs/pilot.json +++ b/resources/test_packs/pilot/config.json @@ -1,29 +1,29 @@ { "name": "Pilot Assessment", "language": { - "compliant_description": "This is a preliminary test result. Please attach the Risk Profile and download the test results. A full evaluation and recommendation will be provided by the lab team.", - "non_compliant_description": "This is a preliminary test result. Please attach the Risk Profile and download the test results. A full evaluation and recommendation will be provided by the lab team." + "compliant_description": "Your device has met the initial pilot assessment requirements. Please send your Testrun ZIP file to the qualification lab for verification. The lab will then contact you with further instructions.", + "non_compliant_description": "Your device didn't quite meet the initial pilot assessment requirements. The Testrun report will provide guidance on how to resolve any issues. If you require further support, please get in touch with the qualification lab." }, "tests": [ { "name": "connection.port_link", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.port_speed", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.port_duplex", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.switch.arp_inspection", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.switch.dhcp_snooping", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.dhcp_address", @@ -35,43 +35,51 @@ }, { "name": "connection.mac_oui", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.private_address", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.shared_address", - "required_result": "Informational" + "required_result": "Required" + }, + { + "name": "connection.dhcp_disconnect", + "required_result": "Required" + }, + { + "name": "connection.dhcp_disconnect_ip_change", + "required_result": "Required" }, { "name": "connection.single_ip", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.target_ping", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.ipaddr.ip_change", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.ipaddr.dhcp_failover", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.ipv6_slaac", - "required_result": "Informational" + "required_result": "Required" }, { "name": "connection.ipv6_ping", - "required_result": "Informational" + "required_result": "Required" }, { "name": "dns.network.hostname_resolution", - "required_result": "Informational" + "required_result": "Required" }, { "name": "dns.network.from_dhcp", @@ -83,79 +91,79 @@ }, { "name": "ntp.network.ntp_support", - "required_result": "Informational" + "required_result": "Required" }, { "name": "ntp.network.ntp_dhcp", - "required_result": "Informational" + "required_result": "Roadmap" }, { "name": "protocol.valid_bacnet", - "required_result": "Informational" + "required_result": "Recommended" }, { "name": "protocol.bacnet.version", - "required_result": "Informational" + "required_result": "Recommended" }, { "name": "protocol.valid_modbus", - "required_result": "Informational" + "required_result": "Recommended" }, { "name": "security.services.ftp", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.ssh.version", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.telnet", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.smtp", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.http", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.pop", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.imap", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.snmpv3", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.vnc", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.services.tftp", - "required_result": "Informational" + "required_result": "Required" }, { "name": "ntp.network.ntp_server", - "required_result": "Informational" + "required_result": "Required" }, { "name": "security.tls.v1_0_client", - "required_result": "Required if Applicable" + "required_result": "Informational" }, { "name": "security.tls.v1_2_server", - "required_result": "Informational" + "required_result": "Required if Applicable" }, { "name": "security.tls.v1_2_client", - "required_result": "Informational" + "required_result": "Required if Applicable" }, { "name": "security.tls.v1_3_server", diff --git a/resources/test_packs/pilot/report_template.html b/resources/test_packs/pilot/report_template.html new file mode 100644 index 000000000..c28e75072 --- /dev/null +++ b/resources/test_packs/pilot/report_template.html @@ -0,0 +1,244 @@ +{% import 'header_macros.jinja' as header_macros %} + + + + + + + Testrun Report + + + + + {% set page_index = namespace(value=0) %} + {% set step_index = namespace(value=0) %} + {% set opt_step_index = namespace(value=0) %} + {# Test Results #} + {% for page in range(pages_num) %} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(loop.first, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} + {% if loop.first %} +
+
+
+

Manufacturer

+
{{ device['manufacturer']}}
+
+

Model

+
{{ device['model'] }}
+
+

Firmware

+
{{ device['firmware']}}
+
+

MAC Address

+
{{ device['mac_addr'] }}
+
+
+
+
+

Device Configuration

+
+ {% for module, enabled in modules.items() %} +
+ {% if enabled %} + + {% else %} + + {% endif %} + {{ module }} +
+ {% endfor %} +
+ {% if test_status in ['Compliant', 'Proceed'] %} +
+ {% else %} +
+ {% endif %} +
Test Status
+
Complete
+
Pilot Recommendation
+
{{ test_status }}
+
Started
+
{{ json_data['started']}}
+
Duration
+
+ {% if duration.seconds//3600 > 0 %}{{ duration.seconds//3600 }}h {% endif %} + {% if duration.seconds//60 > 0 %}{{ duration.seconds//60 }}m {% endif %} + {{ duration.seconds%60 }}s +
+
+
+ {% endif %} + {% if loop.first %} + {% set results_from = 0 %} + {% set results_to = [tests_first_page, test_results|length]|min %} + {% else %} + {% set results_from = tests_first_page + (loop.index0 - 1) * tests_per_page %} + {% set results_to = [results_from + tests_per_page, test_results|length]|min %} + {% endif %} +
+

Results List ({{ successful_tests }}/{{ total_tests }})

+
+
Name
+
Description
+
Result
+
Required result
+
+ {% for i in range(results_from, results_to) %} + {% if test_results[i]['result'] == 'Non-Compliant' and test_results[i]['required_result'] == "Required" %} +
+ {% else %} +
+ {% endif %} +
{{ test_results[i]['name'] }}
+
{{ test_results[i]['description'] }}
+
+ {% elif test_results[i]['result'] == 'Compliant' %} + result-test-result-compliant"> + {% elif test_results[i]['result'] == 'Error' %} + result-test-result-error"> + {% elif test_results[i]['result'] == 'Feature Not Detected' %} + result-test-result-feature-not-detected"> + {% elif test_results[i]['result'] == 'Informational' %} + result-test-result-informational"> + {% else %} + result-test-result-skipped"> + {% endif %} + {{ test_results[i]['result'] }}
+ {# Required resul badges #} + {% if test_results[i]['required_result'] == "Required" %} +
+ asterisk + {{ test_results[i]['required_result'] }} +
+ {% elif test_results[i]['required_result'] == "Required if Applicable" %} +
+ asterisk + {{ test_results[i]['required_result'] }} +
+ {% elif test_results[i]['required_result'] == "Informational" %} +
+ info_i + {{ test_results[i]['required_result'] }} +
+ {% else %} +
+ {{ test_results[i]['required_result'] }} +
+ {% endif %} +
+ {% endfor %} +
+ +
+
+ {% endfor %} + {# Pilot steps to resolve#} + {% if optional_steps_to_resolve|length > 0 %} + {% for step in optional_steps_to_resolve%} + {% set page_index.value = page_index.value + 1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} + {% if loop.first %} +

Recommendations for Device Qualification

+
+

Attention

+

+ The following recommendations are required solely for full device qualification. + They are optional for the pilot assessment + but you may find it valuable to understand what will be required in the future + and our recommendations for your device. +

+
+ {% endif %} + {% for line in step %} + {% set opt_step_index.value = opt_step_index.value + 1 %} +
+
+ + {{ opt_step_index.value }}. + +
+ Name
+ {{ line['name'] }} +
+
+ Description
+ {{ line["description"] }} +
+
+
+ Steps to resolve + {% for recommedtation in line['optional_recommendations'] %} +
+ + {{ loop.index }}. {{ recommedtation }} + + {% endfor %} +
+
+ {% endfor %} + +
+ {% endfor %} + {% endif %} + {# Modules reports #} + {% for module in module_reports %} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} +
+ {{ module }} +
+ +
+
+ {% endfor %} + {# Device profile #} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} +

Device profile

+
+
+
Question
+
Answer
+
+ {% for question in json_data['device']['device_profile'] %} +
+
{{loop.index}}.
+
{{ question['question'] }}
+
+ {% if question['answer'] is string %} + {{ question['answer'] }} + {% elif question['answer'] is sequence %} +
    + {% for answer in question['answer'] %} +
  • {{ answer }}
  • + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+ +
+
+ + diff --git a/resources/test_packs/pilot/test_pack.py b/resources/test_packs/pilot/test_pack.py new file mode 100644 index 000000000..e69de29bb diff --git a/resources/test_packs/qualification.json b/resources/test_packs/qualification/config.json similarity index 100% rename from resources/test_packs/qualification.json rename to resources/test_packs/qualification/config.json diff --git a/resources/test_packs/qualification/report_template.html b/resources/test_packs/qualification/report_template.html new file mode 100644 index 000000000..0a22e2d3f --- /dev/null +++ b/resources/test_packs/qualification/report_template.html @@ -0,0 +1,228 @@ +{% import 'header_macros.jinja' as header_macros %} + + + + + + + Testrun Report + + + + + {% set page_index = namespace(value=0) %} + {% set step_index = namespace(value=0) %} + {% set opt_step_index = namespace(value=0) %} + {# Test Results #} + {% for page in range(pages_num) %} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(loop.first, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} + {% if loop.first %} +
+
+
+

Manufacturer

+
{{ device['manufacturer']}}
+
+

Model

+
{{ device['model'] }}
+
+

Firmware

+
{{ device['firmware']}}
+
+

MAC Address

+
{{ device['mac_addr'] }}
+
+
+
+
+

Device Configuration

+
+ {% for module, enabled in modules.items() %} +
+ {% if enabled %} + + {% else %} + + {% endif %} + {{ module }} +
+ {% endfor %} +
+ {% if test_status in ['Compliant', 'Proceed'] %} +
+ {% else %} +
+ {% endif %} +
Test Status
+
Complete
+
Test Result
+
{{ test_status }}
+
Started
+
{{ json_data['started']}}
+
Duration
+
+ {% if duration.seconds//3600 > 0 %}{{ duration.seconds//3600 }}h {% endif %} + {% if duration.seconds//60 > 0 %}{{ duration.seconds//60 }}m {% endif %} + {{ duration.seconds%60 }}s +
+
+
+ {% endif %} + {% if loop.first %} + {% set results_from = 0 %} + {% set results_to = [tests_first_page, test_results|length]|min %} + {% else %} + {% set results_from = tests_first_page + (loop.index0 - 1) * tests_per_page %} + {% set results_to = [results_from + tests_per_page, test_results|length]|min %} + {% endif %} +
+

Results List ({{ successful_tests }}/{{ total_tests }})

+
+
Name
+
Description
+
Result
+
Required result
+
+ {% for i in range(results_from, results_to) %} + {% if test_results[i]['result'] == 'Non-Compliant' and test_results[i]['required_result'] == "Required" %} +
+ {% else %} +
+ {% endif %} +
{{ test_results[i]['name'] }}
+
{{ test_results[i]['description'] }}
+
+ {% elif test_results[i]['result'] == 'Compliant' %} + result-test-result-compliant"> + {% elif test_results[i]['result'] == 'Error' %} + result-test-result-error"> + {% elif test_results[i]['result'] == 'Feature Not Detected' %} + result-test-result-feature-not-detected"> + {% elif test_results[i]['result'] == 'Informational' %} + result-test-result-informational"> + {% else %} + result-test-result-skipped"> + {% endif %} + {{ test_results[i]['result'] }}
+ {# Required resul badges #} + {% if test_results[i]['required_result'] == "Required" %} +
+ asterisk + {{ test_results[i]['required_result'] }} +
+ {% elif test_results[i]['required_result'] == "Required if Applicable" %} +
+ asterisk + {{ test_results[i]['required_result'] }} +
+ {% elif test_results[i]['required_result'] == "Informational" %} +
+ info_i + {{ test_results[i]['required_result'] }} +
+ {% else %} +
+ {{ test_results[i]['required_result'] }} +
+ {% endif %} +
+ {% endfor %} +
+ +
+
+ {% endfor %} + {# Steps to resolve Device qualification #} + {% if steps_to_resolve|length > 0 %} + {% for step in steps_to_resolve%} + {% set page_index.value = page_index.value + 1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} + {% if loop.first %} +

Non-compliant tests and suggested steps to resolve

+ {% endif %} + {% for line in step %} + {% set step_index.value = step_index.value + 1 %} +
+
+ {{ step_index.value }}. +
+ Name
{{ line['name'] }} +
+
+ Description
{{ line["description"] }} +
+
+
+ Steps to resolve + {% for recommedtation in line['recommendations'] %} +
{{ loop.index }}. {{ recommedtation }} + {% endfor %} +
+
+ {% endfor %} + +
+ {% endfor %} + {% endif %} + {# Modules reports #} + {% for module in module_reports %} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} +
+ {{ module }} +
+ +
+
+ {% endfor %} + {# Device profile #} + {% set page_index.value = page_index.value+1 %} +
+ {{ header_macros.header(False, "Testrun report", json_data, device, logo, icon_qualification, icon_pilot)}} +

Device profile

+
+
+
Question
+
Answer
+
+ {% for question in json_data['device']['device_profile'] %} +
+
{{loop.index}}.
+
{{ question['question'] }}
+
+ {% if question['answer'] is string %} + {{ question['answer'] }} + {% elif question['answer'] is sequence %} +
    + {% for answer in question['answer'] %} +
  • {{ answer }}
  • + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+ +
+
+ + From eca0f675fa31a5a96250ed6e7346bb7a7ce87e28 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 30 Jan 2025 18:16:34 +0000 Subject: [PATCH 31/47] Add result field --- framework/python/src/common/testreport.py | 15 ++++++++++++--- framework/python/src/core/testrun.py | 5 +---- .../python/src/test_orc/test_orchestrator.py | 10 +++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index d572fdb61..b8cc1a1b1 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -17,7 +17,7 @@ from weasyprint import HTML from io import BytesIO from common import util -from common.statuses import TestrunStatus +from common.statuses import TestrunStatus, TestrunResult, TestResult import base64 import os from test_orc.test_case import TestCase @@ -53,13 +53,14 @@ class TestReport(): """Represents a previous Testrun report.""" def __init__(self, - status=TestrunStatus.NON_COMPLIANT, + result=TestrunResult.NON_COMPLIANT, started=None, finished=None, total_tests=0): self._device = {} self._mac_addr = None - self._status: str = status + self._status: TestrunStatus = TestrunStatus.COMPLETE + self._result: TestrunResult = result self._started = started self._finished = finished self._total_tests = total_tests @@ -77,6 +78,9 @@ def add_module_reports(self, module_reports): def get_status(self): return self._status + def get_result(self): + return self._result + def get_started(self): return self._started @@ -112,6 +116,7 @@ def to_json(self): report_json['mac_addr'] = self._mac_addr report_json['device'] = self._device report_json['status'] = self._status + report_json['result'] = self._result report_json['started'] = self._started.strftime(DATE_TIME_FORMAT) report_json['finished'] = self._finished.strftime(DATE_TIME_FORMAT) @@ -165,6 +170,10 @@ def from_json(self, json_file): self._device['device_profile'] = json_file['device']['additional_info'] self._status = json_file['status'] + + if 'result' in json_file: + self._result = json_file['result'] + self._started = datetime.strptime(json_file['started'], DATE_TIME_FORMAT) self._finished = datetime.strptime(json_file['finished'], DATE_TIME_FORMAT) diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index f4ce62c0f..aa0a5045d 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -488,10 +488,7 @@ def _device_stable(self, mac_addr): LOGGER.info(f'Device with mac address {mac_addr} is ready for testing.') self._set_status(TestrunStatus.IN_PROGRESS) - result = self._test_orc.run_test_modules() - - if result is not None: - self._set_status(result) + self._test_orc.run_test_modules() self._stop_network() diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 9807202fd..f4c63d533 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -190,11 +190,9 @@ def run_test_modules(self): # Default message is empty (better than an error message). # This should never be shown message: str = "" - if report.get_status() in [TestrunStatus.COMPLIANT, - TestrunStatus.PROCEED]: + if report.get_result() == TestrunResult.COMPLIANT: message = test_pack.get_message("compliant_description") - elif report.get_status() in [TestrunStatus.NON_COMPLIANT, - TestrunStatus.DO_NOT_PROCEED]: + elif report.get_result() == TestrunResult.NON_COMPLIANT: message = test_pack.get_message("non_compliant_description") self.get_session().set_description(message) @@ -207,8 +205,6 @@ def run_test_modules(self): LOGGER.debug("Old test results cleaned") - return report.get_status() - def _write_reports(self, test_report): out_dir = os.path.join( @@ -277,7 +273,7 @@ def _calculate_result(self): def _calculate_status(self): - result = self.get_session.get_result() + result = self.get_session().get_result() status = TestrunStatus.COMPLETE if result in [ From e490cb2a23af1ce90bfeae1860f829790a73281e Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Fri, 31 Jan 2025 12:35:37 +0000 Subject: [PATCH 32/47] Add new pilot support logic --- .../python/src/test_orc/test_orchestrator.py | 103 +++++++++--------- framework/python/src/test_orc/test_pack.py | 5 + resources/report/test_report_template.html | 11 +- resources/test_packs/pilot/config.json | 2 +- resources/test_packs/pilot/test_pack.py | 41 +++++++ .../test_packs/qualification/config.json | 2 +- .../test_packs/qualification/test_pack.py | 37 +++++++ 7 files changed, 142 insertions(+), 59 deletions(-) create mode 100644 resources/test_packs/qualification/test_pack.py diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index f4c63d533..54ca42a1f 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -18,7 +18,9 @@ import re import time import shutil +import sys import docker +import importlib.util from datetime import datetime from common import logger, util from common.testreport import TestReport @@ -38,6 +40,7 @@ RUNTIME_TEST_DIR = os.path.join(RUNTIME_DIR, "test") TEST_PACKS_DIR = os.path.join(RESOURCES_DIR, "test_packs") TEST_PACK_CONFIG_FILE = "config.json" +TEST_PACK_LOGIC_FILE = "test_pack.py" TEST_MODULES_DIR = "modules/test" MODULE_CONFIG = "conf/module_config.json" @@ -176,6 +179,7 @@ def run_test_modules(self): report = TestReport() generated_report_json = self._generate_report() + report.from_json(generated_report_json) report.add_module_reports(self.get_session().get_module_reports()) device.add_report(report) @@ -229,71 +233,42 @@ def _write_reports(self, test_report): def _generate_report(self): + device = self.get_session().get_target_device() + test_pack_name = device.test_pack + test_pack = self.get_test_pack(test_pack_name) + report = {} report["testrun"] = {"version": self.get_session().get_version()} - report["mac_addr"] = self.get_session().get_target_device().mac_addr - report["device"] = self.get_session().get_target_device().to_dict() + report["mac_addr"] = device.mac_addr + report["device"] = device.to_dict() report["started"] = self.get_session().get_started().strftime( "%Y-%m-%d %H:%M:%S") report["finished"] = self.get_session().get_finished().strftime( "%Y-%m-%d %H:%M:%S") - report["result"] = self._calculate_result() - report["status"] = self._calculate_status() + + # Update the result + result = test_pack.get_logic().calculate_result( + self.get_session().get_test_results()) + report["result"] = result + self.get_session().set_result(result) + + # Update the status + status = test_pack.get_logic().calculate_status( + result, + self.get_session().get_test_results()) + report["status"] = status + self.get_session().set_status(status) + report["tests"] = self.get_session().get_report_tests() report["report"] = ( self._api_url + "/" + SAVED_DEVICE_REPORTS.replace( "{device_folder}", - self.get_session().get_target_device().device_folder) + + device.device_folder) + self.get_session().get_started().strftime("%Y-%m-%dT%H:%M:%S")) return report - def _calculate_result(self): - result = TestrunResult.COMPLIANT - - for test_result in self.get_session().get_test_results(): - - # Check Required tests - if (test_result.required_result.lower() == "required" - and test_result.result not in [ - TestResult.COMPLIANT, - TestResult.ERROR - ]): - result = TestrunResult.NON_COMPLIANT - - # Check Required if Applicable tests - elif (test_result.required_result.lower() == "required if applicable" - and test_result.result == TestResult.NON_COMPLIANT): - result = TestrunResult.NON_COMPLIANT - - self.get_session().set_result(result) - - return result - - def _calculate_status(self): - - result = self.get_session().get_result() - status = TestrunStatus.COMPLETE - - if result in [ - TestrunResult.COMPLIANT, - TestrunResult.NON_COMPLIANT - ]: - status = TestrunStatus.COMPLETE - - # Change the result if pilot assessment used - if (self.get_session().get_target_device().test_pack == - "Pilot Assessment"): - if result == TestrunResult.COMPLIANT: - result = TestrunStatus.PROCEED - elif result == TestrunResult.NON_COMPLIANT: - result = TestrunStatus.DO_NOT_PROCEED - - self.get_session().set_status(status) - - return status - def _cleanup_old_test_results(self, device): if device.max_device_reports is not None: @@ -668,21 +643,43 @@ def _load_test_packs(self): LOGGER.debug(f"Loading test pack {test_pack_folder}") - with open(os.path.join( + test_pack_path = os.path.join( self._root_path, TEST_PACKS_DIR, - test_pack_folder, + test_pack_folder + ) + + with open(os.path.join( + test_pack_path, TEST_PACK_CONFIG_FILE), encoding="utf-8") as f: test_pack_json = json.load(f) test_pack: TestPack = TestPack( name = test_pack_json["name"], tests = test_pack_json["tests"], - language = test_pack_json["language"] + language = test_pack_json["language"], + pack_logic = self._load_logic( + os.path.join(test_pack_path, TEST_PACK_LOGIC_FILE), + "test_pack_" + test_pack_folder + "_logic" + ) ) self._test_packs.append(test_pack) + def _load_logic(self, source, module_name=None): + """Reads file source and loads it as a module""" + + spec = importlib.util.spec_from_file_location(module_name, source) + module = importlib.util.module_from_spec(spec) + + # Add the module to sys.modules + sys.modules[module_name] = module + + # Execute the module + spec.loader.exec_module(module) + + return module + def _load_test_modules(self): """Load network modules from module_config.json.""" LOGGER.debug("Loading test modules from /" + TEST_MODULES_DIR) diff --git a/framework/python/src/test_orc/test_pack.py b/framework/python/src/test_orc/test_pack.py index a2e7c5f97..b1efaf6f8 100644 --- a/framework/python/src/test_orc/test_pack.py +++ b/framework/python/src/test_orc/test_pack.py @@ -13,6 +13,7 @@ # limitations under the License. """Represents a testing pack.""" +from types import ModuleType from typing import List, Dict from dataclasses import dataclass, field from collections import defaultdict @@ -26,6 +27,7 @@ class TestPack: # pylint: disable=too-few-public-methods,too-many-instance-attr description: str = "" tests: List[dict] = field(default_factory=lambda: []) language: Dict = field(default_factory=lambda: defaultdict(dict)) + pack_logic: ModuleType = None def get_test(self, test_name: str) -> str: """Get details of a test from the test pack""" @@ -44,6 +46,9 @@ def get_required_result(self, test_name: str) -> str: return "Informational" + def get_logic(self): + return self.pack_logic + def get_message(self, name: str) -> str: if name in self.language: return self.language[name] diff --git a/resources/report/test_report_template.html b/resources/report/test_report_template.html index 58a116808..755cc44bd 100644 --- a/resources/report/test_report_template.html +++ b/resources/report/test_report_template.html @@ -55,14 +55,17 @@

Device Configuration

{% else %}
{% endif %} -
Test Status
-
Complete
+ {% if is_pilot %}
Pilot Recommendation
{% else %} -
Test Result
+
Test Status
{% endif %} -
{{ test_status }}
+
{{ json_data['status'] }}
+ +
Test Result
+
{{ json_data['result'] }}
+
Started
{{ json_data['started']}}
Duration
diff --git a/resources/test_packs/pilot/config.json b/resources/test_packs/pilot/config.json index 0ba0ae62d..e7e08bc5e 100644 --- a/resources/test_packs/pilot/config.json +++ b/resources/test_packs/pilot/config.json @@ -155,7 +155,7 @@ }, { "name": "security.tls.v1_0_client", - "required_result": "Informational" + "required_result": "Required if Applicable" }, { "name": "security.tls.v1_2_server", diff --git a/resources/test_packs/pilot/test_pack.py b/resources/test_packs/pilot/test_pack.py index e69de29bb..71b73fb45 100644 --- a/resources/test_packs/pilot/test_pack.py +++ b/resources/test_packs/pilot/test_pack.py @@ -0,0 +1,41 @@ +"""Provide custom logic for test packs""" + +from common.statuses import TestrunStatus, TestrunResult, TestResult + +def calculate_result(json): + """Provide the testing result based on the output of testing""" + result = TestrunResult.COMPLIANT + + for test_result in json: + + # Check Required tests + if (test_result.required_result.lower() == "required" + and test_result.result not in [ + TestResult.COMPLIANT, + TestResult.ERROR + ]): + result = TestrunResult.NON_COMPLIANT + + # Check Required if Applicable tests + elif (test_result.required_result.lower() == "required if applicable" + and test_result.result == TestResult.NON_COMPLIANT): + result = TestrunResult.NON_COMPLIANT + + return result + +def calculate_status(result, json): # pylint: disable=unused-argument + """Provide the status based on the output of testing""" + + status = TestrunStatus.PROCEED + + required_tests = [ + "connection.dhcp_address", + "security.tls.v1_0_client" + ] + + for test_result in json: + if (test_result.name.lower() in required_tests + and test_result.result != TestResult.COMPLIANT): + status = TestrunStatus.DO_NOT_PROCEED + + return status diff --git a/resources/test_packs/qualification/config.json b/resources/test_packs/qualification/config.json index 967370b4a..e5e357509 100644 --- a/resources/test_packs/qualification/config.json +++ b/resources/test_packs/qualification/config.json @@ -155,7 +155,7 @@ }, { "name": "security.tls.v1_0_client", - "required_result": "Informational" + "required_result": "Required if Applicable" }, { "name": "security.tls.v1_2_server", diff --git a/resources/test_packs/qualification/test_pack.py b/resources/test_packs/qualification/test_pack.py new file mode 100644 index 000000000..263c5d04b --- /dev/null +++ b/resources/test_packs/qualification/test_pack.py @@ -0,0 +1,37 @@ +"""Provide custom logic for test packs""" + +from common.statuses import TestrunStatus, TestrunResult, TestResult + +def calculate_result(json): + """Provide the testing result based on the output of testing""" + result = TestrunResult.COMPLIANT + + for test_result in json: + + # Check Required tests + if (test_result.required_result.lower() == "required" + and test_result.result not in [ + TestResult.COMPLIANT, + TestResult.ERROR + ]): + result = TestrunResult.NON_COMPLIANT + + # Check Required if Applicable tests + elif (test_result.required_result.lower() == "required if applicable" + and test_result.result == TestResult.NON_COMPLIANT): + result = TestrunResult.NON_COMPLIANT + + return result + +def calculate_status(result, json): # pylint: disable=unused-argument + """Provide the status based on the output of testing""" + + status = TestrunStatus.COMPLETE + + if result in [ + TestrunResult.COMPLIANT, + TestrunResult.NON_COMPLIANT + ]: + status = TestrunStatus.COMPLETE + + return status From c0e9e499c84e6929f967fb70afb02c2af8d71452 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 3 Feb 2025 21:41:57 +0100 Subject: [PATCH 33/47] remove unused import --- framework/python/src/common/testreport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/python/src/common/testreport.py b/framework/python/src/common/testreport.py index 05f5e2843..da5081641 100644 --- a/framework/python/src/common/testreport.py +++ b/framework/python/src/common/testreport.py @@ -17,7 +17,7 @@ from weasyprint import HTML from io import BytesIO from common import util, logger -from common.statuses import TestrunStatus, TestrunResult, TestResult +from common.statuses import TestrunStatus, TestrunResult import base64 import os from test_orc.test_case import TestCase From 66391ae9e1770ef3e3263e38dda53ace5631a4d9 Mon Sep 17 00:00:00 2001 From: kurilova Date: Tue, 4 Feb 2025 13:11:37 +0000 Subject: [PATCH 34/47] Adds pilot recommendation to status card --- modules/ui/src/app/app.store.spec.ts | 4 ++- modules/ui/src/app/app.store.ts | 4 +-- .../download-report.component.ts | 8 ++--- .../download-zip-modal.component.html | 7 +++-- .../download-zip-modal.component.ts | 14 ++++++++- modules/ui/src/app/mocks/reports.mock.ts | 28 +++++++++++------ modules/ui/src/app/mocks/testrun.mock.ts | 26 ++++++++++------ modules/ui/src/app/model/testrun-status.ts | 17 ++++++---- .../download-options.component.ts | 6 ++-- .../testrun-status-card.component.html | 11 +++++-- .../testrun-status-card.component.scss | 4 +++ .../testrun-status-card.component.spec.ts | 9 +++--- .../testrun-status-card.component.ts | 31 ++++++++++++++----- .../src/app/services/test-run.service.spec.ts | 5 ++- modules/ui/src/app/store/effects.ts | 3 +- 15 files changed, 120 insertions(+), 57 deletions(-) diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 300a250fd..fa4f4e710 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -53,6 +53,7 @@ import { FocusManagerService } from './services/focus-manager.service'; import { TestRunMqttService } from './services/test-run-mqtt.service'; import { MOCK_ADAPTERS } from './mocks/settings.mock'; import { TestingType } from './model/device'; +import {ResultOfTestrun, StatusOfTestrun} from './model/testrun-status'; const mock = (() => { let store: { [key: string]: string } = {}; @@ -499,7 +500,8 @@ describe('AppStore', () => { store.overrideSelector(selectIsTestingComplete, true); store.overrideSelector(selectSystemStatus, { - status: 'Compliant', + result: ResultOfTestrun.Compliant, + status: StatusOfTestrun.Complete, mac_addr: '00:1e:42:35:73:c4', device: { manufacturer: 'Delta', diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index a12a536a3..9b07fddf7 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -53,7 +53,7 @@ import { setTestModules, updateAdapters, } from './store/actions'; -import { StatusOfTestrun, TestrunStatus } from './model/testrun-status'; +import { ResultOfTestrun, TestrunStatus } from './model/testrun-status'; import { Adapters, SettingMissedError, @@ -333,7 +333,7 @@ export class AppStore extends ComponentStore { filter(([isTestingComplete]) => isTestingComplete === true), filter( ([, testrunStatus]) => - testrunStatus?.status === StatusOfTestrun.Compliant && + testrunStatus?.result === ResultOfTestrun.Compliant && testrunStatus?.device.test_pack === TestingType.Pilot ), tap(() => { diff --git a/modules/ui/src/app/components/download-report/download-report.component.ts b/modules/ui/src/app/components/download-report/download-report.component.ts index dcabc2281..8184ef6fc 100644 --- a/modules/ui/src/app/components/download-report/download-report.component.ts +++ b/modules/ui/src/app/components/download-report/download-report.component.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { StatusOfTestrun, TestrunStatus } from '../../model/testrun-status'; +import { ResultOfTestrun, TestrunStatus } from '../../model/testrun-status'; import { CommonModule, DatePipe } from '@angular/common'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ReportActionComponent } from '../report-action/report-action.component'; @@ -40,16 +40,16 @@ export class DownloadReportComponent extends ReportActionComponent { } return `${data.device.manufacturer} ${data.device.model} ${ data.device.firmware - } ${data.status} ${this.getFormattedDateString(data.started)}` + } ${data.result ? data.result : data.status} ${this.getFormattedDateString(data.started)}` .replace(/ /g, '_') .toLowerCase(); } getClass(data: TestrunStatus) { - if (data.status === StatusOfTestrun.Compliant) { + if (data.result === ResultOfTestrun.Compliant) { return `${this.class}-compliant`; } - if (data.status === StatusOfTestrun.NonCompliant) { + if (data.result === ResultOfTestrun.NonCompliant) { return `${this.class}-non-compliant`; } return this.class; diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html index acecbd760..61ba85614 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html @@ -28,12 +28,15 @@ class="testing-result" id="testing-result-main-info" [class]=" - data.testrunStatus.status === StatusOfTestrun.Compliant || + (data.testrunStatus.result === ResultOfTestrun.Compliant && + data.testrunStatus.status === StatusOfTestrun.Complete) || data.testrunStatus.status === StatusOfTestrun.Proceed ? 'success-result' : 'failed-result' "> -

{{ data.testrunStatus.status }}

+

+ {{ getTestingResult(data.testrunStatus) }} +

{{ data.testrunStatus.description }}

diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts index 199d89bf5..bb4c54b62 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts @@ -25,7 +25,11 @@ import { MatOptionModule } from '@angular/material/core'; import { TestRunService } from '../../services/test-run.service'; import { Routes } from '../../model/routes'; import { Router, RouterLink } from '@angular/router'; -import { TestrunStatus, StatusOfTestrun } from '../../model/testrun-status'; +import { + TestrunStatus, + StatusOfTestrun, + ResultOfTestrun, +} from '../../model/testrun-status'; import { DownloadReportComponent } from '../download-report/download-report.component'; import { Subject, takeUntil, timer } from 'rxjs'; import { FocusManagerService } from '../../services/focus-manager.service'; @@ -80,6 +84,7 @@ export class DownloadZipModalComponent } as Profile; public readonly Routes = Routes; public readonly StatusOfTestrun = StatusOfTestrun; + public readonly ResultOfTestrun = ResultOfTestrun; profiles: Profile[] = []; selectedProfile: Profile; constructor( @@ -165,6 +170,13 @@ export class DownloadZipModalComponent return this.testRunService.getRiskClass(riskResult); } + public getTestingResult(data: TestrunStatus): string { + if (data.status === StatusOfTestrun.Complete && data.result) { + return data.result; + } + return data.status; + } + private getZipLink(reportURL: string): string { return reportURL.replace('report', 'export'); } diff --git a/modules/ui/src/app/mocks/reports.mock.ts b/modules/ui/src/app/mocks/reports.mock.ts index 2889b0571..d20808a94 100644 --- a/modules/ui/src/app/mocks/reports.mock.ts +++ b/modules/ui/src/app/mocks/reports.mock.ts @@ -5,7 +5,8 @@ import { DeviceStatus, TestingType } from '../model/device'; export const HISTORY = [ { mac_addr: '01:02:03:04:05:06', - status: 'compliant', + status: 'Complete', + result: 'Compliant', device: { status: DeviceStatus.VALID, manufacturer: 'Delta', @@ -20,7 +21,8 @@ export const HISTORY = [ finished: '2023-06-23T10:17:10.123Z', }, { - status: 'compliant', + status: 'Complete', + result: 'Compliant', mac_addr: '01:02:03:04:05:07', device: { status: DeviceStatus.VALID, @@ -37,7 +39,8 @@ export const HISTORY = [ }, { mac_addr: null, - status: 'compliant', + status: 'Complete', + result: 'Compliant', device: { status: DeviceStatus.VALID, manufacturer: 'Delta', @@ -56,7 +59,8 @@ export const HISTORY = [ export const HISTORY_AFTER_REMOVE = [ { mac_addr: '01:02:03:04:05:06', - status: 'compliant', + status: 'Complete', + result: 'Compliant', device: { status: DeviceStatus.VALID, manufacturer: 'Delta', @@ -72,7 +76,8 @@ export const HISTORY_AFTER_REMOVE = [ }, { mac_addr: null, - status: 'compliant', + status: 'Complete', + result: 'Compliant', device: { status: DeviceStatus.VALID, manufacturer: 'Delta', @@ -86,11 +91,12 @@ export const HISTORY_AFTER_REMOVE = [ started: '2023-06-23T10:11:00.123Z', finished: '2023-06-23T10:17:10.123Z', }, -]; +] as TestrunStatus[]; export const FORMATTED_HISTORY = [ { - status: 'compliant', + status: 'Complete', + result: 'Compliant', mac_addr: '01:02:03:04:05:06', device: { status: DeviceStatus.VALID, @@ -110,7 +116,8 @@ export const FORMATTED_HISTORY = [ program: 'Device Qualification', }, { - status: 'compliant', + status: 'Complete', + result: 'Compliant', mac_addr: '01:02:03:04:05:07', device: { status: DeviceStatus.VALID, @@ -131,7 +138,8 @@ export const FORMATTED_HISTORY = [ }, { mac_addr: null, - status: 'compliant', + status: 'Complete', + result: 'Compliant', device: { status: DeviceStatus.VALID, manufacturer: 'Delta', @@ -149,7 +157,7 @@ export const FORMATTED_HISTORY = [ duration: '06m 10s', program: 'Device Qualification', }, -]; +] as HistoryTestrun[]; export const FILTERS = { deviceInfo: 'test', diff --git a/modules/ui/src/app/mocks/testrun.mock.ts b/modules/ui/src/app/mocks/testrun.mock.ts index aa2ec4a8e..adf108c97 100644 --- a/modules/ui/src/app/mocks/testrun.mock.ts +++ b/modules/ui/src/app/mocks/testrun.mock.ts @@ -16,6 +16,7 @@ import { IResult, RequiredResult, + ResultOfTestrun, StatusOfTestrun, TestrunStatus, TestsData, @@ -95,12 +96,13 @@ export const TEST_DATA: TestsData = { }; const PROGRESS_DATA_RESPONSE = ( - status: string, + status: StatusOfTestrun, finished: string | null, tests: TestsData | IResult[], - report: string = '' + report: string = '', + result?: ResultOfTestrun ) => { - return { + const response = { status, mac_addr: '01:02:03:04:05:06', device: { @@ -114,8 +116,12 @@ const PROGRESS_DATA_RESPONSE = ( finished, tests, report, - tags: ['VSA', 'Other tag', 'And one more'], - }; + tags: ['VSA', 'Other tag', 'And one more'] + } as TestrunStatus; + if (result) { + response.result = result; + } + return response; }; export const MOCK_PROGRESS_DATA_CANCELLING: TestrunStatus = @@ -126,18 +132,20 @@ export const MOCK_PROGRESS_DATA_IN_PROGRESS_EMPTY: TestrunStatus = PROGRESS_DATA_RESPONSE(StatusOfTestrun.InProgress, null, []); export const MOCK_PROGRESS_DATA_COMPLIANT: TestrunStatus = PROGRESS_DATA_RESPONSE( - StatusOfTestrun.Compliant, + StatusOfTestrun.Complete, '2023-06-22T09:20:00.123Z', TEST_DATA_RESULT, - 'https://api.testrun.io/report.pdf' + 'https://api.testrun.io/report.pdf', + ResultOfTestrun.Compliant ); export const MOCK_PROGRESS_DATA_NON_COMPLIANT: TestrunStatus = PROGRESS_DATA_RESPONSE( - StatusOfTestrun.NonCompliant, + StatusOfTestrun.Complete, '2023-06-22T09:20:00.123Z', TEST_DATA_RESULT, - 'https://api.testrun.io/report.pdf' + 'https://api.testrun.io/report.pdf', + ResultOfTestrun.NonCompliant ); export const MOCK_PROGRESS_DATA_CANCELLED: TestrunStatus = diff --git a/modules/ui/src/app/model/testrun-status.ts b/modules/ui/src/app/model/testrun-status.ts index c7c92e695..344db09dc 100644 --- a/modules/ui/src/app/model/testrun-status.ts +++ b/modules/ui/src/app/model/testrun-status.ts @@ -17,7 +17,8 @@ import { Device } from './device'; export interface TestrunStatus { mac_addr: string | null; - status: string; + status: StatusOfTestrun; + result?: ResultOfTestrun; description?: string; device: IDevice; started: string | null; @@ -59,24 +60,28 @@ export enum RequiredResult { RequiredIfApplicable = 'Required if Applicable', } +export enum ResultOfTestrun { + Compliant = 'Compliant', // used for Completed + NonCompliant = 'Non-Compliant', // used for Completed +} + export enum StatusOfTestrun { InProgress = 'In Progress', WaitingForDevice = 'Waiting for Device', Cancelled = 'Cancelled', Cancelling = 'Cancelling', Failed = 'Failed', - Compliant = 'Compliant', // used for Completed CompliantLimited = 'Compliant (Limited)', CompliantHigh = 'Compliant (High)', - NonCompliant = 'Non-Compliant', // used for Completed - SmartReady = 'Smart Ready', // used for Completed + SmartReady = 'Smart Ready', Idle = 'Idle', Monitoring = 'Monitoring', Starting = 'Starting', Error = 'Error', Validating = 'Validating Network', - Proceed = 'Proceed', - DoNotProceed = 'Do Not Proceed', + Complete = 'Complete', // device qualification + Proceed = 'Proceed', // pilot assessment + DoNotProceed = 'Do Not Proceed', // pilot assessment } export enum StatusOfTestResult { diff --git a/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts b/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts index 81b4a1fee..814584eb0 100644 --- a/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts +++ b/modules/ui/src/app/pages/testrun/components/download-options/download-options.component.ts @@ -26,7 +26,7 @@ import { MatSelectModule } from '@angular/material/select'; import { CommonModule, DatePipe } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { - StatusOfTestrun, + ResultOfTestrun, TestrunStatus, } from '../../../../model/testrun-status'; import { MatOptionSelectionChange } from '@angular/material/core'; @@ -113,9 +113,9 @@ export class DownloadOptionsComponent { sendGAEvent(data: TestrunStatus, type: string) { let event = `download_report_${type === DownloadOption.PDF ? 'pdf' : 'zip'}`; - if (data.status === StatusOfTestrun.Compliant) { + if (data.result === ResultOfTestrun.Compliant) { event += '_compliant'; - } else if (data.status === StatusOfTestrun.NonCompliant) { + } else if (data.result === ResultOfTestrun.NonCompliant) { event += '_non_compliant'; } // @ts-expect-error data layer is not null diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html index c6fb361fb..15e4a47a0 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html @@ -16,7 +16,8 @@