From 549641c26a1ec0b6e240deb5772c7268d66728ca Mon Sep 17 00:00:00 2001 From: J Boddey Date: Tue, 20 Aug 2024 16:56:06 +0100 Subject: [PATCH] Release v1.4 into main (#687) * Update requirements.txt (#429) Signed-off-by: J Boddey * Remove in progress status after discovery (#422) * Add steps to resolve to PDF report (#411) * Add progress for steps to resolve * Formatting * Remove print statements * Allow for no steps to resolve * Add multipage * Fix multipage * Fix config * update report unit tests --------- Signed-off-by: J Boddey Co-authored-by: jhughesbiot * Bump ejs in /modules/ui in the npm_and_yarn group across 1 directory (#428) Bumps the npm_and_yarn group with 1 update in the /modules/ui directory: [ejs](https://github.com/mde/ejs). Updates `ejs` from 3.1.9 to 3.1.10 - [Release notes](https://github.com/mde/ejs/releases) - [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10) --- updated-dependencies: - dependency-name: ejs dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * For delete report take mac_addr from upper level (#436) * Update dependencies (#435) * Add new mac addr field for report deleting (#432) * Bump version to v1.2.2 (#433) * fix: replace Feature Not Present to Feature Not Detected (#445) Co-authored-by: Volha Mardvilka * Fix PDF alignment (#441) * Do not remove form control on destroy as it causes error; call system/config on settings open (#442) * Fix some pylint issues (#437) * Update documentation (#448) * Update documentation * Update docs * V1.3 (#393) * Adds version analytics event (#306) * Techdebt: adds state for testrun page (#392) * Fix tests (#397) * Fix tests * Update node version * 331379891: (feat) disable connection settings when testrun is in progress (#371) * 331379891: (feat) disable connection settings when testrun is in progress * 331379891: (fix) include more testrun results as progress * 331379891: (fix) fix spelling * 333349715: (fix) GAR 1.3 The disabled system settings panel contains a focusable element (#388) Co-authored-by: Volha Mardvilka --------- Co-authored-by: Volha Mardvilka * Disable device item if device in progress (#370) * Adds ga to track testrun initiation (#415) Adds ga ti track testrun initiation * Fix function return value (#421) * Fix redirect + adds google analytics (#420) * Adds certificates drawer component with list of certificates (#414) Adds certificates drawer component with list of certificates. Upload and delete is out of scope * List modules (#425) * Adds delete certificate (#424) Adds delete certificate * Adds class for links to track report type on download; adds event when download report is clicked on progress page (#434) * Adds upload certificate (#431) * Adds upload certificate * move delete functionality to certificates store * Catch error on report/certificate deletion (#438) * Fix button size; fix text-overflow (#440) * Adds focus on next or close button when certificate is deleted (#439) * Adds focus on next or close button when certificate is deleted * Enable test modules for initiate test run dialog (#400) Enable test modules for initiate test run dialog * Add steps to resolve to PDF report (#411) * Add progress for steps to resolve * Formatting * Remove print statements * Allow for no steps to resolve * Add multipage * Fix multipage * Fix config * update report unit tests --------- Signed-off-by: J Boddey Co-authored-by: jhughesbiot * Add new mac addr field for report deleting (#432) * Bump version to v1.2.2 (#433) * 337012359: (feat) add snackBar with wait or stop testrun (#444) * 337012359: (feat) add snackBar with wait or stop testrun * 337012359: (fix) update to fix failed tests * 337012359: (fix) add fix for deletCertificate test --------- Co-authored-by: Volha Mardvilka * Feature/port stats (#430) * Add port speed and duplex tests Add unit tests for port stats testing Add place holder for ip port link test * Add ethtool to system dependencies Restructure conn stats methods and tests Resolve port stat information for link test * Fix runtime issue * Implement link test Fix unit tests Misc port stats updates * fix pylint issues * update readme * pylint fixes --------- Signed-off-by: J Boddey Co-authored-by: J Boddey * 339315842: (feat) add risk assessment tab (#450) * 339315842: (feat) add risk assessment tab * 339315842: (feat) add risk assessment tab --------- Co-authored-by: Volha Mardvilka * Add steps to resolve to PDF report (#411) * Add progress for steps to resolve * Formatting * Remove print statements * Allow for no steps to resolve * Add multipage * Fix multipage * Fix config * update report unit tests --------- Signed-off-by: J Boddey Co-authored-by: jhughesbiot * Add new mac addr field for report deleting (#432) * Remove rebase error --------- Signed-off-by: J Boddey Co-authored-by: Olga Mardvilko Co-authored-by: Volha Mardvilka Co-authored-by: J Boddey Co-authored-by: jhughesbiot Co-authored-by: jhughesbiot <50999916+jhughesbiot@users.noreply.github.com> * Rename "history" to "reports" (#456) * Rename "device-repository" to "devices" (#455) * Change certificates endpoints (#458) * 339311887: (feat) display saved risk profile (#460) Co-authored-by: Volha Mardvilka * Fix ui defects (#459) * 340835710: (fix) [Risk assessment] change page view when callout is visible (#461) Co-authored-by: Volha Mardvilka * Fix style to allow screen reader to read label (#462) * Updaate device test module configuration from api start endpoint (#463) * Add get profiles format endpoint (#465) * 339311250: (feat) delete risk profile (#466) Co-authored-by: Volha Mardvilka * 341254121: (fix) [a11y] add item name for aria-labels delete and copy buttons (#467) Co-authored-by: Volha Mardvilka * Make testing statuses available outside of Testing Tab (#468) * Adds timeout token * Status is available on all pages; Removed unused field isTestrunStarted - actual status is available * Validate CA certificate on FE (#464) * Adds validation on certificate upload * Show notification when error happens * 341901606: (fix) [GAR 1.6]: update for arrows usage on risk profile (#471) Co-authored-by: Volha Mardvilka * Adds space in certificate name regexp (#469) * 342096458: (fix) [GAR 1.3] add focus trap to certificates panel to prevent move focus to risk profile (#472) Co-authored-by: Volha Mardvilka * The focus is not moved to the snackbar with certificate validation rule (#470) * Adds class to fix focus flow * Changes the delay according to string length * Adds file name in error message (#473) * Add certificate endpoints (#451) * Adds version analytics event (#306) * Techdebt: adds state for testrun page (#392) * Fix tests (#397) * Fix tests * Update node version * 331379891: (feat) disable connection settings when testrun is in progress (#371) * 331379891: (feat) disable connection settings when testrun is in progress * 331379891: (fix) include more testrun results as progress * 331379891: (fix) fix spelling * 333349715: (fix) GAR 1.3 The disabled system settings panel contains a focusable element (#388) Co-authored-by: Volha Mardvilka --------- Co-authored-by: Volha Mardvilka * Disable device item if device in progress (#370) * Adds ga to track testrun initiation (#415) Adds ga ti track testrun initiation * Certs progress * Add list and upload cert endpoint * Add delete certificate endpoint * Update cert response codes * Upgrade dependency --------- Co-authored-by: Sofia Kurilova Co-authored-by: Olga Mardvilko Co-authored-by: Volha Mardvilka * Fix modify devices test (#449) * Fix modify devices * Remove excess logging * "Waiting for Device" :the snack bar appears on all pages (#474) * Make waiting for device snackbar global * Do not dismiss snack bar onDestroy * Rename testrun component (#480) * Rename settings component (#476) * Rename testrun component (#477) * Rename testrun component * Improve API test coverage (#291) * Improve API test coverage * Add timeouts * Improve coverage * Increase API timeout Signed-off-by: J Boddey * Pylint fixes * Disable broken tests * Fix skip * Disable broken tests * Fix test --------- Signed-off-by: J Boddey * Remove certificate if BE error happens (#484) * Add cert status (#478) * Add ethtool to make and docs (#483) * Add exception handling to certificate upload (#479) * Add cert status * Add exception handling to cert upload * Add get profiles format endpoint (#475) * Add get profiles format endpoint * Update risk assessment format * Re-add modules endpoint * Update session.py Signed-off-by: J Boddey --------- Signed-off-by: J Boddey * Reduce locations of Testrun version (#453) * Reduce locations of Testrun version * Update from comments * Resolve make control file --------- Signed-off-by: J Boddey * Allow stop testrun from any other page (#487) * Feature/tls client protocols (#485) * Add methods to allow known protocols that dont fit into the strict tls client detection methods Add QUIC protocol to approved protocols Start moving unit tests to a docker container for consistency * Fix client connections protocol method script Downgrade cryptography and pyOpenSSL libraries Move unit testing into docker for TLS module * Misc cleanup and pylint issues --------- Co-authored-by: J Boddey * The focus goes to the main page element after adding the ceriticate (#489) * Focus first interactive element in cert container when cert snack bar closed * The snack bar with test attempt status appears if the user is not on Testing page (#488) * Remove testrun status snack bar * Update consent form (#490) * Adds opt out checkbox * Adds announce when settings or certificate panel is opened (#493) * Set status code on failed cert upload (#491) * Add risk profiles (#486) * Add get profiles format endpoint * Update risk assessment format * Re-add modules endpoint * Work on profiles * Add load profiles format * Pylint * Add check status --------- Signed-off-by: J Boddey * Add test count to PDF report (#482) * Add test count to PDF report * Fix pylint issue * Exclude error --------- Signed-off-by: J Boddey * Change network mode on network modules to fix gateway routing (#495) * 342365574: (feat) display info about Risk Assessment during testing (#492) * 342365574: (feat) display info about Risk Assessment during testing * 342365574: (fix) change to show Risk Assessment callout only on InProgress --------- Co-authored-by: Volha Mardvilka * Updates status with data from start response (#494) * 341966862: (feat) display info about risk assessment in welcome modal (#496) Co-authored-by: Volha Mardvilka * Adds download zip window (#497) * 344874424: (fix) disable device tile in Canceling status (#500) Co-authored-by: Volha Mardvilka * Fix/UI/345164706 (#501) * 345164706: (fix) disable start testrun btn in canceling state * 345164706: (fix) remove commented code --------- Co-authored-by: Volha Mardvilka * GA option issues on the Welcome modal (#502) * Fix ga initial value * Fix label * 345202815: (fix) callout with the RA message is shown on the RA page (#503) Co-authored-by: Volha Mardvilka * 345203686: (fix) update callout block view on the welcome modal (#504) Co-authored-by: Volha Mardvilka * Allow UI to specify modules (#505) * Update risk assessment format (#499) * Fix issue with checking for error result (#498) * Fix issue with checking for error result * Resolve issue * Update html in test report Signed-off-by: J Boddey --------- Signed-off-by: J Boddey * Announce disabled state of settings panel (#506) * The Downlaod ZIP action can not be performed using the keaboard (#508) * Changes aria-label for Download Anyway button * Fix keyboard navigation for Download zip component * Add create and delete profile endpoints (#507) * Work towards creating profiles * Add delete profile endpoint * Exclude link local for arp (#418) * Add feature not detected test result (#396) * Add feature not present test result * Remove changes to tls module * Further pylint fixes * Add Feature Not Present to relevant tests * Reduce pylint limit * Change to feature not detected * Modify bacnet result * Update DNS test --------- Signed-off-by: J Boddey * Add extension for cert upload (#510) * 340859666: (feat): display risk profile form with name field (#514) Co-authored-by: Volha Mardvilka * Fixes some pylint issues (#511) * Fix some pylint issues * Fix more pylint issues * bug/test_baseline (#513) * Update mac address for baseline config dynamically * fix line endings * Update device config via Testrun api * Add API to no ui mode * Fix API endpoint call * Remove mac address update Change mac address in container * Add container start command back * Skip DNS tests * Cleanup --------- Co-authored-by: J Boddey * Attach profile to ZIP report (#518) * Work towards creating profiles * Add delete profile endpoint * Work on attaching profile to zip * Pylint * Fix zipping * Pylint fixes --------- Signed-off-by: J Boddey * Generate Risk profile from json (#515) Generate form from json * The Downlaod ZIP action can not be performed using the keyboard (#517) * Stop propagating event to eliminate the error * Fix tests * Adds tooltip * Fix export endpoint to use POST request * Fix zip download --------- Co-authored-by: Jacob Boddey * Add required if applicable (#519) * WIP: Add required if applicable * Fix bug with TLS client test * Update TLS results * Fix styles for helperbird (#524) * Form from json validation (#523) * Adds field validation * Fix vulnerabilities in dependencies (#526) * Enable draft button when profile name is present; enable save button when form is valid; remove discard button (#530) * Correct result on tls client test (#528) * Add informational and fnd to report (#527) * Mark fields required when trimmed value is empty (#529) * Update requests dependency (#525) * Remove debug artifact (#531) * Fix scroll area on reports page (#532) * Announce risk form open; focus first element in container (#536) * Fix validation; change element for text-long (#538) * 345258435: (feat) display expired certificate (#539) Co-authored-by: Volha Mardvilka * 348187954: (fix) update callouts position on the small window size (#540) Co-authored-by: Volha Mardvilka * 348356236: (fix) add tooltips for icons without any accompanying text (#541) Co-authored-by: Volha Mardvilka * 348353479: (fix) add tooltip for the download zip button using the keyboard (#542) Co-authored-by: Volha Mardvilka * 348361925: (fix) add helper text for mandatory profile name field (#543) Co-authored-by: Volha Mardvilka * Save new risk profile (#533) * Save new risk profile * Clear and close form after profile saved * Adds status valid for save profile * Fix DNS report when DNS packet is missing the qname property (#546) * Adds edit risk profile (#544) * Refactor delete form: rename it to simple dialog as it is not used for delete anymore * Adds edit risk profile * Fix autozise (#547) * Feature/risk profile (#522) * Update risk profile * Add unit tests for risk profile Fix service nmap unit test * Add unit tests for risk profile Fix service nmap unit test * Update api and session for new risk profile methods * pylint * pylint * Fix some pylint * Fix more pylint * Fix some bugs * Update risk profile logic (#535) * Update risk profile logic * Update test profiles Remove duplicate test file Cleanup temp test files * Remove categories from profile * Update expiration check to account for leap years * Update risk assessment questions * Update dependency * Add missing question * Update format and add error handling --------- Co-authored-by: jhughesbiot --------- Signed-off-by: J Boddey Co-authored-by: Jacob Boddey * Adds save draft (#549) Co-authored-by: Sofia Kurilova * 346351108: (feat) display risk assessment result (#553) * Close form after selected risk profile was deleted (#554) Co-authored-by: Sofia Kurilova * Fix error when updating profile (#555) * Render informational result correctly (#551) * Re-add method for exporting profle (#550) * Fix bug when failed to fetch latest version (#548) * 349769454: (fix) update size on the empty reports page to prevent overlapping (#557) * Fix bad multiple ip report when no ip requested (#556) * Fix Duplicate Certificate Names (#545) * Check for existing common name before uploading new cert Misc pylint updates * Re-add missing session content --------- Signed-off-by: J Boddey Co-authored-by: Jacob Boddey * Changes the icon; adds create date (#552) * 349783464: (fix) add styles for selected elements and fix form size on RA (#558) * 349793005: (fix) change focus to profile form to scroll up on opening profile (#559) * Return focus on create button when new profile created; return focus on profile is profile was edited (#560) * Small refactoring; update validators on selected profile update and on profile list update (#564) * Enables save and draft button for edit mode (#565) * Validate multi select form group on last checkbox tab press (#566) * Remove copy button (#567) * Bug/bacnet device (#568) * Change BACnet device detection validation Add unit tests * Fix runtime * cleanup * Update unit tests to match runtime types * Show only valid profile in modal; fix condition to not download profile if redirect button clicked (#569) * Fix bug when saving draft profile (#563) * Fix bug when saving draft * Remove risk when status changes to draft * Re-add exception handling * Fix formatting : * Update questions before calculating risk * Update unit testing * Update unit testing * Invoke risk profile update method in favor of manually updating properties in session * Update risk after update and fix type error * Update risk correctly --------- Signed-off-by: J Boddey Co-authored-by: jhughesbiot * Focus title or first element in container when navigation is triggered by enter (#570) * Adds aria label and tooltip to risk profile icons (#572) * Add profile PDF (#562) * Work on pdf * Work on profile PDF * Fix risk answer formatting * Downgrade weasyprint * Remove duplicate line * Update risk assessment after review * Fix profile format undefined * Update zip file to use /tmp directory (#571) Update device report folder Update file paths to use old and new patterns Co-authored-by: J Boddey * Remove cert from session after delete request (#575) * Fix error when only draft profiles are exist (#576) * Focus fix; fix profile status icon (#579) * Fix focus after page is opened * Fix profile status * Support longer string answers in profile PDF (#578) * Add extra spacing for long string answers * Format JSON * Cleanup old test devices from runtime (#583) * 351758698: (fix) update app version styles to meet design on expanded nav (#586) * Update risk profile description (#582) * Bump version for release (#584) * Remove skipped result (#580) * Prevent creating device with duplicate manufacturer and model (#581) * Prevent duplicate mf and model * Change error message to be more precise * Fix logic * Remove profile from runtime once included in ZIP (#577) * Remove profile after ZIP creation * Create /tmp dir for results Copy test results and profile to results dir Zip results dir and cleanup --------- Co-authored-by: jhughesbiot * Do not show settings callout if there are no interfaces and saved settings (#587) * Fix modbus results (#588) * Don't show error if config is empty; don't show settings callout when settings are not empty after saving (#589) * Catch error to proceed with device creation/editing (#592) * Remove output logging from OS level commands (#594) * Fix cancel after monitor bug and add testrun.log (#595) * Fix step 1 callout error (#593) * Fix GAR bug with cert upload (#590) * Change exception logic on cert upload * Fix error logic * Update documentation (#591) * Work on docs changes * Update docs * Update roadmap * Update docs * Update docs * Remove dev README.md * Remove skipped from docs * Add exception handling to timestamp parsing (#598) * Change exception logic on cert upload * Fix error logic * Add exception handling to timestamp parse * Stick button to the bottom of risk page (#574) * Adds copy of risk profile (#573) * Lint fix * Set testrun IDLE status if report of finished test run is removed (#585) * Adds Discard risk profile (#596) * Load test modules dynamically (#597) * Update download zip modal: add link to Risk Profiles, remove redirect button, rename download button, add No Profile option (#599) * 351338001: (feat) update rule for the third step message (#600) * 347009372: (feat) add selector for profile options for GA4 (#604) * Adds tooltip for copy and delete; show "same name" error when more than one copy is created (#606) * 346999760: (feat) [GA4] Track CA Certificates (#607) * 353476778: (fix) GAR 2.11 change for prevent risk profile tiles overlap (#609) * If profile is editing, return focus on profile (#612) * Change title for save profile dialog according to type of profile (#610) * Get reports when app is opened and when report page is opened; change status if testrun does not exist in reports; do not change status if testrun is just finished but not in reports yet (#613) * Show tooltip only on keyboard or hover (#614) * Update condition as report field is unique (#615) * Expired profile profile (#619) * Use device mac_addr if report mac_addr is missing (#622) * get system network interfaces util func * test get_sys_interfaces * pylint * get all network interfaces on session start * dicts diff * detect network adapter change * pylint * logging * Add ws server * Add MQTT protocol * Upgrade ws server * paho-mqtt dependency * getting docker container IP by container name * MQTT client class * Fix pylint * rename mqtt client logger * APScheduler * initializing the client inside the testrun object * pylint * check networks adapters in background * remove extra lines at the end of a file * rename mgtt logger * move network_adapters_checker to network_orchestrator * Adds mqtt client, adds pop up when new adapter is available (#603) * Fix some pylint issues (#620) * Disabled test results (#212) * Better handling of disbled tests Add dns and tls tests back in disabled state * pyling fixes * Fix subscriptable error * Re-add old tests as informational --------- Co-authored-by: J Boddey * remove get ip of docker container because it is not necessary * Fix individual test disabling when run from the UI (#629) * Fix individual test disabling when run from the UI * Remove disabled result for now --------- Co-authored-by: Jacob Boddey * Update package.yml (#624) Signed-off-by: J Boddey * Added risk profile api testing (#628) * added tests for profile endpoints * Modified test_start_testrun_started_successfully payload to match the expected json format, updated the profile endpoints tests * fixed the pylint errors from test_api.py * fixed few more pylint errors * Expired profile profile (#619) * Use device mac_addr if report mac_addr is missing (#622) * Fix some pylint issues (#620) * Disabled test results (#212) * Better handling of disbled tests Add dns and tls tests back in disabled state * pyling fixes * Fix subscriptable error * Re-add old tests as informational --------- Co-authored-by: J Boddey * added try-except block to delete_all_profiles() * Revert session file back * Add new line * Update api testing * updated the tests for profile endpoint, added a new fixture (add_profile) to create a profile * Updated profile endpoints: created a new fixture add_profile for creating profiles * Fix pylint * Fix pylint * Fix pylint issues --------- Co-authored-by: Sofia Kurilova Co-authored-by: J Boddey Co-authored-by: jhughesbiot * Adds close button for expired profile (#635) * Copy changes from hotfix 1.3.1 to dev (#631) * Copy changes from hotfix 1.3.1 to dev * Add pydyf version Signed-off-by: J Boddey --------- Signed-off-by: J Boddey * Inform FE about a new network adapter discovered( rename mqtt topic according to naming convention) (#634) * rename mqtt topic * Rename topic in ui --------- Co-authored-by: kurilova Co-authored-by: J Boddey * Fix network only mode issues (#617) Co-authored-by: J Boddey * Update all unit tests to work within the runtime environment (#611) * Update all unit tests to work within the runtime environment * Fix some formatting * fix line endings * update gitignore * Fix binary files in dockerfile * Change unit tests to run from testrun root directory * Run unit tests in actions * Fix pylint issues * Change command in actions * Update testing.yml Signed-off-by: J Boddey --------- Signed-off-by: J Boddey Co-authored-by: Jacob Boddey * Feature/dns report update (#637) * Update DNS module report Downgrade python packages for tls module * Fix header * pylint fixes * refactor func to handle case when network interface not exists * set test result "Error" * check device connected * thread for monitoring device connection * Minor changes * check the device connection only before each test * Adds tooltip (#638) Adds tooltip * send testrun status using mqtt * remove duplicatied line * refactor setting remaining tests to error * pylint * Fix focus after profile delete - track by name (#640) Fix focus after profile delete - track by name * Update the requests dependency (#643) * Update requests dependency * Update requests dependency * Update dependency in TLS test * Update docker dependency --------- Signed-off-by: J Boddey * Revert "Expired profile (#619)" (#645) Prevent opening of Expired risk profile * Improve documentation (#639) * Improve docs * Remove paragraph * Text changes * Fix text for the BE error * Change tooltip (#650) * Change tooltip * Allows draft profiles to become expired (#636) * Allow draft profiles to expire * Move status method into risk profile class * Use existing method * Check for expiry in validate method * Remove unused variable * Build UI during package instead of install (#621) * Build UI during package * Fix local build * Install npm * Remove duplicate build message * Fix ESLint * Fix script * Modify scripts * Improve scripts * Fix copy command * Try installing package * Depend on package job * Add sudo * Add sudo * Troubleshoot * Fix workflow * Checkout source for prepare command * Built ui within a container * Mount src files for build instead of static copy in build image * Attempt to fix actions * Remove manual build container cleanup methods * undo failed attempts to fix actions * Fix path * Remove -it flag --------- Signed-off-by: J Boddey Co-authored-by: kurilova Co-authored-by: jhughesbiot * Feature/risk in selected (#654) * Adds risk to selected value * Adds risk to selected value --------- Co-authored-by: J Boddey * Show risk for each question in the Risk profile (#647) * Show risk for each question in the Risk profile * set top position to 0 --------- Co-authored-by: J Boddey * Show internet connection (#653) * MQTT show internet connection * remove unused method * Change tooltip for internet icon (#656) * Change tooltip for internet icon * Remove unused import' --------- Co-authored-by: Jacob Boddey --------- Signed-off-by: J Boddey Co-authored-by: J Boddey Co-authored-by: Sofia Kurilova * bug/modbus_constructor (#657) * Pin all required packages Update modbus constructor to prevent error Add full trace logging for general errors in tests * Fix pylint issue --------- Co-authored-by: Jacob Boddey * Use mqtt service instead of calling GET /status every 5 seconds. (#644) * Use mqtt service instead of calling GET /status every 5 seconds. * Adds tooltip (#638) Adds tooltip * Fix focus after profile delete - track by name (#640) Fix focus after profile delete - track by name * Update the requests dependency (#643) * Update requests dependency * Update requests dependency * Update dependency in TLS test * Update docker dependency --------- Signed-off-by: J Boddey * remove unused output * encode mqtt message to json * Revert "Expired profile (#619)" (#645) Prevent opening of Expired risk profile * Improve documentation (#639) * Improve docs * Remove paragraph * Text changes * Fix text for the BE error * Change tooltip (#650) * Change tooltip * Allows draft profiles to become expired (#636) * Allow draft profiles to expire * Move status method into risk profile class * Use existing method * Check for expiry in validate method * Remove unused variable * Build UI during package instead of install (#621) * Build UI during package * Fix local build * Install npm * Remove duplicate build message * Fix ESLint * Fix script * Modify scripts * Improve scripts * Fix copy command * Try installing package * Depend on package job * Add sudo * Add sudo * Troubleshoot * Fix workflow * Checkout source for prepare command * Built ui within a container * Mount src files for build instead of static copy in build image * Attempt to fix actions * Remove manual build container cleanup methods * undo failed attempts to fix actions * Fix path * Remove -it flag --------- Signed-off-by: J Boddey Co-authored-by: kurilova Co-authored-by: jhughesbiot * Feature/risk in selected (#654) * Adds risk to selected value * Adds risk to selected value --------- Co-authored-by: J Boddey * Show risk for each question in the Risk profile (#647) * Show risk for each question in the Risk profile * set top position to 0 --------- Co-authored-by: J Boddey * Use mqtt service instead of calling GET /status every 5 seconds. * Use mqtt service instead of calling GET /status every 5 seconds. * Use mqtt service instead of calling GET /status every 5 seconds. * pylint --------- Signed-off-by: J Boddey Co-authored-by: J Boddey Co-authored-by: Aliaksandr Nikitsin Co-authored-by: jhughesbiot * Adds test statuses (#661) * Ignore folders when loading certs (#660) * Remove scorecard schedule and bump version (#659) * Allow ICMP response to DHCP messages in DHCP snooping test (#608) * Allow ICMP response to DHCP messages * Bug/unit test runtime (#655) * Change base test module startup to allow setup script to run independent of module startup process Update connection_module to allow for unit testing Update unit test run script to use new process * enable all unit tests update google cert * Remove binary fix lines from docker files pylint updates * pylint updates --------- Co-authored-by: jhughesbiot * The risk profile saved with old format is shown improperly while loading based on a new format (#664) * Fill only fields that are present in profile * GAR : The alt text for the expired risk profile should be communicated on Enter key (#662) * Change Expired profile title on Enter; announce Expired profile title on Enter * Update wording of tls cipher results (#671) * Show error message if provided; show default message if no (#680) * Test install on supported operating systems (#675) * Test install on multiple versions * Update step names * Remove recommendations on error (#674) * Tests for API (#649) * changed the tests order in test_api.py * added tests for '/system/config' POST endpoint * added the tests for 'system/shutdown' endpoint * added the test for GET '/reports' endpoint, updated 'test_update_system_config_invalid_config' to return error 400 * Check for missing fields Signed-off-by: J Boddey * added tests for delete profile (404, 400), added tests for create and update profile (400), added test 'run_test_and_get_report' skipped due to blocking during testing phase * added a new json file in '/testing/api/' used in 400 error tests * added error handling if 'name' and 'questions' not in profile json * fixed pylint * Added tests when update is available and 500 status code for '/system/version', test for system/modules * added responses library in requirements.txt * fixed the requested changes in api.py * Renamed the load_profile method to load_json and changed the logic to allow to load any json based on file name and relative path, corrected the new lines issues * updated restore_config fixture to run after the test * added test for create/update profile (500 error) * fixed pylint * fixed spacing, removed get_report_one_report * added tests: 500 error for delete '/profiles', 500 error for 'profles/format', 400, 404, 409 for '/system/start' * modified the tests for 500 response * added new profile with missing 'answer' * removed the tests with mock response * Update NTP report (#666) * Update NTP report * cleanup imports * pylint updates * modified update profile for bad request * changed validate_profile_json: handling empty spaces in name and question, handle if 'risk' field missing if status is 'Valid' * updated the requested changes * Add further profile validation * Fix pylint issues * Fix profile tests * Move validation to session * Fix pylint issue Signed-off-by: J Boddey --------- Signed-off-by: J Boddey Co-authored-by: J Boddey Co-authored-by: jhughesbiot * Fix a delay in the internet connectivity check (#669) * Add a timeout to the command * move single-intf check to scheduler * add timeout arg to run_command * move jobs to constructor * fping for internet connection checking * Update internet connection when device is In Progress, Monitoring, Waiting for Device status (#677) * Update internet connection when device is In Progress, Monitoring, Waiting for Device status * check if interface physically connected * Revert "fping for internet connection checking" --------- Co-authored-by: Aliaksandr Nikitsin Co-authored-by: Sofia Kurilova * Check if device folder already exists (#678) * Check if device folder already exists * Fix error message * Fix pylint issue * Remove debug mqtt logs (#692) --------- Signed-off-by: J Boddey Signed-off-by: dependabot[bot] Co-authored-by: jhughesbiot Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sofia Kurilova Co-authored-by: Olga Mardvilko Co-authored-by: Volha Mardvilka Co-authored-by: jhughesbiot <50999916+jhughesbiot@users.noreply.github.com> Co-authored-by: Sofia Kurilova Co-authored-by: Aliaksandr Nikitsin Co-authored-by: Marius <86727846+MariusBaldovin@users.noreply.github.com> --- .github/workflows/package.yml | 68 +- .github/workflows/scorecard.yml | 6 +- .github/workflows/testing.yml | 24 +- .gitignore | 2 + README.md | 4 +- cmd/build | 19 +- cmd/build_ui | 37 + cmd/install | 18 +- cmd/package | 13 +- docs/README.md | 3 + docs/dev/README.md | 25 + docs/dev/code_quality.md | 16 + docs/network/README.md | 3 +- docs/network/add_new_service.md | 2 +- docs/test/README.md | 1 - docs/test/modules.md | 2 +- framework/python/src/api/api.py | 47 +- framework/python/src/common/mqtt.py | 62 + framework/python/src/common/risk_profile.py | 43 +- framework/python/src/common/session.py | 252 ++- framework/python/src/common/tasks.py | 78 + framework/python/src/common/util.py | 36 +- framework/python/src/core/testrun.py | 70 +- framework/python/src/net_orc/ip_control.py | 36 +- .../src/net_orc/network_orchestrator.py | 53 +- .../python/src/test_orc/test_orchestrator.py | 39 +- framework/requirements.txt | 12 +- make/DEBIAN/control | 2 +- modules/test/base/README.md | 7 + modules/test/base/bin/setup | 71 + modules/test/base/bin/start | 13 +- modules/test/base/bin/start_module | 146 +- modules/test/base/python/src/test_module.py | 13 +- .../test/conn/python/src/connection_module.py | 42 +- modules/test/conn/python/src/dhcp_util.py | 2 +- modules/test/dns/README.md | 3 +- modules/test/dns/conf/module_config.json | 6 + modules/test/dns/python/src/dns_module.py | 96 +- modules/test/ntp/python/src/ntp_module.py | 101 +- modules/test/protocol/bin/start_test_module | 104 +- modules/test/protocol/python/requirements.txt | 8 +- .../protocol/python/src/protocol_modbus.py | 2 +- .../services/python/src/services_module.py | 4 +- .../tls/bin/get_tls_client_connections.sh | 62 +- modules/test/tls/conf/module_config.json | 21 + modules/test/tls/python/requirements-test.txt | 1 + modules/test/tls/python/requirements.txt | 6 +- modules/test/tls/python/src/tls_util.py | 6 +- modules/test/tls/tls.Dockerfile | 14 +- modules/ui/angular.json | 8 +- .../build.sh => modules/ui/build.Dockerfile | 9 +- modules/ui/package-lock.json | 256 ++- modules/ui/package.json | 1 + modules/ui/src/app/app.component.html | 20 +- modules/ui/src/app/app.component.scss | 6 + modules/ui/src/app/app.component.spec.ts | 71 +- modules/ui/src/app/app.component.ts | 3 + modules/ui/src/app/app.module.ts | 10 + modules/ui/src/app/app.store.spec.ts | 84 +- modules/ui/src/app/app.store.ts | 75 +- .../download-report-zip.component.spec.ts | 8 +- .../download-report-zip.component.ts | 4 +- .../download-zip-modal.component.html | 93 +- .../download-zip-modal.component.scss | 23 +- .../download-zip-modal.component.spec.ts | 38 +- .../download-zip-modal.component.ts | 27 +- .../snack-bar/snack-bar.component.html | 5 +- .../app/components/wifi/wifi.component.html | 25 + .../app/components/wifi/wifi.component.scss | 40 + .../components/wifi/wifi.component.spec.ts | 100 ++ .../src/app/components/wifi/wifi.component.ts | 40 + .../interceptors/error.interceptor.spec.ts | 30 +- .../src/app/interceptors/error.interceptor.ts | 8 +- modules/ui/src/app/mocks/device.mock.ts | 4 +- modules/ui/src/app/mocks/profile.mock.ts | 54 + modules/ui/src/app/mocks/reports.mock.ts | 45 +- modules/ui/src/app/mocks/settings.mock.ts | 7 +- modules/ui/src/app/mocks/testrun.mock.ts | 2 +- modules/ui/src/app/mocks/topic.mock.ts | 5 + modules/ui/src/app/model/profile.ts | 6 +- modules/ui/src/app/model/setting.ts | 5 + modules/ui/src/app/model/testrun-status.ts | 21 +- modules/ui/src/app/model/topic.ts | 9 + .../certificates/certificates.store.spec.ts | 17 + .../pages/certificates/certificates.store.ts | 4 + .../device-form/device.validators.ts | 5 +- .../app/pages/devices/devices.component.html | 8 +- .../pages/devices/devices.component.spec.ts | 31 +- .../app/pages/devices/devices.component.ts | 27 +- .../app/pages/devices/devices.store.spec.ts | 3 + .../ui/src/app/pages/devices/devices.store.ts | 9 +- .../pages/reports/reports-routing.module.ts | 2 +- .../pages/reports/reports.component.spec.ts | 18 +- .../app/pages/reports/reports.component.ts | 173 ++ .../src/app/pages/reports/reports.module.ts | 2 +- .../app/pages/reports/reports.store.spec.ts | 100 +- .../ui/src/app/pages/reports/reports.store.ts | 91 +- .../src/app/pages/reports/reportscomponent.ts | 3 +- .../profile-form/profile-form.component.html | 15 + .../profile-form/profile-form.component.scss | 14 +- .../profile-form.component.spec.ts | 55 +- .../profile-form/profile-form.component.ts | 13 +- .../profile-form/profile.validators.ts | 8 +- .../profile-item/profile-item.component.html | 43 +- .../profile-item/profile-item.component.scss | 17 +- .../profile-item.component.spec.ts | 37 +- .../profile-item/profile-item.component.ts | 36 +- .../risk-assessment.component.html | 14 +- .../risk-assessment.component.scss | 2 +- .../risk-assessment.component.spec.ts | 78 +- .../risk-assessment.component.ts | 58 +- .../pages/settings/settings.component.html | 2 +- .../app/pages/settings/settings.store.spec.ts | 45 +- .../src/app/pages/settings/settings.store.ts | 78 +- .../testrun-initiate-form.component.spec.ts | 22 +- .../testrun-initiate-form.component.ts | 3 +- .../app/pages/testrun/testrun.component.html | 2 +- .../pages/testrun/testrun.component.spec.ts | 16 +- .../app/pages/testrun/testrun.component.ts | 16 +- .../app/pages/testrun/testrun.store.spec.ts | 3 + .../ui/src/app/pages/testrun/testrun.store.ts | 7 + .../services/test-run-mqtt.service.spec.ts | 102 ++ .../src/app/services/test-run-mqtt.service.ts | 38 + .../src/app/services/test-run.service.spec.ts | 56 +- .../ui/src/app/services/test-run.service.ts | 10 +- modules/ui/src/app/store/actions.ts | 25 +- modules/ui/src/app/store/effects.spec.ts | 135 +- modules/ui/src/app/store/effects.ts | 143 +- modules/ui/src/app/store/reducers.spec.ts | 71 +- modules/ui/src/app/store/reducers.ts | 24 + modules/ui/src/app/store/selectors.spec.ts | 28 + modules/ui/src/app/store/selectors.ts | 20 + modules/ui/src/app/store/state.ts | 16 +- modules/ui/ui.Dockerfile | 11 +- modules/ws/conf/mosquitto.conf | 22 + modules/ws/ws.Dockerfile | 4 + testing/api/profiles/new_profile.json | 54 + testing/api/profiles/new_profile_2.json | 56 + testing/api/profiles/updated_profile.json | 57 + testing/api/test_api.py | 1370 ++++++++++++---- testing/pylint/test_pylint | 28 +- testing/tests/test_tests.py | 2 +- testing/unit/conn/captures/monitor.pcap | Bin 0 -> 389089 bytes testing/unit/conn/captures/startup.pcap | Bin 0 -> 3404 bytes testing/unit/conn/conn_module_test.py | 32 +- testing/unit/dns/dns_module_test.py | 30 +- .../unit/dns/reports/dns_report_local.html | 2 +- testing/unit/framework/session_test.py | 57 + testing/unit/framework/util_test.py | 61 + testing/unit/ntp/ntp_module_test.py | 28 +- .../unit/ntp/reports/ntp_report_local.html | 1399 +---------------- .../ntp/reports/ntp_report_local_no_ntp.html | 1 + testing/unit/protocol/protocol_module_test.py | 1 - testing/unit/report/report_test.py | 49 + testing/unit/run.sh | 57 +- testing/unit/run_tests.sh | 69 - testing/unit/services/output/services.log | 6 - testing/unit/services/services_module_test.py | 31 +- testing/unit/tls/certs/_.google.com.crt | 153 +- testing/unit/tls/tls_module_test.py | 6 +- testing/unit/unit_test.Dockerfile | 47 - 161 files changed, 5329 insertions(+), 2892 deletions(-) create mode 100755 cmd/build_ui create mode 100644 docs/dev/README.md create mode 100644 docs/dev/code_quality.md create mode 100644 framework/python/src/common/mqtt.py create mode 100644 framework/python/src/common/tasks.py create mode 100644 modules/test/base/bin/setup create mode 100644 modules/test/tls/python/requirements-test.txt rename testing/unit/build.sh => modules/ui/build.Dockerfile (77%) create mode 100644 modules/ui/src/app/components/wifi/wifi.component.html create mode 100644 modules/ui/src/app/components/wifi/wifi.component.scss create mode 100644 modules/ui/src/app/components/wifi/wifi.component.spec.ts create mode 100644 modules/ui/src/app/components/wifi/wifi.component.ts create mode 100644 modules/ui/src/app/mocks/topic.mock.ts create mode 100644 modules/ui/src/app/model/topic.ts create mode 100644 modules/ui/src/app/pages/reports/reports.component.ts create mode 100644 modules/ui/src/app/services/test-run-mqtt.service.spec.ts create mode 100644 modules/ui/src/app/services/test-run-mqtt.service.ts create mode 100644 modules/ws/conf/mosquitto.conf create mode 100644 modules/ws/ws.Dockerfile create mode 100644 testing/api/profiles/new_profile.json create mode 100644 testing/api/profiles/new_profile_2.json create mode 100644 testing/api/profiles/updated_profile.json create mode 100644 testing/unit/conn/captures/monitor.pcap create mode 100644 testing/unit/conn/captures/startup.pcap create mode 100644 testing/unit/framework/session_test.py create mode 100644 testing/unit/framework/util_test.py delete mode 100644 testing/unit/run_tests.sh delete mode 100644 testing/unit/services/output/services.log delete mode 100644 testing/unit/unit_test.Dockerfile diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 0fdb8c379..8c4b5bcbe 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -7,9 +7,13 @@ on: push: branches: - 'dev' + - 'release/*' + +permissions: + contents: read jobs: - testrun_package: + create_package: permissions: {} name: Package runs-on: ubuntu-22.04 @@ -24,4 +28,64 @@ jobs: uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0 with: name: testrun_package - path: testrun*.deb \ No newline at end of file + path: testrun*.deb + + install_package_20: + permissions: {} + needs: create_package + name: Install on Ubuntu 20.04 + runs-on: ubuntu-20.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb + + install_package_22: + permissions: {} + needs: create_package + name: Install on Ubuntu 22.04 + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb + + install_package_24: + permissions: {} + needs: create_package + name: Install on Ubuntu 24.04 + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 12884c718..f0f89a631 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -7,10 +7,6 @@ on: # For Branch-Protection check. Only the default branch is supported. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection branch_protection_rule: - # To guarantee Maintained check is occasionally updated. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained - schedule: - - cron: '20 6 * * 4' push: branches: [ "main" ] @@ -70,4 +66,4 @@ jobs: - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 with: - sarif_file: results.sarif + sarif_file: results.sarif \ No newline at end of file diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0556a2189..d6deb1ab0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -39,7 +39,7 @@ jobs: run: cmd/prepare - name: Install Testrun shell: bash {0} - run: TESTRUN_DIR=. cmd/install + run: cmd/install -l timeout-minutes: 30 - name: Run tests shell: bash {0} @@ -55,6 +55,28 @@ jobs: name: runtime_api_${{ github.run_id }} path: runtime.tgz + testrun_unit: + permissions: {} + name: Unit + runs-on: ubuntu-20.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Install dependencies + shell: bash {0} + run: cmd/prepare + - name: Install Testrun + shell: bash {0} + run: cmd/install -l + - name: Build Testrun + shell: bash {0} + run: cmd/build + timeout-minutes: 10 + - name: Run tests + shell: bash {0} + run: bash testing/unit/run.sh + pylint: permissions: {} name: Pylint diff --git a/.gitignore b/.gitignore index 82b6bbf64..92779dc04 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ # Ignore generated files from unit tests testing/unit_test/temp/ +testing/unit/conn/output/ testing/unit/dns/output/ testing/unit/nmap/output/ testing/unit/ntp/output/ @@ -15,6 +16,7 @@ testing/unit/tls/output/ testing/unit/tls/tmp/ testing/unit/report/output/ testing/unit/risk_profile/output/ +testing/unit/services/output/ *.deb make/DEBIAN/postinst diff --git a/README.md b/README.md index 4a04e8885..23fd843ca 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ When manual testing or configuration changes are required, Testrun will provide - DHCP client - The device must be able to obtain an IP address via DHCP ## Get started ▶️ -Once you have met the hardware and software requirements, you can get started with Testrun by following the [Get started guide](docs/get_started.md). +Once you have met the hardware and software requirements, you can get started with Testrun by following the [Get started guide](docs/get_started.md). Further docs are available in the [docs directory](docs) ## Roadmap :chart_with_upwards_trend: Testrun will constantly evolve to further support end-users by automating device network behaviour against industry standards. For further information on upcoming features, check out the [Roadmap](docs/roadmap.pdf). @@ -59,7 +59,7 @@ We are proud of our tool and strive to provide an enjoyable experience for all o If the application has come across a problem at any point during setup or use, please raise an issue under the [issues tab](https://github.com/google/testrun/issues). Issue templates exist for both bug reports and feature requests. If neither of these are appropriate for your issue, raise a blank issue instead. ## Contributing :keyboard: -The contributing requirements can be found in [CONTRIBUTING.md](CONTRIBUTING.md). In short, checkout the [Google CLA](https://cla.developers.google.com/) site to get started. +The contributing requirements can be found in [CONTRIBUTING.md](CONTRIBUTING.md). In short, checkout the [Google CLA](https://cla.developers.google.com/) site to get started. After that, check out our [developer documentation](docs/dev/README.md). ## FAQ :raising_hand: 1) I have an issue whilst installing/upgrading Testrun, what do I do? diff --git a/cmd/build b/cmd/build index d15171f31..d3294a681 100755 --- a/cmd/build +++ b/cmd/build @@ -36,15 +36,28 @@ fi # Builds all docker images echo Building docker images -# Build user interface -echo Building user interface -if docker build -t test-run/ui -f modules/ui/ui.Dockerfile . ; then +# Check if UI has already been built (if -l was used during install) +if [ ! -d "modules/ui/dist" ]; then + cmd/build_ui +fi + +# Build UI image +if docker build -t testrun/ui -f modules/ui/ui.Dockerfile . ; then echo Successully built the user interface else echo An error occured whilst building the user interface exit 1 fi +# Build websockets server +echo Building websockets server +if docker build -t testrun/ws -f modules/ws/ws.Dockerfile . ; then + echo Successully built the web sockets server +else + echo An error occured whilst building the websockets server + exit 1 +fi + # Build network modules echo Building network modules mkdir -p build/network diff --git a/cmd/build_ui b/cmd/build_ui new file mode 100755 index 000000000..afb0d8827 --- /dev/null +++ b/cmd/build_ui @@ -0,0 +1,37 @@ +#!/bin/bash -e + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Build the UI +echo Building the ui builder + +# Build UI builder image +if docker build -t testrun/build-ui -f modules/ui/build.Dockerfile . ; then + echo Successully built the ui builder +else + echo An error occured whilst building the ui builder + exit 1 +fi + +# Check that the container is not already running +docker kill tr-ui-build 2> /dev/null || true + +echo "Building the user interface" + +# Start build container and build the ui dist +docker run --rm -v $PWD/modules/ui:/modules/ui testrun/build-ui /bin/sh -c "npm install && npm run build" + +# Kill the container (Should not be running anymore) +docker kill tr-ui-build 2> /dev/null || true diff --git a/cmd/install b/cmd/install index 53d12b324..c350a969f 100755 --- a/cmd/install +++ b/cmd/install @@ -20,15 +20,29 @@ echo Installing application dependencies while getopts ":l" option; do case $option in l) # Install Testrun in local directory - TESTRUN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")"/.. && pwd) + TESTRUN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")"/.. && pwd) esac done # Check if TESTRUN_DIR has been set, otherwise install in /usr/local/testrun if [[ -z "${TESTRUN_DIR}" ]]; then TESTRUN_DIR=/usr/local/testrun + + # Check that user is sudo + if [[ "$EUID" -ne 0 ]]; then + echo "Installing Testrun in the default location requires sudo. Run using sudo cmd/install" + exit 1 + fi + else TESTRUN_DIR="${TESTRUN_DIR}" + + # Check that user is in docker group + if ! (id -nGz "$USER" | grep -qzxF "docker"); then + echo User is not in docker group. Follow https://docs.docker.com/engine/install/linux-postinstall/ to finish setting up docker. + exit 1 + fi + fi echo Installing Testrun at $TESTRUN_DIR @@ -51,7 +65,7 @@ cp -n local/system.json.example local/system.json deactivate # Build docker images -sudo cmd/build +cmd/build # Create local folders mkdir -p local/devices diff --git a/cmd/package b/cmd/package index fc418ab05..719258a83 100755 --- a/cmd/package +++ b/cmd/package @@ -16,6 +16,12 @@ # Creates a package for Testrun +# Check that user is not root +if [[ "$EUID" == 0 ]]; then + echo "Must not run as root. Use cmd/package as regular user" + exit 1 +fi + MAKE_SRC_DIR=make MAKE_CONTROL_DIR=make/DEBIAN/control @@ -25,10 +31,10 @@ version=$(grep -R "Version: " $MAKE_CONTROL_DIR | awk '{print $2}') # Replace invalid characters version="${version//./_}" -# Delete existing make files -rm -rf $MAKE_SRC_DIR/usr +echo Building package for testrun v${version} # Delete existing make files +echo Cleaning up previous build files rm -rf $MAKE_SRC_DIR/usr # Copy testrun script to /bin @@ -60,6 +66,9 @@ mkdir -p $MAKE_SRC_DIR/usr/local/testrun/local/risk_profiles mkdir -p local/root_certs cp -r local/root_certs $MAKE_SRC_DIR/usr/local/testrun/local/ +# Build the UI +cmd/build_ui + # Copy framework and modules into testrun folder cp -r {framework,modules} $MAKE_SRC_DIR/usr/local/testrun diff --git a/docs/README.md b/docs/README.md index 96eb32223..5f055dbb9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,3 +16,6 @@ - [Running on a virtual machine](virtual_machine.md) - [Accessibility](ui/accessibility.mp4) - [Roadmap](roadmap.pdf) + +## Something missing? +If you feel there is some documentation that you would find useful, or have found an issue with existing documentation, please raise an issue on GitHub by navigating [here](https://github.com/google/testrun/issues/new/choose) \ No newline at end of file diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 000000000..f11b1b092 --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,25 @@ +Testrun logo + +## Developer docs + +## Table of Contents +1) General guidelines (this page) +2) [Code quality](code_quality.md) + +## General guidelines +As an open source project, we absolutely encourage contributions from the community to help Testrun remain an expanding but stable product. However, before contributing there are a number of things to take into consideration. + +1) [Sign the Google CLA](https://cla.developers.google.com/): Whether you are an individual or contributing on behalf of your organisation, you must be covered by a Google CLA. + +2) Determine the scope of your contribution + + - Your contribution is more likely to be accepted if fewer files are changed (keep it simple) + - Are you going to be fixing a bug, dependency issue or a new framework capability? Whatever it is, ensure your pull request fixes or changes just one thing. + +3) Get in touch to discuss whether your proposed changes are likely to be accepted + + - It is best to get the opinion from the core maintainers whether your proposed changes meet our objectives and align with Testrun principles. + +4) Fork Testrun and get developing + + - We aim to provide thorough and easy to ready developer documentation to help you contribute successfully. \ No newline at end of file diff --git a/docs/dev/code_quality.md b/docs/dev/code_quality.md new file mode 100644 index 000000000..47eabcf95 --- /dev/null +++ b/docs/dev/code_quality.md @@ -0,0 +1,16 @@ +Testrun logo + +## Code quality + +Whilst developing code for Testrun, there are some style guides that you should follow. + + - Python: https://google.github.io/styleguide/pyguide.html + - Angular: https://google.github.io/styleguide/angularjs-google-style.html + - Shell: https://google.github.io/styleguide/shellguide.html + - HTML/CSS: https://google.github.io/styleguide/htmlcssguide.html + - JSON: https://google.github.io/styleguide/jsoncstyleguide.xml + - Markdown: https://google.github.io/styleguide/docguide/style.html + +### Automated actions + +The current code base has been able to achieve 0 code lint issues. To maintain this, all lint checks are enforced on pull requests to dev and main. Please ensure that these lint checks are passing before marking your pull requests as 'Ready for review'. \ No newline at end of file diff --git a/docs/network/README.md b/docs/network/README.md index b5536c30c..0f97ecd7b 100644 --- a/docs/network/README.md +++ b/docs/network/README.md @@ -1,10 +1,9 @@ Testrun logo - ## Network Overview ## Table of Contents -1) Network Overview (this page) +1) Network overview (this page) 2) [How to identify network interfaces](identify_interfaces.md) 3) [Addresses](addresses.md) 4) [Add a new network service](add_new_service.md) diff --git a/docs/network/add_new_service.md b/docs/network/add_new_service.md index 7a07e43be..b3fa22514 100644 --- a/docs/network/add_new_service.md +++ b/docs/network/add_new_service.md @@ -65,7 +65,7 @@ COPY $MODULE_DIR/bin /testrun/bin # Copy over all python files COPY $MODULE_DIR/python /testrun/python -# Do not specify a CMD or Entrypoint as Test Run will automatically start your service as required +# Do not specify a CMD or Entrypoint as Testrun will automatically start your service as required ``` ### Example of start_network_service script diff --git a/docs/test/README.md b/docs/test/README.md index 19aa691d8..3163b4c84 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -2,7 +2,6 @@ ## Testing - The test requirements that are investigated by Testrun can be found in the [test modules documentation](/docs/test/modules.md). To understand the testing results, various definitions of test results and requirements are specified in the [statuses documentation](/docs/test/statuses.md). \ No newline at end of file diff --git a/docs/test/modules.md b/docs/test/modules.md index 7c5851ba4..2fe5983b1 100644 --- a/docs/test/modules.md +++ b/docs/test/modules.md @@ -10,7 +10,7 @@ Testrun provides some pre-built test modules for you to use when testing your ow | Baseline | A sample test module | [Baseline module](/modules/test/baseline/README.md) | | Connection | Verify IP and DHCP based behavior | [Connection module](/modules/test/conn/README.md) | | DNS | Verify DNS functionality | [DNS module](/modules/test/dns/README.md) | -| NMAP | Ensure unsecure services are disabled | [NMAP module](/modules/test/nmap/README.md) | +| Services | Ensure unsecure services are disabled | [Services module](/modules/test/services/README.md) | | NTP | Verify NTP functionality | [NTP module](/modules/test/ntp/README.md) | | Protocol | Inspect BMS protocol implementation | [Protocol Module](/modules/test/protocol/README.md) | | TLS | Determine TLS client and server behavior | [TLS module](/modules/test/tls/README.md) | diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index aed663ab8..e8e87465d 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -26,7 +26,7 @@ import uvicorn from urllib.parse import urlparse -from common import logger +from common import logger, tasks from common.device import Device LOGGER = logger.get_logger("api") @@ -114,7 +114,10 @@ def __init__(self, test_run): # Allow all origins to access the API origins = ["*"] - self._app = FastAPI() + # Scheduler for background periodic tasks + self._scheduler = tasks.PeriodicTasks(self._test_run) + + self._app = FastAPI(lifespan=self._scheduler.start) self._app.include_router(self._router) self._app.add_middleware( CORSMiddleware, @@ -165,7 +168,19 @@ async def post_sys_config(self, request: Request, response: Response): try: config = (await request.body()).decode("UTF-8") config_json = json.loads(config) + + # Validate req fields + if ("network" not in config_json or + "device_intf" not in config_json.get("network") or + "internet_intf" not in config_json.get("network") or + "log_level" not in config_json): + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg( + False, + "Configuration is missing required fields") + self._session.set_config(config_json) + # Catch JSON Decode error etc except JSONDecodeError: response.status_code = status.HTTP_400_BAD_REQUEST @@ -231,7 +246,15 @@ async def start_test_run(self, request: Request, response: Response): False, "Configured interfaces are not " + "ready for use. Ensure required interfaces " + "are connected.") - device.test_modules = body_json["device"]["test_modules"] + # UI doesn't send individual test configs so we need to + # merge these manually until the UI is updated to handle + # the full config file + for module_name, module_config in device.test_modules.items(): + # Check if the module exists in UI test modules + if module_name in body_json["device"]["test_modules"]: + # Merge the enabled state + module_config["enabled"] = body_json[ + "device"]["test_modules"][module_name]["enabled"] LOGGER.info("Starting Testrun with device target " + f"{device.manufacturer} {device.model} with " + @@ -464,6 +487,19 @@ async def save_device(self, request: Request, response: Response): device_json.get(DEVICE_MODEL_KEY) ) + # Check if device folder exists + device_folder = os.path.join(self._test_run.get_root_dir(), + DEVICES_PATH, + device_json.get(DEVICE_MANUFACTURER_KEY) + + " " + + device_json.get(DEVICE_MODEL_KEY)) + + if os.path.exists(device_folder): + response.status_code = status.HTTP_409_CONFLICT + return self._generate_msg( + False, "A folder with that name already exists, " \ + "please rename the device or folder") + if device is None: # Create new device @@ -679,6 +715,11 @@ async def update_profile(self, request: Request, response: Response): response.status_code = status.HTTP_400_BAD_REQUEST return self._generate_msg(False, "Invalid request received") + # Validate json profile + if not self.get_session().validate_profile_json(req_json): + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid request received") + profile_name = req_json.get("name") # Check if profile exists diff --git a/framework/python/src/common/mqtt.py b/framework/python/src/common/mqtt.py new file mode 100644 index 000000000..c58d24d3f --- /dev/null +++ b/framework/python/src/common/mqtt.py @@ -0,0 +1,62 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MQTT client""" +import json +import typing as t +import paho.mqtt.client as mqtt_client +from common import logger + +LOGGER = logger.get_logger("mqtt") +WEBSOCKETS_HOST = "localhost" +WEBSOCKETS_PORT = 1883 + +class MQTTException(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class MQTT: + """ MQTT client class + """ + def __init__(self) -> None: + self._host = WEBSOCKETS_HOST + self._client = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2) + LOGGER.setLevel(logger.logging.INFO) + self._client.enable_logger(LOGGER) + + def _connect(self): + """Establish connection to Mosquitto server + + Raises: + MQTTException: Raises exception on connection error + """ + if not self._client.is_connected(): + try: + self._client.connect(self._host, WEBSOCKETS_PORT, 60) + except (ValueError, ConnectionRefusedError) as e: + LOGGER.error("Can't connect to host") + raise MQTTException("Connection to the Mosquitto server failed") from e + + def send_message(self, topic: str, message: t.Union[str, dict]) -> None: + """Send message to specific topic + + Args: + topic (str): mqtt topic + message (t.Union[str, dict]): message + """ + self._connect() + if isinstance(message, dict): + message = json.dumps(message) + self._client.publish(topic, str(message)) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index 6afb229ac..f50dffdde 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -96,11 +96,11 @@ def get_file_path(self): self.name + '.json') def _validate(self, profile_json, profile_format): - if self._valid(profile_json, profile_format): - if self._expired(): - self.status = 'Expired' + if self._expired(): + self.status = 'Expired' + elif self._valid(profile_json, profile_format): # User only wants to save a draft - elif 'status' in profile_json and profile_json['status'] == 'Draft': + if 'status' in profile_json and profile_json['status'] == 'Draft': self.status = 'Draft' else: self.status = 'Valid' @@ -409,6 +409,14 @@ def _generate_risk_questions(self): content += '' + # Question risk label + if 'risk' in question: + if question['risk'] == 'High': + content += '
HIGH RISK
' + elif question['risk'] == 'Limited': + content += '''
+ LIMITED RISK
''' + content += '''''' index += 1 @@ -635,6 +643,33 @@ def _generate_css(self): ul { margin-top: 0; } + + .risk-label{ + position: absolute; + top: 0px; + right: 0px; + width: 52px; + height: 16px; + font-family: 'Google Sans', sans-serif; + font-size: 8px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.64px; + text-align: center; + font-weight: bold; + border-radius: 3px; + } + + .risk-label-high{ + background-color: #FCE8E6; + color: #C5221F; + } + + .risk-label-limited{ + width: 65px; + background-color:#E4F7FB; + color: #007B83; + } ''' def to_pdf(self, device): diff --git a/framework/python/src/common/session.py b/framework/python/src/common/session.py index f555a9732..940fbe8f0 100644 --- a/framework/python/src/common/session.py +++ b/framework/python/src/common/session.py @@ -17,8 +17,10 @@ import pytz import json import os -from common import util, logger +from fastapi.encoders import jsonable_encoder +from common import util, logger, mqtt from common.risk_profile import RiskProfile +from net_orc.ip_control import IPControl # Certificate dependencies from cryptography import x509 @@ -36,7 +38,7 @@ MAX_DEVICE_REPORTS_KEY = 'max_device_reports' CERTS_PATH = 'local/root_certs' CONFIG_FILE_PATH = 'local/system.json' -SECONDS_IN_YEAR = 31536000 +STATUS_TOPIC = 'status' PROFILE_FORMAT_PATH = 'resources/risk_assessment.json' PROFILES_DIR = 'local/risk_profiles' @@ -44,8 +46,36 @@ LOGGER = logger.get_logger('session') +def session_tracker(method): + """Session changes tracker.""" + def wrapper(self, *args, **kwargs): + + result = method(self, *args, **kwargs) + + if self.get_status() != 'Idle': + self.get_mqtt_client().send_message( + STATUS_TOPIC, + jsonable_encoder(self.to_json()) + ) + + return result + return wrapper + +def apply_session_tracker(cls): + """Applies tracker decorator to class methods""" + for attr in dir(cls): + if (callable(getattr(cls, attr)) + and not attr.startswith('_') + and not attr.startswith('get') + and not attr == 'to_json' + ): + setattr(cls, attr, session_tracker(getattr(cls, attr))) + return cls + + +@apply_session_tracker class TestrunSession(): - """Represents the current session of Test Run.""" + """Represents the current session of Testrun.""" def __init__(self, root_dir): self._root_dir = root_dir @@ -93,6 +123,8 @@ def __init__(self, root_dir): self._config_file = os.path.join(root_dir, CONFIG_FILE_PATH) self._config = self._get_default_config() + # System network interfaces + self._ifaces = {} # Loading methods self._load_version() self._load_config() @@ -107,6 +139,9 @@ def __init__(self, root_dir): self._timezone = tz[0] LOGGER.debug(f'System timezone is {self._timezone}') + # MQTT client + self._mqtt_client = mqtt.MQTT() + def start(self): self.reset() self._status = 'Waiting for Device' @@ -332,6 +367,12 @@ def add_test_result(self, result): result.result = 'In Progress' self._results.append(result) + def set_test_result_error(self, result): + """Set test result error""" + result.result = 'Error' + result.recommendations = None + self._results.append(result) + def add_module_report(self, module_report): self._module_reports.append(module_report) @@ -399,17 +440,31 @@ def _load_profiles(self): try: for risk_profile_file in os.listdir( os.path.join(self._root_dir, PROFILES_DIR)): + LOGGER.debug(f'Discovered profile {risk_profile_file}') + # Open the risk profile file with open(os.path.join(self._root_dir, PROFILES_DIR, risk_profile_file), encoding='utf-8') as f: + + # Parse risk profile json json_data = json.load(f) + + # Validate profile JSON + if not self.validate_profile_json(json_data): + LOGGER.error('Profile failed validation') + continue + + # Instantiate a new risk profile risk_profile = RiskProfile() + + # Pass JSON to populate risk profile risk_profile.load( profile_json=json_data, profile_format=self._profile_format ) - risk_profile.status = self.check_profile_status(risk_profile) + + # Add risk profile to session self._profiles.append(risk_profile) except Exception as e: @@ -428,25 +483,6 @@ def get_profile(self, name): return profile return None - def validate_profile(self, profile_json): - - # Check name field is present - if 'name' not in profile_json: - return False - - # Check questions field is present - if 'questions' not in profile_json: - return False - - # Check all questions are present - for format_q in self.get_profiles_format(): - if self._get_profile_question(profile_json, - format_q.get('question')) is None: - LOGGER.error('Missing question: ' + format_q.get('question')) - return False - - return True - def _get_profile_question(self, profile_json, question): for q in profile_json.get('questions'): @@ -455,7 +491,14 @@ def _get_profile_question(self, profile_json, question): return None + def get_profile_format_question(self, question): + for q in self.get_profiles_format(): + if q.get('question') == question: + return q + def update_profile(self, profile_json): + """Update the risk profile with the provided JSON. + The content has already been validated in the API""" profile_name = profile_json['name'] @@ -463,39 +506,8 @@ def update_profile(self, profile_json): profile_json['version'] = self.get_version() profile_json['created'] = datetime.datetime.now().strftime('%Y-%m-%d') - if 'status' in profile_json and profile_json.get('status') == 'Valid': - # Attempting to submit a risk profile, we need to check it - - # Check all questions have been answered - all_questions_answered = True - - for question in self.get_profiles_format(): - - # Check question is present - profile_question = self._get_profile_question(profile_json, - question.get('question')) - - if profile_question is not None: - - # Check answer is present - if 'answer' not in profile_question: - LOGGER.error('Missing answer for question: ' + - question.get('question')) - all_questions_answered = False - - else: - LOGGER.error('Missing question: ' + question.get('question')) - all_questions_answered = False - - if not all_questions_answered: - LOGGER.error('Not all questions answered') - return None - - else: - profile_json['status'] = 'Draft' - + # Check if profile already exists risk_profile = self.get_profile(profile_name) - if risk_profile is None: # Create a new risk profile @@ -524,19 +536,105 @@ def update_profile(self, profile_json): return risk_profile - def check_profile_status(self, profile): + def validate_profile_json(self, profile_json): + """Validate properties in profile update requests""" + + # Get the status field + valid = False + if 'status' in profile_json and profile_json.get('status') == 'Valid': + valid = True + + # Check if 'name' exists in profile + if 'name' not in profile_json: + LOGGER.error('Missing "name" in profile') + return False + + # Check if 'name' field not empty + elif len(profile_json.get('name').strip()) == 0: + LOGGER.error('Name field left empty') + return False + + # Error handling if 'questions' not in request + if 'questions' not in profile_json and valid: + LOGGER.error('Missing "questions" field in profile') + return False + + # Validating the questions section + for question in profile_json.get('questions'): + + # Check if the question field is present + if 'question' not in question: + LOGGER.error('The "question" field is missing') + return False + + # Check if 'question' field not empty + elif len(question.get('question').strip()) == 0: + LOGGER.error('A question is missing from "question" field') + return False + + # Check if question is a recognized question + format_q = self.get_profile_format_question( + question.get('question')) + + if format_q is None: + LOGGER.error(f'Unrecognized question: {question.get("question")}') + return False + + # Error handling if 'answer' is missing + if 'answer' not in question and valid: + LOGGER.error('The answer field is missing') + return False + + # If answer is present, check the validation rules + else: + + # Extract the answer out of the profile + answer = question.get('answer') + + # Get the validation rules + field_type = format_q.get('type') - if profile.status == 'Valid': + # Check if type is string or single select, answer should be a string + if ((field_type in ['string', 'select']) + and not isinstance(answer, str)): + LOGGER.error(f'''Answer for question \ +{question.get('question')} is incorrect data type''') + return False - # Check expiry - created_date = profile.created.timestamp() + # Check if type is select, answer must be from list + if field_type == 'select' and valid: + possible_answers = format_q.get('options') + if answer not in possible_answers: + LOGGER.error(f'''Answer for question \ +{question.get('question')} is not valid''') + return False - today = datetime.datetime.now().timestamp() + # Validate select multiple field types + if field_type == 'select-multiple': - if created_date < (today - SECONDS_IN_YEAR): - profile.status = 'Expired' + if not isinstance(answer, list): + LOGGER.error(f'''Answer for question \ +{question.get('question')} is incorrect data type''') + return False - return profile.status + question_options_len = len(format_q.get('options')) + + # We know it is a list, now check the indexes + for index in answer: + + # Check if the index is an integer + if not isinstance(index, int): + LOGGER.error(f'''Answer for question \ +{question.get('question')} is incorrect data type''') + return False + + # Check if index is 0 or above and less than the num of options + if index < 0 or index >= question_options_len: + LOGGER.error(f'''Invalid index provided as answer for \ +question {question.get('question')}''') + return False + + return True def delete_profile(self, profile): @@ -565,6 +663,7 @@ def reset(self): self._results = [] self._started = None self._finished = None + self._ifaces = IPControl.get_sys_interfaces() def to_json(self): @@ -650,6 +749,11 @@ def load_certs(self): self._certs = [] for cert_file in os.listdir(CERTS_PATH): + + # Ignore directories + if os.path.isdir(os.path.join(CERTS_PATH, cert_file)): + continue + LOGGER.debug(f'Loading certificate {cert_file}') try: @@ -712,3 +816,25 @@ def delete_cert(self, filename): def get_certs(self): return self._certs + + def detect_network_adapters_change(self) -> dict: + adapters = {} + ifaces_new = IPControl.get_sys_interfaces() + + # Difference between stored and newly received network interfaces + diff = util.diff_dicts(self._ifaces, ifaces_new) + if diff: + if 'items_added' in diff: + adapters['adapters_added'] = diff['items_added'] + if 'items_removed' in diff: + adapters['adapters_removed'] = diff['items_removed'] + # Save new network interfaces to session + LOGGER.debug(f'Network adapters change detected: {adapters}') + self._ifaces = ifaces_new + return adapters + + def get_mqtt_client(self): + return self._mqtt_client + + def get_ifaces(self): + return self._ifaces diff --git a/framework/python/src/common/tasks.py b/framework/python/src/common/tasks.py new file mode 100644 index 000000000..5da0b40c9 --- /dev/null +++ b/framework/python/src/common/tasks.py @@ -0,0 +1,78 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Periodic background tasks""" + +from contextlib import asynccontextmanager +import datetime +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI + +from common import logger + +# Check adapters period seconds +# Check adapters period seconds +CHECK_NETWORK_ADAPTERS_PERIOD = 5 +CHECK_INTERNET_PERIOD = 2 +INTERNET_CONNECTION_TOPIC = 'events/internet' +NETWORK_ADAPTERS_TOPIC = 'events/adapter' + +LOGGER = logger.get_logger('tasks') + + +class PeriodicTasks: + """Background periodic tasks + """ + def __init__( + self, testrun_obj, + ) -> None: + self._testrun = testrun_obj + self._mqtt_client = self._testrun.get_mqtt_client() + local_tz = datetime.datetime.now().astimezone().tzinfo + self._scheduler = AsyncIOScheduler(timezone=local_tz) + # Prevent scheduler warnings + self._scheduler._logger.setLevel(logging.ERROR) + + self.adapters_checker_job = self._scheduler.add_job( + func=self._testrun.get_net_orc().network_adapters_checker, + kwargs={ + 'mqtt_client': self._mqtt_client, + 'topic': NETWORK_ADAPTERS_TOPIC + }, + trigger='interval', + seconds=CHECK_NETWORK_ADAPTERS_PERIOD, + ) + # add internet connection cheking job only in single-intf mode + if 'single_intf' not in self._testrun.get_session().get_runtime_params(): + self.internet_shecker = self._scheduler.add_job( + func=self._testrun.get_net_orc().internet_conn_checker, + kwargs={ + 'mqtt_client': self._mqtt_client, + 'topic': INTERNET_CONNECTION_TOPIC + }, + trigger='interval', + seconds=CHECK_INTERNET_PERIOD, + ) + + @asynccontextmanager + async def start(self, app: FastAPI): # pylint: disable=unused-argument + """Start background tasks + + Args: + app (FastAPI): app instance + """ + # Job that checks for changes in network adapters + self._scheduler.start() + yield diff --git a/framework/python/src/common/util.py b/framework/python/src/common/util.py index 096aaf4df..7c31631fb 100644 --- a/framework/python/src/common/util.py +++ b/framework/python/src/common/util.py @@ -17,13 +17,14 @@ import os import subprocess import shlex -from common import logger +import typing as t import netifaces +from common import logger LOGGER = logger.get_logger('util') -def run_command(cmd, output=True): +def run_command(cmd, output=True, timeout=None): """Runs a process at the os level By default, returns the standard output and error output If the caller sets optional output parameter to False, @@ -35,7 +36,7 @@ def run_command(cmd, output=True): with subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: - stdout, stderr = process.communicate() + stdout, stderr = process.communicate(timeout) if process.returncode != 0 and output: err_msg = f'{stderr.strip()}. Code: {process.returncode}' @@ -113,3 +114,32 @@ def get_module_display_name(search): return module[1] return 'Unknown' + + +def diff_dicts(d1: t.Dict[t.Any, t.Any], d2: t.Dict[t.Any, t.Any]) -> t.Dict: + """Compares two dictionaries by keys + + Args: + d1 (t.Dict[t.Any, t.Any]): first dict to compare + d2 (t.Dict[t.Any, t.Any]): second dict to compare + + Returns: + t.Dict[t.Any, t.Any]: Returns an empty dictionary + if the compared dictionaries are equal, + otherwise returns a dictionary that contains + the removed items(if available) + and the added items(if available). + """ + diff = {} + if d1 != d2: + s1 = set(d1) + s2 = set(d2) + keys_removed = s1 - s2 + keys_added = s2 - s1 + items_removed = {k:d1[k] for k in keys_removed} + items_added = {k:d2[k] for k in keys_added} + if items_removed: + diff['items_removed'] = items_removed + if items_added: + diff['items_added'] = items_added + return diff diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 5b43cfd65..dccde6a35 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -15,7 +15,7 @@ """The overall control of the Test Run application. This file provides the integration between all of the -Test Run components, such as net_orc, test_orc and test_ui. +Testrun components, such as net_orc, test_orc and test_ui. Run using the provided command scripts in the cmd folder. E.g sudo cmd/start @@ -27,7 +27,7 @@ import signal import sys import time -from common import logger, util +from common import logger, util, mqtt from common.device import Device from common.session import TestrunSession from common.testreport import TestReport @@ -81,7 +81,9 @@ def __init__(self, self._net_only = net_only self._single_intf = single_intf - self._no_ui = no_ui + # Network only option only works if UI is also + # disbled so need to set no_ui if net_only is selected + self._no_ui = no_ui or net_only # Catch any exit signals self._register_exits() @@ -109,6 +111,12 @@ def __init__(self, # Load test modules self._test_orc.start() + # Start websockets server + self.start_ws() + + # Init MQTT client + self._mqtt_client = mqtt.MQTT() + if self._no_ui: # Check Testrun is able to start @@ -216,7 +224,14 @@ def _load_test_reports(self, device): 'test', device.mac_addr.replace(':',''), 'report.json') - + + if not os.path.isfile(report_json_file_path): + # Revert to pre 1.3 file path + report_json_file_path = os.path.join( + reports_folder, + report_folder, + 'report.json') + if not os.path.isfile(report_json_file_path): # Revert to pre 1.3 file path report_json_file_path = os.path.join( @@ -369,6 +384,7 @@ def shutdown(self): LOGGER.info('Shutting down Testrun') self.stop() self._stop_ui() + self._stop_ws() def _exit_handler(self, signum, arg): # pylint: disable=unused-argument LOGGER.debug('Exit signal received: ' + str(signum)) @@ -385,6 +401,9 @@ def _get_config_abs(self, config_file=None): # Expand the config file to absolute pathing return os.path.abspath(config_file) + def get_root_dir(self): + return root_dir + def get_config_file(self): return self._get_config_abs() @@ -406,6 +425,9 @@ def _stop_network(self, kill=True): def _stop_tests(self): self._test_orc.stop() + def get_mqtt_client(self): + return self._mqtt_client + def get_device(self, mac_addr): """Returns a loaded device object from the device mac address.""" for device in self.get_session().get_device_repository(): @@ -463,7 +485,7 @@ def start_ui(self): try: client.containers.run( - image='test-run/ui', + image='testrun/ui', auto_remove=True, name='tr-ui', hostname='testrun.io', @@ -489,4 +511,40 @@ def _stop_ui(self): if container is not None: container.kill() except docker.errors.NotFound: - return + pass + + + def start_ws(self): + + self._stop_ws() + + LOGGER.info('Starting WS server') + + client = docker.from_env() + + try: + client.containers.run( + image='testrun/ws', + auto_remove=True, + name='tr-ws', + detach=True, + ports={ + '9001': 9001, + '1883': 1883 + } + ) + except ImageNotFound as ie: + LOGGER.error('An error occured whilst starting the websockets server. ' + + 'Please investigate and try again.') + LOGGER.error(ie) + sys.exit(1) + + def _stop_ws(self): + LOGGER.info('Stopping websockets server') + client = docker.from_env() + try: + container = client.containers.get('tr-ws') + if container is not None: + container.kill() + except docker.errors.NotFound: + pass diff --git a/framework/python/src/net_orc/ip_control.py b/framework/python/src/net_orc/ip_control.py index 506b23a95..04686f0cd 100644 --- a/framework/python/src/net_orc/ip_control.py +++ b/framework/python/src/net_orc/ip_control.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. """IP Control Module""" +import psutil +import typing as t from common import logger from common import util import re @@ -43,10 +45,7 @@ def add_namespace(self, namespace): def check_interface_status(self, interface_name): output = util.run_command(cmd=f'ip link show {interface_name}', output=True) - if 'state DOWN ' in output[0]: - return False - else: - return True + return 'state UP ' in output[0] def delete_link(self, interface_name): """Delete an ip link""" @@ -99,7 +98,7 @@ def get_iface_port_stats(self, iface): def get_namespaces(self): result = util.run_command('ip netns list') - #Strip ID's from the namespace results + # Strip ID's from the namespace results namespaces = re.findall(r'(\S+)(?:\s+\(id: \d+\))?', result[0]) return namespaces @@ -237,3 +236,30 @@ def configure_container_interface(self, LOGGER.error(f'Failed to set interface up {namespace_intf}') return False return True + + def ping_via_gateway(self, host): + """Ping the host trough the gateway container""" + command = f'timeout 3 docker exec tr-ct-gateway ping -W 1 -c 1 {host}' + output = util.run_command(command) + if '0% packet loss' in output[0]: + return True + return False + + @staticmethod + def get_sys_interfaces() -> t.Dict[str, t.Dict[str, str]]: + """ Retrieves all Ethernet network interfaces from the host system + Returns: + t.Dict[str, str] + """ + addrs = psutil.net_if_addrs() + ifaces = {} + + for key in addrs: + nic = addrs[key] + # Ignore any interfaces that are not ethernet + if not (key.startswith('en') or key.startswith('eth')): + continue + + ifaces[key] = nic[0].address + + return ifaces diff --git a/framework/python/src/net_orc/network_orchestrator.py b/framework/python/src/net_orc/network_orchestrator.py index f20093a28..a94bca89b 100644 --- a/framework/python/src/net_orc/network_orchestrator.py +++ b/framework/python/src/net_orc/network_orchestrator.py @@ -22,8 +22,9 @@ import sys import docker import time +import traceback from docker.types import Mount -from common import logger, util +from common import logger, util, mqtt from net_orc.listener import Listener from net_orc.network_event import NetworkEvent from net_orc.network_validator import NetworkValidator @@ -223,7 +224,9 @@ def _device_discovered(self, mac_addr): #self._ovs.add_arp_inspection_filter(ip_address=device.ip_addr, # mac_address=device.mac_addr) - self._start_device_monitor(device) + # Don't monitor devices when in network only mode + if 'net_only' not in self._session.get_runtime_params(): + self._start_device_monitor(device) def _get_conn_stats(self): """ Extract information about the physical connection @@ -547,10 +550,6 @@ def _start_network_service(self, net_module): cap_add=['NET_ADMIN'], name=net_module.container_name, hostname=net_module.container_name, - # Undetermined version of docker seems to have broken - # DNS configuration (/etc/resolv.conf) Re-add when/if - # this network is utilized and DNS issue is resolved - #network=PRIVATE_DOCKER_NET, network_mode='none', privileged=True, detach=True, @@ -786,6 +785,48 @@ def restore_net(self): def get_session(self): return self._session + def network_adapters_checker(self, mqtt_client: mqtt.MQTT, topic: str): + """Checks for changes in network adapters + and sends a message to the frontend + """ + try: + adapters = self._session.detect_network_adapters_change() + if adapters: + mqtt_client.send_message(topic, adapters) + except Exception: + LOGGER.error(traceback.format_exc()) + + def is_device_connected(self): + """Check if device connected""" + return self._ip_ctrl.check_interface_status( + self._session.get_device_interface() + ) + + def internet_conn_checker(self, mqtt_client: mqtt.MQTT, topic: str): + """Checks internet connection and sends a status to frontend""" + + # Only check if Testrun is running not in single-intf mode + if (self.get_session().get_status() in [ + 'Waiting for Device', + 'Monitoring', + 'In Progress' + ]): + # Default message + message = {'connection': False} + iface = self._session.get_internet_interface() + + # Check that an internet intf has been selected + if iface and iface in self._ip_ctrl.get_sys_interfaces(): + + # Ping google.com from gateway container + internet_connection = self._ip_ctrl.ping_via_gateway( + 'google.com') + + if internet_connection: + message['connection'] = True + + # Broadcast via MQTT client + mqtt_client.send_message(topic, message) class NetworkModule: """Define all the properties of a Network Module""" diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index d38f888a1..a38371d07 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -60,6 +60,8 @@ def __init__(self, session, net_orc): os.path.dirname( os.path.dirname( os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))) + self._test_modules_running = [] + self._current_module = 0 def start(self): LOGGER.debug("Starting test orchestrator") @@ -102,7 +104,13 @@ def run_test_modules(self): test_modules.append(module) self.get_session().add_total_tests(len(module.tests)) - for module in test_modules: + # Store enabled test modules in the TestsOrchectrator object + self._test_modules_running = test_modules + self._current_module = 0 + + for index, module in enumerate(test_modules): + + self._current_module = index self._run_test_module(module) LOGGER.info("All tests complete") @@ -362,7 +370,14 @@ def _run_test_module(self, module): LOGGER.info(f"Running test module {module.name}") # Get all tests to be executed and set to in progress - for test in module.tests: + for current_test,test in enumerate(module.tests): + + # Check that device is connected + if not self._net_orc.is_device_connected(): + LOGGER.error("Device was disconnected") + self._set_test_modules_error(current_test) + self._session.set_status("Cancelled") + return test_copy = copy.deepcopy(test) test_copy.result = "In Progress" @@ -486,19 +501,25 @@ def _run_test_module(self, module): try: with open(results_file, "r", encoding="utf-8-sig") as f: + + # Load results from JSON file module_results_json = json.load(f) module_results = module_results_json["results"] for test_result in module_results: - # Convert dict into TestCase object + # Convert dict from json into TestCase object test_case = TestCase( name=test_result["name"], description=test_result["description"], expected_behavior=test_result["expected_behavior"], required_result=test_result["required_result"], result=test_result["result"]) - test_case.result=test_result["result"] + # Any informational test should always report informational + if test_case.required_result == "Informational": + test_case.result = "Informational" + + # Add steps to resolve if test is non-compliant if (test_case.result == "Non-Compliant" and "recommendations" in test_result): test_case.recommendations = test_result["recommendations"] @@ -729,3 +750,13 @@ def get_test_case(self, name): def get_session(self): return self._session + + def _set_test_modules_error(self, current_test): + """Set all remaining tests to error""" + for i in range(self._current_module, len(self._test_modules_running)): + start_idx = current_test if i == self._current_module else 0 + for j in range(start_idx, len(self._test_modules_running[i].tests)): + self.get_session().set_test_result_error( + self._test_modules_running[i].tests[j] + ) + diff --git a/framework/requirements.txt b/framework/requirements.txt index c31978d99..0484905ee 100644 --- a/framework/requirements.txt +++ b/framework/requirements.txt @@ -1,8 +1,8 @@ # Requirements for the core module -requests<2.32.0 +requests==2.32.3 # Requirements for the net_orc module -docker==7.0.0 +docker==7.1.0 ipaddress==1.0.23 netifaces==0.11.0 scapy==2.5.0 @@ -21,6 +21,8 @@ pydantic==2.7.1 # Requirements for testing pytest==7.4.4 pytest-timeout==2.2.0 +responses==0.25.3 + # Requirements for the report markdown==3.5.2 @@ -31,3 +33,9 @@ pytz==2024.1 # Requirements for the risk profile python-dateutil==2.9.0 + +# Requirements for MQTT client +paho-mqtt==2.1.0 + +# Requirements for background tasks +APScheduler==3.10.4 diff --git a/make/DEBIAN/control b/make/DEBIAN/control index 488f69458..cecad9d17 100644 --- a/make/DEBIAN/control +++ b/make/DEBIAN/control @@ -1,5 +1,5 @@ Package: Testrun -Version: 1.3.1 +Version: 1.4-a Architecture: amd64 Maintainer: Google Homepage: https://github.com/google/testrun diff --git a/modules/test/base/README.md b/modules/test/base/README.md index e7f05d80e..24a725607 100644 --- a/modules/test/base/README.md +++ b/modules/test/base/README.md @@ -14,6 +14,13 @@ The ```config/module_config.json``` provides the name and description of the mod Within the ```python/src``` directory, basic logging and environment variables are provided to the test module. +Within the ```usr/local/etc``` directory there is a local copy of the MAC OUI database. This is just in case a new copy is unable to be downloaded during the install or update process. + +## GRPC server +Within the python directory, GRPC client code is provided to allow test modules to programmatically modify the various network services provided by Testrun. + +These currently include obtaining information about and controlling the DHCP servers in failover configuration. + ## Tests covered No tests are run by this module \ No newline at end of file diff --git a/modules/test/base/bin/setup b/modules/test/base/bin/setup new file mode 100644 index 000000000..23c96c513 --- /dev/null +++ b/modules/test/base/bin/setup @@ -0,0 +1,71 @@ +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Define the local mount point to store local files to +export OUTPUT_DIR="/runtime/output" + +# Directory where all binaries will be loaded +export BIN_DIR="/testrun/bin" + +# Default interface should be veth0 for all containers +export IFACE=veth0 + +# Create a local user that matches the same as the host +# to be used for correct file ownership for various logs +# HOST_USER mapped in via docker container environemnt variables +useradd $HOST_USER + +# Set permissions on the output files +chown -R $HOST_USER $OUTPUT_DIR + +# Enable IPv6 for all containers +sysctl net.ipv6.conf.all.disable_ipv6=0 +sysctl -p + +# Read in the config file +CONF_FILE="/testrun/conf/module_config.json" +CONF=`cat $CONF_FILE` + +if [[ -z $CONF ]] +then + echo "No config file present at $CONF_FILE. Exiting startup." + exit 1 +fi + +# Extract the necessary config parameters +export MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') +export NETWORK_REQUIRED=$(echo "$CONF" | jq -r '.config.network') +export GRPC=$(echo "$CONF" | jq -r '.config.grpc') + +# Validate the module name is present +if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] +then + echo "No module name present in $CONF_FILE. Exiting startup." + exit 1 +fi + +# Setup the PYTHONPATH so all imports work as expected +echo "Setting up PYTHONPATH..." +export PYTHONPATH=$($BIN_DIR/setup_python_path) +echo "PYTHONPATH: $PYTHONPATH" + +echo "Configuring binary files..." +$BIN_DIR/setup_binaries $BIN_DIR + +# Build all gRPC files from the proto for use in +# gRPC clients for communications to network modules +echo "Building gRPC files from available proto files..." +$BIN_DIR/setup_grpc_clients \ No newline at end of file diff --git a/modules/test/base/bin/start b/modules/test/base/bin/start index 37902b868..d1f29989f 100755 --- a/modules/test/base/bin/start +++ b/modules/test/base/bin/start @@ -14,4 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -/testrun/bin/start_module \ No newline at end of file +# Allow one argument which is the unit test file to run +# instead of running the test module +UNIT_TEST_FILE=$1 + +source /testrun/bin/setup + +# Conditionally run start_module based on RUN +if [[ -z "$UNIT_TEST_FILE" ]];then + /testrun/bin/start_module +else + python3 $UNIT_TEST_FILE +fi diff --git a/modules/test/base/bin/start_module b/modules/test/base/bin/start_module index 0ee68fa6a..fb79cc018 100644 --- a/modules/test/base/bin/start_module +++ b/modules/test/base/bin/start_module @@ -1,102 +1,46 @@ -#!/bin/bash - -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Define the local mount point to store local files to -OUTPUT_DIR="/runtime/output" - -# Directory where all binaries will be loaded -BIN_DIR="/testrun/bin" - -# Default interface should be veth0 for all containers -IFACE=veth0 - -# Create a local user that matches the same as the host -# to be used for correct file ownership for various logs -# HOST_USER mapped in via docker container environemnt variables -useradd $HOST_USER - -# Set permissions on the output files -chown -R $HOST_USER $OUTPUT_DIR - -# Enable IPv6 for all containers -sysctl net.ipv6.conf.all.disable_ipv6=0 -sysctl -p - -# Read in the config file -CONF_FILE="/testrun/conf/module_config.json" -CONF=`cat $CONF_FILE` - -if [[ -z $CONF ]] -then - echo "No config file present at $CONF_FILE. Exiting startup." - exit 1 -fi - -# Extract the necessary config parameters -MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') -NETWORK_REQUIRED=$(echo "$CONF" | jq -r '.config.network') -GRPC=$(echo "$CONF" | jq -r '.config.grpc') - -# Validate the module name is present -if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] -then - echo "No module name present in $CONF_FILE. Exiting startup." - exit 1 -fi - -# Setup the PYTHONPATH so all imports work as expected -echo "Setting up PYTHONPATH..." -export PYTHONPATH=$($BIN_DIR/setup_python_path) -echo "PYTHONPATH: $PYTHONPATH" - -# Build all gRPC files from the proto for use in -# gRPC clients for communications to network modules -echo "Building gRPC files from available proto files..." -$BIN_DIR/setup_grpc_clients - -echo "Configuring binary files..." -$BIN_DIR/setup_binaries $BIN_DIR - -echo "Starting module $MODULE_NAME..." - -# Only start network services if the test container needs -# a network connection to run its tests -if [ $NETWORK_REQUIRED == "true" ];then - # Wait for interface to become ready - $BIN_DIR/wait_for_interface $IFACE - - # Start network capture - $BIN_DIR/capture $MODULE_NAME $IFACE -fi - -# Start the grpc server -if [[ ! -z $GRPC && ! $GRPC == "null" ]] -then - GRPC_PORT=$(echo "$GRPC" | jq -r '.port') - if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] - then - echo "gRPC port resolved from config: $GRPC_PORT" - $BIN_DIR/start_grpc "-p $GRPC_PORT" - else - $BIN_DIR/start_grpc - fi -fi - -# Small pause to let all core services stabalize -sleep 3 - -# Start the test module +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +echo "Starting module $MODULE_NAME..." + +# Only start network services if the test container needs +# a network connection to run its tests +if [ $NETWORK_REQUIRED == "true" ];then + # Wait for interface to become ready + $BIN_DIR/wait_for_interface $IFACE + + # Start network capture + $BIN_DIR/capture $MODULE_NAME $IFACE +fi + +# Start the grpc server +if [[ ! -z $GRPC && ! $GRPC == "null" ]] +then + GRPC_PORT=$(echo "$GRPC" | jq -r '.port') + if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] + then + echo "gRPC port resolved from config: $GRPC_PORT" + $BIN_DIR/start_grpc "-p $GRPC_PORT" + else + $BIN_DIR/start_grpc + fi +fi + +# Small pause to let all core services stabalize +sleep 3 + +# Start the test module $BIN_DIR/start_test_module $MODULE_NAME $IFACE \ No newline at end of file diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index 00f74df82..deed0d978 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -17,6 +17,7 @@ import os import util from datetime import datetime +import traceback LOGGER = None RESULTS_DIR = '/runtime/output/' @@ -48,9 +49,9 @@ def __init__(self, def _add_logger(self, log_name, module_name, log_dir=None): global LOGGER - LOGGER = logger.get_logger(name=log_name, + LOGGER = logger.get_logger(name=log_name, # pylint: disable=E1123 log_file=module_name, - log_dir=log_dir) # pylint: disable=E1123 + log_dir=log_dir) def generate_module_report(self): pass @@ -113,11 +114,17 @@ def run_tests(self): except Exception as e: # pylint: disable=W0718 LOGGER.error(f'An error occurred whilst running {test["name"]}') LOGGER.error(e) + traceback.print_exc() else: LOGGER.info(f'Test {test["name"]} not implemented. Skipping') + test['result'] = 'Error' + test['description'] = 'This test could not be found' else: LOGGER.debug(f'Test {test["name"]} is disabled') + # To be added in v1.3.2 + # result = 'Disabled', 'This test is disabled and did not run' + if result is not None: # Compliant or non-compliant as a boolean only if isinstance(result, bool): @@ -182,7 +189,7 @@ def _write_results(self, results): def _get_device_ipv4(self): command = f"""/testrun/bin/get_ipv4_addr {self._ipv4_subnet} {self._device_mac.upper()}""" - text = util.run_command(command)[0] + text = util.run_command(command)[0] # pylint: disable=E1120 if text: return text.split('\n')[0] return None diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 5e8b78ec3..88dd40393 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -15,7 +15,7 @@ import util import time import traceback -from scapy.all import rdpcap, DHCP, ARP, Ether, IPv6, ICMPv6ND_NS +from scapy.all import rdpcap, DHCP, ARP, Ether, ICMP, IPv6, ICMPv6ND_NS from test_module import TestModule from dhcp1.client import Client as DHCPClient1 from dhcp2.client import Client as DHCPClient2 @@ -39,7 +39,14 @@ class ConnectionModule(TestModule): """Connection Test module""" - def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): + def __init__(self, + module, + log_dir=None, + conf_file=None, + results_dir=None, + startup_capture_file=STARTUP_CAPTURE_FILE, + monitor_capture_file=MONITOR_CAPTURE_FILE): + super().__init__(module_name=module, log_name=LOG_NAME, log_dir=log_dir, @@ -47,6 +54,8 @@ def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): results_dir=results_dir) global LOGGER LOGGER = self._get_logger() + self.startup_capture_file = startup_capture_file + self.monitor_capture_file = monitor_capture_file self._port_stats = PortStatsUtil(logger=LOGGER) self.dhcp1_client = DHCPClient1() self.dhcp2_client = DHCPClient2() @@ -106,7 +115,8 @@ def _connection_switch_arp_inspection(self): no_arp = True # Read all the pcap files - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) for packet in packets: # We are not interested in packets unless they are ARP packets @@ -123,12 +133,8 @@ def _connection_switch_arp_inspection(self): # Check MAC address matches IP address if (arp_packet.hwsrc == self._device_mac - and (arp_packet.psrc not in ( - self._device_ipv4_addr, - '0.0.0.0' - )) and not arp_packet.psrc.startswith( - '169.254' - )): + and (arp_packet.psrc not in (self._device_ipv4_addr, '0.0.0.0')) + and not arp_packet.psrc.startswith('169.254')): LOGGER.info(f'Bad ARP packet detected for MAC: {self._device_mac}') LOGGER.info(f'''ARP packet from IP {arp_packet.psrc} does not match {self._device_ipv4_addr}''') @@ -145,7 +151,8 @@ def _connection_switch_dhcp_snooping(self): disallowed_dhcp_types = [2, 4, 5, 6, 9, 10, 12, 13, 15, 17] # Read all the pcap files - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) for packet in packets: # We are not interested in packets unless they are DHCP packets @@ -158,6 +165,11 @@ def _connection_switch_dhcp_snooping(self): dhcp_type = self._get_dhcp_type(packet) if dhcp_type in disallowed_dhcp_types: + + # Check if packet is responding with port unreachable + if ICMP in packet and packet[ICMP].type == 3: + continue + return False, 'Device has sent disallowed DHCP message' return True, 'Device does not act as a DHCP server' @@ -220,7 +232,8 @@ def _connection_single_ip(self): return result, 'No MAC address found.' # Read all the pcap files containing DHCP packet information - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) # Extract MAC addresses from DHCP packets mac_addresses = set() @@ -394,8 +407,9 @@ def _connection_ipv6_slaac(self): return result def _has_slaac_addres(self): - packet_capture = (rdpcap(STARTUP_CAPTURE_FILE) + - rdpcap(MONITOR_CAPTURE_FILE) + rdpcap(DHCP_CAPTURE_FILE)) + packet_capture = (rdpcap(self.startup_capture_file) + + rdpcap(self.monitor_capture_file) + + rdpcap(DHCP_CAPTURE_FILE)) sends_ipv6 = False for packet_number, packet in enumerate(packet_capture, start=1): if IPv6 in packet and packet.src == self._device_mac: @@ -432,7 +446,7 @@ def _ping(self, host, ipv6=False): cmd += ' -6 ' if ipv6 else '' cmd += str(host) #cmd = 'ping -c 1 ' + str(host) - success = util.run_command(cmd, output=False) + success = util.run_command(cmd, output=False) # pylint: disable=E1120 return success def restore_failover_dhcp_server(self, subnet): diff --git a/modules/test/conn/python/src/dhcp_util.py b/modules/test/conn/python/src/dhcp_util.py index be5f0cac2..3654d0401 100644 --- a/modules/test/conn/python/src/dhcp_util.py +++ b/modules/test/conn/python/src/dhcp_util.py @@ -207,7 +207,7 @@ def is_lease_active(self, lease): def ping(self, host): cmd = 'ping -c 1 ' + str(host) - success = util.run_command(cmd, output=False) + success = util.run_command(cmd, output=False) # pylint: disable=E1120 return success def add_reserved_lease(self, diff --git a/modules/test/dns/README.md b/modules/test/dns/README.md index 13f0df5fd..79bce57f7 100644 --- a/modules/test/dns/README.md +++ b/modules/test/dns/README.md @@ -15,4 +15,5 @@ Within the ```python/src``` directory, the below tests are executed. | ID | Description | Expected behavior | Required result |---|---|---|---| | dns.network.hostname_resolution | Verifies that the device resolves hostnames | The device sends DNS requests | Required | -| dns.network.from_dhcp | Verifies that the device allows for a DNS server to be provided by the DHCP server | The device sends DNS requests to the DNS server provided by the DHCP server | Roadmap | \ No newline at end of file +| dns.network.from_dhcp | Verifies that the device allows for a DNS server to be provided by the DHCP server | The device sends DNS requests to the DNS server provided by the DHCP server | Roadmap | +| dns.mdns | Does the device has MDNS (or any kind of IP multicast) | Device may send MDNS requests | Informational | \ No newline at end of file diff --git a/modules/test/dns/conf/module_config.json b/modules/test/dns/conf/module_config.json index 13c9b3236..f048d5deb 100644 --- a/modules/test/dns/conf/module_config.json +++ b/modules/test/dns/conf/module_config.json @@ -31,6 +31,12 @@ "recommendations": [ "Install a DNS client that supports fetching DNS servers from DHCP options" ] + }, + { + "name": "dns.mdns", + "test_description": "Does the device has MDNS (or any kind of IP multicast)", + "expected_behavior": "Device may send MDNS requests", + "required_result": "Informational" } ] } diff --git a/modules/test/dns/python/src/dns_module.py b/modules/test/dns/python/src/dns_module.py index 607a026b5..c04e289d3 100644 --- a/modules/test/dns/python/src/dns_module.py +++ b/modules/test/dns/python/src/dns_module.py @@ -13,12 +13,13 @@ # limitations under the License. """DNS test module""" import subprocess -from scapy.all import rdpcap, DNS, IP +from scapy.all import rdpcap, DNS, IP, Ether from test_module import TestModule import os +from collections import Counter LOG_NAME = 'test_dns' -MODULE_REPORT_FILE_NAME='dns_report.html' +MODULE_REPORT_FILE_NAME = 'dns_report.html' DNS_SERVER_CAPTURE_FILE = '/runtime/network/dns.pcap' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' @@ -41,9 +42,9 @@ def __init__(self, log_dir=log_dir, conf_file=conf_file, results_dir=results_dir) - self.dns_server_capture_file=dns_server_capture_file - self.startup_capture_file=startup_capture_file - self.monitor_capture_file=monitor_capture_file + self.dns_server_capture_file = dns_server_capture_file + self.startup_capture_file = startup_capture_file + self.monitor_capture_file = monitor_capture_file self._dns_server = '10.10.10.4' global LOGGER LOGGER = self._get_logger() @@ -55,18 +56,17 @@ def generate_module_report(self): html_content = '

DNS Module

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

Testrun

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

Testrun

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

Testrun

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

Testrun

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

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

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

-
+

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

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

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

+

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

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

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

Devices

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

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

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

Risk assessment

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

Saved profiles

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

System settings

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

isTestrunInProgress(systemStatus?.status) || systemStatus?.status === StatusOfTestrun.Cancelling " - (click)="openTestRunModal()" + (click)="openTestRunModal(vm.testModules)" mat-flat-button> Start New Testrun diff --git a/modules/ui/src/app/pages/testrun/testrun.component.spec.ts b/modules/ui/src/app/pages/testrun/testrun.component.spec.ts index 98d1b986f..5867bc313 100644 --- a/modules/ui/src/app/pages/testrun/testrun.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/testrun.component.spec.ts @@ -53,6 +53,7 @@ import { selectIsOpenWaitSnackBar, selectRiskProfiles, selectSystemStatus, + selectTestModules, } from '../../store/selectors'; import { TestrunStore } from './testrun.store'; import { @@ -123,6 +124,7 @@ describe('TestrunComponent', () => { { selector: selectIsOpenWaitSnackBar, value: false }, { selector: selectHasRiskProfiles, value: false }, { selector: selectRiskProfiles, value: [] }, + { selector: selectTestModules, value: [] }, { selector: selectSystemStatus, value: MOCK_PROGRESS_DATA_IN_PROGRESS, @@ -234,9 +236,17 @@ describe('TestrunComponent', () => { }, provideMockStore({ selectors: [ - { selector: selectHasDevices, value: false }, { selector: selectDevices, value: [] }, + { selector: selectHasDevices, value: false }, + { selector: selectIsOpenStartTestrun, value: false }, { selector: selectIsOpenWaitSnackBar, value: false }, + { selector: selectHasRiskProfiles, value: false }, + { selector: selectRiskProfiles, value: [] }, + { selector: selectTestModules, value: [] }, + { + selector: selectSystemStatus, + value: MOCK_PROGRESS_DATA_IN_PROGRESS, + }, ], }), ], @@ -324,6 +334,9 @@ describe('TestrunComponent', () => { hasBackdrop: true, disableClose: true, panelClass: 'initiate-test-run-dialog', + data: { + testModules: [], + }, }); expect(store.dispatch).toHaveBeenCalledWith( fetchSystemStatusSuccess({ @@ -405,6 +418,7 @@ describe('TestrunComponent', () => { MOCK_PROGRESS_DATA_COMPLIANT ); store.overrideSelector(selectHasDevices, true); + store.refreshState(); fixture.detectChanges(); }); diff --git a/modules/ui/src/app/pages/testrun/testrun.component.ts b/modules/ui/src/app/pages/testrun/testrun.component.ts index b861ef953..ce5c104d5 100644 --- a/modules/ui/src/app/pages/testrun/testrun.component.ts +++ b/modules/ui/src/app/pages/testrun/testrun.component.ts @@ -34,6 +34,8 @@ import { FocusManagerService } from '../../services/focus-manager.service'; import { TestrunStore } from './testrun.store'; import { TestRunService } from '../../services/test-run.service'; import { NotificationService } from '../../services/notification.service'; +import { TestModule } from '../../model/device'; +import { combineLatest } from 'rxjs/internal/observable/combineLatest'; @Component({ selector: 'app-progress', @@ -60,11 +62,14 @@ export class TestrunComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - this.testrunStore.isOpenStartTestrun$ + combineLatest([ + this.testrunStore.isOpenStartTestrun$, + this.testrunStore.testModules$, + ]) .pipe(takeUntil(this.destroy$)) - .subscribe(isOpenStartTestrun => { + .subscribe(([isOpenStartTestrun, testModules]) => { if (isOpenStartTestrun) { - this.openTestRunModal(); + this.openTestRunModal(testModules); } }); } @@ -126,13 +131,16 @@ export class TestrunComponent implements OnInit, OnDestroy { this.destroy$.unsubscribe(); } - openTestRunModal(): void { + openTestRunModal(testModules: TestModule[]): void { const dialogRef = this.dialog.open(TestrunInitiateFormComponent, { ariaLabel: 'Initiate testrun', autoFocus: true, hasBackdrop: true, disableClose: true, panelClass: 'initiate-test-run-dialog', + data: { + testModules, + }, }); dialogRef diff --git a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts index 03f7817af..e8be0f93d 100644 --- a/modules/ui/src/app/pages/testrun/testrun.store.spec.ts +++ b/modules/ui/src/app/pages/testrun/testrun.store.spec.ts @@ -23,6 +23,7 @@ import { selectIsOpenStartTestrun, selectRiskProfiles, selectSystemStatus, + selectTestModules, } from '../../store/selectors'; import { fetchSystemStatus, @@ -66,6 +67,7 @@ describe('TestrunStore', () => { { selector: selectHasConnectionSettings, value: true }, { selector: selectIsOpenStartTestrun, value: false }, { selector: selectRiskProfiles, value: [] }, + { selector: selectTestModules, value: [] }, ], }), ], @@ -89,6 +91,7 @@ describe('TestrunStore', () => { dataSource: [], stepsToResolveCount: 0, profiles: [], + testModules: [], }); done(); }); diff --git a/modules/ui/src/app/pages/testrun/testrun.store.ts b/modules/ui/src/app/pages/testrun/testrun.store.ts index eacad9959..4f4edabd2 100644 --- a/modules/ui/src/app/pages/testrun/testrun.store.ts +++ b/modules/ui/src/app/pages/testrun/testrun.store.ts @@ -24,6 +24,7 @@ import { selectIsOpenStartTestrun, selectRiskProfiles, selectSystemStatus, + selectTestModules, } from '../../store/selectors'; import { fetchSystemStatus, @@ -41,12 +42,14 @@ import { } from '../../model/testrun-status'; import { FocusManagerService } from '../../services/focus-manager.service'; import { LoaderService } from '../../services/loader.service'; +import { TestModule } from '../../model/device'; const EMPTY_RESULT = new Array(100).fill(null).map(() => ({}) as IResult); export interface TestrunComponentState { dataSource: IResult[] | undefined; stepsToResolveCount: number; + testModules: TestModule[]; } @Injectable() @@ -59,12 +62,15 @@ export class TestrunStore extends ComponentStore { private profiles$ = this.store.select(selectRiskProfiles); private systemStatus$ = this.store.select(selectSystemStatus); isOpenStartTestrun$ = this.store.select(selectIsOpenStartTestrun); + testModules$ = this.store.select(selectTestModules); + viewModel$ = this.select({ hasDevices: this.hasDevices$, systemStatus: this.systemStatus$, dataSource: this.dataSource$, stepsToResolveCount: this.stepsToResolveCount$, profiles: this.profiles$, + testModules: this.testModules$, }); setDataSource = this.updater((state, dataSource: IResult[] | undefined) => { @@ -215,6 +221,7 @@ export class TestrunStore extends ComponentStore { super({ dataSource: undefined, stepsToResolveCount: 0, + testModules: [], }); } } diff --git a/modules/ui/src/app/services/test-run-mqtt.service.spec.ts b/modules/ui/src/app/services/test-run-mqtt.service.spec.ts new file mode 100644 index 000000000..19bda437a --- /dev/null +++ b/modules/ui/src/app/services/test-run-mqtt.service.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from '@angular/core/testing'; + +import { TestRunMqttService } from './test-run-mqtt.service'; +import { IMqttMessage, MqttModule, MqttService } from 'ngx-mqtt'; +import { MQTT_SERVICE_OPTIONS } from '../app.module'; +import SpyObj = jasmine.SpyObj; +import { of } from 'rxjs'; +import { MOCK_ADAPTERS } from '../mocks/settings.mock'; +import { Topic } from '../model/topic'; +import { MOCK_INTERNET } from '../mocks/topic.mock'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../mocks/testrun.mock'; + +describe('TestRunMqttService', () => { + let service: TestRunMqttService; + let mockService: SpyObj; + + beforeEach(() => { + mockService = jasmine.createSpyObj(['observe']); + + TestBed.configureTestingModule({ + imports: [MqttModule.forRoot(MQTT_SERVICE_OPTIONS)], + providers: [{ provide: MqttService, useValue: mockService }], + }); + service = TestBed.inject(TestRunMqttService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getNetworkAdapters', () => { + beforeEach(() => { + mockService.observe.and.returnValue(of(getResponse(MOCK_ADAPTERS))); + }); + + it('should subscribe the topic', done => { + service.getNetworkAdapters().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith(Topic.NetworkAdapters); + done(); + }); + }); + + it('should return object of type', done => { + service.getNetworkAdapters().subscribe(res => { + expect(res).toEqual(MOCK_ADAPTERS); + done(); + }); + }); + }); + + describe('getInternetConnection', () => { + beforeEach(() => { + mockService.observe.and.returnValue(of(getResponse(MOCK_INTERNET))); + }); + + it('should subscribe the topic', done => { + service.getInternetConnection().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith( + Topic.InternetConnection + ); + done(); + }); + }); + + it('should return object of type', done => { + service.getInternetConnection().subscribe(res => { + expect(res).toEqual(MOCK_INTERNET); + done(); + }); + }); + }); + + describe('getStatus', () => { + beforeEach(() => { + mockService.observe.and.returnValue( + of(getResponse(MOCK_PROGRESS_DATA_IN_PROGRESS)) + ); + }); + + it('should subscribe the topic', done => { + service.getStatus().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith(Topic.Status); + done(); + }); + }); + + it('should return object of type', done => { + service.getStatus().subscribe(res => { + expect(res).toEqual(MOCK_PROGRESS_DATA_IN_PROGRESS); + done(); + }); + }); + }); + + function getResponse(response: Type): IMqttMessage { + const enc = new TextEncoder(); + const message = enc.encode(JSON.stringify(response)); + return { + payload: message, + } as IMqttMessage; + } +}); diff --git a/modules/ui/src/app/services/test-run-mqtt.service.ts b/modules/ui/src/app/services/test-run-mqtt.service.ts new file mode 100644 index 000000000..d5e805da6 --- /dev/null +++ b/modules/ui/src/app/services/test-run-mqtt.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { IMqttMessage, MqttService } from 'ngx-mqtt'; +import { catchError, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Adapters } from '../model/setting'; +import { TestrunStatus } from '../model/testrun-status'; +import { InternetConnection, Topic } from '../model/topic'; + +@Injectable({ + providedIn: 'root', +}) +export class TestRunMqttService { + constructor(private mqttService: MqttService) {} + + getNetworkAdapters(): Observable { + return this.topic(Topic.NetworkAdapters); + } + + getInternetConnection(): Observable { + return this.topic(Topic.InternetConnection); + } + + getStatus(): Observable { + return this.topic(Topic.Status); + } + + private topic(topicName: string): Observable { + return this.mqttService.observe(topicName).pipe( + map( + (res: IMqttMessage) => + JSON.parse(new TextDecoder().decode(res.payload)) as Type + ), + catchError(() => { + return of({} as Type); + }) + ); + } +} diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index 069c94c0a..c3c40185e 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -18,7 +18,7 @@ import { HttpTestingController, } from '@angular/common/http/testing'; import { fakeAsync, getTestBed, TestBed, tick } from '@angular/core/testing'; -import { Device, TestModule } from '../model/device'; +import { Device } from '../model/device'; import { TestRunService, UNAVAILABLE_VERSION } from './test-run.service'; import { SystemConfig, SystemInterfaces } from '../model/setting'; @@ -28,7 +28,7 @@ import { StatusOfTestrun, TestrunStatus, } from '../model/testrun-status'; -import { device } from '../mocks/device.mock'; +import { device, MOCK_MODULES } from '../mocks/device.mock'; import { NEW_VERSION, VERSION } from '../mocks/version.mock'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../store/state'; @@ -74,39 +74,23 @@ describe('TestRunService', () => { expect(service).toBeTruthy(); }); - it('should have test modules', () => { - expect(service.getTestModules()).toEqual([ - { - displayName: 'Connection', - name: 'connection', - enabled: true, - }, - { - displayName: 'NTP', - name: 'ntp', - enabled: true, - }, - { - displayName: 'DNS', - name: 'dns', - enabled: true, - }, - { - displayName: 'Services', - name: 'services', - enabled: true, - }, - { - displayName: 'TLS', - name: 'tls', - enabled: true, - }, - { - displayName: 'Protocol', - name: 'protocol', - enabled: true, - }, - ] as TestModule[]); + it('getTestModules should return modules', () => { + let result: string[] = []; + const testModules = MOCK_MODULES; + + service.getTestModules().subscribe(res => { + expect(res).toEqual(result); + }); + + result = testModules; + service.getTestModules(); + const req = httpTestingController.expectOne( + 'http://localhost:8000/system/modules' + ); + + expect(req.request.method).toBe('GET'); + + req.flush(testModules); }); it('fetchDevices should return devices', () => { @@ -284,6 +268,8 @@ describe('TestRunService', () => { const statusesForGreyRes = [ StatusOfTestResult.NotDetected, StatusOfTestResult.NotStarted, + StatusOfTestResult.Skipped, + StatusOfTestResult.Disabled, ]; statusesForGreenRes.forEach(testCase => { diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index 5620f9404..8d913ba61 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -123,8 +123,10 @@ export class TestRunService { .pipe(map(() => true)); } - getTestModules(): TestModule[] { - return this.testModules; + getTestModules(): Observable { + return this.http + .get(`${API_URL}/system/modules`) + .pipe(catchError(() => of([]))); } saveDevice(device: Device): Observable { @@ -183,7 +185,9 @@ export class TestRunService { result === StatusOfTestResult.InProgress, grey: result === StatusOfTestResult.NotDetected || - result === StatusOfTestResult.NotStarted, + result === StatusOfTestResult.NotStarted || + result === StatusOfTestResult.Skipped || + result === StatusOfTestResult.Disabled, }; } diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index 3ca38d16f..806618932 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -16,12 +16,13 @@ import { createAction, props } from '@ngrx/store'; import { + Adapters, InterfacesValidation, SettingMissedError, SystemConfig, } from '../model/setting'; import { SystemInterfaces } from '../model/setting'; -import { Device } from '../model/device'; +import { Device, TestModule } from '../model/device'; import { TestrunStatus } from '../model/testrun-status'; import { Profile } from '../model/profile'; @@ -124,3 +125,25 @@ export const setStatus = createAction( export const stopInterval = createAction('[Shared] Stop Interval'); export const fetchRiskProfiles = createAction('[Shared] Fetch risk profiles'); + +export const updateAdapters = createAction( + '[Shared] Update Adapters', + props<{ adapters: Adapters }>() +); + +export const fetchReports = createAction('[Shared] Fetch reports'); + +export const setReports = createAction( + '[Shared] Set Reports', + props<{ reports: TestrunStatus[] }>() +); + +export const setTestModules = createAction( + '[Shared] Set Test Modules', + props<{ testModules: TestModule[] }>() +); + +export const updateInternetConnection = createAction( + '[Shared] Fetch internet connection', + props<{ internetConnection: boolean | null }>() +); diff --git a/modules/ui/src/app/store/effects.spec.ts b/modules/ui/src/app/store/effects.spec.ts index 024782c63..7d33cc209 100644 --- a/modules/ui/src/app/store/effects.spec.ts +++ b/modules/ui/src/app/store/effects.spec.ts @@ -36,12 +36,24 @@ import { import { device } from '../mocks/device.mock'; import { MOCK_PROGRESS_DATA_CANCELLING, + MOCK_PROGRESS_DATA_COMPLIANT, MOCK_PROGRESS_DATA_IN_PROGRESS, MOCK_PROGRESS_DATA_WAITING_FOR_DEVICE, } from '../mocks/testrun.mock'; -import { fetchSystemStatus, setStatus, setTestrunStatus } from './actions'; +import { + fetchSystemStatus, + fetchSystemStatusSuccess, + setReports, + setStatus, + setTestrunStatus, +} from './actions'; import { NotificationService } from '../services/notification.service'; import { PROFILE_MOCK } from '../mocks/profile.mock'; +import { throwError } from 'rxjs/internal/observable/throwError'; +import { HttpErrorResponse } from '@angular/common/http'; +import { IDLE_STATUS } from '../model/testrun-status'; +import { HISTORY } from '../mocks/reports.mock'; +import { TestRunMqttService } from '../services/test-run-mqtt.service'; describe('Effects', () => { let actions$ = new Observable(); @@ -54,6 +66,11 @@ describe('Effects', () => { 'dismissWithTimout', 'openSnackBar', ]); + const mockMqttService: jasmine.SpyObj = + jasmine.createSpyObj('mockMqttService', [ + 'getStatus', + 'getInternetConnection', + ]); beforeEach(() => { testRunServiceMock = jasmine.createSpyObj('testRunServiceMock', [ @@ -64,6 +81,7 @@ describe('Effects', () => { 'testrunInProgress', 'stopTestrun', 'fetchProfiles', + 'getHistory', ]); testRunServiceMock.getSystemInterfaces.and.returnValue(of({})); testRunServiceMock.getSystemConfig.and.returnValue(of({ network: {} })); @@ -72,12 +90,21 @@ describe('Effects', () => { of(MOCK_PROGRESS_DATA_IN_PROGRESS) ); testRunServiceMock.fetchProfiles.and.returnValue(of([])); + testRunServiceMock.getHistory.and.returnValue(of([])); + mockMqttService.getInternetConnection.and.returnValue( + of({ connection: false }) + ); + + mockMqttService.getStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); TestBed.configureTestingModule({ providers: [ AppEffects, { provide: TestRunService, useValue: testRunServiceMock }, { provide: NotificationService, useValue: notificationServiceMock }, + { provide: TestRunMqttService, useValue: mockMqttService }, provideMockActions(() => actions$), provideMockStore({}), ], @@ -387,14 +414,15 @@ describe('Effects', () => { ); }); - it('should call fetchSystemStatus for status "in progress"', fakeAsync(() => { + it('should call fetchSystemStatus for status "in progress"', () => { effects.onFetchSystemStatusSuccess$.subscribe(() => { - tick(5000); - - expect(dispatchSpy).toHaveBeenCalledWith(fetchSystemStatus()); - discardPeriodicTasks(); + expect(dispatchSpy).toHaveBeenCalledWith( + fetchSystemStatusSuccess({ + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + }) + ); }); - })); + }); it('should dispatch status and systemStatus', done => { effects.onFetchSystemStatusSuccess$.subscribe(() => { @@ -423,6 +451,12 @@ describe('Effects', () => { done(); }); }); + + it('should call fetchInternetConnection for status "in progress"', () => { + effects.onFetchSystemStatusSuccess$.subscribe(() => { + expect(mockMqttService.getInternetConnection).toHaveBeenCalled(); + }); + }); }); describe('with status "waiting for device"', () => { @@ -439,14 +473,15 @@ describe('Effects', () => { ); }); - it('should call fetchSystemStatus for status "waiting for device"', fakeAsync(() => { + it('should call fetchSystemStatus for status "waiting for device"', () => { effects.onFetchSystemStatusSuccess$.subscribe(() => { - tick(5000); - - expect(dispatchSpy).toHaveBeenCalledWith(fetchSystemStatus()); - discardPeriodicTasks(); + expect(dispatchSpy).toHaveBeenCalledWith( + fetchSystemStatusSuccess({ + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + }) + ); }); - })); + }); it('should open snackbar when waiting for device is too long', fakeAsync(() => { effects.onFetchSystemStatusSuccess$.subscribe(() => { @@ -487,4 +522,78 @@ describe('Effects', () => { done(); }); }); + + describe('onFetchReports$', () => { + it(' should call setReports on success', done => { + testRunServiceMock.getHistory.and.returnValue(of([])); + actions$ = of(actions.fetchReports()); + + effects.onFetchReports$.subscribe(action => { + expect(action).toEqual( + actions.setReports({ + reports: [], + }) + ); + done(); + }); + }); + + it('should call setReports with empty array if null is returned', done => { + testRunServiceMock.getHistory.and.returnValue(of(null)); + actions$ = of(actions.fetchReports()); + + effects.onFetchReports$.subscribe(action => { + expect(action).toEqual( + actions.setReports({ + reports: [], + }) + ); + done(); + }); + }); + + it('should call setReports with empty array if error happens', done => { + testRunServiceMock.getHistory.and.returnValue( + throwError( + new HttpErrorResponse({ error: { error: 'error' }, status: 500 }) + ) + ); + actions$ = of(actions.fetchReports()); + + effects.onFetchReports$.subscribe({ + complete: () => { + expect(dispatchSpy).toHaveBeenCalledWith( + setReports({ + reports: [], + }) + ); + done(); + }, + }); + }); + }); + + describe('checkStatusInReports$', () => { + it('should call setTestrunStatus if current test run is completed and not present in reports', done => { + store.overrideSelector( + selectSystemStatus, + Object.assign({}, MOCK_PROGRESS_DATA_COMPLIANT, { + mac_addr: '01:02:03:04:05:07', + report: 'http://localhost:8000/report/1234 1234/2024-07-17T15:33:40', + }) + ); + actions$ = of( + actions.setReports({ + reports: HISTORY, + }) + ); + + effects.checkStatusInReports$.subscribe(action => { + expect(action).toEqual( + actions.setTestrunStatus({ systemStatus: IDLE_STATUS }) + ); + done(); + }); + }); + }); }); diff --git a/modules/ui/src/app/store/effects.ts b/modules/ui/src/app/store/effects.ts index b6cdfcc71..5fdb4d461 100644 --- a/modules/ui/src/app/store/effects.ts +++ b/modules/ui/src/app/store/effects.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Injectable, NgZone } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; @@ -22,29 +22,49 @@ import { map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import * as AppActions from './actions'; import { AppState } from './state'; import { TestRunService } from '../services/test-run.service'; -import { filter, combineLatest, interval, Subject, timer, take } from 'rxjs'; +import { + filter, + combineLatest, + Subject, + timer, + take, + catchError, + EMPTY, + Subscription, +} from 'rxjs'; import { selectIsOpenWaitSnackBar, selectMenuOpened, selectSystemStatus, } from './selectors'; -import { IResult, StatusOfTestrun, TestsData } from '../model/testrun-status'; +import { + IDLE_STATUS, + IResult, + StatusOfTestrun, + TestrunStatus, + TestsData, +} from '../model/testrun-status'; import { fetchSystemStatus, + fetchSystemStatusSuccess, + setReports, setStatus, setTestrunStatus, stopInterval, + updateInternetConnection, } from './actions'; import { takeUntil } from 'rxjs/internal/operators/takeUntil'; import { NotificationService } from '../services/notification.service'; import { Profile } from '../model/profile'; +import { TestRunMqttService } from '../services/test-run-mqtt.service'; +import { InternetConnection } from '../model/topic'; const WAIT_TO_OPEN_SNACKBAR_MS = 60 * 1000; @Injectable() export class AppEffects { - private startInterval = false; - private destroyInterval$: Subject = new Subject(); + private statusSubscription: Subscription | undefined; + private internetSubscription: Subscription | undefined; private destroyWaitDeviceInterval$: Subject = new Subject(); checkInterfacesInConfig$ = createEffect(() => @@ -190,8 +210,8 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.stopInterval), tap(() => { - this.startInterval = false; - this.destroyInterval$.next(true); + this.statusSubscription?.unsubscribe(); + this.internetSubscription?.unsubscribe(); }) ); }, @@ -203,11 +223,9 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.fetchSystemStatusSuccess), tap(({ systemStatus }) => { - if ( - this.testrunService.testrunInProgress(systemStatus.status) && - !this.startInterval - ) { + if (this.testrunService.testrunInProgress(systemStatus.status)) { this.pullingSystemStatusData(); + this.fetchInternetConnection(); } else if ( !this.testrunService.testrunInProgress(systemStatus.status) ) { @@ -235,12 +253,10 @@ export class AppEffects { tap(([{ systemStatus }, , status]) => { // for app - requires only status if (systemStatus.status !== status?.status) { - this.ngZone.run(() => { - this.store.dispatch(setStatus({ status: systemStatus.status })); - this.store.dispatch( - setTestrunStatus({ systemStatus: systemStatus }) - ); - }); + this.store.dispatch(setStatus({ status: systemStatus.status })); + this.store.dispatch( + setTestrunStatus({ systemStatus: systemStatus }) + ); } else if ( systemStatus.finished !== status?.finished || (systemStatus.tests as TestsData)?.results?.length !== @@ -248,11 +264,9 @@ export class AppEffects { (systemStatus.tests as IResult[])?.length !== (status?.tests as IResult[])?.length ) { - this.ngZone.run(() => { - this.store.dispatch( - setTestrunStatus({ systemStatus: systemStatus }) - ); - }); + this.store.dispatch( + setTestrunStatus({ systemStatus: systemStatus }) + ); } }) ); @@ -273,6 +287,53 @@ export class AppEffects { ); }); + onFetchReports$ = createEffect(() => { + return this.actions$.pipe( + ofType(AppActions.fetchReports), + switchMap(() => + this.testrunService.getHistory().pipe( + map((reports: TestrunStatus[] | null) => { + if (reports !== null) { + return AppActions.setReports({ reports }); + } + return AppActions.setReports({ reports: [] }); + }), + catchError(() => { + this.store.dispatch(setReports({ reports: [] })); + return EMPTY; + }) + ) + ) + ); + }); + + checkStatusInReports$ = createEffect(() => { + return this.actions$.pipe( + ofType(AppActions.setReports), + withLatestFrom(this.store.select(selectSystemStatus)), + filter(([, systemStatus]) => { + return ( + systemStatus != null && this.isTestrunFinished(systemStatus.status) + ); + }), + filter(([{ reports }, systemStatus]) => { + return ( + !reports?.some(report => report.report === systemStatus!.report) || + false + ); + }), + map(() => AppActions.setTestrunStatus({ systemStatus: IDLE_STATUS })) + ); + }); + + private isTestrunFinished(status: string) { + return ( + status === StatusOfTestrun.Compliant || + status === StatusOfTestrun.NonCompliant || + status === StatusOfTestrun.Error + ); + } + private showSnackBar() { timer(WAIT_TO_OPEN_SNACKBAR_MS) .pipe( @@ -290,22 +351,40 @@ export class AppEffects { } private pullingSystemStatusData(): void { - this.ngZone.runOutsideAngular(() => { - this.startInterval = true; - interval(5000) - .pipe( - takeUntil(this.destroyInterval$), - tap(() => this.store.dispatch(fetchSystemStatus())) - ) - .subscribe(); - }); + if ( + this.statusSubscription === undefined || + this.statusSubscription?.closed + ) { + this.statusSubscription = this.testrunMqttService + .getStatus() + .subscribe(systemStatus => { + this.store.dispatch(fetchSystemStatusSuccess({ systemStatus })); + }); + } + } + + private fetchInternetConnection() { + if ( + this.internetSubscription === undefined || + this.internetSubscription?.closed + ) { + this.internetSubscription = this.testrunMqttService + .getInternetConnection() + .subscribe((internetConnection: InternetConnection) => { + this.store.dispatch( + updateInternetConnection({ + internetConnection: internetConnection.connection, + }) + ); + }); + } } constructor( private actions$: Actions, private testrunService: TestRunService, + private testrunMqttService: TestRunMqttService, private store: Store, - private ngZone: NgZone, private notificationService: NotificationService ) {} } diff --git a/modules/ui/src/app/store/reducers.spec.ts b/modules/ui/src/app/store/reducers.spec.ts index ad611e9f9..b6fe9d675 100644 --- a/modules/ui/src/app/store/reducers.spec.ts +++ b/modules/ui/src/app/store/reducers.spec.ts @@ -25,16 +25,22 @@ import { setIsOpenAddDevice, setIsOpenStartTestrun, setIsOpenWaitSnackBar, + setReports, setRiskProfiles, setStatus, + setTestModules, setTestrunStatus, toggleMenu, + updateAdapters, updateError, updateFocusNavigation, + updateInternetConnection, } from './actions'; -import { device } from '../mocks/device.mock'; +import { device, MOCK_TEST_MODULES } from '../mocks/device.mock'; import { MOCK_PROGRESS_DATA_CANCELLING } from '../mocks/testrun.mock'; import { PROFILE_MOCK } from '../mocks/profile.mock'; +import { HISTORY } from '../mocks/reports.mock'; +import { MOCK_ADAPTERS } from '../mocks/settings.mock'; describe('Reducer', () => { describe('unknown action', () => { @@ -258,4 +264,67 @@ describe('Reducer', () => { expect(state).not.toBe(initialState); }); }); + + describe('setReports action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setReports({ + reports: HISTORY, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ reports: HISTORY }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('setTestModules action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setTestModules({ + testModules: MOCK_TEST_MODULES, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ testModules: MOCK_TEST_MODULES }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('updateAdapters action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = updateAdapters({ + adapters: MOCK_ADAPTERS, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ adapters: MOCK_ADAPTERS }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + + describe('updateInternetConnection action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = updateInternetConnection({ internetConnection: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ internetConnection: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); }); diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index 501c231a5..dfc54b11f 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -106,6 +106,30 @@ export const sharedReducer = createReducer( ...state, status, }; + }), + on(Actions.setReports, (state, { reports }) => { + return { + ...state, + reports, + }; + }), + on(Actions.setTestModules, (state, { testModules }) => { + return { + ...state, + testModules, + }; + }), + on(Actions.updateAdapters, (state, { adapters }) => { + return { + ...state, + adapters, + }; + }), + on(Actions.updateInternetConnection, (state, { internetConnection }) => { + return { + ...state, + internetConnection, + }; }) ); diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index e8d31efc8..facc8bb74 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -16,6 +16,7 @@ import { AppState } from './state'; import { + selectAdapters, selectDeviceInProgress, selectDevices, selectError, @@ -27,9 +28,12 @@ import { selectIsOpenStartTestrun, selectIsOpenWaitSnackBar, selectMenuOpened, + selectReports, selectRiskProfiles, selectStatus, selectSystemStatus, + selectTestModules, + selectInternetConnection, } from './selectors'; describe('Selectors', () => { @@ -55,6 +59,10 @@ describe('Selectors', () => { systemStatus: null, deviceInProgress: null, status: null, + reports: [], + testModules: [], + adapters: {}, + internetConnection: null, }, }; @@ -127,4 +135,24 @@ describe('Selectors', () => { const result = selectStatus.projector(initialState); expect(result).toEqual(null); }); + + it('should select status', () => { + const result = selectReports.projector(initialState); + expect(result).toEqual([]); + }); + + it('should select testModules', () => { + const result = selectTestModules.projector(initialState); + expect(result).toEqual([]); + }); + + it('should select adapters', () => { + const result = selectAdapters.projector(initialState); + expect(result).toEqual({}); + }); + + it('should select internetConnection', () => { + const result = selectInternetConnection.projector(initialState); + expect(result).toEqual(null); + }); }); diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 2f42db3d6..383fee1b9 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -93,3 +93,23 @@ export const selectStatus = createSelector( selectAppState, (state: AppState) => state.shared.status ); + +export const selectReports = createSelector( + selectAppState, + (state: AppState) => state.shared.reports +); + +export const selectTestModules = createSelector( + selectAppState, + (state: AppState) => state.shared.testModules +); + +export const selectAdapters = createSelector( + selectAppState, + (state: AppState) => state.shared.adapters +); + +export const selectInternetConnection = createSelector( + selectAppState, + (state: AppState) => state.shared.internetConnection +); diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index e2528c5a0..76e2d3254 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -14,8 +14,12 @@ * limitations under the License. */ import { TestrunStatus } from '../model/testrun-status'; -import { SettingMissedError, SystemInterfaces } from '../model/setting'; -import { Device } from '../model/device'; +import { Device, TestModule } from '../model/device'; +import { + Adapters, + SettingMissedError, + SystemInterfaces, +} from '../model/setting'; import { Profile } from '../model/profile'; export interface AppState { @@ -54,6 +58,10 @@ export interface SharedState { isStopTestrun: boolean; isOpenWaitSnackBar: boolean; deviceInProgress: Device | null; + reports: TestrunStatus[]; + testModules: TestModule[]; + adapters: Adapters; + internetConnection: boolean | null; } export const initialAppComponentState: AppComponentState = { @@ -78,4 +86,8 @@ export const initialSharedState: SharedState = { isOpenStartTestrun: false, systemStatus: null, status: null, + reports: [], + testModules: [], + adapters: {}, + internetConnection: null, }; diff --git a/modules/ui/ui.Dockerfile b/modules/ui/ui.Dockerfile index da56be93e..7ecb32dbd 100644 --- a/modules/ui/ui.Dockerfile +++ b/modules/ui/ui.Dockerfile @@ -12,17 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Image name: test-run/ui -FROM node@sha256:ffebb4405810c92d267a764b21975fb2d96772e41877248a37bf3abaa0d3b590 as build - -WORKDIR /modules/ui -COPY modules/ui/ /modules/ui -RUN npm install -RUN npm run build - +# Image name: testrun/ui FROM nginx@sha256:4c0fdaa8b6341bfdeca5f18f7837462c80cff90527ee35ef185571e1c327beac -COPY --from=build /modules/ui/dist/ /usr/share/nginx/html +COPY modules/ui/dist/ /usr/share/nginx/html EXPOSE 8080 diff --git a/modules/ws/conf/mosquitto.conf b/modules/ws/conf/mosquitto.conf new file mode 100644 index 000000000..9027ba814 --- /dev/null +++ b/modules/ws/conf/mosquitto.conf @@ -0,0 +1,22 @@ +## Logging + +log_dest stdout +log_type all +log_timestamp true +connection_messages true + +## MQTT Listener + +listener 1883 +protocol mqtt + +## WebSockets Listener + +listener 9001 +protocol websockets + +allow_anonymous true + +## Persistence + +persistence false \ No newline at end of file diff --git a/modules/ws/ws.Dockerfile b/modules/ws/ws.Dockerfile new file mode 100644 index 000000000..7e9408a47 --- /dev/null +++ b/modules/ws/ws.Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-mosquitto:2.0.18 +RUN mkdir -p /mosquitto/data/ +COPY modules/ws/conf/mosquitto.conf /mosquitto/config/mosquitto.conf +VOLUME /mosquitto/data/ \ No newline at end of file diff --git a/testing/api/profiles/new_profile.json b/testing/api/profiles/new_profile.json new file mode 100644 index 000000000..d63ecd17c --- /dev/null +++ b/testing/api/profiles/new_profile.json @@ -0,0 +1,54 @@ +{ + "name": "New Profile", + "status": "Valid", + "questions": [ + { + "question": "What type of device is this?", + "answer": "IoT Gateway" + }, + { + "question": "How will this device be used at Google?", + "answer": "Monitoring" + }, + { + "question": "Is this device going to be managed by Google or a third party?", + "answer": "Google" + }, + { + "question": "Will the third-party device administrator be able to grant access to authorized Google personnel upon request?", + "answer": "N/A" + }, + { + "question": "Are any of the following statements true about your device?", + "answer": [ + 0 + ] + }, + { + "question": "Which of the following statements are true about this device?", + "answer": [ + 0 + ] + }, + { + "question": "Does the network protocol assure server-to-client identity verification?", + "answer": "Yes" + }, + { + "question": "Click the statements that best describe the characteristics of this device.", + "answer": [ + 0 + ] + }, + { + "question": "Are any of the following statements true about this device?", + "answer": [ + 0 + ] + }, + { + "question": "Comments", + "answer": "" + } + ] + } \ No newline at end of file diff --git a/testing/api/profiles/new_profile_2.json b/testing/api/profiles/new_profile_2.json new file mode 100644 index 000000000..2ac93dc17 --- /dev/null +++ b/testing/api/profiles/new_profile_2.json @@ -0,0 +1,56 @@ +{ + "name": "New Profile 2", + "status": "Draft", + "questions": [ + { + "question": "What type of device is this?", + "answer": "IoT Gateway" + }, + { + "question": "How will this device be used at Google?", + "answer": "Installed in a building" + }, + { + "question": "Is this device going to be managed by Google or a third party?", + "answer": "Google" + }, + { + "question": "Will the third-party device administrator be able to grant access to authorized Google personnel upon request?", + "answer": "Yes" + }, + { + "question": "Are any of the following statements true about your device?", + "answer": [ + 0, + 2 + ] + }, + { + "question": "Which of the following statements are true about this device?", + "answer": [ + 0, + 1, + 5 + ] + }, + { + "question": "Does the network protocol assure server-to-client identity verification?", + "answer": "Yes" + }, + { + "question": "Click the statements that best describe the characteristics of this device.", + "answer": [ + 0, + 1, + 2 + ] + }, + { + "question": "Are any of the following statements true about this device?", + "answer": [ + 2, + 3 + ] + } + ] +} \ No newline at end of file diff --git a/testing/api/profiles/updated_profile.json b/testing/api/profiles/updated_profile.json new file mode 100644 index 000000000..91714bcfa --- /dev/null +++ b/testing/api/profiles/updated_profile.json @@ -0,0 +1,57 @@ +{ + "name": "New Profile", + "rename": "Updated Profile", + "status": "Draft", + "questions": [ + { + "question": "What type of device is this?", + "answer": "IoT Gateway" + }, + { + "question": "How will this device be used at Google?", + "answer": "Installed in a building" + }, + { + "question": "Is this device going to be managed by Google or a third party?", + "answer": "Google" + }, + { + "question": "Will the third-party device administrator be able to grant access to authorized Google personnel upon request?", + "answer": "Yes" + }, + { + "question": "Are any of the following statements true about your device?", + "answer": [ + 0, + 2 + ] + }, + { + "question": "Which of the following statements are true about this device?", + "answer": [ + 0, + 1, + 5 + ] + }, + { + "question": "Does the network protocol assure server-to-client identity verification?", + "answer": "Yes" + }, + { + "question": "Click the statements that best describe the characteristics of this device.", + "answer": [ + 0, + 1, + 2 + ] + }, + { + "question": "Are any of the following statements true about this device?", + "answer": [ + 2, + 3 + ] + } + ] +} \ No newline at end of file diff --git a/testing/api/test_api.py b/testing/api/test_api.py index 75811e3bb..70c1a617f 100644 --- a/testing/api/test_api.py +++ b/testing/api/test_api.py @@ -29,6 +29,7 @@ import pytest import requests + ALL_DEVICES = "*" API = "http://127.0.0.1:8000" LOG_PATH = "/tmp/testrun.log" @@ -36,7 +37,10 @@ DEVICES_DIRECTORY = "local/devices" TESTING_DEVICES = "../device_configs" +PROFILES_DIRECTORY = "local/risk_profiles" SYSTEM_CONFIG_PATH = "local/system.json" +SYSTEM_CONFIG_RESTORE_PATH = "testing/api/system.json" +PROFILES_PATH = "testing/api/profiles" BASELINE_MAC_ADDR = "02:42:aa:00:01:01" ALL_MAC_ADDR = "02:42:aa:00:00:01" @@ -45,21 +49,18 @@ def pretty_print(dictionary: dict): """ Pretty print dictionary """ print(json.dumps(dictionary, indent=4)) - def query_system_status() -> str: """Query system status from API and returns this""" r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) + response = r.json() return response["status"] - def query_test_count() -> int: """Queries status and returns number of test results""" r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) + response = r.json() return len(response["tests"]["results"]) - def start_test_device( device_name, mac_address, image_name="test-run/ci_device_1", args="" ): @@ -74,7 +75,6 @@ def start_test_device( ) print(cmd.stdout) - def stop_test_device(device_name): """ Stop docker container with given name """ cmd = subprocess.run( @@ -88,7 +88,6 @@ def stop_test_device(device_name): ) print(cmd.stdout) - def docker_logs(device_name): """ Print docker logs from given docker container name """ cmd = subprocess.run( @@ -97,13 +96,23 @@ def docker_logs(device_name): ) print(cmd.stdout) +def load_json(file_name, directory): + """Utility method to load json files' """ + # Construct the base path relative to the main folder + base_path = Path(__file__).resolve().parent.parent.parent + # Construct the full file path + file_path = base_path / directory / file_name + + # Open the file in read mode + with open(file_path, "r", encoding="utf-8") as file: + # Return the file content + return json.load(file) @pytest.fixture def empty_devices_dir(): """ Use e,pty devices directory """ local_delete_devices(ALL_DEVICES) - @pytest.fixture def testing_devices(): """ Use devices from the testing/device_configs directory """ @@ -115,10 +124,10 @@ def testing_devices(): ) return local_get_devices() - @pytest.fixture def testrun(request): # pylint: disable=W0613 """ Start intstance of testrun """ + # pylint: disable=W1509 with subprocess.Popen( "bin/testrun", stdout=subprocess.PIPE, @@ -165,7 +174,6 @@ def testrun(request): # pylint: disable=W0613 ) print(cmd.stdout) - def until_true(func: Callable, message: str, timeout: int): """ Blocks until given func returns True @@ -179,7 +187,6 @@ def until_true(func: Callable, message: str, timeout: int): time.sleep(1) raise TimeoutError(f"Timed out waiting {timeout}s for {message}") - def dict_paths(thing: dict, stem: str = "") -> Iterator[str]: """Returns json paths (in dot notation) from a given dictionary""" for k, v in thing.items(): @@ -189,7 +196,6 @@ def dict_paths(thing: dict, stem: str = "") -> Iterator[str]: else: yield path - def get_network_interfaces(): """return list of network interfaces on machine @@ -204,7 +210,6 @@ def get_network_interfaces(): ifaces.append(i.stem) return ifaces - def local_delete_devices(path): """ Deletes all local devices """ @@ -214,7 +219,6 @@ def local_delete_devices(path): else: shutil.rmtree(thing) - def local_get_devices(): """ Returns path to device configs of devices in local/devices directory""" return sorted( @@ -223,25 +227,240 @@ def local_get_devices(): ) ) +# Tests for system endpoints + +@pytest.fixture() +def restore_config(): + """Restore the original configuration (system.json) after the test""" + yield + + # Restore system.json from 'testing/api/' after the test + if os.path.exists(SYSTEM_CONFIG_RESTORE_PATH): + shutil.copy(SYSTEM_CONFIG_RESTORE_PATH, SYSTEM_CONFIG_PATH) def test_get_system_interfaces(testrun): # pylint: disable=W0613 """Tests API system interfaces against actual local interfaces""" + + # Send a GET request to the API to retrieve system interfaces r = requests.get(f"{API}/system/interfaces", timeout=5) - response = json.loads(r.text) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Retrieve the actual network interfaces local_interfaces = get_network_interfaces() - assert set(response.keys()) == set(local_interfaces) - # schema expects a flat list + # Check if the key are in the response + assert set(response.keys()) == set(local_interfaces) + # Ensure that all values in the response are strings assert all(isinstance(x, str) for x in response) +def test_update_system_config(testrun, restore_config): # pylint: disable=W0613 + """Test update system configuration endpoint ('/system/config')""" -def test_status_idle(testrun): # pylint: disable=W0613 - until_true( - lambda: query_system_status().lower() == "idle", - "system status is `idle`", - 30, + # Configuration data to update + updated_system_config = { + "network": { + "device_intf": "updated_endev0a", + "internet_intf": "updated_wlan1" + }, + "log_level": "DEBUG" + } + + # Send the post request to update the system configuration + r = requests.post(f"{API}/system/config", + data=json.dumps(updated_system_config), + timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Check if the response["network"]["device_intf"] has been updated + assert ( + response["network"]["device_intf"] + == updated_system_config["network"]["device_intf"] + ) + + # Check if the response["network"]["internet_intf"] has been updated + assert ( + response["network"]["internet_intf"] + == updated_system_config["network"]["internet_intf"] + ) + + # Check if the response["log_level"] has been updated + assert ( + response["log_level"] + == updated_system_config["log_level"] + ) + +def test_update_system_config_invalid_config(testrun, restore_config): # pylint: disable=W0613 + """Test invalid configuration file for update system configuration""" + + # Configuration data to update with missing "log_level" field + updated_system_config = { + "network": { + "device_intf": "updated_endev0a", + "internet_intf": "updated_wlan1" + } + } + + # Send the post request to update the system configuration + r = requests.post(f"{API}/system/config", + data=json.dumps(updated_system_config), + timeout=5) + + # Check if status code is 400 (Invalid config) + assert r.status_code == 400 + +def test_get_system_config(testrun): # pylint: disable=W0613 + """Tests get system configuration endpoint ('/system/config')""" + + # Send a GET request to the API to retrieve system configuration + r = requests.get(f"{API}/system/config", timeout=5) + + # Load system configuration file + local_config = load_json("system.json", directory="local") + + # Parse the JSON response + api_config = r.json() + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Validate structure + assert set(dict_paths(api_config)) | set(dict_paths(local_config)) == set( + dict_paths(api_config) + ) + + # Check if the device interface in the local config matches the API config + assert ( + local_config["network"]["device_intf"] + == api_config["network"]["device_intf"] + ) + + # Check if the internet interface in the local config matches the API config + assert ( + local_config["network"]["internet_intf"] + == api_config["network"]["internet_intf"] ) +def test_start_testrun_started_successfully(testing_devices, testrun): # pylint: disable=W0613 + """Test for testrun started successfully """ + + # Payload with device details + payload = {"device": { + "mac_addr": BASELINE_MAC_ADDR, + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + }}} + + # Send the post request + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 200 (OK) + assert r.status_code == 200 + + # Parse the json response + response = r.json() + + # Check that device is in response + assert "device" in response + + # Check that mac_addr in response + assert "mac_addr" in response["device"] + + # Check that firmware in response + assert "firmware" in response["device"] + +def test_start_testrun_missing_device(testing_devices, testrun): # pylint: disable=W0613 + """Test for missing device when testrun is started """ + + # Payload empty dict (no device) + payload = {} + + # Send the post request + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 400 (bad request) + assert r.status_code == 400 + + # Parse the json response + response = r.json() + + # Check if 'error' in response + assert "error" in response + +def test_start_testrun_already_started(testing_devices, testrun): # pylint: disable=W0613 + """Test for testrun already started """ + + # Payload with device details + payload = {"device": { + "mac_addr": BASELINE_MAC_ADDR, + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + }}} + + # Send the post request (start test) + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 200 (OK) + assert r.status_code == 200 + + # Send the second post request (start test again) + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Parse the json response + response = r.json() + + # Check if the response status code is 409 (Conflict) + assert r.status_code == 409 + + # Check if 'error' in response + assert "error" in response + +def test_start_testrun_device_not_found(testing_devices, testrun): # pylint: disable=W0613 + """Test for start testrun device not found """ + + # Payload with device details with no mac address assigned + payload = {"device": { + "mac_addr": "", + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + }}} + + # Send the post request + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if the response status code is 404 (not found) + assert r.status_code == 404 + + # Parse the json response + response = r.json() + + # Check if 'error' in response + assert "error" in response + # Currently not working due to blocking during monitoring period @pytest.mark.skip() def test_status_in_progress(testing_devices, testrun): # pylint: disable=W0613 @@ -264,22 +483,35 @@ def test_status_in_progress(testing_devices, testrun): # pylint: disable=W0613 600, ) - +# Currently not working due to blocking during monitoring period @pytest.mark.skip() -def test_status_non_compliant(testing_devices, testrun): # pylint: disable=W0613 +def test_start_testrun_already_in_progress( + testing_devices, # pylint: disable=W0613 + testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) - payload = { - "device": { - "mac_addr": all_devices[0]["mac_addr"], - "firmware": "asd" - } - } - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", BASELINE_MAC_ADDR) + + until_true( + lambda: query_system_status().lower() == "in progress", + "system status is `in progress`", + 600, + ) + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + assert r.status_code == 409 + +@pytest.mark.skip() +def test_trigger_run(testing_devices, testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) assert r.status_code == 200 - print(r.text) until_true( lambda: query_system_status().lower() == "waiting for device", @@ -287,102 +519,350 @@ def test_status_non_compliant(testing_devices, testrun): # pylint: disable=W0613 30, ) - start_test_device("x123", all_devices[0]["mac_addr"]) + start_test_device("x123", BASELINE_MAC_ADDR) until_true( - lambda: query_system_status().lower() == "non-compliant", - "system status is `complete", + lambda: query_system_status().lower() == "compliant", + "system status is `complete`", 600, ) stop_test_device("x123") -def test_create_get_devices(empty_devices_dir, testrun): # pylint: disable=W0613 - device_1 = { - "manufacturer": "Google", - "model": "First", - "mac_addr": "00:1e:42:35:73:c4", - "test_modules": { - "dns": {"enabled": True}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } - - r = requests.post(f"{API}/device", data=json.dumps(device_1), - timeout=5) - print(r.text) - assert r.status_code == 201 - assert len(local_get_devices()) == 1 - - device_2 = { - "manufacturer": "Google", - "model": "Second", - "mac_addr": "00:1e:42:35:73:c6", - "test_modules": { - "dns": {"enabled": True}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } - r = requests.post(f"{API}/device", data=json.dumps(device_2), - timeout=5) - assert r.status_code == 201 - assert len(local_get_devices()) == 2 + # Validate response + r = requests.get(f"{API}/system/status", timeout=5) + response = r.json() + pretty_print(response) - # Test that returned devices API endpoint matches expected structure - r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) - pretty_print(all_devices) + # Validate results + results = {x["name"]: x for x in response["tests"]["results"]} + print(results) + # there are only 3 baseline tests + assert len(results) == 3 + # Validate structure with open( - os.path.join(os.path.dirname(__file__), "mockito/get_devices.json"), - encoding="utf-8" + os.path.join( + os.path.dirname(__file__), "mockito/running_system_status.json" + ), encoding="utf-8" ) as f: mockito = json.load(f) - print(mockito) - - # Validate structure - assert all(isinstance(x, dict) for x in all_devices) + # validate structure + assert set(dict_paths(mockito)).issubset(set(dict_paths(response))) - # TOOO uncomment when is done - # assert set(dict_paths(mockito[0])) == set(dict_paths(all_devices[0])) + # Validate results structure + assert set(dict_paths(mockito["tests"]["results"][0])).issubset( + set(dict_paths(response["tests"]["results"][0])) + ) - # Validate contents of given keys matches - for key in ["mac_addr", "manufacturer", "model"]: - assert set([all_devices[0][key], all_devices[1][key]]) == set( - [device_1[key], device_2[key]] - ) + # Validate a result + assert results["baseline.compliant"]["result"] == "Compliant" +@pytest.mark.skip() +def test_stop_running_test(testing_devices, testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": ALL_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + assert r.status_code == 200 -def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0613 - device_1 = { - "manufacturer": "Google", - "model": "First", - "mac_addr": "00:1e:42:35:73:c4", - "test_modules": { - "dns": {"enabled": True}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) - # Send create device request - r = requests.post(f"{API}/device", - data=json.dumps(device_1), - timeout=5) - print(r.text) + start_test_device("x12345", ALL_MAC_ADDR) - # Check device has been created - assert r.status_code == 201 - assert len(local_get_devices()) == 1 + until_true( + lambda: query_test_count() > 1, + "system status is `complete`", + 1000, + ) + + stop_test_device("x12345") + + # Validate response + r = requests.post(f"{API}/system/stop", timeout=5) + response = r.json() + pretty_print(response) + assert response == {"success": "Testrun stopped"} + time.sleep(1) + + # Validate response + r = requests.get(f"{API}/system/status", timeout=5) + response = r.json() + pretty_print(response) + + assert response["status"] == "Cancelled" + +def test_stop_running_not_running(testrun): # pylint: disable=W0613 + # Validate response + r = requests.post(f"{API}/system/stop", + timeout=10) + response = r.json() + pretty_print(response) + + assert r.status_code == 404 + assert response["error"] == "Testrun is not currently running" + +@pytest.mark.skip() +def test_multiple_runs(testing_devices, testrun): # pylint: disable=W0613 + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + assert r.status_code == 200 + print(r.text) + + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", BASELINE_MAC_ADDR) + + until_true( + lambda: query_system_status().lower() == "compliant", + "system status is `complete`", + 900, + ) + + stop_test_device("x123") + + # Validate response + r = requests.get(f"{API}/system/status", timeout=5) + response = r.json() + pretty_print(response) + + # Validate results + results = {x["name"]: x for x in response["tests"]["results"]} + print(results) + # there are only 3 baseline tests + assert len(results) == 3 + + payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + # assert r.status_code == 200 + # returns 409 + print(r.text) + + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", BASELINE_MAC_ADDR) + + until_true( + lambda: query_system_status().lower() == "compliant", + "system status is `complete`", + 900, + ) + + stop_test_device("x123") + +def test_status_idle(testrun): # pylint: disable=W0613 + """Test system status 'idle' endpoint (/system/status)""" + until_true( + lambda: query_system_status().lower() == "idle", + "system status is `idle`", + 30, + ) + +def test_system_shutdown(testrun): # pylint: disable=W0613 + """Test the shutdown system endpoint""" + # Send a POST request to initiate the system shutdown + r = requests.post(f"{API}/system/shutdown", timeout=5) + + # Check if the response status code is 200 (OK) + assert r.status_code == 200, f"Expected status code 200, got {r.status_code}" + +def test_system_shutdown_in_progress(testrun): # pylint: disable=W0613 + """Test system shutdown during an in-progress test""" + # Payload with device details to start a test + payload = { + "device": { + "mac_addr": BASELINE_MAC_ADDR, + "firmware": "asd", + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": False}, + "baseline": {"enabled": False}, + "nmap": {"enabled": False} + } + } + } + + # Start a test + r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + + # Check if status code is not 200 (OK) + if r.status_code != 200: + raise ValueError(f"Api request failed with code: {r.status_code}") + + # Attempt to shutdown while the test is running + r = requests.post(f"{API}/system/shutdown", timeout=5) + + # Check if the response status code is 400 (test in progress) + assert r.status_code == 400 + +def test_system_latest_version(testrun): # pylint: disable=W0613 + """Test for testrun version when the latest version is installed""" + + # Send the get request to the API + r = requests.get(f"{API}/system/version", timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 200 (update available) + assert r.status_code == 200 + # Check if an update is available + assert response["update_available"] is False + +# Tests for reports endpoints + +def test_get_reports_no_reports(testrun): # pylint: disable=W0613 + """Test get reports when no reports exist.""" + + # Send a GET request to the /reports endpoint + r = requests.get(f"{API}/reports", timeout=5) + + # Check if the status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Check if the response is a list + assert isinstance(response, list) + + # Check if the response is an empty list + assert response == [] + +# Tests for device endpoints + +@pytest.mark.skip() +def test_status_non_compliant(testing_devices, testrun): # pylint: disable=W0613 + + r = requests.get(f"{API}/devices", timeout=5) + all_devices = r.json() + payload = { + "device": { + "mac_addr": all_devices[0]["mac_addr"], + "firmware": "asd" + } + } + r = requests.post(f"{API}/system/start", data=json.dumps(payload), + timeout=10) + assert r.status_code == 200 + print(r.text) + + until_true( + lambda: query_system_status().lower() == "waiting for device", + "system status is `waiting for device`", + 30, + ) + + start_test_device("x123", all_devices[0]["mac_addr"]) + + until_true( + lambda: query_system_status().lower() == "non-compliant", + "system status is `complete", + 600, + ) + + stop_test_device("x123") + +def test_create_get_devices(empty_devices_dir, testrun): # pylint: disable=W0613 + device_1 = { + "manufacturer": "Google", + "model": "First", + "mac_addr": "00:1e:42:35:73:c4", + "test_modules": { + "dns": {"enabled": True}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + + r = requests.post(f"{API}/device", data=json.dumps(device_1), + timeout=5) + print(r.text) + assert r.status_code == 201 + assert len(local_get_devices()) == 1 + + device_2 = { + "manufacturer": "Google", + "model": "Second", + "mac_addr": "00:1e:42:35:73:c6", + "test_modules": { + "dns": {"enabled": True}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + r = requests.post(f"{API}/device", data=json.dumps(device_2), + timeout=5) + assert r.status_code == 201 + assert len(local_get_devices()) == 2 + + # Test that returned devices API endpoint matches expected structure + r = requests.get(f"{API}/devices", timeout=5) + all_devices = r.json() + pretty_print(all_devices) + + with open( + os.path.join(os.path.dirname(__file__), "mockito/get_devices.json"), + encoding="utf-8" + ) as f: + mockito = json.load(f) + + print(mockito) + + # Validate structure + assert all(isinstance(x, dict) for x in all_devices) + + # TOOO uncomment when is done + # assert set(dict_paths(mockito[0])) == set(dict_paths(all_devices[0])) + + # Validate contents of given keys matches + for key in ["mac_addr", "manufacturer", "model"]: + assert set([all_devices[0][key], all_devices[1][key]]) == set( + [device_1[key], device_2[key]] + ) + +def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0613 + device_1 = { + "manufacturer": "Google", + "model": "First", + "mac_addr": "00:1e:42:35:73:c4", + "test_modules": { + "dns": {"enabled": True}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + + # Send create device request + r = requests.post(f"{API}/device", + data=json.dumps(device_1), + timeout=5) + print(r.text) + + # Check device has been created + assert r.status_code == 201 + assert len(local_get_devices()) == 1 device_2 = { "manufacturer": "Google", @@ -413,7 +893,7 @@ def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0 # Test that returned devices API endpoint matches expected structure r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) + all_devices = r.json() pretty_print(all_devices) with open( @@ -437,7 +917,6 @@ def test_delete_device_success(empty_devices_dir, testrun): # pylint: disable=W0 [device_2[key]] ) - def test_delete_device_not_found(empty_devices_dir, testrun): # pylint: disable=W0613 device_1 = { "manufacturer": "Google", @@ -476,7 +955,6 @@ def test_delete_device_not_found(empty_devices_dir, testrun): # pylint: disable= assert r.status_code == 404 assert len(local_get_devices()) == 0 - def test_delete_device_no_mac(empty_devices_dir, testrun): # pylint: disable=W0613 device_1 = { "manufacturer": "Google", @@ -510,7 +988,6 @@ def test_delete_device_no_mac(empty_devices_dir, testrun): # pylint: disable=W06 assert r.status_code == 400 assert len(local_get_devices()) == 1 - # Currently not working due to blocking during monitoring period @pytest.mark.skip() def test_delete_device_testrun_running(testing_devices, testrun): # pylint: disable=W0613 @@ -550,41 +1027,8 @@ def test_delete_device_testrun_running(testing_devices, testrun): # pylint: disa timeout=5) assert r.status_code == 403 - -def test_start_testrun_started_successfully( - testing_devices, # pylint: disable=W0613 - testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - assert r.status_code == 200 - - -# Currently not working due to blocking during monitoring period -@pytest.mark.skip() -def test_start_testrun_already_in_progress( - testing_devices, # pylint: disable=W0613 - testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) - - start_test_device("x123", BASELINE_MAC_ADDR) - - until_true( - lambda: query_system_status().lower() == "in progress", - "system status is `in progress`", - 600, - ) - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) - assert r.status_code == 409 - -def test_start_system_not_configured_correctly( - empty_devices_dir, # pylint: disable=W0613 +def test_start_system_not_configured_correctly( + empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 device_1 = { "manufacturer": "Google", @@ -611,7 +1055,6 @@ def test_start_system_not_configured_correctly( timeout=10) assert r.status_code == 500 - def test_start_device_not_found(empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 device_1 = { @@ -644,7 +1087,6 @@ def test_start_device_not_found(empty_devices_dir, # pylint: disable=W0613 timeout=10) assert r.status_code == 404 - def test_start_missing_device_information( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -673,7 +1115,6 @@ def test_start_missing_device_information( timeout=10) assert r.status_code == 400 - def test_create_device_already_exists( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -703,7 +1144,6 @@ def test_create_device_already_exists( print(r.text) assert r.status_code == 409 - def test_create_device_invalid_json( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -716,7 +1156,6 @@ def test_create_device_invalid_json( print(r.text) assert r.status_code == 400 - def test_create_device_invalid_request( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -727,7 +1166,6 @@ def test_create_device_invalid_request( print(r.text) assert r.status_code == 400 - def test_device_edit_device( testing_devices, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -740,7 +1178,7 @@ def test_device_edit_device( new_model = "Alphabet" r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) + all_devices = r.json() api_device = next(x for x in all_devices if x["mac_addr"] == mac_addr) @@ -770,13 +1208,12 @@ def test_device_edit_device( assert r.status_code == 200 r = requests.get(f"{API}/devices", timeout=5) - all_devices = json.loads(r.text) + all_devices = r.json() updated_device_api = next(x for x in all_devices if x["mac_addr"] == mac_addr) assert updated_device_api["model"] == new_model assert updated_device_api["test_modules"] == new_test_modules - def test_device_edit_device_not_found( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -814,7 +1251,6 @@ def test_device_edit_device_not_found( assert r.status_code == 404 - def test_device_edit_device_incorrect_json_format( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -847,7 +1283,6 @@ def test_device_edit_device_incorrect_json_format( assert r.status_code == 400 - def test_device_edit_device_with_mac_already_exists( empty_devices_dir, # pylint: disable=W0613 testrun): # pylint: disable=W0613 @@ -904,42 +1339,9 @@ def test_device_edit_device_with_mac_already_exists( assert r.status_code == 409 - -def test_system_latest_version(testrun): # pylint: disable=W0613 - r = requests.get(f"{API}/system/version", timeout=5) - assert r.status_code == 200 - updated_system_version = json.loads(r.text)["update_available"] - assert updated_system_version is False - -def test_get_system_config(testrun): # pylint: disable=W0613 - r = requests.get(f"{API}/system/config", timeout=5) - - with open( - SYSTEM_CONFIG_PATH, - encoding="utf-8" - ) as f: - local_config = json.load(f) - - api_config = json.loads(r.text) - - # validate structure - assert set(dict_paths(api_config)) | set(dict_paths(local_config)) == set( - dict_paths(api_config) - ) - - assert ( - local_config["network"]["device_intf"] - == api_config["network"]["device_intf"] - ) - assert ( - local_config["network"]["internet_intf"] - == api_config["network"]["internet_intf"] - ) - - def test_invalid_path_get(testrun): # pylint: disable=W0613 r = requests.get(f"{API}/blah/blah", timeout=5) - response = json.loads(r.text) + response = r.json() assert r.status_code == 404 with open( os.path.join(os.path.dirname(__file__), "mockito/invalid_request.json"), @@ -950,188 +1352,484 @@ def test_invalid_path_get(testrun): # pylint: disable=W0613 # validate structure assert set(dict_paths(mockito)) == set(dict_paths(response)) +def test_create_invalid_chars(empty_devices_dir, testrun): # pylint: disable=W0613 + # local_delete_devices(ALL_DEVICES) + # We must start test run with no devices in local/devices for this test + # to function as expected + assert len(local_get_devices()) == 0 -@pytest.mark.skip() -def test_trigger_run(testing_devices, testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), timeout=10) + # Test adding device + device_1 = { + "manufacturer": "/'disallowed characters///", + "model": "First", + "mac_addr": BASELINE_MAC_ADDR, + "test_modules": { + "dns": {"enabled": False}, + "connection": {"enabled": True}, + "ntp": {"enabled": True}, + "baseline": {"enabled": True}, + "nmap": {"enabled": True}, + }, + } + + r = requests.post(f"{API}/device", data=json.dumps(device_1), + timeout=5) + print(r.text) + print(r.status_code) + +def test_get_test_modules(testrun): # pylint: disable=W0613 + """Test the /system/modules endpoint to get the test modules""" + + # Send a GET request to the API endpoint + r = requests.get(f"{API}/system/modules", timeout=5) + + # Check if status code is 200 (OK) assert r.status_code == 200 - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) + # Parse the JSON response + response = r.json() + + # Check if the response is a list + assert isinstance(response, list) + +# Tests for profile endpoints +def delete_all_profiles(): + """Utility method to delete all profiles from risk_profiles folder""" + + # Assign the profiles directory + profiles_path = Path(PROFILES_DIRECTORY) + + try: + # Check if the profile_path (local/risk_profiles) exists and is a folder + if profiles_path.exists() and profiles_path.is_dir(): + # Iterate over all profiles from risk_profiles folder + for item in profiles_path.iterdir(): + # Check if item is a file + if item.is_file(): + #If True remove file + item.unlink() + else: + # If item is a folder remove it + shutil.rmtree(item) + + except PermissionError: + # Permission related issues + print(f"Permission Denied: {item}") + except OSError as err: + # System related issues + print(f"Error removing {item}: {err}") + +def create_profile(file_name): + """Utility method to create the profile""" + + # Load the profile + new_profile = load_json(file_name, directory=PROFILES_PATH) + + # Assign the profile name to profile_name + profile_name = new_profile["name"] + + # Exception if the profile already exists + if profile_exists(profile_name): + raise ValueError(f"Profile: {profile_name} exists") + + # Send the post request + r = requests.post(f"{API}/profiles", data=json.dumps(new_profile), timeout=5) + + # Exception if status code is not 201 + if r.status_code != 201: + raise ValueError(f"API request failed with code: {r.status_code}") + + # Return the profile + return new_profile + +@pytest.fixture() +def reset_profiles(): + """Delete the profiles before and after each test""" + + # Delete before the test + delete_all_profiles() + + yield + + # Delete after the test + delete_all_profiles() + +@pytest.fixture() +def add_profile(): + """Fixture to create profiles during tests.""" + # Returning the reference to create_profile + return create_profile + +def profile_exists(profile_name): + """Utility method to check if profile exists""" + # Send the get request + r = requests.get(f"{API}/profiles", timeout=5) + # Check if status code is not 200 (OK) + if r.status_code != 200: + raise ValueError(f"Api request failed with code: {r.status_code}") + # Parse the JSON response to get the list of profiles + profiles = r.json() + # Return if name is in the list of profiles + return any(p["name"] == profile_name for p in profiles) + +def test_get_profiles_format(testrun): # pylint: disable=W0613 + """Test profiles format""" + + # Send the get request + r = requests.get(f"{API}/profiles/format", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 - start_test_device("x123", BASELINE_MAC_ADDR) + # Parse the response + response = r.json() - until_true( - lambda: query_system_status().lower() == "compliant", - "system status is `complete`", - 600, - ) + # Check if the response is a list + assert isinstance(response, list) - stop_test_device("x123") + # Check that each item in the response has keys "questions" and "type" + for item in response: + assert "question" in item + assert "type" in item - # Validate response - r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) - pretty_print(response) +def test_get_profiles(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for get profiles (no profile, one profile, two profiles)""" - # Validate results - results = {x["name"]: x for x in response["tests"]["results"]} - print(results) - # there are only 3 baseline tests - assert len(results) == 3 + # Test for no profiles - # Validate structure - with open( - os.path.join( - os.path.dirname(__file__), "mockito/running_system_status.json" - ), encoding="utf-8" - ) as f: - mockito = json.load(f) + # Send the get request to "/profiles" endpoint + r = requests.get(f"{API}/profiles", timeout=5) - # validate structure - assert set(dict_paths(mockito)).issubset(set(dict_paths(response))) + # Check if status code is 200 (OK) + assert r.status_code == 200 - # Validate results structure - assert set(dict_paths(mockito["tests"]["results"][0])).issubset( - set(dict_paths(response["tests"]["results"][0])) - ) + # Parse the response (profiles) + response = r.json() - # Validate a result - assert results["baseline.compliant"]["result"] == "Compliant" + # Check if response is a list + assert isinstance(response, list) + # Check if the list is empty + assert len(response) == 0 -@pytest.mark.skip() -def test_stop_running_test(testing_devices, testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": ALL_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) + # Test for one profile + + # Load the profile using add_profile fixture + add_profile("new_profile.json") + + # Send get request to the "/profiles" endpoint + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) assert r.status_code == 200 - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) + # Parse the response (profiles) + response = r.json() - start_test_device("x12345", ALL_MAC_ADDR) + # Check if response is a list + assert isinstance(response, list) - until_true( - lambda: query_test_count() > 1, - "system status is `complete`", - 1000, - ) + # Check if response contains one profile + assert len(response) == 1 - stop_test_device("x12345") + # Check that each profile has the expected fields + for profile in response: + for field in ["name", "status", "created", "version", "questions", "risk"]: + assert field in profile - # Validate response - r = requests.post(f"{API}/system/stop", timeout=5) - response = json.loads(r.text) - pretty_print(response) - assert response == {"success": "Testrun stopped"} - time.sleep(1) + # Check if "questions" value is a list + assert isinstance(profile["questions"], list) - # Validate response - r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) - pretty_print(response) + # Check that "questions" value has the expected fields + for element in profile["questions"]: + # Check if each element is dict + assert isinstance(element, dict) - assert response["status"] == "Cancelled" + # Check if "question" key is in dict element + assert "question" in element + # Check if "asnswer" key is in dict element + assert "answer" in element -def test_stop_running_not_running(testrun): # pylint: disable=W0613 - # Validate response - r = requests.post(f"{API}/system/stop", - timeout=10) - response = json.loads(r.text) - pretty_print(response) + # Test for two profiles - assert r.status_code == 404 - assert response["error"] == "Testrun is not currently running" + # Load the profile using add_profile fixture + add_profile("new_profile_2.json") -@pytest.mark.skip() -def test_multiple_runs(testing_devices, testrun): # pylint: disable=W0613 - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) + # Send the get request to "/profiles" endpoint + r = requests.get(f"{API}/profiles", timeout=5) + + # Parse the response (profiles) + response = r.json() + + # Check if status code is 200 (OK) assert r.status_code == 200 - print(r.text) - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, - ) + # Check if response is a list + assert isinstance(response, list) - start_test_device("x123", BASELINE_MAC_ADDR) + # Check if response contains two profiles + assert len(response) == 2 - until_true( - lambda: query_system_status().lower() == "compliant", - "system status is `complete`", - 900, +def test_create_profile(testrun, reset_profiles): # pylint: disable=W0613 + """Test for create profile if not exists""" + + # Load the profile + new_profile = load_json("new_profile.json", directory=PROFILES_PATH) + + # Assign the profile name to profile_name + profile_name = new_profile["name"] + + # Check if the profile already exists + if profile_exists(profile_name): + raise ValueError(f"Profile: {profile_name} exists") + + # Send the post request + r = requests.post(f"{API}/profiles", data=json.dumps(new_profile), timeout=5) + + # Check if status code is 201 (Created) + assert r.status_code == 201 + + # Parse the response + response = r.json() + + # Check if "success" key in response + assert "success" in response + + # Verify profile creation + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the response + profiles = r.json() + + # Iterate through all the profiles to find the profile based on the "name" + created_profile = next( + (p for p in profiles if p["name"] == profile_name), None ) - stop_test_device("x123") + # Check if profile was created + assert created_profile is not None - # Validate response - r = requests.get(f"{API}/system/status", timeout=5) - response = json.loads(r.text) - pretty_print(response) +def test_update_profile(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for update profile when exists""" - # Validate results - results = {x["name"]: x for x in response["tests"]["results"]} - print(results) - # there are only 3 baseline tests - assert len(results) == 3 + # Load the new profile using add_profile fixture + new_profile = add_profile("new_profile.json") - payload = {"device": {"mac_addr": BASELINE_MAC_ADDR, "firmware": "asd"}} - r = requests.post(f"{API}/system/start", data=json.dumps(payload), - timeout=10) - # assert r.status_code == 200 - # returns 409 - print(r.text) + # Load the updated profile using load_json utility method + updated_profile = load_json("updated_profile.json", + directory=PROFILES_PATH) - until_true( - lambda: query_system_status().lower() == "waiting for device", - "system status is `waiting for device`", - 30, + # Assign the new_profile name + profile_name = new_profile["name"] + + # Assign the updated_profile name + updated_profile_name = updated_profile["rename"] + + # Exception if the profile does't exists + if not profile_exists(profile_name): + raise ValueError(f"Profile: {profile_name} exists") + + # Send the post request to update the profile + r = requests.post( + f"{API}/profiles", + data=json.dumps(updated_profile), + timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the response + response = r.json() + + # Check if "success" key in response + assert "success" in response + + # Get request to verify profile update + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the response + profiles = r.json() + + # Iterate through the profiles to find the profile based on the updated "name" + updated_profile_check = next( + (p for p in profiles if p["name"] == updated_profile_name), + None ) + # Check if profile was updated + assert updated_profile_check is not None - start_test_device("x123", BASELINE_MAC_ADDR) +def test_update_profile_invalid_json(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for update profile invalid JSON payload (no 'name')""" - until_true( - lambda: query_system_status().lower() == "compliant", - "system status is `complete`", - 900, + # Load the new profile using add_profile fixture + add_profile("new_profile.json") + + # invalid JSON + updated_profile = {} + + # Send the post request to update the profile + r = requests.post( + f"{API}/profiles", + data=json.dumps(updated_profile), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (Bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + +def test_create_profile_invalid_json(testrun, reset_profiles): # pylint: disable=W0613 + """Test for create profile invalid JSON payload """ + + # invalid JSON + new_profile = {} + + # Send the post request to update the profile + r = requests.post( + f"{API}/profiles", + data=json.dumps(new_profile), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (Bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + +def test_delete_profile(testrun, reset_profiles, add_profile): # pylint: disable=W0613 + """Test for delete profile""" + + # Assign the profile from the fixture + profile_to_delete = add_profile("new_profile.json") + + # Assign the profile name + profile_name = profile_to_delete["name"] + + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete), + timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + response = r.json() + + # Check if the response contains "success" key + assert "success" in response + + # Check if the profile has been deleted + r = requests.get(f"{API}/profiles", timeout=5) + + # Check if status code is 200 (OK) + assert r.status_code == 200 + + # Parse the JSON response + profiles = r.json() + + # Iterate through the profiles to find the profile based on the "name" + deleted_profile = next( + (p for p in profiles if p["name"] == profile_name), + None ) + # Check if profile was deleted + assert deleted_profile is None - stop_test_device("x123") +def test_delete_profile_no_profile(testrun, reset_profiles): # pylint: disable=W0613 + """Test delete profile if the profile does not exists""" + # Assign the profile to delete + profile_to_delete = {"name": "New Profile"} -def test_create_invalid_chars(empty_devices_dir, testrun): # pylint: disable=W0613 - # local_delete_devices(ALL_DEVICES) - # We must start test run with no devices in local/devices for this test - # to function as expected - assert len(local_get_devices()) == 0 + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete), + timeout=5) - # Test adding device - device_1 = { - "manufacturer": "/'disallowed characters///", - "model": "First", - "mac_addr": BASELINE_MAC_ADDR, - "test_modules": { - "dns": {"enabled": False}, - "connection": {"enabled": True}, - "ntp": {"enabled": True}, - "baseline": {"enabled": True}, - "nmap": {"enabled": True}, - }, - } + # Check if status code is 404 (Profile does not exist) + assert r.status_code == 404 - r = requests.post(f"{API}/device", data=json.dumps(device_1), - timeout=5) - print(r.text) - print(r.status_code) +def test_delete_profile_invalid_json(testrun, reset_profiles): # pylint: disable=W0613 + """Test for delete profile wrong JSON payload""" + + profile_to_delete = {} + + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + + profile_to_delete_2 = {"status": "Draft"} + # Delete the profile + r = requests.delete( + f"{API}/profiles", + data=json.dumps(profile_to_delete_2), + timeout=5) + + # Parse the response + response = r.json() + + # Check if status code is 400 (bad request) + assert r.status_code == 400 + + # Check if "error" key in response + assert "error" in response + +def test_delete_profile_internal_server_error(testrun, # pylint: disable=W0613 + reset_profiles, # pylint: disable=W0613 + add_profile ): + """Test for delete profile causing internal server error""" + + # Assign the profile from the fixture + profile_to_delete = add_profile("new_profile.json") + + # Assign the profile name to profile_name + profile_name = profile_to_delete["name"] + + # Construct the path to the profile JSON file in local/risk_profiles + risk_profile_path = os.path.join(PROFILES_DIRECTORY, f"{profile_name}.json") + + # Delete the profile JSON file before making the DELETE request + if os.path.exists(risk_profile_path): + os.remove(risk_profile_path) + + # Send the DELETE request to delete the profile + r = requests.delete(f"{API}/profiles", + json={"name": profile_to_delete["name"]}, + timeout=5) + + # Check if status code is 500 (Internal Server Error) + assert r.status_code == 500 + + # Parse the json response + response = r.json() + + # Check if error in response + assert "error" in response diff --git a/testing/pylint/test_pylint b/testing/pylint/test_pylint index 1f71482e5..7e102c7f8 100755 --- a/testing/pylint/test_pylint +++ b/testing/pylint/test_pylint @@ -14,27 +14,35 @@ # See the License for the specific language governing permissions and # limitations under the License. -ERROR_LIMIT=25 - -sudo cmd/install +# Install python venv +python3 -m venv venv +# Activate the venv source venv/bin/activate -sudo pip3 install pylint==3.0.3 +# Install pylint +pip install pylint==3.2.6 + +# Declare the applicable files files=$(find . -path ./venv -prune -o -name '*.py' -print) +# Define the pylint output file OUT=pylint.out -rm -f $OUT && touch $OUT +# Remove it if it already exists +rm -f $OUT +# Run pylint against the target files +# Change the evaluation to total the number of errors +# Output to the specified output file pylint $files -ry --extension-pkg-allow-list=docker --evaluation="error + warning + refactor + convention" 2>/dev/null | tee -a $OUT -new_errors=$(cat $OUT | grep -oP "(?!=^Your code has been rated at)([0-9]+)(?=\.00/10[ \(]?)" ) +# Obtain the total number of errors from the pylint out file +errors=$(cat $OUT | grep -oP "(?!=^Your code has been rated at)([0-9]+)(?=\.00/10[ \(]?)" ) -echo "$new_errors > $ERROR_LIMIT?" -if (( $new_errors > $ERROR_LIMIT)); then - echo new errors $new_errors > error limit $ERROR_LIMIT - echo failing .. +# Check if any errors exist +if (( $errors > 0 )); then + echo "$errors pylint issues have been identified. These must be resolved before merging." exit 1 fi diff --git a/testing/tests/test_tests.py b/testing/tests/test_tests.py index aaae1a09d..21be6b7de 100644 --- a/testing/tests/test_tests.py +++ b/testing/tests/test_tests.py @@ -96,7 +96,7 @@ def test_list_tests(capsys, results, test_matrix): print('============') print('============') print('tests seen:') - print('\n'.join(set([x.name for x in all_tests]))) + print('\n'.join(set(x.name for x in all_tests))) print('\ntesting for pass:') print('\n'.join(ci_pass)) print('\ntesting for fail:') diff --git a/testing/unit/conn/captures/monitor.pcap b/testing/unit/conn/captures/monitor.pcap new file mode 100644 index 0000000000000000000000000000000000000000..0dfb85ff4a04427badd206f3110992dfdfcd51d4 GIT binary patch literal 389089 zcmdSBbzD_T)HlA*;m{otN`sWdp}Si^TDrTtyF;V|lr9BnBoz^95Ckkh5D^6=Bt$_# zP~N>U?iGFReeUOd|M>m34zS~VXU&?mziZ8!IrFTip%@MzfFH-v5dgrzlYFIOTkI@! zAR7D|D%aizJ&r@mf$LGHz%T%L0Pw|I0{|aFA}b8=z{0}92l$_`1D2(g)G-m^#}FV4 zj2G_Xg>ZLsb4GFXc1LmZv;zQ20Mr~q(^{J0SLfD+&lKr-z#q65z08C3oz6XIAV)0Gq{K#GuM3X})&O{UT{;5N?hAm%Q}N^s@L`|`?n%&k9{}J8)FuQR1MBGM zeiC#YhJu0yA$zp{h5WZxebclU07wDv(cUle(P`#E=&-xD#n3KeCj0mnwH6WCzv?mn z3wbggD;h+<1R`TV$kX@G0U59+sQe}~AB_Ygb3{Fa3ZdT?gWZKtLG+Yx0@wosSx`pU zH+dms2=QSTIuHzM3MwZNVXici5MsE`9}&r64}j!f5lK;CFn&<;+$boV6U}rAa<{WM z7A}{j)lHb>R66%qj*b~jTx#Dj32j!Sr4doyxf4Mm&9m}kiJ0Ru=j&JKc{mzQ8t+0| z2{Nwt^a2vZaAFi$WotT}( z056aa0HojzGO*wRNPrW8f{KEMf`Jac6POrS7}yvH3{(s>1Pm312KERX1qB9& zp}^sA1RNEP1_#aom>4Js6zC2N-~iw-7$_4HzzEh$`ypzY>cVNXZHDscdXq;cu?QEb zcZF}^Q!&JK^@0ZX<)Oa7+xV+@f3q9Dr5ljlU@D!kn-m}4;;nE3z|}zSTVH@Zn+91V zgqXC64zz-;fy!@)6-#9hBF2+HB9a5HktM$(lA@ph?r`vJML`Mjm9#D#kYv#h>sPP7 zbIFh-`kKJKXP-&e3>dqb%;*(qSqb9a-@C=B_bRaK%-|JyX=8%;3(qd!Tyyp6pAx)u zIvngrP%+q`lAxXf`|6Lq0rmvc47>N}z$!=}P&wHQ)S0Dns2L~)zcvF4@k24sYkzA7 z@g;14I1V6=2GG!vk!UE$NW{A$6gUhHCqSEdQE}+WhhrSg)fQMQ!iB^|!(@w9irvM9 z!LU&Qq!>3g5)%zY7lB3$*VX362k(O?Sj1@3E*61quH2N!)6fMJV&ZQXXtlJZX?c)* zLbPf~ZX`7sE)Op^4>uq3_%H-d`M9}xgm`%Qh53aIku2OyNXFyO<5012I=Fdr`gn2p z*?4(#@NiljS771i#cAc{f{cXG|6Mc~Du5CR!w10YSnx>D@^Ypsj?R7@*Q>s4!TeZ7 zSrlk-dpi_}ZBFD!A}VNJ($V5msTMwXF*_t;zsvl2866M4s<}&{(k&^c59g;U1hmoK zFVB#*$Xrt6CMG)4rr2iapWo|c=<2~Iq`0Oh*m+srMCrDp;~dj$C$;dRc0@_dcsIOT z;OffL?pruttlzLL0+v-)dIMe!IUy+*jl~}|2~oZ2orv8-k7sai&RAx7g8f)z_RCne zsABaZ@S!uz`tba<)vmiX@U!MI@vABBEFBwicOON{_YAU&*J+-TOiA18D>=(YSML%X z$D5?m==Ssp?h)ZTelpA}_ukvfF?)m(!aiXw@H`k(yM!obl!e|wSZO2*F)|VbA4vo1 zl!^gCjwB5yiMWktm6$+8zW7;GR3o$S?P4l%43Y|Z01rWmAR!tla(Fgd`GziF+0ZP) zC(j*jq=}S=ZWAITkYbT%{-IYQ|E1H2AsAW&4iXEAfre%mfddi<5)ZnF4}%>c5GZie zqHm&{MUWy%&@j%S!BA1rQD8s+f+Izt$7v9JNFF4zh_i@2md)PV+g*f<%gWQ4)04*q z$^G3@yghxqydhiR`gRq(kM#Kg1cMnXHl9cvNM>TFiEt!jqM>M5V9OA&i2#s05s@%gusFi(0Uuyv&ewflM)wx)7nKi5vP0PC#x zi@4H;nhfm^XZ)6mPqR37!D;aXLQZQ(NzR&k*x!;nh3)?UeGL&VXGiaT2qzj7l4Jq= z0HykQr|B|9KKw;T_~(4&J4!9s@Wq$x5mR^XR|!9l_WE8e=r+J=m5>T>I$#o;3agHk z?GsZMU^0+uw_ib5&br)Dv}}HlaYUmF=MMPDQf9?v;sN8k?_`l&0$rLMR~Mqsh%I3m zWO>J>U1Cd>0epe!mitbU%`NnWx!re-otE9xJe==pM#u2oyDpAYnmnlbUNPNq#fnII zIIRiycsR^Vq%o>&5RZ#kBPA|j@>=|^Oiib2;v;i42O;&az8XQ!3lJd`CZS(}l3g9gA|l zr`8dV8y`Kxe_+C;YS)ob*in3W$v~&sKlUL`b=r`}x|6P8QqX0uZZb2^kMS2*3S%?v z9gN!=i^jdlC29|Ep*+%Uxld(uk!bkq`xnU7duJ0z;F<$vb9iDzEbR=cl@H2&%oNP) z@}?tEmkD^YM`-%5IGLDEe>R4gzYb)6DA<9@$?yc=qmeoecxrzQPl~tDE@L5}fJZO( z8}ozi@G|5UAw=vgbbt*HI4&oB2_EIrO~{uV82{=^@T-q~3Fs2b7rr6>aI!D6DJg_1 za1tL~ccEYsZS_hWvsFcZTWvWgHXla4prT;VP1 zZNwJ_Oth)zUS{#4(>~2=CtC29?8V1^)JUPKc8q)-eb8pxzz z;Pk8KfW`is=lILV{m}*f-A6`~KaAm9YsbmR;KVS<8)zW7fQ5k!`oaF7^Ke7DAh~{= zgU*BD7v}+5kd?)6eh4Y@^8@f$&YxZjbRU0yp6kbTE6{~Vesg<5NC6~Y5l<2FZvdd% z`!@j4_5IQJ{`#&Q6JZT73rA((MB}k+a=Mj4x|i>!Jn<)BkM}dxv%6?g07BydEQZ`8CU#NC;r!HqkggMhk9DKp1Tr_ zXQf|J$yh{RPKd;C4CH%OVEtM=&M`eT$EsLmFQsLb?SoA{*xpVaqU-@ zAvm^81O_p>w5J0Y7F%!=BMBjzK_|x2wsQ0K_OiBcw&6aDWPmPW5mRb=TX@eyJ>ySh2M**VyFvC}HMT5)oVB88#HP>6Z{dQ98K)7Qbu#*0?#n67L*y&Y^FtSr23 zXr+C;?cF>byaQ-CXcc*&keizy$j4F*g|Bt&9@++Yw6M&r;a^8bP#|Bb;K{KGwl z{Kw}#>J-B@yhB`{PsaOhXWQ@U)0yvkN5iES|r(Apd&*OB3m5@9Df;eqpZ9^|Tsj z&Vi4@8_M?U))XmHG=$J+O5%j|_u9rQ_Lc0ASsNMtrfvzPnw`j4+hTd!Hl25+J!$(& zGe?h?ksKi$-tPR9@r=6#y#}s#z2e9|@F=j{mi^pHRXNo3p^y=eLuJ>)*wo=@H4{E8 zlvt}F{Jc1Z2}vp1Fxv3ma8(IWMy6y$t#i&`CGHOf;|3Xw z1Bnm4!N)-$DiX{S9Q~QTj$`mY($C-dDbEaeRSBC!o)d~Mx$Sa3?*n046w?0(y@34W zjC4eD{x}EuiT)%%+5H$${S1?lLO-8}$FiTGr{5nxW+(76L{8#R(`XPNH6cN2D&qXx z$G?%&KYSddCg{xohXvrE0Evc%0RIF|ykEz}WGN_P+)mIeafy8eC)Zj24LT6G-=~j# z2O~*hz45GAvzx}k2)|?b6}~JUx(X$^s=Rgarb;iDg)q|a1m`gp6!Iv=fKq}`q*1kK zz4@HqGmNs=RKcd5clUFG=<~|~%+P8DvZ$FnCcSK=uw_=t`t)pN-@`K!L$dbHdZg{+ zc(j~fkGdUAIE&7^km$!{ycOm)3-%gLjdzSD%wjHEpNdc@3oZ2ou(Twi;?b@A5**0z zg=3ynVaTgJ(iHh(Vj^g*8j5VtWt7Pms5i+Yi?G-l+9!ZBR=vaZPP-f=&#ECD-%yso zf0mlWQIUb0%Q)wAO%9%==5@A;-hIA&I&uIJ01}fANK7W*hzS!Y$2+ge7ugziLS0Dv zU)kt?Ni!fnKs19*%=#D2$U0kidC~IF%Kju62RGNhC+I(-r(bG!$rBx`%3ZM zEvL(8r*cOl9?L86WVy%9!ts=4V_KZO?pm)}NLN32rt>O}Yk0zOPj(y=_2OC$f zE~Sr@Iav%`SNKvG>Noa^c#n)&|1aG2M{4>zcilH@eLVV-CU)CMw|Zuhpc}6uehI1m zgL^>kl0`}*DSw=U+(qz%yEwhPc))}Z7n1JhU3l!N?|1!w-1R@EB&KhaM1!P4o++Xz zBK!HA*N^8sIiZZe|BibYsOA&(hC)`z0#vuCb-z5+c9l@Xf7P%1p#dSV9!{g(Yr@Ck z)G?rYXtz2n8J60>J90*!AQHZ}$F)Oj{tR_=D*~hIWx3B)b@Dqdv%6Q)Fxo<;*w?#O z@A?+-qWMwbVuWeGJY!uI$-Lz-u4Y=hUEJeSt}n;&LCpPHb(=#=re7F7w<-H!QJjdP z!}|cUZ4#a9nWlvPMCQz+h24Hf4BCDsZAYsHOE1Ri(z7-hbNC4Y^V0h9Lb5+eQoP#n z)OmfX0^J}e>b@4ILQ(@@?Mtcj^m)Lt>&1t)>?o;|6hhexLzomsN9bAgH^capUtO%& ztv6feT~AVMMWUE&qZ}twi@}lOKa54CkH?}lzmG-ntHH78C(T>Gk466lQRW!2`gg=r zI^hIx29=rrj3|4ISoJ&N=|d1vLjnDF#FP*J*lM|B#LC|hsWL#sP+95U5rL2Bz#SNH zTuzR_VO2|2&fuMyVFbEc{d*tj{&BD)7-inM;o4CWhMZPNC& zHXeNqF8;&GPg@PX_MExjEhi^q!lW~X%4jak9H7LWAk`GXKX;F+nC{V8cVCQHn|Q-> z`kgxAYe8|9P@10>7o6Gyr#GYklJC_HP{tAi%`TamJr+`yZ`N{@Cg&ez!UrY|!eer1O5Ux^yV-^Dl@B z$A~xofJkzTc;$D*9DIzQ6NtYxUkm`GfMv89eD)Eaeh3wIH>!Yeor|wifUN|Y=A-BQ zYntx}ElUHmJj!02JZCaXFbsg9~S}LWpZCe?$bQ`L2I|=Rg^c5y5FbI5=kmJ48XLEN;Dalw(vl z@p<=&oZV{V``1RIzK5ZByBTNg;5kSITBc#o#QR4GQ>H5f^7U0E*}14=7fg40$I{XZ z4=^||+yC#U`C!m|G6ghiN8)YcY~y0%?HRynZR6?%4gtX_l>c!KkN~`ci2->}E^bcl zpEG~lAlm=y**}CeN03`kaV7(tj`~^gg-I+gpPp1Yeq@G$)w) zg3Ejh_D=y*YW&2H-09F<2Q^w3ysZ<|cJhf%2L5W?DoEwyARb5Ndpc9k#*+4DvOrz@ z+f`^<=M9GVvf{eh-=^!p*j|uZn4g=U2h1Jt z2}8MqAJcWzKd1X}h^6(kX_eh{{x(vu0f)W+JW1Ds$>_O3w2(dOtxP{|=$^8Bd9%0e zI!58+#aHKqIg`9~F(mPnKDXb~L}b(WTZ!ce8h>e0svsp37mD^2Pp5G9rqdIW8EZY@ zKxOwKW0HC0%a4{~G0-PfWc>Qs*X>P3FZb`8*GA$k5%#9w%GTNV*w~ zoxm;t*A>SlrcY|eAu#yRq!Mk;pErYdy5#bNy_5glVy-&9*)b*(#;j)_f?&}k>9VKH zbY3TuaVVufQ8*aNsie(%#NuUs5Wa|}De-8}otUw=TYxh8O4w~h?O269 zo&$9ht;E)?E9CcCnNJDU$={hJ<;Az9+XYPfNK0FvnRjCjoEFxjsg7)QDES(TF?gsq zGCV^tck6Q5EKT{-iwTxrp9-$zwv=P4t}7V^MGTh@VbWgLHdOL^G666}7cfs*WEO;u zv$;IV(&lgd+Pf0BB~Iv&oA1j0{zXvkG{zg(D{P)MBdVE4{l{#sY(i4~Qwf zUoy&Jvp-4Xw&bq9!`Uz}9eaJfmg$NL)+B}v1Eoh0fn$B5sPUaD?iR<14#>+Z5P&l) z(gNuS3;{(?BKl@t4XYP&ob5DJ7wUI>BKz6*7j#+`+iwvTx8?-0weQC1YL2}~ zy%~X|9vQGg5C7<2p0Ge@jZfFm_cejzQabgd3T=IjeDq#};p3aLS^W&_%#BOS4;3?6 z3lX&1_+&W)WmOCvBl$z52&&p3R8`AvZZCApqOTq_>`d)91tW6|r|XjeC9L=J_lTb( zleo1JTH=F#wfQv?3S^_N&4V#Z!|-WJe=QeTjQ$AJ3zQ7Q|?JboXq6 z*AN$}%&M1*g=5}_HVkjwoI`<_bjle6oc#pXV1ebgV8U*x<~WwZJrPTh`1qEsgp&h) zSVG^J6lBp`pp#Ddn-D*I%DZZ=dn9T&_tk3sDRG62c^M&{wqL{j|_*!+I^r$l-h|i)++!zZKy<`Yd+(Zr?4k zo3?ruzDOqO*a(+53ltyMmTb#dM+X7=`cQPGSmxP#GC6)8)z5Pt$Z-rnO(3Si{D)O; z*Dph>+`{`XFc9BYl)YL2EHgj-*Hvyd|7n#Q8xj++0#yo?-}+W}srI;UsZaE+qK|L6 zA=I~BFMsy!JlMCXkXk80BG?$?NVHWsUP(JusrjJoQDx;6$^S zhUKKM;+4oS%0H9v;RS9gelaIcm*eNi&MPUJcxJ+-4GDy)g!wb1{Y?jaS(l(-Sw87R zu*}~nz+MinZ9;Di{4Nk$RuL40LWnpZVi(vvsQiZ5n3Z&li1SB8asYi5RN--u{2dW` zVFSSc*%ut(Xv<}#^-=4U7(_@LHH~JFr;lvXqQvTv_Y(!q&wJ7TYe-8B0{~8NH5eQP z0WJVLUjAjzcJ3OU`>9^+h>!%Lwx#|GMpzm?B`US?mf2^2;hy8UZalC-N?>E4@>_#M zvMwJtDEdT$nvPWjHmG3b*9Lu~07y~zAg%|936pns(hLNmAHIx^cf0PZct5qbfzoqO1Bh0et9!*(>^Q3O~gp8S!mJY)e{UTjWVr zD={la>R)C8yv5Bty`1BN1?Q9A^R|s$(2F`+5S$d$YN6{e)4IRynOwub@20|!IZV{C z^3X-XVBtw;V|e1Q9Cq4LfrCo>!`-d|^nln-9E=9X@eI{H%@?b9F1c#7s}e%7Cbo?i zj6G*h^ZV**Tv|?NlYOj>fiAyzqem!?L@ZFH%-85Q$&59#N;I7Zx6qe^LF>8Am?+0PZkdZLMzXeGrp#6oiPc z>}9WE(^A^9sBNYV^t#teu6=h7fgbgq5Z}DbL~CL%8ciMTAqQ#Bs9WE9$BQk)?atx( zihN?DG$Q}QdJg;hifIG%r)wEKSg*FkcJ6W*8-C%VPp`Eii((`UzlwAIJSytJSGP`m zA=*ilIGAB6C8x#wt@}yEt5=?ODAd@+_6j-{n59y=-Zl=W zG^cG3$v)ytY5Oqz=y4}-+m#kmOJ>4#2Ue&k+Hb>Zb46ltOu$a>`u!)1RG&3=9?8y$?$LKVSZcf8 zWK%MzbWXAl-LmVe)2KSr{o9>0^cS@)PTl=T6h2we0?Y(pz-HyCZ_GtVY_BhVwbJq} zWYXf3klHZ0lV;>}gCE=Y3Eh}2rsSO-hn{DX8%;6lLgh@fp28?^urwK6u&!MzTfb$w zEJi#xiqWIP7V@ICg;{bYS&NQ=_yaQTeG`7fgFA+DzNffXqwiS>@ZIBDakwbCs-D~4 zb#9-^`9L|eKw_19wRxq$uA;JzyeVHouUS+2>NY7;YA%}2$8};WEIyG>22&gOh{tNS zqGQw6i$1w5J8Y;QuN%czPB`7GVNr_y~e~MA;iqDDO;=Wpmop4DMOLnSeHh$)iGrgns zl7mQ&E6%++6}GGn_B|eK+SW2@6FHw$n|<%|<%5`yt4CZmv5+_Ftd?%q#R)F3`t1oN z*I#Qy4cjphT@9ZX4PVz_I%^X>x-oU@LnfX@3pX(>9l2TY=Vx+f6~$D{CpwR|6+08AW^Xpn zoEGzQd)Sr}MZJ1o+jup&AqlG~JqwX}b4}ox;p@Kh|ShJHIjdHD&mv<*Wn=r z8vHZcw#mGzd1Z&g3&k-KlWT2jSMobOr~TaJX{6GV2cl)9m1u7#*7B0jA(-!eo!|R} z*7;#VeU4_xWHJG3+F-;v<90&er@hkw;%)clC!Sj{n{lGF(+)OZ&`k5~93>*~_Ad!~ zBk|+uEz#^*@UfI3-HG zHS@#Bef5VpFSkFcCziH;kjkVpoS%%G*OPaDTdo`I=aosu`T>b_M}s!sjE{uo$=7UuhP}-?DvS7fSo(9eZYs!dsEpY;_~=Pf2wO^~~sKy*-G` z9XPwHGw%J;*Sz@X6Z#g>%qwX+muNh;j#t7PfXx3tr_3|ipfF@({nxY;dpVfMYJw73 z&0s3#dm<|fOk@>7&g@?h&mAMK{Q$6$r8E@OQ*?u+{0+>vdE6j@1WCrhiU#I(fg5k}sP}mC zitY4r3NLn_MkAkBtT#|-9@i`No`14Bn=%qR^?8rG{?5x58Hbng1&iDs5oR&#OVRo+ z$>GkP1{=X%FH2HgU~^&*57#OV$XFIXFXCXsc1NkDG|vxC3ERo-UfrJQnu5vfw);jd)*eAfBCXYG?ZPEzPB`m~Uhg zZ;|-z82R7vmP5t*(%~cXDSDQ*MUyw64Zo+kz6)1#9_wy@^4qhXaQ!A`gO&KuXMuK_ zPnIXjl-(Qgx{P#J&+9kyw3woAKCELJd{S=4W1(y;dFENq_{-bpy}YxptKNL_1+87_saF6`?F*~%V&NsD; zm-d$82+79Ni^xJ7Ce_(6E6Uh2Vo+Gpz{QWdzFJZJ!YeF_(10J#2mo zI`lJdir(!Yu(;mcq7%Z7AAWlGG-LEfb(*tf%WvBZD55PCRKj1PY^Yw!#YBkjGL&9@ zA8MD;=o}rdJ8j}k(A<;nE{bEKd7XFALd0nHo*~b~3RA0{s@O)IPzK;hJTs#qbGpUK zj4hs=IJ}%q&|BkcW68}VqzV2i$p=pN7a#aBuOZ{AuMuASLH-PUc`3Viu_ zL!q4JKv7<aa+mM>c%SB6O}8xZb`b- z1E%k1lI@;h75K4wh<1M=O1ywh`y#!}k7)#!_{CzZFZLdZ_A_Vna|aap9eg}|`%eeP zt{016W;Ru?`{W@%y>vuX7xG91Yg6-@JWj4@QVZU$$3Q@^k2U-nJ{&8iqB z-%Pn*E#sr_X7#J7oBCI%+b$TlP_ia_lTe;7<;i18O~^h)`Ek>hvDG^DVw>;-hie~0 zDtqmA&?LbV%=VFSP8wUBPvR2%x)#lOY*+eSFDcEm#sSNUMx+Qkycu2p=h#&@q=Tw9EC zZ~~;wBUh@vAV@7fi#>js>Rn=^g4s?~kz_ryoR&6^X`D!+-x$1~A<^PQ_~gLsY=q zG=(V5TKGV|^&9Ju@=xKKEHPI!nwrhe+1Y!s&pYc6M!I6Nyn5F^5F9I3(8PP@S~uAw zp5NMv`G?^_><^{Mak~O^x|n45ux+p6O^iry9vLfd4149`5cw;jsB}+$(Y(j=@+&hQhr6~%w@$_Kl6K|!7|~RabL;g6 zu61DC6V~lvPK{gWqKPvM+Ps?Tu`=}ttcdq#;&Op@DVMMBYl>J4laOA73 zt$XxtlDjM2iWBbXX_@x|w)VZdK2mYdOxRXutQr?qJn3f%D{nBg77uZ)y0UbwM(5OJ zH4BGQ?gmoKLW2Va8SA1V#^*-Bqi!m?7!Ef1OVi~tuG--;!+axGNGip(=Fxd+c6gWv zc`RcBoaTJ+7b@E7?qfZswY|@t9T4*RRX$}yCp>TGy7+nf5ngxf@uyewpLh6}Fh7W3 zDn=L54_KR^PmjdX=luMsouV->F1k_XbqL>fQ0=^lXgj^Sss0LErCb6klhy~m2`9AK zvKB*3`ioj#q0R~9m3~V**(M>E5?_Z*tO<0drw-m_s`XxWZU=-1z0#r=QDZ!uNJufU z!rniUIE^^a<3MGz!BiD+GA68zp;1k~9MKs3Ak1!yS1N%dVo9a7RetQ|r}T>iIU^;x zG|w{fmI_R08b$9{o;ru$%#&q*=;l0?E>JihTllnWQK61nl7Ay+n`%!FdDL?j)!J^u zNy?2t9S6ZsVtq($U?@&PVN@j(6cgA~?UXN>7{7U=AYDBnjacP{t7*ASksIFX#rGR# zHSaH8d-zcf$>orH+hJ6JERWFojzOMLg!1ND%Bg9ew@%_u&TeizzR6}*YO_cgeFw|K zGKZi@sXXvbcJI@QO*aCc*GBDUH*H6Ksxn%?whbwY-wk1>{E$qLyyIt5F^9E`LVl3L zQYZ>*%z6xGDf^WQ`w`&Qx=7bJ{NoXD7yy6RfQTRxQyU<4hUvP^*_rCzhAy&57WTGt z3x}3!CEQxeZ;W3*zecj+tG+ig!SUcsM;$E6QMgcWA(;*yJ%v+4eoMwxm-$RMj@^X{ zg6E&!Un<`nEDNLEZ3bQG^7p1(jK?ofwBSvw`(7qCtDVBzkVxe;br)QJ- z0^>BC9L5NjI;cFalx|l=ON83oVmh4X%-(yTW=UB0y#KlRMricTpw7GIy9{HL96MKP z?p2=a9i>QB(Ry))e8VcPf_Ji~YHe!OS?W70{3c)5u_OiwlsZ50LfDhy_Z zWfM`3Bfq(M&7!N#n$e*WI24XS!Ej@Bocq&=*Ebfr7nkswSho7~{DPt_Tg_*lv2xiQ z;GB+|D}Jijit?dDaX-_hrqSZOxU%YX%fw^MimR) zYmVhF0t-YjHmAv!u1+`9(2!xN#jSDPj@zGdc$qh-u9wu}-bQ7C&AovYz>fXE_7Pzv z5i@2IoE1M@8T~_%^}%=bIUeEtsc-$Sun~9c$f&#!**K%gNwmPSFMlKH!ld zdR!arpKKfzx|l$xSaz-zJ&z^%-I1%_<=h}o7NWG5^_KkSD6ht;@N393e<91grxbtS z%|6m##@N+W6%;mp-kUNv3jgEtuS|A&(+RFV%7ztF=6R(W4yRo8?a5z8YD`VU5&7;? znkM!aIu;E-*)mQUqF@@cv5IPlXU!FHH;&68k~X^&GdZa%rqkszBm68(nRr1|A^R26 zhqBXoqE4aaIQ!`FQx-Go+*UrE}=5zFjkh?M%m5O59n}B`V3%jhUz$mtd zcmii<1bZ*2`f>!t@+CiY_bYc-f-uY%Gf;((4n(n%UgNs?uRFcDy?Q4IrCpeBIK$*! z`dUbNpZPUhK}4vCbl_``W_|i=ILZ1!H-Un>3DX@`XF(jB0c#;((C_TE!3p-K0b({% zf|LT{A;u4T+l_)z+FctZTCu#Df`|49ukSE^p?b(zxWSVBRZQkW)3u01omR={K_|7P z{?hjQH7@u;TZ^xa;@t3ph=duvS;$+^R9+J?-gU_2aT^|Ow)13#)ksGj4p`&VyR=+P z9%^Hrw68z!%F5ca@S1^nt^1mKw*XyoZE|Sdo54K$b@a9Tt%-d3z<5jIVLr%+t3e|+ z2UE>ZIcdZ&t*k4M5zE>BYQ(ZSKa4n0@#iqJ7)q&wd0iNVsLIhZQ>SxmCa6@jg3XS3 zRhX#+W134eXhf4dghY25WN?S0Qu|9oO}OTZJgSEykWEGcDfj@1Z(+93ct`@;Sd!sx zRU>_K+G`39Yiqhw@uGd|u`vRO%IM-w!^-z$j*F`8fv6R*BTC>StI~zTZ5sDFx{>rl2 z1d-^H=hu4p4AQsP1xz3q%%MZ&q`m>ytPDus^2)#JTh8-`zUx2zrf+~B`w&q3+-ZWBxs9@h^y+$A~9Z2tlbDQDJa>6hnp9e@5gwMqK_~Myb~z zqDE=Re?~+eBQE`pD60b^h8E5IXGHE}#J9gA%6USo!U|J=K`e%PCRhC*dxqy2aq$m` z;3`oN@#61@QO1}+BxrL``E4%GJCpYq@%kST!MVIM#=jzdn-_v+ari@4Bsn`9N%>H$ zvG_XZ-QG~`sJ)AFA9}>dcgM_@=@jnayR4ucd3JRo)#qVJqwQ<8LKmjqKyBPJ!2~uy z*9n!A62a#(`5=kb1W!n`M7S(3RfDFYvobk)0}tql zb8~&mPAQ6sm-4(#MGB;->oX-@e_FQW_ijpRUo(R;MZfZk!GU)iXYqB#W~SEQhVfM^ z_%#Ni{ihwAlEEdg5&wld5k>*H>!(tcgCvA5P)g zR!!42Pf>`b@(f9JNR!1r3P;8pivVftNe_LrUT3btN3BKNDH!kPVxL4Eo@?FPVe%U1 zIdl`TiQg2kHb*lEHmW&O2V)MIj)V)fMqgy&JCjW^D*mI(Yn!lyluMNnw*X0RRZfKvvC*RDIW@lFN?LrW>tHuMv4y0i5r1qb5-)8M}?Oo z-v$nO``uJ568zK=91!Crb>5e?S30Pov_-WAz6dkCVAA1hp|y28(>SGOEnw3&XGurd zeW==QY$7+j?Sjb7(!M9g1NZFG^0j4^$EfN<=^UNIQ(kXI(u<3X$138muWy8udtsd= zk)VzxdNU$6&ZcGtrWE;^&-2l|^>3kk?95q1HXr9T8~` zu?*ytpNlH^9zJi@+hZUCZ2Z7{&=hIP2Tv~fJF5y>!?4h64=Tb@X>dm8f@g^jhXD*puXE`%OD=WxfwQt9H zA<{m15#RQ_1hjRS_~D)*H8w>wD7uXLXXQQ$)X(`#&5+*VW}55R+np#vCuD)vSbtw)P>}~%vgG{NB?fl?(-H%jKW(Jr_Rlhb8-0K>Ri2ayBQPQy zT*nE%j^O|Q#pXW$O{TGzn1D414wc{h<;A7C<7E$&Czd_fgUcSWp=A#iCEr+LG9H@% zj88Hl#-aj|paXt=M#|LnX=roPlO&4wmAdIyW+Nt!CmhHYL1qG=@3#$d%Co_B4~Y;nJ)rqkCs2M?I9Q17q6i z=Q|$GKe!^&v2r%T@6HAJ`m3_fsm@GjRx`Tk`D=WvcRl@t$g@l~O4aoZ>|+GZE1q;J zoN4=afvU5Klds}m=Q_O2MGBB4ZU_~tb%%zI^^w)RV~kw5_N#0)i)5nPDe?$aV zBtM?}6;V-MhnCCD)!D(-hRe&_!rR9S{C=^EGp&-2js~>47Z*zj+>T0w7TPTeT>E*v zTNEd_TNEyqu9uA`hqRrID|lBHyrE{{3Vwi(o7T_4%iR+f3%X`wqd1EA4xA8 z7P^Hr(wuz{d<+>MTnMqkSg*=Q+=fYspUt>q;X?7H3#HX*`f-7g1kvuD&%AAPuPrMR z5T01~BOMMZ3=(_sxn8Cz(3KRgoNgLLNf3@6b$L(jm$OxEGJrGDhG9F8(f*w7%;*j2 z_EJcXp-ou8b2xBZPMQmRD3c#Dmo-($T%eYJ`x*h)6~c9S4bWVkQ-W*dzR$iEg0ruO zV5{M2)wdM{{q=A(8?3Tdu^aUs6l=zAzwGZYRIa*^{G~71d%bGLxWrte+`sSn1))+) zYQOVY5B%-fsg=2hRPIL4^qyK$nAt=~e6Tj?ojv5s%8K<{%MLS88FdKkqE*YLX49fOC^t=R&g^;fLCN>Z z_G1F)A+@N!w7E0Gq_cIFPwz<_O6~h4EKGU@Wu4hx8_CnTy7lU6);I@W)Y4T7iK8n< zzELr|>I&l!o;Ry~RmvTu+p~_GF7E>HKz;5YyElH@&+y zmV58MK69b>(ccXpB1rF-n0TbrAI!A9WQhJ@&f{FQ+DuPWWS|X4qpENmS(TIcuH=P} z86;w=luwuJE$>;}kB!c!=bDTBRIzyD4`LZ!P)4imGB~tY0()iy1OY-zZVUh#E%_k{s1^T^skewq$o4&CM=zvT?F3p1wU5T;(DNt-UMx^*7BIrr) z2meGggAgHh{t+l5nkFS*oGO0v8NWeG(`%5L*vzY}kp8$QAOF>g-)x4DM)la0mO#Du zSD<(^9(1K6&@f%t_$N1(gDo+G5K9lR|H;Q(JN7Xze)Tc(8l3_>tYAEVk>K$i5$&ob z*owkqAJa!GSr}4}K$0_6eG*)s4;{VJcj&NLhpc+*BqYiqx3ebF(&J4+A?@cluJ?}U z40jRVq)c%{N}ZEC1~r}eLtW>OmNvjGR1YouZTluEWAkEgHu5aNvZm=$67rcjhfdkbXqk&+erBAS+s)rW%n)_ zBWnd>?Eb3{37?@>qrh+g7V!IkP&uhbl+&34kRIP1KzjVk$MCR%K4v?d90hgc$xl7r zJOWz|`Iy(;GmDbdkECcfU#Vc;H+uS-`H1ptj_Y3k@W73&k3It{hHkI#i!*Q?S};2H znd36LbbJ{GT2~e4*+}MCMP4rV_wk04xu@T{YToP2<}Z4;M5j9^`eWL`6Ky^@Z<+Lu;3nm=*9Hrb`txVx9-{VR#5DSi2!60&;Z z$flsJh%MIyoHxtOnM1MUQJ-|^Gzwt^k9AO$torvA9;34<(;wU}e2J3~Cd8=QwuMhr z7y+iv-8y;rZc)@TY6#ri@d}M!yi|-@uJ4yhx;Qj9^T|Q|#-Zzrk5BxkX<9@bv+Oa;%+}v8OTm#hP8;h1^5$`oYfyiqVWU<8xUX ziq{Pae&+*9JX#yc^eWbMQ4`a^v5aYU*Y%f!nsv&E?I28sz{xe zN||<~k~PUlxa%N*o|Ze1%H&HzQEW$!5cO1(V`5c8?AfJTdi|FPZebRayhLR6Z==*s zAbGZI=1Y~WWj;G%lMYns-`Xtt;I-N16V`uLzKyA>7t?jzWLkmuE*E)pIPSg9VTd0|kJw^L-hXn^)M?DmxH0W^E3O)EstIb0(%LflI=b;!z9xDo+K}O{ z<4LbyVq#Zf>%i&Xf;%K(zvq0weG1j@R97QsC@c*%1BUW% z*?+z_l@R|xY$5(VlrQIdnPl4|J9IfCZ{)U&(t)^xs5ZL|VL%6$Xjco9!>qgq(yO&K zEnMQRT*QBfu&xMSnKpXO*Gft3>l#on{MrF&cTZ^!bY1fuUVk;|nc|6*bEP@F`Ori)f^wud z>MEOz6PkLNg_Y@y-}%Gq4Z;MA@HDmPyA+~kd%mL`id8Pl0mGLrxJX6ox27d$upjRX z>jySU1{4D-zgY_(&4puYDLP>-L!h_XfV`E^m7ms91@=xC)H7Zg|A?r5j9BuB8}j0ijA;2d z00Eeqg33um82r{J2+=Jd^+AY8Gj)DNCkkKu_9^JRdBJOFNNB8iqN{+Ik@`c zV#$GfM~Tqt*;upF^6=4~_i+Xv1pnX`65-_*;TNP;RMR=m26%(10uG%3ckp3v8-H&u zD9`ZM4OJWP>!bD}w0!)3OdaTRe9tj(fLqLfSqc%_Z%GR%sUQX+Nr1m>EUdv_T;G3N zx>*N6r$2swdkWGI2B@DOR&i24pvHHPHLm%qewG`LHBJc3dwhRif9MB7JX`P=;_r6G zeFn0MvE$vK|4Q?uAebnZ8^JWsl>6LYRso?xh>wf@5s~s3(HpX}6NpXWgdpPV?}+2d zxc?MqWgo{`( zQ|^&to+c-enU8CIFI(Irlt&ssPM1Ec_F<59l4bfc@(N|MdOOG1nbh))sK}r)7K$A+Nt9L>u}(>oa`C+ zt4u*ik88(Y&HF3P;>RakSGWeoS-b|$KlNA-##v=hoK@DzM9q-Om;7#$5JyVuX-y_w zKI|U97~?IZTv@tx`Nuro3YV0qPR+CalPO7-?H}DtUmWgW@t9T5>(+L!UwEARW_)5@ zt8N&tP8stVRYS~q-e4U2VIhYwDb*cv&0hMXktbCZ8bc~?4pGI$7~{C^uDb@&#!Y#> zqxovNrqLkUzc+z0mp=(yy<+o!sC&z>xR$MLw{dq!kl^mF!6mp8T!I7$F2Nmw6C^-z zcXxujy9alIy9DQSlf5KspLc!lpYJ-?nPi0So;|C&ntJXUHL7aX12yrou$EEGeKz)s zLZTkP+tbBchE@i89koxd?#ank90Nmy7%91ZSrNj+DQEXOwH(=b+(fzPO&8h=>~2s3 zx|lO9i=|-NoB|f0QBu*sIR|l_hLEsHDQwFMd`HBrnH@d8N4|@WbZ+$XWkU!#l~kWb zK~Q{Sw`6MDBeR(1(|~wpTV9v*klC`LSx-Tge{-pgU5LeqHJL3{S@VO4@m_WOLK()Z zW5m-)eCr*Z)VryxspB@nLDpP{K}`#mQ24iTP$4E}l;pB)gywS)bA5`W#j??1vybm3 z*k~PN8w?zxB4zdCFkSOe2nE^6A?Cx~tw=;_$1%O}ap`$9 zw#(8Ta*Z>QMtkv4(s%E-&sX2dP4i)fWn?db02*E9n8ZQ}4>e=1Wif;pj=%`hJjpIi zY)dXrOq=FQs+Os@WJ)R>6K;FZf%up2>$p6m+K3!^?AfMjcFj3dpR-NE73#FymhxJ9 zdLPS2sfCumeJH@JoKP*H)ZvZ?w9YP?wTU^8Y{->GaD zgdWCAGP~frBBd{%Au@pUdj}ut7w(>K)i2|44y^oW$pIjU01ayT9@N3N;{4xs@Trd{ zV}b|Pxj;D~5B`x@fwNQIT1(+tmy30jh(1WQ}z_G-Lq z;H^0{Nm8v6nD4cdkx{|Pos#by#r@!;Kl>Igvr}C?%Bvc|#Be-mT})b)SjZ?6AzOqx z$YL1?5Vd_m7C_AYAW+a5cq{)nd-Cxv{}eb;+^PVWBvgr-KQ zo;V$ADBq3R9?1*4P#^Bem7$q*KV1Op^L|2~m)(dyhfxsyA+k;oSF9YF=%y_L; zjNLG9W=C zxrE|QropCt+3Lsh*G=+?_rx;cGus;(IO=Y3p~OUchnqRZJR!+9Uh*V9|j)PdkYeICK8u%Lb|j<_lIiAtE&7J*Ica5 z7vvWal5C+4FpDSO*7T(@A}HemBsGt)P#KD};OF|)K0Ay1k<&kwl?a}E??e2YZu)R^ zHA^PXlF&rf}YfsKw7$`WbCE)UQXM)@1R_8rYN!3Z3409X3vm=w=TYE zf`r3ylUvLyX)d50U*~nET>WuJaIR`Q2Pv5Pv6c#+=g!YJ1Gdw3p|8`IHxSHUkZNMe zcL~0ajCud+wOP}L!6(y`6>juSRX1Yidx00a8l8F4YNkBUB)t_!YYrY!;1^6-Dv0;d z8^xRjZ#z5F8nZl-k|-U7$Ie2cJ~yo=D*vDwwSxXmCu<9b70;dFj4H9EYA&0B)2rGO zkkV(%w^n;+^}zO$O~_}fs9SoYh2&lV5^>(_4J1ilMqc6dlyxMyzuaOdRS-0z6{I)b6EgET^4_Oenab;g3#^TA$u#AZf^non-hd0Ow~> zK99kqBcuGACwTqr2`c=<6RZY$f>0pe+qU?Ztbjh71`sC2sX(EMy!#bT1;tc<#Tb9# z@INs|G62}4H}DkjNIY?SdIat!^N;x9-(rk1z%d@+-64Sg{p~$qPrz&b$mtF69LSz? z0{<;1RGz2*Lr&oS0Fe~n#lX>uUsr-s-P8XS9|gro{|0h!;OB=Ee*@t9E7f>#wWmjd z2=IUA5&*~z6a4ihfD#8lKnkt`fAIp10BC>*RHqi90MMgf2N>8h(7-{t=VkmFYXY_k zf6x8b4luJ{lb+!|`#apldD}R6T7hs!YJd0-=Ah$2vB3IgxOeq#rpe?J>Lko(CwJaB zK+SxUv@swx(|>%=hu*{y!*Nv=pLHtsUV>v#^;<@Q`LCVCdvZB=7?PkJWXdB$5=xWiLUT=uA*y_AV8%S$^$&_4+7dF zuuL9xzxmxuvlxYiJ^iFSLE|EgfOLRJZ0m~f-<7W6zVjXp|Bm(DzBd z3pNi&f~*TL9CUkP9|hgal9#%BABiWTAlh0<-enf&dxWv@rhbQ(fDm$na&YT14{<^f z#VIF2&>^Ua63s2k52_&a-j6pJWr!A;6pDU)McgOg#7LuaV+7IRT0=izZ&70@tUg#R zT@7}esu&`{Z(6AP^3*ERDU<87XtIa>0oi&B36M5*M*SjFd#_UTpv}8w21zJF8kkxv zW0VAQsyB_qXG#cc#PzMym|m5RP*2yT4~(b8gO#e9EA5Vp7a%y++1JV9Q!OS5+Y$f#}$6PIiDKgP_zL=Ko#AnCgzHmzZmoEopUk zt&)FQ%J#6c`s<4x#?D<;<5nW{#gYO6=y+XfzD=&~o(`AX%D0unUmeZyLT*QVX^)u} z9Y5Hz*}ks^Hu|Sgr%Ae9fRn zkejF|@V|8vTU~P{nTbfgf^tGV2KzVXE%{mB-~XZS)iw^^Ly*3gOaIh2s1MrytZ&Tv z1tr+hgi_R-wL@@vh$XP0u0+Pm*v5N>w5y=7fao_>=QrMZyd>?=lQBN#kWDF?p5*-r zuDX3mqH0&{!HLI{U1S#Qa~0c#4V(U>$*F)NZ8+*QpeYcH zpOC_gfPeUI>J4dVD;13{#vCkK#q9E)H6Az7JbrWG6q&UZqettt>USk!TS@WC>gyW6 z9c@sl124b;!$56=?7#Hg8q4#nZ>8UzH{L_ELy1p7eK$D$XT}4t!~;NmKleccYG^)& z+*_CJpcGp8cUHYObC&+7^_?_WNBtE_PsvBSxr^_|E9LGb*p%`vL$0EuBcZV9-wfU= zTAIC`oV2NTR6i+eE4pzlVDEb@n6jLeEIAxbYZ~Vk6>ZvhsDgEIs0i-3F){Dsg%hHx zIfNXDnBrPUaU&2tt#M~KF0)@K?c1g_zum3wwV?RQ6(Hi&wwp6h$osRvCnj2bVg4c3 z^nqMT4ln$tYwB(UW|GE8+7T0a;gy?<;j$G z{&;?=80uDJPi+Ou-`%CMf>1!DKPLbF=`mQhqt6elk8s$$tE5nOjCAJMu6pP;9a3Hs~oT1^5uRO~SCaatFOpoE2%OUFer?U1R zhjl18qi_3o8TK%p(N-Weq^IHY>&TiedVDHr&wM!HMv-ALkE4adCJT^>Gp!`DLLIY( zhF#Mt#o`dN!g;CQ9lz{RGx$!)mJ0)_+{(S+f5PK$4g;vkt^>#n%?kpU(WKiz;P}<^ z1LA|LPl^lzdItbNa0LKR02X|b=X=s^$PLGx(0{-J*yAH0&A&){{#|lx0Lm#v0OH@^ zk@5_Wncv|d@u`hNz!?Y+tw>&?*eUq41a34R>MctK5w<-6*F9dVI-ZH{%Rq*+>a5x0FT8nzWmqxQU7zs|PgQ%172-V}oNWjywOeuEoo}|sB;}tI-=1?$yV&zg} zGhzgodB=95VvNJYi{au$zc(zR;5cVDFT3*CXsH>mK&37eg8+~Mdwn4LCp;jqVqZV^ z{QfaeDBz5CC`ko`#|}Zn|#85VamBjUb^h)S%Xi{aWg-wshPwCSEsi9FO)MsTj`HD&B^50#dB zHAy+g=a+X0dydu%|7oGOBr02OuliVxeXMVzKJZ=VT(wg7Dl8jUTvNpW;&I1{CyKzE z7mPOaw&fuNg4dk0A^gH&pPiLG&2cG`gi1Gl3_C{&enGD%wsD;#ku9029P{lWuRyBQ zC|V0jkhJW3K(!Gec#tGfdsRy=VX}l>vPJfBHARsO?i(+BcYV5Wpgvb?L~VbD$at9_ zIo|h>_bRngyj>4)+(+~rcK&jc--32d244#y7q`Q6ganA{s#0$l`$P6k zF)Ro2aC^oxY>L*y?pUMtA@KZOFdEzNU;QQ>>dWRa9gM@JmqbGY)L=zgU`bz zzQ8*5fgUwjmxG}`qggYh>W+MLyD45=RBu(T?rig)bJVig3l*CR+p$}XR(GFQglgu{r=UqKiHU&Y* zkC0+En@Bv(R+(f?4R5RQ(SPbht%paVFc!M9^QI=GkrbZa$)6$$aBJPj_P^fNQHI}0 zVPDf@pU`E~OqN{7^mu*HIV0hWK6uT?Qe9Kp)4l{}!Xiouk_=WXoglVhuI$e*HrYBHBer+M=LVIk8_iG{3}=5>q4(PSIeR@ z*4IwP9FDih-+plNY@E&d;qIPsCeH6U&^^a)wg0h~(g#(auw>h8smo~=Cn=XCQyPPo z@TTiyUpV(oCv9Q->SFmM|Ix;Q=}1Oij`umyHWg0`Mvk0@2B${e}1* zSq1U>mc9!oOZXUY{;s78gow-Wx=yDf*@61E7w=qZc}<(VKeAbuHW*vb9VW>-v6S6j zpNAizkoBurH5Qj2rKv-|f$X3wJf;#TD~Hebmb0B|cb$1bG22k;v+K{}WhonIdFXwZ zt*Pa^r<<%c7YP?6MfteqTvPuOIyOg=^oY&x0dq1^X@>w^?qbeCD&|9iNDT3OZhzyE zgSF?$!z!T{9;d{h|5u#O1$70ZqiFHkSUzei7UywkT2SS7-h}!$NvAzadg>pN z9%|zd-2_S+B{lR9Fk;)nY7%@3+}QsfL)!zK@onSQ-_oXd(Cd3E9*vJ z?W04FdBBtV6e%ssC~6B@Df@|34ZIH;^~Y7igUUPxK}PF-f}W4B%W=-I(JB$;`Ewsi@R`13B`DJ&% zv4O*W)|ma^!tsLoDF4@H!geih@Ab^fwHdDNC+@KoFW_4FEuig{He^okwdpWQM`?Ot zJ?+`jq5ZG(qo!VpU_5Od-j0*wMMZb4w7=j-A{dDLAeOqp^wXB@dY?9@-X#DHPtPG( zy4~f41zMj7P3gg|-h9eg>#G+y-g0Hunmxo_6HE;;=067!GWz1F8RZV!q0>Wa*Tb2$ zw0W;l0Hbb0x@8q-;YP@C?mtr)wHIh$NHt#F>{2KGBvz~N5($R{KQxC`jpOJ!gUcUQ4)1MQcc)!J*3KQ@a|6`zAZo zrEIPKy8@dgD;_|QN3 z2^5V4h&x9xDajCGUe~=p2EeFqB;VzFJlFjqNoW8KFboK?r|08Y0T8gjJ+ol}(2#&% zVM7RD?FV|spab{_8Kj!EBxryP@HxnyuYvs4FL@4U@FzwBRfFdAyp{!c1!QD4aNA>= zp$vX$;A{!tKhOc%9Gm0!&9Q;|b%WBzD}e?8`{U2{x8BaLHB6ejji9utDnRK14Y(ON zF!eW7p8V22-oUh zb7)zw92x+ODn1)4D=6UnE6@J~v59z|{>Tk<74QK6XMj2oV9EeG@yGlA_*8!n_U>2w zmCHZM1iJKZx%_eUAE|*(0}t>Z0KWJ;;DbM2|Kok&e+y_o0dollz6WG~`-_1B+A!gZ z0Ck=}zd7I=KnM6gQv(BRuYgXx`@ir_pZ7rjAEqS#5nf*rl}=>_P9^@^Hx2qwe;Y1% zg9&{8zn+o|s#Qs1F#p!7ra!l;lfSpBQZ{WI;^ClHwIA4n|8**d+f!=Cap9$8 z#Punm%FJst)*s3F8foVu5L%$`l($2;EKp(L0p-|L zpi-Yo!vHFQEh@;K>)qeNo#5HA{LiiG2atY2CI7X&Lp&VqP#P21s#+(V{HN4Ut%pz0 ziIAXH)tvsY_EJ^>51uF=SMfZ$(PHJNd2x_1rFH`vI#0t<6el6S05Y5G6!ZNOeKRt< zpnHClC+Q0N9bX4xIvfJ9IQeq zOU8hPoMCThFPw^T$@~e0t66uh72XZ?lzDzkM$as|?xAF=>c}p7Z#O>+Ro=ruj|i4k ztrkaor``>?3JyJgVwnhb=o<-d8(W)_6WoaSjG&ny1`1XKO+Me#_H>HQY+Qf~Tm!1^ zJBkh!UpMoYaPY0pQQnm2x6X#5rst8qU-~~)t?rPFS`>X;oS=FndM!%80gLEyMPQQC zXVfXA&3fqGasu8vUa^W_d3`je<3je*0EO5Hyx4l%7g79+ejlW>Fb6c0IH&0_3Dv$tO00!BZQS?R zmVQ>m5zV0@AmiSLtrxLXiRTcn9Ijf4I|4>PHcJsRhTmFnf14iL zKWEJiXxXmKgrr`lVxs$qw`607O6$k9`wrt`pPg7*IbP zpR&eOjZavP*^`f)i3b#IB~Shg;vEq)E$x%4UJi#`WfPHbZ=r?g^L@`2)POm5@soM8 zZ|_F-xizqd1@HsK4zgcEe}67yK#2DM!2UHz_dierIP}*z1-uvlO6@8Z_TMOw@l1h< z-zgxC*~TIH2%WFGffhppY-`Mxd^)y48OfC5214$ZC$F2%k(pg z?W^shA%lCg`*K$ab+sNU>B~c=7HBYAtF!V5xO3y?%HrZWbRs8N8Cm1dr^x3<#Tg6fFa#=fU=l1K3rGNv{TBsdV+Ecm@VqkfFA7LL z0x3`jq(F*)%%2pv1X4f~L;>xI%q{i7*X*0av)NCcmzb<52IbSSMac=s?`7-^63t)c zs;TUx;01yEdBfLik~2;ngv5`c)KxR~#T_Gd=_p(O)QoF_VZe}Cl*Kz>MVpYjgPzp4 zvq59#$yAG$LaSe>>OCyIw2}ix7>4TxolX{Zw?5{#CI#vx8|2kCwlJJR;?--L-U#x| zvc_1^9rde~&Y!SC-Svkn^edq3!Q#_=`|QS(PrkxefZO5*COA{tnz9eE%E4px6s*Fx zs?iY04COPTq&b>)pAK&9_cFCTwLVA%2{@ab4NCD!oG?*#MdhS}P+zT%D1JHW4#vAU z-$j5jjhUQD-6gB8t0 zx~j(8elRHB=ns_&5hlUvb{F}P6t|x3QUThEB)WW$jGSKNu8Z$qe$$Q@UY#d3zvm0T zBg=PKI$Nd+_27hGID`Knf zBK-u_@*}%^2`@hv&%FwtcSO4^wddw*dST0lMR-ZS@v^9&IX3v}xUM98porcoJ&Yzb z#f`p2^qVyGi!EVqChM)QKdbqJQNI6>on-U$(GYRH&bv@gZHBN%Dl1&tOrVXjr`$4+jrZV z0xlFlRn~S5?%#x(`7G4&e+ad%jYCEcC{$GQKN>FR#4?~zFQ0{~@~}vktaWfNpBg<- zfqAgGo48-Kq(-NdQa#98tA152%3BS_%S|}0PhdMdLd;9lTK4o!-ro9KGfOCXRDGB~ zc3I=FFtrE>NZ;NSJdCfImOac{tv5t2;(4W==jziC$OchU<%X5du=@cAs0E4? zWd9}9{8+(fp&I_)T+8U89m;3}g$lg$=bu7-1PZkbBvfmbn!PU>C?$ssSTDeGzeHoN zY+LFBA73E)0#Ab*9vsTw*3rn1h%Y`o#oz$)>0z`kJ()eVy zG+i-{t=S&U**^m#HqgHK=I5y)b~#s#69>HgTk!8T2_3V6KHe*@KA*IbXXYcC$(`AYO7hK zw@C+gpI%Ewejt8@a{S=@A?DiuN(%Zm6iQC2eAf1zjp28=nM5R=Dx~Vtv>Hh>a(y8L z&Pe_Gv1E-p-4I`tH@;Itd_lKN`kfl1Y)vaRHY20M8xaNb4yLZoane^;uxM~|p6)!W z--Z*hN)*)c1dlktvSV9DNihh|S!afCA`9vq>J1x+<$E_rC;$30i7q1a@6ja1wF_d$JgDTg}RlUP923zw$-H`$Iu-+o>v| zuVDc;A;;X&M%{$pM43bNO$D`zdqSZ6H+gi%`863^Wy$aKPR=%x6|tiB+)i`)+re!W z-Y6f=qrZA&PZR9hLZ_taUes11R7tgtM%F6Fz~|GXNvn8~gGl(kA^)6YZ7b6wI45>^ zD7#b}0z!RuU%R=pRkHsP-MsXxAk>aWl+i$i^Vjz`rVtfDCC>pY6PkZr<(BoV-Lc=b zE2G`UAvX!quH*C{jcgJGP`i)M+O=U!}Z+CK(%;(d*!_SQManGs`=Yta9S8M5VJYlE{_# zVpU3lM)!7}MSM4WL_;lW&3y|M?mGiF@;*qr7)%HNSYY=YWdEhz+gPDz?V|p!UAalL zLsRv1W3E>zRs^D>0_|R_+_&-BzmIRM7eQYvWH_RrSe7x79BYa z(|Wb97_@(Q`M9Cu&|dbuVD+;yS;zbvB*(@K_z?#6eI#vK|PRZCiYM($fOcS^Hw!^rBdVFNF_3RdQ!)oVTE zo5B4Q^(!)Yf`Mrj&flDgd0BaM6x74nf3$yLZ7Wg_F+{sXIbzyue3#@hv;*B-l|;Ls zz&7Vu-BQSk3!vSD;e|AlnNt4g8*-9uRYH`2PtI7%aR?I$!;|@8pA;-uOq*i?=N*n# z31wR|9QJC5!OV7w<#pT3_Ydz}3avxN{Ibujc3=?iUzLZX7NwMEMLvpo>3c`tmQc1B zTWoQ_myUW2xA;G)#HBKH72__!$GVW^j1?bD&z`FEklThcn&5=3$8@Z*~EvFd9R#T8f_VQ6Z$?>3e|p_2=AT*}to4?-TK1 zCz?IsVj*X3wacn{vCTPMSu8}z9^%@BUMXkJTWpuHq0!GDs+sG&=|74E)zAsYJdMJfr_Tw}w>Wqup)Y>;Hn zE8W%MO(-EWwe+nwCJY)G_TGsuK411MM4{Syud(=s&(pJ`hK*|Nz&T4(!?JQ6r!B(exHKm?b(~ZH zpQG1D`x*P2FH`j1wqN%Fh==1l0Z2b55`D!k#^RGP`C7Rt0@7=fK&2k{LIj`zNdU5c z(f|S?R`{6)(7)3_F#$+}Y;SBRGHc;KX@CNxfd+^M1JYcnx|k`1-$hYBe8lIwIn3Ri zpVxcvu9~_tJtZ~`dLxkwW+4?Pt!DGQPIhIsX}QxzHcS0NuuV)P5!>=u81MNu0K zwaHPqqKSqVcuaP&i@FK8Y0BYZ$1h)qL*Y_qD&`4|P# zYS+o8F5HGUobvMnrO0^eST+f3IR*pS$H1mF&z&oQk;^8Bba z_Ej$H2A=MqYxB;#Q0Xa1>2Ek-3#qw%0nYc)?ql;tY~U@uX_JA2@{J*TxazB;=xqXI zDqPa7e8!V!0xH&YI`g#5Adgso~ny-D3WsC zXEELst~H^q*=k3>y;2vb;Y4oUQiFpO4WD^GxA$w?s8np@frb^E zOucjk>e-bPCqJFaWiYPCr!DJG;m}<)T;YcyKc}EVJk%xsLMjvmvAz8&uc3v`4nwe3>3LvANbQ&MLB;%E##8V;Xg|N1KfD-`F*7;gc3(G-Z9^e zvfs%m(y6DFF{nlWP?o9gzm+r2@qvaN*O*bUgChLAXKqay|f<9O7ikm5A|Z{C!0(?*G-hc z%W-*8qoQ0-?X=sgm>2W#Ge{7bm$uw(HMLt#g5(#P!kDG&YM#<_{IaDRc|WuEBc6UZppJocX_Y)Wy5x5d zNP+zvIuP~4(h7iPw*JV)9=Dy%38`@nksAfs#)yt?3LBBfjJ>(_zB^Pfd z1xtbpcvW0P@A6;l<63-KG|!f38edzx;C3;>4HThmJn32+5(9X>18ZwbKt605C!5RE zdb1-vE2Yd@)$(~nR&-hj+yJT0F4V8#{iT2W%CTzXb)ybG>6SRhf>nu9XgFzZlQ}v{rS~!qyxX68v~yzkJ`^d#+)}S6Enn^6@5Hs@2NZiHaO{YN7KhJGtz(e^rPbT*nS;N00OHQJVL+t|)Sy*_^e;_Lv3WzN1XJo!>a- z2D%n|Rx~cVtX~YvVIQBl??&oPv%l02?2zl9Z%snohnGdW5^4UTv!z?`m}G$Td4Xx5N{s-7ECwPJ zKm{-!0kVIB1UxHN^x2a!`W+-{AAle^0D{CTe*RBShUf`884Bddn8knBfh0-jI!7QGe)$f!^jL-4F}%v^(Zl8Xy`4Stm#;7yp>1O~>~>Ev|iy977CGd|evU^}+&HPn{qTMc0Cxw*(C{a<~xpb;i} z@Ck;~Qr7A`#9ERqZOltUh1r|lO@6|UuA(?T^qUN0Sx%+sPa=?Kq&c_`;9(D)s)G5j zeqa>O-9P4%sR^qsAQBQXIiNYlo$G5vrm~sHnk9DXQ$JJ%3wg_%pOJ`XE8CSF{_|}E z)F^zkRm{p0`nwt}4e1N_^Kw2U9#TZMnrMmItS*)58B)&-RyxY@#ALQJ>w`|!?7$2+ zoy%EtUYu5<{dBdIoe)5Xi=ay>Ao54PK_psNf}0tMGz%2umk)<8yw+q}B8r~~NBJjU z5DoLXEOb#?h4SYxU0iWP@SD-3drZ@(ciy({FL;q81`9ZZ+}aja!#as3v4W*LNawnK zV)@aeg1IPZzI>JELby*<$m!!Ntkfy1QbUG05paYf?17O1xQy0vTbaZL2!B~I)jDk* z!xVcx^U)yJrMDRRNoek^wAR|kS#|Dj*S!9kB_CBhO?ac@ebL+6yJcjrm8jl+sKK33 z6piq^jr_qL1ARPujSJQ=C&rfhvXj67RFBDd6|SqJ`GgsoVKw|@A2L7;u zy$9n4zQ*?aV+>{l#`Hh_Z~@eW)(4J*dxJ*9odIZY>df?Z)&}qGK0;Yo z8|qsC+THUlK9jEB_*4OltDb@l&w1 zi`DCs>xOvtS$)?if8TLu4P(`&x^OLsYqWv;7^L} zO7|i4peDogz_ua}HyAYeiCa^$gF72}OY1RGWjmNu!U2@*_Q)lj#g%AjI;akn?l-pJ zc=i4LrlPHsN|A?ad8nIbri5N)P^vKM}jt*vE)={Afm{` z-rl)^V47lKK@de((o;>KdgHAv9D9;P9s5zl4?^J3fc*tyVVFeL#Khv!qrt03-Ip#c zd35SzaU_jG7)6}TVk7xfw?jhOYt&_ESP+A7wUPx;!^|xSm{+5KJhGXuybnb3@UI%Q zzHPUZ&__@z`Hk3?1t!)oh)ASm;|t$KZ4{Clpc!7C!>OKb~a}cOpXptIrLY*R(x zl4M5<^iR0S@*sKc;Q8;{v-$|$-SI!2otk^pZ=Sc_Fp0+sv#b_Kn<9GG!&1T_U&0fKEq=tD)4Dleden>F$hL4o8T3o@@}O-!qD{w|2GFE{}~5^zvDnYvW-JK4TOWp zPk-W|3Wx*hXB^nf()WF&mE&NssnpEd{Is))DzNN=Kw*&p`_^NA468IDq$?`flij~P z0ZNg2PicmNJL{*B45XfYZ{TB|JW8XaEK;m~CXs4kaDzE`@Fc;bNCk|ME zIH&^Q;1dVbX8NruGU^rd;cPgUrG&-zMWLK~9EY8NOu>w+Q(R?o`v}wOpTot77d*@A zP<#FC#rHaadb0%i7FoEGjHCBdIsNM?yv)v@>jPzKg(%_7s#|A%Y^a<)H|!{ z6dL%w?$C^2gXZaCHWz!T9Wyu_){g=2)8bLI1LxB<6@M|Lqy8;04_pH>E^Odwco{*5 zC2k34W=(9ZI7(ec8d8GHf!8*;XXwPB(F0m+!6MUb2dN-BUEcvu{T%NQGj!&&?x%)y zC_+z}=CuTv{3x)+Ldjsi*X{SF{wv=+TB4aIK1Y?u&+Xincz-Rd>6X4~@?gyuIIv`L zb5EgAtnfY@>g)NE@G(2x{E#q{ZQ+8*`^^oa`!4mxo{rZWPsQ!ae>oNn^bltO} zl^nVhQqk=XzJ%7OoL*2dwypYLJm777oUGCf>B4IWKTHbR0bw0E{(;K920B%}o6AR# zx!NMoSL&zqhGe))WHM&!1l_#>%du@oUTtz8P(f82dGueG0Tnz8df<0KE3mb3=m8hR z0A7qk{~_oUIG~_eo&_DSo%eE-;uOOrl1_cyCwkx&WJfZZjjx}k|Owp9Yu2T|ta?L`n6 z2!whYNg@xdiX07?=!W z#?s`jmsL;Zpi^~W`srofjBymG)G1RC|2C2${w!#qp8q~+NskIBXrNaE4RfsVr=WSB zpi|&Lf^NSN`I5!Sp7T@LgmQ-oP*T0vi>7wjpthp9PtsMkS8G6d4L2kbE54UZpiFNA zMVF(EgwkhLxY>D~q!8f4b9*5#>3n%lIqsL)`!<`kG%$Uio)EeKGCqCK#h&~nOS>v5 z!-k~_JQ!yA{ZsJS*^hmJtso_%i=#rqXmEzl+PbnyE;-m*1c<{mU^zFRQQjnHwP09X3VCkA75`{^|Y=tsrxOVt9DLKL2x~hMf1@TBc`xFgm*|SeS`L zFk4niI&BP^k`C^q7Pm(~-RrHJ8YSCqY}`wx6o`XN&U%dVcftx{)?=};TxVItUxD+% z>w6YP(_P6ab}mcmZS2%zmvC6vGQvK^Xm!Gz; z5`QoFMr2U!5XgNkg#!*adZ#u0l!9C$piVVv8?i{_-e%5ut~VTaOSk?yA3hb4|C=U( zjEtYLA1M_iH(EwA>Md$r;{p3?Hrbahbbz`JCS~)hymZKv!4j;BGPT@AkGI8pYv|sI zIHjL&qo<)`bgjQ{Y)Hak8+zwi`7RI(OhHqd#(|&UH-$yj&6}&5E>+`_dwFHmL~a`- zs!@$xz<+@ejqOG3p+m;Y&f8b&!~sus?pgL!hv3M$pnGz|GBetGZs^M5AOVuVt^&w@ zO%wjrkpWKjZ3BA^004D?#&r(roPf>(L;)gTl(XAlV99^9e_(dDjYIzkh}oBif806`IzyG4J3eX=-t$tZE356b8dqx>PS%XL;!`5BF@(I(lJ}4Tl|Dk{ z+=&K?I2b@->0O0L;)_p3N*FofLMR%7+%*Pv35l0pG8ft3Pe|K&Hu#7aD4 z*7|qM>K~yUD!KzP%T{psCuT)}m<8Ql5?m`Mfiuq%nx>XPv%ZNP+tps8hp>z0%--KL z6WmUlSi9DaBkr{g1j1CI{jT8JLfHW7#IW9_une7O6DwG zji6=o!E%~eAN%Qz8e59mbmFDv-05=9nuflT$A^*uI~bza zetf$m(Kf}m!-(7Zz&o?;xpL3fCH5NXO-V+6PTPkUaI`r-csR2l8m+Y`OrAxSB&bNW z+PurKB;1;_@BAUcY@`_1v_(_h)fgdD$)hHrzE5|+MBA2Q^m1@a$UMW6_C3z{G&G-3 z;UVv<-Qwg>i9N)aTAap6h4W4E5Qg@GjFx@oLHT9d!9sSa(>j<^!x6n)u#X%VLH;wx zqJ3LFZ_wjDp(R-qBwu_MU4dLgG35MCl*~G^3*|}M3=7lXDyW1V`pp_FiqsKzCGwDlxsb}(8%i?5dh~{qtWOF2E{Tvtl@gs> z#G*38i*es|!&nViyN zVSL^OT(sm~#4M=Bx-dcge>8&}lskPS^uP74ik>Oa_d6vN-P<^fEnXPY#{0S`fnfo!2|&P5sv{S1~I^NmU!TSUQh2Tm;} z?OKC6(KaiHNT;daKLpkC_gOW@gJ;i=v7nB6XRw@L#dc&cgr?Geuh3|K?iiF#s(h&y zYrkta$gF)K;D%Z2ZeIHyzekSGx(E;+^km(xpuDczLI$>9KBxK4NO!IvE9j%N!+0G1 zy>WZy|3lqdM#Z&l-J*p%1b2eFy9P~gLU0Wl+}%C6OCUgScMC3sI|SF@ZUKT5!mHx! zle2gBx%+*uy?cM$YK%rzLG>wX^*+X2wZ3w+-E&8Bxq9l4ZUa8ukB9$)Zz{qS+TGY8Vp~lp{r<$WJ(pJm_><5oDlr zqPq{V^ZwAdG2eM!e0ITQV9YW~NX|8cLM z+*G~IMj5M!+%gW!ZfHNM>seVtDdFyUW+@c2rxq1*FF@L9@LpFU{EN zq$`t(@K3iyL`RhzBQdULC$Hi^FVs2(!tcrJUOn2pi$t;c=B_+w4HxU^6lzTS6&pde z}^TE8zDyjL>InZ5`wuKarwy|sEMED5X-A(Z=>$uB#2xAR-n(4*` zdP(2|WrA>|lFm%^D!VJ~pp{Ap9a9zZ?1D~mb~%UZ3l+fSw_{H_N`bZ;LicvmmtVQ{ z9bye3Z~7w!=n66}qSvetn&z-Ihm68R^wy3tO_Fyhs407OZ@2;jQoJeo#*B#)JWbWf zv9Fv<)N!*O=|p|F$c(V}-{XCep~xK7e>KCwM6Xl}Ft_h`e36xyD@xo*sx&)7H9b-+ z=eLQiUQGM}&rJ2D%IA97afJR`W?|kbZ!1+SZ&XWSyXWWF#7$$I)eT;+Fg>NbPlOj% zdu&%6a&KRLik;l3v_Fj!)*D%)_w!rr8|I@dID8NJhUi1YpnO2yOsl3D9o~`q&E_NR zg&v0aYo(}hp5=i&-?|IYa=Gl@orKV}Y5%V=;k*PAaYig0u`QmP-t$B{LFyfa?d&%> z9Jij|qaA1Tqbmig?wybdRVJoN(mWx3gA~|q%v2CQVK`*MA-!TY)K52@6r#Alvq#sj z&^fa;DLU?kN=)R1;)m1g8QfRtZ!g!T5m8e7$3j| zLI<%2@D=26L+wU#`ay&`qDR5*5pLO7o|0}7;ln>+*dO$8A z`c>>Nm!SFx8adb{eDFfYqfRsLM-p$Zs3CV^x&3COq1w;DBjZNq>Q*5e-%#bG`P@<0 zKK2894YToqy16K)J!;F`-7~(*7+Lw#K|yR;$8u)kx$|P_X6WQSsLw!sNqHYv?uVIa zdh^&=Ty!$SqeeSbqiOf$?E5B7VPcQUS=HT;*ZnUC)EWD8%u6F-3w>(pJA1Lol0>3n-8QP)rU)yUehV~QB%o~? zrfAhe#2^ohU&9r)|R8eB=V+C`h)5ZcV>27RpJRBnPPN*C`>kw!J5eFi zUwyO>Hd3pvJDKb)tU0mtcTY;TYWQHO1{Wk`E|)i0;Z3zAzM1Sx3n{kWL_~move!x7 zg=bSCvoAl&zuw!2`r%?9_3^9|f1$IPj&d)&*KiaIQx$1#@hIbwX%h005zCDobI+dr zJ!)m#mlZ8*yIVmhhorRn8P&}YO?zfhI3s=)UQOXw3D1>7>fE9hq|3<0rXs})+iXv& z$TYK}^S938zi=ls@w_9$)aACQd_JbVBSE6Vc1CfK{y}Af_~lV@IWJj{jgkm+9o#4*Tuj|(=fM051pgJXH-3S`N^Ge46SPh(;I_s!Y|M~?-cq|qglSb zQ9}6=_+%er`Nu?r@+XS-{YLQ~Ah-E6nBoz2zbI}FqPXf4#SaQKmj(vqftCRweQs*k zQlqkHK8y{dsSKG+y=s#%EkupVkaCQ5@`A_ADz)E@igJqTJ%JY2Nb@Gam8G4cT(b6Q z2yHK$2RGU^mtGMtp$hca-aJ+_yf(=uMjBdtIA;@3E`TY$9)Tvw9P`&B4NgrZMN!gk zn>sMelQvFpEB~3T6%$9ae>XH*{1JI7=^nY89PS2ne>qBP-#71q6B;xGFIkWRcnitR2d=8I&xdArJ-8e=io5}RllV%`b$bR8oBAN z)29;GWAZ&{R`7PmltWPEkKjr*1b;((*F`HQ1iZebo>%9R5Iv23n7p=fB&&O63$!i59K3%Ro(Vty^RveluCS zaI*Tf*Ud_uOl-$$2-BiCTFkGet4@2~M}CHo1X)8DLaU~W@{$7TLB+DwS{EL;Zp2#M zto{?v1n%>dXmZJ&%I>fTrIAkVE*?^Z$8&AuyC{4*a$|flH=nLA6DJ14w$TH(xFNck zv4kq4_t@tAQxgG@J2x8cA3*zklbpsv0wUk1U=8N_?7P3&YNOrCtlBY{sFb)CAstjQ z09_!zkJpnh>~)JtE4Z9r2_$<*kd{D*pZUl$O-I~ziZdV@iO2t<7cdYKmV2jKl3$n( z=e0L5k-cKYc(NpKz1=Y!Tw25w1`Ux&jW04R`RA4 zZsuZ)%rMTj;~H{s~p%G8Zd^2wq*K1IXq#Zyk>v)4tfHBUJ}WTo20o;rc~6sCL{6 z;o|)uE$iC-`e4yh%l@2Nl^>1Ek}m`=#qy$twig3=lox3cZ99*?le)#*_NWX`l&2$w z3`2~|z8%K$%O!HheMxXLc1s`ZGJMC=sSES*AM3bPJQ23{uY^@L19DplfeBl>_KUE6 zAj0ZC5w?U7&&&v7nRHEWa+$f2zMskBi(hxo#MgWA4n#cGcbTkp?JFx#R}l@!$I4Jb z%qO2P<{bFZomQQwt=w-TlGT2EH1sNgyYmq=-r5Z-YW68Oepw!!f4aKeg+Rslkgpl= zcl>iFWfd{?rLs)ptqhSX43R{B=Nzr=u_DX$A<6PuUj&2ADxU~q#_VZ9rh;M z_`=HOD0Kv}sqMgorOZSG&_Dy8?03RK<0r|1348Eckzy+$^kWrd5MlQ#Lw^z06hzoQ z(5Ru-;7mne-;TnEBl0852ah9*FaUTWUr_Y?VH&j2fW63rc zif=7PqpPh^zYSYd&7;nlS`u-?Nuy;iY$sh!cKOB@N{cL3FqIdrFMEzUI4+)g!ht}q zpkgs>#Gq%yfMHDOjaV_yj;5cICSRa5Ox{uKQ`(R^kVzMGvG~pg&k^@KbDp6cN-VqX zDECDppud&nNd$E{#% zb2h8$yb;$5vmV_D#Sc*Y4LL@Y?b=v)m}4q8=j-?4&tF7Yu-^cruUq^Ld*?Nw-kgq2 z(F!dh7pjp}pQzK)2`f|)oL0E|6<-Qs|1A0-{UJt$StjFHe(_ZMF*D?c`tE9zHG;%X z+F_P}^zKA=CxHgL@~t1<4S3gab+JF0p&Ns8x-5o}OiMaUmQRXjenwU9oC`5qAxZ%k zuNqZ25F(j;3{5$QRz}|Kx{v2;QUVNeEKO>_)%!mpeLcJD=UR zv*>vCW4Y0iF`{Z z#8I@sA44^K$^|>wxA?-=oi}|=KlP};H6af2jtjq+l3^?-l4>R-PHTxx5EUXO>iH^3 z{Arl2A>`2$cDYowRaV?Cd-8x;#rxRDD;q95&EyidEU#$z+V2uhZZmwKUEGW#{QmN&PN3ty5g^`}tqGstV9&BXF8F{fNhzm5{c z@-}*`VN8nvQZ}KjIZN?KahLg@A@B?t>5eUn!7l9560LB70(fyr4;VqG&%Q6^$9oQt zMBEY+bccLZKUZp48@d5}R=w}ZDstswiKZihOf zBpUCH+H?qRX$#bn3uqYzwmwFcdrJ@q2kInSTNVL|p5zVyX)-&ev~1blH{MF zxtHL1|IdQWjvVA1s{HWaaHjcwIS1QE*i0gDH0QVV(&qIin+a5}fyihaq6PPP?Xe<9 z8O%wb)CQ*lPeKUPez(gX+Iq(l_Amz`CdjjJ;A|KK|XqPquGs#M>v zSvv>LN-#4x1=&38?vbgyg06~pK@dJTfjgV#rQ;Qgi^^;4Dj3&VdTsDFEu$(X@@im& zz#Q||BL~`rRR^ht^u7zR=tdUXmGiO#&2~yE@cSpn%zN8fIaUNy&x164}9aJ7&Ja-N+0$``c8^>F>kC>CcEH zF-)>J>f@$7+Kt<`7a>2H%bzzL5Q+siKHIpz=Mg>Uf6MIZT4G$dG)p$7Im|f(0?dt9J4Nj_Fpnv_b#~af8 z(GNZ59BNm6>bqE@&(OZO9JY}!ozF+hFq*V6*x~iE6Fa5Wkuso0s%kZ|g)&Tbsxma( z@Cj+70R9*4)-fK+TA^)rZ+_nW%wkKdj}pqwa49KmSUQ<4aA?ok!ic(tP~D>hu#m(~ zUL|S4=-G%_$f0cL0;nZnv|j`;ma)Fx&iQ(-F04$cH>9jD$%BWZ_+>GKZO*`}C90C? z&BxDK!k-7>w&(HMmnZ0R@RY93{p9hR{JY~bQ6)5!eC}lp7{5MafAx_Fb0C(Z8$zZ{9xT|$O|Qt_(nlIa@d{6p>-W^U77HV1?w<_L{qucOxEiwJvKCt40) z@M-;m`yW$Cs-DQ){TrF9fZPs+U@}*`{vxwIh|IQ6WM+*MF=_se&IhLx)=?-8nVG7g z2=q|MOe4mkqsSDf+2UR{?;A-eY=2lmRC;XdyQ{VO7)CsRQN)nSD-M)pyC7320YAKTtzxes&1nj z)5O(n2v4Jed@{YIumdz~mruSy5;4!S^2KjIT6Sm;dw8I7-+)KmC4d5e16_;<+uz81 zo22kW=BI0Z{~@zOA^NeZ3W&_&-wuC~*$qTydoY=Sq?|%~6i|%RKmz3VnYQ{dMvY~7 z=!{IXdB_F;$7AvY zO6o*N4qv~mv{%jnj9_#dJ+jU3*Z30(Wk4jhxSfik%+BJ*bsilNRW=aTPI0_0a~qUW zf&ZAL=rFIR=YWHtNJO`OXH2ZtsjsJ2^|AnNFggA*jNWaPQKa2&A9KFsRyf#;C3sP1 zIpoF5p&@@(?T;DOQ^znmWWINhJ`#j_d1FZ#W^QvG{edeR8%DHm;)F95OJi&cQ11IpH*IOxfxz=u)4l1bE&8>@X#;x1 z=w|QBIy7PtKaA?L0XwGAWYnG=5Fj3OD&%gJupRNKhSqWw#hagwa-7#$s3K2bhIy}( zgGx(Dbw$<*OLzqAj+}9yHD_wNA=e3ezbwuA{_Rai`R@^zWdPRIbYmxd=pj>xl@DF;xl> z>WmezbT{DMrI{d8X?zp*TPAoJhW!{Gqe<4EKn#5+;IQ&ClGmd%%~(w4!f8FW;^+GA zSlW{#^fuNSpl6^?&}24BGwq`Kd(z?$d9{ zGBI#VnSuXU{|8h?cMn#g>#qW*ssiM8wgL;BsmU*a>jw#(+mpcgGII9!akjutrS4y? zZ;1s^)!@B7%qdVSQq~S0QYlH}Lg`aZyFQNEA&;^H#;PQJgJcVjl|Q50>SY+QmQD+5 z<%z4fwLpbe}0&B+t_2yF2Le8%UN$-e3e7GlmVAc?$i%Izu#ZUQ_O(ch;iF(?t zWOh}OcA&SP?@2gR5F+6fZq2NJk`u)tmEzKE}jZcG)O ztoJ4oXdJ)?s8m@M5E-2!xuujE=T1mDTabDj6~1qxkZgwyTya!;aH$7o)qhEA*{v4y ztUF$Megu_5E|k1*{t1`pD##qKxga?q8=WoC{TUk_RZO|?bAOVA`8B2dSeg5@&TjhAb+s20Qn`F>;1{~WkcRH8Gy64^hbd@WNlUN#MVP&@57P6 z`YUx$eBXywf`MD#4G8fK>bPC?F;1yOKNy(K2#oaxaUSYg%WZE%b*4t0P;u#K77~R9 zzak*>$%T?Oqt0e0H5(M&z+*4j`s)Q|AW>s5Ows=HXMm=2Hrbm@!8=#;xZTU`mbL?k zs*jvmhLaJT3etW$7knQgw48fc((!_<&+N3w?bzuFUY;;j2kND!=d4^`crN8*)S`L( z{NYymoci&4vzCZqEU&%oEbO}lorl&bXxmGK(IG7-q@jwj+GpK!vA~prIm{~?%>K6p zsbo}ha`vAqQHj_yytifpS18Xp6poFRdkd|C)bTzw;8>2rzlW!b_Qiikr*yu3FSfd- zYEq|mdYyhju9lY9Fixt2!A(%mz4h}#^F&oHPiRk%B`tdyxAM)+5w(oXn-GRK;#cMq zQFvu4CzX2UUKGQv8yJ9T<2Gzc1;XNmPbJKH#6*_una9x(gt#d^hxh>{xqG_t}bsp#D?g1(~`RnAYLf= zjCEpe(t$47w42PMxMA0BU^LwrshDGLla>V{lU?LLR+*`Jl90~dB;*Fj?Pdy=5SEC4 zOGp+WNJ9LcB;>}jtxQ~&Dds}jUcE!DBPG-jVhWdh%q#87F$v_4m`^0^uj+aDOj z3Z~eFJjGtR&Bsr#I+gD6o%VC86^OmDBxFJKvTbA589J0qqkASusx-)q=O8f+o&BY^ zd%U3;R#r4KHG4?QRvtX+khp(k-v2MM?-idU{u8sYCzZocM5JHtPD$~~UPPzsgtw+Ib=h^#mk_iDy85P*0^a<&+R z@ku%R@;-xBFQe)K`*{9prI><+UMJA9CUfjtz~~CMlTzkhUJIzf14+d zpAYlZ7YUEEE#j!)3uEpH3eE5e1$)nRh)yf$6El|x%5;zx zErGYZvHRCMudav4XT^J>MG~`Q5<*Y9qS@aFD?dQ>n?z~gsPdOAiTUulG4v6=<<>05 z4tJwhPpmD?GhlISHGCYn)G`;#j^|ekq$Y`$!@QUzy=l2wg@50=nzR!Bp4g1^8dgKg zQc3)@a|L=hSY^A1m6fQ{fV36G!#+Ka3E$q84E@`~_mIVxw|wLf*4p3par68O{BG|= z2a)?OiLo)J$KJm(WFmSFT)#{g#|PbEv!=OhuCub+nRDHehV4cv&JNiOfBxKIetokw z9z_u~&C|9F0MV78(+r86+Y6=kZpMKOgT-2Aj`lMo&B1xwk1nXU{pVT zT)h_gM+1Ka=sj~+UkbQEB%hoh{Z18*XJ;b#X5R6GV0C z9}~wwWt>5g1@N!3pq38g_T&c3g7?)gSpYAc!k%Oy*2S{YlWz*0ZGmXHY!B`v^T9T zcVdY=^5U0m(b|E6;k6^`sLIS}$*(Ej1n0)NMSr|mYLw=~Lp2Wx{z{oEXyJaePU^8A zfXLqq>#VEDKJFz;HgH8--$gBCVvF^@O26pQuQcHE4V@-Ac+`Vypt~wSQ3cq3k1Qb1 zliq-3;Ryb(k%cEW`mwqQNEU{nQGdxo97qZet*6lg zVaALP+0d4IQv{j{YxilLzSB9R?sLl`d5eg#!g* zX%5|Io%pxz>iq9wY0{q7B%xEFVS7 ze%p`iyIXsDqS;;4c!jooUl5B_M)J9#bgdjxA@2n;m9AFe>drh`qyO_}y;_!HrbH&p zJvBZsA9(KnQ_tpiJ^y!j* zG!^vE{h|iSbH{>G+_)`XtdEC!_t*9F1n^w4JoaqlxFV~&OJYpBQ%syGjPW1$L{-GBV-CsyCS4FNL8)|7^Tdj8LIZfJ|6puE!#~y5VFz5wYBXeWQTn@XZzdXoy$o2QjbYq!hr5~by7IeKO9vlKc>=Xlx!2W89lpP451 zi*Aib(PD;)*E$0x8`~Fp(>n6)L=&^*Z*Va1?n`})$I1tWIs|!6kC5<*7~>_4Pkd*9 zuQ5gl52>@O)3g)Lbw44rVzKvCJz{2b&#$6<2I_r$W1?MbFirI-A9av83H>v42)6%+ zH)+?*pGw}$ctdp~&%PkSIs4Gr`_|*sgLS$-Ze$fg_5S28(KV30)KEm9@KZY9ST-dI z{UEVLej=fv*5$buT-hBdZ;i-ueL8%@U6N7a_lc1bv1M8}BZ%?>L;cX!OwhKd-9gm^ zmRM?0QfNijD923S<$c4A73P~$E#DcoVwH^+$R2;f3)Ns$7J3)ldR5^Vk)9RbLb;Mr ziP&HA{2}&2!*|76&`@@vta5v@2d2G%y>TKMjxikBkdPydKZwU^ap=-Y+LII6cRNH| zMT6UYw5KJ6;j^L6J=f$%#i1npj_NclsSz;xT+xW^v% zfpLHk)={#CDoWzE!po;fjkuq<#j0iPX7p=>ab4hcq1SEm)^yV%MKpr0`!ez6&`DB5 zxQN?Ln`ghvL|<|~0G784vZMef@2BO?nMhC$=RiMQt*O~{sezmEy|&~i1eP%%3)g+S zpQp=O8A+(pxikY`DM%@|@8D3ChKL66jmI^uc305$Y%2!oUBH$`3eE>9((Fg`3sLBz zdN1s4%iFNg(MOAXy!t@8I`^74l2c_RWOQwA6D@ zCJ)I!KbDuh+AaP9cVx`}iG_e(^Ro_w^0RBw-lCapghX*-3P!cA*WoOMu!PD5J}371 z+NQOG7qKV)VfL#iWfA3%i`cCUqCqUNc(LXtR@4`Va4{1vxIAAIQpVWnM?A2@3VCQC zKFoqCK~4$%>oU!sE}r~RLTi-esf3p0e@bY9M2DC7>Q5!KApcxK>)#Rsx@bbm9~Kz( z^gDug65LX+=Ra1)`TQg~?Y~LR7LeQTGgxwl0{<;J*~B2pNqv%>=k1GM!g>eo&??c< zyr;qmXj)XyF6y|Y+cYvZJk0qY``&D4#*GA;=uXhTih$$VashOVON?JWJg#(v?DRJv z)2!xJ*V=ee_^W+k^jgy#au#!JWOF-^i8Zi|o2U`A+(BKVyiV_~B}_K=OC%X~9Lz&Z zcQj?6meGEk7zxiE=-n7zIof9QbEK1dH>7N8J$s4PXb! z4cLB{94N{p<)@X;^j||9zt8B$8lir8@YMMKxp61=5jL9`yz)7OhX_@62@0%uc9%H! zHEn6%(YaMdqA+wW&&=BJBMaq>pZJRsxbs!&gNz+>>N#!BC4t6qx7RuKx7~18D^>zD z-SYIOSZ@4D(1hNiQfwRTDl+wa z_5_`fC00>OGdqu)P&_{B)a$+)sa9wVk#(OhpiMZid|MY9=Vuj@biK-pwjf=1G{xhL zOX@U<^XZr0cLedqAm7pRE-4lhaUhkNH%8+15B!nL z=ptBIN4hPR&I})PT`6dE$oL~9MR$_*6tMzbc>LAkWDR=~GBJY1PuwaGKS8Ql4mtz` zk(BD$2NWmrwlBKgL3owWA;lW3r7N$OU^BdBjz32&qwdgNUyL1Vsx+g}eYJW1uGgh6 zlD1btL8>wlD|}MzLmu6y+`WAmRowL|r^fB*FeB)h!P9ZyoVU{NW66mODs22DpIZ=5 zYbJFwV&^`81nr!|S=dGcXDmvE8mDnYCc*hiG{}M(SL>D?1F&3^{Rn@@SZ-B14}1@y zVuT|mnE;`l=kwgSt#w4@q9~$b$qUk+5rP0%?|0X$aNzr8XE`PRH2%6cyp9W0-$hUR zeyvq@N1YmRyenZRuwxx-GK$XPEd8wVE+6JJ^#=yxcA+u;H_fZ`oNMlMI8xc3NfAEg znhiYRA_hL^R4o9i&0I=Ps@Bwo?-S=;Nr{)H9Vk zYT}lGmSJ|C%<-AdKp*OB#+Inwvynf39Ch`r)90Co2eP_s zFH?7vugEufZ`aJJm%t2)pZv#Uu=*$6X#1;fXoLc}1M|VUk%j+DH=IGbk^7_@lu&#; zn=)QqysC?j?-*&=kQGw971xyM*R#v&XU-}R4f%ZgR6LkgIkrO;%W#m($G^t-zU7^T zF5rsIMR_bu^r2I&cgEMKruaeqnnxR<&TO1cEgi`(sA_A5FzQc7y!X z;89=A!2pm!ngO=obp!HelFE~A-2PQJ0`t+2H5oy=p$w${6{?hibi)~}8@)OsE?On@ ziuXb5CB8f{hX&eZVHx4xuI~+z1tCW5X{8h1oKcdMPRv?4Lf^43j$4il!o1y0H7X?m zE(Bi)8+5bPi&Rdp(}ObJ>tN3GUNv{RW!Q`jh&70y?Wze9)o|jW3e_+gw??$o?W&X& z;*#aiaDU-e7A{}wsvGDQS_R+r@duH z+=`=^${fzj2bOiW9C>v0u)Od~ro!0w%=Z^(WWI+gG6X4Dory^bH-#+O?;AA96=Rz7G99Q`c0juwp0} zBCq3vIj=;Sf68xCBnqQvye>DjcN@Uk_{qLdf11~DeMBdYlC!p$-Op>xIK|>btvAGhe z@JBeMP?KsL1mqcR8b{(XP#MCvnD5AT?QuQeErl=NT+ze?u2ZaiPJvo`?tyL;GAma$ zgDMfAlGNLI|ES01Xd|WHo`neWvJ)?D`w~&PV7m>XOZeMo{YVdp^E|9#%)3}k>f8K` zm6ztvl(H2IOEk~g9heqi_dy#gz^8GVeHHG7Ol`_uyZI_GzB`onIghP%T)rCG&A^0v z81;KwO=!r(LPw#7jWOCD7A`!;nGBB)x5U)$C6)Pcu#2 zDWk^py%=jy>s?lYtmwC|OLxhJueB};b}pf}L(&;fnZeu`hk^aywt*1n;sXN6Ko4j= z8xu!pJtrf(KQ{XT)x&{+)-$)W1EPbU!hoMT{_8oko{7^RZ^P-izB4p7aQW9C;Qx5_ z$NwSdIfDL?$2G314AeUf-uIKTzyei*R*P3(4(RG-#mzirhVYzq>*FCek= z+Y|XEc6A`J8wQJAxnnE3X=lcVf}s-4uU5!ogsl)l?RIj-*Mtn>hMy@&#bAT%-xnn- zKazYa`9M8IvJJGY*zBkEq1a~Qje(bT)U?pK=xY{>5LoAcESwwsJc6XIk^mir?Wdl` z@_volszPUlvu^4UD^#6z=@K%(z<9m7a|)s6D>cUI59b-xq$#)dGj>iky*F{M%7R6& z2oQUYj-IohBouzQ|mo8wk63Rys(C2X!S2;1n z4@yfeW)bKdc64xPVZX%)P}_b?Y3GV)Te(es)o%(ir= zv1%Lpdlr1)l@EdCeKKBusHBLhCQZJT!9^m+7>2qS;%5bR0OW@dylt-eHdkkZi2hW#HC*K!Q%9c0PHmv*Y zHQsKJbP;6vOHk`>M4Ko0tE>n@(od|1L_B}so05C!R#)jx?c2&#h?T& z+8V&sB4(3nx}IQ3wBXA__UyU=Vx~N@J>IvNB77YmOC*E{g-;u%h5Rj0M>>Dx^C9k7 zo%Y=JohCGdV8opat5zKwmFJM;@DGl6suXPxHIa~^y|OOHk!$ZCQI9Rl9K92kH<+wv z6rPRnejh{T8P52w?e0HNgvML1PRqJsDBJiZOVrORyxg8;q8F$EPt=7Z&)oes#GoC1 z5AiEWoU7*>1h~;hx-S+K&MmZO@Vt(@o554NP~Y;!M>MtQ%iSN}(Z3X;!(<1ZbaI@1B!YNVxnqXo_w{`soDY#1L{-!*8oT4W?hOT- zOhjS|gWVsnq~-jt>SXHEZYRNw!gX)EWky(@A03M+Jg6&U>$A48MvpQG?Bh&bRQNic{E>N#EXk)Q;pHkjH zfeq+N%zx=K5G1xw>2_!UXsCm<$v?kl?DMDp%WF_V6SyZYK|RR?;eqXMJ&8N``qUGR zzx2eL_Va4VQ%}G%_4l5D#t3`{09AYgK~4gs;cOh4*x0$)5DZ)$jX}@Zd7*3_OabVB zyfm|Qbh0*Zbb99iYhZ2QVQT|rWNQujB>3Y*q#*bp0ERQ@)f+!>ch6FOJ0yQ90|gM= z0v{6CoJsJcfhS;;6{3Z)f-)Bjmj&tYOqAv+Kdwhe$)A-~- zT7GjNUx3_UWMBuPg#|)_fJc0U%^?Ljkh&)aa{ty95EE&+AAx_{)a`brhadD+z(kBc zd{mkzic??C>SZYF%jWaf7n%sAsOT84qh^rg`#A1|^7m+Bxg7`e@BJO}3+LeI7#3+s zuQVtIn{N=-Mz8c!X-Lns;-TyWIT@c5(O#v)t7Knn?x|t3OL{rDO77kq>eR za)n~`5SG;ia|g&`45VCx1?I>PC0(g*sJ(1&*Dbe-(cIrt^ImbFB45xN7zni_N$^&J9^>rWv;23ZRUmYkg-!hew)q+b%k1 zK9eCEBRwq@c*XoqL{Px*!`+8XpZeC2F#0Gv6G7+O!gIgQ_V_`RIG~<6eyGoBvLo$M zQ@wC#^%&M(U(s;E*LNq!Cyv4+moxP)h||vP%-_`|4w*J6)55$bD+Qc<6CI1RwmdZ@ zsx+b};I^+SVCroUp&Hlgtuo8B(}CT@q6bJ2)vdu_Q@A1mC$2+cku%b~=g+JcTSFX@ z4idLBndUp9ymbW{`C4Z)v^&^E(2CEmHXb0O%&={L`0koyTB<|&n3lhnU}@am zGmO28H=C!#upqJ8AJeYBk*rmz)B4!4o2$Lm)1%xK1G|`CQ;lrIlpziZ{5>bI~6|=MxwRZIbRZU^3%F}cvOmn#6H8Gjj)!&_}v7nVSUqhz~YIr{$ z9%1uXPGyoa-+GrrBx{M26qw9uD7`mssOEWbc&Nb5BDWfUK1X6`d9fSDH6{_K)E}pv zR2a8S!~^;wbLhPZ=d*ph3bltOaUaO5X9o8J0tL$ntmG~lnQJsJti4tCzg5gvSG+zX zjj^~RQ&Cw*T{{X6dgj?JX0kh6q+!uFyZ3^niLdaO{iB}=FP*u1MbRMmbe0)||K$t* zq-NlYasUA6%Wx6EChX=8SQ@~mG(^iNE1~pJ7f2%VvM|h6?h$__)U27KqlNP=hww%-vU z`L=Mth*?kjTK`GP{)LDKG58KtYVwb}gPss^pdcVYk_qtx3QC@$Va6$&2PSKuHg6ho zi#5`$!Ni?fyczmXeJE*-Dv@+aPuOiCUyMK}&KX;J60bqpzymcd)2?OVS($*$lnV;r z1tugW6ea|a5y%S294Q_t8YuxX09gPTKuRDjz!FFSqyd-$5rJ3$J|F@R6TkuZ1V{%6 zfo7Nszyf##bpxc>089Wn0DubeQ_O$(R%VcIMFP1d0H~{o0A!F618@N(0DJ(bJ_6`3 z2=Hc3PIfG8%xnN0(C2YM#s)GJ04snQ8VUvq7788?9v&V69uXc19vU779u^t`1_Bmz zh#;Xr!-s%^goK2Kgn@*G1TcWWprAp?Kafxm044w=1O%v))Bs9Qi=_BaRVK`}I31HO z$iEyPWVBjs&6VVaT`IANu|;IuMyUa?0rj9}|1W%iPb(?lKc;y$J^8WbzxpvPCm?r3 z2H1~@fF}J9Kjs2D{ac^>n1^2<KPco<-@h zY^+;cl@QkZm|@ZM@RbP_NYBa@Eo1NBgZi`0;Zied$5cpH>sdTG6E1PhU{o^dbNTKtuxD-^kRQr2gc``2Xt1A~MjAwediH zZ0Vx)7nyoNe#`~z$Naf9GC5+(t$*nF>s-(bv>3NXt694wLeuj~s(5v?v>Su6GiUl- z#?rFfUDVgeS4K`TiRIUs0|mqNQC5614v)x9eWG@JWGN3y@o3j4SW6uvqy&OFORVa7 zzMnzNiro4r-%IwpIo{9bRWJqoEz0_t1Pyn7vSiqk5jgTF`@0Z*?f53xH&RQY;+Tb% zXNk5C>eu);GRbomFbOdZ2Z|J~THYfJqDI*2uxTyaF=l?2*#?b^}9_ zACw#zGC0vQKj2JrdBby`%P`mNi!UMC5zab`IZSYGefFQO;@b=*i|(_Dn;%WoVy1;W z!{vF+--+#JH3sjaDE^GO&5l#$V{->nYp`U~O#vilpmzodP(EWqU<~8Dbwjk#DKy^n_{!0c)NgQ#l6oItl<^ZMy{6x;Xa@356{{r;e3T^)tps`%>ilX2uh8bK_uR}8h13h z6>oDQlFeN#3E>M^qVFj~zMt6HiQHAB3g4q041I%SI8E*Wv$+uw@t52An?vK&Ck8vT zz=_`+S~p__b+O+w0Nf@>!@z6?xg32En>)aWtsuls(EDKf8)B_K$rIw6|B8qQfXnIq zg=hdq!~uOH>;dAO3ltREiOo_p*8BdSSL9Z1s_VRP~g4X$x{>w&VH#SGR)+ ziujp9$_)tx4HD}osWz5}O$iZxfWu??LA<$2-cz_yls0ahP=^9r%M;FNV+B1KQOv{cEq@$U1EE1M1c^Q$js1OXFE_D@xC}y-5eo z2vYFC$dUhZ?0?rZP{@J%6mrb{HRPZ!CX_xF1BV=IARqQefY1**{sv$~ihoALdqSN3 zFNji4h*kfe5y3aB!{*HV7ev=5ME}1ds)G6$j|6zK-=`fZNS_Qm?QY(`O}p#A&-f16 zzfC))$wKpxwvW8uz61&Fx{*kO)6$Q5E=h5%M&m0Dp?8r%AE==xu^HtSZABxSukv=j z`#I|n%fW5vEGu^rf3ujdj_K&nNh1UVeQ`X9_FY=^y!|{>*YAj55GaB)YiwJ9MYz?n z-b-ojd0TZ`9YzDB-Hg=WBa}LU`!J~)k5vlF=qM-*Y!(a?HH1)%$8#~!K8i_Kq%yD2 z+I!Hxs(Ke(VOF_oMQeT_uuLI>+XN3-0LbsM~6r_?r=yvAjZ=UxgZ?#%u|(dIjb?WoJ2=A!XQ?{XPg?s;L^ zldaz>KO-cM?pX|dC$(e-JJXO^Q|de2qQ4sauIO9z@Z`{FIASg)duMi$JtCGSI!CQn zNQS@oH(I^8Qcp;FK0HkF6IaqbCc4ZV`VL~EFOLSb2z;HmcPig)=ePuMsThDGz>dSt%cwcz%14SxED8f9G@Thl237LOfY#0rvT{+l|mkhM;@pPikjQ_DmHL z#NHh4JP|Y*mJq&IVUI4Bfj6JTEu$eu%MqC1ut=0$V10RX%kFUnsTN=tZ@ADtrXnHc z`La#PV3K>!3a`7N+%kQpMB%V1f7!$o1ugSISb{vDdfL&pQlgiT_?^8~4EyFW(GdbP z#b&fAL;$hOJJ}kSu~>~GRk(dXRNr@^k&3NHAO6dZ`m-%Q;Yj7ofW*{m_}t!|7ftyn zKl{7_l{J)MkP2Rf3|F%SaKsl+ami;zB50eRQCfCGjC{1F*B+%->u#h%lGw5r{kGa> zhxf8U%fym))o+)TZ^`!Py~-Wu+mp0evHORu?`Dgk9!V|!ISbMY_RcCbpLh`HncW~&Ls=z9xoy&N}|If z?iF+C=tgkKVqa(G?^jbSxe@zZV>98)1#6ItC*TZW zVgFE=it|xGLr(nDfS?XU;{pDowrQPGc>AZy$o{o(_$@V-z*H#98){1xdAjX7qRQ;# z_xat~&-~fvg5f-dESTUv<}F_n;?C69?(79+savCj;b)rW?C!tgJy4rAzqdtC{s}QI zXbWgO8L547OLSu?NwscTR(2VONJynh<-dD)D-zdalbGSOh9kgaoZ_v0P)3@_k7{-n z)*D|{-vfCNVM}1aST&o6@3R$jgp&Saq<0=82uB$@;|h`iUKG#k4IO!4WFuXD5qzk@OuP97im{hCH>d((z8yv zj@iUavAk+>>JYkep?Z|L^LD8Y8QVT+Zq1XSX^l>r1dHk|mjO}f2BO8H6iq?$MdiXB zcusJqMXKLg8TNr_NUHTj2g0l5^VA9hOe10;qUf-O$P>_6+!iJMR1d@Y?L}$J>??GYIqKRefhr%F7f(J!`yvuAu!7RH z-o#1d)C|qJoFxCtiak%VsGMFBk>jz{rQS#Gw*QB+w~VTDS-M7XcL)TB;O_1koDd+m zySq!UAi>=N!QI{62^QSl-5u^)ocHYe?dSsboJA7c6D_<-F77=i|C8* zRd1q2p~6Xv-J1GP%{In=WwqJcCKH$?)`A~y8}aJS50JSxSTx%U-7-X63{|#>deb?Z z*0n$hJYk1RS@f)rU`cQ0yOc z6dpxY-h-(22ewgXD)aeycUN*a57$Q;yHnugYr9;R2J^Qe`$MY^l0}@2&1~V#o{yPR z)_4v3Rr}q>V~z_Xm71BvN_&#?(%CTFeHaPtUi1%FFx-7B(~2#aey1 zaLT6r9;!S$x~lpL#YIwva#Q@OQ%aJN_*<4tSMovab)7J`0fP8kM zH$p_3tlM7aqGer^=BBVJ?)$lBv_1=DtIq28# z(k(TM6Z99*?JpD~8ur}ZO$V3SKSXqd>B3Eqd7VP%;ua(Fckj5)TDEh~E@K93S-^U0 z=p;vo%kHcB#m$HWn@)0^`#5*=e8)8UbV0-%{$tL+#A|YUt8)x^&a0w?L zN|d9`=gx-773ZAk=GGB&o}t|yJ!f5hFL^>~iMwa=QRRU{F;eCHA1ndvMB}HZH&j1! zA0m{jHWzRe#=6J3bee`-cD*K9&WLt(H013P>-orBBW)UxSJ1#7njHKpe{|8W`Z5Y^ zM{$P|gh&r(;y5iKa4d|~2dJR-Xnm8m_rB@sUZ?mn@@+*-ye8UAT6u6~eJo_l(^?(2+TG*K1!(5nbqf3C(fI{&$ zR4RQK*U+Z>D344e5H5n+nwC=#F=uOqU^21N*DGfW3sr-O_s!kSIcR-0ceKE? zl05p*!#u{SS>@4&Z=1Dy`q{t){?pYZ5t~7WvQ$RXlgz@&s8u5&c`9wKH#OzO`liZ} zr6hu%o@*L$W4i(uC^SMl$qA2<(LEnhTgy*Zuecx5_#`&`U680mbMILik6k&A3f6Tf zhYSS$Da=(Vb4!Akue2?}R*PW1HX>Ezqf3B%m93syJ#utPtERAvv=g_8{ib7f&dnYX zpeT^x?Efq+Pj#C5OIzdvCxm7({vQexT@RBN1NJB;r$B+`40=$jwneu1pQ`4_$23n} zTg-O)_UpVgYB>u}QO2XiPT#E5L9WD-w@?>~+4r(_MNn<>f)-f_FwZo{COfZXZh36t zvQU3;YH?{}Znw5`5zs5n6?{BCGxe7Ft@wE}!R{d6^_P*33T}xVtF?dWRPw5# zQt{m^?R4|@TBVFApzKjr@|B{;Moq4QbN86DQ`R-8y;R&aUn~cbaBcxsuy^Se z7?I7<(IcLOLiN4myG56{d7|6CIif!|5KzHF@H49Um{i|$pEA8`^2kD++SXC^6PUom z#8vU+!Ng=H2ym(z4qFgf@2O1-x4(>OY$Weug?lAHRxcN+Fw+uEqXzj}4|qrQyL%Qi zk#_fSOpq`IcQkw{ogEKBcJtj!TfXOS_7md8uVp85WR*zyyP)HO!n6|_jS*QS+j~=W zsLsXfaRMSO%bsZ=YNeHNSA~XyW{m6et?g359LwK6NMT+-TKc08OhXzc!ywQT;Xz_+ z*SG1i96*H8NJdOa?d+@;Blm*OkmRrWtSp&f?8DGA{7%yL6}Uq1R(!gwLTQ{X^!%6} zV2zY@tjmhcVXbdp&4IPBL71hH^_!5utv8L#<$#%TEUGr;W9NxgNP~EU!v3drQq&|U zA)%^Q^L!%cap{(m#-AS^`L+rA66FP2Kr+aE^k?_Zr~9oYiTYuCZc!W~dROQ@$2M*a z4pM%nci2fT_!JqRd&9{vsK&5ZpXv{i7xR#U;U4;9hhbJX45 zLERE^*uoO~vJ53}X0T$v-hLBRW1j_Sy&7;ub8x;hhr}|x-OED8?#fwLsEyT4+werbIb}@)E63DxjOGviP6H zt=#pmx*LPRw&eW?hb4}hiRyW2K_xy)dV==Q-0VO)^{dZpG;jE>*Yj&vtZId*n3F4R zx=OWFtt`f^8boEEl#b{XfRe-g?vHzl8hVrl#m}OhB5A0 zX3cvV84Pu(1d)$W%C}qL5rAH|_5~Y`T_eO9{kv}cL>S)F4~wJuIIN?bNY3w<94sI$ zF1J$*A1-2Olg?MZD^8*}G{n`gaGYnJ=ya2_^~yg5G@ci8<~S9MvUo35gdQ{SZkek{`C!PPkBMe4Yq}eo zZuJq3fN4XaTrssVw(A7O79ghoMfQ3td;IWdI$&#bcok~dADx!j*YIEbqMah z6+)LMsy%I8@t3m>f(^fV+uVXH(zQSMwHNm)y2AxHs2U9{bVGIjd&`y^pCJjk&ilzZ zPn3}_%b6ed+a6F_B3YyJ%&TA$g>G0I-xv7*vQ~)}rS!|EeR-rs)vTijkI2P4^!pqr zOS4_zGLo~c_>4tHdPcE@*H1W2o-1@5bXl=mHTHS{>JYrKCfO0C%1EOF^`zeG-BhzR z&3_Ds`nAa82vj3Wno2cq+m8R~^RG|_A7di~h|kDTU+b!Kw%Y5cBb0FI~Ol z>GP0#dU5zdpI8e{xHL91>atZ)BCi#POdrHKZ@YYFZjDQn^N-S z5RYjWWEHq!AEFP0ux!azA5|;+hu(GemrvUx4Q@CK8@|h~=3Kk{k@TQJd!2KjTzzCxO=Sqt%T8+-4#@bQ!NIbBwq~sPpC=1Pnn?hZ_M&2%nMYb?V9>P{2MxAvA0! z&LSwG42)pq-Qv+Oh$^mo-wrG8jt8@D%T9tEmzK4z9mBbKE+BkLmT42Gm%{v2+H&7i zxQ=)?7f$^N#N9oB9wW>eGE?Fn%n?(fiE=*_s#oXUWOy9gNyf?%@ic)}xFE#+gPh)`e`Jai}SfqdT8j$t} zq*W*Wkye!g*#IDV|I+&>;s5|q7m#2F+Mhui=x1FrV9@4Q{I{SDfcD>@ZT8_mh`>ZL zFzpY>W`kl^!1Nb@V`$=JNn&bMS**S6atZ@(+T-?$a;$PhIi7dM<#<58E+;yt(r+%| zL+kS1 zGIi8o5QqoAGfu$3$BFY)hW*^gKo0Nm3)Od~g0muC zrUSKgL25w|s^;MX^Q+vFNIqqzC+}yxzM589*G6T#_=4GL0`k3f3_mjMnqLP&CYWa+ z`;i`CQVAO@66j`=**2vNaX(O#?I57PJpp4DXn*!Mh_x>H+wMWc z-}@U3;F%GgRsF8H>ZS?AYW!&k9^7)$}GZXkqLZBshJr{-~B)*1l!mb z!g`V0s>B8@%%g;5CYFi6sgL0aY|AS0zm6urmJeVwjsJ5rfq?)M>rpjKOejR--g%(y>Vw2vzV#_a=WQ97VF`u||HK9Di2 z#Jd%@gzZ}pa1BEP8=3-!k;)6)zie)V{Fb(j#S^UpOluWEUI+q$ixD@+80`g6e@(Sy zqHbI@vQMCDwXuwGKr$|UDu@zI&%P6=3qG8P6kvKP<)JTMbg;8S#x9{`qy@(j_f848 zI~#|-^?}Vmg3t@Kw}I(w{aIqScl==fPE!*7TDTKoB2|RJQ7$0?^N3%@m#a93bS%A& zCN|-h{y0ZxSXjFNB{pjB4XnStuinizc)9ZOvPm>l+(b(Il*kwD_SeGwhNz6R7v7Ky_NL< zA4La1CV}?nqX4Vg`T%?s8FGJr6qFzS`zT0@|M4im+#H|4ksH( z*+@LI?F1uBU{Cu=-es~Af}$mNc)4tIht9M=FPNuT=xL4#j0R!Z%<3`X6*b~W%5efdGeeh;;)B+UYBO~<x*NmUeVa4*tvOPudePBS&m3!7-G?Q`< zedN~xMeNwc^4Wgxq3e~|2|BPB&52Fb^;`X0{fRQjcTv2q>T2;D;Aya@*hvEAYheDS+A8; zq&5syEL@&wL5n`hG?t$=Y=R2~@+Te41$FqC>`Q?V?Bh>iW#b!tXj(0Cdzn@D{yJ5MiJ=ojS6av)Y;G5H1K6C31)4}JeZK%m=? z34iD|zU*qgLC+y6r^q^MaOEkh7pO76F_~EWuxQMXrFaO|K4{rI&-TfuRiLzxV*Kmj zolS2wI*os?(b0I{!5%>htkH?k`=9X)l{EqoRb?3zjjW9)7X>##qOUy3arQY#lWn1R16=6^(Sh30%99eKk7xQf%Tgo8r-0qV!ZuQlQu1IPf4u;2>}ICyT++ z;#GkxK3oT~80h(bZd-^T1=Q%Y_`HXO>G1uF#iM{4oe_Z7VDl|Iw&Q2aJ)@@KOv@eK zp`K4nHaTsqdX#G5#`)l2+0aQ;1v0tg^o@%vv7^S}Fz~&cE@-TV&Qe-0+`Bu2qGd?f zH(lJ2+|U6I+zYzNtf~rXA1u4$ka@f^MK9DnUe6$k29rZ*e4M$Hqv5E^8aDYfgL$IF ze)Zw$;o}qyrD{Gx?ku8wYS!aSNLo|ZxKU~9Qu+qjOM?F!7r$55ldR2^KITsvaJiEboT(tuNSY_uG2i?L8s9JbKgmqJ?(z9 zOe%kDv~|%|exLVD%2*Hudvs1?6n^NRX(hiFbc^{=7kSxEt~#ZF0*`;kp5-|}Q^p0U zZ)IvBHXAdtxA>F&nV$u`mfId{sz4j6!(f9_5`o;;b9}L&vu9=JLCs-&MT zD-(@KQ!a`)qF;`K`qO>{6|EXT+3_-%_7SL_<#ert z4}SRRPwt4&(EHIBpZ-fsBa{OCenRxgZ+7&?GPQKh%OHb|Wj+-#%xp(UQKWnm zrIxb^s_e4mA`8UxWUAWXg6_^_C-0Fk3AD%RDSKgR{psGC(wJ4&MLSBpwK?6;oTsfP zO_G#sAB7RZ&S47G;#J+zbQhop+!VFja zg;@JvM3sL>q z?=7TAi)BIG(AbVmfSbKae~uUhHNy=L9#J6q$dF>f2h%3~h|D*4f{J;1-yq<|_YON( zW^fZSb0^*Qrd=pJpqj?7Q}M{ETqWw5O0V|QdtHAKrA%xdb>yCG7fw17YYDsdFblcZ zv#}H}?iRRg+_Chsi)g`?QYn+5UlXR}<66ClbCil8y*pi8XES7a7D6zZw1a_G0k%jp zrszg^!epf3x0zysct%PqYl?fQDeCQL*6z zUGv+V+3?4l+0wxt4F;SukEi}KE?%LsNde<(|B z{nKe#8PaUpm3m|0x{ha;Cv7QvHR-RwdT9(nuE*VZ#md5ueSR}0aDj{MUFjZe3=tN{ zu9MrjAYIHL;dhD*X_Hx#3q4;GiQxJ(K(Bb=%p($C94wn&ER*90hx~ABVk)kr2qo?79CA==HWG0-zoaP?ip`Kj%!a zt9Z4yIrHW3b7nLc>anISV9sRY&H5j&Upr=io&)F1=IGMfP>ShaUSTCa5ZP`4ZQW0=_O!u)jyOOD`SeZQIOR|sg^RMs5GvPa^jY#m!q_cj(Z4e$z zO1hCl(fX1ak8#4LKTk^6wre#?mkpF*dyN6LT%iF^LAf8hMZVQ~;RXyn@ekN{k19RI zOqTLa4!D8t_*bJd*Tl^~5O5+w-pkEL%roi8(l(L$#yL!xr()QQ8zEq_-{}x9_+TkX zRfINaWqOr{H02t+hHR6utjs9r7>>h@(L?pq^kr~?KT4VmVu_oqFSdbJsZ3DCWQP`D7n!E!vLFVZ_d{eV6xa?Qo=!eyJL% z!ihGPAI3*n-M2K*w>0hzgHw%J_*%Ro+j>7CG^VD}bn>MpG7~MC{1{--h$Mz=xtU$eqB!#1&(?9}BI%HSmn=CGgoK{LgisnKM5``eVg?R22n#j7*vGWs^dv%dFf>@g)f^4+7 zZbR{Hh36x&(eN>KhO?CflP|-T_MS8fVSrC)HI9@8#{@;USci`Vya3duT=!lUR`dKf z_)sL;zLJ8o^In$aO)dwyX!tfTSmw5_yl@CO{g2sj?N1_Y{tZ}KND$zfXBe;8+Ku*{ zxEGBcEoVSRe2+u;YgYV|5rC>l5+Eb8hX1}6q|U&Vf&eBn=6WpuV#FL^W_&xz1-Ic} zPv35NQ%id}9W+KA_`#PDTDYhnwh)2Wa#ezMkoWAi7*QwJG& zth8|2A=mIt=Xc>@pvbY14g>&e-ESxr`1NJ?#0;rGu$-%V~9(il2 z4IWM%y{3H3*tMP9c&d11{X?NV{}Cdpas)LKGqw?h6Anrnw&1&$2nO5siM8Ey z*`P&uMvHh_P3}H$)>i8;U;;?FONO86q*EWT5o+1^LpZmg(-0~AaW*vAamOd2$o#ia zR>_#poHbk{z7HNP5K- z*Cv6fTcn5{D$@(m2zwd1*_ir4b$3$-jOor0@Jx4z7}s zZ*<`(v_CwFlDM@+oSLVR)@x*>eG86TkUtmvZSx0-R}=ZewTC|nPv)K?abRjoM(BDq zb)`{j9!``PZm^xCZ4a-4nNWvG;mB(8i0IhoPEl;5twY>L9rklb<}a(0-zq{qnX7lf zuj^_Gg^}TYhnV#K)-Wzbk_Q+1If%adlm0L!2sj)y5Rw0FdT)IrVcp+Js0Go%9{&m? z;pTr+JW$!c0VG^~BVmTkj5U%OC6bX+Kw*Q%>Ud1RhxE(Ln}=@)+(VhHX5+gO3!iFz zJV&A`)cePRN3t&Qvz;dfqO|N=C5|+;>kCgTE4}q{UGhAwabPq2d2(R*d>^XZCPM}x zn+zNuh(VsdN@5@4mneQV@%$vF9#^U{;URS=to<==fUjwweh;QxQ&KygZQ*z#fy`&L zbfwCn8%RR4mntcq77{ll;|g$JP`8Dd!L??aJ5sPOJMtOFl-XZ(pdgtjvdyJYz3;G&YVh#zEI{4M_eSzv-jw_PuIIWh{o<&859Sy9XvrOpy0t2Iqw*nLTdont!>8zs2kBPj+%CqnA7f*^k!~`;cR@8F+g5-=LkGH(>vM!7J$XC8%yvK7oiMDo``PE2U=#L3ldM-hnn zgxaQT);NzaTAms>*mX5}{nrClMN+}GTU|S)*J%(>rMxcYIe0HEul(hk?~N^ zTVadgFUBWWg6O`>@~DA>Z$WLJEvy_pilkjJZBg1_t>@I-Rz3$$sCXuDg30cyB-PqK z8xDpT7g5Xzg_3hsE>4jP7V+jNI*aK%-4!yO=jRJ2yUK8Hlb7D+T{uV3TszFXpv7$z z9y(Q1wFy$s1hCc|@Lt_8AY%_S78by-$NyYcAGoXwRuKylkNW!J1Nav3vnnc`&V*rP z@d_B}|HscG#y-YANPI^S2uJ|%>$ksx>VvX_vH^@0lnL-*0W|{U0({5%_Av%E1ZDjH z{BQ#P*BIQ)mK{RR-WHH2L+}9wpt^#f!m2XS+1uy?P7V2=LrOYyLGb?Pk0^gO66o5; zcmH~@Y}=b4)&3z!1s&{3RzN}e`BzksLkLR?^O%jhy@)34)@r%}=6g7;BvHj6huVq(l`JHkJ~-!SoI_G17fEaJKT0 zWYe6H&Bu_ity7%w4iQs3D*6)5_M*vxVI_#CzlL1FEcG2=!e%n>vLx-j#bwBNVuZ*XmQka|ZB1WLGpv%77 z>U`|8O6m536=h#npgT?8bF!6yvD$L6>o?vEBT4>ghE{cUC2PLRJV!?I@w>0uwL4Uy znPA-Zfq_?bl5TCQ$7O(;O@40a@>8EG1mez^@YtL-5!!LfhbFmwL&ox7VOrVpx?+e{ zlKY3+c$iN(@mV}cvGsoQm@@@DMI`N7zA=$x++wHdmL6M zUs&KQy2_+cye2*~e$|o%Kl$0@Fu0`1<*=W3himu@NmIa?uVHI$OUNhO?^5Doa~Hb7 z$0LxQ88!BhJV%GtFKo1xzcn<<3sRMptG0n#$J&{snY!TtX|r;5Z=t;?l^uYodwaPZ zHZ}Vs#A{oy>mNKQ2a_)KGh#qOifb!~MXc zZ)7AhkMm>KfTR3%Quq!ox4!tw#nTDau?K>1dYrl1fNxewuSLEeZfzSB zkk`LUEgM%NgY$WbtI`ScyN4Ry`BR=>Fq_3%u3?_knJx^uaV=v@e?bxad2%u<9qrD9 zJEf@K21(5Q9)f2L9`Ug1m^5bV63%IZL#`jx2B-u_6<(IqD3@H%0%9) zhT%%QlNzG3=%iU}t8`zEMy1Ma|e(GTLvJ*)OpUSPe>}Y+&XRtc%gA zrXIivd9T2+J;!?$6Uu6LMcK44pp|c&&QBWJV4!#WyXQOe#=8p-9uROUS+BO`Ato?+GKJ9z?#10$l(xYUT!M6;YoOO#)?gq90F?*Yp8^B66tDFb z@u2-(U{V7C0&@lsm;*b9zXawUATVCQh$mZf5z7q<({LMB%3qbf{1av(2Yi%ZC;D~< zD!DV5m^!$Qzyf2$>#T;kch1NTQR_Ej>A5_^sIJ1x*Dbqyt!_LU{(cw4Id{I6N`0-k zPJNKmvu;tfGZ8RCa&;uIJ>k}!4hy(25ZXgG>(Jmd-Ey)(q8a*G=C7_clq}PfeYyD! zI- zAMvg~X8PFb+t)WQbU($APCR{VDM*z%HcM)8HH{J5<4k^zzz36Sv@ElO0DlV=l+i2-&N2H!VGQzO|Aa=e~gzsYRR4(3)|V=wOc~`=c#~O&xX5a@f>3Fd-5ISy2rp>3-X7=nu1}Gb|V!S_H5$Q)=DN8`%(iS<|WNC}M)%7TA zK956T;pu8Vcda}eTDY9_;^tUckryvtC1NkmbTULk#aCXKjCX}x&PTJoy+VS$yW)>% zG<_lFwzT;6bkH*sf?!vmoKm#2m$p()!+B4+Olw0=sh$h`rd=)Lzvi| zP-uPZ^_Z8U*H_E|cir^Z=e3awzbyz@)i(YH;K=8w`PY-9JKp$S{RiLAI@r_cfqdtR z`-|_u70&$|-|rag*-Yl=VU|A7Xlf~@N|S&ciJ58%GB^Dca|dtweih{?99(Rc-QzOC zq$nM&NbSb5wmr?FT4qSZ3NbE3#4x%TfqZfGPG}$oWGp`FoPxyJhSBIDrE|jl`PY_& zAb1|ANCc7z<$})!Ifs-pk2;pAHWa9H9ELvK1G(LAAvt%fyV^r*6i%SN`HE_ zWO2F~eORze{k(cwl&}#R!vpSFAb-)&l`4%eLUU1 zxtoLeB$KXuH}b<_D06)Zm}`Y3(%&psw=f9~9=MN8N1o?W4;kDdFA~3(2Bjb%f#Hv( zd%Ry09ztSw_p97)%XVQi*XvcatLNOVgm2&u;3kHS=1A5njS#TtJj%*jg!W63s?chVwURZL#g)yTD_amRMz@ z5>*?!W@Y$p-ezM#LoLKyP%I!j?X%M1wHA1#ql(BC-$yRJR$AK-!W3Tb<6x#uln6qO z$1_Drv540D!sL3bjMx&%*gL6a)wq2T)!7au&!fE7z-iXkn&pao9VK??RG>DnAU+<` z&w2Mr=whqY zxK3(H(|GJ<&9Ki#*N4co`-`Q{4L>4$9y0vLsmr~E9*v8u-DNK_Y$k?5H%(;8q*#~t zz|5kYLG#~r1gIYJ)PL16GO)*MKsIDjsZlQLJVvasoc)o!5LN=WDW{@&xL1>=GUjJQ zx)pQV(!+Enzlmo{RrP*q=lZfJcOk(kGgM}Vig0iR8aTQI!2b1!)y_9gSN)ySI&mHB z8BIV=170xyPmK^%F6HZMC+Hie)1}+Ajulu}sK~mm5Dr$R${PG9#P**FQ7aLO*QT8+ zvQaZJCCSMwk^7Q4D6)28nG3ne@fCE2`LL&YnYwY)efu`Uf;iSf9$}MEq;_xGKKP7Zcath4>2`$S9=(R00BuD~)&p`VJr-S2l-Z*XecTQ(Cp&sjU z0yxcxhV~by0mo(KQUd32c}4Db=U7gL14+Ikc(uEHl`|*q0)}SfS`MD(VcIKAv`j>U z-=Wd0;-)NIdxPwz%lQ?h9pYY7T&_s4R6fFHkbA+JvJj6L&F1;y3u-6(QDp_h{2u91 zvMH6COWDCueoeBp<$G#=uA7zxkBhakO5*I7@-bbaf{b^JOr}K`qnRh}^K~cJEW6q% z$IJ{mEQp{saI?#x6c!;Y+2&2%JqP*Hl2NK!Pd*r^u)-5_=(=Y#tjB<#eBlh)j${W_ zI#ygrWkgYx;YiqY+hNZVIKsT8LNwZ+L-U0qX>9{c##V@l4jd;Y^BmhT&HhUjrt~sN zxcGh6=j4f;+w!Hf!F0zj;u=$lEZ)C5L7{S4Kiaxwsx2&p<#gtB2G(nLtnX2+m=j%0 zt(syNI~vr5v!2OKp812SbQ!>44L3Gez50tem6~ZG6UY;0a}hZYpOQVF{kF}g594oU zKk~D<&fMZR-^-4c)nxoSPVa)_%!|nPRGKe_He1(t?T~EY85n7Szc@RR!<7wIbb%N^0 zd?#4hQYl-`{WsDSln>u)OzG@c^HKrjdq=4LmPIg9{U#`X< zO7oW%@?mBcj;2YMZ`Q4?&Gz zh61eDL@?bH(Byv1%Y7d1kkj;)$JKnRn6 z1vmWSL##H31uciPuU$Bx*-g!rc`g)?DVAv4m~b1S6`7fcvh+ZozRwY&tgpE|RcRhr zK4j*biF40Cqfn!%8Q3b;XH@Km?v zQU_ETi4u*_K6T5D&9|2NuUfUtSji|!r>IEs8E^NU1sr7sh?!EJk4@upva<^GzRXia zIOj92nR*)K;nY@%5{FUoI`0k?NM&qd7&c>SHwb@*#7Ele;GjXK2ws#o=vUwMC8llhgL2l4yMGq_5RO4*dvrN=EvYvAsgSxs4P-!5-VfcyfE|9hIfsb*P@sHsO?Lnq%hgsfRo0_K#{>Hh6!oN+5A8-CQ(}W zsdDnG%jq=C<`<_M_qAu28xWR}=Bn0?5riZN4n@u0i~!9!2~gu{E(FPBVwq zf^B;$G_%S*8wCP*!RBX4^}ieujiqjL3lh&W(u2#4kd5B^x``FJ`G8;7r<3dtaBJC+ zA8{XM4qNI+nfV(JdJY3K!+0IJ??gbf$kSzs z51kyHS>`2T`F!25xLT8EH8(h+FNVwd*yYEoKwYe3NN5YM_JzT--c()^l-pd$I%n0k z%K&=DItRAX^SK&B8V+1nn`XA!@(erC-Ms3h_x%2uGWt%JD*6ws$hpJhD^_O5&)u}5 zoGcOL1WtjFJ**i(uY1d(LEr&81hhZ32<$mt?@f!2|E@)OpHYwXe1KYnYVwyB;R3V> zxUmY>6N$6wLYF zOJ*OUp@A?8j??wA{8n5car$ZEZ<(+^ix&)#8+$ zOzT}2;>L|pfH#VL%qIP5$5zKbmxTH|HQWHyHyZ)LofX>#KU{q2rox9%!bMC}+#AR! z?&e`Zj)5dI4vD#yZ%Ivwd7)67E=F-wB{^+W7N$%ynfB>zs}D66`^O$XCQ4AR&bsud zX3{0;wI0@6-aBG%8uGe{d@uaA)rDK`?!r`ekGN7XeIajI9H^Je{4TiQ;;A zdlu>F%2COVcL=8h41Ppp+&N7j(x;>Jj^-%t{E;GJ+6Ln?B)YR3!bf?oItQ7j2Yx-> zRMpel;DUtonH`ZXazLR<)70Fi4JLq+?unOsLG_q@y+#RTDQj0$w}FRSiR_$Ivl+X3 z)(A(D0L7B7(!@w@fA!a>n=IeicjZSm2`ZKF?+ZN!H0K4F%ts?B0%gTCBb-{RU9x$zFQ%S^;+_|-i@jWB0{f^O z7RV{(B61HHDnhXJ7mv>j-F3IaG@y#AXY zyj{};^!z^+z=gP|$NGS}XE;Vu+`j~Y^cCPaP!KlH!l4You?dt)9s&{QaB42l@;xJI z7BpXwOTYH`r4Jl%Ob=A_YBw8~LM0;}>0n13C}(78@0wL&ia`;a51SFwAmV5s!OlC5+31hN|2Je&3v&`Yq+e{;`al zEG2?Z4NM@7p6u+oZN`Tw388i)E`rm&YoJoIBI|pfaK*#MLZq|usN0=PuKB(aU4Av@ zVDJ`xY^EwzTty>BC|{(2ag@KbFjRHPCvQ+58%=a>_EWG)hCg2%l zYhdL=)4KzREs6$I=X09p9K(UAwD4Z4PVLXK1qCR_jNJrnkv3@5myB0z@jDWPkK1Lh zm2jg+ng~IQd9~5M6L5@H=Oeqv!S^k@AKHJ|#B|id5ovX#Fag$l)#-y^``A{b+^Y8U z7N&9}CizO6TqCbp1Ymixsd$gBh(paoUMCQ0!|zS>P-m%J-v{?Qs3Er=cr90Vz>yT&EUsIG1rSCyz zO;9h44HkB98ouM>*?e3q%G8yRY|5H$vwGRr9%Zg~O#$-*HxOsm6oIT=gZBe6M@tIs z-xf2yZ+cPocfHUj?O-nff71(+zx2WnpclY8K#-qY!mt)TB_(MDE;-?)=1Pb|MN3vP zKQoKA;4}K`$EFXLrIZl2*X$t_YLC!c;4gjlInVS_CA)i+Ug7V{zP23K;?LHMB{6>% z|9xrkdO_jymtN2T^uiCQ7yU8~Xghpecd8DTPCm2e*q{TSTH~XZ9U$y> z*4i7t;>Zlw7Jf*}@Bmq*H}LMMMI5(yH#(+35Y)?o^`%oywdeanUJwlpW*t|UGnZm3vgtr^OGUB z^7GHm$K~!4UhT@1oy^pEBGD6T3&j$CatWjN(?p*RquY^vi9c#}^{0oC#FfeDAYFt5 zHaRlm>p+$JehOvO;csav;{V2g2#y_!W0;9L7qpsJqKy3V5+DEwMXOuIvh)8S!Em32o|5RVOA5W{b{=QjcLq1a;*geir zlOM~euR^%(#(ICEt6V^jOc9nh=wQc3FHETK8E2o9L*bfmU6rj*a%fuy;c`>u>oIJ9 z&iy~Ey=7Qj&9<)HxVr^+3m$@daM$1-+}+*X-7Q#f2^!pkySs&;Awcl2o3&T=O7=eQ zIX}K11FnXiRdaOD=dK!MBae73d}2mfnfa3Ip0PGL`G;zmDT)l1!%BC)sTw$iyM=<`c)H3|70CXhOsSP_0_p!-`L02$^QpgPdDURqP2 zLcroAK}jzg(EnePUMlTgWsVyvy$txB0&8*s08jY@+Fxt(y8vc&`2c_*SS~2d2pC9< z5D=6Wz&C=9-2VUdE+jJlao5zq3j-_vV&G60d({MpfgD)B8Mp{!Aj1m-&6b3~oVtt> z*t4oh5@KvDLWecm-0}hJgGiybeHq&1!77bQ6`%NrZfkj~ z1+;DIZqsZz4(6{;kw!90A}V5^i)=hTo_^jK6|YFV(@Iudc` zXx>1cAkl8sN8Qb^A5g(;GP$;@Vaj(U^T?Zg`C4^JUzkHvAj283MAI>%`9tzq)A!oS zPriJg(Dr{C?C*j6Vcy=ciAun?(jO4X-UAtq_jZ_QWK-Ld!G@X5uj(m^{b|WR#BWur zKbI`>mnc(Hre#6<#eGh9@gypjRtQ3SwQIiEj~^y1V(;IbeKu<#_3I5ZT(m)yEq^4s zY*o3^4oP}SrSFXE7kHIuYa+24k5wh!j!wsQ$3CpzFeA2=*jEox00%)B>Oohvv&`XT{Q6&BG z(Bcp>q_TeC`FKqtZ1!B<7qF>NU_g%Qc+Y{BAHZ;wh+E%04n0RL8(QtgBVJ0k@FCoE z3QQF0Znpoy72cnVegE4ICSQZtBp{o&^!?-14!QLMp<+71J4!DK1gK;!4G{wV5(FcW zfn15)QZJ;20*lJThVD6}Za!++66n;iuMcs$&x*5Z?`ydiT`z{`qbw1-ay~DrpAbst z=N%IlGB4K3H|J*M0>t1pdpv z8B)RwWC+^}Ll9TO#G=_dQlEqGG}&h|ZUya=wJA%aou49M(Pq7M%FL5*Y_hL1q%9G6 zq^K@O8<=A2amG-iwC3y!7rzw`sZ}K#$HEa1ydr#M|09Ba-gOy@PMo?yVU|mTm89t%#Rv@p9ejx%Lfs-{L`7JJmxJ-xuL zONj`lL7!j7^nj3m89`)UWc|Vx*{AH5?E}f8?lh+zMVoNHk9T>)LVBPT? zX1&78Lmx8vkz9{3S{i`5Q1uwn%OX4&f-5v3ALnXoKoYMC8HGE*w?7sWvWL z(ne*?|1xbn;h_#gw$4*tG3o9u^6f;-7Oca2k%~KV#A$*~MWEqS-fVNUS*!OAlO6#4 zo5Dkz!#YfjN}S2CGd`!5zrdKDWcI9KZ6=kbR59~OiE}oKE+>F*=GboU0=sjFWJQ87 zB<->&gyh3f7sJtQg~Yj-E?BGNV{@%37nC7$fp@Emcleg->*CAP|ZFrUlZKg(MN4R0ZSm6z|ipv;s2)DGs(ux2UKN zd;%ULn-S}6mvtl?h8p5EW6GTBXeC+Qg}i!@J4g)RlF0uQN)5dr;M1Q7FhcEOZ+KaE z#a#Lu0ii$y@V_8n@1mdU6N!9!KL9VRCy}nj(p|r#oKMzI@lbRk=_iY)%)9f9A5v5BHEb$d~Lm3a-W7?6S!fa|V|8eR~f{Tl(| zKm>$>5HP95^?(Q=;2mkXbyh5Gu)KZ9;;K5fmf`HLh|r`zzpEgnn|;anO5sI<@Rz}xX%LjGk0Ze?{dQoZ;s<8wNL&<=7u zLyn4KD_NPvorIwD8TCXfE)4I|Pq3fbvqw)8;!q;)%C0w_ifyI2DerbR zj!O2yTAqTlT*HqqsIbd5GDV%pg?2wrd8N`n1O<50c!x1dpSKkYpV)a-5_2^dJBz7# z`d{%<6x6T>Vui&GC&b0YQJ`ONM!BnFv5#D9+(W@J7DL&7d(95BT45)I6*N4@78a5q z2MIi8n`6xrqwbsK*A~;l$vjj_a%Ec%nwD&KqZ0z$>;(+MI8$O;@2{&OQN6tw7`#hS z3JswNg;K6#ixxNuFuh(}W+*=@7$bAvkHw5DhN`p4v8CqQ*NctdE(d#GSG1!sCR(Hi z8aLB6lN0x}m|0^RJ3d(}TT8&Vw8wh>%$cKx`;1A&r*k(MoZ=={_(~63hS&2pqQ-iU zOw9_0Rf$umP}JC9PEe0-#pc~%+=cBxi$>7V*(M_M$K83bvmrp49?hkgnRfQo$jsT^}1ZL`BCC1mx)VOHTBWR})0&KYQPA)p*pJ|)Oq07sK zdD0Z8Jll9gj&_6b(_7@ThgQ>mwXS@>UMvYW-h7ps|40igpQvykB zJ{JcQFS64#&;IVFC{_s%1Cyt+C6Y~?^V6r>y487uSDrO6@tF!AL?TN+7qRPHzT!$K zr<(*9w0g%28z{-J%SVJI6FyAKtTc&^*7^oV{sBBHFbb`Tc7UKVcF)kNXlY;WCYycYjdZ*)}l$p z=J%JDa-|sg&zK78U-8aM0wEa}51>rYGM_;*0UFfETTo0@ru+XIQ(f)`oJHla*MkHp zUGm>G{YOj%^{<*(c0e_4rhsYz)duhc*2PIDW(o|F;Wu~h}~V1PrIKk`I%>ZfTo()gQbvPzSfQOR$v{^x1ccEWz_ zxI#7oeMe*y`{~^)l#J_Mt3O*7eJ*R3XW^)kBimAJ6}_4`5*ct0jNXnVv@d`8G{jyH zm<4&Arvc0y1cV*P{stcSbF%3R@b*BTEy(kKjWsuEp`943gMdfy-*J;X5O`}K;0+XP z#yrg>7)~CSLQ=`c_t2ywM{HiKaxEgKZZ>L!q;u3`ZIK$4A@^5=8|=RsEt+~2xo%1k zVY)W%bG2?*E3y~;-T*A!uvo_?5m3ctAFt?AQ5>g}@+t|V7zU!DSD42WMhdF%YQ<{r zbP>#!NPnQRCzOavwFJv#$F-s&@Djb^=j0F6F6=6{XW$N)P))3dvXY2L;-I-&yPSEQ zI{H2dkx_UbtW?g1Xa)&%S!p$Ehv7QE)~K@)2d}Y1^`ORCjxH#Y6Y=i{H^m!M^uK8H zJE+tIm09M5l>=o2It6C12jP<5$Iys(_>!L;#C0_BWn%{CeL`5Ly$S3UB^f>`XXGX0 zjEfwDshnnd^5N+Z43Uo4#ml)7wAw!ZX1I>y?A~GI5guZdLu|-e^wvM&9Y$5;xSUK6 zdrfhw6#D&z^%$0k&`5bT+_3U88kN{YN+B#NO#}9lR=(gyI{A1DTHTiZEo0NdB<%sw z0huj8$27+Gl_i6(Eb`;8Ry}4pC+jtr0&^5^75Ap$GU_77=tS4nVcwczvc3%jA+PZP zXD-L2oYh^O*QEV!u%i&Mv!wY?4W$@m=?Z}x2RJ`0lUj*bpZvXthnp|rAUrKdX!Yd2 zL7ne>g+n$TfQ5&UE2!%d&32+4Uk}6-3N$gL;mj6@H`{&hyKbe#_$Gn+u7G|)52L}B z@#psybV`r6NX6d+^)u=tQ7fiz7hEhXe-_&$A*^y9W(sL}0qDWv_>&(D$s%5BV%%ya ze}yi5O)>adVeGSdysNP!M^CD(E!u!xdg%A>yBSke0>8}oq*peT)j{}9dm zE-pD1-o5IiJpjxJC(N}W7ojCkG?o7{$T4Cf!AR~qlZSaoy^HbpylF&6#d-|)gvEr~ zdcwMpD94RuwJe3Jb>g_ttS!-OezJuQm4B#_r1fSEaKfgupc{s$5Ylg&U#I2Qa9 z39VE>Bs_qSka_yM52OzA9E1c5kCQ1waeelmas=De4Z4Z<6l(_jhtzW&0w`NeoXIxD zbY%^XH$Ot#VDrOpk*p{?zDJnh8}Ac$XHo7Q^~usSzxr8Q2^OrccF!H{66$xMs~vr7 zExLn_BaGLl>PsK0UQdafX#M_rCbBi}gs96@4_k$udadY45)WyL64tM)CkMi%-$(y@ ztTXb0#;&RRjD+j)bvBa@k6QE?O=ASxj=Q+&4RJ_#x^1x)<@BK4slP^fTAZ_n_&3G8 zWW(+O+ggSnNp>4JDBETuqT6g_c}RnBp-@o2g!P%KAN)VMi!^*Af5l>^LxutiW9ccp z5B1rkgvvpnE_AO`of{MUd-|l?Fba3K%AtE;n(DGki2~ud6ie9Z6J!_~f2it102lK< zV{~Pu?`D--X3%aDg%H>l9yI|?W&S)5k`1Yg zwfHO!d{u;!UHdJvO3}PX!Fe!JWUf`K|LR$Q&19-Mxe6T^v@SCWTaAwPhxgFp0R#mG z6p9)*D^{Hi9xdU?u^G)DEJUI=PKq+U2GAE-XxNZ)@^ymk1W{*76N7@wajP=%k#6y7 zy=>3~-NqR+nOu(}V~>OLaKJx;VbzTs*I}LI7uO*eemsAWAJNtUHK@l+t zvE$dQ}G2GUghXT?d3fH{?3**f0 zn@x++9+(B?`!`7V1{LMH*P%?>%{{B3Hf~9MSO^?hTBGmw$9(oSs|-85@r08#+=36a zGivx0s^u$J?Ob+(EZ|wN7C;Yh`t?6<8TtAm+tR;e`_RSSo(z)hYx&=@jR4A4{YAFl zf@{IQ`hO>GCsQr&jVm)2Ut@vg&3Bqo#;tpn!Ugv!I^pQ*#NBdHww!96$hU#X?2k~_q0A(8i zlI`3Abc=f!T#zq0SJu+tu*`>65$sZ%{is9baE2rAwzn?jxq;lM*or?5MnBHJ;r@y@ zb*6A}v_HKv;UM4`v8*`ooHX%Nm%j5(Dxk#0eAL0%x}TX@GJL>8f5l~+P7;1nHlg}H+@u`Fx9>)#=Tu;Cxbn7kdA^yG zu}kgyH-;`ww|*Cd3F1yfEo!JdH%=0Q=o^G0?Qk5U7nBUp;vg+Cn?5RHDsS(PL9z%I z?-xt{iu;Q$onHsF$o1x9X2k|ZZ5@#kIj&UV2v_17FXat2F{nXw5L`O~X zeO9)TO&;da?9tZ*BiM79Y2rCh1{)zOwFByp2*ZLKB^g3Y*ZaFo{M{Hbf=F9{-aMOt zN|TyQt6ngjqanTz zh9_AdMBO`0#2#>ZAoZTszq3TTU*A9#K47SzQ%P93#pqE}<9bo;)ogebB}`~2d@B=$ zl81iie6|W-4cfrm23lEA=El7J)C~B$u+m~?lZbL30Zl8~CIhioeg~$nLu8hOdrUtB zeBENDkb1j*=t#gc(8m#PvWpOi3H$5Nk34u?clSnR`r&P!4Y$Otl5x__PFhQ26>K>- zZyLi@KO~`7)^lqv9;pBwB`yDZ8p5q|jeckpRh%y$(`|`EGfJlc#F2Yd`)T$DZK*w|{6MUb9fixZtkS*#zU!M{I~i}PK9QZUWp=_h8d0L( z6cR%$2h8Lb=O`;+gof3cF?PW2aUMQ~W~46BTTRG{L;bw1sW@z{iv?@&YH+xGPpcNg zenu`}YYozh;#kG8o%%!83m`6mh6LS zB9egor>J7|g+(QQvdAQ_i@nnp#Gu%B} z96c@`j6$I%!Mu9vmVclaO8v#Bd#$cVZ@i+XZDi0Jiys0l`|c;WHoHU-vPvncrW%H$ zYzv1l04=^0PA64x5s-z9OoJ0n-ulBc$-{Jog>I}z&hl350_1hGEF=II$RUvZ4;CRL zTfDI7C4<_3v#8S+?Zi|B#3JnS-z+i*vS<^;qUrVYVpE6_d|MbY`mIkM^)_w%FtStL(wOvhAOsAokh1_4`9KyRBTQXhyclkRJR^6I|7VZW=-b zXUG!A?GozXJ!FHoRT=GvBs$C-)m8_g4a}}CghKDWG$}F<+DODQvVHUt_uf`EKfvD9 z3^(%q$i#r+RUX@KxNMk7L3j39J|?x?r-%(J^~OwISHJN?(pkZsXAUglTqw+;BF8$` zszST}%|Y@wd)4I4L%Bdvq7JKoe8FpXMUKb%G5ugKkHXXxS@rZFE9E4S8K0j|oBpd8 z`;Wr&R{~p>Gec{-8K(?OI)uww0uHg;Sv)K)4oE{(CJ2Dx&8`Y)d&U?*c>j<1b5Ca? zU6rl_*$m2$$tIJ5KNAH5EJV2yAv0U&YvQnm;SZobB~d)MW9_S0 znyjIZn4b{ywZ0n6X`JAr_iA`ulVg-87u`{eQE0~)0K>1aueT#9cwTkCc^>#4j%n;b zs3Ar#GHWrRsd;l8j)9iTAqMZ0_Q;p(O|VIshz+C-3}L^u;YBwg$cAIj$eE+wrFSOa z8^a0FgSfZpJgG$)-ovX>H;p61jj@4H{hJoC*VnUK@-0QjEIJFWxCAx^p4c4q8;?0I zQit(t-;)N~$2R9R>1#%ed3}vkZEE+3%i=uDH;}&jOsSVSU(phOny<*N^o<%=u>TTU zL`bNw)Udzk!&DA$|8XWtSf$R{W7a1!upotRkt&~>q6d#yi|1h`y%8J7gA)P%%u(}9 z_^{Tbr^N}D);~Qa{Fu$yEGUG1Y483CaDE$xD-KnL$K_sXP=}1uvr)=Chorv&7Ivc@ zf|1%QyVyfbZ(JGo*eMFkg}hB}ge^nlEWUj?i~ycM6miZGP(VN*0@sUr001bER(z6|N77x$t!KT^{{|0mSc6O+s+!{EH*BdI zs3wf`f82vJ_5z~fKS5+F(#75*1_F`d{l7s}#tsCL`3r~=daJ~*W-OLsYqWhG?^VPu zVKt{xPU6-tq6NsRp;U{mSQ%#HE_rxs-gf&N=e2nlKf|Y1Quzv0+4?GOUsNo?w~rn= z8@#g0njNn(MBwsTHXJObLQbv>Lcgi`CJTv0 zSSJL5{-#1s#Eo@2_VR>RiKWz_?J?59Yd1*8LM6NRqE0PmfsY8}HD4|i02K%!ko^x3 zfhSwOfN1^CSf)n|2%PPa&o!FyeTIj|%VW_20`6cNUp@6%{ISLOX zvF`LYOHMZ`jN`xT_YO@>h$$q{CQ#T&%rw#umI}#aKjZnbbSfAHjFQolLnaDK9J3mD zZ8x#D2kepH^FW%zhZ&H277(~9BG;&BWxyI3H6j{ShNw~hXyN%G@uSeP$-I=aEvL5i?fP2Wk}iGNYMCUt^vudYz&tvob-wA^iFLiDq2_l!HFJt;1_beuRZ5+7-#9% zkpW2?4I-Z5OhTm_xmNpdSijD3RWe9Z`GmJ#2;t=ed5Oux?rCqj3k$7yYEwS3q;R4JSFx3TH48dfPW)U!*gzMIBtf1x~IBgciUP1~CM7`;Ap*3Ocl z1Zhb4^L^D^Ra4bUzP+mQ%sc7Fz+jRe2qGsRoI3{5f`Ya+wZO49e_#5FDGG}cXy z@a0bILcI}hOhS>Zu01zzIylE42H10WLdb+@NgQtIU2NXEnW3r_`te(vz6#!kZ)MT$ zV>RDLrM>dv-a1Jq8k&5U+iqj_0N?9U1wO}?WSqeKt-pU0I)`z??sD9>WR$QlUIIoo zE2cEvTYGf@V{B$(ANuV#a7p5~he^rpo7zP-G|u$lT*4n@&P;bdEsOM+2(Cq5@|W(> z$%5bB1Jk7~mMav!qr;f0Pq|1F^NR{-NR%~aO{b1hS6!$$K0+EoKFO8@<;et zJJ!|fDtF``sZFyqnb&#Z87|!_UG6-i?OZI45Qh6a(wO((-&Keal`JmEpwoLxkP*$0 zOxx_WNuFA-i8}Y8>s#5bwntVw$i6hxTNnSh`)B-x3LpQX!eJMCKPHF@yFS0E5Cf!w z!wVJmaYhm06r1XTo3ec+$Lim|KGH8ASM5#}{wJaV^dk?BIPo8kQ?}$3Z6hJ#DJ*4 zfnd;Et1liivl??Q?^@>&oC~YD?#{V;^?Y4$irUhzV;bBug|$Yf?88J#w#Zb8t&ri3 zv~E%SlbmU=4MQ{cdh@mqVL+e`4BkpImy+lkg+hPl9qwX@NrE7qeDZlj8>gGd^73uswQAJ{ zvgVZ6kn1gxVym6_NIPJ^M(D0mFau`9YEXAKbGh#g7hn(Cm?j8@4_I`AC%KUn4`${x zOMX>VK6-rTtxhsLZ#WOFiyE)N7Rv4G3$kt9=e62R-5dxT+WOF6`89|CXQM@61q_)l zQI$qU!$+$8?abx#l!k2~4;Xw6E{Ka|jW=?R{T}j*rrE5SS1@S?TeY*_RX&m!-6?sT z-}TL_q-~zke-dh^=R-s-EA=GD{8pjslbEwTHa$bTz)oqPj6dK*apCnWTHc zNTR?AD{BiK9x7iiuSgfwPWzPa$$dq3hd&JArna3|5(YUPOjlat8yzM31hNBMdbA@5 ze%kBuA7MAJz>|ahx@>MXg_?HdYv(YGS{qTJNe?$<@k-~KTO%h?dbWoWFH+o2G5ByZ zD?>tx78jJMT*q&zWd-UR(nYcdIV(7b)sQ0kh!SD)+CIlri=~}U;N%?LAJ8%(n30;m z_>LG8vq`0g8$j=@kxUv}y?LemJ$f0qTpiJ`&nH1J(GS-@KQ?%vOnEN>0; zG-3+;<0AKo7Y!EusX?=-F7`nMkOnU)e`|0Ts6o#c4d#2NE>|p3lI~muF`q&gZQ7X! z{KUXnB!_B7i9QW_p5pbrs*=Z~&ScY%E5C3nQq!oA;(Iv4vHLJ!xRz(c>1Yr}4SAUK zrb@_ZGAmW$^L?WMueOZEEH3PVdni{N0F2unVRcZ5M>!EQ;BZ$UH?6ptb%4cMvfaS~ z)mMGA^+Ur^>x4%s_6o*r$}sEcjG_%Y_tJ?3%F;pM8ay2dGsx>P5m>-BP=6r%9~zuZ zwtmqdP=fzC@i3@>c4E#9(%_81Zw&?lHMk4XU?vUw{`kZYuDm-MIBp4KjUL+l7Cq%2 z>ot$R$)0-lm5)lOSF-8~sZgTH`|KWE3{!r8f%IBSkMD)*$l?d}fk$i)Sj63GS#7GQ zs_2l0vF|APt>2LQ;!#j>^l@Uy&|&oaeCXhwobPVU>n^AP?4fe%W}N-NL~$eE)NOMr zmiNxehFv>2Leo+=L0g{fAr5VRoY~luI{NG|$WpRrMnUaC7;7wEx}`krd7e{E(%ZhH zv{=S2X8aVaOJL)aS0*nvrYX$j{J#2}+dT-by@6ku_id{$);zdqOI7A|+%!kIAe%T^ zZF4*KCtm2tpS!DF%=gU}7a6ePk*bqRkbM=@Hv)V(nAXM1GTe_0chzztmZ8;omy3*E z9{gXDfp7c=-q^d6fA zE)Hthhh$`gDguSyGlcpH1GR{V!SS=;QBUerWiq1vp3QZj8m@fB)H?!usG~9pi=9LE zt72$n`yLz3p78?;hIcqfP-kc&am)K&A7&qN5vdo)JG*++VYXZK4{Ndc?K#3aomkq6 zINuy>s7>ZE*;pEYgTEnVjo>W9=tS{yx4G9arFj3P+Jys^-Qoy*ud(Zm97_Juj@^fN z9zzqQndM&7KCdBSw|UvD1$Jn_EA6#AgZi_#uYaD9c6Cl`H0A0)=I=akVBkxu7%ylL#odLO;gV{pz_Aea( zpLsCQ!cYJJ^z?(SZ2#Y{1c&|eN^s!&fol5CD|`RqW(cduFICapiPj7KTp8K+Q+-YA3ry0lrI8L|gGPbsyrqh&(T{E?H3+{l_%kCl8Z|b* z#SDDh<(iB{F^g-nou5Pz%U#JSG-N)r`^$Y2pigi!9`2upt8HE)vzOdBAkY6b@jQGE zG#ZCBaq?T=jk?0mj-$(%!Y%oo$bXP!^Ay2b9BwFp+Zcytks-(>RTDb3u6fVHa zH!PCTET@T!W>v^MjsHD?>*P=P`-hydrx$)-4=J5qQPtkpPikWSi@f6 zMH!h2l{ZnN;VltD)hfF;uj3cS2saD5(G_FMzKs!*y&l)%+;!|C#!ZOR>xoT{(60)C zXw@#lDdd;z>I0TU!czh7CCVyRqWUR!)FZJU=9|DoS&D7yj-{e_Blyc#(NE`7PUtBGNXJmV;wgvlEC@$A`FU5%=XoM6dR!be)QWDVbBO2Yn^N zCyfs`eV1!8hzFF?sA`VL!pTy^yOOW>vxQE_CR2km-LveC14m^9CcMc^)+*Mpc3uycuzqgt#sX$IlQ=Fi`z!d1HZA3l;mHpG5e z?8&ReeA50xt|d^iYIMV4zr+=(>*$~O~(~KF-{v|YeKP^C}tO92DoHQ;-o{$Y4dH72u>JEH>0RizG zd*HK+1vqLDk6LKnTDHY0{UYkb8#syYM#TM%fvZtsL#cOaOk86J1>jw*W6vqxk=B@R zZ*BEgaX3xzlBv=6#r&Tk*Ca;$!Wi`F13CTY*K=cW;-Sf zsdIJ*dHr<*9uNm?&p`G^3k2LU0UnTS`_euNk#{w?ikx5YI@1+xt#cze}QGgW<9bpZ5<-Y;ClL_ zbjK%+v|Svu;2LbX4|Xvc>Qc!-20j{vVK!P6Xv5Uy$2R27dF>_&6SBl-;33BD>vASN zr})L2O1p08*pi1B_#_1~NGLX>@gC;O;u51+Pg^QGZjSN(GG?gbIM=J#Qcy&`yV8%M z;|oVYjoIx|shWtK-Jeu2DH-iD(_$o38ncpprMAfS8e`>>)K`c5vj>zPVXmpuNSMgN zf_w-Uyjb7;R!KB^k$d$cL4+#j6c>IjK2c`ds*{`K1Ql*7sPCw!i&^ir{Ef-6}Z)uc` z#!U2!&cuEZq!6H%)t2&zGA5r5fsg9hwO}he)2)q$w&1fNcE3yi7{bash2Unpnnhy_ zuYPkYnFFJLK!$F!#wEHP<> zb8}{M3yIcj=O9)l;Evrh_XRzl{?z}tNPhZ7mHB_F(!#Baec}~Jl|F30RSAmQl3!Fw z#xI~dS0MIP`_@=58y*qkUgQy$ATj=s(G*;BE4MwIYTgOTUjbwFtK#eOX40_`T(*4J zC))-WM8b~%@bU4LUxv{&G1=kt*&DDAU!7(Sb#t`bW$djMeU-5x@$Sv?)P|W2>grSA zO+MOTCZnG~11%uY9K{1!L# z<63_e$ZI49M1Uf2G!2w5%50!2L2(-de427$ zqm@J_(>DnkBnHa5(-5v7j_-Es=wP$k-}YgI2>1J3ej;oz{YHhv66I^2jbLEKAVY$T z4wH&bfS>Qmrr#v?Tn3n2*Ns)Hy(?0MOS8y&}85c{%77Ru-u> z-Cv!5*Ndp9I#V~{$uI6(hh{h?D;$ABm!|K0@cRop#F`_F-iu5Dz^`im@Rb? z!4COUwJ1mEDR^=5xE5kbFpB4U>VvW@GE&C=b4YHjYB^pIi8Wo76zuJS!yi7Bj&!mG zeu?r9;Ta3%Fh=n?FE9FOmDjO&Mi~BK24!k$LsKx#ylHp#*%P;)a9(#lb<5A1cY zB(qOgUj{iM28DeTj2{u8#iF|_8#=a23bLEh-mQBTmFbW0Ce@Snf zh$3!IfUl#ot|M7bI4%d3)qOd;#b$U} ze)Z`1k9ihmUa*q)Csr&8y4a_wL0IW||8J~(0)+$FFIbW4s;C-~LkwA0qG$3pCgpx0 zT^J#5j3GLFW3eFSMNY9gfAZO7$WR9?y*o%c~GI5=pbQB;L?hrRlC*VLp6m6LWMcolAF&%j#`N^hEj5MiD3Sl0Fs->LY1P{O|@ znCKjn(98wSz5TA#^sp%1pO8D=rW)HT+)ViTlsoP%V3-2B2{8zFpHxdTM|7n zL&MD0C=#3?4N=*l#*`cZVkq-C)7SFNgLHivW!%;q#IS>M4#yH-X}wJ*3`B9Tv;)i4 zewQd^Am#(?%k+0t?>Fnev z+OZ!Hlv10J?uu8R%{klU5H9ep{i+G}Bq&sSjcQLpqpk+Xu8NdFIC%)W&GbyZkd{jk z*sEcjJ8=DCXC8MH+^SpDl>76$`qbXJ2P)L6o|*vLmr9tf8F07x~C z*@w*&LHll`x#w6Ey6i-j$2b*-0r%u9gpL=QY z*mhppps$;KI8;&0=HyZvE^mHm?%BZ!j=a09TVd$@p#vzfF1{F5BgXie%_+TppR{ck zAz@u@wB0NH)P$H3RAqWth}|TWy;|P5?A}4xUH?ROdsLLmZ9DOky@kyp^myi3Z9@)| zo8KX}J5bYV*{XV6tUG}>`+LlrGJV=(x7++<;er#>|E#yq7vyI63<`Tj8@p2^U;e3Z+ zLUkpANT*fG7+Z=D8xU;!jApVEKF`ZEU9uhI&SztxP~8|ErO){F*OYNR^Tn!HJo=7L zz845Pn+(VRe&B!%$X-t72mgICKSG)}0G9jzb}~PBFrW>XGz$O#l_oOsk9($OU(lWV z7rMK;*yn-A!UOOROMjy~9*FMZ7j(mnhQFU_7mA4-)pJyxzQC|qK&99wl6bO{JbZm% z7$&svy4+GS>BueW)%UzS|JH@~tsxs>eVJ}w)nZw&g3}YHwIl=(5LRAmW4EjOE+6@8 z5uh+{D`#Q9jj)v&Wfy7~t>>rYp8h`M&wUIV)Nqcxt8}KRBdhKI1ut~xYyp_QN{LN6 zo2^w>uQ*?MC$pb)rnf=+XHVt|SuCUpl+;Uur89vH=m*AVAp0BL;7!R6FL4^#pXi>K zK|8TB0-;+c>o>Zqf#{A0dJXQA4TY^~97g6RFe7wFN?g)+C4yOA^DzwPxaiu%^(}IM zfG-*Lie}3aaID)IUp#RFSJ|O%?v_JM0%agdnb*QV;aLA~J zg<}7P1hQzxlXbyRSSkgyY$!ZEOrP_>$m!64Hn&-I_{mzT2VzeG+qtN@Y4CEOf{~Bl ztlv_1D!q~gwWt?__zwnvFH0Xen9&x$AaVtNL^eNRV%R*@hV*Cs58W6PdolX$bU9-L zRwaiBEu5?(h0+0n3mwh|6$LxjIfeX5_GVtmNYZ+YvNd3cDcCYXciMQXnHT*ndtY3v zSor&33cf^N$+V8Jp9*Hs-p>jIR2~BEAP#X6WuMZ&nW9n%R2zMv(-EJOTKI z&bISwcnU-mg>S?;YW*%A8#2_mA;jyB?%TENzGPva{9oajw{Mg>SPGV{`%^ zH)48Fs6+zlXB*oY=lA(OQ4hG3ZhaQ09T+b+qJCv=-ceXdMHjM;ScpCMUe#HprzY-T zQs|f&;p``3!U{%LphCs3G@WCXp283q@dtff^jU_3CT9rv;-Q$2mj;YT%q-HVB z@p+C~;f(8dJFC)Gm~Sv4?6rk6(W90^Hg4aaOgAY18x%W1x^V2WwXiG6mB{l6`SQur_rdoo| zZ;d=|(mov8MowB)I{j2(nP!}5x<-mz;w*+eqMv1l)eSyll3wYQSu<@vcL+wl{dtkI z9@J~xG z_O>}T1`en#R98Byw(Rb!b8b8eAP)@%=D0sGj&O5Rd5Th$bs^zP1s8eTc+)|mSMy-L z{fyvg1GUt@Fc_#*ix}dqS3&xU$|J4pOj7Z)qvOotJ27-t7rgzZ2W>G;hM>fy#$dr}ORk1p5!ERqGjqRi16>wCj434j5tVvpsz#go?p zas$~sBWbT+A!c9|;?9?RC36JMBC7O6q!i}# zSL^lj(ap0a!y0ki2X&-Ms|+^URF;UztQ)g_qgkj{i|yykbWkjfYjvQ z4+c;Lb`3!G9}|Ayz_dTWzW_-7yK|8SuJ-&y6bt_32l@xF89n!Wewqq}4h4uxf?ogq zcd&m=Oo4-H85sZr$N}F2vX|!fzowr4tCm;)UCX~q{dZ~qt~Cr0>@4`d)B-AfiS8fw zQp~?_F#AsqTElj+ue_WDVcPI-4p#63IoR~VLE3OEOA616l@`iRGe6D*iV{l7cWO~7`A-lf}cYHV+`gHeAWhF>A6I$E5oHGUh zB^w@rf-SE&S)EDvUARFoj06vHfKT9UN^G^BO!I5O0L%1lH@>rUJRX+NE$3*u2t}R7 zXSqzHH+Q~b)_QN|H8HrFVgnVWpaTjlO)s8o>P5IOMn6gLwSXi5wDgT7BO7}!&&*M`_t&=a_WsBk= z$gl81*`rOO($VrKOjVV@p75Fcq#T0RXx0|We;`opSfTaogoup!yb|t${|2clHiFDQh1V5a)_rUA4W<5B17i5@RW@=x zm&_$}Oo%WXV>IkC3IK~>Jfp^tYuOLlNuD9x236qKG4{%WUGduG59onQk340-D4Xr0 zci*$>N|=tpm7~m(dfUYHSRnt;v*uf*Fs>nzFGRVb%ut6n?A1zYq(gcwYPc11#C%Me z4>JXW&NJ7aYQC+}#H3ACH~PgO^vwlz{%RY^k;TIqfMqi}*AG?RI%^_?Du9|A>_BZ| zYf!j{fGU|jf^-bDC7Va<5_<(WwJ^4us}L_Gh*U4DhQ*a9V-FX)3%8Xn4O{TP1iV9m z=nOa3^7*)A!C6aa{6Ht>!G&gcoj8DEY~7FxpGgmqBHhL_0q;JPDG60-9f+gCF8KO_ z`+dw+@U@H?b64BRTtK9xSjp&YSdCBT?0xr_kM<`E71#6kS3l&u^`R<>aTIC-@f&y4 z(B-)zrUMkcV7o9v;O3MKeVCmmPUPG?q(Tk|S_El&6D|@Rm^$|-pF@6#>pN2~33S)y zvz%;tf{R|K>>M}dbI~Ill^;?AsQp%uV%0!WS@ZbEd>soffXMm_5EWhQ>qVfL=WOOT zAQFLq=z0MJBySi(Z%No0;rkp2c)1Fi*2N3jB`N3H4PSy=_J zYMI?0TO`N{?$4*G$up2G=saz=L5OHM0K_i0pZK zBS||c#n?c)BtLB#5_p_uA?d-CrVwq&d@*(=x(VTfSMg~LV=W?!wBU>OBJ5|12OLAe z2{v4sB8!WvjL^GOkk{XRApp0)cn4&E0|NYeG7zW01YqC)1jKp~+KCNt%@+LsN8MY1 zRn@$Izk6@GySp1U-6f5blyr)8mvomP9g0XwH;MvEqjX3pC5V7DNT=|wjgS7+=Q+>s z|DN}p>pEw9&4p|2wZF4w&D@`R);(*@Ks|sv#=o8`?gapm0s}ODMad*_KUIAQL$P&P4dEt!f!kH^(c_Wo$=mZYRtu+$ zS>pKZIH`O058i9rboYX$&X;Ve$-mi+_f;8ppD30sMD!ZTI`vhgLT;t6GDB)=(g)Zsw~% zY)Vs9&SnHwg1R(~z39Zssr>3)hi^RaV$Y;FTNPD})Quyt;pz#jiRz!V=L%KkSQy?m zLujeD@E@#H2>3EwH&OCUE% z%y~eVfv2~XA#~qw9hdIgbZ4`|l#lhJ*oaAka|+)`41d&}Nu-xnB->c|#C0Sz+9csQ zaQu8tD;ZO&W;!rKP9|eoB!kA&EH9*O<>`A)&z@{pXR>`n1AoJ`41jQ4;IR* zF`*{hX2J~1tX=fV5`Ib6a;97C?TaJw?X@Tzj+Gk12D-ECo8g?IGyk-C5A~5DqU_Lbzy@nyWKzIMnrsfUic& zs~P3r>86k0bxmnE4}S{xtu0fce8#J%l`ZQ#z_rSam_P zsQ;ondi&iR=ChVZ0mjRjxI)vM-pvU}_~OKHJ(Dv~hcixXfu5b&JBFkWWIZYSReNZ% zMATf2vVq@)lQa5S9X!2a!RPwHPOZS-Q8hPImSC(lxmo){RBys%LNOrh-*W{j)uyv0n1J*r5KU$E~iYZPm7%{!~CAdnl$l8sn4Ub zJZ`@2a8hZ%5dwNG~!mtH5=h3HZL=m465=E*hOf^MT z+y|uZyz?!)&AD*WzodJ76!+wPixSo5sXfuSDnW5kW`g`|EuVF_mazp71LA4NC-cVM z;@LYr&gb!@>`^Cy=XndMBsb{EAyK_kH7K@~xU+qfmCE*V@aMDJlw0VX`}s#lLUQ0n z>RyYepwGD6PyJu|E-WNBt_`J~5V4<+l&x6BGfVdcq(&DB!YkGGLncvh%JP=>d$*5B z9@*=F3)IBBam#v85yTxc3?`q-@oU?WjajQ({-4mhNQg%e!q^jZUnf1oxE-IL^ z{qjW#H8EkMQ@Cc(HET1W*i`va-c3rAGqAED$`M-+KE77$-MxK8>Zfp%v!!tPRuqxdKGQyz-Vm3 zfIzE0LM%-mY!VUj8T@cpktC?fyyfC!B^J@h1b)PGEDzI*@EhuKFTFNO+9VNCS^NZE zo6uo3%cCv8=O(Clc{+7Y-q;F1x?jN^;@Gz)?08h;on@-QQ!VAp6i=z|iX{`1Uc08z z((ExPGL=U0%DbnI!jO;4I9$rQ|FM1^YJ13L(ezUVPy-|L{BjIiH6y$m;#D=o{zKi`!ys1biqG;9V7A)AOr!7& z@n>k`AA%0Qgf@Nz9ll|Ew@60A?g#(%D)#@qT>cDgM1hok3!wZ6 zdHkA7FHn{r0hE6T(*OaKKhN<;E)aqdo!Ft*{tkjc=tcV6}N3N4j!JZ0x)wyPEk zn{2Meu+$;17qqmByd6V7_o9a5c4c-XIukpA>Yo+cHAU^3BCf&L960UXHJC?#2lJN>oGlkI7?_jSeu8-s05j~#D@f0bzed)~m4JE4PS6&PDckJ4F% zE`7Wo_o#Y%i^qSR@9CYteJJLKn_BLIbnMs~=;utrKhX-i2U2wiqk#aNAGX`{?=VT@cMYL(LaL zTPmd1BTR!AvvBqB_6n{hvc{#px@I%xWG%0IKU0#E>Y7N+4O%1mACr_~bfuMQ9QfcM!{%Em> zM-NGz3bsF0!ktVC-7oyp3LbeMMb)m;z#MIYsx2b{Qf91z5c6YXS8jaS-43P=8-%GmF)S;g#^BoP~C4GpurQ z(Toe%Vdv~FgsUq>GxK?;(I>>=4t1pfLl@7lm7lww+4eH&cwu8)P3@p+?9L7OJNTk@ z87Cu8q8gxjpU&t#0VjKVXHzR?2Ik3O;4{A{bP+SVRKU*9yg5J0?8KS#sJ0VM zjhJrHz5AkLh8BDdyq^L(p%L>xY~PNZ@}Dx0&euoAM4CM~y()XdJU?Q*F^hc5ctHNe z>jHRET&J%Sfd++_T{q3_e2@F|(PfIuSB>RC*HFks0s%uY*Xi0ke!QFjn`>O+F*{%$ zINnAu^8gb3W(|lR#G0Z(mI%Cc^4E(e@U|{q0!Wt5PEL;IPH;|E9>BCRATUgO9?-S{ zv|;HNSi)b=$7is~0@G%cf$75ZLHvNaJaZsfVn{HceXah_+HL5t8~{-y5H>X!c@mif z7(#=6P&U+I`HB=EflPo+So*`^Al`@oF(XR-fbQv57PPvmEAuwMZm$e+1`K`K13 zFUpWikLr4`*5}B5JQznA$z2?T`vbs5H#gq>N$9x!A z=F=TFjUvXS%U9NoF1*<-nu^lRLJ1ue9T5cW%K7Tm+GX1wvVEYT;Kselt~2oB3}nme ztVU~l!I3EZ#LEFkUR(MFy%An=MX0ty=#vaAL>r(EnqZthEFH`k!FX2Llb~uaMgY0R43s^eb?1cc?PZZ1Nh5Im@ZG zl?B+0zG$sav){zI6S-7b+qT0=tE(A*-x^C$jER`+imx%d;ti9akmduBjq%)YY7Nnf z4j&0=cNnI#{7t*0n~6R*x$>|m*Pq$!FR^NctWyoozpr-;fKp}MkRD2NQ9(eT#*GkA=fB75L({fysHbSa zSb&Z4akI(T`M_bk;9O`zD_cr=)~j7D^!yfzdfGPOEjmI4iq4Ep^3%wDfST|cE9U(q8%k! zyP*xv@qK%8H$pG>?lvh)b-!lt^g~JnKjfZPI^LACuNMjZJu1)Yh|#fKDoL%Dl%gaRV4$0n#*tH)F|Ax^>b>-UsoH_!ePsY2R2qd^os&$N z%rAT9(y2JNr)xAn4Ko})dCjo{<*-6Za5(Nhr+rW93rl>$W+XYx=ec}|%{Rxa#!R-j z3k`TSoO_t9jW7k|y=r93Ds3jls%P)mvq$2jb){vebtjFTIIH8z)-S!>dHp_@*yzFB zApRb1buqK8UqjHUbiFJ!|J z6_^Pqp#1B5AhXwopYey`e`*uhl!6()|I;6aUnLC~{>HW8`@V8SGd#nhZh5R3yY)>| zFXE-b@cb~zODv=%*Kz#wP$bM12lpCtXWtHk`yVCF?5&L+7o|QAbP*lBe;>_EUM8jJ zp71-rW-;ALt4I9-gsd#3iZ zqQhBN->RavY;Mt$Bxbai&!?k!N&4I^*zvxq_G#;%_16YdPF9(VNln5^4aPzR%>qUb zOMf%`p)_BZ;r|(I+myoDat((W{u0o&|I6?Xt`MuFVTPY}OTA)bk0ri+LhNx<`vbm5 z*|E<>!U-v|TTTc12e)d6+RyxMrb-=5Mlp%oMH*$G5X7-Yn2ya_37T{cG1Oq`Lo))e zNW!=uQ%GJ8D0qkO^SnA5l%jaRIQ?qU1*!C2FGh3kT`O;EwT5joBIwtCUPH@^94e0a zil7Rz?zgPs;tIMWom_JcG1mEIX(^j9d;#qWTfzZogS`SbbBu zI=dX;tHPq8iY8#nu$4iCOr)Hm5y^b3RaP=lTGEH-SV~a-afZRuK;I(!n4E=kSBB9i z66&ARC-k#4JzBvaOnH)Y<|X`T+Q}qbHN@h%_vG1&hjVxFOB>6-T2z+4sZX+wJ9Xe4 zEm69gP!p<&5G4Cvdn-RtbLTd;f#5y5@W=u0t@|+n57l0>*xecpAK8uN0(3QVRi<2=E_7Psjh=xmvg4+#FYWId@!n{uJ=8G?B`<0knZ`bIN+&30>3NA ztv3e*5uJ_UqgS_SKBu&P6sujM5Wh!nzBte{6+%pEB&Mb{`(W@!a1@VVcAFj}pZ_=l zT)*aY8~EtC^|Vh79KORRr;36sZbbg3$=h)&lTO_#*;lXF#lI>ou^%-MhUQSTKxSkr zOlTd@q*VFGb|__ZaaCfjBJLFk-l4u1tR`cbnNR35ps}Wx7{nfh`SQjJgU<@u(m#@{I84EJcD(w7W!aaDiXG>mBUoIi<{0TAurnrP+v{QLIweZ~yiPs{ovWt`)^Dd#ISy)U18V)hTe zHl2Fa_MlHFr8mt2XV(U!dPyD??Na9b_71_IjQ;E1iNI30y2hgibYcCeNCNsZ6a>iM zhVbhoj?E+}D~2i3rc$!W=+iTEs@oY4QdFgHkp+FbQA2xJI(}}2Df#we^|#p#Dv{c7 z=AQChpS5ZNvrupDe)*S_Bb4TJ~WyTa0+M1w;}^SkcK^uw+@ ztmNO{echhM*>Yos5pA65C($kdqGiB{Hh_;Z&!|yUjv-#6ZiO4hxT-L1H;?mtHcG$g z!w1Zu266w2RNlE_Jprf0uM`P6-YAbnSx8(?BFZQh`mR#Wgxlqr9F@aIKVzLK)%CKx znK5K5Ilv>UzW`=46F@SFB7ByWC@wfDqV-n&Y+MUR&CI_XZA$0pOU;M(S5x0ojq(@Q z7KS0~3Xbi1bwM3BJklcaISjnDJ|t3*%>`0ulxpF7HB+-Nt=_AsN|IzkN5F{&JrUwd zPSVs~*^Jf~K-d1n{iM*j%?;cq2r_9Pv15g|UH@F(K%;77H*Yc!T~QBAXVLD;g)KS6 zm_lsvmTyzgCM)e(>Ok!U54THUr{m&@zY*)F#I}N4ny3XHi1-1*mdRX)Me%}ljhp)# ztj2swtjAJ#Zo#{rh+=h)<~~2kPQfWACI!Xq&G2Mogn(JT02*zxIA%bC^ozRKRTT|;_gSec<4-!fxY@ydclFBsd$K3{%QMA9;P?01 z{OF}+9!y3YCYn6Fr4xpdq^^5&Tedf0DsJr7xyuZSAl3cH+ugeick%quca{`glaQt# z3&U|#uTOH!FU{HEfJRmZS?`Cuz0F}fAK#Nk?x`3ez#AA#-knK>x>d$NBWaCdGG~qy_fe7o-(!O5@T!2Mc06 zRd)j^+C|`vOE#y{hYn$$TgN!mtoAkQ0XFMj2ueTu`0lv4sEt}+>`EBVNUTsL9KutCY+}54QQ}eDqcs5%M=D_TlD#0+VUrdDuwX7 z3UpOOh+lTc`tDX0N(VMjS)%ZkY8EKG7?m~fQrJg%xQA|`de;4IAdg8X2PL@OR!QS*P?^SfHz=Z=}$1gW2E`Rz_8x&I~aD$0Wb`}z`&IH0}LGq z02sD`Qp1hkn0}tA{o*SRmOA^A7(qhoL6WX;{X%Iiw%q}tGN1D-z5qz%G36OXKb2}t z2KAR*gLBEEJVB&jE7r}-a0>K;_Qo5%$^qm}sZo|n_|Av>W$K_b{moW4{Rydd04d;8J84=H! znfvlX!l*xGKDkV&mwaS?Dn#cUQ})dbhtULyFme2KS%pZ%q!ND{IL9Oa8E5C4Gl`)HG4%0;nb|mO*>Iz{_W~pPHbUx@&FkQ1$CM z2HKD8kqQWnqUpP^FJH+q=I@dtoo+Q8Q};-t7?t3xe;%jEi(hc_rE-E)J!F1v!3Lmlzr2;iyevR)`r6}y>zf{-#^$Rp`ExxT{voUq3!XA2u%tfkH&2fV zLuV0hb>NVU;z**@zL95uGasqFy*b%w_30vkhhRb_`UUSjQ|>x+-LI<6`wJUAaLps* zV)0m%(JAYOqFQOBAF60gyBfBfTyQ=tc&1cv^RVezRycpVndkyGIzfxC&~)lkpU|CZ zWai2%*Mu%!Y3rZd5~5N%mCI5(#;IK^*Gu2ijC}4-iZU4$D&qbqw6bdzQ}`^T&<)9J z26OYSGo&d?Fm@Z&gIXmL9U9(Q)>J9jVkYLdhGM(F-}ee9N|?*&6D`lp_9<0P<;j$< zGGo|unTE>!`YQNMO!f~&0rQ%H8Jp~a&x7!?{wH?iF?$iQr)LT^RqK~Xky!V#Vki^EG*C`(_Q_<7r*K4M+-Ynd=|N`l(Op#tlJ zvgW)b6)HCy(KTZ3_0Q`5Sdyl(BTf{By(<5q_n?PZLglVp8?iWI!{wstm$3zHds+(` z9$nyVAH;>+1P}FQ!jFsaCGslq)j6pbWf-C(=Cz0G%I0YSpdP}234=757OcWBTRAC3 zv#?D1C}Eak7%J#qql1)z023_zi3*TdCkhM|?T9~7Q2~C1E(1n~g`{#JZrFX%*TJvn z--BNr2(bM>f?tii7$7Ae6bDOJ*Prk2!~h|_Hn1oN1SsPO?ZbWn`w}Dzk^wUveE@?I zK>^EOfQf8i-~9gh{`=qe{r`E(GE~cw9}E^p0)H==APyLUh>k@{?gK}|%);t@dVwPh zfk>gF=y7wnIa^q{d0fG&Y-cllt|27VP*k$S022~-;f zFxBXMhx31`YFTNj0jM%`n6lV52IvNGMPcc0je)Nd^|~=&{LvUN0ClQhY;doh z`cJ=s0aJ!4KHI|pVFC>Rmi|-(gF4Y*if#W{1mJt~`mRU}?k%SLU6BwD4C)4yI^p1) z&$I~W>Ktme-nhrGq9ynS8V#)!JyC>`HmCOrt~`+eftcIa+SuDr+ECl*+UVQ3+MsQC zZTM|WZOou-P!5O}Bn+|!*@L)1SAd`!P+&q3J_v+k>tt=^?!kG-%E{f8H5Ic zhXa8jAXE@KkSIVzAW9Gkh!{k7{TpD7C*Usz2nynaheLovghNI`Mn*auqk$Urylf6gMy;2}>ed7-LwCX_!7?ix=1rWvz!V{n=!h;=(A zk^U(9qknup+9y24lr&L7@kb}Bjr2+WW$}2FZ;x@+@y6rxxSMvqX)V<*&bah(zf3T^ z>=>Jz^=kZpJQL>c+#_G|bcqCRe#3A)+JPONW}4;5fGhf#j95WrjCf<~l`3xth2qQu z`u;S5*sT?~;=;LDSy@<{@L|IQ2?4h(u=Hn}fMZPyfVGK@r$1X+Hux2+#5h~-F0kH- zKi8fO0GqN>`f<*xB{6Z9mr3aFcT%g_$1FE5`qq?ANwwwX$v8V z|p=A3bqzTYli+hw)labG0bnSIZJ z;^HZaDvwVIx%xY|BBCP-Rn3NrS74?)S`TltU^f-)NhssC@X!_9rP$4gp)Ck}uqpvU zZ&Y)5{w2lH33%$adBP{VQd6^rM!$cZ#N6k)8OBTvutiUo>-i zvvS9lEYfqjkO$p32cb874iN=5?r-6RKB;PV=vEq>OY(_VtyD&#hWN~Gr_zzQ>=~g2 z8DjSe^v}WV4ARuuZ$COil)7w|#lY7VDq*@gAL!#*E_hS3`5u`|q$fP_%U3=8r&yZ=vvJcsC*?Qh^lRACm|@k>s>c$~jh z&*FUy%V`ca{V{BkG+=sZAg^Cn&!V<$`gQfJAIiYA(sU<7psVY@@0{FVIT6xg{q#V; z6SNP0)#*#LMSFMx;`Fhx>8u?RpkEd5RKVdbN1 zMbdv%BnBbn?EF;phA9#PYq^_3Kp;anIN^yoiT*T_SL&k8;|X}jW@yYlMZt|aEVAmi z)f6;k!PFLr?z)@|U&|YX)njKVYCNN#naMa=zi}~jeLB=F3YKr<0zx;ySpidUgCJm- z9|AlP;7wp>ZAJH=>+mNdL42L)Fh;hc!LBy!%)`F>%_-noguy(qZud_{5&+l28>W~B zD6Rt?G+6rcIv~teX2Pxm^TZ$5A?(MMH8%a*b-+^rqECt-JUI|T4>S~^3mOVvkqZX_ zLm=2Fjw5QT7t@r;$iq%>{p<8l3`7*xNX5uw3@{iC0R$D}MuVat!s);x;z4w@xUqom zfelnVL}^EJUuP$7awsWm2OJ*W_Z`%lTGG@!P(C4QRVWXXk_dx`5BSRuz5W;goBU8- zC?5|mzc9bB0hEZF0E&10dvtWxUpzT96ioZ;nSc>MaG_u<5U?K=5();o79Vv2Y)kFl zFM~^8HydZIvN(jsP`xLwM<#g%Cq+$qtV`Zvc4}CV=S1r!0g;x%#(6!KtznNGhTOa= zY5Ho%rDb*Z=HSxZ_=`aBz=1Sd#lsj5U-LCH+TENw8xfIV%lQ4=s%@?tt~^xJ7aeik z#s-GrUgBtWE8o1_UfrW<%U|&J!->Y6fV-dTTC+5WfHxj@cNBS2@`$=kJVl1LsZ`Hg zt9yP*>ccw6{kNkU17Jq;gLm60*4o=_ApzLHqn z$a@>N*@tg)&*d_uoxL+zH=OnUP(xQ2(nHJ&1%X|?yts4;tSDZtmw7S}pnC-_2!$*| zLm>-LD&P`Q(7_W!34`(5xHSh2G&)25JzR(S*RoE1H$p!{DPRX+!V|*do3vKZ`3uM( zTb19M8#cx}$bPG74<&+qjRTJj#SHzu8enxpK@E=%MTH_GB3g%_1N|Z>CTu4b7<>f} z4+lZW{VvV`PYA_FL}o(-BOoBbfq(uF0u_ZFP6ZEZ2GCs2T=qy-8xIc`5iTxEHwR8P z9!Dq-r}=eT@^JG6x^tG!j$GgO0^dVDe+YrW@U~EED94X&C^(YlFCAw~cb9(_f(raR z9P&%H9{+swk8G{npy;r3#)B0R0);iYKtxoaWbokkK0uccJ{0T(Bs;JTuU@Xr#C%qX_Qv8P|>oCPY>qv>70^W;s;d zwgVYZj{@=y(5J**xX0>8Zf@;w^6g|K$_PJKEiJndj%x}tj?)Kt=HWVDptt;2zSO4o zH&KT!E(3e~x;*3gG$r3GA8MzXZ92cS)V{=08$brmEb2_?*{Y^($bMvYv9i{TM-|@} z{&Q{5rw!qJ1Rt7alYo4`jB;B_dB?S5OCx@8Z5(PJl{!ifk34UR;-UCPDHyNuSH7A5 z$yalRwqlMlS-Y5Z(dH?4Z)fcBeXKIIj{P*v<7%>fTMr=LuL_HB^O@-MCqrQ5;UPxt z>WmCC$O*03V03sV``4JZUr_)+2|xiDB!k9kmrg-dL9~ZRo-zDjU?$c_N3Fh&l23%h z%NFI1mCsoYW%uLUL?c$`r4{7|heCSHT0Eq#H{cztZ4nA8&f|%k2AU&-JBT17GNMm% zv0iK+mm!t85}DW(nmLX*$xojWIX{1`m&79@?~&ZgYL!Av3$JG3Sn&{#qRaWUf9hOv z0Vq2B4jys1u?18`Lchp%aOuPZnRjXRa|E4v`yI^@FNe>o?)7qoh(3rhOQ(&=c@b38 z7~2&O^3xa|P=6f4#nz8QoNPJoI=HIu;dCy5k)NC%3$y_|q6bTVk{{z^CE&eR5vj+1 zC%*>UHTeM#*yZ;-`9EXXtdX@4N`F=t!OiguD6$#{yHyn?>58Nh5|TOHSRA?6?bLEp z;0+%n(;}fu8U$|vI0h4FD4hIv=YWUgj;nmLe_XA*UUYpoHJn2PEANl0fC~Q?|3GpL z7r;MY|L_ma&K}e<($w77&VdIi#KX(O!^6)D1)Kvf^q0;5L)Y`)Jjg2<;E7rv0sEF>gb2Wp?ePoNh+)S4DJ{9kup0!%v5>mUzVn;B?Y|2D) zbzrphfYMa+fu0vGoz|Us{fD8nFBx|aHttEV%_zWCPWcF-hI(5?Ek~b=p2x-``KIc} zVaQn`k{lXd9V$VV<1VfkM3ztJY}9MC-G%&)c+l+FD-OfAA%ULme|ZqF0f0AMDDF?- zAwv-X?g5>g-#HJC{BNB9kB|9B7xb%-3MU?j;M;tSo|?*ute@3ghpmQ+4E6iLY5?`l zP)8`&k8J?;bibhgKP3(K1?YG%7|jSX+@ z68OxX-&$`y3!b*i5VW~lg}_n+7i;(paqU7b0dB=G+mmRq_GO6|`y4zKlZka5RzO#A zy_A{+^I*64M2Joxym>QUtw7UQ>a=c>65nGN6^SeMupoX)Y>@_e=0r8SqfPcJFM8Ie z7I@-gwS`5D`8ap+$bym9Hn-nsffb$Wk?S(4t!Qh-B4Ub(6ACsq(F$vk@qwS*VTdPUcp{{iZQ=Hu-jHu{+vsl?;$Cn+6d^j2XgnvnO zQ8*Ac%s_wI+Q8dcIT1d&G64ji4nQUufQ;{7kUq1Lvtv~hBFaJII!a%ZPjaxG4%uYSR!CVy(j>xfG8DF)!ry~MQ;Fbq7iakg~ z0xAdF(RAGs8-j=RBqvHXHFF;ty@V*tv!7mu3KT9Y>n_PHoPK*wc;+#k^_X9n>#T-a z1JS{EB)G2Vw0cv4Btb(6X*NGfSns51qV!bJ8k)YHde6i;rr=f^H1ckq+}$Saje^dk zQ^nbScr$K<$q>}q6Ud$pbh z=@+_Rr6J@zbIxHw_NJRsd%^pYbq`in6y1ACDe55V;I3d52~m2+c=#%Z%$_og9|YqD z2*v@$0_yO4mkj|52N3L^@pat?{zv@$%BL(-(0m#A1C&iDI={hDIcpCmDGYk=2VMX^ zIY8~8oIkb!KGFWkC+oj;YN0|ukB3CE|AD8!AAU_IU^fg-;;^Do!2@W50%*$R{N?B0 z;q)&*2haqw44@Mabm5_hi15IFL4VlSHJB^}Zy3GCu9t{qUqgTFAom#w_Dhtg=mf0 zC+~6Oq6G?nleSlGiZ1$|G|umgSopqH4nASPr%qZJN*YSur3l*82ES1mhW`og9`8naSQSa za|;+i|33`I|3 z>M$F|(}%DH5AZ3>H_jzmIYNfnlZuK~<|2rd{O_t2h&YzkZ}V|BVZb%;&MR!x+CN~J zdOQ^JPEMXD-6d)cf~h1E@xsCV)tz;7>5ATQ?fEFKZrVD=fgF8po1BXlR<14`a!)BU zq6mnNaBL95cY1|2u-`@N2?Y(OxF@8r96_f%>mP?XL^z%6P zAIG_I!tUSyXY8S)SiFC`FJO(xM`f2%=i7jmlY|1+{9B#9I(UJtU@EOHV?Jj4mTsL3 z>-B!gpu{@fK{7q;P{_#%*CC$SIKt3w2y*+!V$Te9;zq~0^d0(4er(^Yq_>y>kB%fs<`3Pp7YIs` z^!>tGH96(u>OgnA$Q8#XeO8@x2ddcN5zTZp&q12y>tqlKqj#$@<$bVNf^aB(%kHXi?R3%!Q>`z&g1f3tmjW;5-^HVvTYu5N3ZgQq~BWPx`*$HcZPVc;l;VCxpAn%s!6E@sq zXD2e!;vx!gSV0=EQ#FMtmn(q;?!y?2@v%=crMS-TOvzY`R6|LLL(XrD_j$im8z*z^ z!p~%reN^gxYNAg{!`8s|nVJUN%3)7D{>ABdjJ6?)jRNA>62wJ3Pzc^%cp&Y@MYZ?v z;fd_n!!Efd!nR6K=jCzycxyDF`aMmRj(A#7>dQK&NknU@43gEbO)VGvH{;EP9Q1B2 z%J-K?mmv`K z@PEShrkK}?27gz4DS`!zT7$0B-v$JdI`LqNZN-0Y(|{r|q`bHJry?P2Kmaz3!GH|{ z(NL2oUzE4B`*g-SM?T{=Bo?Zq01-EekEd63q&1+h31#Kjz6OS3u#E+W!|L+kvo3+9>#0N!KIWC%!ma z9>6k)s8B`cpOr)mjBJ-&k8Ep>=LiZlKY494C>AX+K@YwYWGEY(Nc`&XYG8e0s92-L zhDdHbh8!LNK28n2Dm3xx3iG19hK6D&szFT&O zV#N&!V@=lkQ)rw7Jh<;rFPc1qW3_%mu2qxvgL+ zlXOQk9GwF)yhp+}<++g4B1gi>zNl!3Pr;+dN=zl|(iO86#LB;aW7^>)R}~1R0lOsG`mi(5$|1`lYcDLt})!Y zhV2mI6e$yBzK5S%o87+U(dR7CWS>~S-26vZ_YW2-#|%>BkkSa3NGb_C^hcqS>?Ut+RnDyB+(64!-yqnlC4+SKJftMM{l5+T~I} z1b5DPjua?7b>EwP@{lEKcw?(Wp@I#nA{fH5O9{=hL-H`R4^Wj9UGupiotBM7!e6IS zDW}S{h5Lw5)I-F!YTKS8mByMbT~C^es*P^|Kekw-I!`)9U$kW4N|HX?DW;cAZ0 z1p>k^iJh1%c0y0yEZy7Y!qDM7x3{4WWc`?B*2jZFW5Y7Q6}Q~9)d?Pzd~$bJf?1!3 z=P8O3R)Ga>5G~10rx|iXb#{IaMkDnjK95bjW?3+47|7-m)FuU89U<#EdF@RI!b_MP zAv1%2c3b|&dcMlsYu0=JLJwdR*c2081|k3(1(s3$Zbty<`T%+m!W7W~MWCl}o&KgM zQki$H=<<(>Kn$D>cw7zk9Pkgt?@)y4VgR~;*6%yOU+ezSF)+oyK2t!q2zbct zSKVvO0&3SEX$E*E+^6bnsa*&C6)MJHaZ*i8b@PZi4M+OW7GgmPSoRGp|G7|q^9p>O z_}6|0?k8}rhlwEqz{Qe=`5CGI0EYrrG?=0a8yM6N^m1V7PepKZCjm^c)#o1-0YBrv z@DpLziU7jE;eYv!7OR;HJF0*c(lF20FZkn{lCfMcE(@?iX4Cm+!Fg?%j@UFtUa|3Q z-5N2Cbke=eD$78m7g1@=B5S|bTI_19zD@KihOo1CkL;@Mf+-s zjXno`e2JVfR@o=ifuU9P&NUne4{tZ#?kh1^b=l!E^mn>Le`)b-8% z0ey@!2fh}9TeBi!g@KOZ+6qf?{IWxKiD9zEf^-5LioV?e_(RK86cBC1>Z%_5mPx-V}olLeDjZ*yefX-ZWrn0z`A1|RKkB6sGi+-Q7P8iSi2KUGNX zTa%2p5%M}|UUVuccegXg={{Idm@MSJ*@?(Kra_YDjxx%hmG^9P4tnU`ZCthVA7*2- zR6esVu7Aha7WxsB8m9rjlhWf54(#ENF7u|amBp{r1y;2YIpBX=Aa3E>Cnx`61AtHV zz;6>c2cEkEkrh4pt7;KzAb?d7U;E@8LNyZ}kpNPef>d%8(yTc%p_ye8SLAuH7A#}Ldm zb)7Hiq@y?aksCo?XKW*1&h;tmNtGUZj;f}>N`3GY3_Ps?dt&474H`~0E$G@O2mNf& z*FO2&1t@s~%qJgB{A|!PS3t>uQp4d3qwaf63aQzGW6q>$@*@s0^^K>3y0u*HIheba zVA_FSQQy0Kq;zDhiSYR*WJHZBbxVd@_GW7`8LfZQBR5r|l_UqBGM@{MJ5vO0tzxF0 zeQ_h}0^tQoH3{SIS$sa6N9tX9K59%7XgVdo={k#+{24U_`#w>twE1f#7epRM@%PDz zL8Bj9H>$kPg`0671~8bmv~apM%R3h?tQvq1Uhdm#uVIoZ-LY<#$&t7zm2`IXGWtEl zk3r`^{{*Wvgz=Nm1pFSSINF&fb;qD~W1+@GU$NG7!V$3A4B%j;~B3?fIz>~&0+LJ{I3EDb%v6o(io6W~Y`k4G)jSWN7O`=6c!AS%? z1jpElddqcgOsx%Xgjc?bao!W+t$M{bNBfx*UiOH(p!3cjy?!to#<=B_gfmOXG1^*W zpr#`3o8&Ip`)$>`ayQN0zPZ<}p_AvX_{TV7WE$qe65Ka+Wbb8eEfnjBHe+Sr<$IE< zUs~_@FGnhme!+zf-GvvmP$wH>)=N(3?zeGy%-FJlweqptyDGcHi_APjPh~b`u@o~$ z4nFF(-veA)jkmZqlOgfMfDL$``v}L^vi=~*tU&Ww&|NL=I?F&Y$B!;5pxXN#j zfk`9Dtz*VDUA3pUlxyD2k5RciC*xpy(QV>tJxN-Q59+IGjB zo;kLrkUpv=*DC+o7~R~**tD%Tfgy~vzV_Cqx*iNEcyLX@GO!nESm?%x)ES~y- z6IbRM?`wVY_=ht!s9#p_P1&d}B1T^b$Ies}qkE*9490||sV|+g?oWAm<*c?aiEKIt zJ=0x`sg6L_h*^L3R$v-xqEdL0^wEExJ;r~(T5Qf*l0Mx{qo)_V^NXh`G zH5%3}-jVzJEvXEP4pGmQ-&pjt5=kIWYLO)n7@@ZYizYknc;3h|k-x*+-`|AOs_dy2 z??V<;Jl@&H=W6L)o^;zRpSfhIe#VtD!uEE}^AY4-Sb2joA)qke>|p6nUkUQ;B!r=* zt?egTuDvf@D^TWn;GS%fgzG0-n0B|NnPKG>`^#6hqMJHQ=6jG+B-A9tqqN$Q^4CJB zD=aGeh1NOqsdqceKPuGU*if5h>Gd&sY$7W3cv&%~9d=5+YyUdW^5<*DC;d_dFvd5u z30%g&7%wmI*C`>^=mJ$hbIo`~+f8OyuBA;Hzu||s7w&kjJ9S5i?sTwyp)~|1?UtY! zkxA~;ywIgZ=4knrsrB-U-FkymL%v<=z1^+JY#C-j+t-nTnRuV{@4hR$$S8P&Srw0# z>{MyNw1`FHgK&Efj*bzR?aLVggQ%(A7O@wch|aEgIOw5sT{@YmP=SSg#keL>r@Gr=Z8ViDX`5;3W_OPRPKLZOR4?N2PCHdU+h~pR z7u%erfRFbshu3O4U)P1?N7o{mJQ}gB8v@BgZ`{%fjrL_i#S~(DKa`^j_tfA$=9^*U zsG7%@$X|FWavE3)=Fxme=R`Nmq;vw!o*jo{xk-0#ty;-H&3Z6eSjZdZ{<1w;wMxPn zwL`DTd77^~Zt|(H-As3GupCsHN+CS$Id=L?EWw(qi3Q7$$96x%c8%Q~qh&H5MYdQT z%10cwpCoJXKhCWoWoY+mDL?^ zzK0B5B4h@qGKuq~F!+qGs!OQsfGqKhI5|r^$w@H=DlQl%Tu!RyPTcd_;f;^?#)gKN zk!ku4Cc6QNHBPpaXJ1THXVI?rvgWI$Q$F#I)X4tE)2WQpPu3cnYD@{~NOB|6z296; zsZ}C`p#O`#w}8v4=^B17x*Mbw0Rd^GyBnmtQyQc@q>+-65)hD(4k_slDN#bCQ32@^ zlsJ1|#;xA>PMPFe78O3h2Gc`UJ;VcUJA%YhToPAH6c*Tq#!GD>>zSJZd&0vfi z|AI^?xwt)iv8;)ffM5x&p~G;H000wU`WqVWmgPgQ6S#hgOAw6<=;H7Y5RLQCzt&Iy z6P`d^(t>fx14sf7*pn-}JHZ^B-4Dm?YV9{lk&bFyg%`+mHXMwwko;p#sP69Q+cmtg z{AB8jHKbGXRtL;ZHv1o!TU~)-BL1iB0GM#oZ|8>0fS6ePzCi}BZg3!hQVwkrq^Bwh!q$0VFg{)EpFJ@wmj6(Bm<08EqsR>Px@QuCn_ z8jIs*6zyKyi&HF~%WK$rBjK5~&9%Kue61=~-3-w!xoFfbomqAc1O1Ak;}G43`a9|> zwVF4z@a%SxLuB-F_jXg88cg8i#b?!e?I`QXLhy}tjs+J-{mRpuxE%pvB3Q5rVCBjvkuY#VtsXN`l4CSNjp zH-ogtOl`BOH4;7}CO$B-W9zuFujtFpP;1b6P96Kul-p;@KsvJ-arAs|7EvB8qTBju z9I#s_skaHjC1bu>8G1uhm$gtuX^#x`p(T|cR}b@z2(L%47!!JGx^v)}5kHo~$v~nn zMNjvqp7skaRbpX!)=&GO}AE^``Q@Q4_Jnv_TF1P z3xelzbaZ0xONp$aUS*&xnxtn*eOf5YLMDr{x7p`x$_cv)iS5vrZhD+Q|Z3}il zzTxp+Dw9QtSMPABfB0ddKbJagYhqcqzLh=480Gc6ob#qbt#y%KE4_Zp(@Tr zStMggRUl>+Y;~KSgk$kgKTM3R_t0F+ zz#P5rZZoRK{l`)QSD7dXlTye!`Hd{jx=-YV%1{f2I?h}*0ES`5w!Mh78^6NWFm^i@*c})v@qj3 z^=qk*)m(WYNB$(1D>vQsq4H>a!SX`w-jG>HhPlGaIda>o4*w@D^rYx&#xT4{k5$(w zvKeJ5HSc*@IQL4en$*&-##ITm=4Gf#6X*b@Q|bnV=|yaW?EqSM@xhQWIZU+R}TRK zj0Zr|-y|x~DgqL5R-i)(W8(7r-FLuQ9;8P9?ktad_Da9>lSwL2(`F#TpZW(@p(lm7 zUr!3|kalkP4e+Gk?*A?Ch*iLZ&Q3PyNr9%BVY*jZo3KPDAd-z`c?d29X~KX?)k5=b z1*+(_jQ2+9b_n0I`Sax}s{99)r|~iad*WZ7vDYU&C1Ev4ML+9gp%1I>+^1`Y6XM`2 zfXx@b<;*dEfz2JBe;+bh($!&@dQnIx(y~MA7hJuRzkx$Be&av{c4(a_BPigESF$wG zUDS70Om;Fo|_i-#fnX%WbKpvOM;|L_>tb_6U2 zP71)r1HfGQ$3HyAcL`VySPk265>Q~WxNeh*(5vBb7^LOCRCd0Z*ds>=*hev18`Biw1rKwI& zK}ON@Pj2sJMIDbkN%`a-B%(}DG?s6ZQMcUk^csi0d zJ$tm^_JW$_ZtlsnHbDiU?ku$iG3D^|8M_=>%zKUzwemdYeq-^Iv@tL|9wQ<`h5-D43A(f4mmfPD!d50il5zg4oejU>uj8*nlH0=p z0l+eh(7zCPBL_i3GZQ-qFt-*4-pJa?sT~J=iU2-!`T88*$kO%O+ek(p z7N+JV?q5GZ{`TtI_fU*nfG;w+xLAGvor}Hc*QaPkCYDZ)?!f&}X5S=%rsV}A{vhjb zZSn?)!l;)Bgbha)xV)9$2csY21F)5cl9Cz@aSfQU@Bzk`xB_BXQTw{s)agJcyU`9W57fUJB_vPz=62FM@} zZOlD#oZ#wZbU(*5*3Ub-et*cYmirMh4Hts&M7Xz}y1HO4oa2PG!@Z^&jgn?D^8E+w ztCltq^Nh(a_F;3%XqV{D%fww}Mk%aD{SUkcU9NS!YpmAixEvqQY|L(c%(YObDEQ?u zC4)C1j7zztV#{3QdH{{V(XGv`k;TN*6yzf_k3OXbi7(@P9x@i$@b0RB^g{>TSf zZ9R$zF#&pA!Swr?3r;OL7)n;RU&)G8kF(_=1Cn)`;|E#A0J5qBR>P6h!=aZ`?o9_| zAE^*&+dnK02`LLsd1Za)UqHGpoh@wpI*+lyk zs!iEmDa`5Oc`c=LB^nFqxsSP%N1M3&7$#llsEV-|Tay@lF@=HL3&xcJI(p1y!YIK$ zJGb5o|N&d*^EqTpI zV@mO<_VDG0>sd4om;tC`QiJ#=(s$EK#J#0Igbd>mknCW;GU(%l!#9hBh<|t>Cmy7$ z=Vso8`1!sq@ur4%1hJABr9f6cGRUG=Qy zR_7g4j1j$xvhBwJV+t+qimg!xQ$T69z3MMz3FIhJ$4OM{^t$6NPy z)?oVbv7$384Q9e#V<6j*K2k(wKC6LiFfFG1R1;q@#R?1QTuHh&yP{RL)~vh7OcIwa zBJtd4z&&VaiF*>^jWHTe_4= z#`X0+lamK#d3oGru@6w0xCa7dE4-72w7i6A8$!rk1ma5ohW>9}&I=o-1^DRWb^Okz(RA{a8`9I5Mj zC9Mp?i&P`7QqQL-QuI5@_DQ4U7miuf_1#F+h{6{I9~%Xa<W&to#PYbG*zq&WE;V;y--v+78TK~5V!e!5dc#oh7fa~K6u zt6XqGNZFovlehS0@*4{(Yn*ElGUSME^xDfF7cB`g3G{k*IvPaP8#Z8LcL(c=R>I@t z<>EoB-;g0gL;=Kr>2H`}PY!`%Ch1qqpiTmq*#t3jR{8@o@&IOv!3NxC$r9_+;lzAP z{4!-%r;x&PCC*P0G*|1yh;cfugsr>gj9X}vUvrFZ!wPf|OWRFeT57VsiU1E885xGH z7sxe!C!_WCsD z^>^53uVDl<@{O)#s2IR|mlu$_N>U@5xplaCPHPW^=@y^Bz1bclPxsk4u13oX9!Y5& zT4^k`+~#VKsw{zdXmW>^%7kmhI^&Kq*2I^t%WJ{$VJo4XneBpFM4z( z`4wZco^ah5`=tC>i*SEys<+mnU0MjwV6k-giIrQkdLRdGhdV{~JL8B$L@CQDi~GJ0 zrJj1Q`QDGe!K}KgXUf0&EJa-=!q0qCz{=V#N-%DTmOq7aJ!$y!z=!ofLD7Dc8xtmK zO3sWmENTHaRWWza<@C89?*<=tbgl=p$Fshg7F%)aj#+w~l}%!3Psk zL!sM`#Yt3eB@sNITRz!++QqZidaCJ)J?YGdR0OwOB$u{z2h*k%-IV+4a03~?UPb?N zE$zC+e#R<-TYCD}_Ug^wuZl5!Y`wWfbVfxepxa+5Z>p`>)MG=DYx}7bW>f5DdS#Tr z87|xANPL!!HEJ!n4EfR~oC~aj&>e4WTfd}HvTfP;fz>y;{$%=8$3)4i@BBF>meYvU zu?1831LXW7uA*a;z{YgWeDW*mlEmrR*DLh(C5OK+ceU&SgQgi)zWcLSP@+>!SulDv z#XkKfB_24~ogigW2f6iKaM+V^@_6TR4 zFy1KYx|QMz3km(o9W=s;G#5;nq=nr=Xr=j|BJT-gyu`DMib5gTAX~vn`kY8n*U3aG zn}4GxuguPf2GjKz%lk`2*!>H;Oh;Sfx4fX$k@hIS)f+GY|DP~|k{k-fNXxGn!FY|c z7~OE|D~ z1vekj))XsyXs%KOkPaQ^@ne#czak`FUkXfBJlMGJ%pww}W#YnQ5HIr7)=}EokLJ1f zln>5Gp;Z2%by%vMM?VYQq2Iny;Hc8pW|h$ZhSUU~Lv`;`^J>Smv650S?YIH{67{;< zE_Et}q?5MUUEkh^CRxs;H?1%}zpGb~*L4=QQG|=_u2ObJ>awKafJ;qv{Gc%&#!-7! zXzK2*h>itYM2#u|)(?c(oV4SV%NQgw`!1@;YJ$qHZkD*8yS$Qd$1LIQpS(!h;Bc*1>;#^@gDo%V^J-^j3KF9fo&_ zpTrU0M)b)Zo#M)ia#$2$dk;*poZNvn?c0KRuLF;3Li+BQw#7aHE;J5eG41d#qZ;IpaMl zHTNWz`VNkE=Df&kz>rwieMQlh&+Tv_#a@lf?1>~Cvro29F*8t=vG2)}KF(~l+`#ts zR@*#z^)%<`0!b|NPRiZ3t=F1LR(0diY=|fKD_hOtFeA_j-<-)48|LIB+v8usF1xcj zq68Z|nP~=3amrnrn5dQm=Yq#robY;Zt%1!N2Dy$q;E2J~WQRB1TP*saNrCU{N4Jnh zgzvO9WqFbHxP6go;1h<|olP)~}qWvDSa~&IvaFNE3THKoQq`g1KQf`&Ftcroh zg3@Pg%6Vt{p6%3>G)-(?B^V}6O@CZFdJPJP`@h1$GrpZ0`zZ(qAHg4R00$VsMm;3^ zS!D;Do?kv`AivFr$FkhDz9UF)Dt#=;mNhRqi$W|4B&p4Na~vgkxJSw_;#%4`S%z>G zno`epI0GnOW+q{A*WV6rn!G!((r&1u9DLil{5nG+rdvf^4%}jVzg^{J_A4B)pWBAR1(%Io%_?Qmz_U{@PN3nJQY0yyKvJ zqF*ev`N(Up*%+NhWHB``EM3=E(5e?x)%LA!M(5ekrE6%Tw*XrT#ri@+Zci#Ye#dx5 z>b>fAa@x48py;rIf6{ATkdh4!>;3SdT*$x z$l#Kp2D^QR8Ncj?CGHgz1{lBbq?F7%Cy0cp?2X>Sk3Fxyg}Du9f?}<-UjSU#6#!5xcD0)U#d;c1BI~ zYIBd@=6iB!{1yQ(@IXLoHRlO@MU_I;EuIN26r|LS=3nfvlll>mPy$Bz;_jklmqtHTOrK9i2=Nzy0-Myom zO&r&3#9u^ITK9_Z&<&E%N4bY{j&9|V-F=dA2c=EZRX{#tK-yiO386lljyR<7g|AQCAlD!B@9BZ@jnvVEs1MB_^+RWxc=d z#;IoV(;?mh{Eg(*RkOm7q2oG=jUqPryrGY`7?lKIuGu4X))g`P)7;t-Z~LCfqT?P=S4++YVSE>RVQUA7c--q`i6 zmvRHM4^~o88wH6Lq_KjO$T4RX0^C74Gp-! zc)Y~@{>dQxa$@OYW4>QN- z6cJM#gs$|ni3F0|E%H58t+g_e&{{0Qi_8ej+z&e?SwpoNheSIu60r63mo=tD+U__O z9IYxDi=avkoqUEUyNjFGUkQN~v)0`ltC*yE{K zlx_-hK@|1bm9`1buq8;74+Q(J5@Y0yPbOA0c@d2cyd)WFMFOp+B0`5;0Jk}S>30gj z?k0yrDfH@B3gN8*6q*JpWcSzjr3FBtaKQcrZqW(pA<5E8*6rCNl#GD}J3nKc~8qPjPyLy!+q< zx5~RWWL-MEK0PmwtK65_qI zKv(iSNtcV;a2m6g527~=ja9HcYhpg#5_6RpE+F?kGDjm&r@sG^EUEsoOvmiSoRn&4 zY=%muk@-=_#}%#P7m1Oh1(eJQ(P4POod(hK8ih#p7sM;ZxXepK$-qr(C%DR8{uU-* zc%Mb=$cu+4aIz;_u6wZ?f8sYtj1_Kf#P`SWJb{4V$?6={$ETQs@yql!pWNf#~+m)2Id=N;MubDpw{t zhF8N8V&TGa4LWSKqOZ`kFFSOoR-X7jXiUaNDP}NvlCZvKUw0SP1J!lkfGOjUs_mY} zy_(z2i2FIUtx=cU^BJ6~rQ@2YEN@sp>&T+DJ+<$}<$lnWrmt>#BaP`jeQD-Lem1M> zWrX_0TC8RX_SJx)kHk7sYDa`f2@f(U(PQr~NldR2*Wen2N7|L~+s&(u6NUGjReT)8 z^lsM#qe0vp<4>0<8&C#B{>FgmcJ3?C9i#7eerG@p0>A(ZCMhQ#=0J>SSXkTNkOGCZ$I&1}+SOuYAByQ@O!B|1AD?%P#EO z4VDQrZGtBai8|Pg#?Sq|uhsD)7T(-qC(5TO^K0k41Jmex8hG=gaF-vnx1-t`10n#l zLcsJp1K^mGBcKdO{*?h&fE}Z~eL)7KbN^_GI9wvufJ;Wg9$jI(Yx1dykmE@MRv{s} z)Td-6nfQLP#*`6boI9u!t=q3q6?$J=SD6d#qEymr67`f`TR_S2@nX13_e`TLf*`*PVq{~w!8skys6%5%sWKBs=6Z=eXxxm$s{ou2hbjBtirYKlje<5o%Ml`PExI$ZxyFL)~ylbmyz1(;uZ`_IruHdEG!}5UI7BL=etJ_djqMhUA%FA|>4RFBM^Y1r=&FT} zkw2&%p<0NF(^bz0#rd(mON|pry>BokW({^O$dk?gpWviSj-at)CyDrwS zf}Kq)wHuUQMJ*TJqB{5=u_hYHAw_G&Nvy-&6VXW9<}Fj+T_MzR-6$-lt(GMU_a2~_K_-?aJIBE2kQX7QnlkJ58@Xx6{Xn(fk32%wR%1uJ ziJg6+)`*a~g3`<>FW2M~Ob zF3S*9cuY+z{`j$U!TrbMC`^h)ePmhgrjZ~)&a3{o{OU(2K_Y%7h_`P$H*pr&iU`U1 zK@eSlAP!K15Qn+B9gQ*dS@2c+;W?{GxZbKh$>8LaNwi|N$HyE_^gKA(TM8C0h;_<9 zk<==W8FRRg8ML=V(x}X!%GNL*X^}`CZ!o!2WnDhX6uD+X6v{obL*_-mueW>QzwTaV zUBGkwfTAc+^1Y3^MKQU=$N7h!RD#a;av36v#pvhnQ8osNtU66E=Rzcvsjb@tjcs6; zIYy`g=btip1r(3p2d&nw$AoYI^Z?V}2ttz_2_;D8uLL0m?*HgIpmb+6Ff$WiX0DG_Fvqp4|PRpPpIKzQ7f^m-k?z{5#Q z?sTA0%tPX_CKZ7}n-%q@2~tkXCjvNpx!R!@0h5~bTNQ_+?B6=IYdIuh4~#i{{?9XM(w*ni9ODC3@=n#xMha)@vLAh} zoHexi^YmTZ(Uwcd@|d1#7QX=Yc4FmG(9 z{mO>*0Mf#cyIwEg12a6iG^FCklOhzBe{OOmDPh%GeI;N%yFyo8^V%g2BlVRS@lB29 zl+SB;Z!Z%D)lMHJ;<%PNu5@5wt)t-WWu32YVf8I$WX?EwaSsQH>zX7>(}|y=2Ffs9 z>=Cy!A>rFGrfO1G^Ad5q!#IPX4v@E5NLeb={h?YYye}g%=cWus*7l3eLWb4_OS0 zQ-#%(uM9pH-NiSbeCJtu5GCxdXIiO~XwwkrDx}Q+&SqIQfe}r#Nc?!qGl7)ey({dU z0Hzr1MWntxT$~;@_I+b6n3(7Ak{sK8? zM*u*MO2BG3F1kxkQyCvGBf8+1M!cQ5sShbS-o-A)ePCa+zuaT5Hpsg5W#f4k^GBfYwbtIk z$+?-pC!up9QAcOD%>3zwUD!^RmM)@cjc5aVdY?^*Cihda7fhphd1?wF@}|4BHRv3- zBLWH|^$If`L{&Yuue`0l`c`)siyI`a-J~({PB1o$55I-uSR#7k8IZ>hW5lY-`TjNT-2UBlmYo{Cv3F*$;IH zi~Q<+G9+CtQ-nRP~e4VgmhIH98N)HyI^befrEui3c@D4+3eTs*_i#qGGnONlJz2BOOhWsX zWoq(0Te`FQ(#MjgpYN(YS}&}r&CgRxQ{N0oMxYu(${Zr!)<5<+R-C-)Sw)MrF7r{W zBDmBx)!5R21lA7jC`4)Ne(mUK9r;!5rn_x$C2#z2Po6mk#~1kA=8g}-Qw?9en}c6? z+p4oq?Cpe5GJaHwlTS?kR%e?Ce5tk(Qvb*1XOFFdQ(t#=Fl$E;u?}2Uq)tD`4ktu3 zHE_PcCyRT9*ZyAbtykjQX$}e+O8aSu2CuK9FFXmFhgi(eJMP`+T#H;?(Z6$9xNV=m zqwFU0dicFsKu91$K^QHGcbkeF;{Kw%o17M@TTz)Fc}|!ts!+HmAsM&N141=FN>6j{ z`3-eitinFqbV@K7$FBTwrpFc(L1DiksJor}+7O7Kai{MHsznA66aYn#87)yj*Io`$ zvA|=$RUAtXwlYdx2R%#6J>ju5)O$T1j;*4755jiOUzeHkJ*z&HazzxgvMTodq5uy= za%0ZS@XJi@>-QM0JhS-T#ExjpZzMy5lPLlw$CHVLvK~a&x+?MKj(+wcnc*ExrkO+* z_#lKC^8QUjjg;1vxED5V`qNqkZknYn4PpWNY*^-L8QCeZY?DgoD2eumO9+INcR;H{ z$zUP00Di#qHv|zU--jaTuVoORck5jn!rAh*0ugi*Sn&ZmHg(w< zRriNDBdQidxOAzC;hsJ)^rPQ07H0BXH0o1b95i(Ht(vCv#Y@tBSbyEKK-i6zAwera z?!9D2gqIX^ocr<=WuD~1%XDE+B#paM(LT*{1C|zTDCR}fj0}6$0ICm1}3ChK4zeuw*NnLL3ujt!%JAXz{ zi&j)(T3xNW!LpTUbyVq_-h_EBJkYLlA^zCX)!$KPN9C~b>N1;^YH;>Id&Xkalk_J7 zSm7IJNmpZsP>M`su2Ln&U3-_o(|;g=F5fG~x1+5U)rM4k8q=rG%`)?8B75+9yi|jy zywr6H(w@u>voJw(XGSL-`x4b`3>*)%KKB5!huUxltsinqn@-|=~b+^cHrrK#}NP1U-4d)GzSFVij+^iTSF zSBbOcyq=`!yg)Lo1;a+l5$;cYCEHL~g#HQ(U#oU*%1RIxcHh?lLaa3gU=aa@g-T5j zr}$jbH9*5QSPg^enpg`DiR-?KAsKY!)Uy-@?x(&i(-2mGf)yxP)X9W zhCbQplkSzbo`fN`P-jnfV|1KiSm6=lBL*8LjZh0(@f-86*vq(|e8zsYer@J7X;35P z_~72#_;$EGboFK>Sb18**PV20lT`!LyW#<^V&ihv8kpB+N#f~VytSIP{;-^NgY_ed z^DA#?d&LzFk_dnTOn-w#XmT_Z7JR?9S12oSw*1gRSopX7fJHn23uCaoqM-Hk{C@em zuUxwfT!{t&`{}MJT;Fkb#=t{=e^fnPJ4A0wy3LIJe$R(rOdfk1XW5syW_)^nILjt; zhZxgW!RBUcaNXtX{*6SMMe~Qblldbr^h8LA9=a}sq(^Vb$q>m=ar@g`48%WWOnAdn zy+a*%uU^{2{a(h5aCpZu_lVG^!%E9q^J6a_CaIIQZpeAZGanxlp~4`4e#pJ>w8%E2lku~#`>J2X4Aol@cN~4=w zqj+%&TsL0!86pH%eNfV>b8Ys&akY{mJz34-Jy&h0yh+h|3~$O{KEajjSB8ChmJB`Q zZ*o(rY6~sqDTc(9B_4O$QpYiLz5Uq4OeikCRo2f!OE>~ttdAsK%-jkURYMw7U1K9t zb1NSD+(P3R&$mkR76In0;c(jPX*?snbneAlv@f5}Zf%S{ck*iTnaevo#+{d=zYDM8 zqQ}ZV>1m5B*^1K>8UAwZuAF)E%>B#i-kXmSY>KY(rrfi}3X+ui6j+ddotGccg+kqT zg`;mJexKw6nV$4{N%8W!3k5?8qYyuG24Bp)YUAzoIt4?cO6<;?ZCuyZ4+sZz^&TqfkqWd(h&bfx9$VA}_N8)8A9!=5{2I(-Qvj&Ciw(#r8xqg_ABCB|A zPnL~Pl8V@3BT`uDO+moQ|DmKkhmR>%z=Q5VB4S+Kl7OzDLtUmr&XmWK&O()kwS5CG zD<^S7-;KQMj11q}lr3Zya9B#zI3xG((D%BnYMG{+DZf33RO4@@KCp{~m=RRyp&7vM zXmD+YCB7eqsMkR>NAsj2AbWLBR)>fC!2ad=%cQ3V`+kw((G8tOM4sN>n}>TlYDhC} zsLx(rpOv*VchFn6V>imnS38~UaAsOsZE)TDynMSXDaT?`Sw$A_xgToNl(D=G^=gl) zk09~e%1eb)jZ_PVy2%$6LGqsMotSHqQEc0efrn$Tx1@|$XT5UJPChihwxrN)>d+i< zzwLl{hvG;nw`tKj4L+Q?eoR#~>TG7P*sAlPn=G}_^Cx{>{y1KIWu7J@+3U^9dgQ)i zQ{w(1l7c>mZJK_ZF-a$sTVRNuJpbcFv>hmzLVg94A9_1C4Gah-wD&(+G0y-n#Y4fg zD?o)&wvXe*{(4vIU`0466RrCxtFfs6+;}IZK&^q9@5OvoP+j;iE6e*h;m`8UtF23W z(q%olGJeetUALd0EBjh%>Y@^mO4hJr$8}&u4M>=nuhe>y-ripu7&1)OPhzkLMW|(| z&{SD7LT2ZPyHUE|^fA1P7wN!qqzj4qaPeWI4K<_GsaiU`#$GmFVzqcPW=Ih4w7`iw zHAzAkXtiVqJR}x?5}5u5roiMFD42wP1rrSn044y(sI0u6Kfsg*faw_sCh0m9e)Y~8 zJ09CMr!DK+1OoZlnxh$BI?E}n+bcT2N;-$NiKp`9hi`L9HD?XQ;AEH+9#N{T7lo@3 zX*9f}F!|K(KXO#mqx&pxC*|H?T;-EJ-Uq=iZa&EPNHqqJRae3GxtF8qO=-Dst*89V zX9AZq#C*O&9!5$ z(vB2M{QS%zu>^&3#9?;>tb*qTZ{NBs*30>b3h>G&_kl?V-5^D*dSnLmxv;*sQJS!a;GLrcr;ne&Zix76_# zqd0q?aJ&IIms%ECntIugw=8HCmSN(4LxF@8aXYt6oYy-Z8Hbad2@%)k#VbzV&pUV6 zg!j+MSD)Qnf2d-%-SW8E=D{FMLF<{w%<_;*LX^A~_S_i!L7UD&ZPR#G+_AA6LQFG*1 zjoDw?`)Qrs=NK3}82#|bsY8KiP+mNB(BH11sIb85i%Z1YWukG;AYbw?p)aM|827f~ z{dH9!YFfJ(r{NLLEzi&!AC#S71{rpR?z1AyqIj)O}kHl8a4Sx3LF>x{N-1~-#uc8?rv?~w31=<&c15n z?cYNg42j2(&nX4e3N22NvnfVK9FCT*9pqArW> zv-_|28<2=MT+-T)SJT|3(l(ha;jiqtb5HwD95dRPC~oOT-Q@JoH?$Zxib3#|OU!I8 z%E)Kd$}BN6DXW-?^1wzGhapHJY2hblI<4vOv%X?s?>nh$g}!>jxBE5EVbr7Id?8@Z z8Wu(po?&Z_k*KjAZ}DtfCY+bVSecf-)U{Y2pE$ofxGg-E1iQXMb2ake)erL}+4PIS z$vNz%$4z`w6AA4|EhKX<7)fN;!g$umys^~nbQFt*I;|KByYD<OTE4SIb2oj7|v3AMe)Rg)%AlHzrlKbKg`1ne^uLJCmNF0ZdARGAV}Lgo33b zhCJfl`k1qX{EL=xA}Gx$dow+*r-nuHRwjPw~zwXDR1KOKG@y%X@j0Vu&gTa5-UNrAio%ZuaOHh*o^tFYb^MyL1O_MLMqw(Jr)}6fmkR2C0<+qOXBb6J_tv09FTX!?Fg`x zK&jA8tAWV+UeAOxzt^)aT(F)M0urxI{;{3`Jt06ni~m;cFpr7Aa-XiHf*?@0j5gS}q7s&mQ>zn*LtT{)ci=1mpskF7#vG(m&L*JD|N8xZmtW zC{YLPb$;{j)-yI3*7tUH9Xy~WF44a<{p9^`^^9{E)bzF#(DbdI0lI#!XVi}TKi9MG zl7ad~2QM1a1$~q#6e_9!2JP)C{dax14;MU5 zEfT2;Y8v`U)8Bo_8&`dHonwgk}R0PMB> zuV*UXLIyQ0Lg#ydr5jxb+SD=f@A|N)42NO_Z&Dl7)DLL-gAdO^A7guNfK3`vU$@HrU)e4f4*}dRx6>q4 z3xWyC`StkEcDa{e!EVI=DbzfrprOY8J=7YDz-l>U`0s{V+IsUsQoNupsLAKSUz+|N zYTR9*rZ;teg&Lsi=TM__js7{*zDou|j1eW!2Lt*T&fyJedW81>$yvFeX^5>zklVlw zj$ne45%#Y$0j$XR1F#TrKYeo-EVyknEaUAvU>q=$U)%*Yl7_CX@N*=5{r%7H{Ga{& z>$cHw;MUOG$S`muXY^Qz=mOZ35?}W$rjW1zcbTCCww3;BIXd>Y-He%J*rmn9fQ@8; zLi+#V0oE?sb|i=bU^|$gwF~~Q+657MqypA1H|}q>3x4epgcCwfZxE3J12j9o78$&? zlRFoZnWKY)y@>;ygM}*uLI!~WWhaayL9Bo;f$6K%uNt6j7~M@XP&N&?G(J2iAHoSp zm!k8JNC^rAWJ9I@v+Q!saFyPZ&pUaE!aKzuGB-BBaA2;=offxiL{*x$9oKo)^(WC-McNA6_bAwe{OvI5i3 zxdVLwBNJQ)`XFWyD1TU}55yr7FdQ~_VSl3s_!;;eFqdDYfY1KFaeSo*0vA{bVS#19 zAdsst@RtBL;lOub=)u1Th~QuNf5Ze>2RwC9ASS>s!33>wKeyJ^t|5g>~IzC6pp zNAR<+jUV^u|1^I7)A#}F#GekZ4$p4=>Eswz1D+hCh5@Mk=i~?$!hhrB2$u39P|Eax zE5HOs>E$I9!*Ixm5a4`;2oL#+WpGt~VE7L_z|#o$OZ6Qjh%?Y|0n=A>|A_u}81v#a zAo^hnUVz^Qvj+zU%$)}guwnBb-$5Y%cN;d}Jpi6h!SvN@-#qZ$ci%2XK_7hm9C-H4 zi=Z#QAE2xuz?=Wj6ovcR6b%{&qb;r+{5>cQ0&R*~JN}ENC{)MSynNU3fBxgo9)tUD zMjP?oZ!rV+bIiy|fH8yU1eP#Z(m>3BS7*?e`Hu&FjhX+B2O5BSo6$Bt3ILb}Nor;k} z)1!pxsjp4niLa{3n3-OQ%GB~MJ?oo_Y#pmK6?Lr zfgt$^=H+qXb-e0hk*TzFq?AaUPZpcBF@>(CpJ7`Ao(i<(TZ{ILbqg9ZAbT1a^sp|j z5BA7k&!dTE*AIQ^C{vK5e?1hHfuqt!-&j%yAIB z2!7-B;=634p9z-Kd8Vv_>m3wKI;=FU%Fv#xEmK99J?4;@wjJb^xt=<;fccuN({1%h z@NPCzrco;gt3OOAX6N3H=}m-+kzl0ziP`~6J<>OHINw>r#<;X{JzOFoEh4Ar5~f!- z5-E?XKE*=zE-!Pix6Up9nEtAvwv{-?m9);U?7?=0bkOaOOx1}tvFeR}96prQV09@o z_~R}l`_R$#px;Im)!Vrl=)uu-T$gXv1@Qv=@^aukbaZ`Ve(qT)4~MS@%ngR)1*UR2 zUH#JYiqR*d*}nP9l11;x5O-zCkEkmpzkIg3M>Z#`8}znMPed(RC-Ssk)2+y-DT8Z) zA+K=G(Wu3(L~(i6?#;?-*tBKF-F|^`5tt%363oE6J%Ue^k)>}1$zB9JV5~d$#dg?+ z@ypd*;>pmuy=BoXh(`GG`N<(N9}I73!(|`^_A5g{%ac65_c=V2pw;+Bf7+-g`2iRt z+hM;32?ISa>Ujc=u5a=D2$G#kz;aL`ZNdNU3z9}a3%V-`1lUdqnxMh`bAOp|P9k^` zyL$-c8%XfQnLvLTgPWz(1<-;4{(}b$E3BIfypyA&9h`%!6P%+n&}ad=yFlTGe-@qu z6@Kuma9E4}%RdVT`pQ7z;ejZS7(hRmzMc}k<{#P{7jT>j3MVZAa}6qhZ~}R^Rp@cL zfcD0L-6H=g=RkMd*X}xD2t2~$ZmgeQ53kD({QNqxF6;KsuVp)gxWLRMq@Y1U02N^R zTjt!4pFuO1@k{1R>C*czp_zjg?LQSOU^%efCD2rXKoIaD3@~>PfD(6bX(vIoa&&RE zH*s;baBj!3d@K_Pfe--H1`@6tkONF?Fi)_L(EjA=$53y)|J57MAJD)2$s26{($#~1 z7M=?g{_a=dC@CGoe-{4D)d@A|e=5x$XldU6y)-+9e=5y?$ocQ3Y4N{Qnhy0Cf2w}a zp>tC2U-D#2mq-8dr|S0)c>*flkLq*}0w0vn(f{-7>35J@KfgwJ+>rG1Yq?vd!Jw-Z zTQUCR>SCxj=706ZYdRdhpS*GFU%Gne&%&QVh0pyeyt&TudC{dx4;nM_6!QQ!=|0gG}G5T%soI&>63r;j6%oJzx7)JJ)?-C1OL=B`di;5Oc+G$ zrO1DyXS4~lGx8q?ZNaW(I*7uEln8_lr@$Jt5wsQJ16azVBnzwpybgiDRYM?%VRMcA zCmZrTF;+oF3*YSm25WgQS$&wGkGI~ifSQ;+VE<5}%XDCg!a(n&0pmKo94yhS)_=D| zFYLTxtIkPLKuwyMa6dHtR1v>+mn%5igDV>cw1Zl{+63t0xwc3Ri}(Q;R00OHAZ&qA z(Gwf;yS!YKqDf%Bqr*ipuH~S{CLMiYCq!w^%7yS@}3P z_}Ew}H2yF4-U6)3rRy8tbR*r3N|$s>gMcDPh$tx{h=ha+(jXxMf`o{a2ug}{NrTcM zDJ9(?Ac7L#JsSzp<8#jQz2AFX|NnLNIpYrY{;gSS*33P#>ei5f-k;*5GrV=vLC^vh z85Rvg$HPW}ho1raJt7Q`4F4Ag-0ok{kxlHbb8ln72#z5^oI>Vr@Dc&Z!V^ZbjSam- zL`6VRgSkNe!GKueoDTv-e;EM2@D+F{;PE3H_pk7cI}{2yqY4QVl!ZjYItFdr7Xjju z%K_rilOq%g7y>B|>Vh{I&V!TV!KQe2_6OolgBaKp1H|PA2Y#Yo5HHZG14MUcfcT5s z269ZjfE-@CXVHf9z-@EbG4+b}p+*YJWWk)V!8I?#Z!{Rpet=+J;Q_&4gmPgrAMThU zJ>r-Ows&}i4jfa%(z^t^ecc3hb{ftfc1)q`DWa3|qn8KEOY-;gg+^Ar<^co|i~s1D zx&Y@8>>Lkq_zE3zOl1PcRKD^)hiZUm34}N_e6RriA~O|_H3$*E_Y*(_i{S8mFokcg z{@p3uOn-tMl`u&hP-=!9EPzLZ1ueBLh+wIGk$$k$LbQY=m<}zqzK56EJ!EL=ijfZZ z6v3Z|+|>akn@J=X1El7l9pwp;C=481Q0EeVt67ju;x663l!Aa}0h`3fp%JxPc|wc` zVmj7O(Z$;nPkf`A(^)fkwOgwgUe~N-sBtO z3kOV6fpT z0ZT=Z0SfZ-$WWg4^H=dX9#WKbDxCBvjQ(_r<_@vcYvkN+73T+MR1oh7%RP+RMlqVW zzY~$yPLA}}8p*J}MN{L0|JEa=WExj58~o0yVX6zHm&SQ;p5);YWiXAw@Wj5H!ut5- z^mNOsr{s*o9$8o#uX)mPHk7(GkDH6R)^8(u+_FF-Hq3B5ll7N+k&+Y75JoxoD1H4<|18x9-AFjdRvWsHf$Z&ScFNTAFYUH)>`zM0 zu$ByBa_q#fFwu|=PbZ$!Ko!xZ^wijF7nP*S6WT~kAC5Nr?9utem`v~cd-Bdt-`nT< zyKA=3xe&AJ+fl2 zmkV-W0#l1h?vGi3hMNK{PC;-}3s-suOm)>b-{`5r!xxXnIH%`W3(y#OZO8jV<*HwB zna9@&AA^{Y?oh58~7rY2HtfH56D&6cO9`}&Rr%0kq>FFew4aD_Y z?L#k&OYmX8UM`J!RGh9@k>bK6fr`|H-OK^F9^gXxgX=yFSQStgJb#}19GLj?uF=5M z(qpqf_ak>8&H-ZQvHxF2+X2>?aM1RZ@mICygsXiHJACnnc2m7N{Y25W$1z3ZGSh zee|wGe;?omu-Q}p6g;;c1+dB60WQFY@k8iP@Z7U1_}Lkb{(J2GV=l(8!E=DFar$2d z&mpzV|E=J;0@(T54JHtM2M?YzW;h%?*YFa24ua`D3hsXkp^g3V=Htc)5eNI2L$dl|&S)(Gd+g^c2YYOYmXNgZp*^<$;XU>q zGPJx(oO1hG4}bR3^9GcfJq`}!zg{{J0On2O`UrDOKf-{t9B=jDN!t;ZsO zk|x<--lz$>bl7RZcTC*}J0^&hQ1RWr+A-}R16~YjzrBV(zY_=ql$c`B|8P0vDgr!Z zUj{sZ3ss0Z;Hl`v-{q-m(o8DPP^1%3(l0rnbYz?$o&=%OGzb1$#)HctL`#Uk>#r_{ zd&q#7;L7z6@aO8QN`O)|F6Pg(de4RPr9sHpSS1$N_GU8 zf0zN>1n?OEzXk){9K8j23dH`qJn4KQM$K29hzFEZxeq8E87F842zg7wIPps!9n1iT zmXJxwU(J9$WWdWSrocG(GYj<)pycI)1)GNU2%sGuCxnk-VGvKKb05Jt9oU(EoCqLm zKrzeSiJ(ahoCtt5AYm*0>LgP)N$ItqD}rV755?9XssD{k`*DodDoFX*fc(U8sQDodHY)Ky@HAwi}|Y@>xOyQ}OLn^VnEbMC=ezML z=`M2g&$pQrjAu>n)TFe*rnBvN_vGFzM}!84=tAo|^F&r51Y*kO*{EcK z@U>OZB#Ei8%L9Ib-wpG9SsK?xLRL>Wfq}o?{>L)$U*HE|m!A*7uF>U!oLS%i9EJ8{ z;L+$#@9Z?G!4JUWxA{1uRpxW}>O+Ic2Rgsrs9y6(WU#J5>&0LwpZ;LVJrhFI>Fs>_ z;}fDUd1iDU9qJ0twY=J*pnG18fE$w)szJ=5ky=0~tH8b!yK|Y5pJEVSg(<;F`HpB@ zoJ*Y}dB}~ms(hOZ(-`iNvtN2A(Y#%5iXt>In!g{GchI);?7-lI#eTR*ciHw+i7U%A zuh;p_(^=nJZmSgXB&1995d*HbJFx$B01gN0zTp0|x^rgX&$~&218`-_zPex9fjF;$ z5YL#Q{%+Yv*kAT{JvPAX{9~gJ(7`WV%u#>7`0ND-?Ol8hZM$}-<^jgx%TFzY^|s_7 zI2!)ly)7j?I2vUXEKQh6fHKtn%?$fDGwiSe{qZ%>E~7tY4n1TC+U2DIGz)0hZn);Z znM401bEqDYGO;}JeijVe*GsaWGYv*aXwOh^FkDA`y(A(q6zG_U01JYltwY~J1n18q z-}!#zGlU%?lN*4#cgBIY=IBNMc7av{tc>nXV0;hULqD7+kb6k};@-;G#sQos;Cir^ zx0Et_#Y!|F(fzv@tIOpa1c@!RkAPs}#{+`D>~Stk7Qo#@BuCssnUH&k3AzBe7lrQ< z?Dj3{+SzI2fD`;78H{`~M*G5z3GL&PtEYwen7PzQ0l~TcKe~rB;T%3W!XXpn9%=^e zA%~!S4!HnPfS3l|&}}bo>3?^`8X)TFRwzGH|6hv@XoRRoIAOttp(`3_NA(4vJuCu9 zb8ZAk^I+rn<4Ou`9A6LZNwXaRTuH&k@rl%2P4#O$bgL_ZYl*yPTsZrL{pxEJg88U3 z+wL#c&E*sJSTBcHGM-VI=e4^Sf=rUuZ!a~7q~YR_eOrw|sdCXZ)Tp|{iZqW<(KaV) zgX`UpEJ2!yyOphua+T+)r|diEGr=b&TRCn{iZd(HT@Rvm@DC2W_Zjt}?TvHg?xCY9 z@}3zzIN~Lru6BGvom~sA{l?z0I*}t-HHK*Hc6CSHa^_a1&d+DtB7j}dqcr9+;z53+dL0;=iA`I)!7v>+<4L3 zc9SXPk*O3u;qpyU zv&HeQdO=O8Dz&OU6X%h7KR=y1J&h3$AxYw=R8jXL#r4J7e1h&7ZrL+;w7A|+xJE~L ztkYUCyC$@j4mG-FE72#%_R_7Nq7DO`0q#`amN#AdH@7sj5 zcs~}e?nw~Y#!}aKUi;D$ZtVoS%896zse1eKkpaaB+CW4Rl$DI zbo0k}qp4Et?6f1m_lvJs6faH##wXmJ+tyCxO@Elm%!IqM9A_UqJ|j%}>S|}0 zqB`!&Qx8;ntg5(7CYnOa9@>_?3#5==JLZAte|cpkdn}tDeIY0tHRpNUEs@G=yye9T z3S@TO!_OSi)qyf!UF8Pon=;_LR;iE+gLYyM0~Rs&{Y|b5o5nOtV=( ztviNH`l|s7A1H_{<5egM} z0`H^C&ol9$j}Kb+`q&```q%~`*3%)uJfX!Dv|kYItH=Oi`P+TOG=ySo8D!YwZ38Nh z!-`R}k68A3LFx_|Yq6LM1qgn*tmTnUMCd79w-SS|2?sGfIq~^gRm9dk+t1>IuT7Yk zXqPkP*Mi08^Qw#9uZW%STY;VBGNfPvUHX0j8RiOg2eeer~kl4~dcc5-b zLF$J-S+?7y&kLW8c6K_-K$nCdB=A5Q!ie=`8Z*`Mr7J-Pq& z&y6-cn%5NiPhq;tp_|v8S2)oS54v+v6M26We~}Ph6**i&+eaiM1$9XWTuU4W_9avc zx&#pCUmWb<;h9nYrZ?`6&;Gyv)^^wz#Xouhv@0g(PYG8(O_09g`@Mnm_1}amAcX%S z;R;9@CGSrOS1M3cq$C1T16UORk;4gB|Bk@_eZmzOfj+20&TkS65qA>{1EH`ENqmn0 zNc#VtRDlrwOFhRfKceRc)+Q(!H@yMqdBXG$J^v{g_umotS91{fwql0#r zpdH|0)-%Yf z9tQdwKA$1}iisM~nD{oWgpkTwFJ<&O9e<^iRH7*QX=>C{XWm+UZXCQj8!-32hO>dq zH_Qu%Ijk2w;9Dne9Zl3Hm=wj^YHrQ%tbqjl_B%I_f~+(ZYS6#54&Nxp$<#UZZ9pH* zc2%)Y_37DB@7WlCkHmPr)Nb!7iHrO-Ubb`-8-pZI%?8cioTrL7-Cjv`C;R2)WGtXB zvkHIAEL?}5-kc6?pP`$Yxs8ds`hsTsbZ6%SF8uV?vr40OPQwyOnp3N~ zwNU{Z_hZVuGp${-V2)Ml(o}&mq7rpvU(x(f}3QuAo;zeVEF2KyD@Qg+p0**$mfqh&3(F<6tMF7bcq``haU~D9lM3 z+AD!}be2FTqo-h&EWn+R2eaf92mZXfFF3u~uI$eek{yWiP6)9+=1*ZhiNJ=0Z@E`6 z4|zBQg!z!K$D=|<66k;M_76`ou?a)E)AJxcus0I-Kr+e20_0G8Xx8XDgK`}pUY~bPv`mkG|0{rqR6tn<> zgZ2Z2_O6M4<;{Ty*1#ct`llDd>f=QHH_PJZEDIo(>b?Vu@h{W)0<9L9&h8+=?XZb@ z*MJL|C9*p9rgJwIFyMkkY|liUYRP$AJk&D|rvPn*mzaQ^x7G>}R6@txujO?}S9f!T zE=(4}P1M9kOw`SgiCP|7HQhIjb_sU-esSE{=?3ZhFp3{zjVVu}e_^Y&i>3LVj_v|h zbB*pIAefGU`G*CIRvXS?#1RhVArtjHFe~uyn{v9b0nt7P@lF^b%nTw5+Al2VrYFJe zCQ64EeqcAz*|VD%r0=rud69?=vYXTb78ri8fGnv|K_?r{&v<_$`m%mEDY93pYisi~ z59wAHAlw>`_y<<1C;%(vTYwe#+PiB*0u~ZCz>0>?{$QO3#P<-vQUcm9!>?ZjLM&0s z+kT4(4Zq#;&xnAPlRvCd6>i({w`%Mt!~0RX=0wF?A3wEJ3(FmO&x&90E=XAz5N9_& zF!=l;N51rAxE!sI$k7dwqlFtD3|%X4Uygy0Dx3z0V%6yTRkMyPyAXqiYjf?DCfs%d ze}KmEM-en1*qJln{n5`6emIi%sQk1n{g)9=0LuyL@i~YdXoq9~mTu@17};M}6Wim! z7j}Z89Qx|f9s~62HNRbY|3mx~_+>c{=Ds9<)34uDIom7fw%0EoSZ6F?;ppdkRn7nv zWM0gD?te305cfxaeUwXI7;&TIZ>RutZmIteJ+^z{fk%&lp6*_Fz%Ny$|3eJhj|&l) z81Q}{!q`RH{kI>&qSr%%IYAp0(2h!4!*!-Xd%ulpHq#Kg5k%! zUxQ8KV6V1z3CAf3cd7K>Sh_NJf_yXvC*;kAydZ+h_bz2nf8}Ps7q2lYpLEBs%-rX~ zRd+I!ZITxKH2NFsC06q!Y4?zZuNClAK5ruA)^;I!XDF6c?;zP?!9>PF26KJiN8e3S zQ%7`tjpKN7a(p|plh%VA{#uyy*ucFDxAZZb-99{dR$_J=^Yr#j%TnD;i)nW<7}iyx%t!A_ zOE^F56cx9o%MrDBjD7W~n^_T}b-#7)tA5Bg1w|QG#Oa6EbGC;c&#P`F$I<(KqEspM z#>cGTR4Sf%=vj`%fQCRN<-9i(<YP$FsKJZ!PdJs6d|X684AR^ z`LTNM5nv-U>?cy=d3^>~%CR3JZp;?Bz91x9zSwae^@Bb+yPy`v-HBRUcSm9emFdE` zxU_~F_B;xNPI?6oO%s}ZFGbE%gf88ar8(a@r6ul+k@ZX${OXJNP(e6hNAodFaH90F^(&RL)>r1`ON()p71%Mo=$xV% z@h!!PY=tigXg%24v7vF=MsC5i{GjRJ!T3q!yWVCKZ?_+A@d%R2>q%W*4uvaPmHEw``s)Juko%b;1S2XtGg7H$WR z8WPB1J?Ll3dBq=fQX}RZGWQxDaYSN~nF{BH#f>M;+c!*aMF%r+xhu&&=~DhC_yU>DH21!y z2kXrnG#7{-=keVZy(rCZD-y&0adL_@WR3Kl%`+UNTetepJ8AOi`^uSh=4S8MbNy~6=RXfYu%k-sg z#++dR7>2wAm`oF7{J1L56`DIk1(6Gjbxc=hGlKi0-lEk~ee=Bf@REshf9;%EPTqyG zk^vQav$OG5Sl5^>Syq|8ut)~r>0O*&RKg}*@4hEeP~h?H*+W+( z1B^)gwXc1CPy2-N5ZLh^EDDbyr8xk7?g>HP-wzyGq<8mHdn&=H$b#CpNl~bBo$( zX9zajZ&u;N2RkX{vkp?+Vkiv^q~U++s9nW%JkttwQIdlBnzLwH_WD_}3DQnCu~h4H z#nkpTmj!xPtqRGr%Hl#LJ#Cf=<`PpyZC)Xw=7Y~+QF6~o5yBjm7z0(5DuQ&ohIR6| z%$1#kkZb0VxL%T@ja{}OeRFmmMfW7VdaHPPkWFs-Ir3-j@eyLR)Qh zpWUF0ZcFIZs!{(!i*ncSqi-00EESRL^YY2_%p2JLf@d$E3QK5+Iq~)~x@k$rlds71 z3QKnadu^IyP6S)yHjY#`q|%|=_Gmj@uml67YlH{^`;pqb6+v7 zt9l(Ii{MpG_JXE+z{6NQ#iuQQYJM3nndND;pGG6kph9tKWMY}DF&NMb z7WzNMd5oO|D`x^cyZ>P27+(hsxHNN(G~sUOM8&JgWKsc}RzD~JRP0U+B3Aix|URL~CT zFftt3g7`B)-ZTTK03BY5=OS}(ZX5i}4?=lhze9!+dv?q4aKez;Yu#CYdJo+>=J0zU z1JD!YAcXz14xGmWD*zHl1&j?}a&J4cw`l^@4?PejBOz-C+hh?&(E;D6W|pQVwst?U z4i)X#BX}12J!roO^>orYT&O`m36)L6(UaoO;6LR09=gyuL%^W@B3CQoWw=~Tev+#$ zbEqf9fn0yr^TQ=(1R&8K7A5o^h;m=F;O~&=0jJ%NQNa@G2x$MiMLdQe>Q7Oz|KaZV z=s^3x?y$B?if({hKBtgcE?9i_ulWFW_)4Z_L7i<_CDXEG7O^Q|Ax0hdi3`mK)d82ch+A!4WDoU?Yg4|Y1h9_Lm-6z!f7}L zN!e`pAou*xlKf)>7`_24C~`E1EGUo*!rce%f`9^_1RnjkETg3oLM{jrfcW;)0U~@= z*bAVCRt3ns9}SHg>H<7u6>egJ-|+mvyl(;JitYwyeG~`geSp|GbC7xV3!(}a7SM!` z3SgaSl~G>qGX@zxsy2T&!-s*|=za|A#m|6J(xF_UBhn~A#t6 z3b4eOe`}*TFpvK>XLJhsXtlVz{UJr#A?^-q?%R&_NtKG5c zp8}RmV7%M2-KH%)yXEI(`!#;#Q%`S)6F<{36Cg+;bU^TkfMKu;lSOdbEzuF%?L1_= zH37C;$kGfa*zLznyR*|j1t<7pMZwbR5KX--BVz6>lE?fSIp}dlE+BYC_>Z<*9XN+! zM>sS=eMpZD44z+(?Q=K{hyp~6Ih;RiJCmn@Hk!u+M5z7Pc2;_^6CLc3e%TyCdoO@2 zyMqG*1~1SK31@fP`G^!9Ab;lor~nbbhNTWGPVMCT>wo*2f=&6%P%6RxqPbD3=oPpIm}4_TpZ#pJOyvKH!5fMjx7VwX%Qj=d7}Vw z#HnNZ$cF^6yC^|+Q%Tq?YSSBl3eXXXeGG>ByH3-$kE{?Av-$* zh!8S1K;Gd!wtwFJMdA7KiQ#>Iaiq_r5PFsy9>RO(cTgYUk)s2c02v@Y7d-}3fO-$w zQA9-Hd=h|Iw(Ijhkisq^8Nz!vs-F=_kPr~TDPa={3B>>@XI)v{|E5{|7Jp=Uk=)#j z|9hW7E-MA{HTD*YAvz#x1QRaDC?|yR!k$6pxWpl6 z89$5*1{Ezuu!jbP5QYyG6oXDmoIg%VoX|-L3))x1psr%Vu%Si`BZY40K@KB>;X!}F zK)-HhXU)aK$pZtUO$s$0sNul4VVuZFC`hPC=xFHZ=osjj=ve5;=qTu@$OtG1sL&8W zK`98(P$42BA|s+8LY2zcATT6kC?*RL2?53dLqvdL8(=44$03Sz2EA-B#IOjsipyXsvgI zRNesziE{3ps-(rFbMRul!$qMQg{L(yZM?LEx9xER3eOt**8E3BC4`n2PH3zVk&vO` zg%56NVBsw-x=qCge){VV*Cb`KaC=#rcuRO`pJLS4I!RIjhD0HT6d`6n`-LHq`YJd> zpMPe^n;R}7h@oT8e`bgXVh8~N;EF-GHV`Igzu+3zSHp4Zj^GOH;g*E`j0>(&tV^{W zx!Vm_jLO9P+Eic35=}nKz-rdHOE>p&^<&U-f@#m=>J5vqFkLkc(19ox1XwyD4Bn1* zfH1bc26SN2???ws_BzlBU6#Qj1&uv?q@WHEUXx-K6eUg)b{{i3PkpK3>S;N;lc=Fg zF#6?c{7p5R+A!KD7Bfrk_9M!?gahw{ZRzO+E;PSP|HveM?~Ri~Xwoy2LD4OpD^3(V z>JLz?q{&s13gUKb@5IxiR^R5MhJ~ONI`^#7C4~2AOk28*%Q&t_B$*e{t@}w&VeX_Y zt{YS5=VRPGeWI?mmOcAXDv6V;l!-p3Lw0U7$!8O7%#zJZ=Na(mJnyLnD)xkqQ223M zV?L17=hLx8Gla}B#klxjRbUNKRa{HE2;<4MxQm2NKE*;}f3 zqde4a#zPbtudfXWN%^-~Z}-};q_QVX?nsY2g(keBylb&dkJK8chi_0Q^YEHs*2@Pc zCnxc>RB?*f?0Y>r#r(FeRmQ{YBc2bWZg`L-wkSIB#=lDD&t^)dmE>{{tRCW68PYN) zL1b(5&r+hW<#_DQaZNhn#Ca|uy>l0ykf$nl&d(eAf1ycjVwz;Dgyrl z6lz}v7W=DqjjanCtc}gsOD&R`cgYIAyPWW+@p?|`H#ua-pZgWvfi0Wxvy>BGMXH_2 zqq{jFm4;nQn3vOf6v~z>MDr>xDU&0`@f5$wwbigQ6Vr^5ZIu)A_}+7FFB-A*XrlRJPt?3Slp8{oCd% z-W~2SZzSrOifS$DB=0e#qscG5CNVL=ahCgx>tJ++7Awo*NpT~?3920xYVpuFeB5sx zY2L+;_2h^fsx?kvYi2#Z>sz8pU=nI;=}3o8v4r>ano&7nNHQjYi?QA_&bbP1d_&xb z5iO6H#~1CMSm5`E8RLu-(bomjzRoFfbqc>EukwkdOCzP1V?iq4q_gDXE1Ir)yvyaZ zFgZRIM(#$mv$h*&);=Z&DMZTL&j!RP25CjREc^njiGbud(uwe*zv2R;YE2$1|AXV{dJ9& zM$VU;pO@}D;_FJjuR6LDh4OYNIr4=xmxJii$RKslLWd^d+tQys~y&|IW(m;;+Bd$6?uCL5aWU^4Ku7If5d8 zTg`T?ir3l6ws=t4BR4ZX{2lp&&Z|ggRbD(vrD{k*<}wxBR=u}OtXEvMRXs0cIe%`n zew^_=rjE`m|MQ_@+WU4Q495^2Bl#+^rL;O1V&hsXne+I|V^*+SO9;X03rHw)OtYU_ zR_23wV&6_nyr#)fP)TcM$n9<}@%&Ac{!8x0*TFU!!ZA7?jrl#@wEBnx5|-CaHA<_6 zLypbXw=$3CIP>2+gh(Z`inrjR8|O`_p_QpVTlbBx^yv$6_PtL#EMmE!n>kvk5>u~V zkXjvT0sC@tcu63H!dXGRat7O&TTfdsZt6yo$aHM;<#LO8={d30k(R~aQBS$@QDk*% z0kfR6Ms$=wjJs5}H>EXCJe|4m`Idy0OE#X&>LRIjaHPX4?$=+gV%f8w@!}j!>0{Dl z7|!WF$w+E4moe^_meb1Ly-Ica+a+W>Jk6y{5@I(0l2wV8zzS!UJbkA0DYLiRGyP6G zwgbecB-I4hx1zL6tL(2y%Hfa6K5LN{(0A(2{;r`F`n?~!PK}gG%fdya#=tmLaBG5^ z@%XZ=zu*l=Ma(yp&Uc*a9?N3U3GiJ>D+zf$Rpml!-Q*}G`e$*bz&Orbx8lsk8ekK z5?+pc@JYD+DEmE&GAqFafs9;2k}H?D-@GWv(o{y6U3bs06~8;+y(uIvQ9p92cEny_ zMVUx7m!|t=id#TUb@M{Co4=cmhNOhojKp}t;)L>B0q&?zoMDSmF-X)ye%JUoP99@o zYjN(%J1%3Q-WW3>RdDI^T{61OLi<~%uA$>!_si5OZMm1wqoNhX7V^Eao`qTDGEs?b z!TWEHUqbKFursVh-rc0S9ZzB2qN9xQB#_wIIs3Gn!sya{naf)~a_k)KBY2xQ=K@oN z>Fn>+$uMBb_Ql~%hQCi-D8{Isaq;$4e^;+~mPni;HDuxVH7Smeo!1qLB!yL&pPkw4 z9t#u>>(+-)MK4HywN1E~?p9=|g!Ej1DZWVBxd?Ni44tgh&8BT!@GACajjA`(J2(i} zEp}$hbc{Mp%zoB-ay$8|$XS2&vJ&!`uQn~>G9$O%-_j~1)2DdET$s}SHBWUV(zBm{ zCQt~&@h#RP-NGqa$=L`hO$FB+?)S3|)*jQQH%Vv%%S2ne7dg+93iO7{N(oE$s=84I zow`dt9AqtMBdM zIBETH_!_T~BEOXVhaNRMs21{rGwLjcnhKioHzLqUHBQy^77J)sa5fuV%PhJZwCelG zr*N^4BiGBSFM@U@5jV87A?$r#V~sFIU1#{cpv5uf5l2EI&0c{E19#{?Ne7-%-; z2TF4|<+pIR><6S}+4A)+;NuEnZN99PL6Z#rj))wonS&mf-HOwVN1m zGO_x%hAUOrxyt(ZUiFiQlD>7|`Qqz&`HgUi{5QD{?>huZNKvuw^&O?w58DQ6ve2%0 zf1Ds)E05k}9FIV>j=gIZ+V_r&cGBRyva?4VW`>y>o6PsIn9~my<}&ZdydZe%MHsww zy5f{OlR$(bZa)4_o9Yhfnz5!!!p{7YZ?prR@)74Bn18N&hu!Mzn*MzSb0>cdc}o6z z!0Bl{d&jDs`?4#{UbvH4%4L=A%7$fOvnlSf3hUGoSS{=AYP8vXr=>YIRmposRH-IZ z%>C+Runbf6#EH0P`We{GcIr&I+M|qi!p>+RpCC=HjPhYSr%}i8;d&>XHHv$_8fJ-N z&qE^bK?jENjh0=cZ&F%Etz#)MCPLTrJF(&XNuE-DVKB!Mrf( zNU|7h1aL-fw3_x;TM>r0H9bXnTPP z$r5 zao)D6CO=-vRTEv+`DJUG%1z+wYbO+@kMz1X=uyt31*d4NON&LAOW4tfVhx9&9Mcu7 zxc^mGBq}U-AglhW9QsE368;qf*VSzlhvrd}m=8r^Gwvp(qHHDGU1AHFEh!a`XaSwfbx0wh$EY=Ci^RPw>`l%~e}4Y#b(&8VoPsp+D&GPxI^n%` z^*89q8{1xWxy`&_hBn@-Np5=cIU;563{Si7Nb*Q&x1#&dWkj?4R|=^VNVPr9u8p=t zRx3-!3%nReaaQPcCw+uSK5QcYs&Hr_=jDCg6neyO$p%d3XMOD}X5}{G+45~)E%e5T zSL+>s!neL?Au$@LN%~6kiA1|TdY7B+kwh!lvVz#f`cARx7_+^=CoB@Wq=#;W-#^P! zNMAzgGS8Mrg&^9tiu zcCX?U8}6V$QBofKs4-bLo9<+X;13QXWj688JyPEU;Dn!BP-PelocA zR`axLgjx)b9`(H%R?)%%C3P95rhdz4W!aec?j?kbK%I7!0yn$q9OP){iuaEx@eoVJ zCG5rD(dqgT6wAZjuT|R)s`9=QL@_AsL-)jLsD$Nv_$=6c4kzZ0jC`2no#14o#i*`f z>>Rb|aCKQWC4qXvmv?axt}$r@>I@Mv1^ls*q%)SQ43L zcd;uque^tb$+jnIB{xP&%Up?!u6}Y8ethoCY0Q^?sydZV1nY^Zq)#Vu@~{?kB(l#_ zFZ}wr?-``M2XcprLObg-bPODKV z2|syPTPg6kno?|*3P-^;Gr-0%K_Q8kSXsfxJaaN{L|->l{%S}C`e>6%n@+=!FZZeM zHz)^LTpQl_HL_o%WW>yM?hWLl$YYfzi<3!E>8nPXcE5dlJrZ|BnM~HYDw>8)B^74L zqAMA}8xqCIRORPY6M%r$Y&?CAAO#D ztKS^v{<;d6W}`7bwisywwJ<8TL0%+~I=0-_YSHxiJW6JQj;F+}mpIP!Q|5t%N#%H3 zeZK78h%#uT0b-mmGBhcIK~9>O%#?&aWU>=^E0nXnb;@L{gO8>=vup0q3iBJm?&H;O z>x%jiTxYmAiHQG6xP?hQq^j#&Wq^FXcA(Zs1B*|$oZ=#$iDc^^-SCTbj6qtPO`eX0u)Dxu_U0;+SH1%e_Qtq_~V1%`$u3VyI z*~}$;Usvttq3nm=x>ZVz@lvzDLyMb>IXn#Kl*b*lDm*?k3&(fmn(wyCJ&n zo6yuwsVrZ?kxZFqx@DhSMgQWiE|FVLTw7JkoruUS^6-K#gD&}=RhJiB81uxV2{M_r z5)SAR%db9hjSRHPreR&hW9=EdXLKj7pZi3tJ4Tsq)pz!4Bq_|}<|1*vJapkNe9Ra` zCRYP`CcZ7bV-vaJJNbN<&(la49|b|Fj!Cx^?P#z55tN5TSN>H00Z9NKqP}BCj16b0FF4mYYv2r zwtp89UMHaf?!p2_#$C@1ypJYuM98fE!wa&H*w6Y$uHktF6>-1z@c|BmxJLWwA0xu^ z3M!8HRX`tub0NnaBL((9Mx=tv@rb_)AckLo5YH0{{$oUHI3oO>>p#mevK2yf!4Llx z5s@7MW&oMPK|5;tMNqGQ4@|!Z9Y33XBU|@Ozg6bDh=)wS2;_GfT1IJf82K%3uOieI z-yx!V$Xz3hdAi7q^@|8&Y{n&Y`vtVqC{*tDLQ`5DB3Q0HL! z)<(~CaZ1+kYVDe2?>B)ckE=Ml^_kBNU|#H=Y{f?JY*qIic7Ddi z>`#Gu=S=fGpHG7G@e=NG6BJ_Vsk|GvOAebKh2hv_Y4R_i`xneZ@%-9EQw{EWN{oL8*j0#Bw((UUG}ic5?R&IQ+;7da?$<$ z6)z&en$nHspyo7@_h4=zL-M`{%~a5i${S``Uk|tS=lv}2CkuP>7FXVv_ZV=B09!x5 z$kz+4%vauRY@J>A-dOg{a2OW8nm*{>HoK0P&_J3z(nE31gzesmmZ!XPl@)(* z1CkYJzpQR{`E+o^OTR@VLrm4B`WbQ8=K`17wORQ-ImS^Do6Yp*2x)>8m*lU*UsKCP z&KW%nJ+|N|aq2qzhvOOpAJKJDr@fju&l8C)bt-@!c}pU|$f4N++R+{%VCB<;9+g)f z>Cxq(euzh?N5XYK_XzZ*eIQ3}Prr5P- zn`d^Su&6j%hDD5SQpe`qc`}%?etV2TIZDxRJH&@l$F48(QV&yuUPWh&J`aCrCqoZe zq~e!3?qXlg7-=%)cD8(*is@?k?;I3NycrwUZ$`bORIq-9bn|kh5?7(Rc$@EtPtarr z@>0maVCcJvOO5IBt=L;kqS4_fenx-&R5?6SM|l8RXvIsQuWKgqe#HG zr|PBrK*aze{>?ukLRmGCk8L^qE4hR#Wg>&JP5*c;-tUWmtImVDcK>?GWf3FU8x?nat;ysZzsy->q?&WPO_ou3 zFtMcaT-1GXqZCQ(&3!b)kl^;9?TQAxSApHx z-}sO`%@z)ICX{ef678bF=!nA;Gv1Pq9`6kv&>)G0Wn8_0{9KM^ zcsxGiy6TyN7sq*CE2poMegOI+aqf>rf4;$s{@gthaqn9$$cF`r{@9rP&=<7#96LLM z@RH84@6TYN-9WFqN}xflrszGO{(uf_^tGiZ8FE$GUy6_utks7?`121Qg+VB9lw zUN*jp-zU61S;>eLeq%6=Lacs4F8{-P7Nu0>D!q{*xFuIj< zTU*bGNyWdNectb)^XGc|rQ%lYi<=spr*Q;i-m`L;60j~TuT9?lWc!p>KYzpwds~=1 z1K~kMf6$roX#4=<__Gso9}J(1s}aw26uZw9%)wk{_3w)Lw6>OOYLtH?NELzNjcd%3 zL)mG#e3p!ZnHg9)&^L9g1k2U<&acg1ByPCxT^EvnNdl9^!=*d>dc8IyXLxwGh_7@r zo|=SoP?+clUR&n(NiOxXIM#F;>J?)q^;Vy523U2C&%N+2^BssSzDePibxgKtNre0S zV=86cxz`e#6HfJ~rB?dVB(Si23bG!N;Zc6u%tCM2lGVBS<_+zaQ@U>~Hgt_%A$lWy zS3|j5t((Qd^1diF3T3LWlgUIjF=;~?7OiWCrz?@5Ah2*bI8Z%5NVYa}^a_FUa$B_2 z0&m|i?MF(Q0Q}a{E7=?b1?gYdp+RfSI>)2?{dwcK>!4z0$xN!{-S;fhB~7Vzd_$(( zVvUTY84HnIj(VR=U>*(;loXLE4`Q>kr}(S37_DSX`svD=j9%aG6^J?6c$dehyCL*` zQ)b2Z$SvONFDPer?AJLyH0YLOh|oBCz_pql9VQGJO+fp_aKe_)0A^(Q5hL(T-l6_z zX?Qu|fc@)MOLTa%5-^-BJMe6I3Y0}!aq0{44h^xuOlMw_&+FtG=FWM=T<{~ltdgne zK51P_PVDVlJ~72G)c8=eI{2ZO{;ar6Lb3Z7GBV@WLuqvL;~fPxR)*0?y~W6!h+FI?Y?J+sLD19Uan2aC7e5X}38BvduxSLYq1%788e3k%2uYfmTnO{Wk<@yT7Mb49+PC)59Q9r4oW-80U&os6D7d#=-2+fiFA9$wKe zwWGQxNK;6Np#369oqR^PAaB8!wtZ{5CF`Cbznc6aNCZ0i)BblXBm-1&m}pW(1twW%2-?Cqv&EU6*X z!-S5=Uqy>ERlPO46x2^aE+<6M#r?E{XU#Nt)j-2uxTt9UUV7lL4hr!lCLHD{^oQuv z^&hv^7*|hf%D2Q;568NUa}-8N(O|M(+D5|64M;O9l`Ci-`kcw99C4obu1Ow2r~go^ zRdOzVQX>^5gTd>w*pZ}ORE63UORl`y{gX1Mif?q};$wDcy2B`dH#eJ9&rE1y8u z(H4NH`DeryIO6QDh*qiqabx9Y#6<|v02dv~MS%>>eu1bf56R}mRhClhC_g6NQvP->B^3pQIQ@QWNfxuLw-LhgN z^hrURG!5}Wzv74LDXXg5Vt#puo9goDM^A}(It3+aY~BjwXgHk zdFq<0)nxlWa`=uod=&s^(E2^A&dXD-71ICFAPzqB1jxV=qV8w0tK+@((!U1B1=XK$ zQRSd}s_ksMLHY+h_A%7TO6LJj^sBxK29=!F>6Xut=1^^?GB z@Yu^Q2h{T+e!v$&gVWtR|L<8{f0xCG1W%l09R!%|*`u%1w${5)QJd*(ll z7s_Fj-PZt%nF{51;{~O2!Jz#rDl%+0>lEyXe^3D&fnP?*{MO2U@XK9Uz<+rl3-HTs z*&|l|qy0ZBi$fTPxd2#%VmPn{=tI!p^&h_P-`EF1_bMz5f4_2ALi4+wZI9q*&=)yuZLjbB&i1C!?n^9v!)w-74kouCg1eur zLafbuVec;r|GT9vzChGbt{jxmJF+AJ3H_$<+tDS-2bMSrzIycYy`^UjqLN$m|C-8I zhzjS0e`|hX|6KDEh{cEuW50UI|C=EOR7MXai~q6;U!c7NR$+I0u&Nx&PT$SlhO*N+ zb@x``ks6S@4NfR~+3D%)Z4**lyfK9Voet9li5Et5UI2oErhm*%FNSBQ6aJi?PLB;` zr+b0ybUV{sg57@RRXaN)HE@DJjiCp2G~ecZ#3YUb#9u~%FDw@Hap4Xah2L3jHVgLpI11g!<~_#-gc$n# zPvx33z_{XH(_%MD5 zT^`dkj>_E)!iJ;&9=jYH2IbO2q)A|SbO`7@=;RP%;DIr#3h0tTW?((&Eog8l{kRfA z`>YvQb5802+sA?Z)E>Phz4a zn3>75N1fW)B`JYqX74CMo_0j0W=Qx5L?P`rf zxYUjg?V)s-L*6a0IfOU1tDIDcN+(7B*dX&vz+@?04HSB<%G?Anc>*Gq!5UhiN$}Y) zPr!z5u@pz|_R)3T{!s}|xm339-Bb2JxY*3YFXywbFM5{_t4p0>nh-mU+ z4RQ0js&Y4Kgnj>GZ-cTb%SqV%uV8N@j>Mbmq>!N8IJ)S5K?;!6ue z^t@GGSVAL%NSMc$3|CzEb>yq{A_kx&4Z>lNpRIWcc7r@M z>p?0w@h`}l#vo6G=Kqu@e0>3rL#%NxAtf(2tW`=Ic;ZB;4kKj|oD_i*kjgSofR?yx z4ygV01w{FB5D46c&LYYAVU?;7OHactG&11DQ{Y=6HtOqlhfzrBtToOmrAXTMDmY*0kvzUce{0I!U;I4ZQ-c?yDo)7_!k+6P|82HmhwvW>;o<+Wv5es zz~AgyD1?70Wt;vDd7Zyy6&COzKvv;Bn4^r^!GkL^f$IwaRv8$F|LXbwKC2Mw`KBH~ zM}1Ivo!UvT2fmZ8^uTYImi`?BzxTku83VYW2kwH14io0`vvK`_NpU9*SQ^~X`>+R4 zJ782^=Ss#J$m=wbTwQ;hfqqZ0ImBo$Xg!(4;UH-_e%MCklxqL`rFWTjEw|rupF2M0 zr(8dhrF`*%aMQI)$u@}>q z_jx#d;Sh(Ny3x0=%uzSHNAxpOP0d#>a7oX8Gc`WNKfx8!oRzCtpTr*cmMT9pVec;m z5o41dKLczUN02I#LCA+s$i4Z^G|<}_h-8JG-TS~`XEV)C*MF1`JYR{^c8CfSzxj_spu z(Sh&njncZ>UWUAC5{NR3tY?X8?HuXvVVUv!|Y->uI$(ftq2 z(~tG%4NhNaB#*-n9Qhh^5@~f_u?<=MEokzzZsgQ-Qe}m2l;h`&FhMg)1IhB~R~$<8 z?D`j4$TVhnylh?tScIEfWh#x0sT_MCWSXUxTJUaC|9xc{RkyMaRF|u2+opB?f*Zs` z&g(XappdfHKIDGUA(+-_5w)T&_I$L(b>Hyxvg@|2EZi~JyjI2uKaYvtBB#UWCn?Em8{GBP}ba2V(;HufV zH87k;QxgqGo1A;yeVbF*E z=Q6>zz$A0faS14bD{Dp)!vhhG?kKNuj=wpE55Otl?Iu0L# zpwCh0=5e^S%Hcyuln@|t9^W!`Vyyu5azGs}Jb^)jA1FWgX7Gs(&K2}TzZ%3}776_K zsNPtIv5d6*e~9J3(NVJzYpZ0CmG*4JO5ZveWR0UOOIj0yR?s;UA$S2Qd^YM2Cexh+ zl-a%x`B^jDD;f#RcDoWL2&Vf{}o5|*snMan%;5CuVQxzPIA9fjHF7b1mNM0Zl6E<`? z*O>ZLY8Uc^1`1NJTG{J{Z#&_No~CX5&+J9UWE+PaZgKix4K!>+Uqp88pI`+g3a?Gpm-T6rINJM)5;JzZu*LwgcE zD6H8YMX*FNKlS5hGP9vp=0oP)emvVgaK$M2YVU(m-+og(x#WVrIs3s(hI3MrqA%dl0d;IM-9&7#=zv zsc>BM{gV5Qs4~JFR>zTWMw0PLHP?fQSQ%_O*nYa=-c$@qxcDw067vs#392q#4XyGh-xEhT38#_>)PnC+Q!SUjpP9F z5x5Ey9H#iw9h~VXkFMgnHo|a1DaxY@od2T9%ZO7GgZRLs`wI3o3qXS(T$@o5Ek=7T zdviAON*l&lH}eN0T;#WZ9h{Yu$$JpdIHtyhZ^j)+8*UbKP4G>P zo_^0aSdgkMxde$bW@G*zB^yENlJ;v}!p)kNnSts>s|~%_2YRs`28C?iulAzX($c6l z1wgFGC*3Nq4P!O0ZOb$0@XhZ9q`yZh(Ej&7O{RZ1gH8>wOykykm8dR7Po`j(jVVk2 z4s7o-xhzo4*1HHa*{VhgXW);ZbbZ;T;7#yb8F(YrC*aur2U2!P3(BH59>>p+<@` zta9t@jlB7sZ)G*t$de_r2_V5;<=gJ?3{f10ZQ!s1?C{RP8D6Mml|xTR6e6lntle9( z#Wa_Nn&!@JnC5!X)13Bw^fb3n0jD{L=ta4PxTzOJD)+!PBIjTmQ46Z&RY*H}6=GSr z+Qw+G3h|q#iAJ1=fHyn>F=L1<^(U2@O%S~ZcxN0g zdsIbCFwUsKYS(1_z#Pe#NAkxf9WQUzVqoXahtmoDJdol_@yZK{RaSDN`1N5nhvAD% z-;QD;trRC5NojI-kk{+C_qgiPx-i-KBSi-AOk`^se+@Nt)cwFJKRoL{%l}Z3lh*R5 zrx<^V;q39Cp<_C6pyCvzEQk4(&r2f<%w_Qxoc#Oa6>4tnna{Uflo^<~ZqTo?H;|Ax z-f|@SGyRF?DHZoT$AI$JyQHs_;&)#4F|HYqb?BqdchyGTQ)Lf+aE?57V$#7l9Ggy0 zgDN*;fIhMI`B9};hBUch1-4lgH$RdE*;kSKr{C=wW;WD3Bz?&1RCLMPeT5c9Q+j!y z@Dx6ekFA=O?6l`26mZx06NI!U3uz5zBu zW)Is7m^pWa=@ef+SrR7po$n!@{ma;6#?sLgbYzEHLdhb`g0nl$OLGZDFmoKL4b^nW!(t%Fiwtfx5zU$Nj{Dm@{@wwEQ6;jMS2fv0HY zu915mo64s~S$V1}>Z%M+Ro)X3nQ-O&>KSA8qM^U@Xxoo!0}Z$0evxPI^7)O<+&dCH zG;PKo-5p^dWI*29;dV=+V5sV{q~Kk%H({UlV6o21*Xus!~1vez7+|9lF2`ckfDbI0{}#}pd9t?KXZF4ma9BGM2vb$R#PAh2(i zGCSznDJLhnp^7l<$3H_wEV-=Jc?YiE4)#@$cWraKfp^Tr-|_sD9==~RpOYUbnq3uA zX2>$o$}E}xW_J=)`>l!5NWEurywa6-#-FiiD*KMRmi+bTK6gz#ovH2aC24J0GK^!Ipp_)j-o%=}utHy~r8y2f?i_)gO4!@lx9zdAd^kO-Lr z_g&v`NOyahk|x%hyGiiuAi(l;!I|9Msr#Wfgc9!>mLktNsREbNkpbP4*VD${bhn+; zpiHdWaj8Era^HJ8wVvj4cauXAj(k7CVyn7OmNM_qpgv94Ulj4#CIXv#25Zl?N{PCrTfi_|`o9XRW zwxW`Cy?jkE&dYsSLZo1oPf((>Y+8uyOF?4xLOhQrT$}~jl~&J-bc^Kq_o>zGdfzSl2{&uh9I>rODx^7 z4xlj{P4MY=b>c*vKbrpi(3uUl;@rfMPT&la)exdn%e=Cvi&5-y^rXw;&#-f@n3Dkw+ zjBdX9Jkr-VviyTCF0$JVYQ?DJ%>B5nBg7x-@O0KK{B3Die@OexHO-p0Hp1^^Wy3`- zHp|sbI*ixOl13^sRM9m#8o#Y7HLqbe&8PlQz98;>?wViGl)TB|kw~ASP$CYyJ6Ads z4%?sZ9&$MLwxVu_M|-8M&R6lzi4o0Q&Lr)|ty;9t`cj<8ZeeN}U8n5&NQFzF^nOrX z^s$G^nJN$6VtH=YB9r)@Cz3=@I5dV@V?X>l8Fx-#vM6GTx3B4atG~`e3=t>2{Cw)i zw{5HSX~%O?1a^MWuY8Ggwt{+*d2PFsUwtsXjGIvPyX6!j-FXx*7@W`(&Szbn#1etF z{n`N#ALXnq2DZ>$ew6M$|6SAYg3!8~M~~LD3L&6ri4YMpcMWl)bw`NwaiiM!;!kbN zD?qn#k{sPed+9X~(B?MQwV<+h2LF`3BfsMJii)@bD!C2qgl&wHSwr01#sbu0pgbJ5 zajWc|k9MnVWPsT_3;+?I8gunhI20l}4-jUzN#zCut|<%_AtHEI?%nIfBgz0t`!H`G zd^X*?2QvO4vKuqzHrG9KLEjR^P(DP3=*!nV3u+T(czOV2tUn=x69da93K%!sK@5I| zORDvMhcaLUEeV^!Lhu4a_-vFK7E>h)?AbEVh%FXnU}@aaiI&VEw7A!q;TGEc8||K zuIVH-sjtaZ z&PrSwF~?rQR!x~Z(zC%MF#|+9TW%I*=j4;f`B_jj@lgAnY{l&%DE#dISPnCbi*iJw z2XlDA`DT9Q!ILS^{l#SSmY(P9BKi)9wkUG#75S`jNz=#hlZfU8$vOA;%OHHl@>@I#uJc7V*TJAPiY}iVM;Bq zupJqaI(b(3fb~Nu>+#xV|7l91FJ|+jf`M70woMCHCM?cLa|<|O4${WX_haqyN|YhR z^_lSreRzgb zexRx67~^w<51Gmx-P4vDYWLM7uI+j!l!yXB^0`V%r zB7t~6+w$O+G!6kU$_wZTQ zHQ1{@)#Y#=w67~x9ojhfrn4CK_HQnGV^MD|6Gp#Ty&rq~_OEzU;lE$z4^rRIc<>`^ z#X)dl?Ks#=!RK%0=w=zAGj6aBU}DPd$N%e%-AY7T{tjwae8YC$*kllO1UCwM1MU3l zjfFz^7deek%8$2}GCFhA3QF0D8W8xKISPgFFQrWXYP-zQA~^)eTZ}-(X<=8c&m8@$ z=V&X6BL=6lf2-#^Huk`8nWIL)7b1%>sS60eebVolqkqQ$V)+Ebau58QF~AHg%?G?~ zGe?tvrNJFtt{$^m5p*S20i+;NsGygPU;SXN0`x7l|H$LKqf+etNP(kL)5Sjfs&;AF z-4;XYMTWOWGVry{7f#c~>^qL!jrmQP=c)02-8ri16uoqN-F;_@FC<=+waZ1~mQf$H zb@rX#<@PGI#vO}Ask-gcjJqAp50ONZ?%}5_1-A#wK6P~G7ts=G4puvwWV&azV4aGI zrFuyE;Oe7ckB%D|C z!h^yt&iZ`-M*2bpmF=7K#Ax&4`wi~`IPp@fUVZ-H;d-P<$U61yJ#En@y`2|`)D!a+ z$Z4e&Sr04#8hyQ=n-Se5-dyjJP)6ltg?RL2=z=p5ur!)nVM zA9b=;%9QG1tdcU;R{Xs9ollc6s;JI3_XESzwE5YgL>*HF`Sh@#|wA z0S9@W8@-`l!E7p1`D|578^?f`40eY3ZvU+-1bhPS_FJ(jc?2#iB6u z9Q@=g_qNx?dzCWK&bl`X;DoU8f4oFqdajyuyh!c5&)J`PdqZdzvf51W+%nI!EG6$# z8SIr!dvaK&h~&U~OqEMo>=c5h=VX0Sw3vtQNIW=tOgiRW45cAiPgNNC(tYLE#=fVO z-pofH#P7NBCGHiArQffIVfgiV-V1}qJA4e|vyJzgMw%tj5 zb2J$al5tcN5OTg;Huu02iZLI0m~_ODw>n6|mH_9h0P%;;TA_+{n~aqsnh^*nM@72d z<>*6)k|Rhuq6LE%l{t$4kQ~S{3?iE9ts!pO|B2j5LAB8in$oS>xI+Tn#u-A`MmB)R zxG_f(ZJf8;RH0l?+vrq z%NIQl30|Ar&Rxu8jN&k41Bcxp8XW+m(O;=nIXnZ2LPWB}?G75tiUUN7ItGZ?tf2Ds z@h*6PwdTz~J(TNk`Nn)aR{#N|G}idXgT{pjRcbqU&={KsQi0SER{$;X)_dSk?hpb& zc55>28+^AQLHaYGZznk4~x=3YH9EO_eE)JUO_~o z|8nbiK33Vu2j`HQ=s73JL%xUVRR=41k8v$%-SZzvd56QBNaTIR;a4Zc7pypU>S5x79fA6l zgvZrjlctonby#Mje0JvRCM=kN3LKXB;6gG4@f^`|lcpxs1(ufP#vtM;RUE`Mz#x1! z*_Ml$IVeQVEfMJv#6(9{5d|S4WD;G_0k?Z zE=^Euv0~^vcYK4&((@~+p&5QR=F-}M_D#L0Di4>|6iw|bqZFfK30j^bVXO!|W+0LO_!%xQ`St7xTREhp=7fWA z0&l=)gD8;*OV$Vk*hcZeF$G7!{k=ntnb9gT zrH1ZMtpw1aKOkS`XV(xnNl>J6FRD+bAWvKN$x5-zrKNA?=suA##rr=>0Oke!XnZC9 zy_f>JBqXXY5~ZLX6O{kD#G!foM<1wN{pH)a#Ir`=SUU?{D1Tkz zPze9RSqr5cNlvuQ>|!KhPnpd{7Fr=efu~14Ax<+=Rg=>-MITVv42BiKNL&v_?l;9)8N-& zItvHCrw#0ob*_|UM+0!Bi0?%Y{vQ(1(Xif?l5K|n?h;jV+J2;c-IGj#jI_qnkYFJr z5yBXB4t&;)+GUTQ#^7_5D+NUi62&Mfl!9K3OHPjv(3R5sX_;W#buIO!r5_UOT`A;( zjRU$BEJW4yJypt&HD0weNEIgTCQTB#lM3ZLj&jPOHYupu8*9#C z3MsUlf8j&NE2@o#Tbi8zaH+4c`m zyR@sfTQuk01*3*SZx~pozl&xlgnzMUhEmqwTFNVVB(V`t$`^+K0T^^xIXijI783&( z!MAJi;?mMe@eS}d_-|mRE>B(CcJKdFfBfIZ(4mxH)NGeWQj&-O?zcPSP{QE(Esx}{ zp8xOjNVe<&bdU{Ify`0?_P`J-_zCqGWb#--C51KM{*Hm)d*I*Xkw}0sK#Pyq?q=CE zU}z)u>RQ zCpC-uA{6*TqHID)v3l31J37WEW>s1l?)t@7sEpjRTN1k9$@%KCRvK0c8Me*o0|yxr zq>q^Rl|E^wn5iVBzZmW?p;g85h%xSXT+7sL?&RSKoiELV4c(FQ!w*OlLLE}>U#$5# z-ajo*oz`;7Fpb=-rMf}Q7fUnpCY>S|&l9PTh!hzdf7W4kB1P-R!UT_14oY2Mj-N{N z%Q@TptdP|a$2I(HZs*7Nl-J3~;FCv7ttIK4b6&sodXqTZ;grE%PJVYbEADB(TgOWq}gKe5lCosf8mFGDS`r}%!|trmhSJSKf!Bg}(Qq1S!d zY<{ZEoY*sgHM)>8-d~+`uYylcVt4c{!dETR_p+)Z=BBzAzc?~?ms;55rh87L`aWSW z?L2t3Cf`2X;@h+WLay75ROEVdPIgu@&KT1D%AF!a1(igRb%(HcHPn}3&FvCNA~4}P z{LsY>>u`GHe#uanNaA<{y$=8WX=&+K7TgZS@`p^erb{+HzSg+=9?@|72})iav5}Ay z$UHI0@#HIVoae~WxPvu5k`I1}lXT%BKI_86vX6d1v`*uFp(tn^6DrlSi=AQNd_QZO(WXM$*`8vuivsqWo`!Wc!pITSVTVj1RZ!|IpXp{$OM=`>$ zH<@OlA*CM_YvKG&eWe;p0eLEY38e|^Y`=XEdEz_zPkHjmKUnYp3)jI~ z{OhWD%dW5HjYbB%R4|S6T|j-Eco+#OH8HF?RyOgZ!DI@@l&2NsY0Z!z(RjyI~sg*egtjy?{RuO-|IR~!H?unAuIo;-(w#<^*tLi{nX)&>?FT){&nnyBZjuj-JS2*uKyUGI!AlASK1*+#qquA7`W}!yQw`ORmn%p z0*OZuIjVm^bAkNofi#ur(hpo?gR%<@ub*|^9?_!AJFG>g6;rbpGfnq^G(ajitB~< z{{Bz%c?EAmS3wizRhP8f-%A8+Zvns$)XK`PF+_sVIJY)imE>fAo?EZK zh4!Pp`kI*!4vH(4ls-%#%(17{xt#xH!bfiR1HupvzE4@Fu+m?=_saRA`{VW9-A;#2 zEnIqDS#avIQ6s^O;(Zl*C#zf$%b&ll-&xRz{bYyq*`xV*CfuNO>hb+!zX;BpMxifyck>!Zv-rhomm;gltx~h4a4S`#5NBH?mDnAO$5J zGsQB0oWG$Plu`1|+GTIp8!wApg8nBDewS~&{Q<}F=Y7JrN*MczHJ%?Ix6U}t;)a3e zjc~_mF^(9_aP`9#DEmGXo*3W7{z>QRXq+NVd?@FRnxYD&Dl4<^YSzjY+zdwso*3TV z5pt2~j`0&^`y01prgKL-_MV{hBpHiHvdc}0yVh5b!uEjkYMbxO(k_qhE*xpkh{I_| zl6~av_a8QR<&D9p+2G#u$(Z>`$18lP?*hRxIoi)_cUB;IOx@mDPJMaNB|o3X^6`B@ z0cU8u;C@!)UUe!$M(kM6HV(rc2lh9M0&m2qEs8EBOnJYgl_WVYs#lRS{DQkZGN6FX zt|RRtHxUvX%6R9*nYFKiSlL+ zc0uXl(`3%)j_g0Q6D0F@$R^-<-b`51W!YhLqICL^w3HGy&yFMhif=y&vuR~%?XFcg zMIuO1a_ah#EBAaq((457CJp<9o9_}!HuYHw2|j$Mt21zet7}O=PJ)WirS3- zI8i~VlA)BZ=)6?x?Bc?A_-oGD2@6{_o}OcoHj#~#PCd%e{r1hV#EU)Ig&#V{_R#$K`ZmOhc+V3Kv zrNiV2lS$k8d~Ij$y}O%j7|Gba$tAf9=#PFWC~*1qF`h}M$m*duPv4m&ljxQcnG$XB z7pF6Ch6y?6m+J`Ll)C+sG-bz|eJA6ReJ^7L7cWh5lazB@!>>@e@%a*_{5P)KINT|o z3CIt|6fZt?%wD(@aUlDL)MBn5?*MDuulJ!Yk|VWL__;gU^><5aIK=orz}|u2P`Iwd zQi(a(V%lpUNZ4zttrMc9X@5wgKh#Y0Z4Ax6emJ(;qA|C#QP?OOW&690@-;&4mlcCH zYPt<=qu4=Y76S`qqu&0qh-LOJ2Oph9C+wQ z9aHl)gx+`8b;YnI2E|@mSXi#xXd|o7AprMM}oB0^GUaroVRo!^<(|H<~Ye8`(IRm5~ z6>;*4sc{dSRPVmW8=vPL=GuGu1>`y=0Ta=Mi9kIYEehuAJ6R};vR3*MH51EgP zge5xR9#tXKd+%M&l|rAxM3vMXeQ$BpSfnetlW7!|-csHW|3&dmtF!B^RwF4sgQ-b; z$FDjoCJ{r9i=U*KriQ;MMi&emm2dB6nsQK~d%n}DvWt^j%~y88%GrjXj#lUBy}a|+ z%6*zXwQ^~_zDvg@DcvZS74_@}x46MV6h}moXM0KeUBbswB!wXg^#dZ=eh+Z>cVD|q zf5iDmQz~fz)t$B<2{(E_jqlnBK#38S-h>oJUbcJfrDQ&{ z``G&j`bN~v;&UWJbnR6Y&sI1F2hcneb&4cZ8@1^2lRA1JNulS?CAK^)X$hqX7N&g0 zw&~|Iel8CUEoG(16@Z*G*x}%?E>X;UhB=osdSfWp8=Edqw1s?ty4B^Ck_Jh-T(Z5 z*vVmht57$W|1gW?UhS@17)^&`qqAQvPVAYd+;_dkM7c7O$NEB1AL6OmR5trrW095| z?poP#o&D`k=Bxc=v(GM7(LMc+$6x`~U2Yg_yFreF8sry#ALM@6$o=xD0>#N>tAiY5 z2xAbT26-QA>Cnyszl#_(CL)Vxl(^|wiC^?Z_%)6=jqdUuI2&!G{CaQaV|GmA*TJpK z3e!d%tem=w0>{~Esbo5Zdi(C5L~xpQ*!p8Q&vo&&n8|Z4=2SkZ3JK7CRYpzeeAmlY zA^O52eCEronx~#}9KKa{1y?CFLD20n(cK(1B<&8Vj`yi9blBrT85|9}Sh^foXFiu! z9;|#B$q`-5xU1$;B;=ZW0t-)>9iv3dIAUD{vI>@QP zAcqC~Fdge-GLl&|?m2`v6<>>Z84~tAA#wd=X1KcQnM&At2pSLWeA)BQ^wtKY}!h`kS8Vy4U z&K+aQy2@BLK_gTxCY|Q=qoA5A^x}+p#QC!_w~k!nCKfw9g3;|TrE^1%=!qq(NRD7x z$}SBZ7P5;6!|`Z+A~|k^vV69UT%g-kJviCX-Ww&=+3(-OCd1DeNRIiOtK~zP&Gn?Z zsN-j_?)w&x6q2O}&Z+OKE!H#89(xybrtd_ZjM>e&W*Z;%yI8v!quy9i;q@BF_bI>W z#}fDEdb)qNrANXLQI74&j`SSUnK#3qn^Ob$$jZDb7P)>%hV2%_`oj1tbqW({o>`FK zm7;sycyG{t#v^mI$a8YZ-Pt*(r-F4(4&UFEO&m&CB9Y3)y8}U7JXm9}d*o{76FKbo z=nU~M949|Ej9Yrs{n*oFGTiRMWXZ2~)YM8wAtZpU%AoejZ92dBsD;k-gNF(k`XW6n zyr=R)5AV35c}~Y8#=7{NK!_ybQ|iK1^~;*|N<2ptRey?A2fY)?pyn91mNB-Y>;6ud z`?gSInp0vX!192iL3u<+Y4dAW_L?!8fJufc2V?P{J;gjfC-PK=>(LGc1|j^al=(n% zRhBCKXLe~?JE{1a#EO){NyN{whFza=JXZr%SzZ)py8(=g8o=xC5d=F4zf|OYMO8RU zW25UL5wN!crKUy=;IHq~6E038E^8Y+@VWlwSzfKids1=sdj_-329inzUk1!cOlACh z@Q7XMG|oSz;KAs+ndp$}0z4zhHmAK?;$Slxe$?@2T@xqU|`Aeq- zj85U|kOfco(EwZrpaFY-yOGsDo2vImux56)R+u9)Ge$)ayyKPU!!kp+AM zKln)mn5R|938-*-5VME76YD?U)Nx>J&&KpkgM^Q5Upt%=3KF?y3D?{$hoRf*TKK9U zZ6R?qq75JnpN+N^VOWWBG9ghJo2X6RdPL~z0}iE?O&$snvVyK1UR~zWhZII((AV+XC4MFT#woH6# zvKX`;!}c&hJa=y`!v@6;+5?YDOWzp=1Vk}|x>XZs+uyYGziH_}?f=;aYS(FS)9s({ zo-Wjc+NHA(Y8JF*f9qyr1JVA^?w(c!Qbwk))ehWj6(aDURZt{fXBEI4>92yCBf&xL z)5*(LVTnKi+Sxr2ktK5tag&h~xs#2Wf6V_h|5)}eBO(yk1aSU21`x+US~^HGL_NRR z8FZv?tLj)_`MU7~u}VSmIoNcgo^|}L#T&Q0>_#2DiIe>|5qU!*G87Az(pofWQ+I1H znZxe(JO{gbUHED_xC7?23l`|^#+iY^9XO|<&vZY~%rc#8n`{ z*A8n0*YzMe5xE={z9QKWzIqA5S28esH9@sZu0+QzWu3*l4m~yWI_BnlRL>QK7(N zdG#*9I)473mCLy*e~RNb<22q!{KEZ$6}iLWYU@`dvm=X2d$qvF>za=r^!5juXmVru zl}XmiGq_{+I2;O*=3w))+m}*>QzSRWa!c~TlLX5f<>kG!YRc;FT+#^#UQdKP(zH$I z4$0ih=8a=U!iGOS&~nr|mtwM$OnT=4WiB<%u>$ryjGMtsvy;^yE-*D->d~)ArVLZ| z{(M!H4Y8#9lBU{34WAt=q;}up(-FZL+|f?F&v&Byf9MoCX)URTu*OkOUOCb`XZRSi z`;O!zUp_h(TT3Ez2?j6r8ep4CP_>0))RO+c>o1Fg8%tH> zer0axd@V6q)|Vv$Lo0B}-#fT3hz;~EeD-aRwZg(Az1{7L-(BPmkAK4nx*x7&pnX>& zHnoQK`E$&q#_OV%H3axi`Z-^G^OcUuR3DQ-AkWWS4SVrg-QBtKn=pT=pt_n>l%<=z zX9^FJaQBd;$=9O#my?8n7*gAkCk*Ii74qE2{LC6 zCjSijnM$GE8~4qPU)+%}b>RGBsSAY*P3nb1A0gN5$G1)605wE_^VJmEskKp}%ndl7 zgw9v4)ie&=F334VWOCp3rXOC27#{!;HzicjV+nJLqQ@N`9!#i$<4)xIgof}lHG!}G z+46r?9Mr1R@J_{@aT(HyR>FJ zUwuWs@!-o-$BPb0EafSll@JO|3Yt9?=Sp*quZHN-eLCJ<-w9*5pA^%Kq$KAm8;{Vv zQGM@YXK?arUE$o3#Z#7zecElkfj|2igj+%=#|yvJeRN{U>=CWJK;4#-DdfhJQu0R66 z0(>@Zb-_(Pe6TCxJ6)J3H!b@|x@fvX9d~K$Vo!607JPf)G~(cT_NtP`M3e5^w5sNb<9ym? zx_8*+gk(LB%4I0}b_g&Ze5W3@GgdI2Y}s=vJDR-Ee#E&FNHqE6OV_0v@2BcD;d6f4uTM%EIBE2Hj<_w9TP zh9A^Y`W1HHYq3?73XVJYvSm-?#q1=Gbfxa{xGS%d_*e`;TFn@Ltavh9QF@t(XfW7lQMJxkXAz`^T2kH0esi6jXYNXN$Ozf|ZL zjOx&M_{gw?#TW0`-W|QM)LCWz^6$?^syO6-8#!$xmOSL(H4v7B%hj&3huzD_OFTL$ zwN5wR)_j-Y{%0vOFL#^my_0n6D&u0E?$P6xJi}k7m~PqAygAB~`NO&E%h?KM;_3F# zG5XsXE^{Lc6gQin=5yJUIhCGHz-16*jWuqpd-RyxgH1?8$tCgLKuELT<&&qi_Uw~w zz7r%5^*Q$Oc6IEHgizU^&o*R#2Jg!ftpX7N!d!x7eI{~9Wk(?J@E{@v+qO6T2t!2A z*)0+25rjCEtB48^5gM!VoHL|vdqNPGFk@SE_|v`+ zaNF4T@r=p26cl?gPcDWs7{Ptf3={$=k%gH#%=7MvdBF8x7QVeyJnxx3}1ZmRD*Yr zb7790XOOojJ2GDHxj445WlAUB&Th?z5^uPr)bsAqXZ|xO+Q9iE4k1>Cn$loDVzd`riL2LHj&RyWi@VKNl`Zg z+PYFeFf9sknlzE`j;9Ae#`+WT>J2o68*ZclKg03x+uuQ1C~eRZ;p(+q>UAwy_H>HW zy+dWW_x&l$UE=uiXo`xo5(BMbfQV5wYls_XFoZ|}H|&S<I&b>%cb>@eB393hHpWClb;Ey|X|t^pv5eAolHsEd795xrsb6D~#3 z(!O}9U_NRo@eq9W_P+b^=7|)r12=E z|7PFK-cQR@3vf!h&0q4K{SP z4_bZrK^HG~HjXFg>>Ch~5flNF2WA33n@3$G>f8o$3O)=sVERQOAwv3)f*RBQ5{m z2w=T(T8mk2lCB1mB^(W&sGOX2L9*o#60ks1g=py#y@nwqI5^QkvRp5F+hNu?Ne31+ zt_G)cm$VaZkYHTm8o^BpqA^&33X&0T2$J=KAejLSk`-1h6Ko5@0iHFY*C>K3JW)AB zs7*iT_Ic5G?!`~>Ifkm7Q;^_D(zZdeb0`jdp={T2Xn-5Sf(^&}#Ew-C-$0_q0CAyu zyL^=h0;uDPr0aAXIuIsM4g?UHMwl)mE)j%N00%-BM3k#tL)_F0B6o6O8^=vnbsXiw zTHBnn)uG!+3?kEpR%ZcXXy|qsFibuGkzxw9P5`KXGhqH^z^pe6+kOwV>u%w888CI{ zp>`?LLd}9Y`!@py8u0&F1`L!kYd!w|QFfylpk6_`cFuT{O;SC581^x&l)YTuJ>?cW z(M(VDPfs*cpLpjV@2-;oDHR{UT0J|xVM+ky-S_{3C(l2}td(~Mw8XnQ)|Gd!TwmTD zjSNmQ_j51GU4&nGaAaRu07_<5YiHay^HhLZ`;|LGo;IDO1vg^nz;t=kd0$dzcpoX8 ze5f8J{M^$`r**B(hspcscYf7VA}C5})N3A$O_W3mP^*@5)HUeqC|PuUj6rx%VBwVh z{GAePyB8h)HaDM^jl=%Xh=Oeg&&s}1-{_Y3Hv39O1O`NA+JTQq0~!jSb@E@{S3b~G zgvg}>5EY`MZa(!d_;gDgwXXyX-D1ZIknv2-E;yWXbiq7l3#K{L-WG{Cf_K4zS5Qb) zkigj4!4!NiB#vckZ-Jm#`O(tW!O_Op!O_ef*Vx9`)%F6Gi7m`Yg|9O*142Fs%1huU zbr0BZCB_XRSzjDM3SOchiyNg58x1F8?_zKzwA7bc2p5NGPu{AIs$!pujR}u%lRCrS z!z%U=875=>Hxj!bmJ!##N$i4eK9pT^POR$~^ftQP%pn)7oy1sw zW$S3OKuV9cZ~J=`rS1)s=uvH_0Bv7!Nw04^rQEjC=s+QU-hj9RAX=iN@%s0+!#0x0 zZ`k;(*B{QisHI_#0ZMA^77g!IPJ7qbJ zT1u+16Kg$;#TTNXU)asWj1xMZ6yO(kZ7_Uy`gzYF{QQ?gSoZe3FI~bi8pYdp(jsK2 zFsZPpFp&F@0*Kg#w1(7%M@SwdKOzRnhU7$8Az6_e2n!@3k{WRUNr0q62qU5qQ3z>Z zL_`t%h$YYrV73rc2(aWQwRJS+TQQD&z&H{CKN|uxl@LJ;o?Qq!#BRh61U-Tr{DlDD z($Ue5Uyx4_K?~le1CJnhNDu-DK5Q%;EL<#nJbZk70(?SzB7AIoP&*eJ0|x^abP-53 zz`(@7!oBzI#YFG`Ff43vSQ--x1Hp^H#J~WJ+>2lX6qy;RRcpcga9U z`&h>Cja_W!{TR4Zf8HNV}aAs~W735UQ(oOSy(I0WV! zrAC^7;lNV?(-$Sf`}h{w?w2z;3=~Hjz|mE5w1Yx zov^U78{a=*Y_`%o`Z_Pb{?z^GubPL|W>tHWX^zgMo>5Y-{f~-D1tv;9(ASt)*r0n+ zo$FioahILcMBSp})Pd=KAJ$(pkxnsUVLi5jPiI& z#KSoDD0M=?DQ=zO?juGhQRc(10V03`((yZkW7pm11Zl zu26{=93ai%7yER^g6hzxYPAH?zOUj5=S`%_(*#TTM)&S7sp1NwcQ>XY?CfmNc~_=l zM3$3IQPi!SL+2bBpZ@IH?a8Cq%mx01o~$%+mkgf{^JdE^-d6M8$FCe2{VbFczLq?F+o8_tOJ=G+ zMUD#e=$*;L*k9+{w5$Hh5E3JNsmyj?Z19Bh9TCwlt~9mH4;oVhT0_s`UXh*iv?1+N zA?Y^hjb_!vZ;UKs%<(Tex4>DbsF8WF)jBSyD4{Bk@O??zc-njAl%EF^0=kbO+KVm* z?{&40!f{YdDBE+-;@1A}j0}`N9d+8M<#hWxKD)YR>^k`RMv>rivL6j^cUM%Y9e;be zlZZ*f!_p`|fTE+7@I&j6PDkX818qLOQEA87q#M8d&^KbV^IW2KuYXC?PCLt)(0*~h zvf&h&1oP5;W1E5A7zM*8UgKRZb0-7)@g8~I%DX9n^KlAaL4_ar&f{IyKBJb#)@bDE zyi-lZ_Y1v8uB#{)mVRTUo&T;FpB-qe(na{Hn@OlEkVSk<&qzS1q(o&NV_;bC1RWzZ@2;Up-o##ICD@6;^~lE(*OAYcKS6wS`vQ_)GSZjPB zc6E)rO2c*V1J7+5G~Q$G(d<_10D%;2mJMY^#IGaD_arT%0T$1miQ32+Phl-s~DuF_0iy4R6UdZhd!0$*qNRw+S`z%^&62c`tHA-#RY_|Sr zIs!2e#klQ7AYptW^`Ki*dDA|6r`y+jxGzO}`9ejh21{Cr$~dD1Wv}0Q(|#+ey?*(u zO!VHT{WaCt^MDw7@hc??0btjhyZEsZ#@9o41 zG;cx2?g|>zKA@Ihdq=8AQHTF=(7XTB^F9T6VNDa5yswg$sFW%3qFA$KHWK777*FAQpG7>azqaGTgi&1G-Az*)I znn8Ytq0pPhnWr%c-ba#YtXUzvKZ5f{c`JOBI};@S+T>)Ibn0=d5AH_}WJk{Qr{nLp zO)|u^bml)*j-_e#VbFTG;8CE%R)-UW>94Fz?8A)kKAlz}F;=_B!PcKwZR4^=u%uUqLGB5u6nHj{gCULQqQS^_D*7oa-E%O5`WbLgn0e3-WXM6)&EO zxw(*7<>#-Bh}h?tBB0p2@9c z=+o5|Bcu#zV%~T9Mm}(_{Hb^HjN~h;3+zqgi=UUdj(eiOaS?UFkmmL49MdJl!IHPTycinl-`d^wSeamm z@upUDW!yXT6A@5fsbZFaWVWUf9=XigaW8PJYhhZr+H2;2Exd5tGMH@9A+=ZH?`*U_ zJ>R2npD`tkQ_exP4%u5!7)3#U$II?6otl0Fw%V2IoYve!?{Z3F7}P6kZ{~L&R)>-$ zO($&Qqq1?u(_v{fU{$93%oMJs#GBZlJ+CSAGL_fNUTqH#P~L00Q_9E3!9bTxHthoI zyy!!EQopA$`L52M#b28Z8K>C#+@dL~s-#1E#Wn z2vOy=TuHh{i#rm0TAO4g&;9+2>rYzpl37A=HJ=?E(tarOyhkwkbS~o2(I`XVQzy4c z{CYhip+tW24GH-IX1mWrAEMmY2uEpd#A@lupAf56OT8i|NV=4c?5`l-xpbJv!Gxz+ zM_PMJ_aVV7@7qt!n%5(472vs=myam!K9>?Lbs&-Qx~Nu+;&I0|ErJs-2H)D;aP>u# z-81AC#Yu&raECALq3*nLvX^R)LSA#ga96OyFBWSP;2I8jVoTV2W_dXAmJeD7l*&KG zo%F{sox8RXm7{u)74~`}jJ?+B-Z0VX6DLV_x$@{zohRvK*E7CmI>RX!h@kPVg;>adEdqjsXB`1iRG;gVO=cCxOZy# zmWH}0bt#S>ab+)-UM^|pw|%@UqMD0H*Dbu`g5PxL7w;hd_+ZGU4c~8qNA}T((;cSu~bAxG&@=B@8+!UHrrel5sV5R0!|k5e2J`d6)`Zjb2~a z#EAFE=iRtD>HG4S;yGuNmrmB+Q=eKK%^D z_v|Kn){}cu;pcTIuRZXv+dLBDShI%te3N_qa0ha+fS$*ZDaXn;sP2g;Z#PxHc6})E z5+B__KBqUEAT}Lj67k+7m7qj;)?h48=VjE9_xzbPNVJAjS^NML%^cM1BH_g4`j3y>t zrXn=QaAM!f$y_C)M&gB`@&L{K{#_M@!Rw@-S((J|iw(Be?^ry-G`Q59$6q{6FiW=V zYtJ$e6j>2rAAkEL8+Vhe=i}pm44$40+Fo6>TgD0brYU4j>tc;nm@6M7b?>-OiQ1)- zchCsFE9%cjMhUy_jN3zIC?L<6 z|13v7@Lpt?^=W24A_@{tR(qn3jNn|*QHGzb20Scd6-O#lS)gJoggI|&7m4E+%kXGP?FyJH=Vzswvnh`7E;jY5FH|ADik)28DKbP%egQm^H@sBWq_NO3;MP&bKE^IR9Re?3LcW;_>{ODXzPOM1n1+%T$ zMXMQU_HcL%MxFdtgP-bNv4Trg%M?uDqGv9X;>8pT%5`| zNF5<@W~KW4u02m|5{6fchxVn*E@yWKDHU}{q-uZ8Jv)3>u}Y!ttCtP4Y%mrsiuuuQ zkTkd{w!SvN2z(p0UBr1=>vHRh*ZQ!_M#64>l2?mq_;Dk4OW3e1E!}CV5j-hgD3rB+{48qmzK|(vNS~PdUILsWjk(fV;EKzUjpe|msG`7wgiVyo1Idfv3bfb zHC|XebQNya&s=~hKMv};vk5oF`}o#E7c<$zVFZ=yER0>|6HL@J{;0xt^4=Zm64jJ= zWVfdbDCJ@!^F$X&T-E=?IP%dkVk|tG-czeo+bPl0Whiz;yO8hYJ*^T;t-X~#$L`}$ zqBwsX$6~edi%vYEm3p!3ixQ%rQEzE~u_e06EgvJN|D563n4Z-eJbvFLnwYx&1}(4c z5tZUe+lSflC(OD*&5xwg=|2?hAYIBSL}R_1Zu?MNXVQR*!eTX7R7^Zl8M}~aK4`hG zN=6br{O-uOGWKy-Gq@!$;{-qNuITX zz$Y*Voj2JDs!3 z8gBIC3(2ffs?KLgS$#U7iHMys%DF6VQAXU2*leDE*(q^@tEPW~iraNdjyge|S!5TH zoOH7lvJsS37Nl4yppVo9i^kmhPDpi<_rCt5$KB`p9lpHuT7&~gS zyDDaP@HreD&M~qahSV{mge>EE7YM;@ftt{cS7B@+Yj+cC~b_AIOCC1k7)( z>fNOoe*UzDU{kj~4#!0MnlXN1;X9!fHDa37`*y{-h_o6$nq2ck%_W;AEtbQ#YfM(M zaD$%NH3)@q^D9(*G80oCM1wQvPVKElm4bPrv+8@$1zQw<)F6pJ=qQ!2ir%rQ0guL? zTl%oY^LX$H{P5MhyJ=xAYg0yUh4WvOni$C+lBmBIr=M<>PTSqmq3ZIp{gQNu?<`n*rZae;48czm0Z+AX1K2C#Ob(%-Vb1oBV@oWVUA9*E2zc*gLk)p z;GH-SIRwl3`2Yyl$9m`t!;N3(1D1Q-Nj#mv83s9Ee&*YBtv?u<{0|TtporK1fanfI zr2MZD8=;6Ae?ZIu5oI@iNA!C1SE-wzi0Z#1`f31(I}&97HDWUq@!Y%$sK+OVz!E5G zq3=uRE_~PHzu~b3ig<436-0cV0wAjL+Wyx(wn7nAf0z0NA%N(?)%QE1pdTtk4+w{Y z<$UNDMh$@k4E?^J`Ze_Xg77T#tK5Bx2v|-s81@HZ&oIQfA?v>9gm4XLe- z2I#0O<8Em)c{7s8@@PAVSA^VE(d3YNd#m`t_>yv?c<5Up7G~RxH=ZG5ysT;>J12HV zyJ3xfApMZvf!rY=OwDOg)JbkN=nd1ORTTGpzfph_K935 zdl>1A5Z8rk*8)2FxgCk~Y-=-jtXP znP`gN8=AMWs7XL`jXsd`((-Qg1v4GJG1!FBt37i1&A07$isO{mT@=$mUatB7wGFjF ztx4r~YYO8AtSOt*_ZMqQ{tpn_p@_Kj6Wh0 z!De3vtokSKmV7$r0?v?dGkXqmhK{gLY&-kh-y=not+_pJJnI$LiFTtGFL9=ttr)T#y5d7dPJi_hyh%%oaYDz?PCYXQLMwS9IarwlZ06T%h9uW zf!WEQIRbfk*BKd%kZ{L_O*C|?T#GT!Kp#f zZLJsAx^rpotlNEb7j+AE88SLdYROMg>2b$Sx>TdZb#m>&%_Wwg{xLNL|O`GcGaQkZdLH(n(#u(3x57EU4*kBUq~cfw}16 z&a#tH1Ht&yFZ?pzf}Fga)6ckt#y!oB>gYQ?KSA!L=yY0v9tBBh!>%VU;lu(04}lM`qpgD z5RC!EZg)h;ZCD7joaYf%8G#%`96jfR!$2MvfaSi(sdvtJoCR9R1%JS#VDVR*%WlnJ zT9@qS+lG?~Ge*sz&rdg%Y`CYRfdo>W!d;ERrI$lW)j%A7r_BgPKiAW03+tjPTm+jkAkiZ%|Kd)hYAN0I5^gVS%$?h zIG1S4FK69zM;2*k~qPz=as+ ze0Cnyx5X@9)i7Ycmhm89875pYmLKa4IkIG~tb&VB`H-Q5DPt{T?(~*I$Pca^WqTqQS&HPl$+-^?OH-Wf~2;@HCp?8a1oN5CSgYScPY8a6QZNwXT?KUu=W>R^fg4 z*ZQ!;1UG1(Qn&goBY8|E5A?Kth;4QqR<^1%%-^sZ-O9;t%9~K(*4;_*6S1ECfTc}r zv*u@86eaF7O;A{S4;B`*wY$Q9+bQs|6Y5YE&bhAzcan(5fQuesuKn)5hUDjFeM|ww zwKyb*BGAUc0(FRIEA4?KCJ^D;MF0_7?G=a!6!?f3V6g||O{tth&^H6O02jQ8wIdJG zT`NmV3w=vCOGA5L9UBA&#HN0Y1OeCVfaRx_ak}bG`^F^@yGkC!1@R%=0J=22M^2%) z7r3YexanVD1N*fA3c%MFAXt;Bm-Jf$!A7yMPP!JargI?ql$a|I^%=1KjQU!G0fL{KVZi z530uvdZ&jK|7wqz#?-0BpxMLAgS)yw#JGSTi02=n5Ux3P&<2yL!5RfoPmezTu}3WR z`yMeY=pHc;G4liZpPn`U=FD2~&aN5sUl%c+EpPi*`$S*>zP{NlsPVJQ0>JVDTEahP z)}CqPPyhc($@M3#{QJ*9>%wmN<9>o~r>QnffWA;U39!z8QgVSx_#cu*fm;6jXDxqA z$<@aMYFQKve*Cj*160EQsAU)6WMEssi-HCE1o$N-m#sepNC-6vW{3SPCD%W8{%b<0 z|JBZatASrqa<#n#YaoE|TMhh@lIx!~@M{hHuWevh_{Z*--}_a$Z=kz?Z~ruo1UHd? z-vvDVZPd51p&G*&mdC z(gwmQrH82?rQiX85&}pK;tJ5>iwOcw)?5HKykG!N(5oYB)abxIrY30|mE<#I;QiLbdesK2jhRS}*N)EgggVNWqZj zi0apz$eNihCP+_V90^T->k@WJY+9S_2N?HDu(uwseMJ?mD9-FPIf*OU<$> zm2aBZC8mDt6VP4?4(q2|!1HmWoxw5}3YK}ybU3?Xpi?H%5uxcH-e*~VlU<=Ck@2J? zTS%wA2_nE^?ukK{yY=40IMR8H-m#?S`bY9z5L&q*aa5UBB4hD78D&nde%cq|&JyyW_HtI8|- z2Kv21y0WatFL&U`tHWjKQJJxHbekjgcgL9Gv$2ASuiQipMtRPHT;I(s6e+YVN zydk6Jx$grRvabToC0OaNnb1(dW-4j&Q!@oOGOyS1${~T7(2_Av&E*883D~ZNbqvP) z64=apEH($nXSY-G1ub3aW|rwVx$53uZ4w&PlTDx@aGspXQ4eW@i1DI~vBrcJq2>nPEHBXX~l-4Yj$Y%QXnn)971K1tuzUqJE0_ z2U8JQ#=4%tCwgRq48z;n3387TchEYy@h@;ajbRGGx?4lotV5km-wPq6RJU`K8M=QG zstNNtiyf4D@9&w=UW1KfRsXvyfi@C3B<#-tVPGb-vdXhYf{g@9ZUrFX3L<{rsHuik z^GMeS+W5dh?gd0C;7Hk%W3SVWa1^ZO$uape*t7SlST!fd!0-JaLt3Jz+p?fPgo78I z23}kR3OV?}*`OGF4PG$c0*oMa?2UiYf1gYL4XqSl+uaR7RbV--j#J+YR0{A3gz`tF zoW(iLD&?$_{2_93;7`wg4)bjs4N{Q@s3-%!0n1Om9jtob69~}{Drcx?XlGOdAhaZP zz)#OUKl>es?tz2)U4}oP|0KiNU1x@JhVBm;OaK`<|M|}b)PsxqU4}ns3XtLa-RF4? zfZ%tE{(%Z813}u4gbz^kvnT`@LsO?P1)H0PZbCzME zwDlDOj!!y*qfYoqSiG~~{o+iJU}FCdfSJ{g_pE$cx}#pS(ja0ux`ZE9vyBEB#1irBWnJegj^J6PzfKN zlh6&wFhLE*9rLulOIQFBwFM9*fGjXz+Xo92@oWrn7P558KO{O$90cX&^m|YukPFx}lmd4*EN4_402wB3|9GJN-_aCEMIQdgAt^s;>RU)W5)GQ%2L0DQVZaM$pAblHlU#M` z%uY~df!-lD5{MXv@B{I8_ZVIV?Qfks?)OK3s|1)_I32p-0m*He4jci*gv$Thns5WC zyp4o~m;iMFmftjSE4Kk4PnaB3!v7E^_W-m^H}IpE{+mlur!WUyk~cKv*-yiQ(@rzsl8(NwAS4U~ zJIy~23tk5aH*ngh*$1QB8`-jbDz(Aq2MJ2SqC@lnD*(%HZbaJY$P{T5Z(8DNWCQhH_Yvg#rw8&D(7%8_ z;$O6W*L&Z_Mvx(hIPu|csjZScLA_T(v#}r~Hept%fO;>L{7?1XJXP&mKQAc&Qc{IQ z{a1RYhbT&AIGHaCQ-pOjmKDLU8YsLUWL@x9-##GN5nR zcuq_|=zs2yUmgOKgk63l2K!A*it@09t&zA8FvBwiE@Jg~*3v@)|G{U%c)`cZLke0w)ld zECBr<%$}crHZ}^z`~XD1$P0fOZU37^IpCdBQ5SxV zADo55{#E?oUo6V;2e4FL{OAXtK(ABV1t*qZMIpc?itU^S{s+H=NJ1oFnH>XRU=Sg! z7msa#PoODf!SBzm)BDff{r~^}KZ|nyw>ku^D>3Ph{orqlaynN*?<*Msw3>ew<$y}~ zANs+dmec;MV&OdG7*Bba=+CTu{Y1C1Ga5^wS1D5l>X;_@_ za#3K+u>u{BLdP6G&wB%lLnM9AdO@U@!8k0?DT5hZmTh}3PvwzSo~VO?t%ma~F(T^- zwN3)Nc(7UF!w{~6q22zSS6Fpd$2M zuD$%25d{;t>tbk{fFd(ZZV*j6OTHTWLBaqw^9?Ju*#0f{U7JlQ){EwRCDD;4aET4ttJD-S((<_w64W z-O#l6FVBDE7(GGhc135)bw4uaR_U=aJIkIm;|@{|=QTdRM}dus_tZQe?cu**Qjs}v zdtWZ>3mb&0QYraRS}FZaAEJAx|MqkD2%P@@PbiPGURJbZ?(;KYPv2X6lD?H9ndU7w zG#FFNyPaV~G?{=HSdJ|Uibn!POx=+^6<(g31^SfskGXJMjq~;(`9(2)LfLs9TySHUKiebLcLIZpr?e23~Qi46L{fxgptUaoS{emVY@XqoA{UM zeTyZ@%vjut;kn0{2{LdqXhsR6Z)*~tQY6r*R_Y>Vi>>v5Si*$25X*=gl-fx1 zujk}dfURWp4(O+Tv=aGw?9Y$+z||pA?%!L<6;N^z(R1$qt@*Vl0QIXE{uX_Y!~kni z3ia1oe9Q~2MPNQh2JfsEIe~>}q*p-1Im{o3=luwTE4&h_q0_9n;8HxOANipnNnkha z=?In#1=LU=fcWhEbobe_;cQIwr+HAoi=DqfEP~cQPIv#Bp4u80~8pq24FMMsRsz?ghl$c2MQkP8$OU*))^J+TS8kFSxy;o zadr+)bO&(<6Z3nvR#x_u>^%IOJS_Kk4LSID`B?1i^$pAoSqvO(%?%&0I9Qq(nHb(< zu{AU|)VDLFR5Y=4aHiy9++!v1Yn(djqH@P_urY{w`N z984f7E^t9#!oZn7gpePfTT=w8MIJbVNFVqaSbj5obqY(+^u6^#%D;tlf#3}X?V0Ic zGyy}ppi4Yk%Th+@g7Wlc^pj!Nv5t-SSUP=5B9I^@`wxP@`C#>r&CsAV%DJGmI1se1 z1cTPO)29T7pO(@=AQw%b1Oe8;i$Fdg26s#)>|_7V-LEEnWQttZL4uAPe+^n&K_!G9 zDE*U!l|ay19}JZ+1M!;E#(mKgBpM7L?irj*(*E-vx~rYz03x*e9K$^5e2OP zw7gSR;q;=@`zx|QVcF-uAOkz+K;#P+KahV?`{~}itG8u9 zJO5rqr?mU4Wi7eq`V{v#rgay%jJ zJ7Nup_*2Spqf%kku!|w&ljZA7LZfxJ!juy3J{KCW$1lZ(G1INRoJ@Qa+?GM4xe!YE zoNe&I~3Ishx-4&DaS#xx-j=6|HMxYv~L10oUti1k1t1IurS#e~ODM4LY%0vWpfIDSR^Ddo7`3*W=HH!&kGV-1lN zk@l{LMZL(Hzq}v0j!4c%1w-oiZw>ViDaWrf*t`;TsHl~{WARj&@h-nList1{5@JGa z1N!+TSiyXtKtzB*z&s?h{3eh9;R#frrE>!1LRAC^bhq(Wflkw3fVd<8t_^?-mfvvi z5<#H2wdZh)&v46ef5inKnT$$1@#~y*956=K{0Ez;{PMi}W$L*0h7)|l;amrgkg*F> zu811Nbg|;mgA7p3|1IS>5e&#cfzLSxX3iLBHTsPKT%)0A{Ow2)g?)DfS`OSN7m#ph zQga_Xk)vY_B%EIUs(lbwZNQ-1)**MN%J$l566Gkb^jL;9855_sag}IlLGryz{DMM@ zH?#C*nBvm$g|t%1djt6CxXj=(*Sb#{&m02f|xf$4s?U4A`w! z+6XTQFSxb$xE~4m7G|c|@>~v5ysrCE)2OrEh0&IPDZc8m%UTV)JclvQr4}5Hg1coT zD$LRP$utG5rKxoqt%>AW9kY;Uw<==#S8uGmJ}ET2tWlX0LG$jAwBOs(D=uu` zY4xGGyTj`>M0-)Y+F`2r`bGY<^^AK@e2~pKN79`un~zPJV#a20j>IU$T2APi^-JS0 z@xw~hYAjx(#InCE6ddHyp3#ov%k{bEGwZ)3cta2I&7B89C`a-95)^rp z!dz9fLBeWjlB&sDIQk&|%PW{IiP9n;T834GW9~Fp3KwJrS9jH-D;By`nTTG;@V#vU z^35|R!yd#X8MrX1%Ma&Umi@TJY9Ag?p}*9~!51EkW4ba?MtW9;^Ucm+1Ez$kvE;Z>K=hmzH@af+4f8 zLHjY6HG8)a^=8iXtie`eOz9~8P+CqQ!{di3^oykCg|SYtHQG!$?JGC(^RrXZbtGlP zbouXIbl3U9qeq{5E9C9E?bp=!6QuPMvf$pVOH5`cKF;&Cn>Fpx(=WDTDMvY`SJhBI zdq}ffevs=k=rN~CFTu=?m{}#>O|W9&ntY-hcpKqlggKC^)2beWh<8iW5E28&Md+aD zBC8c%!WoaJ$GMlvDOnXJsjYA+b;~mv;<@qc3kr{P#^fZ@^Cx!qPy}$=`R?Kd(B(fb za(eSJxpYec$HGxXunDFJlYx@N33+UA!U*xuii%pX?&^__y+kudG5vl&VsBVU7Mf*4 zCP}U`->k!ntlf-={D^`9{LnIX(%7k{;myk>jJzF;Z4XU7*e~@z`;e8bOu$B~!l8nO z%KGfMtq|vaiwyaadO3Z$_e<`@S6fI@-GXtcNpePvz3F*tSW!nvFV^1DKMY*;nO@b{ zbu|_gt>NX(jSE^5q38}YOW1=1BwsRS?6Q9m$5Uwf0(Jh-hHWEY99@`nQ?btRaSyoz zX*N%)h0EG5K`=p%F8b?I6j@V4WO#IGwf4up?N)5s0X8ZRQS4%6Djc~MX%_41MmqVX zl-+Ma&@ay&F}I2)^P~&H72lP9OlFW3{U&J&W&`%E#g|kE!kYZ}8RzRdbbXuj;13G)E+5TMk2@ZiJsv zxMyQ3TaKC?JjTcD=v23WUY#rI4$TU_;&2tV6o&mf)u^r!8BSMHPHe7dCW%vf#eVf4 z9NJt?w#&d{w|I}Vlg+aT6XX})s5P;w8~I$+v`C~~S^1g!#iF$dNt8G|?2BpEW2|k- zjMY-PZX7C#t;(o-C;AS_!SQ+HRxb4?G}cFVId6&k9LqE(%_$N0cRw+IQa<4w`*ixQ zEgaiZNA%o5BfOi>r6b{k=rznQ&wp{B@X+Q-e-ic%QOUmS{=}1{3o_~BJ_8qqB`00W zUu-v!$6IN8_RvSxw4ep&9r=6=P$!2G^@LxJqIL+Ht6w+1(Q43Xyu0%R?;wN< zgJNAuFR&-$C~Rvu3h#ZZW)<5TUps4ik<_7NrHrBnkQh(nySoc@d=eiY?nz?>HOsOy ze;r)I_8;Cuv%e?ex3iPkDxLqaR`F6Lq#H*2hP(6gg6_rQTea4_pZE(a$_7;(Whv0% z9{Cy~Xi;{mCvg_)TrQu*dU)ybErpk@!`ji<9}2m1uX;wWZJS)vn?MmTCYRjyed{>Y zA|a`ge1krgZtqbA$^*w&ER#e$4}w0mQqCi+cNO+;73T~ryAC+u?_4EKQ9Eu`9z^-_ zsqokcF@E@P32`%Ras1ZcgO@4W6}srsZCjXWP0tJ2z1PFFl97OuY(4* zmPzAD*q=yC`AaFN2-7#Zb+8RKNU(X*lrN#0BPH0%_SLb659ZC~gtvFO&x~9n;pm6! zz@&dFtGPO~XO$k>tVS^ETd`GGtZme=dM&gxm6cyXORw~?y3{91b064es;+&j5$FdH2>CX!eayu z3G^yw7lfz+oHH}w~$JU3awcOad-_HMDTSo z7HL(hAen8$eO-}SUgE10-Pnwz(9BM}nHTqT%WQ(>F=Z&L4MM({;|DyoT=kN#&4(5e z51%_-@|dw9@Ul&?ucwL9(@Y$DZ4y%t<01*yTdyyO_Ejh?;Xbn=LC4!z6jH^A(&AQ$ z_qU=6>fj`w_}IU5+m=q7dWQSKr%XFdHie_QL1Zer{WcelP#rTe=BPJ9xZsR#=M>Wg zw@pNpd>J49+Nfe2gRACvXpe+P*a_Nu4-Z(}sEuBFQ0m}$tszbs#;E7oGs3Q+D$=vS znr}@F@>IM~v|xQLi{BZ_#u*_u?#<`kH*i};rOeNh^8l7b!Yi@*`pCXOBi_X8R?#Yx zJjOc_^u+K*IZJ{K!904R?C&R&_FXY%gX}nVv0d#_`l)&=B-t!BE?&0jLbp4hYD<;8 zM>sCGzHg&x)*mCp2alHBm-q7v-M!>$6+*_7BFg-rrT= zN9t2=J% zM?8D^JP}8v(4CLWA^UnUJO_mk=|HIv+lBW5!=IP-nA>W)mMsIUU{tUlV!)D6x)ySM zzRD|kTNurFMU-R=!ih3Jz2z&;y7;bf5w$$z7$uxIELQmbApBzuCT3I?3;p6w41DLu zBck54Cf=mtJ9^gZt&S_GH{Z*3>7J-eavt77- zdSz?Kyvl*rMWeGfP5*#JdBsF4Q*{RM8l6;1xX*JQuae}TFWd;M-9@hEm9(yqH=5!y zx3ns3_*n#$i(idCQl)(;OrLtw@BSDzG3Qg0dMk7}_OeEs#0R*u6qGXY7Dpi z!d^r-BX2ZLg(GSb(Osi;i(eR;ep45`62O~=%x-5IR;rbwY<|`aSg+T*jyx! z^BTT@88It_6TG^Gj!$WmP-2v{-cBq~nN_QvZF_|J;N$G7tkG2pk(-eX@c9RQMf(G! z*p{yg-kVW3Uf?uL7e+6}AKh7M`#kPO!AsXc^JVcJrq+6|Q>Sb2uC9wcTr0YD^&^UQ z9TTHR#*u}H^XgvB;E4?!<4`6@gK5^>3&b{?KN*go0bIOG+UdH z$}^#RYjx+?e_gw=zN5%?9v(rK=%NCd#XU>YX3iUKrfpdn!M^qXQhxKB3v!~;p>L*V_~5!H~of>4oA7UcLhhS zKBm@q7e)JLQ6jB9ov@=(1g?LPHQ zvqP7l6VxVoxFA01jwgp`3wUtgut?YxyAo9<+uzl4zBX!7uV z!`eGHpQa*}_L#3h_6m#()>e4Tb3TiON1lQ(?04zBRxp^oOgxoXAWcnojh$%itao|! z?~GZ)W{Pv^x5sXv;-ja8VH&69+s2h$@r(=T#+W3Hpe#sVth>6)NjQC7A~5IUXDLZ$ zlToe9O@2;#g#O{uA~o}J*)1xUa{3PHA8Q%SFbI*#_HAmT&ocK7BSj8kn8IDDjwllA zX-&CUwofy$7DuiWW>QvULb&7gJfg?t!gNsln+c(hs~#+oT_Wm~^783RqaJ4DhPL%K z*KFT9hqIf6jZ@CtZhB~dBtPDN$ms8k^F{s!$EEwYCU2g^a3EsZzn!~6wketYW`!u& zs9DZ;TA|6C6NPhGl*G=zOp87AEmK)uW=VnElhpff@%!WqNm@L{#IFz8gtjwgrDh0D zGZ;_?A3u`x%hASDY8tvAuxn%EtuC_6%Hgqp*!#vR&hz3I1GJDkevlZ^YQ^wUH@TU2SRa>(kGw;VnNCypt&mAgf^4ivuCi0d?M6X-DXY|C4151aR z#S7B5TBELFlo=$vjW5TOA0i`?Uh*aTEWWZUsD!rBUzp_*bGfR*wOEO~jRRMcp1prf zXg(H2LVlDs2#a;~Btgvg@*$!fTA6&y2}i^e&svJF*}OT|jmMJ`c(*nVZXT%a8ZJvr zFqO&jDQ~T44&#LiT;n5QdM-NWmxTVfs;WEPr`V0PY$(Y|VKX%uW= zQDZ|>U-wY!e*flG!mFF~3731CzsT2Bd)9Wp!b7)(PkJ!x(6>o|zl3?$u0xQB?_;Ey z5uQXK4^c(UGXBDm=^p1DM@pxw996mk+>c>d%^34;D>7j>6W?3Ujhn`G`r1-~LaOwo z<*heKb9zU7xDWkYjBur;<-B-pP`4n{zsv^! zL0e#kAs3usa6vx}-T^_|W-xgBA0Pr7XMuSZvOgdeLlF`GYs7viBIzFxXF$XU!@nbT zFZ?Z4HW30CvTW%1HDoEQbQZGY|NK2<32b$^SOdgNVTkS1Db+mQrB4~n2ASPwEEH+;v-<(Mdofc=4)MI?qyTlJzpiSAzVVI{3TCef%)>56M~AM`SQjK4sFPW+_uJ6;H}OvBpl!?71Qv7nGl$Yy%fm zE_h(!brt2RvCE!N!ONGQ1D?wn+EY?7v{Ym6FUuq+RZI@Oy6$Gzv&yoO+;|J;4SKlA|52W zD}MLsH$tA2`o(Y~%2|F;Q6fJUqcnbtJE3WZ#JlHd%Oobw{{f;=h`?r+iGw(sfWH1xek2znc)FP>1g74y?UlD&wIX*c+RQAOy z^DZs#6ui2a;wzmoOJrLkUXrWJ5BYst3aEMM!>Ss*nELBaE^EBQt2dAG=&E=N%6NGn z6;cY=5LnLpTbT5jF_5DqXolUN>Y;vQ=ai%Al3zIjJ&)ynUhn>5u-Xgz+8c}lrj#}% zpBs$KOr5atJH%7>u{vqy(&=Omi-tdj^HoJmNr(?suQe~c`BKmrwD`DX->-NRDLhX@ zF?IXMfk3}GOGta)$fCk$?*98}R_&tBDd1!uRyW*|5X9${@>w@}gHXbH@mC`b(cp4e z(lHolbyjU}Cz0xXq04xuMsP`mYpI5cGHJG;9iu)TgLs&Ha(4Sp%eLtJMZZASXnSs! zlZI!nVRN+RBATB?(ye4Ed`V+T5AtG}*=Dlds(#!1I=C#L%?cq?f;^sKy@71t5*8Kt zZH3@oKmHz#X#)i#9Nf_8M+)V=8qF$;&Wy#}5;QC;9@wO5WJ^WHM&rUm=9?JPmb!^0 zzA=W0o5|GnsZ?oV%LR?^956gdJxA#$Ck>cmE#I~@eDZiAR7B;;v>$lMd97*ti*wHC zgC*hRM9CtZyLz;duo{mrFEM_BSdW>Rg>MhiSlpgcTX1aRrIuiDI0P#($^i`mtOA0T z-zsr097y*DtSpTJ&FZ%`bHK_{@$U&xF1CX!!oW(bElQd;(And=(z!y*zmwQ}5~U z=##YTW7uJSxUFZXBT2ctN-OR>Y1o6L!D_1H=}Dxmx1ME;D1Uxs3-M)5hzVP2O~JK( z>`}rltRSRghE{PoeQdTYF5~`G@8x)wcQYZ4 zEi0nV4OS^BE86z+P}DN5pwCBZUZsUo5fW(>?U!zf)p$z3l_*q#TUH4R>3d!HfS1!B zL5+b#+g>hz*Y-V1)dT+m^YuQ(ch?FR7!-u^Os=)%_6`MC=TDcAX-~{PMfjS5?SMQ{ zP9NAK@a1iO>xUZ+tqM#@ytJmGK z`s#Hn5_kr8PQzgxAHL!?@&9P;77xjo6UDKzEcnvgREk^l?iI^a|DY26+a$+|4fSV| zJNVlqR{$Jld*08$O@J$~_lX5KsmuTu6@%}HTmL;G^dH2(5li@hh+wQhL};VQe@7g( zg9q3_1OCY0+X!ud4fBrJ*7Waf1R?^{)3;OqAH?1FHUd2tnmS{#6hAQed6@>sdTr^u zq4d_&lwj%sl+;l{R?_69Bq_fW=C5y0BIK0)(F=lNYgLidE<$+*7-q>c$$OLSWH%mD z+BS{K>-1m%!|cJ-k1O$j9}_QaRE#D$TL%=&?X^;QdGg57`n@3}Ex1{M7D_VACRCrH zvv_vvB(7JIzV~W(VRQ{_ebf`<`e2o-N=(uT$d=5;E)pmu|$PvSoieNC}*r>eWF%8y`;&Wg7F#@uifa}%(8pq{qQZEp43^GYCrOBOO$Q z+ylf!yp5fzFu3(q+0Q=aNZsOA-G(Nfxf28!{SZc^P(O*R$fl)HKYQ+a6F`&DpFjNjxa=z z6+~4n$?0nc&qt{>Bqw4<1{|^>XI?&RXQ_tdIiV{$ZYejv^3E0U_oSl7*SBZ9m8KBl zk^B%gK^IqCyCoOa$2m_@04GB2-0u$@+~}_C5*`1^TR)W-uqQrjy2KZEoZ_y?-60J% zPnLat8tprw0z%BdT-YL=pds;a@`CG(gW-b@l9|J3k1cf{!rKZL=H;NKf=|~v4xI|~ z37iJ;cdUI`wfj>di4d|h)^2}6pDch5SI*RNT3)y!w+uX7@tI>CY-T?^+Lmz{#x)`F zCECw7F}{n?{NN0_P8J(k#$wZ6wbJfmCzC6qW>HR|+GXgPfMt`1o&2~;;o2xm=9x3z z^(|#y`qAA^`U~yQM=cygYz;{_7FhQ@b_Of5K%9H2hj~u71W4Ekp%lMbMP=>716d)g zm=lTvu{-#$Mq)nn+(^#uXf-7t8B>lO<_`|BE%dEGG|554&^Bt>;MB-e>qr zhay=|F9ap1bF0=j!g-Mdi?B3$EIkpKtW%^f9q=mv^-Jsg{S>G84>*Q*lE1(8N!NM1 zN}{$}izZagDVB2Q5n9r5)%wC^K6}u^D683Uu{nG|w|Nbwm4^zp(E>NC45f&}vaQ$( z33X$tkQ+AHA^KSs*Y!JQYRUh#E}*yqF)?m@5zbSHS`sHqpz%q3zKOZgyllQCz~u3_ zVYFWR@^&(gm;&l?BcSA}X*BseHnK=~+}SQw`eSc|e$=eCiQLQxah@4M=TO{ar$>DK zY(@LEYi3fAaqj*()nVneq0S_i7txjAQn|zt|2-%1E=XXsGZ*}59$15TUL(@79EA>s?zQjsqIc$b*MY61QiE^RX|Lp70+!e`}G^Lp0 zpWZi?E9>1*yq$mQ$+rIQXw(OQ(LfaI#QJE39CIr!n4S$Oaj`?hd-^!3Ovl)Q_gDRGAOZjp4p zrs<%bbm|)EFg!!)xp1u$dU6IL*$h0SY(ut-m45kr;b=_cv*@ljr6@;B=E&+2voD)! z%jIf5J~u>NceIAs0opS;N#k+7((K;4K6oP_dcUT$a@}#V0XmFH5ldLM>a{{u#XhOM z%_M#sB!38zkTEA<>Z z2~K-OAM)eCB46%c3)K2z>t?2e&xUfd1Cyntem4o-2T|Q*4CX*O9&kvWY1D7&C7&GZ zBZ>p}DWa4K9X+i2p@n^`o)#%tP#^mT#OoWZ`1Q0Zp6trhqrEB$0d*LY2}!YjmM>jo zS4gDDGxX!?=24Di`WLwjl=Lz?PQ!c&WipO@(nWL9gzbPNA`|VlwhyFS+v~Jbgye5E zgIB*nvU=UJb0bFqCNk*ac5!s*32x3;T7CN^I}S0y1LK4rIyH`*t=NnIjrN zK=PE)D3`>onfOYgvV6#vTITSkw4xIlTdL}R1~0q1KwwO|MIs7I!Pl?3atj!Gr+A6a z^w7CYm+nZ$Ky;Q`{YDK=y0=jaOAy%GoI8Ew@IQRQqnNGt;JB5_kdmP=Ia^uy?uWzz z3>96+#k)tk{ovFEXQL?qr7feD{#o@>@J!`^CKEGWyKPm}*y(d(?DVj=81R-Jm#v09 zcQSHVRO-B~Pbb}{m(D{CPpz$CXP!r!6bh?|EiJ_!#oJJxjrf(49QK;nNLfmwGiFAl zH;G%1+mOmq*@yZf*=oj}X>mnByR6|JJL)<^J#|oX>*dhpGN{4m`u3{fJM|3%yxl+s z(OG7a2Uiu}1&0-6CTPi*SyD*ER{tCSsOLGXRD0O?g;P8I&!>irYJBp=Tck`#&cdNi z5N@i`I*i{Lo6I6%D2RKh{WpUpWImnQ2wG@b5u4iYrpT~fzb%ygP?C^YD;gZZ|CS_Z|nf-zyhOh zt?zDYqwnGXW@u~u{&#;!(lp=x+nVnG)D-ZqH5t5+J^wFi`VZ1HXc9bN7}&CR`KMLd zUv%tNEr<7-8dBc%d9Upc9lMZfgY5YaY5ET_`hV03SQC&W4e@RR;eG%Lq5})PlLNhr zv4bN$GrdbUK{pQoco_w_#o+{qK-xIavoU{y0UE>z_#-3JpYOV${72Eu*1^$Q-@(z? z9`Y}{bpyHg@BbB_7?}Se0D$WBUL=3-tMOWRzyq-KF8`+2k(SdRz0CeauX$>OW$izD z0cq0zcYXDrdV$LS*LwZM90vn{vugFm0QLN*@{`-Ylvf&m|4Uh`Z{%OgHRk_PUODCd zm$KFq$-kC=!u)aBMtFcEP?>l6x0iq?+zMz0$UKSnu6=J|u@^`iykShoyZY};bfd0+ z@Wp|PaNuSEXx{%|qLa74|94l|5dU0ZGydD5}&=Y|+L*B;Xe04OaLNh&%#Z z-UG4-ZF|{CS;RzA=xCC}if5|dg#QhSUCPy= zhdEX*K>Qg0=&Yu_I`zp0_QKS-);G>`8%Th+CLEg z$x;7<&WtGz0On3AaeD8acT|wJVlN}kugJh&>hwXiN1ibK(?iL?-4O^lD9E3|V`}~X z<=ZsxSz)Qd|0GAs)B0PVcLGsSCSX2R=l z#Jr$*AFAjbE0OEQU8qp@=y!6imMk7XOHR44_Lm6UBmO_)?UMpXfVrRlWhiEFU}5+_ zLovhqPz(pW?g0Qc9Tor&6aoMifDnKKzy=@#e**(;=IChmk%@r`fC)4d7EqXgf&gFy zFo1(WfI)&mLqS7B!$8AA!$E^X0~3XUgFt{l0$&~A?Q0NF5HL_sP;gKPP+(w(76=9g z4)*>V5CA;@*o6QV00n>ySR-*R?(RuBe;a4?X~ANoAzh;rG5*WZ6Nrl-Y{JNgO=KM) zj~{qR;{OlNyDK5UAOJoA8~ZN*=IB%*z}ugfkPm-`p?`986nMk9K6zxoQvlUp4v7jx zdA||!F8_v76au^Z8!kpBfgKPG;lhwj@)p2)mb$6w_AmYZ#ohUK=Y4axS) zplJL9f9`Fq0F~eYD)27FsKJK1=Qb^i2h>=pc0lzz;ww#CH{KbSAkf-!Ss*( z8JMYO;ODNLuEH>^tC-o zs!YHNcUKY1xuj{ypF@^4V5QwaI{&-;y;8K=kdF6CfBOEd(t-c1ln~epz)H~qRa1)u zHa*b6PtdPGKQ*S?_kA5VkzA`hH1TUjzOdFtuwCVZZ17$91e;w;T>4_xXya`7>+BVQ zJ15H!(j5_-J=dHXJ!YHZXN}MYq){uA-Rtoz<8}g+v+DL19VPMC+c|v1v;GOcG~Pi< z_=AbGiUJC(-l+}g05=#!$4g@XjS9&GFZlxly&`RgQ^xLFO*@BIUes6j8?qtPG_M?HyM)s)O8rM>_xbxy|uR?o&{6MZ1W; zZJTM_s&x2m6hwkgprTcQ%QTz{39ipK-Nc`!TJZdHwed%OsoW#~Y$IhW^|qAeXUNVLd}=&pIA-Sg?CCR^FrQU{q%TXd?}w> zH+kzH-fsQXJJ&0nz!{F@>pIC#2CBKwW(+)0lF(fAejn~?=>{>P1BhOk`y7AduhYaI zD;`^JKP+E13_3tMyIz!<1a!ckpXZ_3Kp1fem(8o9aC zeZVlhtpJ}jAeQQ)Na}N_JID}Rqj*XdKLTJ zm`JY)2KW4wK;07HgFTWiBBE@IdL2mOH76MKb{8A9;Wd$+4iXXfsl$s(_M4?^ptScd zd330}|AjxbbvC!Sc>Ybp+GVpcV^_WAdejGdY_ z4;Lv0qGb%GUMg-MR%)1Atjh67uTC%g_SVFV1-@(+>Rd&qiQRauiIg|OV!TC3u5Gk9 zFcnD+F`7F=(Jwg@9j?|gVKe|#viL`^AeT71B_ci#YV8wLb>D7x%8RTy*v4JA3k#0f%J9`R#7?w<45C8Y4d(y-qua9n#RK`}9k5YQC1%1HRj1+G7k z|3<|zDoDnBO@m7MxP$b?znVz2!Qa1?B`BnlZZW2}s86*WkHKbk7pYzl)~q42Nv)ur z&lkCy%c&#JKzJN24H@xf)+g~O@u;C?{5ir~&7b?Mhx;`t59*m+1BOrh=4q#pLz{Ev zZey5IAlpzhbo|vC`hbtmQf~e`sho_3vVw3{mx-DE#DN&NlO5B#iBs8Ff#+^qAKQrp zb|Qdr<3>{1$YODSHk&HAWcKK5Sj5J3&n?(DQ=Yj!3x3)VOFG=#vg(-^{ElgIJc+Oh z)Ix`oIR6`+X$i~K-tCw;m-6W0p}1$U5(eBN6W86wH3+yy9CRgC8fI%duJ#*FIUBeF zPs{lS@wKCqzSmv@-xOI@nS-AuQkE{)YUxCeN;bUG{TdAe0KCxdF0$zL{C=`)7`-tx zsIR{oyED@_qJ^}xPAXq<1Cd%|Tl>Elur#D^E+QESJGk<*_8dJ{>LzB1tF_j#Q$>_%tdGZA(m~qEp*u=ftW76qOo6EAF1*{#f=!U@h z!;(Z#!cc2S5#iu&2XOMmZ+b@_9?Hz~AwkcpB21y|luNGF9$n>XaA&BWahgR;f~@=E z?YznqxFf+1eco++Jy+Ss={FeYr2V!9qPtC>_ZZjdHf_S3OcM=-p7n-{5`KeUxeoXC0V9v zL{KD*6TeL^LoVi)G2CvEp40WbioGp1%+nK^BLW5nvRrKzd!?8u=e6{pr8T#7aPm?p zuhxw#Z|dSVKI4;&^E&IJbxqh|<7FJ-7cK2VZy4dNqHV|THaTXvh0U(bz)#gy`9xV; z;OvQ7hscMs`|Vj|YU^C@10Fleqn8F~P>I4nhGs!m-Lo6`yW>5cpr+$SI*bhURh_nD zet3x80nbgywS&y12Te3K6!z9A)905qkJZNOqzwsh`!+wYtacx*>&zI%r!g&``>p!i z)Y<3Jju~o8R3PkG8=~PcHca8ttbBe$&N^P!uH5u^Np-x&Op%{0SoPBSr5c%+KBt3~ z_k#!oH3)VRL~>~r!LS@QW(59hKd#^Jn+quAm+adqh+miMMsSfK#y1*;s5$M8Ci5cE zRyRBFlZd=MqAX_fiZp!!S1O1wXW=8=Zt#(9t#L)E}5DN$0 z9W{Q&-9CMuJf5t3b0|ZrhFGRW(V?I4r=){DHWaL@cT?+)T5S_Cx)N4t2+X&C52|?=h=D ztMZwI0QZc8`?DTH-5ljKl)t6yUESJeuZ&y_jWOca*sr%8iy6zL<~E;IXrLaF%LPgR z$w&p};}%=VMq&d&VYQ}#D-0*mKkboyGK@69Kcr7}&YJ1+b43k)#Ii^n*Z|ie$JLEy zA_pGp#zaqJxgdBcrI5rdk!j(#$me%0(N^L?eiJUpi;N|`H6KAGgagv zTX(V<4GYR2**dxre@XV!EEEp07Ve?0y+OTZZy({K^+2$ zWI{3kk1WP8`;?6sncwUV$^%D=M`n~XI?)mS+~6?3Ok+VBDX0?!1K&|Z`p-eLS`Rc_ z2L>PFgN~x@H0RYIh5CRfKLy3@42YF)6pi?p7IrXmUoX}jxcbyF%{(Wem|@W@TJb0k z?4#z^=R8YGaT4L)ATgHAm227O%Px-=aZ%CP zkfun&lBy&_r8&3Ob;qh$*f z;NUb8@A@XM*R1QdOk(wV&u@eaeu(T8s)6={3$Dy5)}^$EIbRSGlPD6n$*N-9HZ3iO@1+W~ zX5nm{MyQT7Ea#Y{m}_3;MSCc+&#o3Y=QYOb5Y98H=aR3TC}VDap-qOl%`Ps&e<2-{ z6sR&`a*UrrNH-uOgb&K6620u`OPHxls6|0b!(zLKHfZ{_^}h*>MSFBxMJh; z)TYoSMmZX@?#tY{UW^+#_@<{>{~gCTp6(A)Ij|tH)nNoo^c0K7CfoQrD?AtfJ^kwApzPiqCVSQgpK$E(*o?AOWR>lb z^9Jvl(-_pa2()yCOv}U35&{ao1vup_7+=LtWLWJWMfon8wLQd^v4q8Ni6NY*oCO%` z8-0uMYoCo|%%|%dsUDt~2R}W*`_?Cc=m?PN!x81nF&E)rXI!R>o0WoM?|++FgmGQk zwERil7d74Cg6OftG@ibp*cwfyzJmr)@8YqpjDrT5z5C(&1yfC}tCorv+1%!}Vt#uS z3%*hXOLP|FnfjSVWb-cqHFIo@Dqg0GimMu??^wIvHZ4tAsXfuWzWZx}Idcm2k4=^% z9&a@^z2(sQmREr&)70kIwQ~kS8k~U>4d9u$P#z=c_g7=C8{NsN5;)KnRiRhKjZkB> zD|E@}2T?1`D8mt0^ZN3$8?8rt;x&pYqI=#ld;Cl@T1us882I%HPgU0NBP_FbIg^Q1 z+C_e@67S}@uud8$Bj)v6(Pc)r*R3h_6-o>fnz@6dW>P;@d!t+z+!{ob~_#T^8g8fH8^6z1O;E{ptpJO7;e+~1;^#P9zqTi1U z{D7&c{(Al|u{})(a|6u*ey#F3+{OkDYPrC;GVEv!y_Dyw1HUn-?sm`UH=2t`>82p-rtTz-rM!(5sm{#8f6 z2q2c?SC#(-*88a^WS;WB>li2p#PYHM9wzPsW4w1q z{~F^Bl>5&ZuU_Dv7%vFUylkaOHB0q@=3oFNE^4=TB(%<4n9L1zF#B_$w+q4Jckzag%#`=Vn2(@ z$VQ*YwDxEp7A|0f~L zWU^%aXSD}h-r%HP==jM2wDgyG7rLZu(jWSf4=Pj#6$_c%cI%FYY@0|{U(@R8#_f!K zgsMyt!-&-KNzTTO)vA?rr1QB~mT$37#I4`Pzp$jCnTS1~;vv4<*a>K3JD|zmxo>QSk>=dQ*z{P&Qie_xe*1|J!%M`H#OS{ky*eSp)s0%o_hMZ~5zF z=6}F)`GckSUtkrzV_EI|GnNHD0>Byg5AX7CmMwX{}KyGrUzU4&jXS_)?s>O zfnOCFfpZ||3))gaq2&0q_@ar=?W22f>16sfQ37Y`@<^<w{9tk3MZ=&N^K5qHYA+{TYj&Ewq6rs1=W{MyEMCv13;IN3P*NTnmJ^k;8R z{2W0%gP}VBoeIB-{bN{hrGV>x<*5!Dwjx1BWld9~=~rpgQU*YsItqURt;|uCeX_i@ zX(0v_nWtC0iL{)fMj0@H(C6>fh+Q!-tZOabk!m@1@DGxin9U9-B=~#5mfLVh*@}|j z3R`x!G(f7Zc@l8S^j#49lE+{Ma^R9H&#;}NBqrLmmY+4iMKJDT*Q%ELepHn{4=xaj z%J|i#HB2h)XjMk9E}Khig5MTma9fVmJdOLOVECI?V5kZ`0R}D@NQNkdmlVlT3@+e0 zSY{~Y#!t$#JFrdLG+3+nE^4Fp@Kt^E5mAI-~?4;$4lz9=C1qlwp5WG}B@5|!8e*JMb@vEjS zRUnD9oj>jNm65V49xoBY>D4UVMn8`(%{wy>5e|(810?bFxnj99UYBjI;D8BZ=JFtN z+NQho{8&abgtu58vL9?<9B$PpAR@+eJLt{{;a<8toJ@S?-sNz9)J@KP zToI(lDVtm71V=C(-xWsj4TMSGPZKZp-kInr|giGU0P0u-9zAqS7B}wx_iTV zwGfnUn9hI7D&F(+ zuf*%h=nCQ{TNB65vkiR>Zn5w(haK8#5zl%%N~p0H!l%{5QrhA@e9?0$o`y=>vwBLw zB*BlWLpTg2&)?I<`95FDGPHnYsS{eXk(1$dpY0>^wzFKKUKIVjB|kSHKpZ}oBYK*C zW&P4A_(LJx!L9d3u1>Sw@?xeakd{ZKxGOq@@;EIF(>#!@OwAtoE)Ok) zI!e8Wn%j20+}33MS~HHrm4-N6^RK%E+Lo?l-VLkNM+1gOSTkIKAHQ_8Z;cMXVP}lh zE9cXmG8#+mq(Gz0Rh3w7?N2YT>mE9K&=Te@7jXUuJ2P*jtCgvWqEQI3Rq ziDVSzp9~Q$RSsTVw5KV+53fPw6OAbwqUsT7J==r-jhMW0&McW%V;Z0@cau}oRebx1 zIqgHJ*3=P_P8Yrk8pZIiM;HdVPxhkdc-R^x@;E)U3%;C7W13fCa!R){Ui|1CNkZp3 zYUkySFA)`<5FX6ppma+jP(9I!CtO&! z39ME*!)2E#jyS|D!Jl4W>ufe#;74|~6c?B+*Lj_D)w3C8^?s<*?Ok5EO64g88(J1Bp+hNq2fgI$Fa~RCxQf` zhqQ}boL2p06mJk%S+ECW7-z;OQR?_lGaq#3NOVZ z8^UyGqzTC9dpS#={9a@uMDq`%Kkbo5ThP>nyYJf1dD!^Eod-p_C%>9Wj<=6I~gZsJKTI#uI6Iq@W# zx1BO0JNt&abxbSOdwkG368$`BsD8KWfW^sr1j$rp-gPNPCx})cBdBC?`syzi*gE60 zp_v0UpL@K|hkF11DyJdMVJ)YSoMa>-97Pr?l~>L*7_Ac!Hla$_ z4%$+!0p$&H=Lh#B}L?fZeda$s{P-LE! zm2@mjg#K;DVx=6dUfXK^CbGo7f-%!b?Qp_Z$%Wsk-SE@7Xkp={#I>bmt8&W_0kdFL zOzNkTS-oHWt%akt+0}5b_m9QX{s6*8g=X;)D@-dUqv2fsrbboyLlmnNN*RcA+}-?& z8N59yE7Q*@SMU*7-xQWVVsro&OjXdu7VWP@Fhkl zTuUj(#gmD#=tI-C*uZa)v?CBGmH1Tq6YDX|1tz@Al78}2D#UM{D?!$4a~eCegcTDc zr*RJBj5)|^@>C}ds*Q!{^~P8cc!qV{VC(Alq%kHKW7rfTUT&AAfnn?C&h4x^dOMB- zr$iUEeRe;oI_i?;g{RE?Ny?k|GB>SmY-6_<^E>Os*p0wh*ju3csm)b#nT!Oyy;t%h zyIMXCQ4cev8lee@oxBYS>=uyjs@o1ybk<4)U$OZ3?l{N!hAuOsYeRN0uWarJth(2C zJjy5=gA>iYV7&^1&r#)m60i=eIIf`JLFNv+?R$2!ESw3CCwP!O-&iAjI8nxYh`Ard zF@Wuz2DOO0khH!XWRxvxnlQVlEEl9~{+06SoyMD nn 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

tD_>x2GWaDj0FGA@yu! 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 literal 0 HcmV?d00001 diff --git a/testing/unit/conn/conn_module_test.py b/testing/unit/conn/conn_module_test.py index d31a8051f..906abb754 100644 --- a/testing/unit/conn/conn_module_test.py +++ b/testing/unit/conn/conn_module_test.py @@ -13,13 +13,17 @@ # limitations under the License. """Module run all the Connection module related unit tests""" from port_stats_util import PortStatsUtil +from connection_module import ConnectionModule import os import unittest from common import logger MODULE = 'conn' -# Define the file paths -TEST_FILES_DIR = 'testing/unit/' + MODULE +# Define the directories +TEST_FILES_DIR = '/testing/unit/' + MODULE +OUTPUT_DIR = os.path.join(TEST_FILES_DIR, 'output/') +CAPTURES_DIR = os.path.join(TEST_FILES_DIR, 'captures/') + ETHTOOL_RESULTS_COMPLIANT_FILE = os.path.join(TEST_FILES_DIR, 'ethtool', 'ethtool_results_compliant.txt') ETHTOOL_RESULTS_NONCOMPLIANT_FILE = os.path.join( @@ -34,8 +38,12 @@ ETHTOOL_PORT_STATS_POST_NONCOMPLIANT_FILE = os.path.join( TEST_FILES_DIR, 'ethtool', 'ethtool_port_stats_post_monitor_noncompliant.txt') -LOGGER = None +# Define the capture files to be used for the test +STARTUP_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'startup.pcap') +MONITOR_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'monitor.pcap') + +LOGGER = None class ConnectionModuleTest(unittest.TestCase): """Contains and runs all the unit tests concerning Connection @@ -46,6 +54,9 @@ def setUpClass(cls): global LOGGER LOGGER = logger.get_logger('unit_test_' + MODULE) + # Set the MAC address for device in capture files + os.environ['DEVICE_MAC'] = '98:f0:7b:d1:87:06' + # Test the port link status def connection_port_link_compliant_test(self): LOGGER.info('connection_port_link_compliant_test') @@ -117,6 +128,17 @@ def connection_port_speed_autonegotiation_fail_test(self): LOGGER.info(result) self.assertEqual(result[0], False) + # Test proper filtering for ICMP protocol in DHCP packets + def connection_switch_dhcp_snooping_icmp_test(self): + LOGGER.info('connection_switch_dhcp_snooping_icmp_test') + conn_module = ConnectionModule(module=MODULE, + log_dir=OUTPUT_DIR, + results_dir=OUTPUT_DIR, + startup_capture_file=STARTUP_CAPTURE_FILE, + monitor_capture_file=MONITOR_CAPTURE_FILE) + result = conn_module._connection_switch_dhcp_snooping() # pylint: disable=W0212 + LOGGER.info(result) + self.assertEqual(result[0], True) if __name__ == '__main__': suite = unittest.TestSuite() @@ -136,5 +158,9 @@ def connection_port_speed_autonegotiation_fail_test(self): suite.addTest( ConnectionModuleTest('connection_port_speed_autonegotiation_fail_test')) + # DHCP Snooping related tests + suite.addTest( + ConnectionModuleTest('connection_switch_dhcp_snooping_icmp_test')) + runner = unittest.TextTestRunner() runner.run(suite) diff --git a/testing/unit/dns/dns_module_test.py b/testing/unit/dns/dns_module_test.py index 6c3dec74d..a4b7a81e9 100644 --- a/testing/unit/dns/dns_module_test.py +++ b/testing/unit/dns/dns_module_test.py @@ -16,7 +16,6 @@ import unittest from scapy.all import rdpcap, DNS, wrpcap import os -from testreport import TestReport MODULE = 'dns' @@ -28,7 +27,6 @@ LOCAL_REPORT = os.path.join(REPORTS_DIR, 'dns_report_local.html') LOCAL_REPORT_NO_DNS = os.path.join(REPORTS_DIR, 'dns_report_local_no_dns.html') -CONF_FILE = 'modules/test/' + MODULE + '/conf/module_config.json' # Define the capture files to be used for the test DNS_SERVER_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'dns.pcap') @@ -44,11 +42,13 @@ def setUpClass(cls): # Create the output directories and ignore errors if it already exists os.makedirs(OUTPUT_DIR, exist_ok=True) + # Set the MAC address for device in capture files + os.environ['DEVICE_MAC'] = '38:d1:35:01:17:fe' + # Test the module report generation def dns_module_report_test(self): dns_module = DNSModule(module=MODULE, log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, dns_server_capture_file=DNS_SERVER_CAPTURE_FILE, startup_capture_file=STARTUP_CAPTURE_FILE, @@ -59,12 +59,6 @@ def dns_module_report_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join(OUTPUT_DIR, 'dns_report_with_dns.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT, 'r', encoding='utf-8') as file: @@ -104,7 +98,6 @@ def dns_module_report_no_dns_test(self): dns_module = DNSModule(module='dns', log_dir=OUTPUT_DIR, - conf_file=CONF_FILE, results_dir=OUTPUT_DIR, dns_server_capture_file=dns_server_cap_file, startup_capture_file=startup_cap_file, @@ -115,12 +108,6 @@ def dns_module_report_no_dns_test(self): # Read the generated report with open(report_out_path, 'r', encoding='utf-8') as file: report_out = file.read() - formatted_report = self.add_formatting(report_out) - - # Write back the new formatted_report value - out_report_path = os.path.join(OUTPUT_DIR, 'dns_report_no_dns.html') - with open(out_report_path, 'w', encoding='utf-8') as file: - file.write(formatted_report) # Read the local good report with open(LOCAL_REPORT_NO_DNS, 'r', encoding='utf-8') as file: @@ -128,17 +115,6 @@ def dns_module_report_no_dns_test(self): self.assertEqual(report_out, report_local) - def add_formatting(self, body): - return f''' - - - {TestReport().generate_head()} - - {body} - - DNS Module

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

DNS Module

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

NTP Module

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

NTP Module

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