furtka/tests/test_webinstaller_assets.py

99 lines
4.1 KiB
Python
Raw Normal View History

refactor(webinstaller): extract inline payload constants to furtka/assets/ Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:08:53 +02:00
"""Asset sourcing tests for webinstaller.
Slice 1a of the self-update refactor moved every inline HTML/CSS/script/unit
file payload out of webinstaller/app.py and into furtka/assets/. These tests
lock the new contract: _post_install_commands() and _resource_manager_commands()
must write files whose content is byte-equal to the on-disk asset. If the
asset tree drifts or a constant sneaks back in, a test breaks loudly.
"""
import base64
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "webinstaller"))
import app # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
ASSETS = REPO_ROOT / "furtka" / "assets"
# (install target path, asset path under furtka/assets/)
ASSET_TARGETS = [
("/etc/caddy/Caddyfile", "Caddyfile"),
("/srv/furtka/www/index.html", "www/index.html"),
("/srv/furtka/www/settings/index.html", "www/settings/index.html"),
("/srv/furtka/www/style.css", "www/style.css"),
("/srv/furtka/www/status.json", "www/status.json"),
("/usr/local/bin/furtka-status", "bin/furtka-status"),
("/usr/local/bin/furtka-welcome", "bin/furtka-welcome"),
("/etc/systemd/system/furtka-status.service", "systemd/furtka-status.service"),
("/etc/systemd/system/furtka-status.timer", "systemd/furtka-status.timer"),
("/etc/systemd/system/furtka-welcome.service", "systemd/furtka-welcome.service"),
]
def _extract_written_content(cmd, target):
"""Pull the base64 payload back out of a _write_file_cmd() shell string.
Shape: `mkdir -p <parent> && printf %s <b64> | base64 -d > <target>[ && chmod ...]`.
"""
match = re.search(r"printf %s (\S+) \| base64 -d > " + re.escape(target), cmd)
assert match, f"cmd didn't look like a write of {target}: {cmd[:100]}"
return base64.b64decode(match.group(1)).decode("utf-8")
@pytest.fixture
def install_cmds():
return app._post_install_commands("testhost")
@pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS)
def test_post_install_writes_asset_from_disk(install_cmds, target, asset_relpath):
expected = (ASSETS / asset_relpath).read_text(encoding="utf-8")
# /srv/furtka/www/index.html carries a one-off hostname sed *after* the
# write; the bytes written match the asset verbatim.
if target == "/srv/furtka/www/index.html":
assert "__HOSTNAME__" in expected # slice-1a invariant: sed still applies
matching = [c for c in install_cmds if f" > {target}" in c]
assert matching, f"no command writes {target}"
assert _extract_written_content(matching[0], target) == expected
def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html")
def test_assets_dir_resolves_to_repo_tree():
# The resolved path must point into the repo's furtka/assets — sanity
# check that the "two levels up from webinstaller/app.py" math is correct.
assert app._ASSETS_DIR == ASSETS
def test_resource_manager_commands_use_asset_units(monkeypatch, tmp_path):
# Create a fake payload so _resource_manager_commands doesn't bail out.
fake = tmp_path / "payload.tar.gz"
fake.write_bytes(b"not a real tarball")
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
cmds = app._resource_manager_commands()
# Expect reconcile + api unit files to be sourced from furtka/assets/systemd/.
for unit in ("furtka-reconcile.service", "furtka-api.service"):
expected = (ASSETS / "systemd" / unit).read_text(encoding="utf-8")
matching = [c for c in cmds if f"/etc/systemd/system/{unit}" in c]
assert matching, f"no command writes {unit}"
assert _extract_written_content(matching[0], f"/etc/systemd/system/{unit}") == expected
def test_version_asset_matches_pyproject():
# Slice 1a ships a VERSION file alongside the assets; it should match the
# single source of truth in pyproject.toml.
import tomllib
with open(REPO_ROOT / "pyproject.toml", "rb") as f:
version = tomllib.load(f)["project"]["version"]
assert (ASSETS / "VERSION").read_text().strip() == version