From d25e97af3c909236f7c48ac803c8487f6ccc178e Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 21 May 2026 11:32:08 +0100 Subject: [PATCH] feat: implement setup status check and reject subsequent setups after completion --- repeater/web/api_endpoints.py | 69 +++++++++++++++++++++++---------- tests/test_setup_wizard_pymc.py | 26 +++++++++++++ 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 8ff039c..189d029 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -297,6 +297,25 @@ class APIEndpoints: values = [v if v is not None else 0 for v in data_points] return [[timestamps_ms[i], values[i]] for i in range(min(len(values), len(timestamps_ms)))] + def _setup_status_from_config(self, config: dict) -> tuple[bool, dict]: + """Return whether first-run setup should still be available.""" + node_name = config.get("repeater", {}).get("node_name", "") + has_default_name = node_name in ["mesh-repeater-01", ""] + + admin_password = config.get("repeater", {}).get("security", {}).get("admin_password", "") + has_default_password = admin_password in ["admin123", ""] + + radio_type_raw = config.get("radio_type") + radio_type = "" if radio_type_raw is None else str(radio_type_raw).lower().strip() + radio_not_configured = radio_type in ("", "none", "null", "disabled", "off", "no_radio") + + reasons = { + "default_name": has_default_name, + "default_password": has_default_password, + "radio_not_configured": radio_not_configured, + } + return has_default_name or has_default_password or radio_not_configured, reasons + # ============================================================================ # SETUP WIZARD ENDPOINTS # ============================================================================ @@ -306,30 +325,22 @@ class APIEndpoints: def needs_setup(self): """Check if the repeater needs initial setup configuration""" try: + # Prefer the on-disk config so this reflects current persisted state. + import yaml + config = self.config + try: + with open(self._config_path, "r") as f: + config = yaml.safe_load(f) or {} + except Exception: + # Fall back to in-memory config if file cannot be read. + pass - # Check for default values that indicate first-time setup - node_name = config.get("repeater", {}).get("node_name", "") - has_default_name = node_name in ["mesh-repeater-01", ""] - - admin_password = ( - config.get("repeater", {}).get("security", {}).get("admin_password", "") - ) - has_default_password = admin_password in ["admin123", ""] - - radio_type_raw = config.get("radio_type") - radio_type = "" if radio_type_raw is None else str(radio_type_raw).lower().strip() - radio_not_configured = radio_type in ("", "none", "null", "disabled", "off", "no_radio") - - needs_setup = has_default_name or has_default_password or radio_not_configured + needs_setup, reasons = self._setup_status_from_config(config) return { "needs_setup": needs_setup, - "reasons": { - "default_name": has_default_name, - "default_password": has_default_password, - "radio_not_configured": radio_not_configured, - }, + "reasons": reasons, } except Exception as e: logger.error(f"Error checking setup status: {e}") @@ -412,6 +423,24 @@ class APIEndpoints: self._require_post() data = cherrypy.request.json + import yaml + + # Setup wizard is first-run only. After setup, use /auth/change_password + # and /api/update_radio_config for subsequent changes. + try: + with open(self._config_path, "r") as f: + current_config = yaml.safe_load(f) or {} + except Exception: + current_config = self.config or {} + + needs_setup, _ = self._setup_status_from_config(current_config) + if not needs_setup: + cherrypy.response.status = 403 + return { + "success": False, + "error": "Setup is already complete. Use authenticated endpoints for configuration changes.", + } + # Validate required fields node_name = data.get("node_name", "").strip() if not node_name: @@ -452,8 +481,6 @@ class APIEndpoints: else: hw_config = {} - import yaml - # Read current config first so we can update it with open(self._config_path, "r") as f: config_yaml = yaml.safe_load(f) diff --git a/tests/test_setup_wizard_pymc.py b/tests/test_setup_wizard_pymc.py index 12ce42f..7bae1bb 100644 --- a/tests/test_setup_wizard_pymc.py +++ b/tests/test_setup_wizard_pymc.py @@ -206,3 +206,29 @@ def test_wizard_kiss_branch_unchanged(wizard_env, tmp_path): assert written["radio_type"] == "kiss" assert written["kiss"]["port"] == "/dev/ttyUSB0" assert written["kiss"]["baud_rate"] == 115200 + + +def test_wizard_rejected_after_setup_complete(wizard_env): + """setup_wizard should be first-run only once config is already initialized.""" + tmp_path, config_path, endpoints, set_request = wizard_env + + configured = { + "repeater": {"node_name": "already-set", "security": {"admin_password": "verysecret"}}, + "radio_type": "pymc_tcp", + "radio": { + "frequency": 869618000, + "spreading_factor": 8, + "bandwidth": 62500, + "coding_rate": 8, + }, + } + with open(config_path, "w") as f: + yaml.safe_dump(configured, f) + + body = dict(_BASE_REQUEST, hardware_key="pymc_tcp", pymc_tcp_host="modem.local") + set_request(body) + + result = endpoints.setup_wizard() + + assert result["success"] is False + assert "already complete" in result["error"].lower()