The /etc/issue banner on both the live ISO and the installed system was a oneshot after network-online.target that simply dropped the http://<ip> line when DHCP hadn't delivered yet — so a slow NIC (first hardware bench, 2026-08-24) left the screen offering only proksi.local. On the ISO, releng's root autologin on tty1 also made agetty --reload a no-op. - furtka-issue.timer (ISO) / furtka-welcome.timer (target) re-run the banner script every 5 s; services drop RemainAfterExit + network wait - fallback line is always present: every global IPv4, or a 'no IP yet — check cable / DHCP' hint; only rewrites + reloads on change - build.sh removes releng's getty@tty1 autologin drop-in; banner tells users a shell is root + Enter away Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
280 lines
13 KiB
Python
280 lines
13 KiB
Python
"""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/<ver>/,
|
|
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 <<EOF" in cmd
|
|
assert '"hostname": "testhost"' in cmd
|
|
assert "date -Iseconds" in cmd
|
|
assert "/opt/furtka/current/VERSION" in cmd
|
|
|
|
|
|
def test_resource_manager_systemctl_links_every_unit(install_cmds):
|
|
link_cmd = next((c for c in install_cmds if c.startswith("systemctl link ")), None)
|
|
assert link_cmd is not None, "no systemctl link command"
|
|
for unit in app._FURTKA_UNITS:
|
|
assert f"/opt/furtka/current/assets/systemd/{unit}" in link_cmd
|
|
|
|
|
|
def test_resource_manager_enables_all_units(install_cmds):
|
|
enable_cmd = next((c for c in install_cmds if c.startswith("systemctl enable ")), None)
|
|
assert enable_cmd is not None
|
|
for unit in app._FURTKA_UNITS:
|
|
assert unit in enable_cmd
|
|
|
|
|
|
def test_wrapper_script_points_at_current_symlink():
|
|
assert "PYTHONPATH=/opt/furtka/current" in app._FURTKA_WRAPPER_SH
|
|
|
|
|
|
def test_caddyfile_asset_serves_from_current():
|
|
caddy = (ASSETS / "Caddyfile").read_text()
|
|
assert "root * /opt/furtka/current/assets/www" in caddy
|
|
assert "/srv/furtka/www" not in caddy
|
|
# Runtime JSON paths served from /var/lib/furtka/ so updates don't clobber.
|
|
assert "root * /var/lib/furtka" in caddy
|
|
|
|
|
|
def _strip_caddy_comments(text: str) -> 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_welcome_banner_is_timer_driven():
|
|
"""The console banner must keep refreshing so the IP fallback is always
|
|
on screen. A oneshot with RemainAfterExit=yes never re-runs from a timer
|
|
(the unit stays 'active'), and a network-online dependency would delay the
|
|
first paint — both regressions the hardware bench hit in 26.19."""
|
|
timer = (ASSETS / "systemd" / "furtka-welcome.timer").read_text()
|
|
assert "OnUnitActiveSec=" in timer
|
|
assert "furtka-welcome.timer" in app._FURTKA_UNITS
|
|
service = (ASSETS / "systemd" / "furtka-welcome.service").read_text()
|
|
assert "RemainAfterExit" not in service
|
|
assert "network-online.target" not in service
|
|
|
|
|
|
def test_welcome_banner_always_prints_fallback_line():
|
|
script = (ASSETS / "bin" / "furtka-welcome").read_text()
|
|
assert "no IP address yet" in script
|
|
assert "fallback" in script
|
|
|
|
|
|
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")
|