Files
LEDMatrix/test/test_wifi_ap_state_handling.py
T
ChuckandClaude Opus 5.5 6cfcf2e384 fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety, BDF preview (#655)
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety

- Route plugin lookups (installed list, update, recorded version, config
  form, web UI pages) through the plugin manager's resolver so plugins in
  ledmatrix-<id> directories work.
- Captive-portal detection also sees the nmcli fallback AP (cached).
- WiFi monitor daemon re-reads wifi_config.json when its mtime changes.
- Drop the AP check in disconnect_from_network that could never fire.
- LED status file per WiFiManager; config path falls back to this checkout.
- BDF font preview via src.common.bdf_font.
- Asset uploads validate every file before saving; metadata and calendar
  credentials written atomically; no absolute path in the response;
  asset delete answers 400 for a missing body.
- Coerce string booleans in plugin toggle, on-demand start and AP force.
- SSE broadcaster clears its thread handle before exiting.
- start.py log filter handles every exc_info form.
- Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls,
  raw-config error helper, update-route tidy, redundant imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): request BDF font previews now that the server renders them

The Fonts tab skipped the preview request for .bdf files because the server
used to refuse them; /fonts/preview now draws BDF with the shared loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): take the update route's plugin directory from a directory listing

CodeQL flagged the path built from the request's plugin_id (the id was
already validated with safe_path_component, which CodeQL doesn't model; the
same flow on main is alerts 738/739). The directory is now the entry of
plugins_dir matched by name, so nothing built from user input reaches the
filesystem; an id with nothing installed goes to the store manager, which
reports it not found as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): read the blueprint's plugin_manager defensively in _plugin_directory

_get_plugin_version now goes through _plugin_directory, which read
api_v3.plugin_manager directly; the attribute exists only once the app sets
it, so test_path_traversal_guards::test_a_real_manifest_is_read failed
when run on its own (order-dependent in the full suite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:07 -04:00

199 lines
8.3 KiB
Python

"""WiFi / AP-mode fixes around the web UI and the monitor daemon.
* disconnect_from_network ran an AP-mode check that could never enable the
AP: the web routes build a fresh WiFiManager per request, whose grace
counter starts at 0 and needs 3 consecutive checks. It only added sleeps.
* The monitor daemon read wifi_config.json once at startup, so the web UI's
auto-enable toggle (which only writes the file) had no effect until the
daemon restarted.
* The captive-portal endpoints only checked hostapd, but enable_ap_mode falls
back to an nmcli AP; with that AP up, phones got "internet works".
* The LED status file path was a module global set by whichever WiFiManager
was built first, and the config path fell back to /home/ledpi/LEDMatrix.
"""
import importlib.util
import json
import logging
import os
import pathlib
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
import src.wifi_manager as wm # noqa: E402
from src.wifi_manager import WiFiManager, WiFiStatus # noqa: E402
REPO = Path(__file__).resolve().parents[1]
def _ok(stdout=""):
return SimpleNamespace(returncode=0, stdout=stdout, stderr="")
def _manager(config_path):
with patch("src.wifi_manager.subprocess.run", return_value=_ok("wlan0\n")):
manager = WiFiManager(config_path=config_path)
manager._wifi_interface = "wlan0"
manager.has_nmcli = True
return manager
class TestDisconnect:
def test_no_ap_check_and_no_extra_sleep(self, tmp_path):
manager = _manager(tmp_path / "config" / "wifi_config.json")
manager.get_wifi_status = MagicMock(
return_value=WiFiStatus(connected=True, ssid="home"))
manager._find_profile_for_ssid = MagicMock(return_value=None)
manager.check_and_manage_ap_mode = MagicMock()
sleeps = []
with patch("src.wifi_manager.subprocess.run", return_value=_ok()), \
patch("src.wifi_manager.time.sleep", side_effect=sleeps.append):
ok, _message = manager.disconnect_from_network()
assert ok
manager.check_and_manage_ap_mode.assert_not_called()
assert sum(sleeps) == 2
class TestLedStatusFile:
def test_each_manager_writes_next_to_its_own_config(self, tmp_path, monkeypatch):
monkeypatch.setattr(wm, "LED_STATUS_FILE", None)
first = _manager(tmp_path / "a" / "wifi_config.json")
second = _manager(tmp_path / "b" / "wifi_config.json")
second._show_led_message("hello", duration=3)
assert json.loads((tmp_path / "b" / "wifi_status.json").read_text())["message"] == "hello"
assert not (tmp_path / "a" / "wifi_status.json").exists()
first._show_led_message("from a")
assert (tmp_path / "a" / "wifi_status.json").exists()
def test_the_default_manager_writes_where_the_display_reads(self, tmp_path):
manager = _manager(tmp_path / "config" / "wifi_config.json")
assert manager._led_status_file == tmp_path / "config" / "wifi_status.json"
# And for the default config, that is get_wifi_status_path().
assert wm.get_wifi_config_path().parent / "wifi_status.json" == wm.get_wifi_status_path()
class TestConfigPathFallback:
def test_without_a_config_dir_the_project_root_is_used(self, monkeypatch):
monkeypatch.delenv("LEDMATRIX_ROOT", raising=False)
# A fresh checkout has no config/ yet; the fallback was a hardcoded
# /home/ledpi/LEDMatrix, which is nobody's checkout on most installs.
monkeypatch.setattr(pathlib.Path, "exists", lambda self: False)
assert wm.get_wifi_config_path() == REPO / "config" / "wifi_config.json"
def _load_daemon():
"""Import scripts/utils/wifi_monitor_daemon.py without its /var/log
FileHandler, which cannot be opened off the Pi."""
path = REPO / "scripts" / "utils" / "wifi_monitor_daemon.py"
spec = importlib.util.spec_from_file_location("wifi_monitor_daemon_under_test", path)
module = importlib.util.module_from_spec(spec)
with patch.object(logging, "FileHandler", lambda *a, **k: logging.NullHandler()):
spec.loader.exec_module(module)
return module
class TestDaemonReloadsConfig:
@pytest.fixture
def daemon(self, tmp_path):
module = _load_daemon()
config_path = tmp_path / "wifi_config.json"
config_path.write_text(json.dumps({"auto_enable_ap_mode": True}))
manager = SimpleNamespace(config_path=config_path,
config={"auto_enable_ap_mode": True})
manager._load_config = MagicMock(side_effect=lambda: manager.config.update(
json.loads(config_path.read_text())))
daemon = module.WiFiMonitorDaemon.__new__(module.WiFiMonitorDaemon)
daemon.wifi_manager = manager
daemon._config_mtime = daemon._config_file_mtime()
return daemon, manager, config_path
def _rewrite(self, config_path, data):
before = config_path.stat().st_mtime_ns
config_path.write_text(json.dumps(data))
os.utime(config_path, ns=(before + 10**9, before + 10**9))
def test_a_changed_file_is_reread(self, daemon):
daemon, manager, config_path = daemon
self._rewrite(config_path, {"auto_enable_ap_mode": False})
daemon._reload_config_if_changed()
assert manager.config["auto_enable_ap_mode"] is False
def test_an_unchanged_file_is_not(self, daemon):
daemon, manager, _ = daemon
daemon._reload_config_if_changed()
daemon._reload_config_if_changed()
manager._load_config.assert_not_called()
def test_the_loop_rereads_before_each_check(self, tmp_path):
module = _load_daemon()
daemon = module.WiFiMonitorDaemon.__new__(module.WiFiMonitorDaemon)
daemon.check_interval = 0
daemon.running = True
daemon.last_state = None
daemon._consecutive_internet_failures = 0
daemon._nm_restart_threshold = 5
order = []
manager = MagicMock()
manager.config = {}
manager.get_wifi_status.return_value = WiFiStatus(connected=False)
manager._is_ethernet_connected.return_value = False
manager.check_and_manage_ap_mode_with_state.side_effect = lambda: (
order.append("check"), (False, WiFiStatus(connected=False), False, False))[1]
daemon.wifi_manager = manager
daemon._reload_config_if_changed = lambda: order.append("reload")
def stop(_seconds):
daemon.running = False
with patch.object(module.time, "sleep", side_effect=stop):
daemon.run()
assert order == ["reload", "check"]
class TestCaptivePortalSeesTheNmcliAp:
@pytest.fixture
def web_app(self, monkeypatch):
import web_interface.app as web_app
from web_interface.cache import TTLCache
monkeypatch.setattr(web_app, "_service_status_cache", TTLCache())
monkeypatch.setattr(web_app, "_SYSTEMCTL", "/bin/systemctl")
monkeypatch.setattr(web_app, "_NMCLI", "/usr/bin/nmcli")
return web_app
def _run(self, active_connections):
calls = []
def run(argv, **kwargs):
calls.append(argv)
if "is-active" in argv:
return _ok("inactive\n")
return _ok(active_connections)
return calls, run
def test_an_nmcli_ap_counts_as_ap_mode(self, web_app, monkeypatch):
calls, run = self._run("LEDMatrix-Setup-AP:802-11-wireless\n")
monkeypatch.setattr(web_app.subprocess, "run", run)
assert web_app.is_ap_mode_active() is True
# Cached: these endpoints are hit per request.
web_app.is_ap_mode_active()
assert len(calls) == 2
def test_an_ordinary_wifi_connection_does_not(self, web_app, monkeypatch):
_, run = self._run("home:802-11-wireless\nWired connection 1:802-3-ethernet\n")
monkeypatch.setattr(web_app.subprocess, "run", run)
assert web_app.is_ap_mode_active() is False
def test_the_detection_endpoints_redirect(self, web_app, monkeypatch):
_, run = self._run("LEDMatrix-Setup-AP:802-11-wireless\n")
monkeypatch.setattr(web_app.subprocess, "run", run)
web_app.app.config["TESTING"] = True
with web_app.app.test_client() as client:
response = client.get("/generate_204")
assert response.status_code == 302