merge: bring the element-style work onto main's api_v3 package split

main moved 16 commits while this branch was open, and one of them split
api_v3.py into a package. A rebase would have conflicted on that file in
each of the 15 commits that touch it; merging resolves it once.

Reapplied into the package, unchanged in substance:

- _schema_type_is / _schema_allows_null and the nullable-field handling
  (api_v3/__init__.py, where the form parser now lives)
- the indexed-array recombiner's union-type fix and its all-blank-means-null
  case (api_v3/plugins.py)
- scalable / native_size on the font catalog, and the removal of the three
  font-override stubs (api_v3/fonts.py)

Two places took main's version rather than mine, deliberately:

- pages_v3 now reads the schema through SchemaManager *and* keeps
  resolve_under for the raw-read fallback, so the containment guard main
  added in the CodeQL triage survives.
- serve_plugin_widget moves off os.path.basename onto safe_path_component
  and resolve_under. main dropped `import os` from pages_v3 in favour of
  those helpers, and they reject a bad component rather than mangling it
  into a valid-looking one.

The url-map snapshot loses exactly the three font-override routes. Edited
rather than regenerated: that test exists to catch a route lost by accident
during a move, and regenerating it wholesale is how such a loss would be
papered over.

Verified against a fresh run of current main: same failure set, 4692
passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 19:27:06 -04:00
co-authored by Claude Opus 5
101 changed files with 16851 additions and 10432 deletions
+54
View File
@@ -16,6 +16,60 @@ if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
def pytest_configure(config):
"""Point the emulator at a per-process config that binds no socket.
Six test modules set EMULATOR=true and build a real DisplayManager. The
repo's emulator_config.json selects the "browser" adapter, which binds a
fixed TCP port (8888) to serve the dev preview. That port is a machine-wide
singleton, so a second pytest process -- a CI shard, another worktree, an
agent running the suite alongside -- loses the bind. RGBMatrix construction
then raises, DisplayManager falls back to ``self.matrix = None``, and every
test that touches the matrix dies with a misleading
``AttributeError: 'NoneType' object has no attribute 'SwapOnVSync'``.
The "raw" adapter renders in memory and binds nothing, so concurrent runs
stop fighting over the port. The tests wrap SwapOnVSync on the matrix object
itself, so they are indifferent to which adapter sits underneath. Only the
adapter is overridden -- every other key is inherited from the repo config,
which stays on "browser" for ``run.py -e``.
"""
try:
from RGBMatrixEmulator.internal.emulator_config import RGBMatrixEmulatorConfig
except ImportError:
return # No emulator installed; the EMULATOR=true modules can't run anyway.
import json
import tempfile
settings = {}
try:
settings = json.loads((project_root / "emulator_config.json").read_text())
except (OSError, ValueError):
pass # Fall back to the emulator's own defaults; only the adapter matters.
if not isinstance(settings, dict):
settings = {}
settings["display_adapter"] = "raw"
# Falling back would land us back on the browser adapter and its fixed port.
settings["allow_adapter_fallback"] = False
tmp_dir = Path(tempfile.mkdtemp(prefix="ledmatrix-emulator-"))
tmp_config = tmp_dir / "emulator_config.json"
tmp_config.write_text(json.dumps(settings))
# CONFIG_PATH is a bare relative filename resolved against the CWD; an
# absolute path makes it independent of where pytest was invoked from.
RGBMatrixEmulatorConfig.CONFIG_PATH = str(tmp_config)
config._ledmatrix_emulator_tmp = tmp_dir
def pytest_unconfigure(config):
"""Remove the throwaway emulator config written by pytest_configure."""
tmp_dir = getattr(config, "_ledmatrix_emulator_tmp", None)
if tmp_dir is not None:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
@pytest.fixture
def mock_display_manager():
"""Create a mock DisplayManager for testing."""
+973
View File
@@ -0,0 +1,973 @@
[
[
"/api/v3/backup/<path:filename>",
"api_v3.backup_delete",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/backup/download/<path:filename>",
"api_v3.backup_download",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/backup/export",
"api_v3.backup_export",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/backup/list",
"api_v3.backup_list",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/backup/preview",
"api_v3.backup_preview",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/backup/restore",
"api_v3.backup_restore",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/backup/validate",
"api_v3.backup_validate",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/cache/delete",
"api_v3.delete_cache_file",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/cache/list",
"api_v3.list_cache_files",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/dim-schedule",
"api_v3.get_dim_schedule_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/dim-schedule",
"api_v3.save_dim_schedule_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/main",
"api_v3.get_main_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/main",
"api_v3.save_main_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/raw/main",
"api_v3.save_raw_main_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/raw/secrets",
"api_v3.save_raw_secrets_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/schedule",
"api_v3.get_schedule_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/schedule",
"api_v3.save_schedule_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/secrets",
"api_v3.get_secrets_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/current",
"api_v3.get_display_current",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/current-status",
"api_v3.get_current_display_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/modes",
"api_v3.get_display_modes",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/on-demand/start",
"api_v3.start_on_demand_display",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/display/on-demand/status",
"api_v3.get_on_demand_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/on-demand/stop",
"api_v3.stop_on_demand_display",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/errors/clear",
"api_v3.clear_old_errors",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/errors/plugin/<plugin_id>",
"api_v3.get_plugin_errors",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/errors/summary",
"api_v3.get_error_summary",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/<font_family>",
"api_v3.delete_font",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/fonts/catalog",
"api_v3.get_fonts_catalog",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/preview",
"api_v3.get_font_preview",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/tokens",
"api_v3.get_font_tokens",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/upload",
"api_v3.upload_font",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/hardware/status",
"api_v3.get_hardware_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/health",
"api_v3.get_health",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge",
"api_v3.get_mqtt_bridge",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge/config",
"api_v3.update_mqtt_bridge_config",
[
"OPTIONS",
"PUT"
]
],
[
"/api/v3/logs",
"api_v3.get_logs",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/<plugin_id>/static/<path:file_path>",
"api_v3.serve_plugin_static",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/action",
"api_v3.execute_plugin_action",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/assets/delete",
"api_v3.delete_plugin_asset",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/assets/list",
"api_v3.list_plugin_assets",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/assets/upload",
"api_v3.upload_plugin_asset",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/authenticate/spotify",
"api_v3.authenticate_spotify",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/authenticate/ytm",
"api_v3.authenticate_ytm",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/calendar/authenticate",
"api_v3.authenticate_calendar",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/calendar/list-calendars",
"api_v3.list_calendar_calendars",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/calendar/upload-credentials",
"api_v3.upload_calendar_credentials",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/config",
"api_v3.get_plugin_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/config",
"api_v3.save_plugin_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/config/reset",
"api_v3.reset_plugin_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/health",
"api_v3.get_plugin_health",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/health/<plugin_id>",
"api_v3.get_plugin_health_single",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/health/<plugin_id>/reset",
"api_v3.reset_plugin_health",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/install",
"api_v3.install_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/install-from-url",
"api_v3.install_plugin_from_url",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/installed",
"api_v3.get_installed_plugins",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/limits/<plugin_id>",
"api_v3.manage_plugin_limits",
[
"GET",
"HEAD",
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/metrics",
"api_v3.get_plugin_metrics",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/metrics/<plugin_id>",
"api_v3.get_plugin_metrics_single",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/metrics/<plugin_id>/reset",
"api_v3.reset_plugin_metrics",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/of-the-day/json/delete",
"api_v3.delete_of_the_day_json",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/of-the-day/json/upload",
"api_v3.upload_of_the_day_json",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/operation/<operation_id>",
"api_v3.get_operation_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/operation/history",
"api_v3.clear_operation_history",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/plugins/operation/history",
"api_v3.get_operation_history",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/reconciliation-status",
"api_v3.get_reconciliation_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/registry-from-url",
"api_v3.get_registry_from_url",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/saved-repositories",
"api_v3.add_saved_repository",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/saved-repositories",
"api_v3.get_saved_repositories",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/saved-repositories",
"api_v3.remove_saved_repository",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/plugins/schema",
"api_v3.get_plugin_schema",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/state",
"api_v3.get_plugin_state",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/state/reconcile",
"api_v3.reconcile_plugin_state",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/store/github-status",
"api_v3.get_github_auth_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/store/list",
"api_v3.list_plugin_store",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/store/refresh",
"api_v3.refresh_plugin_store",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/toggle",
"api_v3.toggle_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/uninstall",
"api_v3.uninstall_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/update",
"api_v3.update_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/skins",
"api_v3.list_skins",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps",
"api_v3.get_starlark_apps",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>",
"api_v3.get_starlark_app",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>",
"api_v3.uninstall_starlark_app",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>/config",
"api_v3.get_starlark_app_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>/config",
"api_v3.update_starlark_app_config",
[
"OPTIONS",
"PUT"
]
],
[
"/api/v3/starlark/apps/<app_id>/render",
"api_v3.render_starlark_app",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/apps/<app_id>/toggle",
"api_v3.toggle_starlark_app",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/editor/apps",
"api_v3.list_pixlet_editor_apps",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/start",
"api_v3.start_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/editor/status",
"api_v3.get_pixlet_editor_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/stop",
"api_v3.stop_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/install-pixlet",
"api_v3.install_pixlet",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/repository/browse",
"api_v3.browse_tronbyte_repository",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/repository/categories",
"api_v3.get_tronbyte_categories",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/repository/install",
"api_v3.install_from_tronbyte_repository",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/status",
"api_v3.get_starlark_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/upload",
"api_v3.upload_starlark_app",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/sync/status",
"api_v3.get_sync_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/action",
"api_v3.execute_system_action",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/system/check-update",
"api_v3.check_for_update",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/git-branches",
"api_v3.get_git_branches",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/git-info",
"api_v3.get_git_info",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/status",
"api_v3.get_system_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/version",
"api_v3.get_system_version",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/ap/auto-enable",
"api_v3.get_auto_enable_ap_mode",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/ap/auto-enable",
"api_v3.set_auto_enable_ap_mode",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/ap/disable",
"api_v3.disable_ap_mode",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/ap/enable",
"api_v3.enable_ap_mode",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/connect",
"api_v3.connect_wifi",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/disconnect",
"api_v3.disconnect_wifi",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/radio",
"api_v3.get_wifi_radio",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/radio",
"api_v3.set_wifi_radio",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/scan",
"api_v3.scan_wifi_networks",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/status",
"api_v3.get_wifi_status",
[
"GET",
"HEAD",
"OPTIONS"
]
]
]
+1
View File
@@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout.
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
with two plugins installed will pass while exercising very little.
+127
View File
@@ -0,0 +1,127 @@
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
// server-rendered /partials/tools, and the payloads are the real API's, so a
// renamed field or a changed shape fails this rather than passing quietly.
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
(async () => {
const partial = await get('/partials/tools');
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
// Controllable fetch: serve the real payloads, and let tests swap in others.
let editorStatus = { status: 'success', data: { running: false } };
let bridgePayload = bridge;
let onPut = null;
const stubFetch = (url, opts) => {
const u = String(url);
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
let body = { status: 'success', data: {} };
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
else if (u.includes('/starlark/editor/status')) body = editorStatus;
else if (u.includes('/starlark/editor/apps')) body = apps;
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
};
// runScripts:'dangerously' so the partial's own <script> executes the way a
// browser runs it -- function declarations land on window. Evaluating the
// source by hand instead leaves helpers like escHtml off the global object
// and the page fails in ways it never would in a browser.
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
const { window } = dom;
const tick = ms => new Promise(r => setTimeout(r, ms));
await tick(300);
const $ = id => window.document.getElementById(id);
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
// ── MQTT bridge ────────────────────────────────────────────────────────
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
ok('password field is EMPTY', $('mqtt-password').value === '');
ok('password field is type=password', $('mqtt-password').type === 'password');
ok('no password value anywhere in the DOM',
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
$('mqtt-bridge-state').textContent);
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
// The save body must omit the password when the field is blank.
let sent = null;
onPut = body => { sent = body; };
window.saveMqttBridge();
await tick(150);
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
$('mqtt-password').value = 'typed-secret';
window.saveMqttBridge();
await tick(150);
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
onPut = null;
// ── Pixlet editor, idle ────────────────────────────────────────────────
const appIds = (apps.data.apps || []).map(a => a.id);
ok('editor lists the apps on disk',
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
ok('no session banner while idle', !$('pixlet-countdown'));
// escHtml does not encode single quotes, so an app id interpolated into an
// inline onclick="startPixletEditor('...')" could break out of the JS string
// and run script. The id must reach the handler through dataset instead.
const editBtns = [...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')];
ok('edit buttons exist', editBtns.length > 0, editBtns.length);
ok('edit buttons carry no inline onclick',
editBtns.every(b => !b.getAttribute('onclick')),
editBtns.map(b => b.getAttribute('onclick')));
ok('edit buttons pass the app id via dataset',
editBtns.every(b => appIds.includes(b.dataset.appId)),
editBtns.map(b => b.dataset.appId));
ok('warns that the display stops',
window.document.body.textContent.includes('display stops while a session is open'));
// ── Pixlet editor, running ─────────────────────────────────────────────
editorStatus = { status: 'success', data: {
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
timeout: 1800, host_bound: '0.0.0.0' } };
window.loadPixletEditor();
await tick(200);
ok('running session shows the banner', !!$('pixlet-countdown'));
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
$('pixlet-countdown') && $('pixlet-countdown').textContent);
ok('Stop button offered', !!$('btn-pixlet-stop'));
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
ok('editor link present', !!link);
ok('link uses this host, not localhost — no tunnel needed',
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
link && link.href);
ok('Edit buttons disabled while a session runs',
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
ok('banner names the app being edited',
$('pixlet-editor-body').textContent.includes('test-editor-app'));
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
+4 -2
View File
@@ -14,8 +14,10 @@ const path = require('path');
const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
function reachable(url) {
return new Promise(res => {
+320
View File
@@ -0,0 +1,320 @@
// Every HTML escaper in the web interface must escape quotes, not just
// & < >.
//
// The escapers are all written as `div.textContent = x; return div.innerHTML`.
// That round-trip escapes &, < and > because those are the only characters the
// HTML serializer has to escape in a *text node* -- quotes are left alone. But
// the widgets interpolate the result into quoted attribute values
// (`value="${escapeHtml(v)}"`, `title="${escapeHtml(v)}"`, ...), and there a
// bare `"` closes the attribute and lets the value add attributes of its own:
//
// name" onfocus="alert(1)
//
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
// exactly this. This suite pins the fix at the source: it reads each real
// implementation out of the shipped file and runs it, so an escaper that loses
// its quote handling again fails here rather than in a scanner run weeks later.
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '../../../web_interface');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 300) : '')));
// ── DOM shim ───────────────────────────────────────────────────────────────
// Mirrors what a browser does reading innerHTML back off textContent: & < >
// are escaped, quotes are not. Anything the escaper adds on top of that is
// the escaper's own doing, which is what we are testing.
class FakeEl {
set textContent(v) { this._t = String(v == null ? '' : v); }
get textContent() { return this._t || ''; }
get innerHTML() {
return (this._t || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}
global.document = { createElement: () => new FakeEl() };
global.window = global;
// ── source extraction ──────────────────────────────────────────────────────
// Pull a function out of a real source file by its opening line and balanced
// braces, so the test runs the shipped code rather than a copy of it.
function extract(file, opener) {
const src = fs.readFileSync(path.join(ROOT, file), 'utf8');
const start = src.indexOf(opener);
if (start < 0) {
console.error(`FAIL: cannot find ${JSON.stringify(opener)} in ${file}`);
process.exit(1);
}
let i = src.indexOf('{', start), depth = 0;
for (let j = i; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') {
depth--;
if (depth === 0) return src.slice(start, j + 1);
}
}
console.error(`FAIL: unbalanced braces after ${JSON.stringify(opener)} in ${file}`);
process.exit(1);
}
// Evaluate an extracted escaper and return it as a callable.
function loadFn(file, opener, name, { method = false } = {}) {
const body = extract(file, opener);
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
// their own -- wrap them in an object literal so they can be evaluated.
const code = method
? `(function(){ const o = { ${body} }; return o.${name}.bind(o); })()`
: `(function(){ ${body}; return ${name}; })()`;
// eslint-disable-next-line no-eval
return eval(code);
}
// ── the escapers, as shipped ───────────────────────────────────────────────
const ESCAPERS = [
['base-widget.js (BaseWidget.escapeHtml)',
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
['plugins_manager.js (top-level escapeHtml)',
'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['plugins_manager.js (starlark escapeHtml)',
'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false],
['error_handler.js (escapeHtml)',
'static/v3/js/utils/error_handler.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['json-file-manager.js (_esc)',
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
['plugin-file-manager.js (escHtml)',
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
['app-shell.js (escapeHtml)',
'static/v3/js/app-shell.js', 'escapeHtml(text) {', 'escapeHtml', true],
['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['cache.html (escapeHtml)',
'templates/v3/partials/cache.html', 'function escapeHtml(text) {', 'escapeHtml', false],
];
// The breakout payload: closes a double-quoted attribute and opens an event
// handler. If `"` survives escaping, this is live script in the rendered page.
const BREAKOUT = 'x" onmouseover="alert(1)';
console.log('\n1. every escaper neutralises a double-quote attribute breakout');
for (const [label, file, opener, name, method] of ESCAPERS) {
const fn = loadFn(file, opener, name, { method });
const out = String(fn(BREAKOUT));
ok(`${label}: no raw "`, !out.includes('"'), out);
ok(`${label}: emits &quot;`, out.includes('&quot;'), out);
}
console.log('\n2. every escaper also escapes single quotes');
for (const [label, file, opener, name, method] of ESCAPERS) {
const fn = loadFn(file, opener, name, { method });
const out = String(fn("x' onmouseover='alert(1)"));
ok(`${label}: no raw '`, !out.includes("'"), out);
}
console.log('\n3. the & < > behaviour they already had is unchanged');
for (const [label, file, opener, name, method] of ESCAPERS) {
const fn = loadFn(file, opener, name, { method });
const out = String(fn('<img src=x onerror=alert(1)> & done'));
ok(`${label}: no raw <`, !out.includes('<'), out);
ok(`${label}: no raw >`, !out.includes('>'), out);
ok(`${label}: & becomes &amp;`, /&amp;/.test(out), out);
}
console.log('\n4. ampersands are escaped before quotes, so &quot; cannot be forged');
// If `&` were escaped last, the input `&quot;` would come out as a literal
// `"` after the browser decodes the attribute. Order matters; pin it.
for (const [label, file, opener, name, method] of ESCAPERS) {
const fn = loadFn(file, opener, name, { method });
const out = String(fn('&quot;'));
ok(`${label}: &quot; input stays inert`, out === '&amp;quot;', out);
}
// ── url-input scheme handling (js/xss-through-dom) ─────────────────────────
console.log('\n5. url-input never treats a scriptable scheme as a valid URL');
{
const src = fs.readFileSync(path.join(ROOT, 'static/v3/js/widgets/url-input.js'), 'utf8');
const a = src.indexOf('const RFC_SCHEME_PATTERN');
const b = src.indexOf("window.LEDMatrixWidgets.register('url-input'");
if (a < 0 || b < 0) { console.error('FAIL: cannot locate url-input helpers'); process.exit(1); }
// eslint-disable-next-line no-eval
const helpers = eval(`(function(){ ${src.slice(a, b)}; return { normalizeProtocols, isValidUrl, safeHref }; })()`);
const { normalizeProtocols, isValidUrl, safeHref } = helpers;
ok('javascript: rejected under the default protocols',
!isValidUrl('javascript:alert(1)', ['http', 'https']));
ok('javascript: still rejected when a schema asks for it',
!isValidUrl('javascript:alert(1)', normalizeProtocols(['javascript'])));
ok('a schema asking only for javascript falls back to http/https',
JSON.stringify(normalizeProtocols(['javascript'])) === JSON.stringify(['http', 'https']),
normalizeProtocols(['javascript']));
ok('data: rejected', !isValidUrl('data:text/html,<script>alert(1)</script>', ['http', 'https', 'data']));
ok('vbscript: rejected', !isValidUrl('vbscript:msgbox(1)', ['http', 'https', 'vbscript']));
ok('normalizeProtocols drops scriptable schemes but keeps the rest',
JSON.stringify(normalizeProtocols('https,javascript,ftp')) === JSON.stringify(['https', 'ftp']),
normalizeProtocols('https,javascript,ftp'));
ok('ordinary https URL still valid', isValidUrl('https://example.com/x?y=1', ['http', 'https']));
ok('ordinary http URL still valid', isValidUrl('http://example.com', ['http', 'https']));
ok('a scheme outside the allow-list is still rejected',
!isValidUrl('ftp://example.com', ['http', 'https']));
ok('safeHref passes a good URL through', safeHref('https://example.com', ['http', 'https']) === 'https://example.com');
ok('safeHref blanks a javascript: URL', safeHref('javascript:alert(1)', ['http', 'https']) === '');
ok('safeHref blanks an unparseable value', safeHref('not a url', ['http', 'https']) === '');
}
// ── url-input onInput: the sink itself, not just the pulled-out helpers ────
// The scheme check now lives inline in onInput, right where the value reaches
// `previewLink.href`, instead of behind safeHref/isValidUrl -- see the comment
// at that assignment in url-input.js for why. Run the handler as shipped so a
// regression that reintroduces an unguarded `previewLink.href = value` fails
// here, not just in a scanner run weeks later.
console.log('\n6. url-input onInput: previewLink.href is guarded at the sink');
{
class FakeClassList {
constructor() { this.classes = new Set(['hidden']); }
add(c) { this.classes.add(c); }
remove(c) { this.classes.delete(c); }
contains(c) { return this.classes.has(c); }
}
const mockDoc = (value, protocolsAttr) => {
const elements = {
_input: { value, checkValidity: () => true, validationMessage: '', classList: new FakeClassList() },
_preview: { classList: new FakeClassList() },
_preview_link: {
classList: new FakeClassList(),
_href: undefined,
set href(v) { this._href = v; },
get href() { return this._href; },
removeAttribute(name) { if (name === 'href') this._href = undefined; },
},
_widget: { dataset: { protocols: protocolsAttr } },
_error: { classList: new FakeClassList(), textContent: '' },
};
return {
elements,
getElementById: (id) => elements[Object.keys(elements).find(k => id === `field${k}`)] || null,
};
};
function runOnInput(value, protocolsAttr) {
const { elements, getElementById } = mockDoc(value, protocolsAttr);
const savedDocument = global.document;
const savedWindow = global.window;
let registered = null;
global.document = { createElement: () => new FakeEl(), getElementById };
global.window = {
LEDMatrixWidgets: {
register: (name, obj) => { registered = obj; },
get: () => registered,
getHandlers: () => registered.handlers,
},
};
try {
const src = fs.readFileSync(path.join(ROOT, 'static/v3/js/widgets/url-input.js'), 'utf8');
// eslint-disable-next-line no-eval
eval(src);
registered.handlers.onInput('field');
} finally {
global.document = savedDocument;
global.window = savedWindow;
}
return elements;
}
let els = runOnInput('javascript:alert(1)', 'http,https');
ok('javascript: never reaches previewLink.href', els._preview_link.href === undefined, els._preview_link.href);
ok('javascript: leaves the preview hidden', els._preview.classList.contains('hidden'));
els = runOnInput('https://example.com', 'http,https');
ok('an ordinary https URL reaches previewLink.href', els._preview_link.href === 'https://example.com', els._preview_link.href);
ok('an ordinary https URL unhides the preview', !els._preview.classList.contains('hidden'));
els = runOnInput('data:text/html,<script>alert(1)</script>', 'http,https,data');
ok('data: never reaches previewLink.href even when the schema allows it',
els._preview_link.href === undefined, els._preview_link.href);
els = runOnInput('not a url', 'http,https');
ok('an unparseable value never reaches previewLink.href', els._preview_link.href === undefined, els._preview_link.href);
}
// ── plugin-file-manager: cell edits travel via data-*, not inline handlers ──
// A JSON key/day from an uploaded file used to be spliced, HTML-escaped,
// into an oninput="...('${escHtml(col)}'...)" attribute. escHtml neutralises
// a quote for an ordinary attribute, but here the value also has to survive
// as a *JS string literal* -- the browser HTML-decodes the attribute before
// running it as script, which turns the escaped quote back into a real one
// and lets a column named `x');alert(1);//` break out of the string and run
// arbitrary JS. Cell edits now reach _pfmCellEdit only via data-day/data-col
// read by one delegated listener, so this pins that no inline handler string
// is built from the value at all.
console.log("\n7. plugin-file-manager: cell edits never go through an inline handler string");
{
const src = fs.readFileSync(path.join(ROOT, 'static/v3/js/widgets/plugin-file-manager.js'), 'utf8');
function extractFn(opener) {
const start = src.indexOf(opener);
if (start < 0) { console.error(`FAIL: cannot find ${JSON.stringify(opener)}`); process.exit(1); }
let i = src.indexOf('{', start), depth = 0;
for (let j = i; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') { depth--; if (depth === 0) return src.slice(start, j + 1); }
}
console.error(`FAIL: unbalanced braces after ${JSON.stringify(opener)}`);
process.exit(1);
}
const escHtmlFn = loadFn('static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false);
const renderEntryTableSrc = extractFn('function renderEntryTable(fieldId, container, content) {');
const calls = [];
const fakeWindow = { _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) };
class FakeContainer {
constructor() { this._html = ''; this._listeners = {}; }
set innerHTML(v) { this._html = v; }
get innerHTML() { return this._html; }
set textContent(v) { this._html = v; }
addEventListener(type, fn) { this._listeners[type] = fn; }
dispatch(type, target) { this._listeners[type]({ target }); }
}
function fakeCell(day, col, value) {
return {
closest: (sel) => (sel.includes('data-day') ? { dataset: { day: String(day), col: String(col) }, value } : null),
};
}
// eslint-disable-next-line no-eval
const renderEntryTable = eval(`(function(getState, escHtml, safeSetHTML, window){
${renderEntryTableSrc}
return renderEntryTable;
})`)(
() => ({ entriesPerPage: 20, _tablePage: 1 }),
escHtmlFn,
(target, html) => { target.innerHTML = html; },
fakeWindow
);
const maliciousCol = "x');alert(1);//";
const container = new FakeContainer();
renderEntryTable('field1', container, { '1': { [maliciousCol]: 'hello' } });
ok('no inline oninput handler is emitted for a cell', !/oninput=/.test(container.innerHTML), container.innerHTML);
ok('the malicious column name never appears unescaped in the markup',
!container.innerHTML.includes(maliciousCol), container.innerHTML);
container.dispatch('input', fakeCell('1', maliciousCol, 'typed value'));
ok('the delegated listener still reaches _pfmCellEdit with the real day/col',
calls.length === 1 && calls[0].day === '1' && calls[0].col === maliciousCol && calls[0].value === 'typed value',
calls);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+60
View File
@@ -0,0 +1,60 @@
"""Regression test: POST /fonts/upload must enforce its own stated size limit.
validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads
like it checks the upload's size, but it only ever validated the filename
(traversal characters, extension) -- max_size_mb was accepted and silently
ignored. Nothing else in the handler checked the actual upload size either,
so it saved whatever was posted to assets/fonts/<family><ext> regardless of
size, unlike the sibling .star and plugin-asset upload routes, which check
`file.tell()` against a stated limit before saving.
"""
import io
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/fonts/upload"
TEN_MB = 10 * 1024 * 1024
@pytest.fixture
def fonts_root(tmp_path):
# PROJECT_ROOT is bound by value in fonts.py, so it is patched there.
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path):
yield tmp_path
def upload(client, content, filename="myfont.ttf", family="myfont"):
data = {
"font_file": (io.BytesIO(content), filename),
"font_family": family,
}
return client.post(URL, data=data, content_type="multipart/form-data")
class TestFontUploadSizeLimit:
def test_oversized_font_is_rejected(self, api_v3_client, fonts_root):
response = upload(api_v3_client, b"x" * (TEN_MB + 1))
assert response.status_code == 400
assert "too large" in response.get_json()["message"].lower()
assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), (
"an oversized font was saved to disk before being rejected")
def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root):
response = upload(api_v3_client, b"x" * TEN_MB,
filename="atlimit.ttf", family="atlimit")
assert response.status_code == 200, response.get_json()
assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists()
def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root):
response = upload(api_v3_client, b"fake font bytes",
filename="small.ttf", family="small")
assert response.status_code == 200, response.get_json()
assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes"
+93
View File
@@ -0,0 +1,93 @@
"""Regression test: POST /display/on-demand/start restarting a running
service must not import a name that does not exist.
display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads
mutable, test-patched attributes back through it (`_pkg.time.time()`,
`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per
the package's own docstring. One spot went further and wrote a genuine
`import` *statement* against that alias --
import _pkg.time as time_module
-- but `_pkg` is a local name bound by `import ... as _pkg` in this module,
not a real top-level package, so `import _pkg.time` is not something Python
can resolve; it raises ModuleNotFoundError. That line only runs when the
display service is already running and the caller also asked to (re)start
it, so this endpoint failed on exactly the restart path -- the one where a
cache write recording the new on-demand request had already happened.
The route wraps its body in `except Exception`, so the failure reached the
caller as a handled 500 with a generic message, not an unhandled crash --
but a 500 all the same on a request that should have restarted the service
and reported success.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/display/on-demand/start"
@pytest.fixture
def restart_path(api_v3_module):
"""Force the `service_was_running and start_service` branch.
plugin_manager and config_manager are set to None so the route takes
the simplest path to that branch rather than tripping over unrelated
MagicMock plumbing; _ensure_cache_manager, _get_display_service_status,
_stop_display_service and _ensure_display_service_running are bound by
value in display.py (see its own docstring), so they are patched on
that submodule rather than on the package.
"""
api_v3_module.api_v3.plugin_manager = None
api_v3_module.api_v3.config_manager = None
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \
patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running:
ensure_cache.return_value = MagicMock()
# Active before the request: service_was_running becomes True.
get_status.return_value = {"active": True}
ensure_running.return_value = {"active": True}
yield {
"ensure_cache": ensure_cache,
"get_status": get_status,
"stop_service": stop_service,
"ensure_running": ensure_running,
}
class TestRestartingARunningService:
def test_it_does_not_500(self, api_v3_client, restart_path):
response = api_v3_client.post(
URL, json={"plugin_id": "weather", "start_service": True})
body = response.get_json()
assert response.status_code == 200, body
assert body["status"] == "success", body
def test_the_service_is_actually_stopped_and_restarted(
self, api_v3_client, restart_path):
api_v3_client.post(
URL, json={"plugin_id": "weather", "start_service": True})
restart_path["stop_service"].assert_called_once()
restart_path["ensure_running"].assert_called_once()
def test_a_service_that_was_not_running_is_not_stopped_first(
self, api_v3_client, restart_path):
# The buggy import sits inside `if service_was_running and
# start_service`, so it only ever fired on the restart path --
# this is the other side of that branch, unaffected either way,
# kept here so the branch condition itself stays covered.
restart_path["get_status"].return_value = {"active": False}
response = api_v3_client.post(
URL, json={"plugin_id": "weather", "start_service": True})
assert response.status_code == 200, response.get_json()
restart_path["stop_service"].assert_not_called()
+8 -3
View File
@@ -106,7 +106,12 @@ class TestMissingBodyGivesTheDeclaredError:
class TestNoBodyReadContradictsItsOwnGuard:
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3"
@property
def _source(self) -> str:
"""api_v3 is a package; read every module of it."""
return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py")))
def test_no_or_default_read_is_unguarded(self):
"""`get_json() or <default>` is a contradiction without silent=True.
@@ -115,7 +120,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
means the call raises before the default can apply.
"""
offenders = [
line.strip() for line in self.SOURCE.read_text().splitlines()
line.strip() for line in self._source.splitlines()
if "request.get_json()" in line and " or " in line
]
assert offenders == [], (
@@ -124,7 +129,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
def test_no_not_data_guard_is_unreachable(self):
"""A `if not data:` guard needs a read that can actually return None."""
lines = self.SOURCE.read_text().splitlines()
lines = self._source.splitlines()
offenders = []
for i, line in enumerate(lines):
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
@@ -0,0 +1,54 @@
"""Regression test: per-day schedule validation errors must say "time", not
"_pkg.time".
The split into a package rewrote every bare `time` reference that needed to
read through the shared module as `_pkg.time` (see the package's own
docstring on why -- tests patch it, so it has to be read back live rather
than bound by value). That rewrite was mechanical and matched the substring
"time" inside string literals and comments too, so the user-facing message
"Invalid start time for {day}: ..."
came out as
"Invalid start _pkg.time for {day}: ..."
in both POST /config/schedule and POST /config/dim-schedule, for both the
start and end time of a per-day entry.
"""
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
@pytest.mark.parametrize("url", [
"/api/v3/config/schedule",
"/api/v3/config/dim-schedule",
])
class TestPerDayTimeErrorsAreNotCorrupted:
def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url):
response = api_v3_client.post(url, json={
"mode": "per_day",
"monday_start": "not-a-time",
})
assert response.status_code == 400
message = response.get_json()["message"]
assert "_pkg" not in message, message
assert message.startswith("Invalid start time for monday:"), message
def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url):
response = api_v3_client.post(url, json={
"mode": "per_day",
"monday_start": "07:00",
"monday_end": "not-a-time",
})
assert response.status_code == 400
message = response.get_json()["message"]
assert "_pkg" not in message, message
assert message.startswith("Invalid end time for monday:"), message
+136
View File
@@ -0,0 +1,136 @@
"""The /api/v3 URL map is a contract, and the split must not have moved it.
api_v3.py was one 10,469-line module and is now a package. Every route module
in it decorates the *same* Blueprint object, so this refactor was supposed to
be invisible from outside: same URLs, same endpoint names, same methods.
"Supposed to be" is the problem. A route function silently dropped during a
move -- a module that never gets imported, a decorator left behind -- costs
nothing at import time and fails only when someone hits the URL. So the map is
pinned here.
The snapshot is intentionally the *whole* map rather than a count. A count
passes when one route is deleted and another added, which is exactly the shape
a careless move produces.
If you are adding a route, this test is meant to fail: add the entry to
EXPECTED. If you are moving one between modules, it is meant to pass unchanged
-- endpoint names are `api_v3.<function>` regardless of which module the
function lives in, and that is the property that makes the package safe.
"""
import json
import os
import pytest
from flask import Flask
from web_interface.blueprints.api_v3 import api_v3
SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"fixtures", "api_v3_url_map.json")
def _current_map():
app = Flask(__name__)
app.register_blueprint(api_v3, url_prefix="/api/v3")
return sorted(
[r.rule, r.endpoint, sorted(r.methods)]
for r in app.url_map.iter_rules()
if r.endpoint != "static"
)
def test_the_url_map_matches_the_snapshot():
current = _current_map()
with open(SNAPSHOT, encoding="utf-8") as fh:
expected = json.load(fh)
cur = {(r, e) for r, e, _ in current}
exp = {(r, e) for r, e, _ in expected}
lost = sorted(exp - cur)
added = sorted(cur - exp)
assert not lost, (
f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. "
"A route lost in a module move costs nothing at import time and fails "
"only when someone hits the URL.")
assert not added, (
f"{len(added)} new route(s): {added[:5]}. If that is intended, "
f"regenerate {os.path.relpath(SNAPSHOT)}.")
# Methods too: a route that quietly loses POST is still a broken route.
cur_methods = {(r, e): m for r, e, m in current}
for rule, endpoint, methods in expected:
assert cur_methods[(rule, endpoint)] == methods, (
f"{rule} ({endpoint}) methods changed: "
f"{methods} -> {cur_methods[(rule, endpoint)]}")
def test_every_endpoint_is_on_the_one_blueprint():
"""The package must not fragment into several blueprints.
Splitting into per-domain *blueprints* would rename every endpoint from
`api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names
one. Keeping a single Blueprint object across the modules is what makes
the split a pure code move, so assert it rather than trusting it.
"""
for rule, endpoint, _ in _current_map():
assert endpoint.startswith("api_v3."), (
f"{rule} is registered as {endpoint}, not on the api_v3 blueprint")
def test_the_snapshot_is_not_empty():
"""Guards the failure mode this file exists to prevent.
An empty or truncated snapshot would make every assertion above pass
vacuously -- the same trap as a route map that imports no modules.
"""
with open(SNAPSHOT, encoding="utf-8") as fh:
expected = json.load(fh)
assert len(expected) > 100, (
f"snapshot has only {len(expected)} routes; it should have the whole "
"/api/v3 surface")
@pytest.mark.parametrize("module", [
"backup", "config", "display", "fonts", "misc",
"plugins", "starlark", "system", "wifi",
])
def test_every_route_module_contributes(module):
"""Each module must actually register something.
A module that fails to import, or that is left out of __init__, takes its
routes with it silently -- the package still imports and the app still
starts.
"""
import importlib
mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}")
routes = [n for n in dir(mod)
if callable(getattr(mod, n, None))
and getattr(getattr(mod, n), "__module__", "") == mod.__name__]
assert routes, f"{module}.py defines no view functions"
def test_project_root_points_at_the_project():
"""PROJECT_ROOT is derived from __file__, so moving the file breaks it.
The split moved this code from web_interface/blueprints/api_v3.py to
web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and
`Path(__file__).parent.parent.parent` quietly began resolving to
web_interface/ instead of the project root. Nothing failed at import. It
surfaced as routes returning 404 and "installation script not found",
because every path built from it pointed one level too shallow.
A URL-map check cannot catch that: the routes were all registered, they
just could not find anything.
"""
from pathlib import Path
from web_interface.blueprints.api_v3 import PROJECT_ROOT
# The project root is the directory holding run.py and web_interface/.
assert (PROJECT_ROOT / "run.py").is_file(), (
f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the "
"project root")
assert (PROJECT_ROOT / "web_interface").is_dir()
assert PROJECT_ROOT == Path(__file__).resolve().parents[1]
+79 -1
View File
@@ -195,7 +195,7 @@ class TestRadio:
@pytest.mark.parametrize("raw,expected", [
(True, True), ("true", True), ("1", True), ("yes", True),
(False, False), ("false", False), ("off", False), (0, False),
(False, False), ("false", False), ("no", False), (0, False),
])
def test_enabled_coercion_is_string_aware(
self, api_v3_client, wifi_manager, raw, expected):
@@ -228,6 +228,84 @@ class TestRadio:
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
def test_unrecognized_enabled_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
# An invalid `enabled` used to silently fall back to False, which
# can disconnect Wi-Fi (or, with force=true, drop the caller's own
# connection to this interface) even though nothing asked for that.
response = api_v3_client.post(self.URL, json={"enabled": "typo"})
assert response.status_code == 400
wifi_manager.set_wifi_radio.assert_not_called()
def test_unrecognized_force_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
response = api_v3_client.post(
self.URL, json={"enabled": False, "force": "typo"})
assert response.status_code == 400
wifi_manager.set_wifi_radio.assert_not_called()
class TestAutoEnableApMode:
URL = "/api/v3/wifi/ap/auto-enable"
def test_requires_the_field(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
@pytest.mark.parametrize("raw,expected", [
(True, True), (False, False),
("true", True), ("True", True), ("1", True), ("yes", True),
("false", False), ("False", False), ("0", False), ("no", False),
(1, True), (0, False),
])
def test_value_is_coerced_not_just_truthy(
self, api_v3_client, wifi_manager, raw, expected):
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
# sent the JSON string "false" got it stored as True — bool("false")
# is True, since any non-empty string is truthy.
wifi_manager.config = {}
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
assert response.status_code == 200, response.get_json()
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
assert wifi_manager.config["auto_enable_ap_mode"] is expected
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
# The exact shape of the bug.
wifi_manager.config = {}
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
assert wifi_manager.config["auto_enable_ap_mode"] is False
def test_unrecognized_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
wifi_manager.config = {}
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "typo"})
assert response.status_code == 400
assert "auto_enable_ap_mode" not in wifi_manager.config
class TestRadioEnabledAndForceAcceptIntegers:
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
`1 is True` is False in Python -- an int is never the `True` singleton
even though it equals it -- so a plain integer fell through to False
regardless of its value.
"""
URL = "/api/v3/wifi/radio"
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
class TestNoRealNetworking:
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
+50 -1
View File
@@ -103,6 +103,54 @@ def test_non_dict_input_passes_through():
assert _redact_credentials(None) is None
def test_a_list_of_bare_credentials_is_redacted():
"""A credential-named key whose value is a list of scalars, not a list
of named-field dicts, used to pass through untouched: the dict branch
recursed into `v` on its own value only, so a bare string list item had
no field name to test and fell through the base case unredacted.
"""
config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"}
out = _redact_credentials(config)
assert out["tokens"] == ["", ""]
assert out["timezone"] == "America/New_York"
def test_a_list_nested_inside_a_credential_named_container_is_also_blanked():
"""The list fix applies at any depth under a credential-named key, not
only when the list is the key's direct value.
"""
config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}}
out = _redact_credentials(config)
assert out["auth"]["backup_tokens"] == ["", ""]
assert out["auth"]["note"] == "keep"
def test_objects_inside_a_credential_named_list_are_blanked_regardless_of_field_names():
"""A list item has no field name of its own to test against
`_CREDENTIAL_NAME_PARTS`, so an object reached through a credential-owned
list must be blanked outright rather than walked by field name -- unlike
the dict-directly-under-a-credential-key case in the test above.
"""
config = {"tokens": [{"value": "secret", "kind": "bearer"}]}
out = _redact_credentials(config)
assert out["tokens"] == [{"value": "", "kind": ""}]
def test_credential_shaped_container_with_named_fields_still_only_blanks_those():
"""Unchanged behaviour for the case the container test above already
covers: a dict whose sub-keys are semantically named fields is walked
normally, not blanket-blanked, so an ordinary field next to a
credential-named one survives.
"""
config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}}
out = _redact_credentials(config)
assert out["secrets"]["api_key"] == ""
assert out["secrets"]["note"] == "keep"
# "list" isn't itself credential-named, so it is walked, not blanked --
# but it holds no credential-named field either, so it survives whole.
assert out["secrets"]["list"] == ["a", "b"]
def test_the_endpoint_itself_redacts():
"""Through the view function, not the helper.
@@ -130,7 +178,8 @@ def test_the_endpoint_itself_redacts():
app = flask.Flask(__name__)
try:
with app.test_request_context("/config/main"):
response = mod.get_main_config()
# get_main_config is a route; it lives in the config module now.
response = mod.config.get_main_config()
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
finally:
mod.api_v3.config_manager = previous
+9 -1
View File
@@ -24,7 +24,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture(scope="module")
def dm():
def dm(tmp_path_factory):
"""One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager
DisplayManager._instance = None
@@ -36,6 +36,14 @@ def dm():
"runtime": {"gpio_slowdown": 0},
},
}, suppress_test_pattern=True)
# DisplayManager defaults _snapshot_path to the fixed /tmp/led_matrix_preview.png
# that the web UI reads. That path is shared by every pytest process on the
# machine, so two concurrent runs -- CI shards, a second worktree, a agent
# running the suite alongside -- write over each other's snapshot and the
# mtime assertions below stop meaning anything. Point it somewhere unique to
# this session; the individual tests that care still override it further.
manager._snapshot_path = str(
tmp_path_factory.mktemp("dirty_tracking") / "led_matrix_preview.png")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the metrics the live status stream reports.
Two gaps made a struggling 1GB Pi look healthy on the dashboard:
* ``disk_used_percent`` was the literal ``0`` in the SSE generator, so every
consumer of the live stream displayed 0% disk no matter how full the card was.
/api/v3/system/status computed it properly, but the live view -- the one people
actually watch -- did not.
* ``memory_available_mb`` was absent. /api/v3/system/status carries it with a
comment explaining that MemAvailable, not used%, is what separates a board
that is fine from one about to fail fork(). The live stream omitted the one
number that predicts the failure.
A wrong number is worse than a missing one, so an unreadable disk now reports
None (the UI renders '--') rather than a confident 0.
These import web_interface.system_metrics rather than web_interface.app on
purpose: importing the app constructs a Flask application and a CacheManager,
and the latter claims the cache directory with a cleanup thread -- which broke
test_cache_cleanup_thread_ownership and the starlark route tests when this file
first reached for the generator directly.
"""
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from web_interface.system_metrics import collect_system_metrics # noqa: E402
@pytest.fixture
def metrics(monkeypatch):
"""collect_system_metrics() with psutil's readings under our control."""
def _collect(disk_percent=12.0, available_bytes=512 * 1024 * 1024,
used_percent=67.7, raise_disk=False):
import psutil
if raise_disk:
monkeypatch.setattr(psutil, "disk_usage",
lambda _p: (_ for _ in ()).throw(OSError("no such mount")))
else:
monkeypatch.setattr(psutil, "disk_usage",
lambda _p: SimpleNamespace(percent=disk_percent))
monkeypatch.setattr(psutil, "virtual_memory",
lambda: SimpleNamespace(percent=used_percent,
available=available_bytes))
return collect_system_metrics()
return _collect
class TestDiskUsageIsReal:
def test_reported_value_comes_from_the_filesystem(self, metrics):
# The regression: this field was the literal 0.
assert metrics(disk_percent=91.4)["disk_used_percent"] == 91.4
def test_a_full_disk_is_not_reported_as_zero(self, metrics):
assert metrics(disk_percent=99.9)["disk_used_percent"] != 0
def test_unreadable_disk_is_null_not_a_confident_zero(self, metrics):
# null renders as '--'; 0 would read as "plenty of room".
assert metrics(raise_disk=True)["disk_used_percent"] is None
class TestMemAvailableIsReported:
def test_mem_available_is_present_and_in_mb(self, metrics):
got = metrics(available_bytes=292 * 1024 * 1024)["memory_available_mb"]
assert got == pytest.approx(292.0, abs=0.1)
def test_the_1gb_danger_case_is_visible(self, metrics):
"""67% used can be fine or nearly out of memory; only MemAvailable says."""
m = metrics(used_percent=67.7, available_bytes=40 * 1024 * 1024)
assert m["memory_used_percent"] == 67.7
assert m["memory_available_mb"] == pytest.approx(40.0, abs=0.1)
class TestEveryPredictiveFieldIsPresent:
def test_nothing_the_dashboard_needs_is_missing(self, metrics):
m = metrics()
for field in ("cpu_percent", "cpu_temp", "memory_used_percent",
"memory_available_mb", "disk_used_percent"):
assert field in m, f"{field} missing from collected metrics"
def test_absent_psutil_still_yields_every_key(self, monkeypatch):
"""The no-psutil fallback must not drop fields the UI reads."""
import builtins
real_import = builtins.__import__
def _no_psutil(name, *args, **kwargs):
if name == "psutil":
raise ImportError("psutil missing")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _no_psutil)
m = collect_system_metrics()
for field in ("cpu_percent", "cpu_temp", "memory_used_percent",
"memory_available_mb", "disk_used_percent"):
assert field in m
assert m["disk_used_percent"] is None
@@ -0,0 +1,159 @@
"""The scheduler actually polls faster while a plugin reports live content.
test_plugin_dynamic_update_interval.py proves _get_plugin_update_interval()
returns the plugin's requested number. That is not the same claim as "the
plugin gets updated more often", and the gap between those two is exactly where
the original bug lived: the plugin knew it wanted 15s, said so in
live_update_interval, and nothing downstream acted on it.
So this drives the real run_scheduled_updates() over a simulated hour of ticks
and counts dispatches. It is the test that would have caught the reported bug:
"the football plugin with live games only updates the live game in
progress if I restart the display"
Measured on a rig before the fix, during an NFL fourth quarter, ESPN was polled
at 23:21:49 / 23:22:50 / 23:23:50 / 23:24:50 -- exactly the manifest's 60s,
never the configured 15.
"""
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.plugin_state import PluginState
class _StateManager:
"""Enough of the real state machine for reservation to behave."""
def __init__(self):
self.states = {}
def can_execute(self, plugin_id):
return self.states.get(plugin_id, PluginState.ENABLED) != PluginState.RUNNING
def set_state(self, plugin_id, state):
self.states[plugin_id] = state
class _Plugin:
"""A sports plugin: fast while something is live, quiet otherwise."""
enabled = True
def __init__(self, live_interval=15):
self.live = False
self._live_interval = live_interval
def get_update_interval(self):
return self._live_interval if self.live else None
def update(self):
pass
@pytest.fixture
def scheduler():
pm = PluginManager.__new__(PluginManager)
import threading
pm._update_interval_cache = {}
pm.plugin_manifests = {"sports": {"update_interval": 60}}
pm.config_manager = None
pm.logger = MagicMock()
pm.state_manager = _StateManager()
pm.health_tracker = None
pm._synchronous_updates = False
pm._reservation_lock = threading.Lock()
pm._plugin_last_update_lock = threading.Lock()
pm.plugin_last_update = {}
pm.dispatched = []
# Stand in for the background worker: record the dispatch and complete it,
# so the next tick can reserve the plugin again.
def _enqueue(plugin_id, scheduled_time):
pm.dispatched.append(scheduled_time)
pm.plugin_last_update[plugin_id] = scheduled_time
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
pm._enqueue_update = _enqueue
return pm
def _run_for(pm, plugin, seconds, start=1_000_000.0, step=1.0):
"""Tick the real scheduler once a second for `seconds`."""
pm.plugins = {"sports": plugin}
t = start
end = start + seconds
while t < end:
pm.run_scheduled_updates(current_time=t)
t += step
return len(pm.dispatched)
class TestTheReportedBug:
def test_a_live_game_is_polled_at_the_live_interval(self, scheduler):
"""15s while live, not the manifest's 60s. This is the fix."""
plugin = _Plugin(live_interval=15)
plugin.live = True
count = _run_for(scheduler, plugin, seconds=600) # ten minutes
# 600s / 15s = 40, allowing one for the first tick's free run.
assert 38 <= count <= 41, (
f"{count} updates in 10 minutes of a live game; expected ~40 at 15s. "
f"At the manifest's 60s it would be ~10 -- the reported bug.")
def test_the_same_window_without_the_hook_is_the_old_behaviour(self, scheduler):
"""Pin what the bug actually looked like, so the contrast is asserted."""
plugin = _Plugin()
plugin.live = True
plugin.get_update_interval = lambda: None # pre-fix: no opinion
count = _run_for(scheduler, plugin, seconds=600)
assert 9 <= count <= 11, (
f"{count} updates in 10 minutes; expected ~10 at the manifest's 60s")
def test_idle_polling_is_unchanged(self, scheduler):
"""The regression that would be worse than the bug.
Asking for 15s year-round would poll ESPN four times a minute all
summer. Idle must stay exactly on the manifest.
"""
plugin = _Plugin()
plugin.live = False
count = _run_for(scheduler, plugin, seconds=3600) # a full hour idle
assert 59 <= count <= 61, (
f"{count} updates in an idle hour; expected ~60 at the manifest's 60s")
class TestTheCadenceTracksTheGame:
def test_it_speeds_up_when_a_game_starts_and_slows_when_it_ends(self, scheduler):
"""No reload, no restart -- the whole point of a per-tick hook."""
plugin = _Plugin(live_interval=15)
pm = scheduler
_run_for(pm, plugin, seconds=300, start=1_000_000.0)
idle_before = len(pm.dispatched)
plugin.live = True
_run_for(pm, plugin, seconds=300, start=1_000_300.0)
during = len(pm.dispatched) - idle_before
plugin.live = False
_run_for(pm, plugin, seconds=300, start=1_000_600.0)
idle_after = len(pm.dispatched) - idle_before - during
assert during > idle_before * 3, (
f"live window got {during} updates vs {idle_before} idle; "
"the hook did not speed anything up")
assert idle_after <= idle_before + 1, (
f"{idle_after} updates after the game ended vs {idle_before} before; "
"the fast cadence leaked past the live window")
def test_a_broken_hook_does_not_stop_the_plugin_updating(scheduler):
"""A scheduler that propagates one plugin's bug stops every other plugin."""
plugin = _Plugin()
plugin.get_update_interval = MagicMock(side_effect=RuntimeError("boom"))
count = _run_for(scheduler, plugin, seconds=600)
assert 9 <= count <= 11, (
f"{count} updates; a raising hook should fall back to the manifest's 60s")
+69 -1
View File
@@ -24,7 +24,8 @@ import pytest
import requests
from PIL import Image, UnidentifiedImageError
from src.common.logo_helper import MAX_LOGO_BYTES, LogoHelper
from src.common.logo_helper import (MAX_LOGO_BYTES, MISSING_LOGO_RECHECK_SECONDS,
LogoHelper)
@pytest.fixture(autouse=True)
@@ -494,3 +495,70 @@ class TestStalePlaceholderHandling:
helper.load_logo_with_download("COLL", path, "http://x/c.png")
assert should_attempt_download(path) is False
class TestMissingLogosAreRememberedNotRewarned:
"""A file that is not there does not become there by being asked again.
load_logo() stat'd the path and logged a WARNING on every call, so a
permanently absent logo produced one warning per rotation for as long as
the process ran -- measured at 114 lines in 24 hours for a single missing
ticker icon. The positive cache never covered this because a miss returns
None and caches nothing.
"""
def test_a_missing_logo_warns_once(self, helper, tmp_path, caplog):
absent = tmp_path / "VOO.png"
with caplog.at_level(logging.WARNING):
for _ in range(50):
assert helper.load_logo("VOO", absent) is None
assert caplog.text.count("Logo not found for VOO") == 1, (
f"warned {caplog.text.count('Logo not found for VOO')} times in 50 calls")
def test_the_miss_is_not_remembered_forever(self, helper, tmp_path, monkeypatch):
"""A logo written later -- logo_downloader does this at runtime -- must
still be picked up without a restart."""
path = tmp_path / "LATE.png"
assert helper.load_logo("LATE", path) is None
write_logo(path)
# Still inside the recheck window: the remembered miss stands.
assert helper.load_logo("LATE", path) is None
# Once it ages out, the next call stats the disk again and finds it.
for key in helper._missing_logos:
helper._missing_logos[key] -= MISSING_LOGO_RECHECK_SECONDS + 1
assert helper.load_logo("LATE", path) is not None
def test_a_download_clears_the_miss_immediately(self, helper, tmp_path):
"""load_logo_with_download() must not be defeated by its own miss record.
load_logo() consults _missing_logos before it stats the disk, so a
logo we just downloaded would stay invisible for the whole recheck
window without the explicit invalidation.
"""
path = tmp_path / "NEW.png"
assert helper.load_logo("NEW", path) is None
assert any(k.startswith("NEW_") for k in helper._missing_logos)
def _fake_download(url, file_path):
write_logo(Path(file_path))
with patch.object(helper, "_download_logo", side_effect=_fake_download):
got = helper.load_logo_with_download("NEW", path, logo_url="http://x/NEW.png")
assert got is not None, "a freshly downloaded logo was hidden by the miss record"
assert not any(k.startswith("NEW_") for k in helper._missing_logos)
def test_clear_cache_forgets_misses_too(self, helper, tmp_path):
helper.load_logo("GONE", tmp_path / "GONE.png")
assert helper._missing_logos
helper.clear_cache()
assert helper._missing_logos == {}
def test_a_present_logo_is_unaffected(self, helper, tmp_path, caplog):
path = write_logo(tmp_path / "OK.png")
with caplog.at_level(logging.WARNING):
for _ in range(10):
assert helper.load_logo("OK", path) is not None
assert "Logo not found" not in caplog.text
assert helper._missing_logos == {}
+138
View File
@@ -0,0 +1,138 @@
"""The MQTT bridge settings endpoint must reject bodies it cannot apply.
Two defects CodeRabbit raised on #554, both of which reported success while
doing something other than what the caller asked:
* `request.get_json(silent=True) or {}` turned a missing or unparseable body --
and the JSON literals `null`, `[]` and `false` -- into an empty dict, which
then satisfied the `isinstance(data, dict)` guard directly below it. Malformed
JSON therefore returned 200 having applied nothing.
* `if data.get('clear_password'):` accepted any truthy value. The string
"false" is truthy in Python, so a client echoing the field back as a string
wiped a stored password it meant to keep.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
import web_interface.blueprints.api_v3 as pkg # noqa: E402
import web_interface.blueprints.api_v3.misc as misc # noqa: E402
URL = "/api/v3/integrations/mqtt-bridge/config"
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "bridge_config.json"
# Both modules: misc.py writes through its own imported copies of these
# names, while _read_mqtt_bridge_config() lives in the package __init__ and
# reads the one bound there. Patching only one leaves the read and the
# write pointing at different files.
for mod in (misc, pkg):
monkeypatch.setattr(mod, "_MQTT_BRIDGE_CONFIG", cfg, raising=False)
monkeypatch.setattr(mod, "_MQTT_BRIDGE_DIR", tmp_path, raising=False)
monkeypatch.setattr(api_v3, "config_manager", MagicMock(), raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
app.cfg_path = cfg
return app.test_client(), cfg
class TestABodyItCannotApplyIsRejected:
@pytest.mark.parametrize("raw", ["{ not json", "null", "[]", "false", '"a string"'])
def test_unusable_bodies_are_rejected(self, client, raw):
c, _ = client
r = c.put(URL, data=raw, content_type="application/json")
# The regression: every one of these returned 200 having applied nothing.
assert r.status_code == 400, f"{raw!r} was accepted"
assert "JSON object" in r.get_json()["message"]
def test_a_missing_body_is_rejected(self, client):
c, _ = client
assert c.put(URL).status_code == 400
def test_a_real_object_is_still_accepted(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_host": "192.168.1.20"})
assert r.status_code == 200, r.get_json()
class TestClearPasswordNeedsARealBoolean:
def _seed(self, cfg, password="hunter2"):
# TLS on so these cases exercise clear_password alone: a stored password
# with TLS off is refused by the CWE-319 guard, which is a separate test.
cfg.write_text(json.dumps({"mqtt_password": password, "mqtt_tls": True}),
encoding="utf-8")
def _stored(self, cfg):
return json.loads(cfg.read_text(encoding="utf-8")).get("mqtt_password")
def test_the_string_false_does_not_clear_it(self, client):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": "false"}).status_code == 200
# The regression: "false" is truthy, so this wiped the password.
assert self._stored(cfg) == "hunter2"
@pytest.mark.parametrize("falsy", [False, "no", "0", "", None])
def test_other_falsy_spellings_do_not_clear_it(self, client, falsy):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": falsy}).status_code == 200
assert self._stored(cfg) == "hunter2"
@pytest.mark.parametrize("truthy", [True, "true", "1", "yes", "on"])
def test_real_truthy_values_still_clear_it(self, client, truthy):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": truthy}).status_code == 200
assert self._stored(cfg) is None
class TestCleartextCredentialsNeedAnExplicitOptIn:
"""CWE-319. A password with TLS off crosses the network in the clear.
Refused rather than forbidden: unencrypted MQTT on a trusted LAN is a normal
deliberate setup, so allow_insecure_mqtt is the explicit acknowledgement.
"""
def test_a_password_without_tls_is_refused(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False})
assert r.status_code == 400
assert "allow_insecure_mqtt" in r.get_json()["message"]
def test_the_opt_in_allows_it(self, client):
c, cfg = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": True})
assert r.status_code == 200, r.get_json()
assert json.loads(cfg.read_text(encoding="utf-8"))["mqtt_password"] == "hunter2"
def test_tls_on_needs_no_opt_in(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": True})
assert r.status_code == 200, r.get_json()
def test_no_password_is_unaffected(self, client):
c, _ = client
assert c.put(URL, json={"mqtt_tls": False}).status_code == 200
def test_the_opt_in_is_a_real_boolean(self, client):
""""false" must not switch the guard off, same as clear_password."""
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": "false"})
assert r.status_code == 400
+123
View File
@@ -0,0 +1,123 @@
"""pages_v3 must refuse an id that is not a plain name, not truncate it.
Both partial loaders used to run the id through ``os.path.basename`` and carry
on with what came out, so "../weather" rendered the config form for "weather".
Nothing escaped the plugins directory -- the containment 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 in this branch had.
These tests are about that: a rejected id gets a 400, and a real one still
renders.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
BACKSLASH = chr(92)
@pytest.fixture
def pages(tmp_path):
"""pages_v3 with a plugins directory holding one real plugin."""
from web_interface.blueprints import pages_v3 as module
plugins_dir = tmp_path / "plugin-repos"
(plugins_dir / "weather" / "web_ui").mkdir(parents=True)
(plugins_dir / "weather" / "config_schema.json").write_text(
'{"type": "object", "properties": {"enabled": {"type": "boolean"}}}',
encoding="utf-8",
)
(plugins_dir / "weather" / "manifest.json").write_text(
'{"name": "Weather", "version": "1.0.0"}', encoding="utf-8"
)
(plugins_dir / "weather" / "web_ui" / "panel.html").write_text(
"<p>panel</p>", encoding="utf-8"
)
original_pm = getattr(module.pages_v3, "plugin_manager", None)
original_cm = getattr(module.pages_v3, "config_manager", None)
plugin_manager = MagicMock()
plugin_manager.plugins_dir = plugins_dir
plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}
plugin_manager.get_plugin.return_value = None
module.pages_v3.plugin_manager = plugin_manager
module.pages_v3.config_manager = MagicMock(load_config=lambda: {})
yield module, plugins_dir
module.pages_v3.plugin_manager = original_pm
module.pages_v3.config_manager = original_cm
@pytest.mark.parametrize("plugin_id", [
"../weather", "..", ".", "", None, "a/b", "x" + BACKSLASH + "y",
])
def test_a_plugin_id_that_is_not_a_plain_name_is_a_400(pages, plugin_id):
module, _ = pages
body, status = module._load_plugin_config_partial(plugin_id)
assert status == 400
assert "Invalid plugin ID" in body
def test_a_traversing_id_no_longer_renders_the_truncated_one(pages):
""""../weather" used to render "weather"'s form. It must not."""
module, _ = pages
body, status = module._load_plugin_config_partial("../weather")
assert status == 400
assert "Weather" not in body
@pytest.mark.parametrize("app_id", ["../demo", "..", "a/b", "", None])
def test_a_starlark_app_id_that_is_not_a_plain_name_is_a_400(pages, app_id):
module, _ = pages
body, status = module._load_starlark_config_partial(app_id)
assert status == 400
assert "Invalid app ID" in body
class TestServePluginWebUi:
"""GET /plugin-ui/<plugin_id>/web-ui/<path:filename>"""
@pytest.fixture
def client(self, pages):
from flask import Flask
module, _ = pages
base = Path(module.__file__).resolve().parent.parent
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True
app.register_blueprint(module.pages_v3, url_prefix="")
return app.test_client()
def test_a_real_fragment_is_still_wrapped_and_served(self, client):
response = client.get("/plugin-ui/weather/web-ui/panel.html")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "<p>panel</p>" in body
assert 'window.PLUGIN_ID = "weather"' in body
@pytest.mark.parametrize("url", [
"/plugin-ui/../web-ui/panel.html",
"/plugin-ui/%2e%2e/web-ui/panel.html",
"/plugin-ui/weather/web-ui/../../manifest.json",
"/plugin-ui/weather/web-ui/..%2f..%2fmanifest.json",
])
def test_traversal_attempts_are_refused(self, client, url):
response = client.get(url)
assert response.status_code in (400, 403, 404)
assert "version" not in response.get_data(as_text=True)
def test_a_non_html_filename_is_still_refused(self, client):
# The allowlist predates this change and must survive it.
response = client.get("/plugin-ui/weather/web-ui/manifest.json")
assert response.status_code == 400
+317
View File
@@ -0,0 +1,317 @@
"""Request-supplied names must not be able to name a file outside their base.
Three of these were live. Each test that stands in for one says so, and
asserts on the *filesystem* -- that the file outside the base is still there,
or was never read -- rather than only on the status code, because a handler
that returns 403 and deletes the file anyway would pass the weaker check.
The rest pin the shared helper in src/common/path_safety.py that the handlers
now go through, so a future handler gets the same behaviour by using it.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.common.path_safety import ( # noqa: E402
resolve_under,
safe_path_component,
safe_relative_parts,
)
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
BACKSLASH = chr(92)
class TestSafePathComponent:
"""One segment, or nothing."""
@pytest.mark.parametrize("value", [
"plugin", "ledmatrix-of-the-day", "weather_data", "manifest.json",
"a.b.c", "UPPER", "with-dash", "with_underscore", "9lives",
])
def test_ordinary_names_pass_through_unchanged(self, value):
assert safe_path_component(value) == value
@pytest.mark.parametrize("value", [
"..", ".", "", "../etc", "a/b", "/abs", "etc/passwd",
"a" + BACKSLASH + "b", "C:" + BACKSLASH + "x", "C:",
"a\x00b", None, 123, b"bytes", ["list"],
])
def test_anything_that_could_name_another_directory_is_refused(self, value):
assert safe_path_component(value) is None
def test_it_returns_the_value_rather_than_a_verdict(self):
# A boolean lets a caller validate one string and then open a
# different one. Returning the checked value is what stops that.
assert safe_path_component("ok") == "ok"
assert safe_path_component("../ok") is None
class TestSafeRelativeParts:
def test_a_multi_segment_path_splits(self):
assert safe_relative_parts("web_ui/index.html") == ["web_ui", "index.html"]
def test_empty_segments_are_dropped_not_rejected(self):
assert safe_relative_parts("a//b") == ["a", "b"]
assert safe_relative_parts("x/") == ["x"]
@pytest.mark.parametrize("value", [
"../x", "a/../../etc", "/etc/passwd", BACKSLASH + "etc",
"a" + BACKSLASH + ".." + BACKSLASH + "b", "", None,
])
def test_traversal_and_absolute_paths_are_refused(self, value):
assert safe_relative_parts(value) is None
class TestResolveUnder:
def test_a_path_inside_the_base_resolves(self, tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "f.txt").write_text("x", encoding="utf-8")
resolved = resolve_under(tmp_path, "sub", "f.txt")
assert resolved == (tmp_path / "sub" / "f.txt").resolve()
def test_a_path_that_would_escape_is_refused(self, tmp_path):
assert resolve_under(tmp_path, "..") is None
assert resolve_under(tmp_path, "..", "etc") is None
assert resolve_under(tmp_path, "/etc/passwd") is None
def test_a_sibling_sharing_a_prefix_is_refused(self, tmp_path):
# The bug the old `str(x).startswith(str(base))` checks had:
# "<root>/foo-evil" starts with "<root>/foo" as a string, but is not
# inside it as a directory.
(tmp_path / "foo").mkdir()
(tmp_path / "foo-evil").mkdir()
(tmp_path / "foo-evil" / "secret").write_text("s", encoding="utf-8")
assert resolve_under(tmp_path / "foo", "..", "foo-evil", "secret") is None
# And the string check it replaces would have said yes:
naive = (tmp_path / "foo" / ".." / "foo-evil" / "secret").resolve()
assert str(naive).startswith(str((tmp_path / "foo").resolve()))
def test_it_returns_none_rather_than_raising_on_junk(self, tmp_path):
assert resolve_under(tmp_path, None) is None
assert resolve_under(tmp_path, 42) is None
@pytest.fixture
def plugins_tree(tmp_path):
"""A plugins directory with one plugin, plus a secret outside it."""
plugins = tmp_path / "plugin-repos"
(plugins / "demo" / "web_ui").mkdir(parents=True)
(plugins / "demo" / "web_ui" / "index.html").write_text("<p>hi</p>", encoding="utf-8")
(plugins / "demo-evil").mkdir()
(plugins / "demo-evil" / "stolen.json").write_text('{"a":1}', encoding="utf-8")
secrets = tmp_path / "config"
secrets.mkdir()
(secrets / "config_secrets.json").write_text('{"api_key":"hunter2"}', encoding="utf-8")
return tmp_path
class TestServePluginStatic:
"""GET /api/v3/plugins/<plugin_id>/static/<path:file_path>
This handler read any file whose resolved path *string-prefixed* the
plugin directory. Two ways through:
* plugin_id of "..", because Flask's default converter forbids a slash
but not dots, and get_plugin_directory returned the parent of the
plugins directory since it exists. Every file under the project then
prefixed that directory, config/config_secrets.json included.
* a sibling directory sharing a prefix, per the helper test above.
"""
def _wire(self, api_v3_module, plugins_tree):
plugins = plugins_tree / "plugin-repos"
def get_plugin_directory(plugin_id):
candidate = plugins / plugin_id
return str(candidate) if candidate.exists() else None
api_v3_module.api_v3.plugin_manager = MagicMock()
api_v3_module.api_v3.plugin_manager.get_plugin_directory = get_plugin_directory
return plugins
def test_a_real_plugin_file_is_still_served(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get("/api/v3/plugins/demo/static/web_ui/index.html")
assert response.status_code == 200
assert b"<p>hi</p>" in response.data
def test_a_dotdot_plugin_id_cannot_reach_the_secrets_file(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get(
"/api/v3/plugins/../static/config/config_secrets.json"
)
assert response.status_code in (400, 403, 404)
assert b"hunter2" not in response.data
def test_a_percent_encoded_dotdot_plugin_id_is_refused_too(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get(
"/api/v3/plugins/%2e%2e/static/config/config_secrets.json"
)
assert response.status_code in (400, 403, 404)
assert b"hunter2" not in response.data
def test_a_sibling_directory_sharing_a_prefix_is_refused(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get(
"/api/v3/plugins/demo/static/../demo-evil/stolen.json"
)
assert response.status_code in (400, 403, 404)
assert b'"a"' not in response.data
def test_an_unknown_plugin_is_still_a_404(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get("/api/v3/plugins/nope/static/x.json")
assert response.status_code == 404
class TestDeleteOfTheDayJson:
"""POST /api/v3/plugins/of-the-day/json/delete
file_id came from the request body and was interpolated into
``f"{file_id}.json"`` and then unlinked, with no validation at all. A
file_id of "../../../../etc/something" deleted that file. This is the one
finding in the batch that destroyed data rather than exposing it.
"""
@pytest.fixture
def plugin_tree(self, tmp_path, api_v3_module):
plugin_dir = tmp_path / "plugin-repos" / "ledmatrix-of-the-day"
(plugin_dir / "of_the_day").mkdir(parents=True)
(plugin_dir / "of_the_day" / "quotes.json").write_text("{}", encoding="utf-8")
outside = tmp_path / "victim.json"
outside.write_text("important", encoding="utf-8")
api_v3_module.api_v3.plugin_manager = MagicMock()
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(plugin_dir)
return plugin_dir, outside
URL = "/api/v3/plugins/of-the-day/json/delete"
def test_a_real_file_in_the_plugin_is_still_deleted(
self, api_v3_client, plugin_tree
):
plugin_dir, _ = plugin_tree
target = plugin_dir / "of_the_day" / "quotes.json"
response = api_v3_client.post(self.URL, json={"file_id": "quotes"})
assert response.status_code == 200
assert not target.exists()
def test_a_traversing_file_id_deletes_nothing(self, api_v3_client, plugin_tree):
_, outside = plugin_tree
response = api_v3_client.post(
self.URL, json={"file_id": "../../../victim"}
)
assert response.status_code == 400
assert outside.exists(), "file outside the plugin directory was deleted"
assert outside.read_text(encoding="utf-8") == "important"
@pytest.mark.parametrize("file_id", ["..", "a/b", "/etc/x", "x" + BACKSLASH + "y"])
def test_other_shapes_of_traversal_are_refused(
self, api_v3_client, plugin_tree, file_id
):
_, outside = plugin_tree
response = api_v3_client.post(self.URL, json={"file_id": file_id})
assert response.status_code == 400
assert outside.exists()
class TestDiskCacheKeys:
"""The cache key becomes a filename, and POST /api/v3/cache/delete passes
the request body's key straight through CacheManager.clear_cache to
os.remove. A key of "../../../../etc/whatever" named a file well outside
the cache directory.
"""
@pytest.fixture
def cache(self, tmp_path):
from src.cache.disk_cache import DiskCache
cache_dir = tmp_path / "cache"
cache_dir.mkdir()
return DiskCache(str(cache_dir)), cache_dir
def test_an_ordinary_key_still_round_trips(self, cache):
disk_cache, cache_dir = cache
disk_cache.set("espn_nfl_2024", {"ok": True})
assert (cache_dir / "espn_nfl_2024.json").exists()
assert disk_cache.get("espn_nfl_2024", max_age=None)["ok"] is True
def test_a_traversing_key_has_no_path(self, cache):
disk_cache, _ = cache
assert disk_cache.get_cache_path("../../victim") is None
assert disk_cache.get_cache_path("..") is None
assert disk_cache.get_cache_path("a/b") is None
def test_clearing_a_traversing_key_deletes_nothing(self, cache, tmp_path):
disk_cache, _ = cache
victim = tmp_path / "victim.json"
victim.write_text("important", encoding="utf-8")
disk_cache.clear("../victim")
assert victim.exists(), "file outside the cache directory was deleted"
def test_writing_a_traversing_key_creates_nothing(self, cache, tmp_path):
disk_cache, _ = cache
disk_cache.set("../escaped", {"x": 1})
assert not (tmp_path / "escaped.json").exists()
def test_the_delete_endpoint_says_no_rather_than_claiming_success(
self, api_v3_client, api_v3_module
):
api_v3_module.api_v3.cache_manager = MagicMock()
response = api_v3_client.post(
"/api/v3/cache/delete", json={"key": "../../etc/passwd"}
)
assert response.status_code == 400
api_v3_module.api_v3.cache_manager.clear_cache.assert_not_called()
def test_the_delete_endpoint_still_deletes_an_ordinary_key(
self, api_v3_client, api_v3_module
):
api_v3_module.api_v3.cache_manager = MagicMock()
response = api_v3_client.post(
"/api/v3/cache/delete", json={"key": "espn_nfl_2024"}
)
assert response.status_code == 200
api_v3_module.api_v3.cache_manager.clear_cache.assert_called_once_with(
"espn_nfl_2024"
)
class TestPluginVersionLookup:
"""_get_plugin_version joins a request-supplied id onto the plugins
directory and opens manifest.json under it."""
def test_a_real_manifest_is_read(self, tmp_path):
from web_interface.blueprints import api_v3 as module
plugins = tmp_path / "plugin-repos"
(plugins / "demo").mkdir(parents=True)
(plugins / "demo" / "manifest.json").write_text(
json.dumps({"version": "1.2.3"}), encoding="utf-8"
)
original = getattr(module.api_v3, "plugin_store_manager", None)
module.api_v3.plugin_store_manager = MagicMock(plugins_dir=str(plugins))
try:
assert module._get_plugin_version("demo") == "1.2.3"
assert module._get_plugin_version("../demo") == ""
assert module._get_plugin_version("..") == ""
finally:
module.api_v3.plugin_store_manager = original
+153
View File
@@ -0,0 +1,153 @@
"""A plugin can ask to be polled faster while it has something live.
The manifest carries one static `update_interval`, and until now that was the
only thing the scheduler would look at. A sports scoreboard needs 15 seconds
while a game is in progress and 15 minutes when nothing is on, and no single
number expresses that.
Measured consequence, on a live rig during an NFL game in its fourth quarter:
football-scoreboard's manifest pins update_interval to 60, so ESPN was polled
exactly once a minute --
23:21:49 23:22:50 23:23:50 23:24:50 23:25:50
-- while the plugin's own live_update_interval said 15. The clock and score on
the panel therefore lagged by up to a minute during a two-minute drill, which
reads to a viewer as a frozen display.
get_update_interval() lets the plugin say what it needs per tick. These tests
pin the parts that are easy to get wrong: that the hook wins, that "no opinion"
falls back cleanly, that a broken hook cannot stop a plugin updating, and that
the hook is not accidentally cached (which would defeat the whole point).
"""
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
@pytest.fixture
def manager():
pm = PluginManager.__new__(PluginManager)
pm._update_interval_cache = {}
pm.plugin_manifests = {"sports": {"update_interval": 60}}
pm.config_manager = None
pm.logger = MagicMock()
return pm
class _Plugin:
def __init__(self, wants=None, raises=False):
self._wants = wants
self._raises = raises
self.calls = 0
def get_update_interval(self):
self.calls += 1
if self._raises:
raise RuntimeError("plugin is broken")
return self._wants
def test_the_hook_overrides_the_manifest(manager):
"""The whole point: 15s while live, not the manifest's 60."""
assert manager._get_plugin_update_interval("sports", _Plugin(wants=15)) == 15.0
def test_none_means_no_opinion_and_the_manifest_applies(manager):
"""A plugin with nothing live should not have to restate the default."""
assert manager._get_plugin_update_interval("sports", _Plugin(wants=None)) == 60.0
def test_a_plugin_without_the_hook_is_unaffected(manager):
"""Every existing plugin predates this and must keep its manifest value."""
assert manager._get_plugin_update_interval("sports", MagicMock(spec=[])) == 60.0
def test_the_hook_is_consulted_every_time(manager):
"""Caching the answer would make it exactly as static as the manifest.
The static path *is* cached -- reading config is expensive -- so it would be
an easy mistake to let the dynamic answer ride along in that cache.
"""
plugin = _Plugin(wants=15)
for _ in range(5):
manager._get_plugin_update_interval("sports", plugin)
assert plugin.calls == 5, f"hook called {plugin.calls} times in 5 lookups"
def test_the_cadence_can_change_between_ticks(manager):
"""A game going final must slow the polling back down without a reload."""
plugin = _Plugin(wants=15)
assert manager._get_plugin_update_interval("sports", plugin) == 15.0
plugin._wants = None # game ended
assert manager._get_plugin_update_interval("sports", plugin) == 60.0
plugin._wants = 15 # another game started
assert manager._get_plugin_update_interval("sports", plugin) == 15.0
class TestABrokenHookCannotStopUpdates:
"""A scheduler that propagates a plugin's bug stops every other plugin too."""
def test_a_raising_hook_falls_back_to_the_manifest(self, manager):
assert manager._get_plugin_update_interval("sports", _Plugin(raises=True)) == 60.0
@pytest.mark.parametrize("junk", ["soon", object(), [], {}])
def test_a_non_numeric_hook_falls_back(self, manager, junk):
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
@pytest.mark.parametrize("junk", [float("nan"), float("inf"), float("-inf")])
def test_nan_and_infinity_fall_back(self, manager, junk):
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
@pytest.mark.parametrize("junk", [True, False])
def test_a_bool_hook_falls_back(self, manager, junk):
"""bool is a subclass of int, so it must be rejected before float()
turns it into 0.0/1.0 and the floor silently swallows the mistake."""
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
class TestTheFloor:
"""A plugin asking for 0 would be re-entered on every tick of the render
loop, which is a busy-wait against whatever API it fetches."""
@pytest.mark.parametrize("wants", [0, 0.5, -10])
def test_small_and_negative_requests_are_clamped(self, manager, wants):
got = manager._get_plugin_update_interval("sports", _Plugin(wants=wants))
assert got == PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL
def test_a_reasonable_request_is_not_clamped(self, manager):
assert manager._get_plugin_update_interval("sports", _Plugin(wants=15)) == 15.0
def test_the_static_path_still_prefers_the_manifest_over_config():
"""Deliberately unchanged, and not an oversight.
Config values that have sat inert behind the manifest are stale by
definition -- nothing has been honouring them. On one rig, football and
baseball both carry update_interval 3600 in config against a manifest 60;
making config win would slow those plugins by 60x. The dynamic hook is the
supported way for a plugin to vary its own cadence, so flipping this is
both risky and no longer necessary.
"""
pm = PluginManager.__new__(PluginManager)
pm._update_interval_cache = {}
pm.plugin_manifests = {"sports": {"update_interval": 60}}
cfg = MagicMock()
cfg.get_config.return_value = {"sports": {"update_interval": 3600}}
pm.config_manager = cfg
pm.logger = MagicMock()
assert pm._get_plugin_update_interval("sports", MagicMock(spec=[])) == 60.0
def test_config_is_still_used_when_the_manifest_is_silent():
pm = PluginManager.__new__(PluginManager)
pm._update_interval_cache = {}
pm.plugin_manifests = {"sports": {}}
cfg = MagicMock()
cfg.get_config.return_value = {"sports": {"update_interval": 120}}
pm.config_manager = cfg
pm.logger = MagicMock()
assert pm._get_plugin_update_interval("sports", MagicMock(spec=[])) == 120.0
+170
View File
@@ -0,0 +1,170 @@
"""Tests for plugin-state reconciliation reporting phantom inconsistencies.
Observed on a device running four installed, configured, working plugins: the
web UI showed "Stale plugin config entries found: football-scoreboard,
odds-ticker, data, ledmatrix-weather, starlark-apps. Remove them from
config.json" for hours. Three separate defects combined to produce that:
1. load_config() merges config_secrets.json into the config it returns, and the
ignore list named only 'github' and 'youtube'. A 'data' key in the secrets
file therefore became a phantom plugin, reported as in-config-not-on-disk.
2. The auto-fix for "on disk but not in config" assigned
``config[plugin_id] = {'enabled': False}`` unconditionally. When detection was
wrong, that traded a plugin's real configuration (4.9KB of league settings in
the reported case) for a stub. It only avoided firing there because the write
failed with EACCES.
3. The verdict is a snapshot written once per run and served to the UI from a
status file, and a run that fails to apply a fix also declines to retry. So a
condition that had since resolved was reported indefinitely.
"""
import json
from pathlib import Path
from unittest.mock import Mock
import pytest
from src.plugin_system.state_reconciliation import (
InconsistencyType,
StateReconciliation,
still_unresolved,
)
class _ConfigManager:
"""Minimal config manager with the secrets path the real one exposes."""
def __init__(self, config, secrets_path=None):
self._config = config
self._secrets_path = secrets_path
self.saved = []
def load_config(self):
return dict(self._config)
def save_config(self, config):
self.saved.append(config)
self._config = dict(config)
def get_secrets_path(self):
return self._secrets_path
def _reconciler(config, plugins_dir, secrets_path=None):
return StateReconciliation(
state_manager=Mock(),
config_manager=_ConfigManager(config, secrets_path),
plugin_manager=Mock(),
plugins_dir=plugins_dir,
)
class TestSecretsKeysAreNotPlugins:
def test_secrets_key_is_not_treated_as_a_plugin(self, tmp_path):
secrets = tmp_path / "config_secrets.json"
# The shape actually found on the device: display state written here.
secrets.write_text(json.dumps({"data": {"mode": "nfl_recent"},
"timestamp": 1.0}), encoding="utf-8")
config = {
"data": {"mode": "nfl_recent"},
"football-scoreboard": {"enabled": True, "nfl": {"favorite_teams": ["TB"]}},
}
state = _reconciler(config, tmp_path, secrets).\
_get_config_state()
assert "data" not in state, "secrets key leaked in as a phantom plugin"
assert "football-scoreboard" in state
def test_missing_secrets_file_is_survivable(self, tmp_path):
state = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
tmp_path / "does-not-exist.json")._get_config_state()
assert "odds-ticker" in state
def test_non_path_secrets_location_is_survivable(self, tmp_path):
"""Regression: a non-path return raised TypeError out of the helper,
which the broad handler in _get_config_state() swallowed as "Error
reading config state" -- emptying the config state and making every
downstream detection wrong."""
r = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
secrets_path=Mock())
assert "odds-ticker" in r._get_config_state()
def test_config_manager_without_secrets_path_is_survivable(self, tmp_path):
r = StateReconciliation(state_manager=Mock(),
config_manager=Mock(spec=["load_config", "save_config"]),
plugin_manager=Mock(), plugins_dir=tmp_path)
r.config_manager.load_config.return_value = {"odds-ticker": {"enabled": True}}
assert "odds-ticker" in r._get_config_state()
class TestFixNeverClobbersRealConfig:
def _missing_in_config(self, plugin_id):
inc = Mock()
inc.inconsistency_type = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
inc.plugin_id = plugin_id
return inc
def test_existing_entry_is_not_overwritten(self, tmp_path):
rich = {"enabled": True, "nfl": {"favorite_teams": ["TB"], "show_odds": True}}
r = _reconciler({"football-scoreboard": dict(rich)}, tmp_path)
assert r._fix_inconsistency(self._missing_in_config("football-scoreboard")) is True
# The regression: this wrote {'enabled': False} over the real settings.
assert r.config_manager.saved == []
assert r.config_manager.load_config()["football-scoreboard"] == rich
def test_genuinely_absent_plugin_is_still_added(self, tmp_path):
r = _reconciler({"other-plugin": {"enabled": True}}, tmp_path)
assert r._fix_inconsistency(self._missing_in_config("new-plugin")) is True
assert len(r.config_manager.saved) == 1
assert r.config_manager.load_config()["new-plugin"] == {"enabled": False}
# and it must not disturb what was already there
assert r.config_manager.load_config()["other-plugin"] == {"enabled": True}
class TestStaleFindingsAreDropped:
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG.value
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK.value
def test_resolved_missing_in_config_is_dropped(self):
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
assert still_unresolved(entries, {"football-scoreboard"}, set()) == []
def test_genuinely_missing_in_config_is_kept(self):
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
assert still_unresolved(entries, set(), set()) == entries
def test_resolved_missing_on_disk_is_dropped(self):
entries = [{"plugin_id": "odds-ticker", "type": self.ON_DISK}]
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
def test_genuinely_missing_on_disk_is_kept(self):
entries = [{"plugin_id": "data", "type": self.ON_DISK}]
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries
def test_unrecheckable_kinds_are_kept(self):
# Filtering must only ever remove what it can prove stale.
entries = [{"plugin_id": "x", "type": "plugin_version_mismatch"}]
assert still_unresolved(entries, set(), set()) == entries
def test_the_reported_device_state_clears_every_false_finding(self):
"""The exact verdict the device served, against its real state."""
entries = [
{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG},
{"plugin_id": "odds-ticker", "type": self.IN_CONFIG},
{"plugin_id": "data", "type": self.ON_DISK},
{"plugin_id": "ledmatrix-weather", "type": self.IN_CONFIG},
{"plugin_id": "starlark-apps", "type": self.IN_CONFIG},
]
installed = {"football-scoreboard", "ledmatrix-weather", "odds-ticker",
"starlark-apps", "web-ui-info"}
live = still_unresolved(entries, installed, installed)
# All four "installed but not in config" findings were false and clear.
# 'data' legitimately is not installed, so this filter keeps it; it stops
# being reported because _get_config_state() no longer invents it from the
# secrets file, which takes effect on the next reconciliation run.
assert [e["plugin_id"] for e in live] == ["data"]
+167
View File
@@ -0,0 +1,167 @@
"""The reconciliation-status endpoint must re-check its stored verdict.
#557 added _drop_stale_reconciliation_findings() so a resolved condition stops
being reported: the verdict is a snapshot written once per run, and a run that
fails to apply a fix also declines to retry, so a device whose plugins were all
present in config kept being told for hours that four of them were missing.
That wiring had no test. Only the pure still_unresolved() helper in
src/plugin_system/ was covered, which lives outside web_interface and therefore
survived the api_v3 blueprint split untouched -- so when #553 moved this
endpoint into api_v3/plugins.py, losing the filter would have been completely
silent. This test exists so that cannot happen again.
"""
import json
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
IN_CONFIG = "plugin_missing_in_config"
ON_DISK = "plugin_missing_on_disk"
@pytest.fixture
def client(tmp_path, monkeypatch):
"""App whose status file, config and plugins dir are all under our control."""
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
def _install(plugin_id):
d = plugins_dir / plugin_id
d.mkdir()
(d / "manifest.json").write_text(json.dumps({"id": plugin_id}), encoding="utf-8")
def _setup(verdict, config=None, installed=(), secrets=None, corrupt=()):
(tmp_path / "ledmatrix_reconciliation.json").write_text(
json.dumps(verdict), encoding="utf-8")
for pid in installed:
_install(pid)
for pid in corrupt:
d = plugins_dir / pid
d.mkdir(exist_ok=True)
(d / "manifest.json").write_text("{ this is not json", encoding="utf-8")
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
cm = MagicMock()
cm.load_config.return_value = dict(config or {})
cm.get_secrets_path.return_value = str(secrets_path)
pm = MagicMock()
pm.plugins_dir = str(plugins_dir)
# setattr via monkeypatch: these live on a module-level blueprint
# singleton, so assigning them directly leaks mocks -- pointing at a
# deleted tmp_path -- into every later test that imports api_v3.
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
return app.test_client()
return _setup
def _get(c):
return c.get("/api/v3/plugins/reconciliation-status").get_json()["data"]
class TestStaleFindingsAreDroppedByTheEndpoint:
def test_a_plugin_now_in_config_is_no_longer_reported(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]},
config={"football-scoreboard": {"enabled": True}})
# The regression: this kept being reported for hours after it resolved.
assert _get(c)["unresolved"] == []
def test_a_plugin_now_installed_is_no_longer_reported(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "odds-ticker", "type": ON_DISK}]},
installed=["odds-ticker"])
assert _get(c)["unresolved"] == []
def test_a_finding_that_still_holds_is_kept(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
def test_the_reported_device_verdict_clears(self, client):
"""The five findings the live device served, against its real state."""
installed = ["football-scoreboard", "ledmatrix-weather",
"odds-ticker", "starlark-apps"]
c = client({"done": True, "unresolved": [
{"plugin_id": p, "type": IN_CONFIG} for p in installed]},
config={p: {"enabled": True} for p in installed},
installed=installed)
assert _get(c)["unresolved"] == []
class TestTheEndpointStaysRobust:
def test_an_empty_verdict_is_passed_through(self, client):
c = client({"done": True, "unresolved": []})
assert _get(c) == {"done": True, "unresolved": []}
def test_a_broken_config_manager_leaves_findings_untouched(self, client):
"""Best-effort: a stale warning beats a failed endpoint."""
c = client({"done": True, "unresolved": [
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]})
api_v3.config_manager.load_config.side_effect = OSError("config unreadable")
body = _get(c)
assert [e["plugin_id"] for e in body["unresolved"]] == ["football-scoreboard"]
def test_a_run_still_in_progress_is_reported_as_such(self, client):
c = client({"done": False, "unresolved": []})
assert _get(c)["done"] is False
class TestTheFilterUsesTheReconcilersOwnRules:
"""A looser definition of "in config" or "on disk" clears findings that are
still true. Both cases raised by CodeRabbit on #553."""
def test_a_secrets_key_does_not_clear_an_in_config_finding(self, client):
# load_config() merges the secrets file in, so 'data' appears in the
# config dict -- but it is not a plugin. A plain set(config) would treat
# it as one and clear this finding.
c = client({"done": True, "unresolved": [
{"plugin_id": "data", "type": IN_CONFIG}]},
config={"data": {"mode": "nfl_recent"}},
secrets={"data": {"mode": "nfl_recent"}})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["data"]
def test_a_system_key_does_not_clear_an_in_config_finding(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "display", "type": IN_CONFIG}]},
config={"display": {"hardware": {}}})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["display"]
def test_a_non_dict_value_does_not_clear_an_in_config_finding(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "timezone", "type": IN_CONFIG}]},
config={"timezone": "America/Chicago"})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["timezone"]
def test_an_unparseable_manifest_does_not_count_as_installed(self, client):
# Otherwise a corrupt file clears a live "in config but not on disk"
# finding on the strength of something nothing can read.
c = client({"done": True, "unresolved": [
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
corrupt=["broken-plugin"])
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
installed=["weather.standalone-backup-20260101"])
assert len(_get(c)["unresolved"]) == 1
+120
View File
@@ -0,0 +1,120 @@
"""Tests that SportsCore's decoded-logo cache is LRU-bounded.
``self._logo_cache`` was a plain dict keyed by team abbreviation, with no
eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about
36KB on a 256x64 panel, more for wide wordmarks -- and
assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league
therefore held the whole league in memory: roughly 11-18MB per manager
instance, and a league runs three of them (live/recent/upcoming) each with its
own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering.
The bound has to be LRU rather than "clear when full": the logos on screen
right now are exactly the ones that must not be thrown away.
"""
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image
# src.base_classes.sports transitively imports the hardware matrix driver.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.sports import SportsCore # noqa: E402
LOGGER = logging.getLogger("test_sports_logo_cache_bounded")
class _StubSports(SportsCore):
def _fetch_data(self):
return None
def _extract_game_details(self, game_event):
return None
@pytest.fixture
def host(monkeypatch, tmp_path):
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
# Never reach for the network: every logo these tests ask for exists.
monkeypatch.setattr(
"src.base_classes.sports.core.download_missing_logo",
lambda *a, **k: None)
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
cache_manager = MagicMock()
cache_manager.cache_dir = str(tmp_path)
instance = _StubSports({"timezone": "UTC"}, display_manager,
cache_manager, LOGGER, "nhl")
instance._logo_dir = tmp_path
return instance, tmp_path
def _load(host, abbrev):
"""Create a real logo file for `abbrev` and load it through the cache."""
instance, tmp_path = host
path = tmp_path / f"{abbrev}.png"
if not path.exists():
Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path)
return instance._load_and_resize_logo(abbrev, abbrev, path, None)
class TestTheCacheIsBounded:
def test_it_never_exceeds_the_limit(self, host):
instance, _ = host
limit = instance._LOGO_CACHE_MAX
for i in range(limit + 40):
assert _load(host, f"T{i}") is not None
# The regression: this grew to limit + 40, and for NCAA to 307.
assert len(instance._logo_cache) == limit
def test_the_limit_is_smaller_than_a_real_league(self, host):
"""ncaa_logos ships 307 files; the cap has to be well under that."""
instance, _ = host
assert instance._LOGO_CACHE_MAX < 307
class TestEvictionIsLRUNotArbitrary:
def test_the_least_recently_used_goes_first(self, host):
instance, _ = host
limit = instance._LOGO_CACHE_MAX
for i in range(limit):
_load(host, f"T{i}")
_load(host, "NEW")
assert "T0" not in instance._logo_cache, "oldest should have been evicted"
assert "NEW" in instance._logo_cache
def test_touching_an_entry_saves_it(self, host):
instance, _ = host
limit = instance._LOGO_CACHE_MAX
for i in range(limit):
_load(host, f"T{i}")
_load(host, "T0") # a cache hit -- T0 is on screen again
_load(host, "NEW") # forces one eviction
assert "T0" in instance._logo_cache, "a logo in use was thrown away"
assert "T1" not in instance._logo_cache, "T1 was the true LRU entry"
class TestHitsStillAvoidDiskWork:
def test_a_second_request_returns_the_cached_object(self, host, monkeypatch):
instance, _ = host
first = _load(host, "TB")
def _boom(*a, **k):
raise AssertionError("cache hit re-opened the file from disk")
monkeypatch.setattr(Image, "open", _boom)
assert _load(host, "TB") is first
+45
View File
@@ -6,6 +6,7 @@ frame for the whole display_duration instead of rotating on -- the same class
of defect as a display() that returns None.
"""
import json
import sys
import types
from pathlib import Path
@@ -197,6 +198,50 @@ class TestInstalledAppsTakeTurns:
assert p.display(force_clear=True) is False
class TestManifestLockSidecarIsStable:
"""_save_manifest and _update_manifest_safe must lock a sidecar file that
a manifest write never replaces. manifest.json itself is swapped for a
fresh inode by every atomic write (temp file + rename); a lock taken on
manifest.json directly would not exclude a second locker whose fresh
os.open() lands on that new inode right after a write, so two writers
could still race each other despite each believing it held the lock.
web_interface._starlark_manifest_lock (web_interface/blueprints/api_v3)
must lock this same sidecar name for that guarantee to hold across both
the plugin-loaded and standalone paths.
"""
def _plugin_with_manifest_dir(self, manager_module, tmp_path):
p = _plugin(manager_module)
p.manifest_file = tmp_path / "manifest.json"
p.manifest_lock_file = tmp_path / "manifest.json.lock"
return p
def test_the_lock_sidecar_is_not_the_manifest_file(self, manager_module, tmp_path):
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
assert p.manifest_lock_file != p.manifest_file
assert p.manifest_lock_file.name == "manifest.json.lock"
def test_the_locked_files_inode_survives_repeated_writes(self, manager_module, tmp_path):
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
assert p._save_manifest({"apps": {}})
lock_ino_before = p.manifest_lock_file.stat().st_ino
for app_id in ("one", "two", "three"):
def _update(manifest, app_id=app_id):
manifest.setdefault("apps", {})[app_id] = {"enabled": True}
assert p._update_manifest_safe(_update)
lock_ino_after = p.manifest_lock_file.stat().st_ino
assert lock_ino_after == lock_ino_before, (
"the locked file's inode changed across writes -- a locker that "
"opened it before this write and one that opens it after would "
"no longer contend for the same lock")
manifest = json.loads(p.manifest_file.read_text())
assert set(manifest["apps"]) == {"one", "two", "three"}
class TestAnimationsRunAtFrameRate:
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
"""The controller reads this attribute; a multi-frame app is otherwise
+131
View File
@@ -0,0 +1,131 @@
"""A privileged system action that cannot run must say why.
The web interface runs unprivileged. Its systemctl/reboot/journalctl calls only
work once scripts/install/configure_web_sudo.sh has granted NOPASSWD, and
first_time_install.sh never invokes that script -- so on a fresh device every
one of those actions fails. What the user got back was:
{"message": "Action failed; see logs for details", "status": "error"}
...and the log viewer was broken for exactly the same reason, so "see logs" led
nowhere. This is the closed loop that src/web_interface/error_handler.py's
describe_exception() was written to break; /system/action's exception handler
was simply still discarding the cause.
Observed live: POSTing reboot_system to a Pi returned that bare sentence, and
pinning down "no passwordless sudo" took reading the installer instead of
reading the response.
"""
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
@pytest.fixture
def client():
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
return app.test_client()
def _act(client, action='stop_display'):
return client.post('/api/v3/system/action', json={'action': action})
def _result(args, rc=0, stderr=''):
return subprocess.CompletedProcess(args, rc, stdout='', stderr=stderr)
class TestSudoRefusalIsNamed:
@pytest.mark.parametrize("stderr", [
"sudo: a password is required",
"sudo: no tty present and no askpass program specified",
"sudo: a terminal is required to read the password",
])
def test_every_sudo_refusal_wording_is_recognised(self, client, stderr):
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 1, stderr)):
body = _act(client).get_json()
assert body['status'] == 'error'
# The point: the response tells the user what to do about it.
assert 'configure_web_sudo.sh' in body['message']
def test_the_raw_stderr_is_still_returned(self, client):
with patch('subprocess.run',
side_effect=lambda a, **k: _result(a, 1, "sudo: a password is required")):
body = _act(client).get_json()
assert 'a password is required' in body['stderr']
class TestUnrelatedFailuresAreNotMisattributed:
def test_a_real_systemctl_error_keeps_the_generic_message(self, client):
with patch('subprocess.run',
side_effect=lambda a, **k: _result(a, 5, "Unit ledmatrix.service not found.")):
body = _act(client).get_json()
assert 'configure_web_sudo.sh' not in body['message'], \
"a missing unit was blamed on sudo"
assert 'Unit ledmatrix.service not found.' in body['stderr']
assert body['returncode'] == 5
class TestTheExceptionPathNamesTheCause:
def test_the_exception_type_and_message_are_returned(self, client):
with patch('subprocess.run', side_effect=FileNotFoundError(2, "sudo")):
body = _act(client).get_json()
# The regression: 'details' was absent and the cause was discarded.
assert 'FileNotFoundError' in body['details']
def test_a_sudo_shaped_exception_also_gets_the_hint(self, client):
with patch('subprocess.run',
side_effect=PermissionError("sudo: a password is required")):
body = _act(client).get_json()
assert 'configure_web_sudo.sh' in body['message']
class TestTheEarlyReturnPathsAlsoGetTheHint:
"""start_display with a mode builds its own response and returns before the
shared nonzero-result path, so it needed the hint separately. Found by
CodeRabbit on #560; verified against the code before fixing."""
def test_on_demand_start_reports_the_sudo_cause(self, client):
with patch('subprocess.run',
side_effect=lambda a, **k: _result(a, 1, "sudo: a password is required")):
body = client.post('/api/v3/system/action',
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
assert 'configure_web_sudo.sh' in body['message'], "the on-demand branch still reported only 'Failed to start display'"
assert body['status'] == 'error'
def test_on_demand_start_keeps_its_success_message(self, client):
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 0)):
body = client.post('/api/v3/system/action',
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
assert body['status'] == 'success'
assert body['message'] == 'Display started'
def test_an_unrelated_on_demand_failure_is_not_blamed_on_sudo(self, client):
with patch('subprocess.run',
side_effect=lambda a, **k: _result(a, 5, "Unit not found.")):
body = client.post('/api/v3/system/action',
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
assert body['message'] == 'Failed to start display'
class TestSuccessIsUnchanged:
def test_a_working_action_still_reports_success(self, client):
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 0)):
body = _act(client).get_json()
assert body['status'] == 'success'
assert 'configure_web_sudo.sh' not in body.get('message', '')
+121
View File
@@ -17,6 +17,8 @@ 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 shlex
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
@@ -150,3 +152,122 @@ def test_a_missing_installed_unit_is_silent(validator, tmp_path):
validator._validate_systemd_units()
assert not validator.warnings
assert not validator.errors
# --- the web unit: one template, three copies, and a warning that never cleared ---
#
# install_service.sh and install_web_service.sh each carried their own inline
# heredoc of ledmatrix-web.service. install_service.sh's had drifted -- no
# Wants=network-online.target, RestartSec, SyslogIdentifier or CacheDirectory --
# and that is what was installed on real rigs. The validator correctly reported
# the drift and told the user to re-run install_service.sh, which reinstalled the
# same stale copy, so the warning could never clear. Separately, the template
# hardcoded User=root while the installers write whoever ran them, so even the
# *correct* installer produced a permanent warning on any non-root install.
def _render(template_text, project_root, user):
"""Exactly what the installers' sed does."""
return (template_text
.replace("__PROJECT_ROOT_DIR__", str(project_root))
.replace("__USER__", user))
def test_the_web_unit_installed_as_a_non_root_user_is_not_drift(validator, tmp_path):
"""The web interface runs as whoever installed it, not as root.
This is the case that warned forever: nothing the user could do would make
an installed `User=pi` match a template that said `User=root`.
"""
project_root = Path("src/startup_validator.py").resolve().parent.parent
template_rel = "systemd/ledmatrix-web.service"
template = project_root / template_rel
if not template.is_file():
pytest.skip("repo unit template not present")
installed = tmp_path / "ledmatrix-web.service"
installed.write_text(
_render(template.read_text(encoding="utf-8"), project_root, "hdpi"),
encoding="utf-8")
validator._UNITS = ((template_rel, str(installed)),)
validator._validate_systemd_units()
assert not validator.warnings, (
f"a correctly installed non-root web unit warned: {validator.warnings}")
def test_the_web_unit_still_reports_a_real_changed_directive(validator, tmp_path):
"""Ignoring User= must not make the check blind to everything else."""
project_root = Path("src/startup_validator.py").resolve().parent.parent
template_rel = "systemd/ledmatrix-web.service"
template = project_root / template_rel
if not template.is_file():
pytest.skip("repo unit template not present")
rendered = _render(template.read_text(encoding="utf-8"), project_root, "hdpi")
# Drop RestartSec -- one of the directives the stale heredoc was missing.
stale = "\n".join(line for line in rendered.splitlines() if not line.startswith("RestartSec="))
installed = tmp_path / "ledmatrix-web.service"
installed.write_text(stale + "\n", encoding="utf-8")
validator._UNITS = ((template_rel, str(installed)),)
validator._validate_systemd_units()
assert validator.warnings, "a web unit missing RestartSec= produced no warning"
assert not validator.errors
def test_installed_user_falls_back_to_root():
"""systemd defaults a system unit with no User= to root, so we must too."""
assert StartupValidator._installed_user("[Service]\nExecStart=/x\n") == "root"
assert StartupValidator._installed_user("[Service]\nUser=pi\n") == "pi"
assert StartupValidator._installed_user("[Service]\n User=hdpi \n") == "hdpi"
def test_sed_escape_replacement_preserves_special_characters():
"""A project path or username containing sed-special characters must render literally.
The installers build their sed expression by interpolating a shell
variable into the replacement side of `sed s|pattern|replacement|`.
Unescaped, sed treats `&` as "insert the whole match" and `\\` as an
escape character, so a path like `/opt/led&matrix` would corrupt the
rendered unit instead of being substituted as-is. lib_systemd_render.sh's
sed_escape_replacement exists to prevent exactly that.
"""
project_root = Path("src/startup_validator.py").resolve().parent.parent
helper = project_root / "scripts" / "install" / "lib_systemd_render.sh"
if not helper.is_file():
pytest.skip("install helper not present")
value = "/opt/led&matrix\\pi|two"
escape_cmd = f'source {shlex.quote(str(helper))}; sed_escape_replacement {shlex.quote(value)}'
escaped = subprocess.run(
["bash", "-c", escape_cmd], capture_output=True, text=True, check=True
).stdout
rendered = subprocess.run(
["sed", f"s|__X__|{escaped}|g"],
input="path=__X__\n", capture_output=True, text=True, check=True,
).stdout
assert rendered == f"path={value}\n", (
"a sed-special character in the replacement was not preserved literally")
def test_no_installer_carries_its_own_copy_of_a_unit():
"""The regression guard.
Both installers used to inline the unit as a heredoc, and the two copies
drifted from the template and from each other. A unit body in a shell script
is the bug, so assert there isn't one rather than asserting the current
contents match -- matching contents is exactly what silently stops being
true.
"""
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", errors="replace")
if "[Unit]" in text and "Description=" in text:
offenders.append(script.name)
assert not offenders, (
f"{offenders} contain an inline systemd unit; render "
f"systemd/*.service instead so there is one source of truth")
+4 -1
View File
@@ -470,7 +470,10 @@ class TestApiBoundsMatchValidate:
"""Extract the numeric_fields map from api_v3 without importing Flask."""
import ast
import pathlib
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
# api_v3 is a package; the routes are spread across its modules.
src = '\n'.join(
p.read_text() for p in
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
+29 -4
View File
@@ -127,6 +127,31 @@ class TestConfigAPI:
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'}
@@ -390,7 +415,7 @@ class TestConfigAPI:
class TestSystemAPI:
"""Test system API endpoints."""
@patch('web_interface.blueprints.api_v3.subprocess')
@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
@@ -407,7 +432,7 @@ class TestSystemAPI:
data = json.loads(response.data)
assert 'service' in data or 'status' in data or 'active' in data
@patch('web_interface.blueprints.api_v3.subprocess')
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_get_system_version(self, mock_subprocess, client):
"""Test getting system version."""
mock_result = MagicMock()
@@ -421,7 +446,7 @@ class TestSystemAPI:
data = json.loads(response.data)
assert 'version' in data.get('data', {}) or 'version' in data
@patch('web_interface.blueprints.api_v3.subprocess')
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_execute_system_action(self, mock_subprocess, client):
"""Test executing system action."""
mock_result = MagicMock()
@@ -502,7 +527,7 @@ class TestDisplayAPI:
if response.status_code in [200, 201]:
assert api_v3.cache_manager.set.called
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
@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."""
+63
View File
@@ -0,0 +1,63 @@
"""Tests for the web interface's autostart default.
Regression under test: ``start_web_conditionally.py`` read the flag with
``config_data.get("web_display_autostart", False)``, so a config that simply
lacked the key got no web interface. Both config.template.json and
first_time_install.sh ship the key as ``true``, so absence means an older or
hand-edited config -- not a request to stay down.
The failure was silent in the worst way: the "not starting" path exits 0, so
``systemctl status ledmatrix-web`` reported the unit as successfully started
while nothing was listening on the port, and the journal's only trace was one
line saying the flag was "false or not set".
"""
import importlib.util
from pathlib import Path
import pytest
SCRIPT = (Path(__file__).resolve().parents[1]
/ "scripts" / "utils" / "start_web_conditionally.py")
@pytest.fixture(scope="module")
def mod():
spec = importlib.util.spec_from_file_location("start_web_conditionally", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestAutostartDefault:
def test_absent_key_still_starts(self, mod):
# The regression: this returned False and the UI silently stayed down.
assert mod.autostart_enabled({}) is True
def test_unreadable_config_still_starts(self, mod):
# main() falls back to {} when the config is missing or corrupt, because
# the web interface is how a broken config gets repaired.
assert mod.autostart_enabled({}) is True
def test_explicit_true_starts(self, mod):
assert mod.autostart_enabled({"web_display_autostart": True}) is True
def test_explicit_false_does_not_start(self, mod):
assert mod.autostart_enabled({"web_display_autostart": False}) is False
@pytest.mark.parametrize("value", ["off", "false", "no", "0", "OFF", " False "])
def test_disabling_strings_do_not_start(self, mod, value):
assert mod.autostart_enabled({"web_display_autostart": value}) is False
@pytest.mark.parametrize("value", ["on", "true", "yes", "1", "TRUE"])
def test_enabling_strings_start(self, mod, value):
assert mod.autostart_enabled({"web_display_autostart": value}) is True
class TestShippedDefaultsAgree:
def test_template_ships_autostart_true(self):
"""The code default must match what the installer actually writes."""
import json
template = json.loads(
(SCRIPT.parents[2] / "config" / "config.template.json").read_text(encoding="utf-8"))
assert template["web_display_autostart"] is True
+5 -1
View File
@@ -114,7 +114,11 @@ class TestHandlersCarryDetail:
"""
import ast
src = open("web_interface/blueprints/api_v3.py").read()
# api_v3 is a package; the routes are spread across its modules.
import pathlib
src = "\n".join(
p.read_text() for p in
sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")))
tree = ast.parse(src)
# This used to match one exact message string, so a handler that wrote
+154
View File
@@ -0,0 +1,154 @@
"""The SSID and password reaching nmcli's argv come from an HTTP request body.
POST /api/v3/wifi/connect takes both verbatim and WiFiManager.connect_to_network
hands them to::
subprocess.run(["nmcli", "device", "wifi", "connect", ssid, "password", password])
There is no shell in that, so no metacharacter can start a second command --
CodeQL's py/command-line-injection alert overstates it on that point. What is
real is argument injection: nmcli reads a leading "-" as an option, so an SSID
of "--ask" or "-t" asks nmcli to *run differently* rather than to join a
network. Neither value was checked for shape at all before reaching argv.
These tests pin the validation without touching real networking: the
validators are classmethods, so nothing here constructs a WiFiManager.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.wifi_manager import WiFiManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class TestSsidValidation:
@pytest.mark.parametrize("ssid", [
"HomeNet", "my wifi 5G", "Cafe-Guest", "café", "x" * 32, "-not-leading".lstrip("-"),
])
def test_ordinary_ssids_pass_through(self, ssid):
value, error = WiFiManager._validate_ssid(ssid)
assert error is None
assert value == ssid
def test_surrounding_whitespace_is_trimmed_not_rejected(self):
value, error = WiFiManager._validate_ssid(" HomeNet ")
assert error is None
assert value == "HomeNet"
@pytest.mark.parametrize("ssid", ["--ask", "-t", "-"])
def test_an_ssid_nmcli_would_read_as_an_option_is_refused(self, ssid):
value, error = WiFiManager._validate_ssid(ssid)
assert error is not None
assert value == ""
def test_an_ssid_over_32_octets_is_refused(self):
# 802.11 caps the SSID element at 32 octets, so a longer one could
# never name a real network.
_, error = WiFiManager._validate_ssid("x" * 33)
assert error is not None
# Multi-byte characters count as octets, not characters.
_, error = WiFiManager._validate_ssid("é" * 17)
assert error is not None
@pytest.mark.parametrize("ssid", ["a\nb", "a\rb", "a\x00b", "a\x7fb", "a\tb"])
def test_control_characters_are_refused(self, ssid):
_, error = WiFiManager._validate_ssid(ssid)
assert error is not None
@pytest.mark.parametrize("ssid", ["", " ", None, 42, ["HomeNet"]])
def test_empty_and_non_text_values_are_refused(self, ssid):
_, error = WiFiManager._validate_ssid(ssid)
assert error is not None
class TestPasswordValidation:
def test_an_empty_password_means_an_open_network(self):
value, error = WiFiManager._validate_wifi_password("")
assert error is None
assert value == ""
value, error = WiFiManager._validate_wifi_password(None)
assert error is None
assert value == ""
@pytest.mark.parametrize("password", ["hunter22", "a" * 63, "0" * 64, "AbCdEf0123" * 6 + "abcd"])
def test_valid_psk_lengths_pass_through(self, password):
value, error = WiFiManager._validate_wifi_password(password)
assert error is None, f"{password!r} rejected: {error}"
assert value == password
@pytest.mark.parametrize("password", ["short", "z" * 64, "a" * 200])
def test_lengths_that_could_never_authenticate_are_refused(self, password):
# 8-63 chars for a passphrase, or exactly 64 hex chars for a raw key.
# "z" * 64 is the right length for a key but is not hex, so it is
# neither -- note "a" * 64 *is* valid hex and must stay accepted.
_, error = WiFiManager._validate_wifi_password(password)
assert error is not None
@pytest.mark.parametrize("password", ["-password", "--ask"])
def test_a_password_nmcli_would_read_as_an_option_is_refused(self, password):
_, error = WiFiManager._validate_wifi_password(password)
assert error is not None
def test_control_characters_are_refused(self):
_, error = WiFiManager._validate_wifi_password("pass\nword")
assert error is not None
@pytest.mark.parametrize("password", ["pässword", "你好12345678"])
def test_non_ascii_passphrases_are_refused(self, password):
# NetworkManager accepts only printable ASCII WPA-PSK passphrases (or
# a 64-char hex key); a non-ASCII value would otherwise reach
# _connect_nmcli and be saved/attempted before nmcli itself rejects it.
_, error = WiFiManager._validate_wifi_password(password)
assert error is not None
class TestConnectRefusesBeforeRunningNmcli:
"""connect_to_network must not reach subprocess with a rejected value."""
@pytest.mark.parametrize("ssid,password", [
("--ask", "hunter22"),
("x" * 40, "hunter22"),
("Home\nNet", "hunter22"),
("HomeNet", "-secret1"),
("HomeNet", "short"),
])
def test_no_subprocess_runs_for_a_rejected_request(self, ssid, password):
manager = WiFiManager.__new__(WiFiManager) # no __init__: no real host access
with patch("src.wifi_manager.subprocess.run") as run:
ok, message = manager.connect_to_network(ssid, password)
assert ok is False
assert message
run.assert_not_called()
class TestConnectEndpointSurfacesTheRefusal:
URL = "/api/v3/wifi/connect"
@pytest.fixture
def wifi_manager(self):
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
cls.return_value = instance
yield instance
def test_a_rejected_ssid_comes_back_as_a_client_error(
self, api_v3_client, wifi_manager
):
# The route delegates the shape check to the manager, so mirror what
# the real one now returns rather than asserting on a mock's default.
wifi_manager.connect_to_network.return_value = (False, "SSID cannot start with '-'")
response = api_v3_client.post(self.URL, json={"ssid": "--ask", "password": "hunter22"})
assert response.status_code == 400
assert "-" in response.get_json()["message"]
def test_an_ordinary_request_is_unaffected(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
assert response.status_code == 200
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
@@ -139,6 +139,58 @@ class TestRequestValidation:
assert post(client, options="{}").status_code == 200
assert restore.call_args[0][2].restore_config is True
def test_unknown_option_key_is_refused(self, client, restore):
# Regression: opts_dict.get('restore_secrets', True) silently
# ignores a typo'd/renamed key like "restoreSecrets" and keeps the
# True default, restoring secrets a caller's request clearly meant
# to exclude -- with no indication anything was wrong.
response = post(client, options=json.dumps({"restoreSecrets": False}))
assert response.status_code == 400
assert "Unknown restore option" in response.get_json()["message"]
assert "restoreSecrets" in response.get_json()["message"]
restore.assert_not_called()
def test_known_and_unknown_keys_together_are_refused(self, client, restore):
response = post(client, options=json.dumps({
"restore_secrets": False, "restore_everything": True}))
assert response.status_code == 400
restore.assert_not_called()
class TestOptionsAreBooleanAware:
"""Regression: bool("false") is True in Python.
Every restore flag used bare bool() coercion, so a caller that sends its
options as JSON strings rather than real booleans -- a form field, a
hand-built request -- had `{"restore_secrets": "false"}` restore secrets
anyway, the opposite of what was asked. Fixed with the same
string-aware `_coerce_to_bool` already used for checkbox-style config
fields elsewhere in this package (config.py, plugins.py).
"""
@pytest.mark.parametrize("raw,expected", [
("false", False), ("False", False), ("FALSE", False),
("0", False),
("true", True), ("True", True), ("1", True),
])
def test_string_valued_flags_are_parsed_not_just_truthy(
self, client, restore, raw, expected):
post(client, options=json.dumps({"restore_secrets": raw}))
assert restore.call_args[0][2].restore_secrets is expected
def test_a_string_false_does_not_restore_secrets(self, client, restore):
# The exact shape of the bug: a truthy non-empty string coerced by
# bare bool() to True regardless of its contents.
post(client, options=json.dumps({"restore_secrets": "false"}))
assert restore.call_args[0][2].restore_secrets is False
def test_real_json_booleans_still_work(self, client, restore):
post(client, options=json.dumps({"restore_secrets": False,
"restore_config": True}))
options = restore.call_args[0][2]
assert options.restore_secrets is False
assert options.restore_config is True
class TestSuccess:
def test_success_returns_the_result(self, client, restore):
@@ -230,7 +282,7 @@ class TestPluginReinstall:
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager = None
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None):
body = post(client).get_json()
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
@@ -77,7 +77,10 @@ class TestTheRoutesExistAtAll:
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
# api_v3 is a package now, so the route strings are spread across its
# modules; read the whole directory rather than one file.
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
assert "'/plugins/calendar/list-calendars'" in source
assert "'/plugins/calendar/authenticate'" in source
@@ -360,6 +363,22 @@ class TestDiagnosticsAreRedacted:
assert 'hunter2' not in error, error
assert '<redacted>' in error, error
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
# Regression: the return value went through redact_text (asserted
# above), but the logger.error call right next to it logged `raw`
# verbatim -- a script that handles OAuth client secrets and can
# quote them in its stderr, landing unredacted in the log (CWE-532).
script = tmp_path / 'calendar_registration.py'
script.write_text(
'import sys\n'
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
encoding='utf-8')
with caplog.at_level('ERROR', logger=mod.logger.name):
mod._run_calendar_registration(tmp_path, '')
logged = '\n'.join(r.getMessage() for r in caplog.records)
assert 'hunter2' not in logged, logged
assert '<redacted>' in logged, logged
def test_a_failing_script_payload_is_redacted(self, client):
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
(client.plugin_dir / 'calendar_registration.py').write_text(
@@ -10,8 +10,8 @@ from pathlib import Path
import pytest
SOURCE = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
API_V3_PKG = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3")
#: Objects that still hold submitted secret values at the point these log
#: calls run. Interpolating one whole into a log message leaks credentials.
@@ -19,7 +19,7 @@ UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
def _logging_lines():
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
@@ -15,8 +15,18 @@ from pathlib import Path
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
API_V3_PATH = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
API_V3_PKG = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3")
def _api_v3_source() -> str:
"""Every module of the api_v3 package as one string.
It used to be a single file; the inline copies this guards against could
now reappear in any module of the package.
"""
return "\n".join(p.read_text(encoding="utf-8")
for p in sorted(API_V3_PKG.glob("*.py")))
# The migration is complete: any inline reimplementation is a regression.
EXPECTED_INLINE_COPIES = 0
@@ -24,7 +34,7 @@ EXPECTED_INLINE_COPIES = 0
class TestNoInlineCopies:
def _count(self, name: str) -> int:
source = API_V3_PATH.read_text(encoding="utf-8")
source = _api_v3_source()
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
def test_no_inline_find_secret_fields(self):
@@ -46,7 +56,7 @@ class TestNoInlineCopies:
def test_canonical_import_present(self):
# Tripwire: the endpoints still need the helpers, so removing the
# import means either dead secret handling or a new local copy.
source = API_V3_PATH.read_text(encoding="utf-8")
source = _api_v3_source()
assert re.search(
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
source,
@@ -63,14 +63,14 @@ class TestRoutesAreRegistered:
class TestInstallPixlet:
def test_it_does_not_404(self, client):
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
resp = client.post('/api/v3/starlark/install-pixlet')
assert resp.status_code != 404, "the route is still missing"
assert resp.get_json().get('message') != 'Resource not found'
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
resp = client.post('/api/v3/starlark/install-pixlet')
body = resp.get_json()
@@ -78,7 +78,7 @@ class TestInstallPixlet:
assert 'message' in body, "the JS shows data.message on success"
def test_a_failed_download_says_why(self, client):
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
resp = client.post('/api/v3/starlark/install-pixlet')
body = resp.get_json()
@@ -88,7 +88,7 @@ class TestInstallPixlet:
def test_a_timeout_is_reported_rather_than_hanging(self, client):
import subprocess as sp
with patch('web_interface.blueprints.api_v3.subprocess.run',
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run',
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
resp = client.post('/api/v3/starlark/install-pixlet')
assert resp.get_json()['status'] == 'error'
@@ -395,6 +395,164 @@ class TestTheManifestStaysRelocatable:
assert self._install(tmp_path)['star_file'] == 'demo.star'
class TestManifestLockPreventsLostUpdates:
"""The standalone manifest fallback (no plugin instance loaded) reads,
mutates and writes manifest.json with no coordination across requests.
Each write is atomic on its own (temp file + rename), but two concurrent
read-modify-write cycles can still race: both read the same starting
manifest, and the second write silently discards whatever the first one
added. _starlark_manifest_lock closes that window -- mirrors
StarlarkAppsPlugin._update_manifest_safe, which already does this when
the plugin instance is loaded.
"""
@pytest.fixture
def starlark_dir(self, tmp_path, monkeypatch):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
module._write_starlark_manifest({'apps': {}})
return apps_dir
def test_the_locked_file_survives_a_manifest_write(self, starlark_dir):
"""_write_starlark_manifest replaces manifest.json with a fresh inode
on every write (temp file + rename). If the lock were taken on that
same file, a second locker's fresh os.open() right after the rename
would land on the new inode -- unguarded, because only the old,
now-orphaned inode was ever locked -- and two writers could race
despite each believing it "held the lock" (see the docstring on
_starlark_manifest_lock). Locking a sidecar path that no write ever
touches or renames over closes that: the inode identity of what gets
locked must not change across writes.
"""
import os
from web_interface.blueprints import api_v3 as module
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
lock_ino_before = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
for app_id in ('one', 'two', 'three'):
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
assert module._write_starlark_manifest(manifest)
lock_ino_after = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
assert lock_ino_after == lock_ino_before, (
"the locked file's inode changed across writes -- a locker that "
"opened it before this write and one that opens it after would "
"no longer contend for the same lock")
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
import threading
import time as _time
from web_interface.blueprints import api_v3 as module
def add_app(app_id):
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
# Widen the window between read and write. Without the lock
# both threads read here before either writes, and whichever
# writes second overwrites the other's addition; with the
# lock, the second thread cannot even start its read until
# the first has written and released.
_time.sleep(0.05)
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
module._write_starlark_manifest(manifest)
threads = [threading.Thread(target=add_app, args=(app_id,))
for app_id in ('a', 'b')]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
manifest = module._read_starlark_manifest()
assert set(manifest['apps']) == {'a', 'b'}, (
"a concurrent update was lost: %r" % (manifest,))
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
"""Not reentrant within one thread -- just that using it twice in a
row (the ordinary case: one request, then the next) works cleanly
and does not leak the lock file descriptor or leave it locked."""
from web_interface.blueprints import api_v3 as module
for app_id in ('first', 'second'):
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
module._write_starlark_manifest(manifest)
manifest = module._read_starlark_manifest()
assert set(manifest['apps']) == {'first', 'second'}
class TestConfigAndManifestStayInSync:
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
loaded) writes config.json and then the manifest. If the manifest write
fails after config.json was already written, the two disagree about
what was saved unless config.json is rolled back.
"""
@pytest.fixture
def app_dir(self, tmp_path, monkeypatch):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
one_app_dir = apps_dir / 'demo'
one_app_dir.mkdir()
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
return one_app_dir
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
config_file = app_dir / 'config.json'
config_file.write_text(json.dumps({'existing': 'value'}))
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
resp = client.put('/api/v3/starlark/apps/demo/config',
json={'new_field': 'x'})
assert resp.status_code == 500
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
"config.json kept the new value even though the manifest write "
"that was supposed to follow it failed")
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
config_file = app_dir / 'config.json'
assert not config_file.exists()
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
resp = client.put('/api/v3/starlark/apps/demo/config',
json={'new_field': 'x'})
assert resp.status_code == 500
assert not config_file.exists(), (
"config.json was left behind even though the manifest write "
"that was supposed to follow it failed")
def test_success_updates_both_config_and_manifest(self, client, app_dir):
from web_interface.blueprints import api_v3 as module
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
resp = client.put('/api/v3/starlark/apps/demo/config',
json={'new_field': 'x'})
assert resp.status_code == 200, resp.get_json()
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
assert manifest['apps']['demo']['config']['new_field'] == 'x'
# ---------------------------------------------------------------------------
# The store loaded, then stopped loading, and nothing anywhere said why.
#
@@ -748,3 +906,66 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
client.get('/api/v3/starlark/repository/browse')
repo.assert_called_once_with(github_token='ghp_configured')
class TestPixletEditorHostDefaultsButDoesNotOverride:
"""PIXLET_EDITOR_HOST must default to 0.0.0.0, never force it.
A browser reaching the editor is remote by definition, so a session with
nothing configured has to bind more than loopback to be reachable at
all -- but an operator who has deliberately pinned PIXLET_EDITOR_HOST to
loopback (e.g. in the systemd unit's Environment=, to edit only over an
SSH tunnel) must keep that setting. The previous unconditional
``env['PIXLET_EDITOR_HOST'] = '0.0.0.0'`` overrode it every time,
always exposing the unauthenticated ``pixlet serve`` dev process on the
LAN regardless (CodeQL CWE-1188).
"""
@pytest.fixture
def app_dir(self, tmp_path):
d = tmp_path / "demo_app"
d.mkdir()
(d / "demo_app.star").write_text("def main():\n pass\n")
return d
def _start(self, client, app_dir, tmp_path, operator_host):
from web_interface.blueprints.api_v3 import starlark as mod
script = tmp_path / "pixlet_config_editor.sh"
script.write_text("#!/bin/bash\n")
state_file = tmp_path / "pixlet_editor_state.json"
captured = {}
class FakeProcess:
pid = 424242
def fake_popen(cmd, *args, env=None, **kwargs):
if env is not None:
captured['env'] = env
return FakeProcess()
with patch.object(mod, '_validate_starlark_app_path',
return_value=(app_dir, None)), \
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
patch.dict(os.environ):
if operator_host is None:
os.environ.pop('PIXLET_EDITOR_HOST', None)
else:
os.environ['PIXLET_EDITOR_HOST'] = operator_host
resp = client.post('/api/v3/starlark/editor/start',
json={'app_id': app_dir.name})
assert resp.status_code == 200, resp.get_json()
assert 'env' in captured, "subprocess.Popen was never called"
return captured['env']
def test_defaults_to_0_0_0_0_when_operator_set_nothing(self, client, app_dir, tmp_path):
env = self._start(client, app_dir, tmp_path, operator_host=None)
assert env['PIXLET_EDITOR_HOST'] == '0.0.0.0'
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1'
@@ -19,7 +19,12 @@ import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
def _api_v3_source() -> str:
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
@@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
def test_every_sudo_systemctl_call_is_granted() -> None:
calls = _sudo_systemctl_calls(API_V3.read_text())
calls = _sudo_systemctl_calls(_api_v3_source())
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
assert calls, "expected to find sudo systemctl calls in api_v3.py"
@@ -68,7 +73,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
def test_units_are_fully_qualified() -> None:
"""Privileged systemctl calls must name the unit as <name>.service so they
match the sudoers grants, which use the fully-qualified unit name."""
calls = _sudo_systemctl_calls(API_V3.read_text())
calls = _sudo_systemctl_calls(_api_v3_source())
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
assert not unqualified, (
"sudo systemctl calls must use fully-qualified .service unit names: "