Files
LEDMatrix/test/test_api_v3_wifi_endpoints.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

458 lines
22 KiB
Python

"""
Endpoint tests for the /wifi/* routes in api_v3.
These routes drive the host's actual networking — connecting, dropping a
connection, switching the radio off — and had no endpoint-level tests at
all. WiFiManager is mocked throughout; nothing here may touch real
networking.
Each handler does `from src.wifi_manager import WiFiManager` inside the
function body, so the patch target is the class at its definition site.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
@pytest.fixture
def wifi_manager():
"""Patch WiFiManager where it is defined; yield the instance mock."""
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
# A bare MagicMock is truthy, which would send every connect down
# the setup-AP background path.
instance._is_ap_mode_active.return_value = False
cls.return_value = instance
yield instance
class TestConnect:
URL = "/api/v3/wifi/connect"
def test_success(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "pw"})
assert response.status_code == 200
assert response.get_json()["message"] == "Connected to HomeNet"
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "pw")
def test_missing_body_rejected(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
wifi_manager.connect_to_network.assert_not_called()
def test_missing_ssid_rejected(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={"password": "pw"})
assert response.status_code == 400
assert "SSID is required" in response.get_json()["message"]
wifi_manager.connect_to_network.assert_not_called()
@pytest.mark.parametrize("ssid", ["", " ", "\t"])
def test_blank_ssid_rejected(self, api_v3_client, wifi_manager, ssid):
response = api_v3_client.post(self.URL, json={"ssid": ssid})
assert response.status_code == 400
wifi_manager.connect_to_network.assert_not_called()
def test_ssid_is_trimmed(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "ok")
api_v3_client.post(self.URL, json={"ssid": " HomeNet "})
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
def test_missing_password_becomes_empty_string(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "ok")
api_v3_client.post(self.URL, json={"ssid": "OpenNet"})
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
def test_null_password_becomes_empty_string(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "ok")
api_v3_client.post(self.URL, json={"ssid": "OpenNet", "password": None})
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
def test_failure_reports_the_managers_reason(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (False, "Bad password")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 400
assert response.get_json()["message"] == "Bad password"
def test_failure_without_reason_uses_fallback_text(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (False, None)
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 400
assert response.get_json()["message"] == "Failed to connect to network"
def test_manager_exception_is_a_500_without_leaking_internals(
self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError(
"/usr/lib/secret/path blew up")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 500
body = response.get_json()
assert body["message"] == "An error occurred; see logs for details"
# `details` comes from describe_exception, which is deliberately
# safe to return (redacted, capped) — it names the type.
assert "RuntimeError" in body["details"]
class TestConnectThroughSetupAp:
"""With the setup AP up, the phone making the request is connected through
the very network the connect tears down. A synchronous answer can never
arrive, so the route answers 202 first and connects in the background."""
URL = "/api/v3/wifi/connect"
@pytest.fixture(autouse=True)
def ap_active(self, api_v3_module, wifi_manager, monkeypatch):
from web_interface.blueprints.api_v3 import wifi as wifi_routes
wifi_manager._is_ap_mode_active.return_value = True
monkeypatch.setattr(wifi_routes, "_AP_HANDOFF_DELAY_SECONDS", 0)
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
self.spawned = []
monkeypatch.setattr(wifi_routes, "_spawn", self.spawned.append)
self.routes = wifi_routes
def _run_spawned(self):
assert len(self.spawned) == 1
self.spawned.pop()()
def test_answers_202_before_connecting(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
assert response.status_code == 202
assert response.get_json()["status"] == "pending"
wifi_manager.connect_to_network.assert_not_called()
assert self.routes._last_connect_snapshot()["state"] == "pending"
def test_background_result_is_reported_by_status(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
self._run_spawned()
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
wifi_manager.config = {}
wifi_manager.get_wifi_status.return_value = MagicMock(
connected=True, ssid="HomeNet", ip_address="10.0.0.5", signal=70,
ap_mode_active=False)
attempt = api_v3_client.get("/api/v3/wifi/status").get_json()["data"]["last_connect_attempt"]
assert attempt["ssid"] == "HomeNet"
assert attempt["state"] == "success"
assert "hunter22" not in str(attempt)
def test_wrong_password_is_flagged(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (
False, "wrong_password: Secrets were required, but not provided")
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
self._run_spawned()
attempt = self.routes._last_connect_snapshot()
assert attempt["state"] == "failed"
assert attempt["error_type"] == "wrong_password"
assert attempt["message"] == "Incorrect password for HomeNet"
def test_manager_exception_is_recorded_as_a_failure(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli vanished")
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
self._run_spawned()
attempt = self.routes._last_connect_snapshot()
assert attempt["state"] == "failed"
assert "RuntimeError" in attempt["message"]
def test_a_second_connect_while_one_is_pending_is_refused(self, api_v3_client, wifi_manager):
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
assert response.status_code == 409
assert len(self.spawned) == 1
def test_pending_is_honoured_after_the_ap_has_gone_down(self, api_v3_client, wifi_manager):
# The background attempt tears the AP down long before it finishes; a
# second click must not slip through as a synchronous connect.
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
wifi_manager._is_ap_mode_active.return_value = False
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
assert response.status_code == 409
wifi_manager.connect_to_network.assert_not_called()
def test_a_thread_that_fails_to_start_does_not_leave_pending_behind(
self, api_v3_client, wifi_manager, monkeypatch):
def refuse(_target):
raise RuntimeError("can't start new thread")
monkeypatch.setattr(self.routes, "_spawn", refuse)
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
assert self.routes._last_connect_snapshot() is None
monkeypatch.setattr(self.routes, "_spawn", self.spawned.append)
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 202
class TestConnectSerializedWithoutAp:
"""Without the AP the route connects synchronously, but still one attempt
at a time: a second connect_to_network would remove the in-progress flag
the daemon relies on when the first finishes."""
URL = "/api/v3/wifi/connect"
@pytest.fixture(autouse=True)
def no_prior_attempt(self, api_v3_module, monkeypatch):
from web_interface.blueprints.api_v3 import wifi as wifi_routes
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
self.routes = wifi_routes
def test_a_connect_during_a_synchronous_connect_is_refused(
self, api_v3_client, wifi_manager):
inner = []
def connect(ssid, password):
if not inner:
inner.append(api_v3_client.post(self.URL, json={"ssid": "OtherNet"}))
return True, f"Connected to {ssid}"
wifi_manager.connect_to_network.side_effect = connect
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 200
assert inner[0].status_code == 409
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
assert self.routes._last_connect_snapshot()["state"] == "success"
def test_an_exception_does_not_leave_pending_behind(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli gone")
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
assert self.routes._last_connect_snapshot()["state"] == "failed"
class TestDisconnect:
URL = "/api/v3/wifi/disconnect"
def test_success(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.return_value = (True, "Disconnected")
response = api_v3_client.post(self.URL)
assert response.status_code == 200
assert response.get_json()["message"] == "Disconnected"
def test_failure(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.return_value = (False, "Not connected")
response = api_v3_client.post(self.URL)
assert response.status_code == 400
assert response.get_json()["message"] == "Not connected"
def test_failure_without_reason_uses_fallback(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.return_value = (False, "")
response = api_v3_client.post(self.URL)
assert response.get_json()["message"] == "Failed to disconnect from network"
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.disconnect_from_network.side_effect = OSError("nmcli missing")
assert api_v3_client.post(self.URL).status_code == 500
class TestApMode:
ENABLE = "/api/v3/wifi/ap/enable"
DISABLE = "/api/v3/wifi/ap/disable"
def test_enable_success(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.return_value = (True, "AP enabled")
response = api_v3_client.post(self.ENABLE, json={})
assert response.status_code == 200
wifi_manager.enable_ap_mode.assert_called_once_with(force=False)
@pytest.mark.parametrize("raw,expected", [
(True, True), (False, False),
("true", True), ("TRUE", True), ("1", True),
("false", False), ("no", False),
# Parsed by _parse_bool_ish like every other boolean on these routes
# (the radio route's force included); this one used to have its own
# rules, under which "yes" and 1 meant False.
("yes", True), (1, True), (0, False),
# Anything unrecognised is not a request to force.
("typo", False), (None, False), (2, False),
])
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.enable_ap_mode.return_value = (True, "ok")
api_v3_client.post(self.ENABLE, json={"force": raw})
wifi_manager.enable_ap_mode.assert_called_once_with(force=expected)
def test_enable_without_body(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.return_value = (True, "ok")
assert api_v3_client.post(self.ENABLE).status_code == 200
def test_enable_failure(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.return_value = (False, "hostapd missing")
response = api_v3_client.post(self.ENABLE, json={})
assert response.status_code == 400
assert response.get_json()["message"] == "hostapd missing"
def test_disable_success(self, api_v3_client, wifi_manager):
wifi_manager.disable_ap_mode.return_value = (True, "AP disabled")
assert api_v3_client.post(self.DISABLE).status_code == 200
def test_disable_failure(self, api_v3_client, wifi_manager):
wifi_manager.disable_ap_mode.return_value = (False, "not running")
assert api_v3_client.post(self.DISABLE).status_code == 400
def test_enable_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.enable_ap_mode.side_effect = RuntimeError("boom")
assert api_v3_client.post(self.ENABLE, json={}).status_code == 500
class TestRadio:
URL = "/api/v3/wifi/radio"
def test_get_state(self, api_v3_client, wifi_manager):
wifi_manager.get_wifi_radio_state.return_value = {
"enabled": True, "ethernet_connected": False}
response = api_v3_client.get(self.URL)
assert response.status_code == 200
assert response.get_json()["data"]["enabled"] is True
def test_get_state_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.get_wifi_radio_state.side_effect = OSError("rfkill missing")
assert api_v3_client.get(self.URL).status_code == 500
def test_enabled_is_required(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
assert "enabled is required" in response.get_json()["message"]
wifi_manager.set_wifi_radio.assert_not_called()
def test_enable_success(self, api_v3_client, wifi_manager):
wifi_manager.set_wifi_radio.return_value = (True, "Radio on", None)
wifi_manager.get_wifi_radio_state.return_value = {"enabled": True}
response = api_v3_client.post(self.URL, json={"enabled": True})
assert response.status_code == 200
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=False)
@pytest.mark.parametrize("raw,expected", [
(True, True), ("true", True), ("1", True), ("yes", True),
(False, False), ("false", False), ("no", False), (0, False),
])
def test_enabled_coercion_is_string_aware(
self, api_v3_client, wifi_manager, raw, expected):
# bool("false") is True, so the endpoint parses strings explicitly
# rather than trusting truthiness — it is a public contract, not
# only the shipped UI which always sends real JSON booleans.
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
def test_force_passed_through(self, api_v3_client, wifi_manager):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": False, "force": "true"})
wifi_manager.set_wifi_radio.assert_called_once_with(False, force=True)
def test_refusal_reports_reason(self, api_v3_client, wifi_manager):
# Disabling the radio without Ethernet would lock the user out of
# this very interface, so the manager can refuse with a reason.
wifi_manager.set_wifi_radio.return_value = (
False, "Refusing: no wired fallback", "no_ethernet")
response = api_v3_client.post(self.URL, json={"enabled": False})
assert response.status_code == 400
body = response.get_json()
assert body["reason"] == "no_ethernet"
assert "Refusing" in body["message"]
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
def test_unrecognized_enabled_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
# An invalid `enabled` used to silently fall back to False, which
# can disconnect Wi-Fi (or, with force=true, drop the caller's own
# connection to this interface) even though nothing asked for that.
response = api_v3_client.post(self.URL, json={"enabled": "typo"})
assert response.status_code == 400
wifi_manager.set_wifi_radio.assert_not_called()
def test_unrecognized_force_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
response = api_v3_client.post(
self.URL, json={"enabled": False, "force": "typo"})
assert response.status_code == 400
wifi_manager.set_wifi_radio.assert_not_called()
class TestAutoEnableApMode:
URL = "/api/v3/wifi/ap/auto-enable"
def test_requires_the_field(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
@pytest.mark.parametrize("raw,expected", [
(True, True), (False, False),
("true", True), ("True", True), ("1", True), ("yes", True),
("false", False), ("False", False), ("0", False), ("no", False),
(1, True), (0, False),
])
def test_value_is_coerced_not_just_truthy(
self, api_v3_client, wifi_manager, raw, expected):
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
# sent the JSON string "false" got it stored as True — bool("false")
# is True, since any non-empty string is truthy.
wifi_manager.config = {}
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
assert response.status_code == 200, response.get_json()
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
assert wifi_manager.config["auto_enable_ap_mode"] is expected
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
# The exact shape of the bug.
wifi_manager.config = {}
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
assert wifi_manager.config["auto_enable_ap_mode"] is False
def test_unrecognized_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
wifi_manager.config = {}
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "typo"})
assert response.status_code == 400
assert "auto_enable_ap_mode" not in wifi_manager.config
def test_a_failed_save_is_reported(self, api_v3_client, wifi_manager):
# wifi_config.json left owned by root is the usual cause.
wifi_manager.config = {}
wifi_manager._save_config.return_value = False
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": False})
assert response.status_code == 500
assert response.get_json()["status"] == "error"
class TestRadioEnabledAndForceAcceptIntegers:
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
`1 is True` is False in Python -- an int is never the `True` singleton
even though it equals it -- so a plain integer fell through to False
regardless of its value.
"""
URL = "/api/v3/wifi/radio"
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
class TestNoRealNetworking:
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
# Guard against a future refactor moving the import to module level,
# where the fixture's patch of the definition site would stop
# applying and the tests would start driving real networking.
with patch("src.wifi_manager.WiFiManager") as cls:
cls.return_value.disconnect_from_network.return_value = (True, "ok")
api_v3_client.post("/api/v3/wifi/disconnect")
assert cls.called