server-up/tests/test_app_templates.py
Ramon 952e7ddb7a
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m27s
v0.7.60-beta - app-instellingen nagelopen op fouten bij het installeren
Aanleiding: Homepage weigerde met "Host validation failed" omdat de
standaardwaarde van allowed_hosts poort 3001 noemde terwijl het poortveld op
3002 stond. Alle 96 sjablonen zijn daarop nagelopen.

Systemisch:
- De dobbelsteenknop, waar zeventien apps in hun uitleg naar verwijzen,
  verscheen nooit: hij hing af van een veld 'secret' dat fields() niet
  meestuurde. Wachtwoorden stonden daardoor leesbaar in het formulier.
- 27 velden die je toch nooit zelf typt (databasewachtwoorden, JWT- en
  versleutelingssleutels) worden nu voorgevuld met een willekeurige waarde.
  Inlogwachtwoorden krijgen alleen een knop, want die moet je noteren.
- De knop hangt nu aan 'generate' in plaats van aan 'secret': voor een
  WireGuard-privésleutel of een token uit een andere app is een verzonnen
  waarde juist fout.
- required werd nergens gecontroleerd. Je kon installeren met een leeg
  databasewachtwoord of een lege sleutel. Nu geweigerd bij installeren en bij
  herconfigureren, met vermelding van de lege velden. Velden achter een
  uitgeschakelde groepsschakelaar tellen niet mee.
- Wachtwoorden werden uitgeschreven in het installatielog; nu gemaskeerd.

Losse fouten:
- homepage: allowed_hosts stond op localhost:3001, nu * met uitleg.
- baserow, hedgedoc, ntfy: URL in de standaardwaarde wees naar een poort waar
  niets luistert.
- Vast wachtwoord in acht sjablonen weggehaald: ghost, immich, miniflux,
  paperless-ngx, unifi-network en vikunja kregen allemaal dezelfde
  database-wachtwoorden; grafana en gotify stonden op 'admin'.
- data_dir bij keycloak, metube, miniflux en teslamate stond in het formulier
  maar kwam nergens terecht (die apps gebruiken een Docker-volume).
- puid/pgid bij freshrss: het image kent ze niet.
- prometheus gaf de gevraagde tijdzone niet door.

Nieuw tests/test_app_templates.py: rendert elke app met zijn standaardwaarden
en controleert ongedefinieerde variabelen, velden zonder werking,
niet-gedeclareerde volumes, depends_on, dubbele containernamen,
poortconflicten, poortnummers in standaardwaarden en vaste wachtwoorden.
2236 tests groen.

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

222 lines
9.3 KiB
Python

"""Alle app-sjablonen doorlopen op fouten die pas bij het installeren opvallen.
Deze controles zijn ontstaan uit echte fouten: een Homepage die met
"Host validation failed" weigerde omdat de standaardwaarde een andere poort
noemde dan het poortveld, en velden die in het formulier stonden maar nergens
in het compose-bestand terechtkwamen.
"""
import json
import os
import re
import sys
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
os.environ.setdefault("SU_CONFIG", "/tmp/su-templates-test.json")
from core import boilerplates # noqa: E402
APPS = WORTEL / "apps"
VAR_RE = re.compile(r"<<\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|[^>]*?)?>>")
BLOK_RE = re.compile(r"<%-?\s*(?:if|elif)\s+([^%]+?)\s*-?%>")
SLEUTELWOORDEN = {"and", "or", "not", "in", "is", "if", "elif",
"true", "false", "none", "defined"}
def _apps():
return sorted(d for d in APPS.iterdir()
if d.is_dir() and (d / "template.json").is_file())
def _compose_bestanden(d):
f = d / "files"
return sorted(p for p in f.iterdir()
if f.is_dir() and p.suffix in (".yaml", ".yml")
and "compose" in p.name.lower())
def _gerenderd(d):
"""Render elk compose-bestand met de standaardwaarden en geef de YAML."""
velden = boilerplates.fields(d)
waarden = {f["name"]: (f["default"] if f["default"] is not None else "")
for f in velden}
uit = []
for p in _compose_bestanden(d):
tekst = boilerplates.render_text(p.read_text(encoding="utf-8"), waarden)
uit.append((p, yaml.safe_load(tekst)))
return uit
IDS = [d.name for d in _apps()]
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_template_is_geldige_json(app):
json.loads((app / "template.json").read_text(encoding="utf-8"))
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_rendert_naar_geldige_compose(app):
for pad, doc in _gerenderd(app):
assert isinstance(doc, dict), f"{pad.name} levert geen mapping op"
assert doc.get("services"), f"{pad.name} heeft geen services"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_alle_variabelen_zijn_gedefinieerd(app):
"""Een variabele zonder veld rendert leeg; dat levert stilzwijgend een
kapotte instelling op in plaats van een foutmelding."""
namen = {f["name"] for f in boilerplates.fields(app)}
for p in _compose_bestanden(app):
ruw = p.read_text(encoding="utf-8")
gebruikt = set(VAR_RE.findall(ruw))
for blok in BLOK_RE.findall(ruw):
gebruikt |= (set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", blok))
- SLEUTELWOORDEN)
onbekend = sorted(gebruikt - namen)
assert not onbekend, f"{p.name}: niet gedefinieerd: {onbekend}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_velden_die_nergens_gebruikt_worden(app):
"""Een veld dat nergens terechtkomt, liegt tegen de gebruiker: je vult een
datamap in en je gegevens staan ergens anders."""
velden = [f for f in boilerplates.fields(app) if not f.get("is_group_toggle")]
gebruikt = set()
for p in _compose_bestanden(app):
ruw = p.read_text(encoding="utf-8")
gebruikt |= set(VAR_RE.findall(ruw))
for blok in BLOK_RE.findall(ruw):
gebruikt |= set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", blok))
# Ook andere bestanden in files/ mogen variabelen gebruiken.
for p in (app / "files").rglob("*"):
if p.is_file() and p.suffix not in (".yaml", ".yml"):
try:
gebruikt |= set(VAR_RE.findall(p.read_text(encoding="utf-8")))
except (UnicodeDecodeError, OSError):
pass
ongebruikt = sorted(f["name"] for f in velden if f["name"] not in gebruikt)
assert not ongebruikt, f"velden zonder werking: {ongebruikt}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_named_volumes_staan_bovenaan(app):
"""Compose weigert een volume dat niet in het top-level blok staat."""
for pad, doc in _gerenderd(app):
top = set(doc.get("volumes") or {})
for snaam, svc in (doc.get("services") or {}).items():
for v in (svc or {}).get("volumes") or []:
if not isinstance(v, str):
continue
bron = v.split(":")[0]
if bron and not bron.startswith(("/", ".", "~", "$")):
assert bron in top, f"{pad.name}: {snaam} gebruikt '{bron}'"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_depends_on_verwijst_naar_bestaande_services(app):
for pad, doc in _gerenderd(app):
services = doc.get("services") or {}
for snaam, svc in services.items():
dep = (svc or {}).get("depends_on")
for d in (list(dep) if isinstance(dep, (list, dict)) else []):
assert d in services, f"{pad.name}: {snaam} → onbekend '{d}'"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_dubbele_containernamen(app):
for pad, doc in _gerenderd(app):
namen = [svc.get("container_name") for svc in (doc.get("services") or {}).values()
if isinstance(svc, dict) and svc.get("container_name")]
assert len(namen) == len(set(namen)), f"{pad.name}: dubbel in {namen}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_poortconflict_binnen_een_stack(app):
"""Twee services die dezelfde hostpoort claimen levert een stack op die
half start. TCP en UDP op hetzelfde nummer mag wel."""
for pad, doc in _gerenderd(app):
bezet = {}
for snaam, svc in (doc.get("services") or {}).items():
for pr in (svc or {}).get("ports") or []:
s = str(pr)
proto = "udp" if s.endswith("/udp") else "tcp"
deel = s.replace("/udp", "").replace("/tcp", "").split(":")
if len(deel) >= 2 and deel[-2].strip('"').isdigit():
bezet.setdefault((deel[-2].strip('"'), proto), []).append(snaam)
dubbel = {k: v for k, v in bezet.items() if len(v) > 1}
assert not dubbel, f"{pad.name}: {dubbel}"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_poort_in_een_standaardwaarde_klopt_met_het_poortveld(app):
"""Homepage weigerde met 'Host validation failed' omdat allowed_hosts
poort 3001 noemde terwijl het poortveld op 3002 stond. Dezelfde fout zat in
baserow, hedgedoc en ntfy."""
velden = boilerplates.fields(app)
poorten = {f["default"] for f in velden
if f["type"] == "int" and "port" in f["name"]
and isinstance(f["default"], int)}
if not poorten:
return
for f in velden:
d = f.get("default")
if not isinstance(d, str):
continue
for m in re.finditer(r"[a-zA-Z0-9_.-]+:(\d{2,5})\b", d):
genoemd = int(m.group(1))
# Alleen poorten die duidelijk bij deze app horen; een willekeurig
# ander nummer (een SMTP-poort bijvoorbeeld) valt hier buiten.
if any(abs(genoemd - p) < 1000 for p in poorten):
assert genoemd in poorten, (
f"{f['name']}={d!r} noemt poort {genoemd}, "
f"maar de poortvelden staan op {sorted(poorten)}")
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_wachtwoordvelden_zijn_als_geheim_gemarkeerd(app):
"""Anders staat het wachtwoord leesbaar in beeld en ontbreekt de knop om er
een te laten maken."""
for f in boilerplates.fields(app):
naam = f["name"].lower()
if any(w in naam for w in ("password", "secret", "token", "_key")):
assert f["secret"] is True, f"{f['name']} is niet als geheim gemarkeerd"
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_geen_vast_wachtwoord_in_het_sjabloon(app):
"""Acht apps hadden er een: 'immich_db_pass' voor iedereen die het
installeert, en 'admin' als beheerderswachtwoord van Grafana en Gotify.
Een standaardwaarde die overal hetzelfde is, is geen wachtwoord."""
for f in boilerplates.fields(app):
if not f.get("secret"):
continue
waarde = str(f.get("default") or "").strip()
assert not waarde, (
f"{f['name']} heeft een vaste waarde ({waarde!r}); maak het veld "
f"leeg en zet 'generate' aan, of laat de gebruiker het invullen")
@pytest.mark.parametrize("app", _apps(), ids=IDS)
def test_verplichte_geheimen_zijn_invulbaar_of_worden_gegenereerd(app):
"""Een verplicht geheim zonder standaardwaarde moet óf automatisch gevuld
worden, óf iets zijn dat de gebruiker zelf aanlevert. Wat overblijft is een
veld waar niemand een geldige waarde voor kan verzinnen."""
# Wachtwoorden waarmee je zelf inlogt en tokens die je elders ophaalt vult
# de gebruiker bewust zelf in.
eigen_invoer = ("admin_password", "web_password", "ui_password",
"superuser_password", "password_hash", "hass_token",
"forgejo_token", "adminpass")
for f in boilerplates.fields(app):
if not (f.get("required") and f.get("secret")):
continue
if str(f.get("default") or "").strip():
continue
if f.get("generate") or f["name"] in eigen_invoer:
continue
pytest.fail(f"{f['name']}: verplicht geheim zonder waarde, zonder "
f"generator en niet als eigen invoer aangemerkt")