2026-04-15 10:02:00 +02:00
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from furtka import installer
|
|
|
|
|
from furtka.paths import apps_dir, bundled_apps_dir
|
|
|
|
|
|
|
|
|
|
VALID_MANIFEST = {
|
|
|
|
|
"name": "fileshare",
|
|
|
|
|
"display_name": "Network Files",
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
"description": "SMB share",
|
|
|
|
|
"volumes": ["files"],
|
|
|
|
|
"ports": [445],
|
|
|
|
|
"icon": "icon.svg",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def fake_dirs(tmp_path, monkeypatch):
|
|
|
|
|
apps = tmp_path / "apps"
|
|
|
|
|
bundled = tmp_path / "bundled"
|
|
|
|
|
apps.mkdir()
|
|
|
|
|
bundled.mkdir()
|
|
|
|
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
|
|
|
|
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
|
|
|
|
return apps, bundled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_app_source(root, name, manifest, env_example=None, env=None):
|
|
|
|
|
app = root / name
|
|
|
|
|
app.mkdir()
|
|
|
|
|
(app / "manifest.json").write_text(json.dumps(manifest))
|
|
|
|
|
(app / "docker-compose.yaml").write_text("services: {}\n")
|
|
|
|
|
if env_example is not None:
|
|
|
|
|
(app / ".env.example").write_text(env_example)
|
|
|
|
|
if env is not None:
|
|
|
|
|
(app / ".env").write_text(env)
|
|
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_source_explicit_path(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
resolved = installer.resolve_source(str(src))
|
|
|
|
|
assert resolved == src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_source_bundled_name(fake_dirs):
|
|
|
|
|
_, bundled = fake_dirs
|
|
|
|
|
src = _write_app_source(bundled, "fileshare", VALID_MANIFEST)
|
|
|
|
|
resolved = installer.resolve_source("fileshare")
|
|
|
|
|
assert resolved == src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_source_unknown_name(fake_dirs):
|
|
|
|
|
with pytest.raises(installer.InstallError, match="not found"):
|
|
|
|
|
installer.resolve_source("nope")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_source_path_with_slash_must_exist(fake_dirs):
|
|
|
|
|
with pytest.raises(installer.InstallError, match="not a directory"):
|
|
|
|
|
installer.resolve_source("./does-not-exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_from_copies_files(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=1")
|
|
|
|
|
target = installer.install_from(src)
|
|
|
|
|
assert target == apps_dir() / "fileshare"
|
|
|
|
|
assert (target / "manifest.json").exists()
|
|
|
|
|
assert (target / "docker-compose.yaml").exists()
|
|
|
|
|
assert (target / ".env.example").exists()
|
|
|
|
|
# .env bootstrapped from .env.example since none was shipped
|
|
|
|
|
assert (target / ".env").read_text() == "A=1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
|
|
|
|
|
target = apps_dir() / "fileshare"
|
|
|
|
|
target.mkdir()
|
|
|
|
|
(target / ".env").write_text("A=user-edited")
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
# User .env not clobbered.
|
|
|
|
|
assert (target / ".env").read_text() == "A=user-edited"
|
|
|
|
|
# But .env.example was updated.
|
|
|
|
|
assert (target / ".env.example").read_text() == "A=new"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_from_rejects_missing_manifest(tmp_path, fake_dirs):
|
|
|
|
|
src = tmp_path / "broken"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
with pytest.raises(installer.InstallError, match="manifest.json"):
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:17:00 +02:00
|
|
|
def test_install_from_arbitrary_source_folder_name(tmp_path, fake_dirs):
|
|
|
|
|
# Source folder named "downloaded-fileshare-fork-v2" but manifest says
|
|
|
|
|
# "fileshare" — install lands at /var/lib/furtka/apps/fileshare/ regardless.
|
|
|
|
|
src = _write_app_source(
|
|
|
|
|
tmp_path,
|
|
|
|
|
"downloaded-fileshare-fork-v2",
|
|
|
|
|
VALID_MANIFEST,
|
|
|
|
|
env_example="A=real-value",
|
|
|
|
|
)
|
|
|
|
|
target = installer.install_from(src)
|
|
|
|
|
assert target.name == "fileshare"
|
|
|
|
|
assert (target / "manifest.json").exists()
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
def test_install_from_rejects_invalid_manifest(tmp_path, fake_dirs):
|
|
|
|
|
bad = dict(VALID_MANIFEST)
|
|
|
|
|
del bad["volumes"]
|
|
|
|
|
src = _write_app_source(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(installer.InstallError, match="volumes"):
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_remove_deletes_folder(fake_dirs):
|
|
|
|
|
apps, _ = fake_dirs
|
|
|
|
|
(apps / "fileshare").mkdir()
|
|
|
|
|
(apps / "fileshare" / "manifest.json").write_text("{}")
|
|
|
|
|
installer.remove("fileshare")
|
|
|
|
|
assert not (apps / "fileshare").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_remove_unknown_raises(fake_dirs):
|
|
|
|
|
with pytest.raises(installer.InstallError, match="not installed"):
|
|
|
|
|
installer.remove("ghost")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bundled_apps_dir_uses_env_override(fake_dirs):
|
|
|
|
|
_, bundled = fake_dirs
|
|
|
|
|
assert bundled_apps_dir() == bundled
|
2026-04-15 10:17:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_refuses_placeholder_password(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(
|
|
|
|
|
tmp_path, "fileshare", VALID_MANIFEST, env_example="SMB_PASSWORD=changeme"
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(installer.InstallError, match="placeholder values for SMB_PASSWORD"):
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
# Files should still have landed so the user can vim the .env in place.
|
|
|
|
|
target = apps_dir() / "fileshare"
|
|
|
|
|
assert (target / ".env").exists()
|
|
|
|
|
assert (target / "manifest.json").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_succeeds_after_user_edits_env(tmp_path, fake_dirs):
|
|
|
|
|
# First run: refuses placeholder.
|
|
|
|
|
src = _write_app_source(
|
|
|
|
|
tmp_path, "fileshare", VALID_MANIFEST, env_example="SMB_PASSWORD=changeme"
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(installer.InstallError):
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
# User edits the live .env to a real secret.
|
|
|
|
|
target = apps_dir() / "fileshare"
|
|
|
|
|
(target / ".env").write_text("SMB_PASSWORD=hunter2\n")
|
|
|
|
|
# Re-run: now succeeds, user .env preserved.
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
assert (target / ".env").read_text().strip() == "SMB_PASSWORD=hunter2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_locks_env_permissions(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(
|
|
|
|
|
tmp_path, "fileshare", VALID_MANIFEST, env_example="SMB_PASSWORD=hunter2"
|
|
|
|
|
)
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
target = apps_dir() / "fileshare"
|
|
|
|
|
mode = (target / ".env").stat().st_mode & 0o777
|
|
|
|
|
assert mode == 0o600, f"expected 0o600 on .env, got {oct(mode)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_placeholder_check_ignores_comments_and_blanks(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(
|
|
|
|
|
tmp_path,
|
|
|
|
|
"fileshare",
|
|
|
|
|
VALID_MANIFEST,
|
|
|
|
|
env_example="# default values\n\nSMB_PASSWORD=real-secret\n",
|
|
|
|
|
)
|
|
|
|
|
# Should NOT raise — only commented "changeme" mentions, no actual placeholder.
|
|
|
|
|
installer.install_from(src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_placeholder_check_handles_quoted_values(tmp_path, fake_dirs):
|
|
|
|
|
src = _write_app_source(
|
|
|
|
|
tmp_path,
|
|
|
|
|
"fileshare",
|
|
|
|
|
VALID_MANIFEST,
|
|
|
|
|
env_example='SMB_PASSWORD="changeme"\n',
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(installer.InstallError, match="placeholder"):
|
|
|
|
|
installer.install_from(src)
|