Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Chuck
2026-09-24 17:36:25 -04:00
143 changed files with 5438 additions and 4951 deletions
+42 -13
View File
@@ -92,7 +92,7 @@ class TestGet:
helper.session.get.assert_not_called()
rate_spy.assert_not_called()
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
def test_cache_miss_fetches_and_caches_with_ttl(self, helper, cache):
cache.get.return_value = None
helper.session.get = Mock(return_value=_make_response({'a': 1}))
@@ -100,9 +100,47 @@ class TestGet:
cache_ttl=999)
assert result == {'a': 1}
# Pin the ttl-dropped contract: CacheManager.set is called with
# (key, data) only — the cache_ttl argument is discarded.
cache.set.assert_called_once_with('k', {'a': 1})
cache.set.assert_called_once_with('k', {'a': 1}, ttl=999)
def test_set_cache_passes_ttl(self, helper, cache):
helper.set_cache('k', {'a': 1}, ttl=42)
cache.set.assert_called_once_with('k', {'a': 1}, ttl=42)
class TestCacheLifetimeWithRealCacheManager:
"""cache_ttl decides how long a response is reused, in both directions:
past CacheManager's 300-second default read age, and not beyond it."""
@pytest.fixture
def real_cache(self, tmp_path):
from unittest.mock import patch
from src.cache_manager import CacheManager
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
return_value=str(tmp_path)):
cache = CacheManager()
yield cache
# Releases the class-wide cleanup-thread claim on this directory,
# which would otherwise leak into test_cache_cleanup_thread_ownership.
cache.stop_cleanup_thread()
def _fetch_twice(self, real_cache, monkeypatch, ttl, elapsed):
helper = APIHelper(cache_manager=real_cache)
helper.set_rate_limit(0)
helper.session.get = Mock(side_effect=[_make_response({'n': 1}),
_make_response({'n': 2})])
now = [1_000_000.0]
monkeypatch.setattr('src.cache.memory_cache.time.time', lambda: now[0])
monkeypatch.setattr('src.cache.disk_cache.time.time', lambda: now[0])
monkeypatch.setattr('src.cache_manager.time.time', lambda: now[0])
helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
now[0] += elapsed
return helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
def test_long_ttl_outlives_the_default_read_age(self, real_cache, monkeypatch):
assert self._fetch_twice(real_cache, monkeypatch, ttl=3600, elapsed=1000) == {'n': 1}
def test_short_ttl_expires(self, real_cache, monkeypatch):
assert self._fetch_twice(real_cache, monkeypatch, ttl=60, elapsed=120) == {'n': 2}
def test_request_exception_returns_none_and_caches_nothing(
self, helper, cache):
@@ -223,15 +261,6 @@ class TestClearCache:
manager.clear_cache.assert_called_once_with()
def test_no_pattern_falls_back_to_clear(self):
manager = types.SimpleNamespace(clear=Mock())
helper = APIHelper(cache_manager=manager)
helper.set_rate_limit(0)
helper.clear_cache()
manager.clear.assert_called_once_with()
def test_no_pattern_manager_without_any_clear_is_noop(self):
helper = APIHelper(cache_manager=object())
helper.set_rate_limit(0)
+90
View File
@@ -0,0 +1,90 @@
"""GET /api/v3/health: the plugin count is real, and a failed check is logged.
The plugin check counted ``plugin_manager.get_available_plugins()``, which
PluginManager does not have; a hasattr guard turned that into a permanent 0.
Each check that fails answers "see logs for details", so it has to log.
"""
import logging
import sys
from pathlib import Path
from types import SimpleNamespace
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
URL = "/api/v3/health"
@pytest.fixture(autouse=True)
def _no_systemctl(monkeypatch):
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._get_display_service_status",
lambda: {"active": True})
def _checks(client):
response = client.get(URL)
assert response.status_code == 200, response.get_json()
return response.get_json()["data"]["checks"]
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_manager.plugin_manifests = {
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
}
check = _checks(api_v3_client)["plugin_system"]
assert check == {"status": "operational", "plugin_count": 3}
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
pm = api_v3_module.api_v3.plugin_manager
pm.plugin_manifests = {}
def discover():
pm.plugin_manifests = {"clock": {"id": "clock"}}
pm.discover_plugins.side_effect = discover
assert _checks(api_v3_client)["plugin_system"]["plugin_count"] == 1
def test_a_failed_config_check_is_logged(api_v3_client, api_v3_module, caplog):
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError("disk gone")
with caplog.at_level(logging.WARNING):
check = _checks(api_v3_client)["config_file"]
assert check["error"] == "see logs for details"
logged = [r for r in caplog.records if "config file" in r.getMessage()]
assert logged and logged[0].exc_info and "disk gone" in str(logged[0].exc_info[1])
def test_a_failed_plugin_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
def boom():
raise RuntimeError("manifests unreadable")
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._discovered_plugin_manifests", boom)
with caplog.at_level(logging.WARNING):
check = _checks(api_v3_client)["plugin_system"]
assert check["status"] == "error"
logged = [r for r in caplog.records if "count plugins" in r.getMessage()]
assert logged and logged[0].exc_info
def test_a_failed_hardware_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
def getmtime(_path):
raise PermissionError("denied")
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda _p: True, getmtime=getmtime))
monkeypatch.setattr("web_interface.blueprints.api_v3.misc.os", fake_os)
with caplog.at_level(logging.WARNING):
check = _checks(api_v3_client)["hardware"]
assert check["status"] == "unknown"
logged = [r for r in caplog.records if "snapshot" in r.getMessage()]
assert logged and logged[0].exc_info
+68
View File
@@ -0,0 +1,68 @@
"""GET /plugins/health/<id> and /plugins/metrics/<id> read the display
service's latest state, not the web process's first snapshot.
The display service writes health and metrics to the shared cache; the web
process only reads them. Its tracker and monitor keep what they read first in
memory, so without ``force_reload`` the per-plugin routes kept answering with
that first read while the list routes (which pass it) moved on.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.plugin_system.plugin_health import PluginHealthTracker # noqa: E402
from src.plugin_system.resource_monitor import PluginResourceMonitor # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class SharedCache:
"""The on-disk cache both processes see, reduced to a dict."""
def __init__(self):
self.entries = {}
def get(self, key, max_age=None, memory_ttl=None):
return self.entries.get(key)
def set(self, key, value, *args, **kwargs):
self.entries[key] = value
@pytest.fixture
def shared_cache(api_v3_module):
cache = SharedCache()
pm = api_v3_module.api_v3.plugin_manager
pm.health_tracker = PluginHealthTracker(cache)
pm.resource_monitor = PluginResourceMonitor(cache)
return cache
def test_health_reflects_failures_recorded_after_the_first_read(api_v3_client, shared_cache):
first = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
assert first["total_failures"] == 0
display_side = PluginHealthTracker(shared_cache)
display_side.record_failure("weather", RuntimeError("api down"))
display_side.record_failure("weather", RuntimeError("api down"))
later = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
assert later["total_failures"] == 2
def test_metrics_reflect_calls_recorded_after_the_first_read(api_v3_client, shared_cache):
first = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
assert first["call_count"] == 0
shared_cache.set("plugin_metrics:weather", {
"memory_mb": 12.5, "cpu_percent": 3.0, "execution_time": 0.2,
"call_count": 40, "total_execution_time": 8.0,
"max_execution_time": 0.5, "min_execution_time": 0.1,
"last_update_time": 1000.0,
})
later = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
assert later["call_count"] == 40
+8 -4
View File
@@ -66,12 +66,16 @@ class TestRefreshPluginStore:
assert response.status_code == 200
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
def test_either_commit_info_key_extends_the_message(
def test_commit_info_flag_claims_no_refresh_it_does_not_do(
self, api_v3_client, api_v3_module, key):
# fetch_latest_versions is the older spelling; both must work.
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
# The route only re-downloads the registry. It used to append "(with
# refreshed commit metadata from GitHub)" for either flag without
# fetching any.
store = api_v3_module.api_v3.plugin_store_manager
store.fetch_registry.return_value = {"plugins": [{"id": "a"}]}
response = api_v3_client.post(self.URL, json={key: True})
assert "commit metadata" in response.get_json()["message"]
assert response.get_json()["message"] == "Plugin store refreshed"
store.fetch_registry.assert_called_once_with(force_refresh=True)
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
+55
View File
@@ -0,0 +1,55 @@
"""POST /config/main refuses a malformed Vegas plugin order or exclusion list.
Both were parsed with ``except JSONDecodeError: ... = []``, so a bad value
cleared the saved list and answered 200. They now fail the save with a 400,
as plugin_rotation_order already did.
"""
import sys
from pathlib import Path
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
URL = "/api/v3/config/main"
@pytest.fixture
def saved(api_v3_module):
config = {"display": {"vegas_scroll": {"plugin_order": ["clock", "weather"],
"excluded_plugins": ["stocks"]}}}
cm = api_v3_module.api_v3.config_manager
cm.load_config.return_value = config
cm.save_config_atomic.return_value.status.value = 'success'
return cm
@pytest.mark.parametrize("field", ["vegas_plugin_order", "vegas_excluded_plugins"])
@pytest.mark.parametrize("value", ["[not json", '{"a": 1}', "[1, 2]", 7])
def test_malformed_list_is_refused_and_nothing_is_saved(api_v3_client, saved, field, value):
response = api_v3_client.post(URL, json={field: value})
assert response.status_code == 400, response.get_json()
assert field in response.get_json()["message"]
saved.save_config_atomic.assert_not_called()
saved.save_config.assert_not_called()
@pytest.mark.parametrize("value", ['["weather", "clock"]', ["weather", "clock"]])
def test_json_text_or_array_is_stored(api_v3_client, saved, value):
response = api_v3_client.post(URL, json={"vegas_plugin_order": value})
assert response.status_code == 200, response.get_json()
stored = saved.save_config_atomic.call_args.args[0]
assert stored["display"]["vegas_scroll"]["plugin_order"] == ["weather", "clock"]
assert stored["display"]["vegas_scroll"]["excluded_plugins"] == ["stocks"]
def test_rotation_order_keeps_its_messages(api_v3_client, saved):
response = api_v3_client.post(URL, json={"plugin_rotation_order": "[oops"})
assert response.status_code == 400
assert response.get_json()["message"] == "plugin_rotation_order must be valid JSON"
+8
View File
@@ -408,6 +408,14 @@ class TestAutoEnableApMode:
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
-3
View File
@@ -362,6 +362,3 @@ class TestPriorityIsAcceptedAndIgnored:
rid = service.submit_fetch_request(
"nfl", 2026, "http://example.invalid/x", cache_key="k", priority=5)
assert service.get_result(rid).cached is True
def test_statistics_still_report_an_empty_queue(self, service):
assert service.get_statistics()["queue_size"] == 0
+25
View File
@@ -180,6 +180,31 @@ def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
def test_manifest_version_is_the_core_release(project: Path, tmp_path: Path) -> None:
"""Not a git sha or a truncated "ref: refs/he..." read from .git/HEAD."""
from src import __version__
git = project / ".git"
git.mkdir()
(git / "HEAD").write_text("ref: refs/heads/some-branch-that-is-not-there\n", encoding="utf-8")
zip_path = create_backup(project, output_dir=tmp_path / "exports")
with zipfile.ZipFile(zip_path) as zf:
manifest = json.loads(zf.read("manifest.json"))
assert manifest["ledmatrix_version"] == __version__
def test_installed_plugins_come_from_the_configured_directory(tmp_path: Path) -> None:
root = tmp_path / "proj"
(root / "config").mkdir(parents=True)
(root / "config" / "config.json").write_text(
json.dumps({"plugin_system": {"plugins_directory": "plugins"}}), encoding="utf-8")
plugin_dir = root / "plugins" / "dev-plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "manifest.json").write_text(
json.dumps({"id": "dev-plugin", "version": "0.3.0"}), encoding="utf-8")
assert [p["plugin_id"] for p in list_installed_plugins(root)] == ["dev-plugin"]
# ---------------------------------------------------------------------------
# Validate
# ---------------------------------------------------------------------------
-3
View File
@@ -342,7 +342,6 @@ class TestLoadConfiguration:
'base_odds_manager': {
'update_interval': 100,
'timeout': 5,
'cache_ttl': 42,
}
}
@@ -352,7 +351,6 @@ class TestLoadConfiguration:
# Key/attr mismatch pin: the config key is 'timeout' but the
# attribute is request_timeout.
assert manager.request_timeout == 5
assert manager.cache_ttl == 42
def test_get_config_raising_keeps_defaults(self, cache_manager):
config_manager = MagicMock()
@@ -362,4 +360,3 @@ class TestLoadConfiguration:
assert manager.update_interval == 3600
assert manager.request_timeout == 5
assert manager.cache_ttl == 1800
+45
View File
@@ -299,3 +299,48 @@ class TestDefaultMerging:
assert merged["enabled"] is False
assert merged["display_duration"] == 60
class TestMissingRequiredFields:
"""One message per missing field, naming that field.
A manual ``required`` loop used to run after Draft7Validator, which already
reports ``required``, so every missing top-level field was listed twice --
and the validator's copy printed the schema's whole ``required`` list as
if it were the field name.
"""
SCHEMA = {
"type": "object",
"properties": {
"api_key": {"type": "string"},
"city": {"type": "string"},
"units": {"type": "string"},
},
"required": ["api_key", "city", "units"],
}
def test_each_missing_field_is_reported_once_by_name(self):
ok, errors = SchemaManager().validate_config_against_schema(
{"units": "metric"}, self.SCHEMA, "test-plugin")
assert not ok
assert errors == [
"Field root: Missing required property 'api_key'",
"Field root: Missing required property 'city'",
]
def test_nested_missing_field_names_the_field_and_its_parent(self):
schema = {
"type": "object",
"properties": {"nfl": {
"type": "object",
"properties": {"api_key": {"type": "string"}},
"required": ["api_key"],
}},
}
ok, errors = SchemaManager().validate_config_against_schema(
{"nfl": {}}, schema, "test-plugin")
assert not ok
assert errors == ["Field 'nfl': Missing required property 'api_key'"]
+79
View File
@@ -0,0 +1,79 @@
"""GET /api/v3/display/current passes the snapshot PNG through untouched.
It used to PIL-decode the snapshot and re-encode it, which cost CPU on the Pi
for no change in the picture, and it swallowed any read failure with
``except Exception: pass``. It now sends the file's own bytes, the payload the
/stream/display SSE stream sends, and logs a failed read.
"""
import base64
import io
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from PIL import Image, PngImagePlugin
sys.path.insert(0, str(Path(__file__).parent.parent))
from web_interface import display_preview # noqa: E402
@pytest.fixture
def client(monkeypatch):
from web_interface.blueprints.api_v3 import api_v3
monkeypatch.setattr(api_v3, 'config_manager', MagicMock(), raising=False)
api_v3.config_manager.load_config.return_value = {}
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
with app.test_client() as test_client:
yield test_client
@pytest.fixture
def snapshot(tmp_path, monkeypatch):
"""A snapshot PNG carrying a text chunk, which a PIL re-encode drops."""
info = PngImagePlugin.PngInfo()
info.add_text('written-by', 'display_manager')
buffer = io.BytesIO()
Image.new('RGB', (4, 2), (255, 0, 0)).save(buffer, format='PNG', pnginfo=info)
path = tmp_path / 'led_matrix_preview.png'
path.write_bytes(buffer.getvalue())
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(path))
return path
def _image(client):
response = client.get('/api/v3/display/current')
assert response.status_code == 200
return response.get_json()['data']
def test_the_snapshot_bytes_are_sent_as_they_are(client, snapshot):
data = _image(client)
assert base64.b64decode(data['image']) == snapshot.read_bytes()
def test_route_and_stream_send_the_same_payload_keys(client, snapshot):
data = _image(client)
assert set(data) == set(display_preview.preview_payload(1, 1, None))
def test_no_snapshot_is_a_null_image_without_a_warning(client, tmp_path, monkeypatch, caplog):
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(tmp_path / 'missing.png'))
with caplog.at_level(logging.WARNING):
assert _image(client)['image'] is None
assert not [r for r in caplog.records if 'snapshot' in r.getMessage()]
def test_an_unreadable_snapshot_is_logged(client, snapshot, monkeypatch, caplog):
def denied(_path=None):
raise PermissionError('denied')
monkeypatch.setattr(display_preview, 'read_snapshot_base64', denied)
with caplog.at_level(logging.WARNING):
assert _image(client)['image'] is None
assert [r for r in caplog.records if 'snapshot' in r.getMessage() and r.exc_info]
@@ -185,3 +185,35 @@ class TestLogoScale:
def test_an_unusable_scale_is_ignored(self, logo, bad):
helper = LogoHelper(display_width=64, display_height=32)
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
def test_a_scale_the_schema_allows_is_applied(self, logo):
"""The Scale field's maximum is honoured, not reset to 1.0."""
from src.element_style import MAX_ELEMENT_SCALE
helper = LogoHelper(display_width=64, display_height=32)
big = helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE)
assert big.size == (40, 40)
def test_a_scale_beyond_the_range_is_clamped(self, logo):
from src.element_style import MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE
helper = LogoHelper(display_width=64, display_height=32)
assert helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE * 3).size == (40, 40)
assert helper.load_logo("AAA", logo, 40, 40, scale=MIN_ELEMENT_SCALE / 2).size == (4, 4)
class TestScaleCoercion:
"""One range for the schema, element_scale and LogoHelper."""
def test_schema_bounds_are_the_clamp_bounds(self):
from src.element_style import (MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE,
_offset_block_from_spec)
prop = _offset_block_from_spec("home_logo", {"scale": True})["properties"]["scale"]
assert (prop["minimum"], prop["maximum"]) == (MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE)
@pytest.mark.parametrize("raw,expected", [
(0.5, 0.5), (25, 10.0), (0.01, 0.1),
(0, 1.0), (-2, 1.0), ("x", 1.0), (True, 1.0),
(float("nan"), 1.0), (float("inf"), 1.0),
])
def test_element_scale_clamps_and_rejects(self, raw, expected):
cfg = {"customization": {"layout": {"home_logo": {"scale": raw}}}}
assert element_scale(cfg, "home_logo") == expected
+39
View File
@@ -124,6 +124,45 @@ class TestErrorRecording:
assert aggregator._plugin_error_counts["plugin-a"]["ValueError"] == 2
assert aggregator._plugin_error_counts["plugin-b"]["ValueError"] == 1
def test_stack_trace_recorded_outside_except_block(self):
"""The trace comes from the exception, not from the handler in progress.
plugin_executor records exceptions caught on a worker thread after
its except block has ended, where format_exc() only says
"NoneType: None".
"""
def failing_plugin_update():
raise ValueError("boom")
caught = []
def worker():
try:
failing_plugin_update()
except ValueError as e:
caught.append(e)
thread = threading.Thread(target=worker)
thread.start()
thread.join()
record = ErrorAggregator().record_error(caught[0], plugin_id="p")
assert "NoneType: None" not in record.stack_trace
assert "failing_plugin_update" in record.stack_trace
assert "ValueError: boom" in record.stack_trace
def test_record_error_leaves_caller_context_unchanged(self):
"""LEDMatrixError context is merged into a copy of the caller's dict."""
context = {"caller": "value"}
error = PluginError("failed", plugin_id="p", context={"extra": 1})
record = ErrorAggregator().record_error(error, context=context)
assert context == {"caller": "value"}
assert record.context["caller"] == "value"
assert record.context["extra"] == 1
class TestPatternDetection:
"""Test error pattern detection."""
+109
View File
@@ -0,0 +1,109 @@
"""scripts/fix_perms/fix_web_permissions.sh must not undo the installer's hardening.
The script chowns the whole project to the web user. That used to include the
two helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
(safe_plugin_rm.sh, safe_pip_install.sh) -- a helper the web user owns is a
root shell for anyone who can edit it -- and config_secrets.json, which lost
the ledmatrix group first_time_install.sh gives it. After the chown the script
now puts both back the way the installer's Steps 11 and 11.1 leave them.
The behavioural test runs the real script against a scratch copy of the
project with `sudo`, `getent` and `journalctl` stubbed, and checks the order
of what it asked sudo to do.
"""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "fix_perms" / "fix_web_permissions.sh"
LIB = ROOT / "scripts" / "install" / "lib_sudoers.sh"
def _text(path):
return path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
def _granted_helpers():
helpers = set(re.findall(r"scripts/fix_perms/([\w.-]+\.sh) \*", _text(LIB)))
assert helpers, "no fix_perms helper grant found in lib_sudoers.sh"
return helpers
def test_every_granted_helper_is_rehardened_after_the_chown():
text = _text(SCRIPT)
chown = text.index('sudo chown -R "$WEB_USER:$WEB_USER" "$PROJECT_DIR"')
loop = re.search(r"for helper in ([^;]+); do\n(.*?)\ndone", text, re.S)
assert loop, "no helper-hardening loop in fix_web_permissions.sh"
assert "sudo chown root:root" in loop.group(2) and "sudo chmod 755" in loop.group(2)
assert loop.start() > chown, "helpers are hardened before the chown that undoes it"
assert _granted_helpers() <= set(loop.group(1).split())
def test_no_longer_claims_to_configure_sudoers():
text = _text(SCRIPT)
assert "Configure sudoers for passwordless access" not in text
assert "./configure_web_sudo.sh" not in text.replace("scripts/install/configure_web_sudo.sh", "")
_STUB_SUDO = """#!/bin/bash
printf '%s\\n' "$*" >> "$SUDO_LOG"
# `sudo -n ...` probes and `sudo -u ...` tests: report failure, run nothing.
case "$1" in -n|-u) exit 1 ;; esac
exit 0
"""
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
reason="needs a POSIX bash")
def test_script_rehardens_helpers_and_secrets(tmp_path):
project = tmp_path / "LED Matrix"
(project / "scripts" / "fix_perms").mkdir(parents=True)
(project / "config").mkdir()
script = project / "scripts" / "fix_perms" / "fix_web_permissions.sh"
script.write_text(_text(SCRIPT), encoding="utf-8")
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
(project / "scripts" / "fix_perms" / helper).write_text("#!/bin/bash\n")
(project / "config" / "config_secrets.json").write_text("{}\n")
stubs = tmp_path / "stubs"
stubs.mkdir()
for name, body in (("sudo", _STUB_SUDO),
("getent", "#!/bin/sh\nexit 0\n"),
("journalctl", "#!/bin/sh\nexit 1\n")):
(stubs / name).write_text(body)
(stubs / name).chmod(0o755)
log = tmp_path / "sudo.log"
env = dict(os.environ, SUDO_LOG=str(log),
PATH=os.pathsep.join([str(stubs), os.environ.get("PATH", "")]))
result = subprocess.run(["bash", str(script)], input="y", env=env,
capture_output=True, text=True)
if os.geteuid() == 0:
# The script refuses to run as root; that refusal is the whole test.
assert result.returncode == 1 and "should not be run as root" in result.stdout
return
assert result.returncode == 0, result.stdout + result.stderr
calls = log.read_text().splitlines()
user = subprocess.run(["whoami"], capture_output=True, text=True).stdout.strip()
chown_all = calls.index(f"chown -R {user}:{user} {project}")
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
path = project / "scripts" / "fix_perms" / helper
assert calls.index(f"chown root:root {path}") > chown_all, calls
assert calls.index(f"chmod 755 {path}") > chown_all, calls
secrets = project / "config" / "config_secrets.json"
# The owner is the installed web unit's User= when there is one.
owner = user
unit = Path("/etc/systemd/system/ledmatrix-web.service")
if unit.is_file():
m = re.search(r"^User=(.*)$", unit.read_text(), re.M)
if m and m.group(1):
owner = m.group(1)
assert calls.index(f"chown {owner}:ledmatrix {secrets}") > chown_all, calls
assert calls.index(f"chmod 640 {secrets}") > chown_all, calls
+38
View File
@@ -8,10 +8,14 @@ test here asserts observable behavior: returned font types, cache identity,
fallback selection, and BDF native-size reading.
"""
import json
import shutil
import freetype
import pytest
from PIL import ImageFont
from src.common.font_layout import resolve_asset_path
from src.font_manager import FontManager
@@ -132,3 +136,37 @@ class TestCacheLifecycle:
fm.reload_config({})
assert fm.cache_generation == gen_before + 1
assert not fm.font_cache
class TestPluginFonts:
"""plugin:// sources resolve against the plugin's own directory, which
by default lives under plugin-repos/, not a cwd-relative plugins/."""
MANIFEST = {"fonts": [{"family": "bundled", "source": "plugin://fonts/Bundled.ttf"}]}
@staticmethod
def _plugin_with_font(root, name="my-plugin"):
plugin_dir = root / name
(plugin_dir / "fonts").mkdir(parents=True)
(plugin_dir / "manifest.json").write_text(json.dumps({"id": "my-plugin"}))
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
plugin_dir / "fonts" / "Bundled.ttf")
return plugin_dir
def test_font_resolves_under_the_given_plugin_dir(self, fm, tmp_path):
plugin_dir = self._plugin_with_font(tmp_path / "plugin-repos")
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST, plugin_dir=plugin_dir)
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
font = fm.resolve_font("x.y", "bundled", 8, plugin_id="my-plugin")
assert isinstance(font, ImageFont.FreeTypeFont)
def test_without_a_plugin_dir_the_configured_directory_is_searched(self, tmp_path):
plugins_root = tmp_path / "installed"
plugin_dir = self._plugin_with_font(plugins_root, name="ledmatrix-my-plugin")
fm = FontManager({"plugin_system": {"plugins_directory": str(plugins_root)}})
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST)
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
+20
View File
@@ -133,6 +133,26 @@ class TestAssetPathsIgnoreTheWorkingDirectory:
rel = f"assets/fonts/{FOUR_BY_SIX}"
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
def test_font_overrides_file_lives_in_the_install_config(self, tmp_path, monkeypatch):
from src.font_manager import FontManager
monkeypatch.chdir(tmp_path)
fm = FontManager({})
assert fm.font_overrides_file == str(PROJECT_ROOT / "config" / "font_overrides.json")
def test_logo_placeholder_draws_with_the_bundled_font(self, tmp_path, monkeypatch):
import src.logo_downloader as logo_downloader
from src.logo_downloader import LogoDownloader
loaded = []
def spy(font, size, **kwargs):
loaded.append(font)
return load_truetype(font, size, **kwargs)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(logo_downloader, "load_truetype", spy)
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
assert loaded == [str(PROJECT_ROOT / "assets" / "fonts" / PRESS_START)]
class TestTheHarnessForkAgreesWithTheCore:
"""The divergence that let the wrong rendering be blessed as golden.
+22
View File
@@ -78,3 +78,25 @@ class TestBackgroundDataServiceHeaders:
assert 'yourusername' not in str(headers)
finally:
service.shutdown(wait=False)
class TestResolverHeaders:
def test_dynamic_team_resolver_sends_the_user_agent(self):
from unittest.mock import patch
from src.dynamic_team_resolver import DynamicTeamResolver
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
try:
with patch('src.dynamic_team_resolver.requests.get',
side_effect=RuntimeError("stop")) as get:
DynamicTeamResolver().resolve_teams(["AP_TOP_5"])
assert get.call_args.kwargs['headers']['User-Agent'] == USER_AGENT
finally:
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
def test_odds_manager_uses_the_shared_headers(self):
from src.base_odds_manager import BaseOddsManager
headers = BaseOddsManager(MagicMock()).session.headers
for name, value in DEFAULT_HTTP_HEADERS.items():
assert headers[name] == value
+134
View File
@@ -0,0 +1,134 @@
"""first_time_install.sh prints its completion summary before it reboots.
With -y (and so with the one-shot `curl | bash` installer, which always
passes -y) the reboot used to be issued ~180 lines before the "Installation
Complete / Web UI Access" summary. `reboot` returns at once and the script
carried on printing while the system went down, so the SSH session usually
dropped before the user saw the web UI address.
first_time_install.sh exits on anything but Raspberry Pi OS Trixie before it
parses its arguments, so the behavioural test runs only the tail of the
script -- from the summary to the end -- with systemctl, nmcli, hostname, ip
and reboot stubbed.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
FIRST_TIME = ROOT / "first_time_install.sh"
SUMMARY_START = 'echo "Installation Complete!"'
def _text():
return FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n")
def test_every_reboot_comes_after_the_summary():
text = _text()
summary = text.index(SUMMARY_START)
lines = text.splitlines()
reboots = [i for i, line in enumerate(lines) if line.strip() == "reboot"]
assert reboots, "no reboot call found"
summary_line = text[:summary].count("\n")
enjoy_line = text[:text.index('echo "Enjoy your LED Matrix display!"')].count("\n")
assert all(i > enjoy_line > summary_line for i in reboots), (
f"reboot at line(s) {[i + 1 for i in reboots]} runs before the summary "
f"(line {summary_line + 1}) has finished printing")
def _tail():
"""The script from the summary header to the end, header rule included."""
text = _text()
start = text.rindex('echo "=========================================="', 0,
text.index(SUMMARY_START))
return text[start:]
_POSIX = pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
reason="needs a POSIX bash")
def _run(tmp_path, env_extra, nmcli_active_line=True, stdin="", hostapd_active=False):
stubs = tmp_path / "stubs"
stubs.mkdir()
log = tmp_path / "calls.log"
active = 'echo "yes:HomeNet"' if nmcli_active_line else ":"
hostapd = 'case "$*" in *"is-active --quiet hostapd"*) exit 0 ;; esac\n' if hostapd_active else ""
bodies = {
"reboot": f'#!/bin/sh\necho REBOOT-CALLED\necho reboot >> "{log}"\n',
"systemctl": f"#!/bin/sh\n{hostapd}exit 3\n",
"hostname": '#!/bin/sh\necho "192.168.1.50 fe80::1"\n',
"ip": "#!/bin/sh\nexit 1\n",
# device status -> one connected wifi device; device wifi -> active line
"nmcli": ('#!/bin/sh\ncase "$*" in\n'
' *"device status"*) echo "wlan0:wifi:connected" ;;\n'
f' *"device wifi"*) {active} ;;\n'
"esac\n"),
}
for name, body in bodies.items():
(stubs / name).write_text(body)
(stubs / name).chmod(0o755)
script = "\n".join([
"set -Eeuo pipefail",
"on_error() { echo \"ERR-TRAP line $1\" >&2; exit 1; }",
"trap 'on_error $LINENO' ERR",
"PROJECT_ROOT_DIR=/home/pi/LEDMatrix",
"ASSUME_YES=${ASSUME_YES:-0}",
"SKIP_REBOOT_PROMPT=${SKIP_REBOOT_PROMPT:-0}",
_tail(),
])
env = dict(os.environ, PATH=os.pathsep.join([str(stubs), "/usr/bin", "/bin"]), **env_extra)
result = subprocess.run(["bash", "-c", script], env=env, input=stdin,
capture_output=True, text=True)
calls = log.read_text().splitlines() if log.exists() else []
return result, calls
@_POSIX
@pytest.mark.parametrize("nmcli_active_line", [True, False], ids=["ssid", "no-ssid"])
def test_assume_yes_prints_the_summary_then_reboots(tmp_path, nmcli_active_line):
result, calls = _run(tmp_path, {"ASSUME_YES": "1"}, nmcli_active_line)
out = result.stdout
assert result.returncode == 0, out + result.stderr
assert calls == ["reboot"]
for text in ("Installation Complete!", "Web UI Access:", "http://192.168.1.50:5000",
"Enjoy your LED Matrix display!"):
assert out.index(text) < out.index("REBOOT-CALLED"), text
assert "Password: ledmatrix123" not in out
@_POSIX
def test_setup_access_point_is_described_as_open(tmp_path):
"""wifi_manager creates the setup AP with no security ("No password" on
the panel); the summary used to print a password it does not have."""
result, _ = _run(tmp_path, {"ASSUME_YES": "1"}, hostapd_active=True)
assert result.returncode == 0, result.stdout + result.stderr
assert "AP Mode is ACTIVE" in result.stdout
assert "Open network, no password" in result.stdout
assert "Password:" not in result.stdout
@_POSIX
def test_no_reboot_prompt_prints_the_summary_and_does_not_reboot(tmp_path):
result, calls = _run(tmp_path, {"ASSUME_YES": "1", "SKIP_REBOOT_PROMPT": "1"})
assert result.returncode == 0, result.stdout + result.stderr
assert calls == []
assert "Enjoy your LED Matrix display!" in result.stdout
assert "Skipping reboot prompt" in result.stdout
@_POSIX
@pytest.mark.parametrize("answer,expected", [("y", ["reboot"]), ("n", [])])
def test_interactive_prompt_comes_after_the_summary(tmp_path, answer, expected):
result, calls = _run(tmp_path, {}, stdin=answer)
assert result.returncode == 0, result.stdout + result.stderr
assert calls == expected
out = result.stdout
assert "Enjoy your LED Matrix display!" in out
if expected:
assert out.index("Enjoy your LED Matrix display!") < out.index("REBOOT-CALLED")
+23 -3
View File
@@ -31,8 +31,12 @@ import pytest
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from web_interface import system_metrics # noqa: E402
from web_interface.system_metrics import collect_system_metrics # noqa: E402
MB = 1024 * 1024
GB = 1024 * MB
@pytest.fixture
def metrics(monkeypatch):
@@ -45,10 +49,11 @@ def metrics(monkeypatch):
lambda _p: (_ for _ in ()).throw(OSError("no such mount")))
else:
monkeypatch.setattr(psutil, "disk_usage",
lambda _p: SimpleNamespace(percent=disk_percent))
lambda _p: SimpleNamespace(percent=disk_percent,
total=32 * GB, used=4 * GB))
monkeypatch.setattr(psutil, "virtual_memory",
lambda: SimpleNamespace(percent=used_percent,
available=available_bytes))
lambda: SimpleNamespace(percent=used_percent, total=1024 * MB,
used=600 * MB, available=available_bytes))
return collect_system_metrics()
return _collect
@@ -101,3 +106,18 @@ class TestEveryPredictiveFieldIsPresent:
"memory_available_mb", "disk_used_percent"):
assert field in m
assert m["disk_used_percent"] is None
assert all(m[key] is None for key in m if key != "cpu_temp")
class TestUnavailableIsNull:
"""One answer for "could not be read": None, never 0."""
def test_unreadable_temperature_is_null_not_zero_degrees(self, metrics, monkeypatch):
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", "/nonexistent/thermal/temp")
assert metrics()["cpu_temp"] is None
def test_readable_temperature_is_degrees_c(self, metrics, monkeypatch, tmp_path):
zone = tmp_path / "temp"
zone.write_text("48312\n")
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", str(zone))
assert metrics()["cpu_temp"] == 48.3
+27
View File
@@ -357,6 +357,33 @@ class TestRefreshPlaceholderTimestamp:
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
class TestFailurePaths:
def test_a_team_without_logos_is_a_failed_download(self, tmp_path):
downloader = LogoDownloader()
with patch.object(downloader, "fetch_single_team",
return_value={"team": {"logos": []}}):
assert downloader.download_missing_logo_for_team(
"nfl", "1", "XYZ", tmp_path / "XYZ.png") is False
def test_placeholder_is_written_where_the_caller_looks(self, tmp_path):
"""A path that is not <normalized abbreviation>.png (the plugin's own
file naming, or an abbreviation normalize_abbreviation rewrites)
still ends up holding the placeholder, so True means it exists."""
logo_path = tmp_path / "TA&M.png"
with patch.object(LogoDownloader, "download_logo", return_value=False):
assert download_missing_logo(
"ncaa_fb", "245", "TA&M", logo_path,
logo_url="http://example/tamu.png") is True
assert is_placeholder_logo(logo_path)
assert not (tmp_path / "TAANDM.png").exists()
def test_placeholder_uses_the_placeholder_geometry(self, tmp_path):
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
with Image.open(tmp_path / "AB.png") as img:
assert img.size == PLACEHOLDER_SIZE
assert img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
# ---------------------------------------------------------------------------
# download_logo: the download the scoreboard plugins actually use
#
+6 -2
View File
@@ -116,10 +116,14 @@ def test_values_of_the_wrong_type_fall_back_to_usable_defaults(bad):
assert isinstance(getattr(metrics, field_name), (int, float)), \
f"{field_name} came back as {getattr(metrics, field_name)!r}"
# The real proof: arithmetic on the loaded metrics must not explode.
# The real proof: the arithmetic monitor_call and get_metrics_summary do
# on the loaded metrics must not explode.
metrics.call_count += 1
metrics.total_execution_time += 0.5
metrics.update_average_execution_time()
metrics.max_execution_time = max(metrics.max_execution_time, 0.5)
metrics.min_execution_time = min(metrics.min_execution_time, 0.5)
metrics.memory_mb = max(metrics.memory_mb, 1.0)
assert metrics.total_execution_time / metrics.call_count >= 0
def test_a_numeric_string_is_accepted_rather_than_discarded():
+65
View File
@@ -0,0 +1,65 @@
"""/partials/<name> dispatch and the plugin web_ui page's directory lookup."""
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).parent.parent))
from web_interface.blueprints import pages_v3 as module # noqa: E402
@pytest.fixture
def client(tmp_path, monkeypatch):
plugin_manager = MagicMock()
plugin_manager.plugins_dir = tmp_path
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
monkeypatch.setattr(module.pages_v3, "config_manager",
MagicMock(load_config=lambda: {}), raising=False)
app = Flask(__name__, template_folder=str(
Path(module.__file__).resolve().parents[1] / "templates"))
app.register_blueprint(module.pages_v3)
return app.test_client()
def test_every_tab_the_old_if_chain_served_is_still_served():
assert set(module._PARTIAL_LOADERS) == {
"overview", "general", "display", "durations", "schedule", "plugins",
"fonts", "logs", "raw-json", "backup-restore", "wifi", "cache",
"operation-history", "tools",
}
def test_an_unknown_partial_is_a_404(client):
response = client.get("/partials/nope")
assert response.status_code == 404
def test_a_failing_loader_is_one_500_that_names_the_partial(client, monkeypatch, caplog):
def boom():
raise RuntimeError("template exploded")
monkeypatch.setitem(module._PARTIAL_LOADERS, "logs", boom)
with caplog.at_level(logging.ERROR):
response = client.get("/partials/logs")
assert response.status_code == 500
assert response.get_data(as_text=True) == "Error loading partial"
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert errors[0].getMessage() == "Error loading partial logs"
def test_web_ui_page_uses_the_ledmatrix_prefix_fallback(client, tmp_path):
web_ui = tmp_path / "ledmatrix-radar" / "web_ui"
web_ui.mkdir(parents=True)
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
response = client.get("/plugin-ui/radar/web-ui/panel.html")
assert response.status_code == 200
assert "radar panel" in response.get_data(as_text=True)
+25
View File
@@ -294,6 +294,31 @@ class TestValidateConfigFailure:
assert result is False
class TestPluginFontRegistration:
"""A manifest's fonts block is registered against the directory the
plugin was loaded from, which plugin:// font sources are relative to."""
def test_plugin_dir_is_passed_to_the_font_manager(self, temp_plugin_dir, mock_managers):
plugin_dir = temp_plugin_dir / "test-plugin"
plugin_dir.mkdir()
fonts = {"fonts": [{"family": "f", "source": "plugin://f.ttf"}]}
manifest = {"id": "test-plugin", "name": "Test Plugin",
"entry_point": "manager.py", "class_name": "TestPlugin",
"fonts": fonts}
with patch('src.common.permission_utils.ensure_directory_permissions'):
manager = PluginManager(plugins_dir=str(temp_plugin_dir), **mock_managers)
manager.plugin_manifests["test-plugin"] = manifest
with patch.object(manager.plugin_loader, 'load_plugin',
return_value=(MagicMock(), MagicMock())):
with patch.object(manager.plugin_loader, 'find_plugin_directory',
return_value=plugin_dir):
manager.load_plugin("test-plugin")
mock_managers["font_manager"].register_plugin_fonts.assert_called_once_with(
"test-plugin", fonts, plugin_dir=plugin_dir)
class TestPluginStateOnFailure:
"""Test that plugin state is correctly set on various failures."""
+36
View File
@@ -0,0 +1,36 @@
"""reload_plugin re-reads the manifest from the plugin's actual directory.
It read ``plugins_dir / plugin_id / manifest.json``, but a plugin directory's
name need not be the id its manifest declares -- discovery maps ids to
directories for exactly that reason. For such a plugin the path did not exist,
the re-read was skipped silently, and the reload kept the stale manifest.
"""
import json
import pytest
from src.plugin_system.plugin_manager import PluginManager
@pytest.fixture
def pm_with_renamed_dir(tmp_path):
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / "stock-ticker-v2"
plugin_dir.mkdir(parents=True)
manifest_path = plugin_dir / "manifest.json"
manifest_path.write_text(json.dumps({"id": "stocks", "version": "1.0.0"}))
pm = PluginManager(plugins_dir=str(plugins_dir))
assert pm.discover_plugins() == ["stocks"]
return pm, manifest_path
def test_reload_picks_up_an_edited_manifest(pm_with_renamed_dir, monkeypatch):
pm, manifest_path = pm_with_renamed_dir
manifest_path.write_text(json.dumps({"id": "stocks", "version": "2.0.0"}))
loaded = []
monkeypatch.setattr(pm, "load_plugin", lambda pid: loaded.append(pid) or True)
assert pm.reload_plugin("stocks") is True
assert pm.plugin_manifests["stocks"]["version"] == "2.0.0"
assert loaded == ["stocks"]
@@ -115,5 +115,22 @@ def test_get_state_info_is_a_consistent_snapshot():
assert not inconsistent, f"observed a torn snapshot: {inconsistent[:1]}"
def test_state_info_reports_only_what_something_records():
"""No field that is always null.
``last_display`` was reported here, but nothing ever recorded a display()
call, so it was null for every plugin. Its only reader is the web process,
whose PluginManager never calls display(), so recording it in the display
process could not have filled it either.
"""
manager = PluginStateManager()
manager.set_state("clock", PluginState.ENABLED)
manager.record_update("clock")
info = manager.get_state_info("clock")
assert "last_display" not in info
assert info["last_update"] is not None
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+95
View File
@@ -0,0 +1,95 @@
"""Repository URL handling shared by the plugin store and saved repositories.
The store cleaned URLs with ``url.rstrip('/').replace('.git', '')`` in two
places, which removes ``.git`` anywhere in the URL:
``https://github.com/user/my.github.io`` became ``.../myhub.io``, so installing
or browsing such a repository asked GitHub for one that does not exist.
"""
from unittest.mock import MagicMock
import pytest
from src.plugin_system.repo_urls import (
github_api_headers, github_owner_repo, normalize_repo_url, same_repo,
)
from src.plugin_system.store_manager import PluginStoreManager
PAGES_REPO = "https://github.com/user/my.github.io"
class TestNormalizeRepoUrl:
@pytest.mark.parametrize("raw, expected", [
(PAGES_REPO, PAGES_REPO),
(PAGES_REPO + ".git", PAGES_REPO),
("https://github.com/user/repo.git/", "https://github.com/user/repo"),
(" https://github.com/user/repo/ ", "https://github.com/user/repo"),
])
def test_only_a_trailing_dot_git_is_removed(self, raw, expected):
assert normalize_repo_url(raw) == expected
def test_same_repo_ignores_case_and_suffix(self):
assert same_repo("https://github.com/Owner/Repo.git",
"https://github.com/owner/repo/")
assert not same_repo("https://github.com/owner/repo",
"https://github.com/owner/other")
class TestGithubOwnerRepo:
@pytest.mark.parametrize("url, expected", [
(PAGES_REPO + ".git", ("user", "my.github.io")),
("https://www.github.com/owner/repo", ("owner", "repo")),
("https://github.com/owner/repo/tree/main/plugins/x", ("owner", "repo")),
])
def test_github_urls(self, url, expected):
assert github_owner_repo(url) == expected
@pytest.mark.parametrize("url", [
"https://github.com.example.org/owner/repo",
"https://gitlab.com/owner/repo",
"https://github.com/owner",
"github.com/owner/repo",
])
def test_anything_else_is_not_a_github_repo(self, url):
assert github_owner_repo(url) is None
def test_headers_carry_the_token_only_when_given(self):
assert "Authorization" not in github_api_headers(None)
assert github_api_headers("abc")["Authorization"] == "token abc"
@pytest.fixture
def store(tmp_path):
return PluginStoreManager(
plugins_dir=str(tmp_path / "plugins"),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
def test_install_from_url_keeps_an_interior_dot_git(store, monkeypatch):
cloned_from = []
monkeypatch.setattr(store, "_install_via_git",
lambda url, *a, **k: cloned_from.append(url))
downloaded = []
monkeypatch.setattr(store, "_install_via_download",
lambda url, *a, **k: downloaded.append(url) or False)
result = store.install_from_url(PAGES_REPO + ".git")
assert result["success"] is False
assert cloned_from == [PAGES_REPO]
assert all(url.startswith(PAGES_REPO + "/archive/") for url in downloaded)
def test_fetch_registry_from_url_asks_for_the_named_repository(store, monkeypatch):
requested = []
def fake_get(url, **kwargs):
requested.append(url)
return MagicMock(status_code=404)
monkeypatch.setattr(store, "_http_get_with_retries", fake_get)
assert store.fetch_registry_from_url(PAGES_REPO) is None
assert requested
assert all(url.startswith("https://raw.githubusercontent.com/user/my.github.io/")
for url in requested)
+21
View File
@@ -93,6 +93,27 @@ class TestResourceLimits:
with pytest.raises(ResourceLimitExceeded):
mon.monitor_call("p", lambda: time.sleep(0.02))
def test_memory_limit_judges_each_call_on_its_own_growth(self):
"""One expensive call must not fail every call after it.
The check used to compare the stored high-water mark, which never
decreases, so after one call grew memory past the limit every later
call raised too and the plugin never updated again.
"""
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.enable_monitoring = True # measure without needing psutil
readings = iter([100.0, 200.0, # first call grows RSS by 100 MB
200.0, 201.0]) # second call grows it by 1 MB
mon._get_process_memory_mb = lambda: next(readings)
mon._get_process_cpu_percent = lambda: 0.0
mon.set_limits("p", ResourceLimits(max_memory_mb=50))
with pytest.raises(ResourceLimitExceeded):
mon.monitor_call("p", lambda: None)
assert mon.monitor_call("p", lambda: "ok") == "ok"
# The high-water mark is still reported.
assert mon.get_metrics("p").memory_mb == 100.0
def test_reset_metrics_clears_counts(self):
cache = _cache()
mon = PluginResourceMonitor(cache, enable_monitoring=False)
+1 -1
View File
@@ -5,7 +5,7 @@ SavedRepositoriesManager contract.
Covers: the three accepted on-disk load shapes (bare list, wrapped
{"repositories": [...]}, anything else -> []) and that saves always write
the bare-list form; add/remove/has round trips through a fresh manager;
URL normalization post-fix (_clean_url strips only a TRAILING '.git' after
URL normalization post-fix (normalize_repo_url strips only a TRAILING '.git' after
trailing slashes — the old unanchored .replace('.git', '') mangled URLs
like my.github.io); name derivation and registry-vs-single type
classification (the ledmatrix-plugins check is lowercased, the
+9
View File
@@ -63,6 +63,15 @@ class TestValidateConfig:
assert "Missing required configuration key: display" in errors
assert "Missing required configuration key: timezone" in errors
@pytest.mark.parametrize("config,expected", [
({'timezone': 'UTC'}, "Missing required configuration key: display"),
({'display': {}, 'timezone': 'UTC'}, "Display configuration is empty"),
])
def test_a_missing_display_section_is_reported_once(self, good_cache, config, expected):
validator = StartupValidator(make_config_manager(config))
_, errors, _ = validator.validate_all()
assert errors == [expected]
def test_config_error_does_not_propagate(self, good_cache):
mgr = make_config_manager(GOOD_CONFIG)
mgr.load_config.side_effect = ConfigError("bad json")
+77
View File
@@ -0,0 +1,77 @@
"""A repository whose only branch is neither main nor master still installs.
_install_via_git tries the candidate branches, then the repository's default
branch -- but it returned None both for "every clone failed" and for "the
default-branch clone succeeded". install_from_url took the None as failure,
fell through to the archive download of main/master (which does not exist),
and reported "Failed to clone or download repository" for a repository it had
just cloned.
"""
import json
import shutil
import subprocess
import pytest
from src.plugin_system.store_manager import PluginStoreManager
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
MANIFEST = {
"id": "develop-only", "name": "Develop Only", "class_name": "P",
"display_modes": ["develop_only"], "version": "1.0.0",
}
def _git(*args, cwd):
subprocess.run(
["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", *args],
cwd=cwd, check=True, capture_output=True)
@pytest.fixture
def develop_only_repo(tmp_path):
repo = tmp_path / "upstream"
repo.mkdir()
_git("init", "-q", "-b", "develop", cwd=repo)
(repo / "manifest.json").write_text(json.dumps(MANIFEST))
(repo / "manager.py").write_text("class P: pass\n")
_git("add", ".", cwd=repo)
_git("commit", "-q", "-m", "init", cwd=repo)
return repo.as_uri()
@pytest.fixture
def store(tmp_path, monkeypatch):
mgr = PluginStoreManager(
plugins_dir=str(tmp_path / "plugins"),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
downloads = []
monkeypatch.setattr(mgr, "_install_via_download",
lambda url, *a, **k: downloads.append(url) or False)
mgr.downloads = downloads
return mgr
def test_a_default_branch_clone_reports_its_branch(store, develop_only_repo, tmp_path):
target = tmp_path / "clone"
assert store._install_via_git(develop_only_repo, target, ["main", "master"]) == "develop"
assert (target / "manifest.json").exists()
def test_a_failed_clone_reports_none(store, tmp_path):
missing = (tmp_path / "no-such-repo").as_uri()
target = tmp_path / "clone"
assert store._install_via_git(missing, target, ["main"]) is None
assert not target.exists()
def test_install_from_url_installs_a_develop_only_repository(store, develop_only_repo):
result = store.install_from_url(develop_only_repo)
assert result == {"success": True, "plugin_id": "develop-only",
"name": "Develop Only", "branch": "develop"}
assert (store.plugins_dir / "develop-only" / "manifest.json").exists()
assert store.downloads == []
+62
View File
@@ -0,0 +1,62 @@
"""update_plugin must not borrow the enclosing LEDMatrix checkout's remote.
Plugins live in ``plugin-repos/`` inside the LEDMatrix git checkout. For a
plugin installed from a ZIP (no ``.git`` of its own), ``git -C <plugin>``
walks up to the LEDMatrix repository, and ``git config --local --get
remote.origin.url`` answers with LEDMatrix's own URL. update_plugin then tried
to "reinstall" the plugin from the LEDMatrix repository.
"""
import json
import shutil
import subprocess
import pytest
from src.plugin_system.store_manager import PluginStoreManager
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
PLUGIN_ID = "zip-installed"
PARENT_REMOTE = "https://github.com/example/LEDMatrix"
def _git(*args, cwd):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
@pytest.fixture
def store_inside_checkout(tmp_path):
checkout = tmp_path / "LEDMatrix"
checkout.mkdir()
_git("init", "-q", cwd=checkout)
_git("remote", "add", "origin", PARENT_REMOTE, cwd=checkout)
plugins_dir = checkout / "plugin-repos"
plugin_dir = plugins_dir / PLUGIN_ID
plugin_dir.mkdir(parents=True)
(plugin_dir / "manifest.json").write_text(json.dumps(
{"id": PLUGIN_ID, "name": "Zip", "version": "1.0.0"}))
store = PluginStoreManager(
plugins_dir=str(plugins_dir),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
return store, plugin_dir
def test_a_plugin_without_its_own_git_has_no_remote(store_inside_checkout, monkeypatch):
store, plugin_dir = store_inside_checkout
# The premise: git itself does report the parent's remote here.
parent_view = subprocess.run(
["git", "-C", str(plugin_dir), "config", "--local", "--get", "remote.origin.url"],
capture_output=True, text=True)
assert parent_view.stdout.strip() == PARENT_REMOTE
monkeypatch.setattr(store, "fetch_registry", lambda *a, **k: {"plugins": []})
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
install_calls = []
monkeypatch.setattr(store, "install_from_url",
lambda *a, **k: install_calls.append((a, k)) or {"success": True})
assert store.update_plugin(PLUGIN_ID) is False
assert install_calls == []
+10 -1
View File
@@ -11,12 +11,16 @@ Four such calls were ungranted, all of them captive-portal teardown/setup:
rfkill unblock wifi wifi_manager.py:1811
mkdir -p .../dnsmasq-shared.d wifi_manager.py:922
The drop-in written into that directory was missing too: the literal
`cp /tmp/ledmatrix-nm-dnsmasq.conf .../dnsmasq-shared.d/ledmatrix-captive.conf`
and `rm -f` of the same file, so the directory was granted but not the file.
It goes unnoticed because a stock Raspberry Pi image ships
/etc/sudoers.d/010_pi-nopasswd granting the default user
`ALL=(ALL) NOPASSWD: ALL`, which satisfies every gap in both files. It only
bites once that blanket rule is removed or the service runs as another user.
Scope, deliberately narrow: this pins the four commands above, each of which
Scope, deliberately narrow: this pins the commands above, each of which
can be written out literally. The portal makes further sudo calls whose
arguments are built at runtime -- iptables and nft rules carrying an interface
name and a port, `ip addr`, `ip link` -- and those cannot be granted safely
@@ -50,6 +54,11 @@ REQUIRED = (
("nft", "delete", "table", "ip", "ledmatrix"),
("rfkill", "unblock", "wifi"),
("mkdir", "-p", "/etc/NetworkManager/dnsmasq-shared.d"),
# The drop-in that directory exists for, written and removed by
# _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf.
("cp", "/tmp/ledmatrix-nm-dnsmasq.conf",
"/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf"),
("rm", "-f", "/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf"),
)
#: Tools with an option that executes a program of the caller's choosing.
+49
View File
@@ -19,6 +19,7 @@ import pytest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIRST_TIME = os.path.join(REPO_ROOT, "first_time_install.sh")
CONFIGURE = os.path.join(REPO_ROOT, "scripts", "install", "configure_web_sudo.sh")
WIFI = os.path.join(REPO_ROOT, "scripts", "install", "configure_wifi_permissions.sh")
VISUDO = shutil.which("visudo") or (
"/usr/sbin/visudo" if os.path.exists("/usr/sbin/visudo") else None
@@ -62,6 +63,54 @@ def test_configure_web_sudo_validates_before_installing():
assert validate < install, "the rules must be checked before they are installed"
def test_configure_web_sudo_does_not_use_a_predictable_temp_file():
body = _read(CONFIGURE)
assert 'TEMP_SUDOERS=$(mktemp' in body
assert "/tmp/ledmatrix_web_sudoers_$$" not in body
assert "trap 'rm -f \"$TEMP_SUDOERS\"' EXIT" in body
def test_configure_web_sudo_installs_mode_440():
body = _read(CONFIGURE)
install = body.index('cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web')
assert body.index("chmod 440 /etc/sudoers.d/ledmatrix_web") > install
def test_configure_wifi_permissions_validates_before_installing():
"""The third sudoers writer. It installed its rules unchecked."""
body = _read(WIFI)
# The check itself, as a condition -- not merely the command appearing in
# the error report that follows it.
validate = body.index('if ! visudo -c -f "$TEMP_SUDOERS"')
install = body.index('sudo cp "$TEMP_SUDOERS" "$SUDOERS_FILE"')
assert validate < install, "the rules must be checked before they are installed"
# ...and a failed check stops the script before the copy.
assert "exit 1" in body[validate:install]
assert "TEMP_SUDOERS=$(mktemp" in body
@pytest.mark.skipif(sys.platform == "win32", reason="visudo is POSIX only")
@pytest.mark.skipif(VISUDO is None, reason="visudo not installed")
def test_the_wifi_rules_actually_parse(tmp_path):
"""Render configure_wifi_permissions.sh's heredoc with realistic paths."""
body = _read(WIFI)
opener = 'cat > "$TEMP_SUDOERS" << EOF\n'
start = body.index(opener) + len(opener)
end = body.index("\nEOF\n", start)
out = tmp_path / "wifi"
script = "\n".join([
"WEB_USER=ledmatrix", "NMCLI_PATH=/usr/bin/nmcli",
"SYSTEMCTL_PATH=/usr/bin/systemctl", "SYSCTL_PATH=/usr/sbin/sysctl",
"NFT_PATH=/usr/sbin/nft", "RFKILL_PATH=/usr/sbin/rfkill",
"MKDIR_PATH=/usr/bin/mkdir",
f"cat > '{out}' << EOF", body[start:end], "EOF",
])
subprocess.run(["bash", "-c", script], check=True)
os.chmod(out, 0o440)
result = subprocess.run([VISUDO, "-c", "-f", str(out)], capture_output=True, text=True)
assert result.returncode == 0, result.stdout + result.stderr
def test_a_missing_rules_library_installs_nothing():
"""If lib_sudoers.sh is missing, nothing is generated -- and an empty file
would pass `visudo -c` -- so that branch must set the flag the install is
+9
View File
@@ -38,6 +38,7 @@ import numpy as np
import pytest
from PIL import Image
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
from src.common import sync_manager
from src.common.sync_manager import (
DisplaySyncManager,
@@ -878,6 +879,14 @@ class TestWriteStatusFile:
mgr.write_status_file() # must not raise
assert mgr.logger.debug.called
def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
"""GET /sync/status reads STATUS_FILE, which lives under
tempfile.gettempdir() -- not always /tmp."""
mgr = make_manager(role=SyncRole.LEADER)
mgr.write_status_file()
response = api_v3_client.get("/api/v3/sync/status")
assert response.get_json()["data"]["role"] == "leader"
class TestStop:
def _stub_with_sockets(self):
+22 -1
View File
@@ -51,7 +51,7 @@ def _memory(total_mb, used_mb, available_mb):
def _get_status(client, memory):
# The endpoint caches for 10s; bypass so each case is measured fresh.
with patch("web_interface.cache.get_cached", return_value=None), \
with patch("web_interface.blueprints.api_v3.system.get_cached", return_value=None), \
patch("psutil.virtual_memory", return_value=memory), \
patch("psutil.cpu_percent", return_value=5.0), \
patch("psutil.boot_time", return_value=0.0):
@@ -90,3 +90,24 @@ def test_a_nearly_exhausted_board_reports_a_small_number(client):
# to fork. The readout has to surface that rather than round it away.
data = _get_status(client, _memory(total_mb=905, used_mb=800, available_mb=73))
assert data["memory_available_mb"] == pytest.approx(73, abs=0.5)
def test_status_and_live_stream_give_the_same_answer(client, monkeypatch):
"""/system/status is built on collect_system_metrics(), so a metric has one
value, and "could not be read" is null in both."""
from web_interface import system_metrics
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", "/nonexistent/thermal/temp")
memory = _memory(total_mb=905, used_mb=620, available_mb=284)
with patch("psutil.virtual_memory", return_value=memory), \
patch("psutil.cpu_percent", return_value=5.0), \
patch("psutil.boot_time", return_value=0.0), \
patch("psutil.disk_usage", side_effect=OSError("no such mount")):
streamed = system_metrics.collect_system_metrics()
with patch("web_interface.blueprints.api_v3.system.get_cached", return_value=None):
status = json.loads(client.get("/api/v3/system/status").data)["data"]
assert status["cpu_temp"] is None
assert status["disk_used_percent"] is None
for key in system_metrics.METRIC_KEYS:
if key != "uptime_seconds": # the two reads are a moment apart
assert status[key] == streamed[key], key
+19
View File
@@ -17,6 +17,7 @@ editing files under /etc and restarting services is the installer's job, not
something a display process should do to a machine while it boots.
"""
import logging
import re
import shlex
import subprocess
from pathlib import Path
@@ -253,6 +254,24 @@ def test_sed_escape_replacement_preserves_special_characters():
"a sed-special character in the replacement was not preserved literally")
def test_every_unit_renderer_escapes_its_replacement():
"""Each `sed s|__PLACEHOLDER__|$VALUE|` in an install script uses an escaped value.
install_dns_fix.sh and install_mqtt_bridge.sh interpolated the raw project
path while the other three renderers went through sed_escape_replacement,
so a checkout under a path containing `&` rendered a broken unit from
those two only.
"""
project_root = Path("src/startup_validator.py").resolve().parent.parent
offenders = []
for script in sorted((project_root / "scripts" / "install").glob("*.sh")):
text = script.read_text(encoding="utf-8")
for m in re.finditer(r"s\|__[A-Z_]+__\|\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?\|", text):
if not m.group(1).startswith("ESCAPED_") and m.group(1) != "root":
offenders.append(f"{script.name}: ${m.group(1)}")
assert not offenders, "unescaped sed replacement(s): " + ", ".join(offenders)
def test_no_installer_carries_its_own_copy_of_a_unit():
"""The regression guard.
+18 -4
View File
@@ -24,10 +24,13 @@ class TestTextHelper:
assert th.font_dir == tmp_path
assert th._font_cache == {}
def test_init_default_font_dir(self):
"""Test TextHelper initialization with default font directory."""
def test_init_default_font_dir(self, tmp_path, monkeypatch):
"""The default is the install's assets/fonts, not a cwd-relative path."""
from pathlib import Path
monkeypatch.chdir(tmp_path)
th = TextHelper()
assert th.font_dir == pytest.importorskip("pathlib").Path("assets/fonts")
assert th.font_dir == Path(__file__).resolve().parents[1] / "assets" / "fonts"
assert isinstance(th.load_fonts()["score"], ImageFont.FreeTypeFont)
@patch('PIL.ImageFont.truetype')
@patch('PIL.ImageFont.load_default')
@@ -123,6 +126,17 @@ class TestTextHelper:
def test_get_default_font_config(self, text_helper):
"""Test getting default font configuration."""
config = text_helper._get_default_font_config()
assert isinstance(config, dict)
assert len(config) > 0
def test_each_font_file_and_size_is_loaded_once(self):
th = TextHelper()
first = th.load_fonts()
second = th.load_fonts()
# Six names, three (file, size) pairs: PressStart2P at 10 and 8, 4x6 at 6.
assert first["score"] is second["score"] is first["rank"]
assert th.get_font_cache_stats()["cached_fonts"] == 3
th.clear_font_cache()
assert th.get_font_cache_stats()["cached_fonts"] == 0
assert th.load_fonts()["score"] is not first["score"]
+94
View File
@@ -369,6 +369,26 @@ def test_default_config_has_no_saved_networks(tmp_path: Path) -> None:
assert "saved_networks" not in json.loads(config_path.read_text())
@pytest.mark.unit
def test_save_config_reports_a_failed_write(manager: WiFiManager, tmp_path: Path) -> None:
# A directory where the file should be: every write to it fails, as one
# to a root-owned wifi_config.json does for the web user.
blocked = tmp_path / "blocked.json"
blocked.mkdir()
manager.config_path = blocked
assert manager._save_config() is False
assert list(tmp_path.glob(".blocked.json.tmp.*")) == []
@pytest.mark.unit
def test_save_config_round_trips(manager: WiFiManager) -> None:
manager.config["auto_enable_ap_mode"] = False
assert manager._save_config() is True
assert json.loads(manager.config_path.read_text())["auto_enable_ap_mode"] is False
@pytest.mark.unit
def test_connecting_does_not_store_the_password(manager: WiFiManager) -> None:
commands = []
@@ -389,3 +409,77 @@ def test_connecting_does_not_store_the_password(manager: WiFiManager) -> None:
"the new-connection path was not reached"
assert "hunter22" not in json.dumps(manager.config)
assert "hunter22" not in manager.config_path.read_text()
# ---------------------------------------------------------------------------
# 7. Disconnect takes the saved profile down
# ---------------------------------------------------------------------------
def _profile_nmcli(profiles: dict):
"""A fake subprocess.run for `nmcli connection show` over ``profiles``
(profile name -> SSID), recording every command it is given."""
commands = []
def fake_run(cmd, *args, **kwargs):
commands.append(cmd)
if cmd == ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"]:
lines = [name.replace(":", "\\:") + ":802-11-wireless" for name in profiles]
lines.append("Wired connection 1:802-3-ethernet")
return _ok(stdout="\n".join(lines) + "\n")
if cmd[:4] == ["nmcli", "-g", "802-11-wireless.ssid", "connection"]:
return _ok(stdout=profiles.get(cmd[-1], "") + "\n")
if cmd[:3] == ["nmcli", "connection", "show"]:
return _ok() if cmd[3] in profiles else _fail()
# nmcli rejects 802-11-wireless.ssid as a `connection show -f` column.
if "802-11-wireless.ssid" in cmd:
return _fail(stderr="Error: invalid field '802-11-wireless.ssid'")
return _ok()
return fake_run, commands
@pytest.mark.unit
def test_find_profile_for_ssid_matches_by_ssid_not_name(manager: WiFiManager) -> None:
fake_run, _ = _profile_nmcli({"home: upstairs": "HomeNet", "Office": "OfficeNet"})
with patch("src.wifi_manager.subprocess.run", side_effect=fake_run):
assert manager._find_profile_for_ssid("HomeNet") == "home: upstairs"
assert manager._find_profile_for_ssid("OfficeNet") == "Office"
assert manager._find_profile_for_ssid("Elsewhere") is None
@pytest.mark.unit
def test_disconnect_takes_the_profile_down(manager: WiFiManager) -> None:
from src.wifi_manager import WiFiStatus
fake_run, commands = _profile_nmcli({"Home profile": "HomeNet"})
with patch("src.wifi_manager.subprocess.run", side_effect=fake_run), \
patch("src.wifi_manager.time.sleep"), \
patch.object(manager, "get_wifi_status",
return_value=WiFiStatus(connected=True, ssid="HomeNet")):
ok, _ = manager.disconnect_from_network(skip_ap_check=True)
assert ok
assert ["nmcli", "connection", "down", "Home profile"] in commands
assert ["nmcli", "device", "disconnect", "wlan0"] in commands
# ---------------------------------------------------------------------------
# 8. The nmcli Wi-Fi list parser both scan paths share
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_nmcli_wifi_list_parsing() -> None:
out = (
"HomeNet:40:WPA2:2437 MHz\n"
"Cafe:80::5180 MHz\n"
"HomeNet:90:WPA2:5180 MHz\n" # duplicate SSID: first line wins
":70:WPA2:2412 MHz\n" # hidden network
"Broken:notanumber:WPA2:2412 MHz\n"
"Modern:60:WPA3 SAE:5745 MHz\n"
)
networks = WiFiManager._parse_nmcli_wifi_list(out)
assert [(n.ssid, n.signal, n.security, n.frequency) for n in networks] == [
("Cafe", 80, "open", 5180.0),
("Modern", 60, "wpa3", 5745.0),
("HomeNet", 40, "wpa2", 2437.0),
]
@@ -21,10 +21,7 @@ class TestPluginOperationsIntegration(unittest.TestCase):
self.temp_dir = Path(tempfile.mkdtemp())
# Initialize components
self.operation_queue = PluginOperationQueue(
history_file=str(self.temp_dir / "operations.json"),
max_history=100
)
self.operation_queue = PluginOperationQueue(max_history=100)
self.state_manager = PluginStateManager(
state_file=str(self.temp_dir / "state.json"),
@@ -0,0 +1,210 @@
"""POST /plugins/config and POST /plugins/config/reset over a real
ConfigManager and SchemaManager.
The save path reshapes what the browser posts before validating it, and these
tests pin the reshaping that validation depends on: repeats in a uniqueItems
list are dropped, and a list the form posted as numbered fields becomes a list
again.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_module, build_app # noqa: E402,F401
PLUGIN_ID = "stocks"
SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"stock_symbols": {
"type": "array",
"uniqueItems": True,
"items": {"type": "string"},
"default": ["AAPL"],
},
"refresh_seconds": {"type": "integer", "default": 60},
"api_key": {"type": "string", "x-secret": True, "default": ""},
},
}
# The shape of the news plugin's schema: a list of objects nested one level
# down. A form posts it as feeds.custom_feeds.0.name, feeds.custom_feeds.0.url,
# which lands as a dict keyed "0", "1", ...
NEWS_ID = "news"
NEWS_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"feeds": {
"type": "object",
"properties": {
"enabled_feeds": {
"type": "array", "items": {"type": "string"}, "default": [],
},
"custom_feeds": {
"type": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
},
},
},
},
},
},
}
@pytest.fixture
def env(tmp_path, api_v3_module):
"""Real config and schema managers under tmp_path, on the blueprint."""
plugins_dir = tmp_path / "plugins"
for plugin_id, schema in ((PLUGIN_ID, SCHEMA), (NEWS_ID, NEWS_SCHEMA)):
plugin_dir = plugins_dir / plugin_id
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
(plugin_dir / "manifest.json").write_text(json.dumps(
{"id": plugin_id, "name": plugin_id, "version": "1.0.0"}))
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(
{PLUGIN_ID: {"enabled": True, "stock_symbols": ["AAPL", "FNMA"]}}))
config_manager = ConfigManager(
config_path=str(config_file),
secrets_path=str(tmp_path / "config_secrets.json"))
config_manager.template_path = str(tmp_path / "no-template.json")
api = api_v3_module.api_v3
api.config_manager = config_manager
api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api.plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
NEWS_ID: {"id": NEWS_ID}}
api.plugin_manager.get_plugin.return_value = None
class Env:
client = build_app(api).test_client()
plugin_manager = api.plugin_manager
@staticmethod
def stored(plugin_id=PLUGIN_ID):
return json.loads(config_file.read_text())[plugin_id]
Env.config_manager = config_manager
return Env
class TestUniqueItemsRepeats:
"""A repeat in a uniqueItems list is dropped, not a failed save."""
def test_form_post_repeating_a_saved_symbol_saves(self, env):
response = env.client.post(
f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}",
data={"stock_symbols": "AAPL, FNMA, FNMA"})
assert response.status_code == 200, response.get_json()
assert env.stored()["stock_symbols"] == ["AAPL", "FNMA"]
def test_json_post_keeps_first_occurrence_order(self, env):
response = env.client.post("/api/v3/plugins/config", json={
"plugin_id": PLUGIN_ID,
"config": {"stock_symbols": ["TSLA", "AAPL", "TSLA", "FNMA"]},
})
assert response.status_code == 200, response.get_json()
assert env.stored()["stock_symbols"] == ["TSLA", "AAPL", "FNMA"]
class TestNumberedFieldsBecomeLists:
"""The news plugin's custom feeds, posted one field per row."""
FEEDS = [{"name": "Local", "url": "https://example.com/local.xml", "enabled": True},
{"name": "Tech", "url": "https://example.com/tech.xml", "enabled": False}]
def test_form_post(self, env):
response = env.client.post(f"/api/v3/plugins/config?plugin_id={NEWS_ID}", data={
"feeds.custom_feeds.0.name": "Local",
"feeds.custom_feeds.0.url": "https://example.com/local.xml",
"feeds.custom_feeds.0.enabled": "on",
"feeds.custom_feeds.1.name": "Tech",
"feeds.custom_feeds.1.url": "https://example.com/tech.xml",
})
assert response.status_code == 200, response.get_json()
assert env.stored(NEWS_ID)["feeds"]["custom_feeds"] == self.FEEDS
def test_json_post(self, env):
response = env.client.post("/api/v3/plugins/config", json={
"plugin_id": NEWS_ID,
"config": {"feeds": {"custom_feeds": {"1": self.FEEDS[1], "0": self.FEEDS[0]}}},
})
assert response.status_code == 200, response.get_json()
assert env.stored(NEWS_ID)["feeds"]["custom_feeds"] == self.FEEDS
class TestReset:
"""POST /plugins/config/reset saves the way every other plugin save does."""
def test_reset_saves_atomically_with_a_backup(self, env, monkeypatch):
cm = env.config_manager
calls = []
real_atomic = cm.save_config_atomic
def spy(config, create_backup=True, **kwargs):
calls.append(create_backup)
return real_atomic(config, create_backup=create_backup, **kwargs)
def no_plain_save(_config):
raise AssertionError("reset bypassed the atomic save")
monkeypatch.setattr(cm, "save_config_atomic", spy)
monkeypatch.setattr(cm, "save_config", no_plain_save)
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 200, response.get_json()
assert calls == [True]
assert env.stored()["stock_symbols"] == ["AAPL"]
def test_reset_notifies_the_plugin_with_its_prepared_config(self, env):
plugin = MagicMock()
env.plugin_manager.get_plugin.return_value = plugin
env.plugin_manager.prepare_plugin_config.side_effect = (
lambda _pid, raw: {**raw, "prepared": True})
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 200, response.get_json()
handed_over = plugin.on_config_change.call_args.args[0]
assert handed_over["prepared"] is True
assert handed_over["stock_symbols"] == ["AAPL"]
def test_a_failed_save_is_reported(self, env, monkeypatch):
failed = MagicMock(message="disk full")
failed.status.value = "failed"
monkeypatch.setattr(env.config_manager, "save_config_atomic",
MagicMock(return_value=failed))
response = env.client.post("/api/v3/plugins/config/reset",
json={"plugin_id": PLUGIN_ID})
assert response.status_code == 500
assert "disk full" in response.get_json()["message"]
+58
View File
@@ -0,0 +1,58 @@
"""coerce_array_shapes: position-keyed dicts back into lists before validation."""
from src.web_interface.config_arrays import coerce_array_shapes
COLOR = {"type": "array", "items": {"type": "integer"},
"minItems": 3, "maxItems": 3, "default": [255, 255, 255]}
def test_position_keys_become_a_list_in_numeric_order():
config = {"tags": {"10": "k", "2": "c", "0": "a"}}
coerce_array_shapes(config, {"tags": {"type": "array"}})
assert config["tags"] == ["a", "c", "k"]
def test_an_empty_dict_becomes_an_empty_list():
config = {"tags": {}}
coerce_array_shapes(config, {"tags": {"type": "array"}})
assert config["tags"] == []
def test_a_dict_with_other_keys_is_left_for_validation_to_report():
config = {"tags": {"0": "a", "name": "b"}}
coerce_array_shapes(config, {"tags": {"type": "array"}})
assert config["tags"] == {"0": "a", "name": "b"}
def test_element_types_are_left_to_normalization():
config = {"color": {"0": "1", "1": "2", "2": "3"}}
coerce_array_shapes(config, {"color": COLOR})
assert config["color"] == ["1", "2", "3"]
def test_nested_objects_and_array_items_are_walked():
schema = {"feeds": {"type": "object", "properties": {
"custom_feeds": {"type": "array", "items": {"type": "object", "properties": {
"tags": {"type": "array"},
}}},
}}}
config = {"feeds": {"custom_feeds": {"0": {"tags": {"0": "news"}}}}}
coerce_array_shapes(config, schema)
assert config == {"feeds": {"custom_feeds": [{"tags": ["news"]}]}}
def test_a_short_form_list_takes_the_default_only_when_asked():
config = {"color": ["10", "20"]}
coerce_array_shapes(config, {"color": COLOR})
assert config["color"] == ["10", "20"]
coerce_array_shapes(config, {"color": COLOR}, short_lists_take_default=True)
assert config["color"] == [255, 255, 255]
assert config["color"] is not COLOR["default"]
def test_a_default_too_short_itself_is_not_used():
schema = {"color": dict(COLOR, default=[0])}
config = {"color": ["10"]}
coerce_array_shapes(config, schema, short_lists_take_default=True)
assert config["color"] == ["10"]
@@ -1,11 +1,9 @@
"""Tests for dedup_unique_arrays used by save_plugin_config.
"""Tests for dedup_unique_arrays, which the plugin-config save path
(_prepare_plugin_config_for_save in api_v3/plugins.py) runs before validation.
Validates that arrays with uniqueItems: true in the JSON schema have
duplicates removed before validation, preventing spurious validation
failures when form merging introduces duplicate entries.
Tests import the production function from src.web_interface.validators
to ensure they exercise the real code path.
Arrays with uniqueItems: true in the JSON schema lose their duplicates, so a
repeat introduced by form merging does not fail validation.
test_api_v3_plugin_config_save.py checks the same through the endpoint.
"""