New Manifest.internet field (viable: bool, port: int | None), parsed by _parse_internet() following _parse_settings/_parse_requires's existing shape. When viable, port must be one of the manifest's own declared ports — resolves which entry is the exposable web UI for apps declaring several (e.g. a DNS service exposing both a resolver port and an admin UI port). catalog.py's release validation additionally rejects any internet.viable app whose docker-compose.yaml sets privileged: true. Deliberately not a network_mode check: a furtka-gateway reaches a host-networked container's port exactly like a bridge-published one — the real risk is a compromised internet-facing container also having host device/kernel access, which privileged: true grants outright.
348 lines
11 KiB
Python
348 lines
11 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)
|
|
|
|
|
|
def test_requires_optional_default_empty(tmp_path):
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
m = load_manifest(path)
|
|
assert m.requires == ()
|
|
|
|
|
|
def test_requires_parsed_full_entry(tmp_path):
|
|
payload = dict(
|
|
VALID_MANIFEST,
|
|
name="zigbee2mqtt",
|
|
requires=[
|
|
{
|
|
"app": "mosquitto",
|
|
"on_install": "hooks/create-user.sh",
|
|
"on_start": "hooks/ensure-user.sh",
|
|
}
|
|
],
|
|
)
|
|
path = _write_app(tmp_path, "zigbee2mqtt", payload)
|
|
m = load_manifest(path)
|
|
assert len(m.requires) == 1
|
|
r = m.requires[0]
|
|
assert r.app == "mosquitto"
|
|
assert r.on_install == "hooks/create-user.sh"
|
|
assert r.on_start == "hooks/ensure-user.sh"
|
|
|
|
|
|
def test_requires_app_only_no_hooks(tmp_path):
|
|
payload = dict(VALID_MANIFEST, name="z2m", requires=[{"app": "mosquitto"}])
|
|
path = _write_app(tmp_path, "z2m", payload)
|
|
m = load_manifest(path)
|
|
assert m.requires[0].app == "mosquitto"
|
|
assert m.requires[0].on_install is None
|
|
assert m.requires[0].on_start is None
|
|
|
|
|
|
def test_requires_rejects_self_reference(tmp_path):
|
|
payload = dict(VALID_MANIFEST, requires=[{"app": "fileshare"}])
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="self-reference"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_requires_rejects_duplicate_app(tmp_path):
|
|
payload = dict(
|
|
VALID_MANIFEST,
|
|
name="z2m",
|
|
requires=[{"app": "mosquitto"}, {"app": "mosquitto"}],
|
|
)
|
|
path = _write_app(tmp_path, "z2m", payload)
|
|
with pytest.raises(ManifestError, match="duplicate"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_requires_rejects_traversal_hook_path(tmp_path):
|
|
payload = dict(
|
|
VALID_MANIFEST,
|
|
name="z2m",
|
|
requires=[{"app": "mosquitto", "on_install": "../../etc/passwd"}],
|
|
)
|
|
path = _write_app(tmp_path, "z2m", payload)
|
|
with pytest.raises(ManifestError, match=r"must not contain '\.\.'"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_requires_rejects_absolute_hook_path(tmp_path):
|
|
payload = dict(
|
|
VALID_MANIFEST,
|
|
name="z2m",
|
|
requires=[{"app": "mosquitto", "on_start": "/tmp/hook.sh"}],
|
|
)
|
|
path = _write_app(tmp_path, "z2m", payload)
|
|
with pytest.raises(ManifestError, match="must be relative"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_requires_non_list_rejected(tmp_path):
|
|
payload = dict(VALID_MANIFEST, requires={"app": "mosquitto"})
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="requires must be a list"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_requires_rejects_invalid_app_name(tmp_path):
|
|
payload = dict(VALID_MANIFEST, requires=[{"app": "Bad-Name!"}])
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="lowercase app name"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_requires_rejects_empty_hook_string(tmp_path):
|
|
payload = dict(
|
|
VALID_MANIFEST,
|
|
name="z2m",
|
|
requires=[{"app": "mosquitto", "on_install": ""}],
|
|
)
|
|
path = _write_app(tmp_path, "z2m", payload)
|
|
with pytest.raises(ManifestError, match="non-empty string"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_internet_optional_defaults_non_viable(tmp_path):
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
m = load_manifest(path)
|
|
assert m.internet.viable is False
|
|
assert m.internet.port is None
|
|
|
|
|
|
def test_internet_viable_parsed(tmp_path):
|
|
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 445})
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
m = load_manifest(path)
|
|
assert m.internet.viable is True
|
|
assert m.internet.port == 445
|
|
|
|
|
|
def test_internet_non_viable_ignores_port(tmp_path):
|
|
# A non-viable app doesn't need a port at all — and if one is present
|
|
# anyway (e.g. a stale field left behind), it's simply ignored rather
|
|
# than validated, since it has no effect either way.
|
|
payload = dict(VALID_MANIFEST, internet={"viable": False, "port": 9999})
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
m = load_manifest(path)
|
|
assert m.internet.viable is False
|
|
assert m.internet.port is None
|
|
|
|
|
|
def test_internet_viable_requires_port(tmp_path):
|
|
payload = dict(VALID_MANIFEST, internet={"viable": True})
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="internet.port must be an integer"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_internet_port_must_be_declared(tmp_path):
|
|
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 9999})
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="must be one of the declared ports"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_internet_non_object_rejected(tmp_path):
|
|
payload = dict(VALID_MANIFEST, internet=True)
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="internet must be an object"):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_internet_port_must_be_int_not_bool(tmp_path):
|
|
payload = dict(VALID_MANIFEST, internet={"viable": True, "port": True})
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
with pytest.raises(ManifestError, match="internet.port must be an integer"):
|
|
load_manifest(path)
|