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) 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() 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 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) # --- Settings-driven install ------------------------------------------------- SETTINGS_MANIFEST = dict( VALID_MANIFEST, settings=[ { "name": "SMB_USER", "label": "User", "type": "text", "default": "furtka", "required": True, }, {"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True}, ], ) def test_install_with_settings_writes_env(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST) installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"}) target = apps_dir() / "fileshare" env = (target / ".env").read_text() assert "SMB_USER=daniel" in env assert "SMB_PASSWORD=hunter2" in env def test_install_with_settings_rejects_empty_required(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST) # SMB_PASSWORD has no default and is required — submitting empty is rejected. with pytest.raises(installer.InstallError, match="'SMB_PASSWORD' is required"): installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": ""}) def test_install_with_settings_rejects_unknown_key(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST) with pytest.raises(installer.InstallError, match="unknown setting 'FOO'"): installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b", "FOO": "x"}) def test_install_settings_merge_preserves_unchanged(tmp_path, fake_dirs): # First install with full settings. src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST) installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"}) # Second call with only password — user should keep existing user name. installer.install_from(src, settings={"SMB_PASSWORD": "newpass"}) target = apps_dir() / "fileshare" env = (target / ".env").read_text() assert "SMB_USER=daniel" in env assert "SMB_PASSWORD=newpass" in env def test_install_settings_applies_defaults_on_first_install(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST) # Only password submitted; SMB_USER falls through to its manifest default # ("furtka") and the required check passes because the merged view has it. installer.install_from(src, settings={"SMB_PASSWORD": "hunter2"}) target = apps_dir() / "fileshare" env = (target / ".env").read_text() assert "SMB_USER=furtka" in env assert "SMB_PASSWORD=hunter2" in env def test_install_with_settings_writes_0600(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST) installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b"}) mode = (apps_dir() / "fileshare" / ".env").stat().st_mode & 0o777 assert mode == 0o600 def test_read_env_values_roundtrip(tmp_path, fake_dirs): from furtka.installer import read_env_values, write_env p = tmp_path / ".env" write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}) values = read_env_values(p) assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""} # --- path-type settings ------------------------------------------------------ PATH_MANIFEST = dict( VALID_MANIFEST, name="jellyfin", settings=[ { "name": "MEDIA_PATH", "label": "Medienordner", "type": "path", "required": True, } ], ) OPTIONAL_PATH_MANIFEST = dict( VALID_MANIFEST, name="jellyfin", settings=[{"name": "OPTIONAL_PATH", "label": "Optional", "type": "path", "required": False}], ) def test_install_with_valid_path_succeeds(tmp_path, fake_dirs): media = tmp_path / "media" media.mkdir() src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) installer.install_from(src, settings={"MEDIA_PATH": str(media)}) target = apps_dir() / "jellyfin" assert f"MEDIA_PATH={media}" in (target / ".env").read_text() def test_install_rejects_nonexistent_path(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) with pytest.raises(installer.InstallError, match="does not exist"): installer.install_from(src, settings={"MEDIA_PATH": str(tmp_path / "ghost")}) def test_install_rejects_path_that_is_a_file(tmp_path, fake_dirs): f = tmp_path / "not-a-dir" f.write_text("hi") src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) with pytest.raises(installer.InstallError, match="is not a directory"): installer.install_from(src, settings={"MEDIA_PATH": str(f)}) def test_install_rejects_relative_path(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) with pytest.raises(installer.InstallError, match="absolute path"): installer.install_from(src, settings={"MEDIA_PATH": "media"}) def test_install_rejects_system_path(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) with pytest.raises(installer.InstallError, match="system path"): installer.install_from(src, settings={"MEDIA_PATH": "/etc"}) def test_install_rejects_root_filesystem(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) with pytest.raises(installer.InstallError, match="system path"): installer.install_from(src, settings={"MEDIA_PATH": "/"}) def test_install_rejects_deny_list_via_traversal(tmp_path, fake_dirs): # /mnt/../etc resolves to /etc — must be caught after Path.resolve(). src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) with pytest.raises(installer.InstallError, match="system path"): installer.install_from(src, settings={"MEDIA_PATH": "/mnt/../etc"}) def test_install_accepts_empty_optional_path(tmp_path, fake_dirs): src = _write_app_source(tmp_path, "jellyfin", OPTIONAL_PATH_MANIFEST) installer.install_from(src, settings={"OPTIONAL_PATH": ""}) target = apps_dir() / "jellyfin" assert (target / ".env").exists() def test_update_env_rejects_invalid_path(tmp_path, fake_dirs): # First install with a valid path. media = tmp_path / "media" media.mkdir() src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) installer.install_from(src, settings={"MEDIA_PATH": str(media)}) # Then try to update to a bad path. with pytest.raises(installer.InstallError, match="does not exist"): installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")})