server-up/tests/test_invulmenu.py
Ramon 58d9be1f48
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m1s
v0.7.90-beta - ARR-stack opgesplitst, /data-mount, netwerkopties per container
- Elk *arr-onderdeel is nu ook een losse app in de store (46 nieuwe sjablonen);
  los en gebundeld komen uit één bron (tools/arr_sjablonen.py) met een test die
  bewaakt dat ze niet uiteen lopen
- Alles deelt één /data-map, zodat hardlinks werken; losse /tv-, /movies- en
  /downloads-mounts zijn eruit, UMASK=002 erbij
- Nieuwe eerste stap "Onderdelen" in het invulmenu, met kopjes per soort; de
  dubbele groepsschakelaar en de lege kaarten in Instellingen zijn weg
- Eigen IP-adres per container in plaats van per stack; containers zonder adres
  houden hun poortmapping. Ook Pangolin publiceert nu per container
- Huntarr vervangen door NeutArr (project offline na lekken, image bestaat niet
  meer), Maintainerr naar zijn nieuwe organisatie
- 31 apps toegevoegd uit het *arr-ecosysteem, alle images tegen hun registry
  gecontroleerd
- data_dir hernoemd naar appdata_dir in de hele catalogus, naast de nieuwe
  data_root; qBittorrent van poort 8080 naar 8097

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-07-31 22:21:04 +02:00

341 lines
14 KiB
Python

"""Het invulmenu: stapindeling, inloggegevens en zelf bewerken.
Het formulier was één lange lijst. Dat werkt slecht aan beide uiteinden: de
mediane app heeft vijf velden in één groep, arr-stack heeft er tweeënvijftig in
eenentwintig groepen. Vandaar vaste stappen in plaats van één stap per groep.
"""
import json
import stat
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
APPS = Path(__file__).resolve().parent.parent / "apps"
@pytest.fixture
def bp(env):
from core import boilerplates
return boilerplates
def _veld(bp, **kw):
"""Eén los veld door fields() halen, om de indeling te toetsen."""
from core import git
d = git.cache_dir("server-up") / "apps" / "los"
(d / "files").mkdir(parents=True, exist_ok=True)
item = {"name": "x", "type": "str", "title": "X"}
item.update(kw)
(d / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": "los"},
"variables": [{"title": "Algemeen", "items": [item]}]}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text("services:\n a:\n image: nginx\n",
encoding="utf-8")
return bp.fields(d)[0]
# ── Indeling in stappen ──────────────────────────────────────────────────────
@pytest.mark.parametrize("kw,stap", [
({"name": "service_name"}, "basis"),
({"name": "port", "type": "int"}, "basis"),
({"name": "web_port", "type": "int"}, "basis"),
({"name": "appdata_dir"}, "basis"),
({"name": "data_root"}, "basis"),
({"name": "timezone"}, "basis"),
({"name": "db_password"}, "geheimen"),
({"name": "jwt_secret"}, "geheimen"),
({"name": "admin_user"}, "geheimen"), # hoort bij het wachtwoord
({"name": "admin_email"}, "geheimen"),
({"name": "retention"}, "instellingen"),
({"name": "allow_signup", "type": "bool"}, "instellingen"),
({"name": "broker", "connect": {"app": "mosquitto"}}, "verbinden"),
])
def test_velden_komen_in_de_juiste_stap(bp, kw, stap):
assert _veld(bp, **kw)["step"] == stap
def test_sjabloon_mag_de_stap_overrulen(bp):
"""Een app-maker moet een veld ergens anders kunnen neerzetten."""
assert _veld(bp, name="retention", step="basis")["step"] == "basis"
def test_onbekende_stap_valt_terug(bp):
assert _veld(bp, name="retention", step="verzonnen")["step"] == "instellingen"
@pytest.mark.parametrize("app,verwacht", [
("grafana", {"basis", "geheimen"}),
("vaultwarden", {"basis", "instellingen", "geheimen"}),
])
def test_echte_apps_krijgen_zinnige_stappen(bp, app, verwacht):
stappen = {f["step"] for f in bp.fields(APPS / app)}
assert stappen == verwacht
def test_de_vangnetstap_slokt_niet_alles_op(bp):
"""Een app waarvan alle velden in 'instellingen' belanden, betekent dat de
indeling niets heeft herkend. Alles in 'basis' is juist prima: de meeste
apps vragen alleen om een naam, een poort en een map."""
mislukt = []
for d in sorted(APPS.iterdir()):
if not (d / "template.json").is_file():
continue
velden = bp.fields(d)
if len(velden) > 3 and {f["step"] for f in velden} == {"instellingen"}:
mislukt.append(d.name)
assert not mislukt, mislukt
def test_eenvoudige_apps_krijgen_maar_een_paar_stappen(bp):
"""Voor de mediane app hoort het invulmenu kort te zijn."""
velden = bp.fields(APPS / "freshrss")
assert {f["step"] for f in velden} == {"basis"}
# ── Inloggegevens ────────────────────────────────────────────────────────────
@pytest.mark.parametrize("naam,soort", [
("admin_password", "password"),
("web_password", "password"),
("admin_user", "username"),
("admin_email", "username"),
("superuser_name", "username"),
("jwt_secret", ""), # een sleutel is geen inloggegeven
("api_key", ""),
("port", ""),
])
def test_inloggegevens_worden_herkend(bp, naam, soort):
assert _veld(bp, name=naam)["credential"] == soort
def test_sjabloon_mag_credential_overrulen(bp):
assert _veld(bp, name="iets", credential="username")["credential"] == "username"
def _bron(env, naam="inlogapp"):
from core import git
d = git.cache_dir("server-up") / "apps" / naam
(d / "files").mkdir(parents=True, exist_ok=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": naam},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": naam, "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8123},
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
"default": "admin"},
{"name": "admin_password", "type": "str", "title": "Wachtwoord",
"default": "", "required": True},
]}]}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n"
" ports:\n - \"<< port >>:80\"\n"
" environment:\n"
" - ADMIN_USER=<< admin_user >>\n"
" - ADMIN_PASSWORD=<< admin_password >>\n", encoding="utf-8")
return d
def _wacht(client, jid, tijdslimiet=10):
einde = time.time() + tijdslimiet
while time.time() < einde:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
return d
time.sleep(0.05)
return {}
def _installeer(client, env, csrf, inst="ia", **waarden):
_bron(env)
v = {"service_name": inst, "admin_user": "ramon",
"admin_password": "MijnWachtwoord1"}
v.update(waarden)
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": inst, "values": v},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
return r
def test_inloggegevens_tonen_naam_en_wachtwoord(client, env):
csrf = login(client)
_installeer(client, env, csrf)
d = client.get("/api/stacks/ia/credentials").get_json()
assert d["ok"] is True
soorten = {i["kind"]: i["value"] for i in d["items"]}
assert soorten["username"] == "ramon"
assert soorten["password"] == "MijnWachtwoord1"
assert d["self_signup"] is False
def test_wachtwoord_is_als_geheim_gemarkeerd(client, env):
"""De interface verbergt het achter een oog-knop."""
csrf = login(client)
_installeer(client, env, csrf, inst="ib")
d = client.get("/api/stacks/ib/credentials").get_json()
wachtwoord = next(i for i in d["items"] if i["kind"] == "password")
naam = next(i for i in d["items"] if i["kind"] == "username")
assert wachtwoord["secret"] is True
assert naam["secret"] is False
def test_app_zonder_inlogvelden_zegt_dat(client, env):
"""Vaultwarden en Immich laten je bij het eerste bezoek zelf een account
aanmaken; dan valt hier niets te tonen."""
from core import git
csrf = login(client)
d = git.cache_dir("server-up") / "apps" / "geeninlog"
(d / "files").mkdir(parents=True, exist_ok=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": "geeninlog"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "geeninlog", "required": True}]}]}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "geeninlog", "repo_id": "server-up",
"instance": "gi", "values": {}},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
res = client.get("/api/stacks/gi/credentials").get_json()
assert res["items"] == []
assert res["self_signup"] is True
def test_inloggegevens_alleen_voor_beheerders(client, env):
"""Ze staan gelijk aan toegang tot de app zelf."""
csrf = login(client)
_installeer(client, env, csrf, inst="ic")
client.post("/api/auth/users",
json={"username": "op", "password": "eenlangwachtwoord",
"role": "operator"},
headers={"X-CSRF-Token": csrf})
client.post("/api/auth/login",
json={"username": "op", "password": "eenlangwachtwoord"})
r = client.get("/api/stacks/ic/credentials")
assert r.status_code == 403
def test_tonen_komt_in_het_auditlog(client, env):
csrf = login(client)
_installeer(client, env, csrf, inst="id")
from core import audit
audit.init()
client.get("/api/stacks/id/credentials")
assert any(r["action"] == "credentials_view" for r in audit.query())
def test_onbekende_stack(client, env):
login(client)
assert client.get("/api/stacks/bestaatniet/credentials").status_code == 404
# ── Zelf bewerken vóór installeren ───────────────────────────────────────────
def test_eigen_compose_wordt_gebruikt(client, env):
csrf = login(client)
_bron(env)
eigen = ("services:\n handmatig:\n image: nginx:1.27\n"
" container_name: zelf-verzonnen\n")
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": "ie",
"values": {"admin_password": "MijnWachtwoord1"},
"compose_override": eigen},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
assert "zelf-verzonnen" in (env["lib"] / "ie" / "docker-compose.yml").read_text()
def test_eigen_env_wordt_gebruikt(client, env):
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": "if",
"values": {"admin_password": "MijnWachtwoord1"},
"env_override": "HANDMATIG=ja\n"},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
assert "HANDMATIG=ja" in (env["lib"] / "if" / ".env").read_text()
def test_kapotte_yaml_wordt_geweigerd_voordat_er_iets_gemaakt_is(client, env):
"""Een typefout hoort hier te stranden, niet halverwege een installatie."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "inlogapp", "repo_id": "server-up",
"instance": "ig",
"values": {"admin_password": "MijnWachtwoord1"},
"compose_override": "services: [kapot: ja: nee"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "YAML" in r.get_json()["msg"]
assert not (env["lib"] / "ig").exists()
# ── De .env-editor ───────────────────────────────────────────────────────────
def test_env_editor_weigert_een_regel_zonder_isgelijkteken(client, env):
"""Compose negeert zo'n regel stilzwijgend; dan mist er ineens een
wachtwoord zonder dat je het merkt."""
csrf = login(client)
_installeer(client, env, csrf, inst="ih")
r = client.put("/api/stacks/ih/env",
json={"content": "GOED=1\ndit is onzin\n"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "Regel 2" in r.get_json()["msg"]
def test_env_editor_accepteert_commentaar_en_lege_regels(client, env):
csrf = login(client)
_installeer(client, env, csrf, inst="ii")
r = client.put("/api/stacks/ii/env",
json={"content": "# uitleg\n\nGOED=1\n"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert stat.S_IMODE((env["lib"] / "ii" / ".env").stat().st_mode) == 0o600
def test_env_bewerken_komt_in_het_auditlog(client, env):
csrf = login(client)
_installeer(client, env, csrf, inst="ij")
from core import audit
audit.init()
client.put("/api/stacks/ij/env", json={"content": "X=1\n"},
headers={"X-CSRF-Token": csrf})
assert any(r["action"] == "env_edit" for r in audit.query())
# ── Voorvertoning met eigen waarden ──────────────────────────────────────────
def test_voorvertoning_gebruikt_de_ingevulde_waarden(client, env):
"""Anders toont de laatste stap iets anders dan er straks komt te staan."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/preview",
json={"stack": "inlogapp", "repo_id": "server-up",
"values": {"service_name": "eigennaam", "port": 9999,
"admin_password": "MijnWachtwoord1"}},
headers={"X-CSRF-Token": csrf})
d = r.get_json()
assert "eigennaam" in d["compose_preview"]
assert "9999" in d["compose_preview"]
# Het geheim hoort al in de .env-voorvertoning te staan, niet in compose.
assert "MijnWachtwoord1" not in d["compose_preview"]
assert "MijnWachtwoord1" in d["env_preview"]