mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
Stage 2 of the web plugin catalog, after #688. - The display publishes a plugin runtime snapshot (plugin_runtime.py) to the shared cache: per plugin loaded, lifecycle state, a short redacted error summary, the version it loaded and when, plus published_at / stale_after / running. Written on change (throttled to 10 s; the RUNNING/ENABLED flip of an ordinary update is not a change) and once a minute otherwise; cleanup() publishes running: false. - The web reads it back and restores loaded / state / error_info in /api/v3/plugins/installed (plus loaded_version, loaded_at and data.runtime). Only a live snapshot counts; stale, stopped or missing answers null and says which. - data/plugin_state.json is retired: every reader and writer moved to config + disk (desired) or the snapshot (observed). Nothing in it was non-derivable, so nothing is migrated and an existing file is left unread. The web-side PluginStateManager (state_manager.py) is removed; the display's plugin_state.PluginStateManager is the only state machine. - StateReconciliation compares config + disk with the snapshot, reporting enabled-but-not-loaded and older-version-loaded as no_action findings. - Backups list installed manifests with enabled from config.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""
|
|
Endpoint tests for POST /backup/restore.
|
|
|
|
Restore is the most destructive operation the web interface exposes: it
|
|
overwrites config, secrets, WiFi settings and fonts, and reinstalls
|
|
plugins. It had no tests.
|
|
|
|
restore_backup itself is mocked — this file is about what the route does
|
|
with the request and with the result, not about ZIP handling, which
|
|
belongs to backup_manager's own tests.
|
|
|
|
Regression coverage for one fixed bug: a malformed `options` field fell
|
|
back to {}, and since every RestoreOptions flag defaults to True, that
|
|
turned a mis-serialized narrow restore into a full one — secrets
|
|
included — with no indication anything had been ignored.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
project_root = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
|
|
|
URL = "/api/v3/backup/restore"
|
|
|
|
_MANAGER_ATTRS = (
|
|
'config_manager', 'plugin_catalog', 'plugin_store_manager',
|
|
'saved_repositories_manager', 'schema_manager',
|
|
'operation_queue', 'operation_history', 'cache_manager',
|
|
)
|
|
_SENTINEL = object()
|
|
|
|
|
|
class FakeResult:
|
|
"""Stand-in for backup_manager.RestoreResult."""
|
|
|
|
def __init__(self, success=True, restored=None, errors=None,
|
|
plugins_to_install=None):
|
|
self.success = success
|
|
self.restored = restored if restored is not None else ["config"]
|
|
self.errors = errors or []
|
|
self.plugins_to_install = plugins_to_install or []
|
|
self.plugins_installed = []
|
|
self.plugins_failed = []
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"success": self.success,
|
|
"restored": self.restored,
|
|
"errors": self.errors,
|
|
"plugins_installed": self.plugins_installed,
|
|
"plugins_failed": self.plugins_failed,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
originals = {name: getattr(api_v3, name, _SENTINEL) for name in _MANAGER_ATTRS}
|
|
for name in _MANAGER_ATTRS:
|
|
setattr(api_v3, name, MagicMock())
|
|
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
|
yield app.test_client()
|
|
|
|
for name, original in originals.items():
|
|
if original is _SENTINEL:
|
|
if hasattr(api_v3, name):
|
|
delattr(api_v3, name)
|
|
else:
|
|
setattr(api_v3, name, original)
|
|
|
|
|
|
@pytest.fixture
|
|
def restore():
|
|
"""Patch backup_manager.restore_backup (imported inside the handler)."""
|
|
with patch("src.backup_manager.restore_backup") as mock:
|
|
mock.return_value = FakeResult()
|
|
yield mock
|
|
|
|
|
|
def post(client, options=None, filename="backup.zip", content=b"PK\x03\x04fake"):
|
|
data = {"backup_file": (io.BytesIO(content), filename)}
|
|
if options is not None:
|
|
data["options"] = options
|
|
return client.post(URL, data=data, content_type="multipart/form-data")
|
|
|
|
|
|
class TestRequestValidation:
|
|
def test_missing_file_is_a_400(self, client, restore):
|
|
response = client.post(URL, data={}, content_type="multipart/form-data")
|
|
assert response.status_code == 400
|
|
assert "No backup_file" in response.get_json()["message"]
|
|
restore.assert_not_called()
|
|
|
|
def test_absent_options_defaults_to_a_full_restore(self, client, restore):
|
|
# Documented default, not the bug: omitting options entirely means
|
|
# "restore everything".
|
|
post(client)
|
|
options = restore.call_args[0][2]
|
|
assert options.restore_config is True
|
|
assert options.restore_secrets is True
|
|
assert options.reinstall_plugins is True
|
|
|
|
def test_partial_options_are_honoured(self, client, restore):
|
|
post(client, options=json.dumps({
|
|
"restore_secrets": False, "reinstall_plugins": False}))
|
|
options = restore.call_args[0][2]
|
|
assert options.restore_secrets is False
|
|
assert options.reinstall_plugins is False
|
|
assert options.restore_config is True # unspecified stays default
|
|
|
|
@pytest.mark.parametrize("raw", ["{not json", "", "{'single': 'quotes'}"])
|
|
def test_malformed_options_are_refused(self, client, restore, raw):
|
|
# Regression: this fell back to {}, and every flag defaults to
|
|
# True, so a caller asking for a narrow restore and mis-serializing
|
|
# it got a full one — secrets overwritten — and no warning.
|
|
response = post(client, options=raw)
|
|
assert response.status_code == 400
|
|
assert "Invalid options" in response.get_json()["message"]
|
|
restore.assert_not_called()
|
|
|
|
@pytest.mark.parametrize("raw", ["[1,2,3]", '"a string"', "42", "true", "null"])
|
|
def test_options_that_are_not_an_object_are_refused(self, client, restore, raw):
|
|
response = post(client, options=raw)
|
|
assert response.status_code == 400
|
|
restore.assert_not_called()
|
|
|
|
def test_empty_object_is_accepted_as_all_defaults(self, client, restore):
|
|
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):
|
|
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
|
|
response = post(client)
|
|
assert response.status_code == 200
|
|
body = response.get_json()
|
|
assert body["status"] == "success"
|
|
assert body["data"]["restored"] == ["config", "secrets"]
|
|
|
|
def test_temp_file_is_cleaned_up(self, client, restore):
|
|
seen = {}
|
|
|
|
def capture(path, project_root, options):
|
|
seen["path"] = Path(path)
|
|
assert seen["path"].exists() # present while restoring
|
|
return FakeResult()
|
|
|
|
restore.side_effect = capture
|
|
post(client)
|
|
assert not seen["path"].exists()
|
|
|
|
def test_temp_file_cleaned_up_even_when_restore_raises(self, client, restore):
|
|
seen = {}
|
|
|
|
def blow_up(path, project_root, options):
|
|
seen["path"] = Path(path)
|
|
raise RuntimeError("corrupt archive")
|
|
|
|
restore.side_effect = blow_up
|
|
response = post(client)
|
|
assert response.status_code == 500
|
|
assert not seen["path"].exists()
|
|
|
|
|
|
class TestPluginReinstall:
|
|
def test_plugins_are_reinstalled_when_requested(self, client, restore):
|
|
restore.return_value = FakeResult(
|
|
plugins_to_install=[{"plugin_id": "clock"}, {"plugin_id": "weather"}])
|
|
api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
response = post(client)
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"]["plugins_installed"] == ["clock", "weather"]
|
|
|
|
def test_reinstall_skipped_when_not_requested(self, client, restore):
|
|
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
|
post(client, options=json.dumps({"reinstall_plugins": False}))
|
|
api_v3.plugin_store_manager.install_plugin.assert_not_called()
|
|
|
|
def test_entries_without_a_plugin_id_are_skipped(self, client, restore):
|
|
restore.return_value = FakeResult(plugins_to_install=[{}, {"plugin_id": "clock"}])
|
|
api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
post(client)
|
|
assert api_v3.plugin_store_manager.install_plugin.call_count == 1
|
|
|
|
def test_failed_reinstall_turns_the_whole_restore_into_an_error(
|
|
self, client, restore):
|
|
# Pinned as intentional: file restoration succeeded and does not
|
|
# touch result.errors, but a user whose plugins did not come back
|
|
# should not be told the restore was a success.
|
|
restore.return_value = FakeResult(
|
|
success=True, plugins_to_install=[{"plugin_id": "clock"}])
|
|
api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
response = post(client)
|
|
assert response.status_code == 500
|
|
body = response.get_json()
|
|
assert body["status"] == "error"
|
|
assert "clock" in body["message"]
|
|
|
|
def test_message_names_what_landed_and_what_did_not(self, client, restore):
|
|
restore.return_value = FakeResult(
|
|
success=True, restored=["config", "fonts"],
|
|
plugins_to_install=[{"plugin_id": "clock"}])
|
|
api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
message = post(client).get_json()["message"]
|
|
assert "restored: config, fonts" in message
|
|
assert "plugins not reinstalled: clock" in message
|
|
|
|
def test_install_exception_is_recorded_without_leaking_details(
|
|
self, client, restore):
|
|
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
|
api_v3.plugin_store_manager.install_plugin.side_effect = RuntimeError(
|
|
"/srv/internal/path exploded")
|
|
body = post(client).get_json()
|
|
failures = body["data"]["plugins_failed"]
|
|
assert failures[0]["plugin_id"] == "clock"
|
|
assert "/srv/internal/path" not in json.dumps(body)
|
|
|
|
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
|
|
body = post(client).get_json()
|
|
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
|
|
|
|
|
class TestFailureReporting:
|
|
def test_restore_errors_produce_a_500(self, client, restore):
|
|
restore.return_value = FakeResult(
|
|
success=False, restored=[], errors=["config: permission denied"])
|
|
response = post(client)
|
|
assert response.status_code == 500
|
|
assert "permission denied" in response.get_json()["message"]
|
|
|
|
def test_partial_restore_names_both_sides(self, client, restore):
|
|
restore.return_value = FakeResult(
|
|
success=False, restored=["config"], errors=["secrets: unwritable"])
|
|
message = post(client).get_json()["message"]
|
|
assert "restored: config" in message
|
|
assert "failed: secrets: unwritable" in message
|
|
|
|
def test_failure_without_detail_still_says_something(self, client, restore):
|
|
restore.return_value = FakeResult(success=False, restored=[], errors=[])
|
|
message = post(client).get_json()["message"]
|
|
assert "Restore incomplete" in message
|
|
|
|
def test_unexpected_exception_is_a_500(self, client, restore):
|
|
restore.side_effect = RuntimeError("boom")
|
|
response = post(client)
|
|
assert response.status_code == 500
|
|
assert response.get_json()["status"] == "error"
|