furtka/tests/test_reconciler.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

135 lines
4.7 KiB
Python

import json
import pytest
from furtka import dockerops, reconciler
VALID_MANIFEST = {
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB share",
"volumes": ["files", "config"],
"ports": [445],
"icon": "icon.svg",
}
@pytest.fixture
def fake_docker(monkeypatch):
"""Replace dockerops with no-op recorders so reconcile() doesn't shell out."""
calls: dict[str, list] = {"ensure_volume": [], "compose_up": [], "compose_down": []}
existing_volumes: set[str] = set()
def fake_ensure(name):
calls["ensure_volume"].append(name)
if name in existing_volumes:
return False
existing_volumes.add(name)
return True
def fake_compose_up(app_dir, project):
calls["compose_up"].append((str(app_dir), project))
def fake_compose_down(app_dir, project):
calls["compose_down"].append((str(app_dir), project))
monkeypatch.setattr(dockerops, "ensure_volume", fake_ensure)
monkeypatch.setattr(dockerops, "compose_up", fake_compose_up)
monkeypatch.setattr(dockerops, "compose_down", fake_compose_down)
return calls
def _make_app(root, name, manifest=None):
app = root / name
app.mkdir(parents=True)
if manifest is not None:
(app / "manifest.json").write_text(json.dumps(manifest))
(app / "docker-compose.yaml").write_text("services: {}\n")
return app
def test_reconcile_empty_root(tmp_path, fake_docker):
actions = reconciler.reconcile(tmp_path)
assert actions == []
assert fake_docker["ensure_volume"] == []
assert fake_docker["compose_up"] == []
def test_reconcile_one_app(tmp_path, fake_docker):
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
actions = reconciler.reconcile(tmp_path)
assert [(a.kind, a.target) for a in actions] == [
("ensure_volume", "furtka_fileshare_files"),
("ensure_volume", "furtka_fileshare_config"),
("compose_up", "fileshare"),
]
assert fake_docker["ensure_volume"] == [
"furtka_fileshare_files",
"furtka_fileshare_config",
]
assert len(fake_docker["compose_up"]) == 1
assert fake_docker["compose_up"][0][1] == "fileshare"
def test_reconcile_dry_run_does_not_act(tmp_path, fake_docker):
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
actions = reconciler.reconcile(tmp_path, dry_run=True)
assert len(actions) == 3
assert fake_docker["ensure_volume"] == []
assert fake_docker["compose_up"] == []
def test_reconcile_skips_broken_manifest(tmp_path, fake_docker):
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
_make_app(tmp_path, "broken") # no manifest
actions = reconciler.reconcile(tmp_path)
skip_actions = [a for a in actions if a.kind == "skip"]
assert len(skip_actions) == 1
assert skip_actions[0].target == "broken"
# Healthy app still got reconciled.
assert fake_docker["compose_up"] == [(str(tmp_path / "fileshare"), "fileshare")]
def test_reconcile_isolates_per_app_docker_failure(tmp_path, monkeypatch):
"""A docker error on one app must not block reconcile of the others."""
_make_app(tmp_path, "alpha", dict(VALID_MANIFEST, name="alpha", volumes=["a"]))
_make_app(tmp_path, "broken-app", dict(VALID_MANIFEST, name="broken-app", volumes=["b"]))
_make_app(tmp_path, "zulu", dict(VALID_MANIFEST, name="zulu", volumes=["z"]))
succeeded_compose: list[str] = []
def fake_ensure(name):
return True
def fake_compose_up(app_dir, project):
if project == "broken-app":
raise dockerops.DockerError("simulated daemon failure")
succeeded_compose.append(project)
monkeypatch.setattr(dockerops, "ensure_volume", fake_ensure)
monkeypatch.setattr(dockerops, "compose_up", fake_compose_up)
actions = reconciler.reconcile(tmp_path)
error_actions = [a for a in actions if a.kind == "error"]
assert reconciler.has_errors(actions)
assert len(error_actions) == 1
assert error_actions[0].target == "broken-app"
# Both healthy apps reconciled despite the broken one in the middle.
assert succeeded_compose == ["alpha", "zulu"]
def test_reconcile_isolates_missing_docker_binary(tmp_path, monkeypatch):
"""No docker binary on the box still produces a tidy per-app error."""
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
def boom(*args, **kwargs):
raise FileNotFoundError("[Errno 2] No such file or directory: 'docker'")
monkeypatch.setattr(dockerops, "ensure_volume", boom)
actions = reconciler.reconcile(tmp_path)
assert reconciler.has_errors(actions)
error = next(a for a in actions if a.kind == "error")
assert error.target == "fileshare"
assert "docker" in error.detail