furtka/tests/test_manifest.py
Daniel Maksymilian Syrnicki ff68dd5ae6 fix(furtka): audit follow-ups — placeholder secrets, isolate reconcile, .env perms
Addresses the four issues raised in the slice-3 audit before pushing.

#1 (critical) — refuse to finish install when .env still contains
placeholder secrets like "changeme". Without this, `furtka app install
fileshare` would happily start an SMB server with a publicly-known
password — the kind of default that ends up screenshotted on Hacker
News. PLACEHOLDER_SECRETS lives in installer.py; new tests cover
placeholder rejection, post-edit retry, and quoted values.

#3 — reconciler now catches DockerError / FileNotFoundError / OSError
per-app instead of letting a single broken app abort the whole
boot-scan. Errors get surfaced as Action(kind="error", …) and
has_errors() drives the CLI exit code so systemd still shows red,
but the other apps actually got reconciled.

#4 — chmod 0600 on .env after install so app secrets aren't world-
readable on multi-user boxes. Done before the placeholder check so
even the half-installed state is safe.

#5 — load_manifest() got an optional expected_name. The scanner
passes the folder name (filesystem source-of-truth contract);
installer leaves it None so `furtka app install /tmp/some-fork/`
works regardless of what the source folder is named.

#2 — TODO comment on dperson/samba:latest. Switching to a digest
needs a verified upstream release; left for the test-day pin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:17:00 +02:00

90 lines
2.8 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)