server-up/tests/test_stack_api.py
Ramon 3ee09eebc7
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m12s
fix(stacks): geslaagde wijziging niet meer terugdraaien
Het audit-logboek las request.remote_addr uit in de achtergrondthread van de
job. De RuntimeError die daarop volgde belandde in de foutafhandeling, die de
zojuist gemaakte backup terugzette — inclusief het oude compose-bestand.

- Adres vóór de threadstart vastleggen bij herconfigureren, koppelen, backup
  maken en terugrollen.
- UniFi: init-script probeert de beheerders 'unifi' en 'root' en herkent een
  server zonder authenticatie, zodat ook een oud Mongo-volume gerepareerd
  wordt; bestaande rollen blijven staan en 'backup'/'restore' worden aangevuld.
- UniFi: healthcheck van Mongo zonder inloggegevens, anders bleef de container
  ongezond en startte de reparatiecontainer nooit.
- Backups: mongodump/mongorestore gebruiken de beheerder uit compose in plaats
  van de verzonnen gebruiker 'app'.
- Appkaart: knop die de webinterface opent, met het adres uit /api/stacks.
- Joblog: geen dubbele regels meer doordat polls elkaar konden overlappen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 16:44:01 +02:00

157 lines
6.1 KiB
Python

"""Routes rond stacks: containerbeheer en de netwerkkeuze bij installeren."""
from conftest import login
def test_containers_van_onbekende_stack(client):
login(client)
r = client.get("/api/stacks/bestaatniet/containers")
assert r.status_code == 404
def test_containeractie_alleen_binnen_eigen_stack(client):
"""De containernaam wordt getoetst aan compose_ps van díé stack, zodat deze
route nooit een willekeurige container op de host kan raken."""
csrf = login(client)
r = client.post("/api/stacks/mijnstack/containers/traefik/stop",
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
assert "hoort niet bij deze stack" in r.get_json()["msg"]
def test_onbekende_containeractie(client):
csrf = login(client)
r = client.post("/api/stacks/mijnstack/containers/web/verwijder",
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_containerlogs_alleen_binnen_eigen_stack(client):
login(client)
r = client.get("/api/stacks/mijnstack/containers/vreemde/logs")
assert r.status_code == 404
def test_install_weigert_onbekend_netwerk(client):
csrf = login(client)
r = client.post("/api/store/install",
json={"stack": "vaultwarden", "repo_id": "server-up",
"instance": "vw", "network": "bestaatniet"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "niet geconfigureerd" in r.get_json()["msg"]
def test_install_weigert_ip_buiten_subnet(client, env):
csrf = login(client)
env["core"].patch({"NETWORKS": [{
"name": "lan", "driver": "macvlan", "parent": "eth0",
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1"}]})
r = client.post("/api/store/install",
json={"stack": "vaultwarden", "repo_id": "server-up",
"instance": "vw", "network": "lan", "ip": "10.9.9.9"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "buiten het subnet" in r.get_json()["msg"]
def test_netwerk_toevoegen_valideert(client):
csrf = login(client)
r = client.post("/api/networks",
json={"name": "lan", "driver": "macvlan",
"parent": "eth0; id", "subnet": "192.168.1.0/24"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_netwerk_verwijderen_onbekend(client):
csrf = login(client)
r = client.delete("/api/networks/bestaatniet", headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_gereserveerde_ips_worden_gelezen_uit_stacks(client, env):
"""Een geïnstalleerde stack houdt zijn adres, ook als hij gestopt is."""
import json
login(client)
stack = env["lib"] / "vaultwarden"
stack.mkdir()
(stack / ".serverup.json").write_text(json.dumps({"ip": "192.168.1.241"}))
import app as app_module
with app_module.app.test_request_context():
assert "192.168.1.241" in app_module._reserved_ips()
assert "192.168.1.241" not in app_module._reserved_ips(exclude_stack="vaultwarden")
def test_stack_meldt_wat_er_niet_meer_draait(client, env, monkeypatch):
"""De melding bij het installeren was eenmalig. Haal je Mosquitto later weg,
dan valt Zigbee2MQTT stil zonder dat iets zegt waarom."""
import json as _json
from core import docker, git
login(client)
bron = git.cache_dir("server-up") / "apps" / "z2m"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Zigbee2MQTT", "description": "d", "tags": ["mqtt"],
"depends_on": [{"app": "mosquitto", "reason": "broker nodig"}]},
"variables": [],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
d = env["lib"] / "z2m"
d.mkdir(parents=True)
(d / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
(d / ".serverup.json").write_text(_json.dumps(
{"source": "z2m", "repo_id": "server-up"}))
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: [])
st = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert st["z2m"]["depends_missing"] == [
{"app": "mosquitto", "reason": "broker nodig"}]
# Zodra de broker er staat, is de melding weg.
mq = env["lib"] / "mqtt"
mq.mkdir()
(mq / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
(mq / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"}))
st = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert st["z2m"]["depends_missing"] == []
def test_stacklijst_geeft_het_webadres_van_de_app(client, env):
"""Het adres stond alleen in de modal met inloggegevens; op de kaart hoort
een knop te staan die de app opent — dus moet de lijst het adres kennen."""
import json
d = env["lib"] / "unifi-network"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n"
" unifi-mongo:\n"
" image: mongo:4.4.29\n"
" unifi:\n"
" image: lscr.io/linuxserver/unifi-network-application:latest\n"
" ports:\n"
' - "8443:8443"\n'
' - "8080:8080"\n')
(d / ".serverup.json").write_text(json.dumps({
"source": "unifi-network", "values": {"port_ui": 8443},
"web": {"scheme": "https", "port": 8443}}))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["unifi-network"]["web_url"].startswith("https://")
assert per_naam["unifi-network"]["web_url"].endswith(":8443")
def test_stack_zonder_gepubliceerde_poort_heeft_geen_webadres(client, env):
import json
d = env["lib"] / "worker"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n worker:\n image: busybox\n")
(d / ".serverup.json").write_text(json.dumps({"source": "worker"}))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["worker"]["web_url"] == ""