mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
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:
@@ -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."""
|
||||
|
||||
Vendored
+973
@@ -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"
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
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 "`, out.includes('"'), 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 &`, /&/.test(out), out);
|
||||
}
|
||||
|
||||
console.log('\n4. ampersands are escaped before quotes, so " cannot be forged');
|
||||
// If `&` were escaped last, the input `"` 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('"'));
|
||||
ok(`${label}: " input stays inert`, out === '&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);
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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 == {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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', '')
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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: "
|
||||
|
||||
Reference in New Issue
Block a user