server-up/tests/test_reconfigure.py
Ramon d3ebf40165
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
feat: wachtwoordsync, slot per app, sneller dashboard en versietags
Vier verbeteringen die elk pas opvielen als je ze nodig had.

- Een gewijzigd databasewachtwoord wordt vóór de herstart omgezet in de nog
  draaiende database (ALTER USER / updateUser). Lukt dat niet, dan breekt de
  wijziging af en gaat de backup terug — 64 sjablonen hebben zo'n veld, en
  POSTGRES_PASSWORD werkt alleen bij een lege datamap. Wachtwoorden gaan via
  de omgeving en via stdin, niet via argumenten.
- jobs.met_slot serialiseert taken per app; de geplande backup slaat een
  bezette app over in plaats van een half herschreven map in te pakken. Een
  taak die klapt laat het slot los en wordt als mislukt afgesloten.
- api_stacks en api_connectable gebruiken één docker ps met het projectlabel
  in plaats van een compose ps per stackmap.
- Per service een versietag te kiezen, bewaard in .serverup.json en opnieuw
  toegepast na het renderen; het containerpaneel toont het draaiende image.

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

432 lines
19 KiB
Python

"""Instellingen wijzigen na installatie, koppelen, en het audit-filter."""
import json
import sys
import time
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login
def _wacht_op_job(client, jid, tijdslimiet=10):
"""Installeren gebeurt in een achtergrondthread; wachten tot hij klaar is."""
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 _stack(env, naam="web", meta=None, compose=None):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
compose or "services:\n web:\n image: nginx\n ports:\n - \"8080:80\"\n")
if meta is not None:
(d / ".serverup.json").write_text(json.dumps(meta), encoding="utf-8")
return d
# ── Herconfigureren ──────────────────────────────────────────────────────────
def test_config_zonder_opgeslagen_waarden(client, env):
"""Stacks van vóór deze versie hebben geen keuzes bewaard; dan moet de
interface uitleggen waarom de knop niet werkt in plaats van te falen."""
login(client)
_stack(env, "oud", {"source": "oud"})
d = client.get("/api/stacks/oud/config").get_json()
assert d["ok"] is False and d["editable"] is False
assert "compose bewerken" in d["msg"]
def test_config_van_onbekende_stack(client):
login(client)
assert client.get("/api/stacks/bestaatniet/config").status_code == 404
def test_stacklijst_meldt_of_wijzigen_kan(client, env):
login(client)
_stack(env, "nieuw", {"source": "x", "values": {"port": 80}})
_stack(env, "oud", {"source": "x"})
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["nieuw"]["editable"] is True
assert per_naam["oud"]["editable"] is False
def test_reconfigure_zonder_bronsjabloon(client, env):
csrf = login(client)
_stack(env, "web", {"source": "bestaatniet", "repo_id": "x", "values": {"a": 1}})
r = client.post("/api/stacks/web/reconfigure", json={"values": {"a": 2}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "Bronsjabloon" in r.get_json()["msg"]
def test_reconfigure_van_onbekende_stack(client):
csrf = login(client)
r = client.post("/api/stacks/bestaatniet/reconfigure", json={"values": {}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
"""Zonder opgeslagen waarden valt het formulier later niet te heropenen.
Was een controle op een letterlijke regel broncode; die brak zodra de regel
verplaatste terwijl het gedrag klopte.
"""
import json as _json
from core import git
csrf = login(client)
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "keuzeapp", "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "keuzeapp", "repo_id": "server-up",
"instance": "kz", "values": {"port": 9123}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht_op_job(client, r.get_json()["job_id"])
meta = _json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
assert meta["values"]["port"] == 9123
assert meta["source"] == "keuzeapp"
def _installeer_keuzeapp(client, csrf, poort=9123):
"""Zet een sjabloon in de git-cache en installeer het als stack 'kz'."""
import json as _json
from core import git
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "keuzeapp", "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "keuzeapp", "repo_id": "server-up",
"instance": "kz", "values": {"port": poort}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht_op_job(client, r.get_json()["job_id"])
def test_geslaagde_wijziging_wordt_niet_teruggedraaid(client, env, monkeypatch):
"""De job draait in een eigen thread, waar `request` niet meer bestaat.
Het adres voor het audit-logboek werd daar alsnog uitgelezen: een
RuntimeError precies nádat de stack met de nieuwe instellingen al draaide.
De foutafhandeling zette vervolgens de backup terug, dus een geslaagde
wijziging werd alsnog ongedaan gemaakt.
"""
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose",
lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: {"file": "kz_test.tar.gz"})
monkeypatch.setattr(app_module.backups, "restore",
lambda *a, **k: pytest.fail("backup werd teruggezet"))
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9124},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
d = _wacht_op_job(client, r.get_json()["job_id"])
tekst = "\n".join(r["text"] for r in d.get("lines", []))
assert "RuntimeError" not in tekst, tekst
assert d.get("status") == "done", tekst
compose = next((p for p in (env["lib"] / "kz").iterdir()
if p.name in ("compose.yaml", "compose.yml",
"docker-compose.yml", "docker-compose.yaml")))
assert '"9124:80"' in compose.read_text()
def test_wijziging_wordt_vastgelegd_met_het_adres_van_de_aanvrager(
client, env, monkeypatch):
"""Het IP-adres hoort in het audit-logboek te belanden, niet verloren te
gaan doordat we het pas in de achtergrondthread opvragen."""
import app as app_module
from core import audit
audit.init()
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose",
lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: {"file": "kz_test.tar.gz"})
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9125},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
regels = [x for x in audit.query(50, src="stacks")
if x["action"] == "reconfigure"]
assert regels and regels[0]["status"] == "ok"
assert regels[0]["ip"]
# ── Koppelen ─────────────────────────────────────────────────────────────────
def test_connect_van_onbekende_stack(client):
csrf = login(client)
r = client.post("/api/stacks/bestaatniet/connect", json={"connect": True},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_connect_start_een_job(client, env):
csrf = login(client)
_stack(env, "web", {"source": "x"})
d = client.post("/api/stacks/web/connect", json={"connect": True},
headers={"X-CSRF-Token": csrf}).get_json()
assert d["ok"] and d["job_id"]
def test_viewer_mag_niet_herconfigureren(client, env):
csrf = login(client)
_stack(env, "web", {"source": "x", "values": {}})
client.post("/api/auth/users",
json={"username": "kijker", "password": "eenlangwachtwoord",
"role": "viewer"}, headers={"X-CSRF-Token": csrf})
kijker = client.post("/api/auth/login",
json={"username": "kijker", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
assert client.post("/api/stacks/web/reconfigure", json={"values": {}},
headers={"X-CSRF-Token": kijker}).status_code == 403
# ── Audit-filter ─────────────────────────────────────────────────────────────
@pytest.fixture
def gevuld(env):
from core import audit
audit.init()
audit.log("auth", "login", "ok", ref="ramon", ip="10.0.0.1")
audit.log("auth", "login", "error", ref="indringer", ip="10.9.9.9")
audit.log("stacks", "start", "ok", ref="jellyfin")
audit.log("backups", "create", "ok", ref="jellyfin")
return audit
def test_filter_op_bron(gevuld):
assert len(gevuld.query(100, src="auth")) == 2
assert gevuld.count(src="auth") == 2
def test_filter_op_actie_en_status(gevuld):
r = gevuld.query(100, src="auth", action="login", status="error")
assert len(r) == 1 and r[0]["ref"] == "indringer"
def test_zoeken_in_referentie_en_ip(gevuld):
assert len(gevuld.query(100, zoek="jellyfin")) == 2
assert len(gevuld.query(100, zoek="10.9.9.9")) == 1
def test_filter_op_periode(gevuld):
assert gevuld.count(sinds=time.time() - 60) == 4
assert gevuld.count(sinds=time.time() + 60) == 0
def test_doorbladeren(gevuld):
eerste = gevuld.query(2, 0)
tweede = gevuld.query(2, 2)
assert len(eerste) == 2 and len(tweede) == 2
assert {r["id"] for r in eerste}.isdisjoint({r["id"] for r in tweede})
def test_facetten_voor_de_keuzelijsten(gevuld):
f = gevuld.facets()
assert set(f["src"]) == {"auth", "stacks", "backups"}
assert "login" in f["action"]
assert set(f["status"]) == {"ok", "error"}
def test_audit_endpoint_filtert(client, env, gevuld):
login(client)
d = client.get("/api/audit?src=auth&status=error").get_json()
assert d["total"] == 1
assert d["entries"][0]["ref"] == "indringer"
assert "auth" in d["facets"]["src"]
assert d["grand_total"] >= d["total"]
def test_audit_endpoint_limiet_wordt_begrensd(client, env, gevuld):
login(client)
d = client.get("/api/audit?limit=99999").get_json()
assert d["limit"] <= 500
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
def _wijzig(client, csrf, extras, poort=9200):
return client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": poort},
"connect_apps": False, "extras": extras},
headers={"X-CSRF-Token": csrf})
def test_datamap_en_apparaat_belanden_in_de_compose(client, env, monkeypatch):
"""Een mediamap of USB-stick toevoegen kon alleen via 'compose bewerken',
en dat werd bij de eerstvolgende wijziging overschreven."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
r = _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
"container": "/media", "ro": True}],
"devices": [{"service": "keuzeapp", "host": "/dev/ttyUSB0"}]})
assert r.status_code == 200, r.get_json()
d = _wacht_op_job(client, r.get_json()["job_id"])
assert d.get("status") == "done", d.get("lines")
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
assert "/srv/media:/media:ro" in svc["volumes"]
assert "/dev/ttyUSB0:/dev/ttyUSB0" in svc["devices"]
# En ze staan in de metadata, dus een volgende wijziging behoudt ze.
meta = json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
assert meta["extras"]["volumes"][0]["host"] == "/srv/media"
def test_extras_overleven_een_volgende_wijziging(client, env, monkeypatch):
"""Het renderen begint elke keer opnieuw uit het sjabloon; zonder bewaarde
extra's zou je map er na één instellingswijziging weer af zijn."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
for naam in ("validate_compose",):
monkeypatch.setattr(app_module.docker, naam, lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
_wacht_op_job(client, _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
"container": "/media"}]}).get_json()["job_id"])
# Nu een wijziging zonder extras mee te sturen: de bewaarde keuze telt.
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9201},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
assert "/srv/media:/media" in svc["volumes"]
def test_gevaarlijk_pad_wordt_geweigerd_met_uitleg(client, env):
"""Niet stil corrigeren: wie /etc intikt hoort te horen waarom dat niet gaat."""
csrf = login(client)
_installeer_keuzeapp(client, csrf)
r = _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/var/run/docker.sock",
"container": "/var/run/docker.sock"}]})
assert r.status_code == 400
assert "Docker-socket" in r.get_json()["msg"]
r = _wijzig(client, csrf, {
"devices": [{"service": "keuzeapp", "host": "/etc/shadow"}]})
assert r.status_code == 400
assert "/dev/" in r.get_json()["msg"]
def test_apparatenlijst_is_op_te_vragen(client, env):
login(client)
d = client.get("/api/devices").get_json()
assert "devices" in d and isinstance(d["devices"], list)
for dev in d["devices"]:
assert dev["path"].startswith("/dev/")
# ── Versietags ───────────────────────────────────────────────────────────────
def test_versietag_wordt_vastgehouden_over_wijzigingen_heen(client, env, monkeypatch):
"""Sjablonen staan meestal op `latest`; wie een versie kiest wil niet dat
de eerstvolgende instellingswijziging hem daar weer af haalt."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9300},
"connect_apps": False,
"image_tags": {"keuzeapp": "1.25-alpine"}},
headers={"X-CSRF-Token": csrf})
assert _wacht_op_job(client, r.get_json()["job_id"]).get("status") == "done"
def _image():
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
return _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]["image"]
assert _image() == "nginx:1.25-alpine"
# Een volgende wijziging zonder tags: de keuze staat in de metadata en
# hoort te blijven staan, ook al rendert het sjabloon weer `nginx`.
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9301},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
assert _image() == "nginx:1.25-alpine"
def test_onzinnige_versietag_wordt_genegeerd(client, env, monkeypatch):
"""De tag belandt in het compose-bestand; een waarde met een spatie of een
dubbele punt erin zou dat bestand stukmaken."""
import app as app_module
assert app_module._gekozen_image_tags({"image_tags": {"a": "1.2.3"}}) == {"a": "1.2.3"}
for onzin in ("mijn tag", "tag:met:dubbelepunt", "-begintmetstreepje", "", " "):
assert app_module._gekozen_image_tags({"image_tags": {"a": onzin}}) == {}