server-up/tests/test_connect.py
Ramon 1db3d3e8a0
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 14m6s
v0.8.50-beta - Leesbare namen, zichtbaar wat al draait, en bruikbaar op een telefoon
- Het auditlog toonde ruwe id's als 'store', 'reconfigure' en
  'credentials_view'. Die hebben nu namen, ook in de filterlijsten, en de
  kolomkoppen zijn vertaald in plaats van hardgecodeerd Nederlands. Onbekende
  waarden vallen terug op de ruwe tekst zodat een module niets kapotmaakt
- Nieuwe test die de broncode afloopt en elke audit.log() zonder vertaling
  aanwijst
- Een modulepagina heette in de titelbalk letterlijk 'mod:mijnmodule'
- De store herkende een installatie aan de mapnaam: een stack telde alleen mee
  als hij 'sonarr' of 'sonarr-...' heette. Wie zijn stack 'media-tv' noemde
  kreeg te zien dat Sonarr niet geinstalleerd was. Nu via de source uit
  .serverup.json, met een duidelijk Geinstalleerd-label per installatie waar je
  op kunt klikken om ernaartoe te gaan
- Twaalf icoonknoppen per stack werden op een telefoon drie rijen plaatjes
  zonder naam. Daar blijven starten, herstarten en logs staan, plus een menu met
  de rest, mét namen. Op een breed scherm verandert er niets
- De rij van twintig categorieknoppen boven de stacks is hetzelfde compacte menu
  geworden als in de app store

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-01 14:20:28 +02:00

304 lines
12 KiB
Python

"""Apps onderling koppelen, geavanceerde velden en de uitleg per veld."""
import sys
import tempfile
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from core import boilerplates as bp
from core import compose_transform as ct
from conftest import login
BASIS = """\
services:
web:
image: nginx
ports:
- "8080:80"
db:
image: postgres
"""
# ── Gedeeld netwerk ──────────────────────────────────────────────────────────
def test_gedeeld_netwerk_komt_bij_elke_service():
"""Zonder gedeeld netwerk zit elke stack in zijn eigen compose-netwerk en
kan geen enkele app een andere op naam bereiken."""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup"))
for naam, svc in doc["services"].items():
assert "serverup" in svc["networks"], naam
assert doc["networks"]["serverup"] == {"external": True}
def test_gedeeld_netwerk_naast_een_eigen_ip():
"""Een stack met een macvlan-adres moet het gedeelde netwerk erbij krijgen,
niet in plaats daarvan."""
met_ip = ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240")
doc = yaml.safe_load(ct.add_shared_network(met_ip, "serverup"))
web = doc["services"]["web"]["networks"]
assert "lan" in web and "serverup" in web
assert web["lan"] == {"ipv4_address": "192.168.1.240"}
assert doc["networks"]["lan"]["external"] is True
assert doc["networks"]["serverup"]["external"] is True
def test_gedeeld_netwerk_is_idempotent():
een = ct.add_shared_network(BASIS, "serverup")
twee = ct.add_shared_network(een, "serverup")
doc = yaml.safe_load(twee)
assert doc["services"]["web"]["networks"].count("serverup") == 1
def test_gedeeld_netwerk_bij_ongeldige_yaml():
with pytest.raises(ct.TransformError):
ct.add_shared_network("dit: [is geen: yaml", "serverup")
# ── Veldopties ───────────────────────────────────────────────────────────────
def _veld(app: str, naam: str) -> dict:
return next(f for f in bp.fields(WORTEL / "apps" / app) if f["name"] == naam)
def test_koppelveld_wordt_doorgegeven():
f = _veld("zigbee2mqtt", "mqtt_server")
assert f["connect"] == {"app": "mosquitto", "scheme": "mqtt", "port": 1883}
assert f["help"], "koppelveld hoort uitleg te hebben"
def test_open_webui_wijst_naar_ollama():
f = _veld("open-webui", "ollama_url")
assert f["connect"]["app"] == "ollama"
assert f["connect"]["port"] == 11434
def test_geavanceerd_veld_wordt_gemarkeerd():
assert _veld("zigbee2mqtt", "usb_device")["advanced"] is True
assert _veld("zigbee2mqtt", "mqtt_server")["advanced"] is False
def test_gewone_velden_blijven_zichtbaar():
"""De basisvelden mogen niet per ongeluk achter het uitklapmenu verdwijnen."""
for app in ("zigbee2mqtt", "immich", "frigate", "ollama"):
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / app)}
assert velden["service_name"]["advanced"] is False
if "port" in velden:
assert velden["port"]["advanced"] is False
def test_elke_app_houdt_zichtbare_velden_over():
"""Een app waarvan alles geavanceerd is, zou een leeg formulier tonen."""
for d in sorted((WORTEL / "apps").iterdir()):
if not d.is_dir():
continue
velden = [f for f in bp.fields(d) if not f.get("is_group_toggle")]
zichtbaar = [f for f in velden if not f["advanced"]]
assert zichtbaar, f"{d.name}: alle velden staan als geavanceerd gemarkeerd"
def test_koppelvelden_verwijzen_naar_bestaande_apps():
"""Een connect-veld dat naar een app verwijst die niet bestaat, levert een
lege keuzelijst op zonder dat iemand snapt waarom."""
apps = {d.name for d in (WORTEL / "apps").iterdir() if d.is_dir()}
for d in sorted((WORTEL / "apps").iterdir()):
if not d.is_dir():
continue
for f in bp.fields(d):
doel = (f.get("connect") or {}).get("app")
if doel:
assert doel in apps, f"{d.name}/{f['name']} verwijst naar '{doel}'"
# ── Afhankelijkheden tussen apps ─────────────────────────────────────────────
# Een `connect`-veld wijst naar een andere app, maar zegt niet dát je die nodig
# hebt. Zigbee2MQTT zonder MQTT-broker start prima en doet niets; `depends_on`
# maakt dat zichtbaar vóór het installeren.
def _apps():
return sorted(d for d in (WORTEL / "apps").iterdir() if d.is_dir())
def _deps(d):
return bp.metadata(d).get("depends_on") or []
def test_afhankelijkheden_verwijzen_naar_bestaande_apps():
namen = {d.name for d in _apps()}
for d in _apps():
for dep in _deps(d):
assert dep["app"] in namen, f"{d.name} hangt af van '{dep['app']}'"
def test_elke_afhankelijkheid_heeft_een_reden():
"""Een waarschuwing zonder uitleg is een waarschuwing die je wegklikt."""
for d in _apps():
for dep in _deps(d):
assert dep["reason"], f"{d.name}{dep['app']}: geen reden opgegeven"
def test_geen_app_hangt_van_zichzelf_af():
for d in _apps():
assert d.name not in [x["app"] for x in _deps(d)], d.name
def test_geen_kringetjes():
"""A heeft B nodig en B heeft A nodig levert een melding op die je nooit
kunt wegwerken."""
graaf = {d.name: [x["app"] for x in _deps(d)] for d in _apps()}
bezocht: set[str] = set()
def loop(naam, pad):
if naam in pad:
raise AssertionError(f"kringetje: {''.join(pad + [naam])}")
if naam in bezocht:
return
bezocht.add(naam)
for volgende in graaf.get(naam, []):
loop(volgende, pad + [naam])
for naam in graaf:
loop(naam, [])
def test_korte_schrijfwijze_wordt_uitgeklapt(tmp_path):
"""Externe repo's hoeven niets van deze sleutel te weten; `["mosquitto"]`
moet net zo goed werken als de volledige vorm."""
import json as _json
d = tmp_path / "app"
(d / "files").mkdir(parents=True)
(d / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "x", "description": "y", "tags": ["z"],
"depends_on": ["mosquitto", {"app": "loki", "reason": "omdat"}]},
"variables": [],
}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
assert bp.metadata(d)["depends_on"] == [
{"app": "mosquitto", "reason": ""},
{"app": "loki", "reason": "omdat"},
]
def test_zonder_depends_on_een_lege_lijst():
assert bp.metadata(WORTEL / "apps" / "vaultwarden")["depends_on"] == []
# ── Endpoint ─────────────────────────────────────────────────────────────────
def test_connectable_endpoint(client, env, monkeypatch):
from core import docker
import json as _json
login(client)
stack = env["lib"] / "mosquitto"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
(stack / ".serverup.json").write_text(_json.dumps(
{"source": "mosquitto", "name": "Mosquitto MQTT"}))
monkeypatch.setattr(docker, "compose_ps",
lambda d, name=None: [{"name": "mosquitto", "service": "a",
"ports": "1883/tcp", "running": True}])
d = client.get("/api/apps/connectable").get_json()
assert d["shared_network"] == "serverup"
app = next(a for a in d["apps"] if a["stack"] == "mosquitto")
assert app["source"] == "mosquitto"
assert app["containers"][0]["name"] == "mosquitto"
def test_connectable_zonder_stacks(client):
login(client)
assert client.get("/api/apps/connectable").get_json()["apps"] == []
def test_preview_meldt_wat_er_nog_ontbreekt(client, env, monkeypatch):
"""De installatiemodal moet vóór het installeren kunnen tonen wat er mist."""
import json as _json
from core import git
csrf = login(client)
bron = git.cache_dir("server-up") / "apps" / "z2m"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Zigbee2MQTT", "description": "test", "tags": ["mqtt"],
"depends_on": [{"app": "mosquitto", "reason": "broker nodig"},
{"app": "loki", "reason": "logs"}]},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "z2m", "required": True}]}],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: x\n", encoding="utf-8")
# Alleen Mosquitto draait al.
stack = env["lib"] / "mqtt"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
(stack / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"}))
d = client.post("/api/store/preview", json={"stack": "z2m", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf}).get_json()
deps = {x["app"]: x for x in d["depends_on"]}
assert deps["mosquitto"]["installed"] is True
assert deps["mosquitto"]["stacks"] == ["mqtt"]
assert deps["loki"]["installed"] is False
assert deps["loki"]["reason"] == "logs"
def test_store_herkent_een_installatie_aan_zijn_bron(env, tmp_path):
"""De store keek naar de mapnaam: een stack telde als installatie van
`sonarr` zodra hij `sonarr` of `sonarr-…` heette. Wie zijn stack `media-tv`
noemde, kreeg te zien dat Sonarr nog niet geïnstalleerd was — terwijl hij
draaide. `.serverup.json` weet wél uit welk sjabloon hij komt.
"""
import json as _json
from core import git
sjablonen = tmp_path / "sjablonen"
(sjablonen / "sonarr" / "files").mkdir(parents=True)
(sjablonen / "sonarr" / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Sonarr", "description": "d", "tags": ["media"]},
"variables": [],
}), encoding="utf-8")
(sjablonen / "sonarr" / "files" / "compose.yaml").write_text(
"services: {}\n", encoding="utf-8")
lib = env["lib"]
# Een installatie met een eigen naam …
eigen = lib / "media-tv"
eigen.mkdir(parents=True)
(eigen / ".serverup.json").write_text(_json.dumps({"source": "sonarr"}))
# … en eentje die toevallig wel zo heet, zonder metadata.
(lib / "sonarr-2").mkdir()
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
assert stacks["sonarr"]["instances"] == ["media-tv", "sonarr-2"]
def test_store_ziet_geen_installatie_van_een_andere_app(env, tmp_path):
import json as _json
from core import git
sjablonen = tmp_path / "sjablonen"
(sjablonen / "radarr" / "files").mkdir(parents=True)
(sjablonen / "radarr" / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Radarr", "description": "d", "tags": ["media"]},
"variables": [],
}), encoding="utf-8")
(sjablonen / "radarr" / "files" / "compose.yaml").write_text(
"services: {}\n", encoding="utf-8")
lib = env["lib"]
d = lib / "media-tv"
d.mkdir(parents=True)
(d / ".serverup.json").write_text(_json.dumps({"source": "sonarr"}))
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
assert stacks["radarr"]["instances"] == []