server-up/tests/test_invulmenu.py
Ramon bc55252267
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m34s
v0.7.70-beta - invulmenu bij installeren, geheimen naar .env
Invulmenu:
- Vaste stappen (Basis, Verbinden, Instellingen, Toegang, Controleren) in
  plaats van één lange lijst. Eén stap per groep werkt niet: de mediane app
  heeft één groep, arr-stack eenentwintig. Lege stappen worden overgeslagen.
- Volgende controleert de verplichte velden van die stap; die melding kwam
  eerder pas bij het installeren.
- Geavanceerde opties achter één schakelaar die op elke stap zichtbaar is,
  plus 'Alles op één pagina' voor het oude gedrag. Beide onthouden.

Geheimen naar .env:
- compose_transform.geheimen_naar_env() vervangt geheime waarden door
  ${NAAM} en schrijft ze naar .env met rechten 0600. Werkt ook midden in
  een database-URL. Poorten en paden blijven leesbaar in compose.
- .serverup.json bevat geen geheimen meer en krijgt ook 0600; daar stonden
  ze wereldleesbaar in. Het herconfiguratieformulier leest ze terug uit
  .env via een bewaarde veld-naar-sleutel-koppeling.
- docs/beveiliging.md legt uit wat dit niet oplost: docker inspect toont de
  waarde nog steeds.

Bewerken:
- Laatste stap toont het gerenderde resultaat met jouw waarden en is
  bewerkbaar; compose_override/env_override gaan mee bij het installeren.
  Ongeldige YAML wordt geweigerd voordat er een map bestaat.
- De bestaande editor heeft tabbladen (compose/.env) en biedt herstarten na
  opslaan. De .env-PUT weigert een regel zonder '=' en logt in audit.

Inloggegevens:
- /api/stacks/<n>/credentials plus paneel op de stackkaart: adres,
  gebruikersnaam, wachtwoord achter een oog-knop, kopieerknop. Alleen voor
  beheerders, elk bekijken komt in het auditlog.
- Nieuw 'credential'-kenmerk in template.json met naamherkenning als
  terugval, zodat de bestaande 96 sjablonen meteen werken.

Herstel van een regressie uit v0.7.60: acht inlogwachtwoorden hadden hun
genereerknop verloren toen die aan 'generate' werd gehangen. Terug, behalve
waar een verzonnen waarde fout is (WireGuard-sleutel, externe tokens).

De menulogica wordt getest door de echte component uit index.html in Node uit
te voeren; slaat zichzelf over waar node ontbreekt. 2295 tests groen.

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

340 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": "data_dir"}, "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"]