mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
feat(web): add config backup & restore UI
Adds a Backup & Restore tab to the v3 web UI that packages user config, secrets, WiFi, user-uploaded fonts, plugin image uploads, and the installed plugin list into a single ZIP for safe reinstall recovery. Restore extracts the bundle, snapshots current state via the existing atomic config manager (so rollback stays available), reapplies the selected sections, and optionally reinstalls missing plugins from the store. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
283
test/test_backup_manager.py
Normal file
283
test/test_backup_manager.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Tests for src.backup_manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import backup_manager
|
||||
from src.backup_manager import (
|
||||
BUNDLED_FONTS,
|
||||
RestoreOptions,
|
||||
create_backup,
|
||||
list_installed_plugins,
|
||||
preview_backup_contents,
|
||||
restore_backup,
|
||||
validate_backup,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_project(root: Path) -> Path:
|
||||
"""Build a minimal fake project tree under ``root``."""
|
||||
(root / "config").mkdir(parents=True)
|
||||
(root / "config" / "config.json").write_text(
|
||||
json.dumps({"web_ui": {"port": 8080}, "my-plugin": {"enabled": True, "favorites": ["A", "B"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "config" / "config_secrets.json").write_text(
|
||||
json.dumps({"ledmatrix-weather": {"api_key": "SECRET"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "config" / "wifi_config.json").write_text(
|
||||
json.dumps({"ap_mode": {"ssid": "LEDMatrix"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
fonts = root / "assets" / "fonts"
|
||||
fonts.mkdir(parents=True)
|
||||
# One bundled font (should be excluded) and one user-uploaded font.
|
||||
(fonts / "5x7.bdf").write_text("BUNDLED", encoding="utf-8")
|
||||
(fonts / "my-custom-font.ttf").write_bytes(b"\x00\x01USER")
|
||||
|
||||
uploads = root / "assets" / "plugins" / "static-image" / "uploads"
|
||||
uploads.mkdir(parents=True)
|
||||
(uploads / "image_1.png").write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
(uploads / ".metadata.json").write_text(json.dumps({"a": 1}), encoding="utf-8")
|
||||
|
||||
# plugin-repos for installed-plugin enumeration.
|
||||
plugin_dir = root / "plugin-repos" / "my-plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(
|
||||
json.dumps({"id": "my-plugin", "version": "1.2.3"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# plugin_state.json
|
||||
(root / "data").mkdir()
|
||||
(root / "data" / "plugin_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"plugins": {
|
||||
"my-plugin": {"version": "1.2.3", "enabled": True},
|
||||
"other-plugin": {"version": "0.1.0", "enabled": False},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path) -> Path:
|
||||
return _make_project(tmp_path / "src_project")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_project(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "dst_project"
|
||||
root.mkdir()
|
||||
# Pre-seed only the bundled font to simulate a fresh install.
|
||||
(root / "assets" / "fonts").mkdir(parents=True)
|
||||
(root / "assets" / "fonts" / "5x7.bdf").write_text("BUNDLED", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUNDLED_FONTS sanity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bundled_fonts_matches_repo() -> None:
|
||||
"""Every entry in BUNDLED_FONTS must exist on disk in assets/fonts/.
|
||||
|
||||
The reverse direction is intentionally not checked: real installations
|
||||
have user-uploaded fonts in the same directory, and they should be
|
||||
treated as user data (not bundled).
|
||||
"""
|
||||
repo_fonts = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
||||
if not repo_fonts.exists():
|
||||
pytest.skip("assets/fonts not present in test env")
|
||||
on_disk = {p.name for p in repo_fonts.iterdir() if p.is_file()}
|
||||
missing = set(BUNDLED_FONTS) - on_disk
|
||||
assert not missing, f"BUNDLED_FONTS references files not in assets/fonts/: {missing}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview / enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_installed_plugins(project: Path) -> None:
|
||||
plugins = list_installed_plugins(project)
|
||||
ids = [p["plugin_id"] for p in plugins]
|
||||
assert "my-plugin" in ids
|
||||
assert "other-plugin" in ids
|
||||
my = next(p for p in plugins if p["plugin_id"] == "my-plugin")
|
||||
assert my["version"] == "1.2.3"
|
||||
|
||||
|
||||
def test_preview_backup_contents(project: Path) -> None:
|
||||
preview = preview_backup_contents(project)
|
||||
assert preview["has_config"] is True
|
||||
assert preview["has_secrets"] is True
|
||||
assert preview["has_wifi"] is True
|
||||
assert preview["user_fonts"] == ["my-custom-font.ttf"]
|
||||
assert preview["plugin_uploads"] >= 2
|
||||
assert any(p["plugin_id"] == "my-plugin" for p in preview["plugins"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_backup_contents(project: Path, tmp_path: Path) -> None:
|
||||
out_dir = tmp_path / "exports"
|
||||
zip_path = create_backup(project, output_dir=out_dir)
|
||||
assert zip_path.exists()
|
||||
assert zip_path.parent == out_dir
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert "manifest.json" in names
|
||||
assert "config/config.json" in names
|
||||
assert "config/config_secrets.json" in names
|
||||
assert "config/wifi_config.json" in names
|
||||
assert "assets/fonts/my-custom-font.ttf" in names
|
||||
# Bundled font must NOT be included.
|
||||
assert "assets/fonts/5x7.bdf" not in names
|
||||
assert "assets/plugins/static-image/uploads/image_1.png" in names
|
||||
assert "plugins.json" in names
|
||||
|
||||
|
||||
def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
assert manifest["schema_version"] == backup_manager.SCHEMA_VERSION
|
||||
assert "created_at" in manifest
|
||||
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_backup_ok(project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
ok, err, manifest = validate_backup(zip_path)
|
||||
assert ok, err
|
||||
assert err == ""
|
||||
assert "config" in manifest["detected_contents"]
|
||||
assert "secrets" in manifest["detected_contents"]
|
||||
assert any(p["plugin_id"] == "my-plugin" for p in manifest["plugins"])
|
||||
|
||||
|
||||
def test_validate_backup_missing_manifest(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("config/config.json", "{}")
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
assert "manifest" in err.lower()
|
||||
|
||||
|
||||
def test_validate_backup_bad_schema_version(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": 999}))
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
assert "schema" in err.lower()
|
||||
|
||||
|
||||
def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "malicious.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []}))
|
||||
zf.writestr("../../etc/passwd", "x")
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
assert "unsafe" in err.lower()
|
||||
|
||||
|
||||
def test_validate_backup_not_a_zip(tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.zip"
|
||||
p.write_text("hello", encoding="utf-8")
|
||||
ok, err, _ = validate_backup(p)
|
||||
assert not ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_roundtrip(project: Path, empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||
|
||||
assert result.success, result.errors
|
||||
assert "config" in result.restored
|
||||
assert "secrets" in result.restored
|
||||
assert "wifi" in result.restored
|
||||
|
||||
# Files exist with correct contents.
|
||||
restored_config = json.loads((empty_project / "config" / "config.json").read_text())
|
||||
assert restored_config["my-plugin"]["favorites"] == ["A", "B"]
|
||||
|
||||
restored_secrets = json.loads((empty_project / "config" / "config_secrets.json").read_text())
|
||||
assert restored_secrets["ledmatrix-weather"]["api_key"] == "SECRET"
|
||||
|
||||
# User font restored, bundled font untouched.
|
||||
assert (empty_project / "assets" / "fonts" / "my-custom-font.ttf").read_bytes() == b"\x00\x01USER"
|
||||
assert (empty_project / "assets" / "fonts" / "5x7.bdf").read_text() == "BUNDLED"
|
||||
|
||||
# Plugin uploads restored.
|
||||
assert (empty_project / "assets" / "plugins" / "static-image" / "uploads" / "image_1.png").exists()
|
||||
|
||||
# Plugins to install surfaced for the caller.
|
||||
plugin_ids = {p["plugin_id"] for p in result.plugins_to_install}
|
||||
assert "my-plugin" in plugin_ids
|
||||
|
||||
|
||||
def test_restore_honors_options(project: Path, empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
opts = RestoreOptions(
|
||||
restore_config=True,
|
||||
restore_secrets=False,
|
||||
restore_wifi=False,
|
||||
restore_fonts=False,
|
||||
restore_plugin_uploads=False,
|
||||
reinstall_plugins=False,
|
||||
)
|
||||
result = restore_backup(zip_path, empty_project, opts)
|
||||
assert result.success, result.errors
|
||||
assert (empty_project / "config" / "config.json").exists()
|
||||
assert not (empty_project / "config" / "config_secrets.json").exists()
|
||||
assert not (empty_project / "config" / "wifi_config.json").exists()
|
||||
assert not (empty_project / "assets" / "fonts" / "my-custom-font.ttf").exists()
|
||||
assert result.plugins_to_install == []
|
||||
assert "secrets" in result.skipped
|
||||
assert "wifi" in result.skipped
|
||||
|
||||
|
||||
def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []}))
|
||||
zf.writestr("../escape.txt", "x")
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||
# validate_backup catches it before extraction.
|
||||
assert not result.success
|
||||
assert any("unsafe" in e.lower() for e in result.errors)
|
||||
Reference in New Issue
Block a user