Files
LEDMatrix/test/test_web_api.py
T
ChuckandClaude Opus 5 f6367d63ae security: triage the CodeQL backlog — 129 alerts, three of them live (#561)
* fix(web): escape quotes in every HTML escaper, not just & < >

The escapers are all `div.textContent = x; return div.innerHTML`. That
round-trip escapes &, < and > -- the only characters the HTML serializer
must escape in a text node -- and leaves quotes alone. Every widget then
interpolates the result into a quoted attribute value:

    value="${escapeHtml(v)}"   title="${escapeHtml(v)}"

so a value of `x" onmouseover="alert(1)` closes the attribute and adds an
event handler of its own. CodeQL reported this 83 times
(js/incomplete-html-attribute-sanitization) across the widget files.

It is one bug, not 83: the widgets each carry a standalone fallback that
did escape quotes, but they all prefer BaseWidget.escapeHtml when
window.BaseWidget exists -- which it always does in the shipped page -- so
the correct fallbacks were dead code and the incomplete shared one ran.
Fixed at each source instead of at the call sites.

app-shell.js already documented this exact gap in a comment and worked
around it by building DOM nodes by hand; that workaround stays (setting a
property cannot be got wrong), the comment is now accurate.

cache.html's delete button interpolated the cache key into
`onclick="deleteCacheFile('...')"`. Escaping cannot help there -- the
browser HTML-decodes the attribute before parsing it as JS, so `&#39;`
becomes a real `'` again -- so the key moves to a data-cache-key
attribute that the handler reads back.

url-input.js additionally wrote a value straight into an <a href> after
validating it against a schema-supplied protocol list, and that list
accepted any RFC 3986 scheme -- "javascript" included. Scriptable schemes
(javascript, data, vbscript, blob, filesystem) are now refused both when
the list is normalised and when a URL is checked against it, and the
render path routes its href through the same check instead of emitting
whatever was stored (js/xss-through-dom).

test/js/unit/test_html_escaping.js reads each escaper out of the shipped
file and runs it, so losing the quote handling again fails a test rather
than a scan.

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

* fix(security): stop request-supplied names from reaching paths outside their base

Three of the py/path-injection alerts were live, not lint:

* GET /api/v3/plugins/<plugin_id>/static/<path:file_path> read any file
  whose resolved path *string-prefixed* the plugin directory. Flask's
  default converter forbids a slash but not dots, and
  get_plugin_directory('..') returned the parent of the plugins directory
  because it exists -- so every file under the project root then prefixed
  that directory, config/config_secrets.json included. The prefix check
  was also wrong on its own terms: with plugin dir "plugin-repos/foo",
  "../foo-evil/x" resolves to "plugin-repos/foo-evil/x", whose string does
  start with "plugin-repos/foo".

* POST /api/v3/plugins/of-the-day/json/delete interpolated the request
  body's file_id into f"{file_id}.json" and unlinked it, unvalidated. A
  file_id of "../../../../etc/something" deleted that file. This is the
  one finding in the batch that destroyed data rather than exposing it.

* POST /api/v3/cache/delete passed the body's key through
  CacheManager.clear_cache to DiskCache, which joined it as a filename and
  called os.remove. Same shape, same result. The guard goes in
  DiskCache.get_cache_path, the single choke point get/set/clear share, so
  every caller is covered rather than just this route. Real keys are the
  stems of files already flat in the cache directory -- that is how
  list_cache_files derives them -- so nothing legitimate is turned away.

The rest of the cluster (web_interface/app.py's asset route, the plugin
update handler, _get_plugin_version, the plugin-schema read in config.py)
was guarded in ways that held, but each had grown its own version of the
check. They now go through one helper, src/common/path_safety.py, which
returns the *sanitised value* rather than a verdict -- so a caller cannot
validate one string and open another, which is how the two real bugs
above were shaped.

Also: WiFiManager.connect_to_network took the SSID and password straight
from POST /api/v3/wifi/connect into nmcli's argv. There is no shell there,
so CodeQL's py/command-line-injection alert overstates the risk -- but
nmcli reads a leading "-" as an option, so an SSID of "--ask" asks nmcli
to run differently rather than to join a network. Both values are now
checked for shape (802.11's 32-octet SSID limit, WPA's 8-63 char
passphrase or 64-char hex key, no control characters, no leading dash)
before any subprocess runs.

test/test_path_traversal_guards.py asserts on the filesystem, not just
the status code: a handler that returns 403 and deletes the file anyway
would pass the weaker check. Twelve of its cases fail against the
unpatched code.

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

* fix(web): refuse a plugin id that is not a plain name, don't truncate it

pages_v3 and scripts/dev_server.py ran request ids through
os.path.basename and carried on with what came out, so "../weather"
rendered the config form for "weather". Nothing escaped the plugins
directory -- the relative_to guards held -- but the handler answered a
request nobody made, and validating one string while the filesystem sees
another is the shape both live traversals earlier in this branch had.

Same treatment as the rest: safe_path_component rejects rather than
truncates, resolve_under returns the path it checked, and the call sites
use what those return. The three handlers that had hand-rolled
resolve-and-relative_to blocks lose about twenty lines to the shared one.

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

* docs(web): say what the plugin web_ui iframe actually is

The docstring claimed the fragment runs "in a sandboxed iframe". The
iframe in plugin_config.html carries no sandbox attribute, so the
fragment runs with the interface's own origin. That is fine -- the file
belongs to an installed plugin, and an installed plugin already runs
Python on the device, so the trust boundary is install rather than this
route -- but a comment promising containment that is not there is worse
than no comment. This is the context for the py/reflective-xss alert on
this handler.

Also drops the now-unused os/os.path imports.

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

* fix(web): inline url-input's scheme guard at the previewLink.href sink

CodeQL flagged this line as a new high-severity js/xss-through-dom alert
on this PR even though it is already covered by SCRIPTABLE_SCHEMES: the
guard reached the sink through safeHref -> isValidUrl, two function calls
away, which its DOM-based-XSS sanitizer recognition does not trace.

Behavior is unchanged -- same scheme check, same SCRIPTABLE_SCHEMES list,
same allowedProtocols gate -- just inlined directly above the
previewLink.href assignment it guards, so the barrier is visible in the
same scope as the sink.

Added a regression test that runs the shipped onInput handler (not just
the extracted helpers) against a mocked DOM, so a future change that
reintroduces an unguarded previewLink.href assignment fails here.

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

* fix(security): address CodeRabbit findings on the CodeQL triage PR

- src/wifi_manager.py: reject non-ASCII WPA-PSK passphrases before any
  credential-saving or connect flow runs. NetworkManager only accepts
  printable ASCII passphrases (or a 64-char hex key); a non-ASCII value
  was previously saved/attempted before nmcli itself rejected it.

- web_interface/blueprints/api_v3/config.py: fail closed when the
  plugin config schema path can't be resolved under the plugins
  directory (e.g. a symlinked plugin dir). Previously this fell
  through with secret_fields left empty, so submitted credentials for
  that plugin were saved as ordinary, unencrypted configuration.

- web_interface/static/v3/js/widgets/plugin-file-manager.js: stop
  splicing the JSON day/column key into an inline oninput="..." handler
  string. escHtml() escapes quotes for a normal HTML attribute, but the
  browser HTML-decodes the attribute before running it as script, which
  undoes that escaping and lets a crafted column name (e.g. from an
  uploaded JSON file) break out of the JS string and execute. Cell
  edits now travel through data-day/data-col attributes read by one
  delegated 'input' listener instead.

  While in this file: fixed 6 pre-existing missing-')' typos on
  multi-line safeSetHTML(...) calls (already flagged by Biome in this
  PR's own CodeRabbit run as syntax errors blocking its lint pass).
  These predate this PR (present on main too) but made the whole file
  fail to parse in any JS engine, which is a bigger problem than the
  XSS finding itself and directly touches the same lines.

Added/extended regression tests for each fix; full suites pass
(pytest: 4580 passed, 62 skipped; JS: 84 assertions).

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:03 -04:00

1063 lines
41 KiB
Python

"""
Tests for Web Interface API endpoints.
Tests Flask routes, request/response handling, and API functionality.
"""
import pytest
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from flask import Flask
@pytest.fixture
def mock_config_manager():
"""Create a mock config manager."""
mock = MagicMock()
mock.load_config.return_value = {
'display': {'brightness': 50},
'plugins': {},
'timezone': 'UTC'
}
mock.get_config_path.return_value = 'config/config.json'
mock.get_secrets_path.return_value = 'config/config_secrets.json'
mock_config = {
'display': {'brightness': 50},
'plugins': {},
'timezone': 'UTC'
}
mock.load_config.return_value = mock_config
mock.get_raw_file_content.return_value = mock_config
mock.save_config_atomic.return_value = MagicMock(
status=MagicMock(value='success'),
message=None
)
return mock
@pytest.fixture
def mock_plugin_manager():
"""Create a mock plugin manager."""
mock = MagicMock()
mock.plugins = {}
mock.discover_plugins.return_value = []
mock.health_tracker = MagicMock()
mock.health_tracker.get_health_status.return_value = {'healthy': True}
return mock
@pytest.fixture
def client(mock_config_manager, mock_plugin_manager):
"""Create a Flask test client with mocked dependencies."""
# Create a minimal Flask app for testing
test_app = Flask(__name__)
test_app.config['TESTING'] = True
test_app.config['SECRET_KEY'] = 'test-secret-key'
# Register the API blueprint
from web_interface.blueprints.api_v3 import api_v3
# Mock the managers on the blueprint
api_v3.config_manager = mock_config_manager
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock()
api_v3.schema_manager = MagicMock()
api_v3.operation_queue = MagicMock()
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
api_v3.cache_manager = MagicMock()
# Setup operation queue mocks
mock_operation = MagicMock()
mock_operation.operation_id = 'test-op-123'
mock_operation.status = MagicMock(value='pending')
api_v3.operation_queue.get_operation_status.return_value = mock_operation
api_v3.operation_queue.get_recent_operations.return_value = []
# Setup schema manager mocks
api_v3.schema_manager.load_schema.return_value = {
'type': 'object',
'properties': {'enabled': {'type': 'boolean'}}
}
# Setup state manager mocks
api_v3.plugin_state_manager.get_all_states.return_value = {}
test_app.register_blueprint(api_v3, url_prefix='/api/v3')
with test_app.test_client() as client:
yield client
class TestConfigAPI:
"""Test configuration API endpoints."""
def test_get_main_config(self, client, mock_config_manager):
"""Test getting main configuration."""
response = client.get('/api/v3/config/main')
assert response.status_code == 200
data = json.loads(response.data)
assert data.get('status') == 'success'
assert 'data' in data
assert 'display' in data['data']
mock_config_manager.load_config.assert_called_once()
def test_save_main_config(self, client, mock_config_manager):
"""Test saving main configuration."""
new_config = {
'display': {'brightness': 75},
'timezone': 'UTC'
}
response = client.post(
'/api/v3/config/main',
data=json.dumps(new_config),
content_type='application/json'
)
assert response.status_code == 200
mock_config_manager.save_config_atomic.assert_called_once()
def test_save_main_config_fails_closed_when_schema_path_is_unresolvable(
self, client, mock_config_manager, mock_plugin_manager
):
"""A plugin id whose schema path fails safe-resolution must not have
its config saved with secret_fields left empty.
Regression test for the CodeQL/CodeRabbit finding on
web_interface/blueprints/api_v3/config.py: previously, when
resolve_under() returned None (e.g. a plugin directory reached via a
symlink), the code fell through to `secret_fields = set()` and saved
the plugin's submitted config -- credentials included -- as
ordinary, unencrypted configuration instead of refusing the request.
"""
mock_plugin_manager.plugin_manifests = {'evil': {}}
with patch('web_interface.blueprints.api_v3.config.resolve_under', return_value=None):
response = client.post(
'/api/v3/config/main',
data=json.dumps({'evil': {'api_key': 'super-secret'}}),
content_type='application/json'
)
assert response.status_code == 400
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_main_config_validation_error(self, client, mock_config_manager):
"""Test saving config with validation error."""
invalid_config = {'invalid': 'data'}
mock_config_manager.save_config_atomic.return_value = MagicMock(
status=MagicMock(value='validation_failed'),
message='Validation error'
)
response = client.post(
'/api/v3/config/main',
data=json.dumps(invalid_config),
content_type='application/json'
)
assert response.status_code in [400, 500]
def test_save_double_sided_settings(self, client, mock_config_manager):
"""Double-sided form fields are persisted under display.double_sided."""
# 2 copies on the vertical axis needs parallel to be a multiple of 2.
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 2,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'vertical',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['display']['double_sided'] == {
'enabled': True, 'copies': 2, 'axis': 'vertical',
}
def test_save_target_fps(self, client, mock_config_manager):
"""The device-wide scroll frame rate persists as a top-level int."""
response = client.post(
'/api/v3/config/main',
data={'target_fps': '90'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
# Must be the coerced int, not the raw form string -- the generic
# remaining-keys loop would otherwise write '90' back over it.
assert saved['target_fps'] == 90
def test_save_target_fps_alone_does_not_reset_other_general_settings(
self, client, mock_config_manager):
"""A target_fps-only POST must not be treated as a full General-tab save.
The general branch reads web_display_autostart as an unchecked-checkbox
(absent means False), so counting target_fps as a general update would
silently switch autostart off for anyone setting only the frame rate.
"""
mock_config_manager.load_config.return_value['web_display_autostart'] = True
response = client.post(
'/api/v3/config/main',
data={'target_fps': '90'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['web_display_autostart'] is True
@pytest.mark.parametrize('value', [90.5, 90.0, True])
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
"""int() would truncate silently: 90.5 -> 90, True -> 1.
Only JSON can carry these; a form post sends '90.5', which int()
already rejects.
"""
response = client.post(
'/api/v3/config/main',
data=json.dumps({'target_fps': value}),
content_type='application/json',
)
assert response.status_code == 400
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
"""Values ScrollHelper would silently clamp are reported instead."""
response = client.post(
'/api/v3/config/main',
data={'target_fps': value},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
"""Both endpoints of the documented range are valid."""
for value in ('30', '200'):
response = client.post(
'/api/v3/config/main',
data={'target_fps': value},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200, f"{value} should be accepted"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['target_fps'] == int(value)
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
response = client.post(
'/api/v3/config/main',
data={'double_sided_copies': '4', 'double_sided_axis': 'horizontal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
assert ds['copies'] == 4
def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager):
"""A copies/chain_length mismatch must not block saves while disabled.
The Display form posts copies/axis on every save, so validating them
with the feature off locked users out of every other display setting.
"""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
'brightness': '75',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
assert ds['copies'] == 2
def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager):
"""The same mismatch is still rejected once the feature is turned on."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
assert 'chain length' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager):
"""The vertical axis is checked against parallel, not chain_length."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 3,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'vertical',
},
content_type='application/x-www-form-urlencoded',
)
# chain_length 2 would divide evenly — only parallel 3 rejects this.
assert response.status_code == 400
assert 'parallel' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager):
"""While disabled, unusable copies/axis are dropped rather than rejected."""
mock_config_manager.load_config.return_value['display']['double_sided'] = {
'enabled': True, 'copies': 2, 'axis': 'horizontal',
}
response = client.post(
'/api/v3/config/main',
data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
# Stored values left untouched rather than overwritten with junk.
assert ds['copies'] == 2
assert ds['axis'] == 'horizontal'
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
"""copies < 2 is rejected with a 400 before any save."""
response = client.post(
'/api/v3/config/main',
data={'double_sided_enabled': 'true', 'double_sided_copies': '1'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_invalid_axis_rejected(self, client, mock_config_manager):
"""An unknown axis is rejected with a 400 before any save."""
response = client.post(
'/api/v3/config/main',
data={'double_sided_enabled': 'true', 'double_sided_axis': 'diagonal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
mock_config_manager.save_config_atomic.assert_not_called()
def test_get_secrets_config(self, client, mock_config_manager):
"""Test getting secrets configuration."""
response = client.get('/api/v3/config/secrets')
assert response.status_code == 200
data = json.loads(response.data)
assert 'weather' in data or 'data' in data
mock_config_manager.get_raw_file_content.assert_called_once()
def test_save_schedule_config(self, client, mock_config_manager):
"""Test saving schedule configuration."""
schedule_config = {
'enabled': True,
'start_time': '07:00',
'end_time': '23:00',
'mode': 'global'
}
response = client.post(
'/api/v3/config/schedule',
data=json.dumps(schedule_config),
content_type='application/json'
)
assert response.status_code == 200
mock_config_manager.save_config_atomic.assert_called_once()
class TestSystemAPI:
"""Test system API endpoints."""
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_get_system_status(self, mock_subprocess, client):
"""Test getting system status."""
# The endpoint returns 503 without psutil, which is an optional
# runtime dependency (requirements-test.txt installs it for CI).
pytest.importorskip("psutil")
mock_result = MagicMock()
mock_result.stdout = 'active\n'
mock_result.returncode = 0
mock_subprocess.run.return_value = mock_result
response = client.get('/api/v3/system/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'service' in data or 'status' in data or 'active' in data
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_get_system_version(self, mock_subprocess, client):
"""Test getting system version."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'v1.0.0\n'
mock_subprocess.run.return_value = mock_result
response = client.get('/api/v3/system/version')
assert response.status_code == 200
data = json.loads(response.data)
assert 'version' in data.get('data', {}) or 'version' in data
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_execute_system_action(self, mock_subprocess, client):
"""Test executing system action."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'success'
mock_subprocess.run.return_value = mock_result
action_data = {
'action': 'restart',
'service': 'ledmatrix'
}
response = client.post(
'/api/v3/system/action',
data=json.dumps(action_data),
content_type='application/json'
)
# May return 400 if action validation fails, or 200 if successful
assert response.status_code in [200, 400]
class TestDisplayAPI:
"""Test display API endpoints."""
def test_get_display_current(self, client):
"""Test getting current display information."""
# Mock cache manager on the blueprint
from web_interface.blueprints.api_v3 import api_v3
api_v3.cache_manager.get.return_value = {
'mode': 'weather',
'plugin_id': 'weather'
}
response = client.get('/api/v3/display/current')
assert response.status_code == 200
data = json.loads(response.data)
assert 'mode' in data or 'current' in data or 'data' in data
def test_get_on_demand_status(self, client):
"""Test getting on-demand display status."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.cache_manager.get.return_value = {
'active': False,
'mode': None
}
response = client.get('/api/v3/display/on-demand/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'active' in data or 'status' in data or 'data' in data
def test_start_on_demand_display(self, client):
"""Test starting on-demand display."""
from web_interface.blueprints.api_v3 import api_v3
request_data = {
'plugin_id': 'weather',
'mode': 'weather_current',
'duration': 30
}
# Ensure cache manager is set up
if not hasattr(api_v3, 'cache_manager') or api_v3.cache_manager is None:
api_v3.cache_manager = MagicMock()
response = client.post(
'/api/v3/display/on-demand/start',
data=json.dumps(request_data),
content_type='application/json'
)
# May return 404 if plugin not found, 200 if successful, or 500 on error
assert response.status_code in [200, 201, 404, 500]
# Verify cache was updated if successful
if response.status_code in [200, 201]:
assert api_v3.cache_manager.set.called
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
def test_stop_on_demand_display(self, mock_ensure_cache, client):
"""Test stopping on-demand display."""
# Mock the cache manager returned by _ensure_cache_manager
mock_cache_manager = MagicMock()
mock_ensure_cache.return_value = mock_cache_manager
response = client.post('/api/v3/display/on-demand/stop')
# May return 200 if successful or 500 on error
assert response.status_code in [200, 500]
# Verify stop request was set in cache if successful
if response.status_code == 200:
assert mock_cache_manager.set.called
class TestPluginsAPI:
"""Test plugins API endpoints."""
def test_get_installed_plugins(self, client, mock_plugin_manager):
"""Test getting list of installed plugins."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugins = {
'weather': MagicMock(plugin_id='weather'),
'clock': MagicMock(plugin_id='clock')
}
mock_plugin_manager.get_plugin_metadata.return_value = {
'id': 'weather',
'name': 'Weather Plugin'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager):
"""Installed-plugin entries surface latest_version + update_available
by comparing the on-disk manifest version to the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
# No on-disk manifest to merge — keep the version we hand in below.
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
]
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
mock_plugin_manager.get_plugin.return_value = None
# Registry advertises a newer version than the installed one.
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
payload = json.loads(response.data)
entry = payload['data']['plugins'][0]
assert entry['version'] == '1.0.0'
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is True
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager):
"""No update is flagged when installed version matches the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
]
mock_plugin_manager.get_plugin.return_value = None
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
entry = json.loads(response.data)['data']['plugins'][0]
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is False
def test_is_plugin_update_available_helper(self):
"""Unit-level checks for the semver-aware update comparison."""
from web_interface.blueprints.api_v3 import _is_plugin_update_available
assert _is_plugin_update_available('1.0.0', '1.0.1') is True
assert _is_plugin_update_available('1.0.1', '1.0.1') is False
# Local build ahead of the registry must not be flagged.
assert _is_plugin_update_available('2.0.0', '1.9.9') is False
# Missing either side yields no signal.
assert _is_plugin_update_available('', '1.0.0') is False
assert _is_plugin_update_available('1.0.0', '') is False
# Unparseable version differing from the installed one surfaces the
# mismatch rather than hiding a possible update.
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
def test_get_plugin_health(self, client, mock_plugin_manager):
"""Test getting plugin health information."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
# Setup health tracker
mock_health_tracker = MagicMock()
mock_health_tracker.get_all_health_summaries.return_value = {
'weather': {'healthy': True}
}
mock_plugin_manager.health_tracker = mock_health_tracker
response = client.get('/api/v3/plugins/health')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
def test_get_plugin_health_single(self, client, mock_plugin_manager):
"""Test getting health for single plugin."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
# Setup health tracker with proper method (endpoint calls get_health_summary)
mock_health_tracker = MagicMock()
mock_health_tracker.get_health_summary.return_value = {
'healthy': True,
'failures': 0,
'last_success': '2024-01-01T00:00:00'
}
mock_plugin_manager.health_tracker = mock_health_tracker
response = client.get('/api/v3/plugins/health/weather')
assert response.status_code == 200
data = json.loads(response.data)
assert 'healthy' in data.get('data', {}) or 'data' in data
def test_toggle_plugin(self, client, mock_config_manager, mock_plugin_manager):
"""Test toggling plugin enabled state."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
api_v3.plugin_manager = mock_plugin_manager
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
# Setup plugin manifests
mock_plugin_manager.plugin_manifests = {'weather': {}}
request_data = {
'plugin_id': 'weather',
'enabled': True
}
response = client.post(
'/api/v3/plugins/toggle',
data=json.dumps(request_data),
content_type='application/json'
)
assert response.status_code == 200
mock_config_manager.save_config_atomic.assert_called_once()
def test_get_plugin_config(self, client, mock_config_manager):
"""Test getting plugin configuration."""
# Plugin configs live at top-level keys (not under 'plugins')
mock_config_manager.load_config.return_value = {
'weather': {
'enabled': True,
'api_key': 'test_key'
}
}
# Ensure schema manager returns serializable values
from web_interface.blueprints.api_v3 import api_v3
api_v3.schema_manager.generate_default_config.return_value = {'enabled': False}
api_v3.schema_manager.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
response = client.get('/api/v3/plugins/config?plugin_id=weather')
assert response.status_code == 200
data = json.loads(response.data)
assert 'enabled' in data or 'config' in data or 'data' in data
def test_save_plugin_config(self, client, mock_config_manager):
"""Test saving plugin configuration."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
api_v3.schema_manager = MagicMock()
api_v3.schema_manager.load_schema.return_value = {
'type': 'object',
'properties': {'enabled': {'type': 'boolean'}}
}
request_data = {
'plugin_id': 'weather',
'config': {
'enabled': True,
'update_interval': 300
}
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json'
)
assert response.status_code in [200, 500] # May fail if validation fails
if response.status_code == 200:
mock_config_manager.save_config_atomic.assert_called_once()
def test_get_plugin_schema(self, client):
"""Test getting plugin configuration schema."""
response = client.get('/api/v3/plugins/schema?plugin_id=weather')
assert response.status_code == 200
data = json.loads(response.data)
assert 'type' in data or 'schema' in data or 'data' in data
def test_get_operation_status(self, client):
"""Test getting plugin operation status."""
from web_interface.blueprints.api_v3 import api_v3
# Setup operation queue mock
mock_operation = MagicMock()
mock_operation.operation_id = 'test-op-123'
mock_operation.status = MagicMock(value='pending')
mock_operation.operation_type = MagicMock(value='install')
mock_operation.plugin_id = 'test-plugin'
mock_operation.created_at = '2024-01-01T00:00:00'
# Add to_dict method that the endpoint calls
mock_operation.to_dict.return_value = {
'operation_id': 'test-op-123',
'status': 'pending',
'operation_type': 'install',
'plugin_id': 'test-plugin'
}
api_v3.operation_queue.get_operation_status.return_value = mock_operation
response = client.get('/api/v3/plugins/operation/test-op-123')
assert response.status_code == 200
data = json.loads(response.data)
assert 'status' in data or 'operation' in data or 'data' in data
def test_get_operation_history(self, client):
"""Test getting operation history."""
response = client.get('/api/v3/plugins/operation/history')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
def test_get_plugin_state(self, client):
"""Test getting plugin state."""
response = client.get('/api/v3/plugins/state')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
class TestFontsAPI:
"""Test fonts API endpoints."""
def test_get_fonts_catalog(self, client):
"""Test getting fonts catalog."""
# Fonts endpoints don't use FontManager, they return hardcoded data
response = client.get('/api/v3/fonts/catalog')
assert response.status_code == 200
data = json.loads(response.data)
assert 'catalog' in data.get('data', {}) or 'data' in data
def test_get_font_tokens(self, client):
"""Test getting font tokens."""
response = client.get('/api/v3/fonts/tokens')
assert response.status_code == 200
data = json.loads(response.data)
assert 'tokens' in data.get('data', {}) or 'data' in data
def test_get_fonts_overrides(self, client):
"""Test getting font overrides."""
response = client.get('/api/v3/fonts/overrides')
assert response.status_code == 200
data = json.loads(response.data)
assert 'overrides' in data.get('data', {}) or 'data' in data
def test_save_fonts_overrides(self, client):
"""Test saving font overrides."""
request_data = {
'weather': 'small',
'clock': 'regular'
}
response = client.post(
'/api/v3/fonts/overrides',
data=json.dumps(request_data),
content_type='application/json'
)
assert response.status_code == 200
class TestAPIErrorHandling:
"""Test API error handling."""
def test_invalid_json_request(self, client):
"""Test handling invalid JSON in request."""
response = client.post(
'/api/v3/config/main',
data='invalid json',
content_type='application/json'
)
# Flask may return 500 for JSON decode errors or 400 for bad request
assert response.status_code in [400, 415, 500]
def test_missing_required_fields(self, client):
"""Test handling missing required fields."""
response = client.post(
'/api/v3/plugins/toggle',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code in [400, 422, 500]
def test_nonexistent_endpoint(self, client):
"""Test accessing nonexistent endpoint."""
response = client.get('/api/v3/nonexistent')
assert response.status_code == 404
def test_method_not_allowed(self, client):
"""Test using wrong HTTP method."""
# GET instead of POST
response = client.get('/api/v3/config/main',
query_string={'method': 'POST'})
# Should work for GET, but if we try POST-only endpoint with GET
response = client.get('/api/v3/config/schedule')
# Schedule might allow GET, so test a POST-only endpoint
response = client.get('/api/v3/display/on-demand/start')
assert response.status_code in [200, 405] # Depends on implementation
class TestDottedKeyNormalization:
"""Regression tests for fix_array_structures / ensure_array_defaults with dotted schema keys."""
def test_save_plugin_config_dotted_key_arrays(self, client, mock_config_manager):
"""Nested dotted-key objects with numeric-keyed dicts are converted to arrays."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
mock_config_manager.load_config.return_value = {}
schema_mgr = MagicMock()
schema = {
'type': 'object',
'properties': {
'leagues': {
'type': 'object',
'properties': {
'eng.1': {
'type': 'object',
'properties': {
'enabled': {'type': 'boolean', 'default': True},
'favorite_teams': {
'type': 'array',
'items': {'type': 'string'},
'default': [],
},
},
},
},
},
},
}
schema_mgr.load_schema.return_value = schema
schema_mgr.generate_default_config.return_value = {
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
schema_mgr.validate_config_against_schema.return_value = (True, [])
api_v3.schema_manager = schema_mgr
request_data = {
'plugin_id': 'soccer-scoreboard',
'config': {
'leagues': {
'eng.1': {
'enabled': True,
'favorite_teams': ['Arsenal', 'Chelsea'],
},
},
},
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json',
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
soccer_cfg = saved.get('soccer-scoreboard', {})
leagues = soccer_cfg.get('leagues', {})
assert 'eng.1' in leagues, f"Expected 'eng.1' key, got: {list(leagues.keys())}"
assert isinstance(leagues['eng.1'].get('favorite_teams'), list)
assert leagues['eng.1']['favorite_teams'] == ['Arsenal', 'Chelsea']
def test_save_plugin_config_none_array_gets_default(self, client, mock_config_manager):
"""None array fields under dotted-key parents are replaced with defaults."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
mock_config_manager.load_config.return_value = {}
schema_mgr = MagicMock()
schema = {
'type': 'object',
'properties': {
'leagues': {
'type': 'object',
'properties': {
'eng.1': {
'type': 'object',
'properties': {
'favorite_teams': {
'type': 'array',
'items': {'type': 'string'},
'default': [],
},
},
},
},
},
},
}
schema_mgr.load_schema.return_value = schema
schema_mgr.generate_default_config.return_value = {
'leagues': {'eng.1': {'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = (True, [])
api_v3.schema_manager = schema_mgr
request_data = {
'plugin_id': 'soccer-scoreboard',
'config': {
'leagues': {
'eng.1': {
'favorite_teams': None,
},
},
},
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json',
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
soccer_cfg = saved.get('soccer-scoreboard', {})
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
assert teams == [], f"Expected empty default list, got: {teams}"
class TestPluginHealthRoutes:
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
they surface cross-process data persisted by the display service."""
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager):
from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.plugin_health import PluginHealthTracker
cache = MagicMock()
cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}}
mock_plugin_manager.health_tracker = PluginHealthTracker(cache)
resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200
data = resp.get_json()['data']
assert set(data.keys()) == {'p1', 'p2'}
assert data['p1']['is_healthy'] is True
assert data['p1']['degraded'] is False
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager):
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.health_tracker = None
resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200
body = resp.get_json()
assert body['data'] == {}
assert 'not available' in body['message'].lower()
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager):
from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.resource_monitor import PluginResourceMonitor
cache = MagicMock()
cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugin_manifests = {'p1': {}}
mock_plugin_manager.resource_monitor = PluginResourceMonitor(
cache, enable_monitoring=False
)
resp = client.get('/api/v3/plugins/metrics')
assert resp.status_code == 200
data = resp.get_json()['data']
assert 'p1' in data
assert data['p1']['call_count'] == 0