server-up/tests/test_apps.py
Ramon 2ba15418ab
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m13s
v0.7.20-beta - instellingen wijzigen, koppelen achteraf, defect uit v0.7.00
Defect (v0.7.00): add_shared_network() hing elke service aan het gedeelde
netwerk, ook services met network_mode. Docker compose weigert die combinatie
("declares mutually exclusive network_mode and networks"), waardoor Homebridge,
Scrypted, Beszel en de ARR-stack met Gluetun niet te installeren waren met de
standaardkeuze "Verbinden met andere apps". Die services worden nu overgeslagen;
blijft er niets over, dan ook geen netwerkblok. Test over alle 96 apps.

Instellingen wijzigen na installatie:
- De gemaakte keuzes worden bij installatie opgeslagen in .serverup.json
  (values + format); zonder die waarden viel het formulier niet te heropenen.
- GET /api/stacks/<naam>/config geeft de opgeslagen waarden plus het actuele
  veldschema; POST /reconfigure maakt een backup, rendert opnieuw, past netwerk
  en eigen IP opnieuw toe, valideert en herstart. Mislukt het valideren, dan
  wordt de backup teruggezet.
- Knop Instellingen op de stackkaart opent dezelfde modal in wijzigen-stand,
  met een waarschuwing dat handmatige compose-wijzigingen verloren gaan.
- Stacks van voor deze versie: knop uit met uitleg.

Bestaande stacks koppelen:
- POST /api/stacks/<naam>/connect zet een bestaande stack op het gedeelde
  netwerk, valideert en herstart; wordt de compose ongeldig, dan wordt de
  wijziging teruggedraaid.

Audit-log:
- audit.query()/count() accepteren filters op bron, actie, status, periode en
  vrije tekst; facets() levert de keuzelijsten. /api/audit ondersteunt die als
  queryparameters plus doorbladeren. Filterbalk in de UI.

Documentatie:
- README.md (bestond niet), docs/apps-maken.md met het volledige
  templateformaat, docs/README.md als index.
- Changelog-secties voor v0.5.44/45/46 aangevuld; release.yml haalt de notes
  daaruit, dus een tag daarop gaf een lege release. Test die afdwingt dat het
  huidige VERSION een sectie heeft.

941 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-27 15:43:49 +02:00

270 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Elke app in apps/ moet renderen naar geldige, startbare compose-YAML.
Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een
kapot template op vóórdat iemand het probeert te installeren.
"""
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
APPS = sorted(d for d in (WORTEL / "apps").iterdir() if d.is_dir())
NAMEN = [d.name for d in APPS]
def _render(d: Path, tmp: Path) -> dict:
velden = bp.fields(d)
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
waarden.setdefault("service_name", d.name)
# Verplichte velden zonder default zijn geheimen; die vult de gebruiker in.
for f in velden:
if f.get("required") and not waarden.get(f["name"]):
waarden[f["name"]] = "testwaarde12345678901234567890ab"
bp.render_to_dir(d, tmp, waarden)
return yaml.safe_load((tmp / "docker-compose.yml").read_text())
def test_er_zijn_apps():
assert len(APPS) >= 60
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
def test_app_is_geldige_boilerplate(app):
assert bp.is_boilerplate(app), "template.json of files/ ontbreekt"
meta = bp.metadata(app)
assert meta["name"], "naam ontbreekt in template.json"
assert meta["description"], "omschrijving ontbreekt"
assert meta["tags"], "tags ontbreken"
assert any(f["name"] == "service_name" for f in bp.fields(app)), \
"service_name-variabele ontbreekt"
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
def test_app_rendert_naar_geldige_compose(app):
with tempfile.TemporaryDirectory() as t:
doc = _render(app, Path(t))
assert isinstance(doc, dict) and doc.get("services"), "geen services"
for naam, svc in doc["services"].items():
assert isinstance(svc, dict), f"{naam} is geen mapping"
assert svc.get("image") or svc.get("build"), f"{naam}: geen image of build"
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
def test_named_volumes_zijn_gedeclareerd(app):
"""Named volumes moeten in het top-level volumes-blok staan.
_drop_empty_mappings ruimde dat blok weg omdat een volumedeclaratie een
kale sleutel zonder waarde is. Zes apps (immich, ghost, miniflux,
paperless-ngx, unifi-network, vikunja) renderden daardoor naar een compose
die docker weigert met "refers to undefined volume".
"""
with tempfile.TemporaryDirectory() as t:
doc = _render(app, Path(t))
gedeclareerd = set((doc.get("volumes") or {}).keys())
for naam, svc in doc["services"].items():
for v in svc.get("volumes") or []:
if isinstance(v, str) and not v.startswith(("/", ".")):
bron = v.split(":")[0]
assert bron in gedeclareerd, \
f"{naam}: volume '{bron}' wordt gebruikt maar niet gedeclareerd"
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
def test_geen_onvervangen_variabelen(app):
"""Een typefout in een variabelenaam laat << … >> in het resultaat staan."""
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
_render(app, tmp)
tekst = (tmp / "docker-compose.yml").read_text()
assert "<<" not in tekst and ">>" not in tekst, "onvervangen variabele"
# Poorten die meerdere apps bewust delen: alternatieven voor dezelfde taak,
# die je nooit tegelijk draait. Alles daarbuiten hoort uniek te zijn.
GEDEELDE_POORTEN = {
80: {"nginx-proxy-manager", "traefik", "caddy"}, # reverse proxies
443: {"nginx-proxy-manager", "traefik", "caddy"},
53: {"adguard-home", "pihole"}, # dns-blokkers
5055: {"arr-stack", "jellyseerr"}, # zelfde app
6881: {"arr-stack", "qbittorrent"}, # zelfde app
# Hieronder: botsingen die er al waren vóór deze testsuite. De
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.
3000: {"adguard-home", "forgejo", "grafana"},
8080: {"miniflux", "nextcloud-aio", "pihole", "qbittorrent",
"stirling-pdf", "traefik", "unifi-network"},
9000: {"mealie", "portainer"},
}
def test_poorten_botsen_niet_tussen_apps():
"""Twee apps met dezelfde standaardpoort geven bij installeren meteen een
conflict. De vrije-poortsuggestie vangt dat op, maar standaardwaarden die
al botsen zijn onnodig verwarrend."""
per_poort: dict[int, list[str]] = {}
for app in APPS:
for f in bp.fields(app):
if f.get("type") == "int" and "port" in f["name"].lower():
d = f.get("default")
if isinstance(d, int):
per_poort.setdefault(d, []).append(f"{app.name}:{f['name']}")
botsingen = {}
for poort, gebruikers in per_poort.items():
apps = {g.split(":")[0] for g in gebruikers}
if len(apps) > 1 and not apps <= GEDEELDE_POORTEN.get(poort, set()):
botsingen[poort] = sorted(apps)
assert not botsingen, f"onbedoeld dubbele standaardpoorten: {botsingen}"
# ── ARR-stack: schakelbare onderdelen ────────────────────────────────────────
ARR = WORTEL / "apps" / "arr-stack"
def _arr_render(overrides: dict) -> dict:
velden = bp.fields(ARR)
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
waarden["service_name"] = "arr"
waarden.update(overrides)
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
bp.render_to_dir(ARR, tmp, waarden)
return yaml.safe_load((tmp / "docker-compose.yml").read_text())
def _arr_schakelaars() -> list[str]:
return [f["name"] for f in bp.fields(ARR) if f.get("is_group_toggle")]
def test_arr_heeft_veel_schakelbare_onderdelen():
assert len(_arr_schakelaars()) >= 20
def test_arr_standaard_levert_een_werkbare_basis():
doc = _arr_render({})
diensten = {s.split("-", 1)[1] for s in doc["services"]}
assert {"sonarr", "radarr", "prowlarr", "bazarr"} <= diensten
@pytest.mark.parametrize("schakelaar", _arr_schakelaars())
def test_arr_elk_onderdeel_levert_precies_een_service(schakelaar):
"""Aan betekent één service erbij, uit betekent geen enkel spoor."""
uit = {s: False for s in _arr_schakelaars()}
naam = schakelaar.replace("enable_", "")
doc = _arr_render({**uit, schakelaar: True})
assert set(doc["services"]) == {f"arr-{naam}"}
doc_uit = _arr_render(uit)
assert not (doc_uit or {}).get("services")
def test_arr_downloadclients_zitten_achter_de_vpn():
"""Met Gluetun delen de clients diens netwerknamespace en mogen ze geen
eigen poortmapping hebben — anders lekt het verkeer om de VPN heen."""
uit = {s: False for s in _arr_schakelaars()}
doc = _arr_render({**uit, "enable_gluetun": True, "enable_qbittorrent": True,
"enable_sabnzbd": True, "enable_nzbget": True})
for client in ("qbittorrent", "sabnzbd", "nzbget"):
svc = doc["services"][f"arr-{client}"]
assert svc.get("network_mode") == "service:arr-gluetun", client
assert "ports" not in svc, f"{client} heeft een eigen poort naast de VPN"
def test_arr_interne_poorten_botsen_niet_achter_de_vpn():
"""Alle clients delen één netwerknamespace, dus hun interne poorten moeten
verschillen. qBittorrent en SABnzbd wilden allebei 8080."""
uit = {s: False for s in _arr_schakelaars()}
doc = _arr_render({**uit, "enable_gluetun": True, "enable_qbittorrent": True,
"enable_sabnzbd": True, "enable_nzbget": True})
intern = [str(p).split(":", 1)[1] for p in doc["services"]["arr-gluetun"]["ports"]]
assert len(intern) == len(set(intern)), f"botsende interne poorten: {intern}"
def test_arr_zonder_vpn_eigen_poorten():
uit = {s: False for s in _arr_schakelaars()}
doc = _arr_render({**uit, "enable_qbittorrent": True})
svc = doc["services"]["arr-qbittorrent"]
assert "network_mode" not in svc
assert svc.get("ports")
def test_groepsschakelaar_krijgt_een_startwaarde():
"""Een groepsschakelaar was geen veld en werd dus nooit ge??nitialiseerd,
waardoor elk onderdeel standaard uit stond in de installatiemodal."""
schakelaars = [f for f in bp.fields(ARR) if f.get("is_group_toggle")]
assert schakelaars
assert all(isinstance(f["default"], bool) for f in schakelaars)
aan = [f["name"] for f in schakelaars if f["default"]]
assert "enable_sonarr" in aan and "enable_radarr" in aan
# ── Gedeeld netwerk over alle apps ───────────────────────────────────────────
from core import compose_transform as _ct
def _render_tekst(app: Path, overrides: dict | None = None) -> str:
velden = bp.fields(app)
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
waarden.setdefault("service_name", app.name)
for f in velden:
if f.get("required") and not waarden.get(f["name"]):
waarden[f["name"]] = "testwaarde12345678901234567890ab"
waarden.update(overrides or {})
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
bp.render_to_dir(app, tmp, waarden)
return (tmp / "docker-compose.yml").read_text()
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
def test_gedeeld_netwerk_botst_niet_met_network_mode(app):
"""Een service met network_mode deelt al andermans namespace; compose
weigert die samen met `networks`. Dat maakte Homebridge, Scrypted, Beszel
en de ARR-stack achter Gluetun onin­stalleerbaar met de standaardkeuze
"Verbinden met andere apps"."""
doc = yaml.safe_load(_ct.add_shared_network(_render_tekst(app), "serverup"))
for naam, svc in doc["services"].items():
if svc.get("network_mode"):
assert "networks" not in svc, \
f"{naam}: network_mode={svc['network_mode']!r} én networks"
def test_gedeeld_netwerk_bij_arr_stack_met_vpn():
"""De downloadclients zitten dan op network_mode: service:…-gluetun."""
tekst = _render_tekst(ARR, {s: False for s in _arr_schakelaars()} | {
"enable_gluetun": True, "enable_qbittorrent": True,
"enable_sabnzbd": True, "enable_sonarr": True})
doc = yaml.safe_load(_ct.add_shared_network(tekst, "serverup"))
for client in ("qbittorrent", "sabnzbd"):
svc = doc["services"][f"arr-{client}"]
assert svc["network_mode"].startswith("service:")
assert "networks" not in svc
# Sonarr heeft geen network_mode en hoort er wél op te zitten.
assert "serverup" in doc["services"]["arr-sonarr"]["networks"]
assert doc["networks"]["serverup"] == {"external": True}
def test_alleen_network_mode_services_levert_geen_netwerkblok():
"""Zit geen enkele service op het gedeelde netwerk, dan hoeft het ook niet
gedeclareerd te worden."""
tekst = "services:\n a:\n image: x\n network_mode: host\n"
doc = yaml.safe_load(_ct.add_shared_network(tekst, "serverup"))
assert "networks" not in doc["services"]["a"]
assert "networks" not in doc
def test_huidige_versie_heeft_een_changelog_sectie():
"""release.yml haalt de release-notes uit CHANGELOG.md. Ontbreekt de sectie
voor de versie die je tagt, dan krijg je een lege release."""
versie = (WORTEL / "VERSION").read_text(encoding="utf-8").strip()
changelog = (WORTEL / "CHANGELOG.md").read_text(encoding="utf-8")
assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}"