From b5cce556041cfd2c3c2f221688ffaae703ebe35b Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 16 Apr 2026 19:56:47 -0700 Subject: [PATCH] feat(config): add configurable timeouts for web viewer integration Introduced optional timeout settings in the configuration for various web viewer operations, including edge and node post timeouts, SQLite connection timeout, and requeue timeout. Updated the web viewer integration to utilize these settings, enhancing flexibility and reliability. Added commands to inspect the resolved configuration with sensitive keys redacted, and updated documentation accordingly. --- config.ini.example | 11 +++ docs/config-validation.md | 7 ++ docs/getting-started.md | 11 +++ meshcore_bot.py | 37 ++++++++++ modules/commands/status_command.py | 73 +++++++++++++++++++ modules/config_snapshot.py | 43 ++++++++++++ modules/web_viewer/app.py | 9 +-- modules/web_viewer/integration.py | 85 ++++++++++++++++++---- pyproject.toml | 4 +- tests/test_meshcore_bot_cli.py | 101 +++++++++++++++++++++++++++ tests/test_status_command.py | 88 +++++++++++++++++++++++ tests/test_web_viewer_app.py | 25 +++++++ tests/test_web_viewer_integration.py | 37 +++++++++- 13 files changed, 509 insertions(+), 22 deletions(-) create mode 100644 modules/commands/status_command.py create mode 100644 modules/config_snapshot.py create mode 100644 tests/test_meshcore_bot_cli.py create mode 100644 tests/test_status_command.py diff --git a/config.ini.example b/config.ini.example index 8116716..db90963 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1392,6 +1392,17 @@ debug = false # false: Start web viewer manually (recommended) auto_start = false +# Optional timeout overrides for web viewer integration (seconds) +# edge_post_timeout_sec = 1.0 +# node_post_timeout_sec = 0.5 +# sqlite_connect_timeout_sec = 60.0 +# requeue_put_timeout_sec = 60.0 +# integration_shutdown_join_timeout_sec = 5.0 +# viewer_stop_grace_timeout_sec = 5.0 +# viewer_stop_force_timeout_sec = 2.0 +# port_cleanup_lsof_timeout_sec = 5.0 +# port_cleanup_kill_timeout_sec = 2.0 + # Optional: database path for web viewer. If unset, the viewer uses [Bot] db_path (recommended). # Only set this if you use a separate database for the viewer; you will see a startup warning. # See docs/web-viewer.md for migrating from a separate database. diff --git a/docs/config-validation.md b/docs/config-validation.md index b4041d7..b3a10b7 100644 --- a/docs/config-validation.md +++ b/docs/config-validation.md @@ -16,6 +16,13 @@ python validate_config.py [--config config.ini] python meshcore_bot.py --validate-config [--config config.ini] ``` +**Inspect resolved config** (redacted, then exit): + +```bash +python meshcore_bot.py --show-config [--config config.ini] +python meshcore_bot.py --show-config-json [--config config.ini] +``` + - **Exit 0** – No errors (warnings and info may still be printed). - **Exit 1** – One or more errors; fix them before starting the bot. diff --git a/docs/getting-started.md b/docs/getting-started.md index 695a2fd..0850f0e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,6 +39,17 @@ Get meshcore-bot running on your machine in a few minutes. python3 meshcore_bot.py ``` +## Inspect effective config safely + +Use these commands to inspect the resolved config with sensitive keys redacted: + +```bash +python3 meshcore_bot.py --show-config --config config.ini +python3 meshcore_bot.py --show-config-json --config config.ini +``` + +Also available in the web UI at `/admin/config`. + ## Production deployment ### Systemd service diff --git a/meshcore_bot.py b/meshcore_bot.py index b3cda16..469721d 100644 --- a/meshcore_bot.py +++ b/meshcore_bot.py @@ -6,9 +6,13 @@ Uses a modular structure for command creation and organization import argparse import asyncio +import configparser +import json import signal import sys +from modules.config_snapshot import config_to_redacted_sections, redacted_sections_to_ini_text + def _configure_unix_signal_handlers(loop, bot, shutdown_event: asyncio.Event) -> None: """Register Unix signal handlers for shutdown and config reload.""" @@ -55,9 +59,42 @@ def main(): action="store_true", help="Validate config section names and exit before starting the bot (exit 1 on errors)", ) + parser.add_argument( + "--show-config", + action="store_true", + help="Print resolved config.ini with sensitive keys redacted and exit", + ) + parser.add_argument( + "--show-config-json", + action="store_true", + help="Print resolved config.ini as redacted JSON and exit", + ) args = parser.parse_args() + if args.show_config and args.show_config_json: + print("Error: --show-config and --show-config-json are mutually exclusive", file=sys.stderr) + sys.exit(1) + + if args.show_config or args.show_config_json: + cfg = configparser.ConfigParser() + try: + loaded_paths = cfg.read(args.config, encoding="utf-8") + except configparser.Error as exc: + print(f"Error: Invalid config file '{args.config}': {exc}", file=sys.stderr) + sys.exit(1) + + if not loaded_paths: + print(f"Error: Config file not found: {args.config}", file=sys.stderr) + sys.exit(1) + + sections = config_to_redacted_sections(cfg) + if args.show_config_json: + print(json.dumps(sections, indent=2, sort_keys=True)) + else: + print(redacted_sections_to_ini_text(sections)) + sys.exit(0) + if args.validate_config: from modules.config_validation import ( SEVERITY_ERROR, diff --git a/modules/commands/status_command.py b/modules/commands/status_command.py new file mode 100644 index 0000000..8c2fc8b --- /dev/null +++ b/modules/commands/status_command.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Status command +DM-only admin command that reports bot runtime status in a single response. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from ..models import MeshMessage +from .base_command import BaseCommand + + +class StatusCommand(BaseCommand): + """Report high-level runtime status for operators.""" + + name = "status" + keywords = ["status"] + description = "Show runtime status (DM only, admin only)" + requires_dm = True + cooldown_seconds = 2 + category = "admin" + + short_description = "Show runtime status for operators" + usage = "status" + examples = ["status", "!status"] + parameters: list[dict[str, str]] = [] + + def __init__(self, bot: Any): + super().__init__(bot) + self.status_enabled = self.get_config_value( + "Status_Command", + "enabled", + fallback=True, + value_type="bool", + ) + + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: + if not self.status_enabled: + return False + if not self.requires_admin_access(): + return False + return super().can_execute(message, skip_channel_check=skip_channel_check) + + def requires_admin_access(self) -> bool: + return True + + async def execute(self, message: MeshMessage) -> bool: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + connected = bool(getattr(self.bot, "connected", False)) + radio_zombie = bool(getattr(self.bot, "is_radio_zombie", False)) + radio_offline = bool(getattr(self.bot, "is_radio_offline", False)) + + web_running = False + integration = getattr(self.bot, "web_viewer_integration", None) + if integration is not None: + web_running = bool(getattr(integration, "running", False)) + + paused = not bool(getattr(self.bot, "channel_responses_enabled", True)) + + status_text = ( + "Bot Status\n" + f"- time: {now}\n" + f"- connected: {connected}\n" + f"- radio_zombie: {radio_zombie}\n" + f"- radio_offline: {radio_offline}\n" + f"- channel_responses_paused: {paused}\n" + f"- web_viewer_running: {web_running}" + ) + await self.send_response(message, status_text) + return True diff --git a/modules/config_snapshot.py b/modules/config_snapshot.py new file mode 100644 index 0000000..dd07b90 --- /dev/null +++ b/modules/config_snapshot.py @@ -0,0 +1,43 @@ +"""Helpers for rendering resolved config snapshots with redaction.""" + +from __future__ import annotations + +from configparser import ConfigParser + +_REDACT_KEY_PARTS: tuple[str, ...] = ( + "password", + "smtp_password", + "api_key", + "token", + "secret", + "smtp_user", +) + + +def is_sensitive_key(key: str) -> bool: + """Return True when a config key should be redacted.""" + key_lower = key.lower() + return any(part in key_lower for part in _REDACT_KEY_PARTS) + + +def config_to_redacted_sections(config: ConfigParser) -> dict[str, dict[str, str]]: + """Return config sections as key/value maps with sensitive keys redacted.""" + sections: dict[str, dict[str, str]] = {} + for section in config.sections(): + sections[section] = { + key: "●●●●●●" if is_sensitive_key(key) else value + for key, value in config.items(section, raw=True) + } + return sections + + +def redacted_sections_to_ini_text(sections: dict[str, dict[str, str]]) -> str: + """Render redacted config sections as human-readable INI text.""" + lines: list[str] = [] + for idx, (section_name, options) in enumerate(sections.items()): + if idx > 0: + lines.append("") + lines.append(f"[{section_name}]") + for key, value in options.items(): + lines.append(f"{key} = {value}") + return "\n".join(lines) diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 5cea087..5af7df0 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -94,6 +94,7 @@ from modules.feed_manager import FeedManager from modules.repeater_manager import RepeaterManager from modules.url_shortener import _coerce_url_string from modules.utils import calculate_distance, resolve_path +from modules.config_snapshot import config_to_redacted_sections from modules.web_viewer.config_panels import CONFIG_PANELS, PANEL_CATEGORIES from modules.web_viewer.integration import normalized_web_viewer_password @@ -2028,13 +2029,7 @@ class BotDataViewer: @self.app.route('/admin/config') def admin_config(): """Resolved config viewer — shows effective config.ini values with sensitive fields redacted.""" - _REDACT_KEYS = {'password', 'smtp_password', 'api_key', 'token', 'secret', 'smtp_user'} - sections = {} - for section in self.config.sections(): - sections[section] = { - k: '●●●●●●' if any(r in k for r in _REDACT_KEYS) else v - for k, v in self.config.items(section, raw=True) - } + sections = config_to_redacted_sections(self.config) return render_template('admin_config.html', sections=sections, config_path=self.config_path) @self.app.route('/mesh') diff --git a/modules/web_viewer/integration.py b/modules/web_viewer/integration.py index 80609ab..611b5c5 100644 --- a/modules/web_viewer/integration.py +++ b/modules/web_viewer/integration.py @@ -54,6 +54,11 @@ class BotIntegration: # Bounded packet_stream write queue: producers block up to this long before drop/retry. _WRITE_QUEUE_PUT_TIMEOUT_SEC = 5.0 + EDGE_POST_TIMEOUT_SEC = 1.0 + NODE_POST_TIMEOUT_SEC = 0.5 + SQLITE_CONNECT_TIMEOUT_SEC = 60.0 + REQUEUE_PUT_TIMEOUT_SEC = 60.0 + SHUTDOWN_JOIN_TIMEOUT_SEC = 5.0 def __init__(self, bot): self.bot = bot @@ -76,6 +81,7 @@ class BotIntegration: self._write_queue: queue.Queue = queue.Queue(maxsize=self._write_queue_maxsize) self._drain_stop = threading.Event() self._drain_thread: Optional[threading.Thread] = None + self._load_timeouts_from_config() # Initialize HTTP session with connection pooling for efficient reuse self._init_http_session() # Generate a shared secret for authenticating internal /api/stream_data calls. @@ -90,6 +96,33 @@ class BotIntegration: # Start background drain thread after table is confirmed to exist self._start_drain_thread() + def _get_float_config(self, key: str, fallback: float) -> float: + """Read float from [Web_Viewer] config with sane fallback.""" + try: + value = self.bot.config.getfloat("Web_Viewer", key, fallback=fallback) + return value if value > 0 else fallback + except (ValueError, TypeError, OSError): + return fallback + + def _load_timeouts_from_config(self) -> None: + """Load optional BotIntegration timeout settings from config.""" + self.edge_post_timeout_sec = self._get_float_config( + "edge_post_timeout_sec", self.EDGE_POST_TIMEOUT_SEC + ) + self.node_post_timeout_sec = self._get_float_config( + "node_post_timeout_sec", self.NODE_POST_TIMEOUT_SEC + ) + self.sqlite_connect_timeout_sec = self._get_float_config( + "sqlite_connect_timeout_sec", self.SQLITE_CONNECT_TIMEOUT_SEC + ) + self.requeue_put_timeout_sec = self._get_float_config( + "requeue_put_timeout_sec", self.REQUEUE_PUT_TIMEOUT_SEC + ) + self.shutdown_join_timeout_sec = self._get_float_config( + "integration_shutdown_join_timeout_sec", + self.SHUTDOWN_JOIN_TIMEOUT_SEC, + ) + def _init_http_session(self): """Initialize a requests.Session with connection pooling and keep-alive""" try: @@ -188,7 +221,7 @@ class BotIntegration: import sqlite3 db_path = self._get_web_viewer_db_path() - with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn: + with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn: cur = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='packet_stream'" ) @@ -247,7 +280,7 @@ class BotIntegration: """Restore rows to the queue after a failed flush (FIFO). Logs if the queue stays full.""" for i, row in enumerate(rows): try: - self._write_queue.put(row, timeout=60.0) + self._write_queue.put(row, timeout=self.requeue_put_timeout_sec) except queue.Full: remaining = len(rows) - i self.bot.logger.error( @@ -275,7 +308,7 @@ class BotIntegration: max_retries = 3 for attempt in range(max_retries): try: - with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn: + with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn: conn.executemany( 'INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)', rows, @@ -451,7 +484,7 @@ class BotIntegration: cutoff_time = time.time() - (days_to_keep * 24 * 60 * 60) db_path = self._get_web_viewer_db_path() - with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn: + with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn: cursor = conn.cursor() # Clean up old packet stream data @@ -513,14 +546,14 @@ class BotIntegration: } if self.http_session: try: - self.http_session.post(url, json=payload, timeout=1.0) + self.http_session.post(url, json=payload, timeout=self.edge_post_timeout_sec) self._record_web_viewer_result(True) except Exception: self._record_web_viewer_result(False) else: import requests try: - requests.post(url, json=payload, timeout=1.0, headers=headers) + requests.post(url, json=payload, timeout=self.edge_post_timeout_sec, headers=headers) self._record_web_viewer_result(True) except Exception: self._record_web_viewer_result(False) @@ -548,7 +581,7 @@ class BotIntegration: 'X-Requested-With': 'BotIntegration', } try: - requests.post(url, json=payload, timeout=0.5, headers=headers) + requests.post(url, json=payload, timeout=self.node_post_timeout_sec, headers=headers) self._record_web_viewer_result(True) except Exception: self._record_web_viewer_result(False) @@ -561,7 +594,7 @@ class BotIntegration: # Stop drain thread and do a final flush of any queued rows self._drain_stop.set() if self._drain_thread and self._drain_thread.is_alive(): - self._drain_thread.join(timeout=5.0) + self._drain_thread.join(timeout=self.shutdown_join_timeout_sec) self._flush_write_queue() # Close HTTP session to clean up connections if hasattr(self, 'http_session') and self.http_session: @@ -573,6 +606,10 @@ class WebViewerIntegration: # Whitelist of allowed host bindings for security ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0'] + VIEWER_STOP_GRACE_TIMEOUT_SEC = 5.0 + VIEWER_STOP_FORCE_TIMEOUT_SEC = 2.0 + PORT_CLEANUP_LSOF_TIMEOUT_SEC = 5.0 + PORT_CLEANUP_KILL_TIMEOUT_SEC = 2.0 def __init__(self, bot): self.bot = bot @@ -591,6 +628,22 @@ class WebViewerIntegration: self.port = bot.config.getint('Web_Viewer', 'port', fallback=8080) # Web viewer uses 8080 self.debug = bot.config.getboolean('Web_Viewer', 'debug', fallback=False) self.auto_start = bot.config.getboolean('Web_Viewer', 'auto_start', fallback=False) + self.viewer_stop_grace_timeout_sec = self._get_float_config( + "viewer_stop_grace_timeout_sec", + self.VIEWER_STOP_GRACE_TIMEOUT_SEC, + ) + self.viewer_stop_force_timeout_sec = self._get_float_config( + "viewer_stop_force_timeout_sec", + self.VIEWER_STOP_FORCE_TIMEOUT_SEC, + ) + self.port_cleanup_lsof_timeout_sec = self._get_float_config( + "port_cleanup_lsof_timeout_sec", + self.PORT_CLEANUP_LSOF_TIMEOUT_SEC, + ) + self.port_cleanup_kill_timeout_sec = self._get_float_config( + "port_cleanup_kill_timeout_sec", + self.PORT_CLEANUP_KILL_TIMEOUT_SEC, + ) # Validate configuration for security self._validate_config() @@ -608,6 +661,14 @@ class WebViewerIntegration: if self.enabled and self.auto_start: self.start_viewer() + def _get_float_config(self, key: str, fallback: float) -> float: + """Read float timeout from [Web_Viewer] with positive-value guard.""" + try: + value = self.bot.config.getfloat("Web_Viewer", key, fallback=fallback) + return value if value > 0 else fallback + except (ValueError, TypeError, OSError): + return fallback + def _validate_config(self): """Validate web viewer configuration for security""" # Validate host against whitelist @@ -667,13 +728,13 @@ class WebViewerIntegration: try: # First try graceful termination self.viewer_process.terminate() - self.viewer_process.wait(timeout=5) + self.viewer_process.wait(timeout=self.viewer_stop_grace_timeout_sec) self.logger.info("Web viewer stopped gracefully") except subprocess.TimeoutExpired: self.logger.warning("Web viewer did not stop gracefully, forcing termination") try: self.viewer_process.kill() - self.viewer_process.wait(timeout=2) + self.viewer_process.wait(timeout=self.viewer_stop_force_timeout_sec) except subprocess.TimeoutExpired: self.logger.error("Failed to kill web viewer process") except Exception as e: @@ -706,7 +767,7 @@ class WebViewerIntegration: # Additional cleanup: kill any remaining processes on the port try: result = subprocess.run(['lsof', '-ti', f':{self.port}'], - capture_output=True, text=True, timeout=5) + capture_output=True, text=True, timeout=self.port_cleanup_lsof_timeout_sec) if result.returncode == 0 and result.stdout.strip(): pids = result.stdout.strip().split('\n') for pid in pids: @@ -726,7 +787,7 @@ class WebViewerIntegration: self.logger.warning(f"Refusing to kill system PID: {pid}") continue - subprocess.run(['kill', '-9', str(pid_int)], timeout=2) + subprocess.run(['kill', '-9', str(pid_int)], timeout=self.port_cleanup_kill_timeout_sec) self.logger.info(f"Killed remaining process {pid} on port {self.port}") except (ValueError, subprocess.TimeoutExpired) as e: self.logger.warning(f"Failed to kill process {pid}: {e}") diff --git a/pyproject.toml b/pyproject.toml index cd5b788..886e8c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta" [project] name = "meshcore-bot" -version = "0.1.0" -description = "MeshCore Bot using the meshcore-cli and meshcore.py packages" +version = "0.9.0" +description = "MeshCore Bot with commands for mesh testing, utilities, and service integration." readme = "README.md" requires-python = ">=3.9" dependencies = [ diff --git a/tests/test_meshcore_bot_cli.py b/tests/test_meshcore_bot_cli.py new file mode 100644 index 0000000..c3b45a4 --- /dev/null +++ b/tests/test_meshcore_bot_cli.py @@ -0,0 +1,101 @@ +"""Tests for meshcore_bot.py CLI config-inspection flags.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from meshcore_bot import main + + +def _write_config(path: Path) -> None: + path.write_text( + """[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +db_path = /tmp/bot.db +api_token = super-secret-token + +[Notifications] +smtp_user = alerts@example.com +smtp_password = hunter2 +recipient = ops@example.com +""", + encoding="utf-8", + ) + + +def test_show_config_prints_redacted_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None: + config_path = tmp_path / "config.ini" + _write_config(config_path) + monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(config_path)]) + + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + + out = capsys.readouterr().out + assert "[Bot]" in out + assert "db_path = /tmp/bot.db" in out + assert "api_token = ●●●●●●" in out + assert "smtp_user = ●●●●●●" in out + assert "smtp_password = ●●●●●●" in out + + +def test_show_config_json_prints_redacted_json( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config_path = tmp_path / "config.ini" + _write_config(config_path) + monkeypatch.setattr( + sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(config_path)] + ) + + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + + payload = json.loads(capsys.readouterr().out) + assert payload["Bot"]["db_path"] == "/tmp/bot.db" + assert payload["Bot"]["api_token"] == "●●●●●●" + assert payload["Notifications"]["smtp_password"] == "●●●●●●" + + +def test_show_config_missing_file_exits_1( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + missing_path = tmp_path / "missing.ini" + monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(missing_path)]) + + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + err = capsys.readouterr().err + assert "Config file not found" in err + + +def test_show_config_invalid_ini_exits_1( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + bad_path = tmp_path / "bad.ini" + bad_path.write_text("[Connection\nserial_port=/dev/ttyUSB0\n", encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(bad_path)]) + + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + err = capsys.readouterr().err + assert "Invalid config file" in err diff --git a/tests/test_status_command.py b/tests/test_status_command.py new file mode 100644 index 0000000..002db03 --- /dev/null +++ b/tests/test_status_command.py @@ -0,0 +1,88 @@ +"""Tests for modules.commands.status_command.""" + +import asyncio +import configparser +from unittest.mock import AsyncMock, MagicMock, Mock + +from modules.commands.status_command import StatusCommand +from tests.conftest import mock_message + + +def _make_bot(*, enabled=True): + bot = MagicMock() + bot.logger = Mock() + bot.connected = True + bot.is_radio_zombie = False + bot.is_radio_offline = False + bot.channel_responses_enabled = True + bot.web_viewer_integration = MagicMock() + bot.web_viewer_integration.running = True + + config = configparser.ConfigParser() + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general") + config.add_section("Keywords") + config.add_section("Status_Command") + config.set("Status_Command", "enabled", "true" if enabled else "false") + config.add_section("Admin_ACL") + config.set("Admin_ACL", "admin_pubkeys", "a" * 64) + config.set("Admin_ACL", "admin_commands", "status") + bot.config = config + + bot.translator = MagicMock() + bot.translator.translate = Mock(side_effect=lambda key, **kw: key) + bot.translator.get_value = Mock(return_value=None) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general"] + bot.command_manager.send_response = AsyncMock(return_value=True) + return bot + + +def _run(coro): + return asyncio.run(coro) + + +class TestStatusCommandPermissions: + def test_dm_admin_allowed(self): + cmd = StatusCommand(_make_bot(enabled=True)) + msg = mock_message(content="status", is_dm=True, sender_id="user1") + msg.sender_pubkey = "a" * 64 + assert cmd.can_execute(msg) is True + + def test_channel_disallowed(self): + cmd = StatusCommand(_make_bot(enabled=True)) + msg = mock_message(content="status", is_dm=False, sender_id="user1") + msg.sender_pubkey = "a" * 64 + assert cmd.can_execute(msg) is False + + def test_non_admin_disallowed(self): + cmd = StatusCommand(_make_bot(enabled=True)) + msg = mock_message(content="status", is_dm=True, sender_id="user1") + msg.sender_pubkey = "b" * 64 + assert cmd.can_execute(msg) is False + + def test_disabled_disallowed(self): + cmd = StatusCommand(_make_bot(enabled=False)) + msg = mock_message(content="status", is_dm=True, sender_id="user1") + msg.sender_pubkey = "a" * 64 + assert cmd.can_execute(msg) is False + + +class TestStatusCommandExecute: + def test_execute_sends_single_status_message(self): + bot = _make_bot(enabled=True) + cmd = StatusCommand(bot) + msg = mock_message(content="status", is_dm=True, sender_id="user1") + msg.sender_pubkey = "a" * 64 + + result = _run(cmd.execute(msg)) + + assert result is True + bot.command_manager.send_response.assert_called_once() + text = bot.command_manager.send_response.call_args[0][1] + assert "Bot Status" in text + assert "connected: True" in text + assert "radio_zombie: False" in text + assert "web_viewer_running: True" in text diff --git a/tests/test_web_viewer_app.py b/tests/test_web_viewer_app.py index e5f594d..b943f79 100644 --- a/tests/test_web_viewer_app.py +++ b/tests/test_web_viewer_app.py @@ -13,6 +13,31 @@ import pytest # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def cleanup_sqlite_connections(monkeypatch): + """Track and close SQLite connections opened during each test. + + Some app code paths intentionally create ad-hoc connections for request-style + operations; this fixture ensures any leaked handles are closed so Python 3.13 + ResourceWarning checks stay clean. + """ + tracked_connections = [] + original_connect = sqlite3.connect + + def _tracked_connect(*args, **kwargs): + conn = original_connect(*args, **kwargs) + tracked_connections.append(conn) + return conn + + monkeypatch.setattr(sqlite3, "connect", _tracked_connect) + yield + for conn in tracked_connections: + try: + conn.close() + except sqlite3.Error: + pass + + @pytest.fixture def viewer_with_db(tmp_path): """Create a BotDataViewer instance with a test database. diff --git a/tests/test_web_viewer_integration.py b/tests/test_web_viewer_integration.py index 103e277..650e417 100644 --- a/tests/test_web_viewer_integration.py +++ b/tests/test_web_viewer_integration.py @@ -211,7 +211,7 @@ class TestInsertPacketStreamRow: def test_queue_exception_logged(self): bi = _make_bot_integration() bi._write_queue = Mock() - bi._write_queue.put_nowait.side_effect = Exception("full") + bi._write_queue.put.side_effect = Exception("full") # Should not raise bi._insert_packet_stream_row("{}", "packet") bi.bot.logger.warning.assert_called_once() @@ -491,3 +491,38 @@ class TestShutdown: bi._drain_thread.is_alive.return_value = True bi.shutdown() bi._drain_thread.join.assert_called_once() + + +class TestIntegrationTimeoutConfig: + def test_bot_integration_loads_custom_timeouts(self): + bot = _make_bot() + bot.config.set("Web_Viewer", "edge_post_timeout_sec", "2.5") + bot.config.set("Web_Viewer", "node_post_timeout_sec", "1.25") + bot.config.set("Web_Viewer", "sqlite_connect_timeout_sec", "42") + bot.config.set("Web_Viewer", "requeue_put_timeout_sec", "7") + bot.config.set("Web_Viewer", "integration_shutdown_join_timeout_sec", "3") + + bi = _make_bot_integration(bot) + assert bi.edge_post_timeout_sec == 2.5 + assert bi.node_post_timeout_sec == 1.25 + assert bi.sqlite_connect_timeout_sec == 42 + assert bi.requeue_put_timeout_sec == 7 + assert bi.shutdown_join_timeout_sec == 3 + + def test_web_viewer_integration_loads_custom_timeouts(self): + from modules.web_viewer.integration import WebViewerIntegration + + bot = _make_bot() + bot.config.set("Web_Viewer", "viewer_stop_grace_timeout_sec", "9") + bot.config.set("Web_Viewer", "viewer_stop_force_timeout_sec", "4") + bot.config.set("Web_Viewer", "port_cleanup_lsof_timeout_sec", "8") + bot.config.set("Web_Viewer", "port_cleanup_kill_timeout_sec", "1") + with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \ + patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \ + patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"): + wvi = WebViewerIntegration(bot) + + assert wvi.viewer_stop_grace_timeout_sec == 9 + assert wvi.viewer_stop_force_timeout_sec == 4 + assert wvi.port_cleanup_lsof_timeout_sec == 8 + assert wvi.port_cleanup_kill_timeout_sec == 1