Files
LEDMatrix/test/test_backup_manager.py
T
ChuckBuildsandClaude Opus 5 ffd706a454 fix(backup): make a restored device match the one that was backed up
Found by wiping a working device and reinstalling from scratch. Every
problem here is invisible until you actually do that, which is why a
green test suite and eleven hours of uptime had not surfaced any of them.

**Four enabled plugins vanished on restore.** Weather, stocks, music and
leaderboard have a registry `id` that differs from the `id` in their own
manifest: the registry calls them `weather`, everything else calls them
`ledmatrix-weather`. Installation already prefers the manifest id for the
directory name and warns when the two disagree, so on disk, in
config.json and in a backup they are `ledmatrix-weather` -- but nothing
resolved that in reverse. Restore asked the store for `ledmatrix-weather`
and got "Plugin not found in registry", four times, and the device came
back missing four plugins the user had enabled.

Registry lookup now falls back to matching `plugin_path`, which already
records `plugins/ledmatrix-weather`. Renaming the published ids would
have orphaned `plugin_state.json` entries keyed on the old ones. Exact id
still wins, so a path that collides with another entry's id cannot
shadow it. Against the live registry and a real 28-plugin install this
takes unresolvable directories from five to one -- the one being
starlark-apps, which is genuinely not in the registry.

**Secrets could not be restored at all.** A fresh install left
config_secrets.json group-readable but not group-writable, and the web
interface -- which is what performs a restore -- does not necessarily run
as the owner. Every other file in the backup restored; secrets failed
with EACCES. Now group-writable, so the account running the web UI can
put them back.

**A partial restore reported "Restore had errors" and nothing else.**
That is the same message whether the whole thing failed or it quietly
dropped your API keys. It now names what was restored, what failed, and
which plugins were not reinstalled.

**ytm_auth.json was never in the backup.** It sits in config/ beside the
three files that are, and is pure device-local auth: losing it silently
signs the user out of YouTube Music. Backed up and restored with the
wifi config, which it resembles.

**Backups were written inside the directory a reinstall deletes.**
config/backups/exports is destroyed by the reinstall the user was told to
make it before. Exports now go beside the install, falling back to the
old path when that is not writable.

**The installer reboots without asking in non-interactive mode**, which
the README did not mention -- easy to hit when piping the install, and
alarming when a device you are installing onto disappears. Documented,
with --no-reboot-prompt. Its log also claimed root:ledmatrix while
printing a hardcoded group name rather than the one it used.

Tests: registry resolution gets its own suite, including the collision
case and third-party entries with an empty plugin_path. The existing
round-trip test passed throughout this because its fixture plugin has a
directory name equal to its id -- the one shape that cannot fail -- so it
now carries ytm_auth too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-06 13:07:55 -04:00

296 lines
11 KiB
Python

"""Tests for src.backup_manager."""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
import pytest
from src import backup_manager
from src.backup_manager import (
BUNDLED_FONTS,
SCHEMA_VERSION,
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",
)
# Device-local auth that lives in config/ like the three above. It was
# omitted from backups, so a restore silently signed the user out of
# YouTube Music and they had to re-authenticate by hand.
(root / "config" / "ytm_auth.json").write_text(
json.dumps({"token": "YTM-TOKEN"}),
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(
{
"version": 1,
"states": {
"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": SCHEMA_VERSION, "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"
assert "ytm_auth" in result.restored
restored_ytm = json.loads((empty_project / "config" / "ytm_auth.json").read_text())
assert restored_ytm["token"] == "YTM-TOKEN"
# 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": SCHEMA_VERSION, "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)