furtka/tests/test_manifest.py
Daniel Maksymilian Syrnicki 04762f5dd1
Some checks failed
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Build ISO / build-iso (push) Failing after 4m34s
feat(manifest): add 'path' setting type with server-side validation
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.

Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.

UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.

Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:39:15 +02:00

193 lines
6.1 KiB
Python

import json
import pytest
from furtka.manifest import Manifest, ManifestError, load_manifest
VALID_MANIFEST = {
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB share",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
}
def _write_app(tmp_path, name, payload):
app_dir = tmp_path / name
app_dir.mkdir()
(app_dir / "manifest.json").write_text(json.dumps(payload))
return app_dir / "manifest.json"
def test_load_valid_manifest(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert isinstance(m, Manifest)
assert m.name == "fileshare"
assert m.volumes == ("files",)
assert m.ports == (445,)
def test_volume_namespacing(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.volume_name("files") == "furtka_fileshare_files"
def test_unknown_volume_raises(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
with pytest.raises(ManifestError):
m.volume_name("does-not-exist")
def test_missing_required_field(tmp_path):
bad = dict(VALID_MANIFEST)
del bad["display_name"]
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="display_name"):
load_manifest(path)
def test_name_must_match_when_expected_name_given(tmp_path):
# Scanner passes expected_name=<folder name> so /var/lib/furtka/apps/X/
# can't lie about its own identity.
path = _write_app(tmp_path, "wrong-folder", VALID_MANIFEST)
with pytest.raises(ManifestError, match="must equal 'wrong-folder'"):
load_manifest(path, expected_name="wrong-folder")
def test_name_check_skipped_without_expected_name(tmp_path):
# Installer loads from arbitrary source paths (e.g. /tmp/my-tweaked-app/)
# — the source folder name shouldn't matter, only the manifest's own name.
path = _write_app(tmp_path, "any-folder-name", VALID_MANIFEST)
m = load_manifest(path)
assert m.name == "fileshare"
def test_invalid_json(tmp_path):
app = tmp_path / "fileshare"
app.mkdir()
(app / "manifest.json").write_text("{not json")
with pytest.raises(ManifestError, match="invalid JSON"):
load_manifest(app / "manifest.json")
def test_volumes_wrong_type(tmp_path):
bad = dict(VALID_MANIFEST, volumes="files")
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="volumes"):
load_manifest(path)
def test_ports_wrong_type(tmp_path):
bad = dict(VALID_MANIFEST, ports=["445"])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="ports"):
load_manifest(path)
def test_settings_optional_default_empty(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.settings == ()
assert m.description_long == ""
assert m.open_url == ""
def test_open_url_stored_when_present(tmp_path):
payload = dict(VALID_MANIFEST, open_url="smb://{host}/files")
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.open_url == "smb://{host}/files"
def test_open_url_non_string_rejected(tmp_path):
payload = dict(VALID_MANIFEST, open_url=42)
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="open_url"):
load_manifest(path)
def test_settings_parsed(tmp_path):
payload = dict(
VALID_MANIFEST,
description_long="Long description with details.",
settings=[
{
"name": "SMB_USER",
"label": "Benutzername",
"description": "Anmeldename",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Passwort", "type": "password", "required": True},
],
)
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.description_long == "Long description with details."
assert len(m.settings) == 2
assert m.settings[0].name == "SMB_USER"
assert m.settings[0].label == "Benutzername"
assert m.settings[0].default == "furtka"
assert m.settings[0].type == "text"
assert m.settings[0].required is True
assert m.settings[1].type == "password"
assert m.settings[1].default is None
def test_settings_reject_lowercase_name(tmp_path):
bad = dict(VALID_MANIFEST, settings=[{"name": "smb_user", "type": "text"}])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="UPPER_SNAKE_CASE"):
load_manifest(path)
def test_settings_reject_unknown_type(tmp_path):
bad = dict(VALID_MANIFEST, settings=[{"name": "FOO", "type": "email"}])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="type must be one of"):
load_manifest(path)
def test_settings_accept_path_type(tmp_path):
payload = dict(
VALID_MANIFEST,
settings=[
{
"name": "MEDIA_PATH",
"label": "Medienordner",
"description": "Absoluter Pfad zu deinen Medien",
"type": "path",
"required": True,
}
],
)
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert len(m.settings) == 1
assert m.settings[0].name == "MEDIA_PATH"
assert m.settings[0].type == "path"
assert m.settings[0].required is True
def test_settings_reject_duplicate_name(tmp_path):
bad = dict(
VALID_MANIFEST,
settings=[{"name": "FOO", "type": "text"}, {"name": "FOO", "type": "password"}],
)
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="duplicate"):
load_manifest(path)
def test_settings_non_list_rejected(tmp_path):
bad = dict(VALID_MANIFEST, settings={"FOO": "bar"})
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="settings must be a list"):
load_manifest(path)