mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): mask the Config Editor's secrets like GET /config/secrets The Config Editor tab (/partials/raw-json) filled its config_secrets.json editor with the file as it is on disk. GET /api/v3/config/secrets masks every value because the interface is reachable without a login by default, but this page handed the same credentials (GitHub token, Home Assistant token, plugin API keys) to anyone who loaded it. The masked-save path in save_raw_secrets_config was written for a masked editor and never got one. _load_raw_json_partial now masks the section with mask_all_secret_values after strip_auth_section, exactly as the GET does. Saving it back is safe: save_raw_secrets_config drops the masks (strip_masked_values) and merges the rest onto the stored file (deep_merge), so an untouched secret stays as it is and a replaced mask is the only value that changes. The config.json editor is left as it is. Its save (save_raw_main_config) writes the posted object verbatim, with no mask stripping or merge, so a masked main editor would write the bullets over any credential it holds. Masking it needs a merge-on-save of its own first. Tests: TestConfigEditorRoundTrip renders the partial over a real ConfigManager, checks no real value is in the editor, and posts the editor back unchanged (the file is identical) and with one mask replaced (only that value changes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions PluginOrderList draws one row per enabled plugin and, once drawn, rewrites its hidden inputs (plugin_rotation_order, vegas_plugin_order, vegas_excluded_plugins) from those rows. A disabled plugin has no row, so merely opening the Display or Rotation & Durations tab took it out of the inputs, and the next save of that form stored the lists without it. Exclude Clock from Vegas, disable it, change the brightness, re-enable it: Clock was scrolling in Vegas again and had moved to the end of the rotation. syncInputs now keeps the saved ids that have no row. In the order, each one keeps its saved slot and the rows fill the other slots in their current order, with rows not in the saved order last, as before. In the exclusions they follow the unchecked rows. Only string ids are carried over, once each: /config/main refuses a list holding anything else, which would block every later save of the tab. Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids) and is in run_all.js and the README. The durations DOM suite now reads only its own rows' ids from the input, since a rig's saved order can hold others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore reinstalls only the plugins that are missing POST /backup/restore with reinstall_plugins (the "Reinstall missing plugins" box) passed every plugin in the backup's plugins.json to install_plugin(). That replaces an installed copy with a fresh download, so a restore onto the same device re-downloaded every plugin inside the request. A plugin installed from its own URL is not in the registry, so its install returned False, plugins_failed set success to False, and the restore answered 500 "Restore incomplete ... plugins not reinstalled: <id>" (shown as "Restore failed") with the plugin still installed and the config restored. Each plugin is now looked up first with the store's _existing_install, the same lookup install_plugin makes to decide a copy exists: the id, or an id the registry proves is the same plugin (aliases, the plugin_path name), and never a bare ledmatrix-<id> folder (#686). One that is installed is recorded in result.skipped as "plugin:<id> (installed)", which the page lists under Skipped; a missing one is installed as before. The list_installed_plugins docstring said every listed plugin is reinstalled and now says otherwise. Tests: TestInstalledPluginsAreNotReinstalled, with a mocked store (installed skipped, missing installed; an installed plugin the store can't install is not a failure) and with a real PluginStoreManager (a registry alias and a third-party install are skipped, a missing plugin installed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): /config/main answers malformed JSON with a 400 save_main_config read a JSON body with request.get_json(), which raises Werkzeug's BadRequest for a body that does not parse (or an empty one sent as application/json). That happened inside the handler's try, so the catch-all answered 500 CONFIG_SAVE_FAILED with "Check file permissions on config directory" among its suggested fixes and logged a traceback at ERROR, for what was the caller's mistake. It now reads with get_json(silent=True), as save_raw_main_config does, and answers a sent-but-unparseable body with the same 400 {"status": "error", "message": "Invalid JSON in request body"}. An empty JSON body falls through to the existing 400 "No data provided". The change is limited to the lines that read the body. Tests: TestMalformedBody in test_api_v3_partial_main_save.py (the 400 and its shape, identical to /config/raw/main's, and nothing saved; the empty body). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore that brings back fonts clears the font catalog cache GET /api/v3/fonts/catalog caches its answer as fonts_catalog for five minutes. Font upload and delete clear that entry (fonts.py), but POST /backup/restore copies user fonts into assets/fonts without touching it, so restored fonts were missing from the Fonts tab and every font picker until the cache expired. backup_restore now clears fonts_catalog when the result lists restored fonts (restore_backup records them as "fonts (<count>)"). A restore that restored no fonts leaves the cache alone. Tests: TestFontsCatalogCache in test_api_v3_backup_restore.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): drop uninstalled plugins from the carried-over order and exclusions2b34f254made the plugin order list keep every saved id that has no row, so a disabled plugin keeps its rotation slot and Vegas exclusion. That also kept the ids of plugins that have since been uninstalled: they stayed in plugin_rotation_order and vegas_excluded_plugins for good, where before the next save of the tab dropped them. The widget already fetches /api/v3/plugins/installed, every installed plugin with its enabled flag, and draws only the enabled ones. It now keeps that response's full id set and carries over only saved ids that are installed but have no row (disabled). An id outside the set is dropped, as before. With no list, nothing is dropped: a failed request draws no rows and leaves the inputs as saved, and the carry-over keeps everything if the set was never filled. Tests: test/js/unit/test_plugin_order_list.js adds a disabled plugin kept while an uninstalled one is dropped (order and exclusions; fails on2b34f254), and a failed plugin list leaving both inputs as saved. The CHANGELOG bullet and the README row say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(js): register the order-list suite apart from other branches' suites Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
419 lines
18 KiB
Python
419 lines
18 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 = []
|
|
self.skipped = []
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"success": self.success,
|
|
"restored": self.restored,
|
|
"skipped": self.skipped,
|
|
"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 TestInstalledPluginsAreNotReinstalled:
|
|
""""Reinstall missing plugins" installs only what is missing.
|
|
|
|
Every plugin the backup listed went to install_plugin, which replaces an
|
|
installed copy with a fresh download: restoring onto the same device
|
|
re-downloaded all of them inside the request. One installed from its own
|
|
URL is not in the registry, so its "reinstall" returned False and the
|
|
whole restore answered 500 "Restore failed" with the plugin still there.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _installed(tmp_path, *names):
|
|
found = {}
|
|
for name in names:
|
|
(tmp_path / name).mkdir()
|
|
found[name] = tmp_path / name
|
|
return lambda plugin_id: found.get(plugin_id)
|
|
|
|
def test_an_installed_plugin_is_skipped_and_a_missing_one_installed(
|
|
self, client, restore, tmp_path):
|
|
restore.return_value = FakeResult(
|
|
plugins_to_install=[{"plugin_id": "clock"}, {"plugin_id": "weather"}])
|
|
store = api_v3.plugin_store_manager
|
|
store._existing_install.side_effect = self._installed(tmp_path, "clock")
|
|
store.install_plugin.return_value = True
|
|
response = post(client)
|
|
assert response.status_code == 200
|
|
store.install_plugin.assert_called_once_with("weather")
|
|
data = response.get_json()["data"]
|
|
assert data["plugins_installed"] == ["weather"]
|
|
assert data["plugins_failed"] == []
|
|
assert "plugin:clock (installed)" in data["skipped"]
|
|
|
|
def test_an_installed_plugin_the_store_cannot_install_is_not_a_failure(
|
|
self, client, restore, tmp_path):
|
|
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "my-3p"}])
|
|
store = api_v3.plugin_store_manager
|
|
store._existing_install.side_effect = self._installed(tmp_path, "my-3p")
|
|
store.install_plugin.return_value = False
|
|
response = post(client)
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"]["plugins_failed"] == []
|
|
store.install_plugin.assert_not_called()
|
|
|
|
@pytest.fixture
|
|
def real_store(self, tmp_path):
|
|
from src.plugin_system.store_manager import PluginStoreManager
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
for folder, manifest_id in (("ledmatrix-weather", "ledmatrix-weather"),
|
|
("my-3p", "my-3p")):
|
|
(plugins_dir / folder).mkdir(parents=True)
|
|
(plugins_dir / folder / "manifest.json").write_text(
|
|
json.dumps({"id": manifest_id, "version": "1.0.0"}))
|
|
store = PluginStoreManager(plugins_dir=str(plugins_dir),
|
|
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
|
# The official weather plugin's registry id differs from the id it
|
|
# installs under; my-3p was installed from its own URL.
|
|
registry = {"plugins": [{
|
|
"id": "weather", "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
|
"plugin_path": "plugins/ledmatrix-weather"}]}
|
|
store.registry_cache = registry
|
|
store.fetch_registry = lambda *a, **k: registry
|
|
store.install_plugin = MagicMock(return_value=True)
|
|
api_v3.plugin_store_manager = store
|
|
return store
|
|
|
|
def test_with_the_real_store_aliases_and_third_party_installs_count(
|
|
self, client, restore, real_store):
|
|
restore.return_value = FakeResult(plugins_to_install=[
|
|
{"plugin_id": "weather"}, {"plugin_id": "my-3p"}, {"plugin_id": "clock"}])
|
|
response = post(client)
|
|
assert response.status_code == 200
|
|
real_store.install_plugin.assert_called_once_with("clock")
|
|
skipped = response.get_json()["data"]["skipped"]
|
|
assert "plugin:weather (installed)" in skipped
|
|
assert "plugin:my-3p (installed)" in skipped
|
|
|
|
|
|
class TestFontsCatalogCache:
|
|
"""The Fonts tab's catalog is cached for 5 minutes (fonts.py).
|
|
|
|
Upload and delete clear it; a restore did not, so restored fonts were
|
|
missing from the Fonts tab and every font picker until it expired.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def cached_catalog(self):
|
|
from web_interface.cache import delete_cached, get_cached, set_cached
|
|
set_cached('fonts_catalog', {'fonts': ['5x7.bdf']}, ttl_seconds=300)
|
|
yield lambda: get_cached('fonts_catalog', ttl_seconds=300)
|
|
delete_cached('fonts_catalog')
|
|
|
|
def test_a_restore_that_restored_fonts_clears_it(self, client, restore, cached_catalog):
|
|
restore.return_value = FakeResult(restored=["config", "fonts (2)"])
|
|
assert post(client).status_code == 200
|
|
assert cached_catalog() is None
|
|
|
|
def test_a_restore_without_fonts_keeps_it(self, client, restore, cached_catalog):
|
|
restore.return_value = FakeResult(restored=["config"])
|
|
assert post(client).status_code == 200
|
|
assert cached_catalog() == {'fonts': ['5x7.bdf']}
|
|
|
|
|
|
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"
|