#!/usr/bin/env python3 """Optional real-browser checks for WebConfig's asynchronous setup races.""" import gzip import json import os from pathlib import Path import re import shutil import signal import subprocess import sys import tempfile import unittest from unittest import mock ROOT = Path(__file__).resolve().parents[1] HEADER = ROOT / "src" / "helpers" / "esp32" / "WebConfigHtml.h" def chromium_path(): candidates = [ shutil.which(name) for name in ("google-chrome", "chromium", "chromium-browser", "chrome", "msedge") ] for base, rest in ( (os.environ.get("ProgramFiles"), ("Google", "Chrome", "Application", "chrome.exe")), (os.environ.get("ProgramFiles(x86)"), ("Microsoft", "Edge", "Application", "msedge.exe")), (os.environ.get("LocalAppData"), ("Google", "Chrome", "Application", "chrome.exe")), ): if base: candidates.append(str(Path(base).joinpath(*rest))) return next((path for path in candidates if path and Path(path).is_file()), None) BROWSER = chromium_path() def run_browser(args): options = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE} if os.name == "nt": options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP else: options["start_new_session"] = True process = subprocess.Popen(args, **options) try: stdout, stderr = process.communicate(timeout=30) except subprocess.TimeoutExpired: # Chrome can leave renderer processes writing to its temporary profile. # Kill the whole tree before TemporaryDirectory tries to remove it. if os.name == "nt": subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) else: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass if process.poll() is None: process.kill() process.communicate() raise return subprocess.CompletedProcess(args, process.returncode, stdout, stderr) def embedded_page(): header = HEADER.read_text(encoding="utf-8") length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1)) array = header.split("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1)[1] blob = bytes( int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array) )[:length] return gzip.decompress(blob).decode("utf-8") class WebConfigUiRuntimeTest(unittest.TestCase): def test_saved_network_recovery_requires_login_before_setup_wizard(self): status, config = self.setup_values() status.update(auth=False, mode="setup", needs_setup=False, needs_password=False, password_supported=True) prelude = r'''''' % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) for attribute, value in (("login-visible", "true"), ("wizard-before", "false"), ("config-before", "0"), ("wizard-after", "true"), ("app-after", "false"), ("logins", "1"), ("config-after", "1"), ("password-cleared", "true")): self.assertIn('data-test-recovery-%s="%s"' % (attribute, value), dom) def test_display_power_controls_and_pairing_capability(self): status, config = self.setup_values() config["display"] = { "mode": "button", "timeout": 23, "usb_mode": "on", "usb_timeout": 55, "pairing": False, } prelude = r'''''' % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) for attribute, value in ( ("display-visible", "true"), ("display-count", "5"), ("battery-timeout", "23"), ("usb-timeout", "55"), ("usb-disabled", "true"), ("pairing-disabled", "true"), ("rotation-hidden", "true"), ("rotation-disabled", "true"), ("battery-disabled", "true"), ("dirty-display", "off"), ): self.assertIn('data-test-%s="%s"' % (attribute, value), dom) def test_supported_rotation_loads_saved_value_and_can_be_changed(self): status, config = self.setup_values() config["display"] = { "mode": "button", "timeout": 15, "usb_mode": "on", "usb_timeout": 15, "pairing": False, "rotation_supported": True, "rotation": 270, } prelude = r'''''' % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) for attribute, value in (("visible", "true"), ("enabled", "true"), ("loaded", "270"), ("values", "0,90,180,270"), ("dirty", "90")): self.assertIn('data-test-rotation-%s="%s"' % (attribute, value), dom) def setup_values(self): sys.path.insert(0, str(ROOT / "scripts")) try: import webconfig_mock_server as mock finally: sys.path.pop(0) config = mock.default_config(True) config["wifi"]["ssid"] = "StoredNet" config["wifi"]["pwd"] = "********" status = { "mode": "setup", "auth": True, "mqtt": False, "cli": False, "wifi_psk64": True, "password_supported": True, "needs_password": False, "radio_optional": True, "needs_setup": False, "capabilities": 0, "max_cmds": 16, "name": "runtime-test", "role": "Companion", "board": "mock", "fw": "v1.17.1-test", "build_date": "runtime", } return status, config def run_page(self, prelude, virtual_time=1500): if not BROWSER: self.skipTest("Chromium-family browser is unavailable") source = embedded_page() markers = (' """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-cli-tab="yes"', dom) self.assertIn('data-test-submitted="ver"', dom) self.assertIn("companion-console-test-version", dom) self.assertIn('data-test-busy="false"', dom) self.assertIn('data-test-enum="on,off"', dom) self.assertIn('data-test-repeater-erase="true"', dom) self.assertIn('data-test-mqtt-enabled="false"', dom) self.assertIn('data-test-mqtt-enum="on,off"', dom) import html help_text = html.unescape(re.search( r'data-test-companion-help="([^"]*)"', dom).group(1)) self.assertIn("set mqtt.enabled on|off", help_text) self.assertNotIn("neighbors", help_text) self.assertNotIn("stats-core", help_text) suggestions = json.loads(html.unescape(re.search( r'data-test-suggestions="([^"]*)"', dom).group(1))) for command in ("set powersaving off", "get usb.logging", "set wifi.cli ", "set mqtt1.preset ", "get name", "get radio", "erase", "get prv.key", "get mqtt1.password", "get wifi.pwd", "stats-core", "stats-radio", "stats-packets", "set freq "): self.assertIn(command, suggestions) for command in ("password ", "setperm ", "get acl", "set bridge.enabled ", "set mqtt2.preset "): self.assertNotIn(command, suggestions) def test_stream_terminal_imports_long_cards_pages_lists_and_receives_late_replies(self): status, config = self.setup_values() status.update(mode="lan", cli=True, terminal_stream=True, terminal_max_command=541, password_supported=False) prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude, virtual_time=1200) import html commands = json.loads(html.unescape(re.search( r'data-test-commands="([^"]*)"', dom).group(1))) self.assertEqual(commands, ["import meshcore://" + "ab" * 255, "list", "to test", "help"]) output = html.unescape(re.search( r'data-test-stream-output="([^"]*)"', dom).group(1)) self.assertIn("contact-0 (Repeater)", output) self.assertIn("contact-349 (Repeater)", output) self.assertEqual(output.count("Late RF reply and ACK"), 1) self.assertIn("firmware help: import, list, to, send", output) self.assertIn('data-test-stream-busy="false"', dom) def test_streamed_raw_log_keeps_entire_file_and_allows_slow_progress(self): status, config = self.setup_values() status.update(mode="lan", role="Repeater", cli=True, terminal_stream=True, terminal_max_command=159, password_supported=False) prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude, virtual_time=7000) self.assertIn('data-test-complete-log="true"', dom) self.assertIn('data-test-later-output="true"', dom) self.assertIn('data-test-log-busy="false"', dom) self.assertIn('data-test-log-timeout="false"', dom) def test_console_stays_hidden_when_disabled_or_on_setup_ap(self): for mode in ("lan", "setup"): with self.subTest(mode=mode): status, config = self.setup_values() status.update(mode=mode, cli=False) prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-cli-hidden="true"', dom) def run_early_selection_case(self, explicit_password=None): status, config = self.setup_values() prelude = """ """ % (json.dumps(status), json.dumps(config), json.dumps(explicit_password)) return self.run_page(prelude) def test_early_new_ssid_clears_password_loaded_later(self): dom = self.run_early_selection_case() self.assertIn('data-test-ssid="EarlyNet"', dom) self.assertIn('data-test-orig-pwd="********"', dom) self.assertIn('data-test-wz-pwd=""', dom) self.assertIn('data-test-app-pwd=""', dom) self.assertIn('data-test-dirty-ssid="EarlyNet"', dom) self.assertIn('data-test-dirty-pwd-present="yes"', dom) self.assertIn('data-test-dirty-pwd=""', dom) def test_early_explicit_password_is_preserved(self): dom = self.run_early_selection_case("new-secret") self.assertIn('data-test-ssid="EarlyNet"', dom) self.assertIn('data-test-wz-pwd="new-secret"', dom) self.assertIn('data-test-app-pwd="new-secret"', dom) self.assertIn('data-test-dirty-pwd-present="yes"', dom) self.assertIn('data-test-dirty-pwd="new-secret"', dom) def test_setup_automatically_opens_embedded_scan_picker(self): status, config = self.setup_values() prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-scan-open="yes"', dom) self.assertIn('data-test-scan-hidden="false"', dom) self.assertIn('data-test-scan-expanded="true"', dom) self.assertIn('data-test-scan-busy="false"', dom) self.assertIn('data-test-network-count="1"', dom) self.assertIn('data-test-ssid="StoredNet"', dom) self.assertIn('data-test-pwd="********"', dom) self.assertIn('data-test-dirty-count="0"', dom) def test_typed_new_ssid_clears_inherited_password_and_review(self): status, config = self.setup_values() prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-ssid="TypedNet"', dom) self.assertIn('data-test-pwd=""', dom) self.assertIn('data-test-dirty-pwd="yes"', dom) self.assertIn('data-test-review-open="yes"', dom) def test_returning_to_original_ssid_restores_masked_password(self): status, config = self.setup_values() prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-pwd="********"', dom) self.assertIn('data-test-dirty-ssid="no"', dom) self.assertIn('data-test-dirty-pwd="no"', dom) self.assertIn('data-test-auto-cleared="no"', dom) def test_advanced_editor_edit_during_config_load_is_preserved(self): status, config = self.setup_values() status["mode"] = "lan" prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-name="Early App Edit"', dom) self.assertIn('data-test-dirty-name="Early App Edit"', dom) self.assertIn('data-test-capture="clear"', dom) def test_bluetooth_stealth_toggle_preserves_custom_mac(self): status, config = self.setup_values() status["mode"] = "lan" status["capabilities"] = (1 << 16) | (1 << 17) config["radio"]["bluetooth_mac"] = "C2:11:22:33:44:55" config["radio"]["bluetooth_stealth"] = False prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-initial-flag="off"', dom) self.assertIn('data-test-dirty-flag="on"', dom) self.assertIn('data-test-dirty-keys="bluetooth.stealth"', dom) self.assertIn('data-test-mac="C2:11:22:33:44:55"', dom) self.assertIn('data-test-config-mac="C2:11:22:33:44:55"', dom) self.assertIn('data-test-restored-dirty-count="0"', dom) def test_second_load_auto_password_clear_remains_restorable(self): status, config = self.setup_values() prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-restored-pwd="********"', dom) self.assertIn('data-test-restored-dirty-ssid="no"', dom) self.assertIn('data-test-restored-dirty-pwd="no"', dom) self.assertIn('data-test-restored-auto="no"', dom) self.assertIn('data-test-manual-pwd="manual-secret"', dom) self.assertIn('data-test-manual-dirty-ssid="no"', dom) self.assertIn('data-test-manual-dirty-pwd="yes"', dom) self.assertIn('data-test-manual-auto="no"', dom) self.assertIn('data-test-config-calls="2"', dom) self.assertIn('data-test-capture="clear"', dom) def test_delayed_app_load_preserves_only_edited_radio_field(self): status, config = self.setup_values() status["mode"] = "lan" prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-radio-freq="910.525"', dom) self.assertIn('data-test-radio-bw="62.5"', dom) self.assertIn('data-test-radio-sf="9"', dom) self.assertIn('data-test-radio-cr="5"', dom) self.assertIn('data-test-radio-orig="910.525,62.5,7,5"', dom) self.assertIn('data-test-radio-dirty="910.525,62.5,9,5"', dom) self.assertIn('data-test-capture="clear"', dom) def test_closed_picker_ignores_late_scan_response(self): status, config = self.setup_values() prelude = """ """ % (json.dumps(status), json.dumps(config)) dom = self.run_page(prelude) self.assertIn('data-test-scan-hidden="true"', dom) self.assertIn('data-test-scan-expanded="false"', dom) self.assertIn('data-test-network-count="0"', dom) self.assertIn('data-test-scan-text="closed"', dom) def test_newer_config_load_wins_when_older_reply_finishes_last(self): status, old_config = self.setup_values() new_config = json.loads(json.dumps(old_config)) new_config["wifi"]["ssid"] = "AppNet" prelude = """ """ % (json.dumps(status), json.dumps(old_config), json.dumps(new_config)) dom = self.run_page(prelude) self.assertIn('data-test-config-calls="2"', dom) self.assertIn('data-test-orig-ssid="AppNet"', dom) self.assertIn('data-test-field-ssid="AppNet"', dom) self.assertIn('data-test-scan-hidden="true"', dom) self.assertIn('data-test-capture="clear"', dom) def test_password_save_receipt_reauth_preserves_results_and_never_resends(self): for case in ("saved", "partial", "failed", "legacy401", "login_retry", "reboot"): with self.subTest(case=case): status, config = self.setup_values() status.update(mode="setup" if case == "legacy401" else "lan") prelude = r'''''' % (json.dumps(case), json.dumps(status), json.dumps(config)) dom = self.run_page(prelude, virtual_time=1900) for attr, expected in (("posts", "1"), ("wrong-login", "false"), ("early-refresh", "0"), ("storage-writes", "0"), ("login-visible", "false")): self.assertIn(f'data-test-{attr}="{expected}"', dom) self.assertIn('data-test-logins="%s"' % ( "0" if case == "failed" else "2" if case == "login_retry" else "1"), dom) self.assertIn('data-test-polls="%s"' % ("2" if case in ("failed", "legacy401") else "3"), dom) self.assertIn('data-test-reboot="%s"' % str(case == "reboot").lower(), dom) self.assertIn('data-test-password-cleared="%s"' % str(case != "failed").lower(), dom) self.assertIn('data-test-password-dirty="%s"' % str(case == "failed").lower(), dom) self.assertIn('data-test-wizard-visible="%s"' % str(case == "legacy401").lower(), dom) if case == "partial": self.assertIn('data-test-name-dirty="true"', dom) self.assertIn('data-test-name-error="true"', dom) self.assertIn('data-test-name-detail="true"', dom) self.assertIn("some settings were rejected. The node will not reboot.", dom) if case == "failed": self.assertIn('data-test-password-error="true"', dom) if case == "login_retry": self.assertIn('data-test-intermediate-login="true"', dom) self.assertIn('data-test-intermediate-note="Settings saved.', dom) def test_cli_password_receipt_uses_last_success_and_stream_never_replays(self): for streaming in (False, True): with self.subTest(streaming=streaming): status, config = self.setup_values() status.update(mode="lan", cli=True, terminal_stream=streaming) prelude = r'''''' % (json.dumps(streaming), json.dumps(status), json.dumps(config)) dom = self.run_page(prelude, virtual_time=1800) for attr, expected in (("posts", "1"), ("logins", "1"), ("wrong-login", "false"), ("early-refresh", "0"), ("busy", "false"), ("login-visible", "false"), ("reboot", "false")): self.assertIn(f'data-test-{attr}="{expected}"', dom) self.assertIn('data-test-stream-reset="true"', dom) if not streaming: self.assertIn('data-test-withheld="true"', dom) self.assertIn('data-test-error-detail="true"', dom) def test_typed_and_pasted_cli_password_preserves_argument_bytes_for_reauth(self): for entry in ("typed", "whole_paste", "partial_single", "partial_block"): with self.subTest(entry=entry): status, config = self.setup_values() status.update(mode="lan", cli=True, terminal_stream=False) prelude = r'''''' % (json.dumps(entry), json.dumps(status), json.dumps(config)) dom = self.run_page(prelude, virtual_time=900) for attr, expected in (("posts", "1"), ("logins", "1"), ("polls", "2"), ("exact-commands", "true"), ("exact-context", "true"), ("exact-login", "true"), ("busy", "false"), ("login-visible", "false"), ("normal-trim", "true"), ("empty-password", "true"), ("mixedcase-risk", "true"), ("password-seen", "true")): self.assertIn(f'data-test-{attr}="{expected}"', dom) def test_setup_security_banner_and_live_password_preview(self): banners = { "admin_password": "Setup WiFi uses this node's admin password.", "open_admin_login": "Setup WiFi is open; web configuration and firmware uploads still require this node's admin password.", "open_no_password": "Setup WiFi is open. This build has no admin password configured.", "unavailable": "Setup WiFi security is unavailable.", "missing": "Setup WiFi security is unavailable.", "pending": "Setup WiFi restart pending. Reconnect after the hotspot restarts to check its security.", "lan": "Setup WiFi uses this node's admin password.", } for security, banner in banners.items(): with self.subTest(security=security): status, config = self.setup_values() status.update(mode="lan" if security == "lan" else "setup") if security != "missing": status["ap_security"] = "admin_password" if security in ("lan", "pending") else security status["ap_restart_pending"] = security == "pending" vectors = ["abcdefgh", "a" * 15, "a" * 7, "a" * 16, "é" * 4, "abc\x1fdefg", "~~~~~~~~", " " * 8, "abcd\x7fefg", ""] prelude = r'''''' % (json.dumps(status), json.dumps(config), json.dumps(vectors)) dom = self.run_page(prelude, virtual_time=500) self.assertIn('data-test-banner="%s"' % banner, dom) self.assertIn('data-test-banner-hidden="%s"' % str(security == "lan").lower(), dom) self.assertIn('data-test-previews="%s"' % ( ",".join(["hidden"] * 10) if security == "lan" else "protected,protected,open,open,open,open,protected,protected,open,hidden"), dom) self.assertIn('data-test-login-default="password"', dom) def test_setup_password_receipt_reconnect_guidance_matches_new_ap_policy(self): for password, protected, legacy in (("new-admin", True, False), ("éééé", False, False), ("éééé", False, True)): with self.subTest(protected=protected, legacy=legacy): status, config = self.setup_values() status.update(mode="setup", ap_security="admin_password") prelude = r'''''' % (json.dumps(status), json.dumps(config), json.dumps(password), json.dumps(legacy)) dom = self.run_page(prelude, virtual_time=800) for attr, expected in (("posts", "1"), ("logins", "1"), ("polls", "2"), ("ram-secret-cleared", "true"), ("ap-protected", str(protected).lower())): self.assertIn(f'data-test-{attr}="{expected}"', dom) reconnect = ("Rejoin it with the new admin password." if protected else "Join it without a WiFi password, then sign in with the new admin password.") initial_note = ("Settings were sent. Sign in to read the saved result." if legacy else "Settings saved. The admin password changed.") self.assertIn('data-test-note="%s The setup WiFi may restart. %s"' % (initial_note, reconnect), dom) target = ("rejoin with the new admin password." if protected else "join without a WiFi password; web configuration and firmware uploads still require the new admin password.") self.assertIn('data-test-pending-banner="Setup WiFi restart pending. ' 'After it restarts, %s"' % target, dom) def test_admin_password_validation_counts_utf8_bytes(self): status, config = self.setup_values() status.update(mode="lan") vectors = ["", "a" * 15, "a" * 16, "é" * 7 + "a", "é" * 8, "🙂" * 3 + "abc", "🙂" * 4, "line\nbreak", "line\rbreak", "\ud800"] prelude = r'''''' % (json.dumps(status), json.dumps(config), json.dumps(vectors)) dom = self.run_page(prelude) self.assertIn('data-test-password-validation="valid,valid,invalid,valid,invalid,valid,invalid,invalid,invalid,invalid"', dom) if __name__ == "__main__": unittest.main()