"""Asset sourcing + install-flow tests for webinstaller. Phase-2 slice 1a moved every HTML/CSS/script/unit payload out of inline Python string constants and into real files under furtka/assets/. Slice 1b then flipped Caddy to serve from /opt/furtka/current/assets/www/, retired /srv/furtka/www/, and switched systemd units from hand-written files in /etc/systemd/system/ to `systemctl link` against the shipped asset tree. These tests lock the new contract: - the Caddyfile and the status.json placeholder still land via _write_file_cmd and match the on-disk asset bit-for-bit, - the resource-manager bootstrap extracts to /opt/furtka/versions//, creates /opt/furtka/current, and systemctl-links every unit, - furtka.json is written with the installer's hostname, - no write ever targets /srv/furtka/www/ (that path is retired). """ 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 / "assets" # (install target path, asset path under furtka/assets/) — only the files we # still copy bit-for-bit at install time. Scripts + unit files are no longer # copied; they're reached via /opt/furtka/current and `systemctl link`. The # Caddyfile is not in this list because it's written with the hostname # placeholder substituted — see test_post_install_substitutes_hostname_in_caddyfile. ASSET_TARGETS = [ ("/var/lib/furtka/status.json", "www/status.json"), ] def _extract_written_content(cmd, target): """Pull the base64 payload back out of a _write_file_cmd() shell string.""" 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(tmp_path, monkeypatch): # Make _resource_manager_commands return a non-empty list so the full # command tree is exercised. A fake payload file is enough since the # test only inspects the generated shell strings. fake = tmp_path / "payload.tar.gz" fake.write_bytes(b"not a real tarball") monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake) return app._post_install_commands("testhost", "daniel", "test-admin-pw") @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") 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_no_command_writes_to_retired_srv_path(install_cmds): for c in install_cmds: assert "/srv/furtka/www" not in c, f"retired /srv/furtka/www write: {c[:120]}" def test_resource_manager_extracts_to_versioned_slot(install_cmds): extract_cmd = next((c for c in install_cmds if "tar -xzf" in c), None) assert extract_cmd is not None, "no tar extract command" assert "/opt/furtka/versions" in extract_cmd assert "staging-" in extract_cmd # mktemp -d pattern assert 'cat "$staging/VERSION"' in extract_cmd # An empty VERSION file must abort the install instead of silently # moving the staging dir into versions/ as a subdir. assert '[ -n "$ver" ]' in extract_cmd # Version dir must be 755 so Caddy (non-root) can traverse it. assert 'chmod 755 "/opt/furtka/versions/$ver"' in extract_cmd assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd def test_furtka_json_cmd_uses_heredoc_and_interpolates_hostname(install_cmds): # Regression: the previous base64+sed version of this command was a # silent no-op on some installs due to archinstall-side quoting; the # heredoc version is the one that reliably writes furtka.json. cmd = next((c for c in install_cmds if "/var/lib/furtka/furtka.json" in c), None) assert cmd is not None assert "cat > /var/lib/furtka/furtka.json < str: """Remove # comments + blank lines so string-match assertions can target actual Caddyfile directives, not the leading doc block. Comment intro is ``#`` at start-of-line or preceded by whitespace.""" out = [] for line in text.splitlines(): stripped = line.split("#", 1)[0].rstrip() if stripped: out.append(stripped) return "\n".join(out) def test_caddyfile_serves_http_by_default_https_opt_in(): # 26.15-alpha: HTTPS is opt-in. The default Caddyfile has a :80 block # and imports /etc/caddy/furtka-https.d/*.caddyfile at top level — # the /settings HTTPS toggle drops the hostname+tls-internal block # into that dir when the user explicitly enables HTTPS. Default # Caddyfile therefore contains no `tls internal` directive anywhere; # if a future refactor puts it back, every fresh install regresses # to the 26.14-era BAD_SIGNATURE trap. Strip comments first because # the doc-block DOES mention `tls internal` in prose. caddy_full = (ASSETS / "Caddyfile").read_text() caddy = _strip_caddy_comments(caddy_full) assert ":80 {" in caddy assert "tls internal" not in caddy assert "__FURTKA_HOSTNAME__" not in caddy assert "import /etc/caddy/furtka-https.d/*.caddyfile" in caddy # Shared routes still live in a named snippet so the HTTPS toggle's # snippet can import the same routes without duplication. assert "(furtka_routes)" in caddy # Default Caddyfile imports it once (inside :80). The HTTPS snippet, # when written by the toggle, imports it a second time. assert caddy.count("import furtka_routes") == 1 def test_caddyfile_disables_caddy_auto_redirects(): # Named-hostname :443 block makes Caddy want to add its own HTTP→HTTPS # redirect. The /settings toggle is the single source of truth, so the # built-in has to be off — otherwise the toggle and auto_https race. caddy = (ASSETS / "Caddyfile").read_text() assert "auto_https disable_redirects" in caddy def test_caddyfile_imports_force_redirect_snippet_dir(): # The /api/furtka/https/force endpoint toggles HTTP→HTTPS by writing or # removing a snippet file in this dir; the Caddyfile must glob-import it # inside the :80 block for the toggle to take effect. caddy = (ASSETS / "Caddyfile").read_text() assert "import /etc/caddy/furtka.d/*.caddyfile" in caddy def test_caddyfile_exposes_root_ca_download(): # /rootCA.crt is the download handle the UI uses. It must map to the # Caddy local-CA pki path and set a Content-Disposition so the browser # treats it as a download rather than trying to render it. Path is the # real one Caddy uses under XDG_DATA_HOME=/var/lib (see caddy.service # Environment= directive) — not the /var/lib/caddy/.local/share/caddy/ # path Caddy docs show for non-systemd installs. caddy = (ASSETS / "Caddyfile").read_text() assert "handle /rootCA.crt" in caddy assert "/var/lib/caddy/pki/authorities/local" in caddy assert ".local/share/caddy" not in caddy assert "attachment; filename=furtka-local-rootCA.crt" in caddy def test_post_install_writes_caddyfile_without_hostname_placeholder(install_cmds): # 26.15-alpha: the shipped Caddyfile no longer carries the # __FURTKA_HOSTNAME__ marker — HTTPS + hostname now live in the # opt-in snippet written by set_force_https(), not in the base # Caddyfile. Verify the post-install writes the file as-is (no # substitution expected) and it has the opt-in import glob. caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None) assert caddyfile_cmd is not None written_full = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile") written = _strip_caddy_comments(written_full) assert "__FURTKA_HOSTNAME__" not in written assert "import /etc/caddy/furtka-https.d/*.caddyfile" in written assert "tls internal" not in written def test_post_install_creates_https_snippet_dir(install_cmds): # The top-level HTTPS opt-in snippet dir must exist before Caddy's # first start — its glob import tolerates an empty directory, but # not a missing one on older Caddy builds. Parallel guarantee to # test_post_install_creates_furtka_d_snippet_dir below. matching = [c for c in install_cmds if "/etc/caddy/furtka-https.d" in c and "install -d" in c] assert matching, "no install -d command creates /etc/caddy/furtka-https.d" def test_post_install_creates_furtka_d_snippet_dir(install_cmds): # Pre-existing installs pick up the import path via updater._refresh_caddyfile, # but fresh installs never run that — this command is the only guarantee # that the first Caddy start on a brand-new box has a dir to glob-import. matching = [c for c in install_cmds if "/etc/caddy/furtka.d" in c and "install -d" in c] assert matching, "no install -d command creates /etc/caddy/furtka.d" def test_systemd_units_reference_current_paths(): for unit in ("furtka-status.service", "furtka-welcome.service"): body = (ASSETS / "systemd" / unit).read_text() assert "/opt/furtka/current/assets/bin/" in body, ( f"{unit} still references a /usr/local/bin path" ) 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(): assert app._ASSETS_DIR == ASSETS def test_post_install_writes_users_json_with_hashed_password(install_cmds): """The Furtka-admin users.json is created during the chroot post-install. Without this, a fresh-install box lands at /login in first-run setup mode and the user has to go through the browser to set a password — which defeats the "step-1 password works for everything" design. Also check that the file is chmod 0600 (the PBKDF2 hash is a secret even if it's slow to crack). """ import json as _json from werkzeug.security import check_password_hash users_cmd = next((c for c in install_cmds if " > /var/lib/furtka/users.json" in c), None) assert users_cmd is not None, "no command writes /var/lib/furtka/users.json" assert "chmod 600" in users_cmd, "users.json must be chmod 0600" body = _extract_written_content(users_cmd, "/var/lib/furtka/users.json") parsed = _json.loads(body) assert "admin" in parsed assert parsed["admin"]["username"] == "daniel" # matches fixture # Hash is a real werkzeug hash, not the plaintext password. assert parsed["admin"]["hash"] != "test-admin-pw" assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")