server-up/tests/test_paden.py
Ramon e61f90745b
Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled
v0.8.05-beta - een onbereikbaar pad liet zich gewoon opslaan
Oorzaak van de lege appslijst: LIBRARY_DIR stond op /srv/serverup/stacks
terwijl alleen /opt/serverup gekoppeld is. Dat pad bestaat binnen de container
niet, dus geen apps in de lijst en alle containers onder "handmatig gestart" —
want beheerd leest dezelfde map. Nergens stond waarom.

paden.bereikbaar() bestond al sinds v0.7.92 maar hing alleen aan het veld
Hoofdmap en aan het verhuizen. De drie padvelden schrijven rechtstreeks naar
PUT /api/settings, en daar stond geen wacht.

- PUT /api/settings weigert nu een pad waar Server Up niet bij kan, met de
  regel voor .env erbij. Een ongewijzigd pad blokkeert niets, anders kun je met
  een kapotte instelling niets meer wijzigen
- een lege appslijst zegt nu waarom in plaats van "Geen apps gevonden"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-03 22:22:29 +02:00

428 lines
18 KiB
Python

"""De mappen van Server Up verplaatsen.
Dit is het enige onderdeel dat gegevens van de gebruiker weggooit, dus de
volgorde is de hele zaak: kopiëren, controleren, en pas daarna opruimen. Een
verhuizing die halverwege strandt met het origineel al verwijderd is het ergste
dat hier kan gebeuren.
"""
import json
import os
import sys
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 _vul(d: Path, bestanden: dict[str, str]) -> Path:
for naam, inhoud in bestanden.items():
p = d / naam
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(inhoud, encoding="utf-8")
return d
# ── Hoofdmap en afgeleide paden ──────────────────────────────────────────────
def test_afgeleide_paden(env):
import core
assert core.afgeleide_paden("/srv/serverup") == {
"LIBRARY_DIR": "/srv/serverup/stacks",
"DATA_DIR": "/srv/serverup/appdata",
"BACKUP_DIR": "/srv/serverup/backups",
}
# Een afsluitende schuine streep hoort niet te verdubbelen.
assert core.afgeleide_paden("/srv/serverup/")["DATA_DIR"] == "/srv/serverup/appdata"
# Een relatief pad levert niets op in plaats van iets kapots.
assert core.afgeleide_paden("srv/data") == {}
assert core.afgeleide_paden("") == {}
def test_base_dir_stuurt_de_standaarden(monkeypatch, tmp_path):
"""De drie paden stonden hard op /opt/serverup/*, ook als je BASE_DIR
verzette — precies waarom de interface /opt bleef voorstellen."""
import importlib
monkeypatch.setenv("BASE_DIR", "/srv/eigen")
monkeypatch.setenv("SU_CONFIG", str(tmp_path / "c.json"))
for naam in [n for n in sys.modules if n == "core" or n.startswith("core.")]:
del sys.modules[naam]
core = importlib.import_module("core")
assert core.DEFAULTS["LIBRARY_DIR"] == "/srv/eigen/stacks"
assert core.DEFAULTS["DATA_DIR"] == "/srv/eigen/appdata"
assert core.DEFAULTS["BACKUP_DIR"] == "/srv/eigen/backups"
# ── Meten en verplaatsen ─────────────────────────────────────────────────────
def test_meet_telt_bestanden_en_bytes(env, tmp_path):
from core import paden
d = _vul(tmp_path / "bron", {"a.txt": "12345", "sub/b.txt": "678"})
aantal, bytes_ = paden.meet(d)
assert (aantal, bytes_) == (2, 8)
assert paden.meet(tmp_path / "bestaat-niet") == (0, 0)
def test_verhuizen_kopieert_en_ruimt_daarna_op(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo", "sub/b.txt": "wereld"})
doel = tmp_path / "nieuw"
stappen = []
n = paden.verhuis(bron, doel, voortgang_fn=lambda g, t, l: stappen.append((g, t)))
assert n == 2
assert (doel / "a.txt").read_text() == "hallo"
assert (doel / "sub" / "b.txt").read_text() == "wereld"
assert not bron.exists(), "de bron hoort na een geslaagde verhuizing weg te zijn"
assert stappen and stappen[-1][0] == stappen[-1][1], "voortgang loopt niet vol"
def test_de_bron_blijft_staan_als_de_controle_faalt(env, tmp_path, monkeypatch):
"""Als er onderweg iets misgaat mag het origineel niet verdwijnen."""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo"})
doel = tmp_path / "nieuw"
# Doe alsof er minder is aangekomen dan verstuurd.
echt = paden.meet
def nep(p):
return (0, 0) if Path(p) == doel.resolve() else echt(p)
monkeypatch.setattr(paden, "meet", nep)
with pytest.raises(paden.PadFout, match="Controle mislukt"):
paden.verhuis(bron, doel)
assert (bron / "a.txt").exists(), "de bron is weg terwijl de controle faalde"
def test_doel_binnen_de_bron_wordt_geweigerd(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "x"})
with pytest.raises(paden.PadFout, match="binnen"):
paden.verhuis(bron, bron / "erin")
def test_zelfde_map_doet_niets(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "x"})
assert paden.verhuis(bron, bron) == 0
assert (bron / "a.txt").exists()
def test_eigenaar_en_rechten_gaan_mee(env, tmp_path, monkeypatch):
"""`shutil.copy2` neemt de rechten mee maar niet de eigenaar.
Server Up draait als root, dus zonder chown werd verhuisde appdata root:root
en kwam een container die als PUID 1000 draait er niet meer in. De test kan
zelf niet chownen (dat mag alleen root), dus we leggen vast wát er gevraagd
wordt.
"""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo", "sub/b.txt": "wereld"})
os.chmod(bron / "a.txt", 0o640)
doel = tmp_path / "nieuw"
gevraagd = {}
monkeypatch.setattr(paden.os, "chown",
lambda p, u, g, **kw: gevraagd.__setitem__(str(p), (u, g)))
paden.verhuis(bron, doel)
eigen = (os.getuid(), os.getgid())
assert gevraagd.get(str(doel)) == eigen, "de hoofdmap zelf blijft van root"
assert gevraagd.get(str(doel / "a.txt")) == eigen
assert gevraagd.get(str(doel / "sub")) == eigen
assert gevraagd.get(str(doel / "sub" / "b.txt")) == eigen
# De rechten komen van copy2/copystat en horen ook te kloppen.
assert os.stat(doel / "a.txt").st_mode & 0o777 == 0o640
def test_de_eigenaar_komt_van_de_bron(env, tmp_path, monkeypatch):
"""De uid/gid moet van het origineel komen, niet van wie er toevallig
kopieert. Chownen kan alleen root, dus we leggen de vraag vast."""
from core import paden
bron = _vul(tmp_path, {"a.txt": "x"}) / "a.txt"
doel = tmp_path / "b.txt"
doel.write_text("x")
st = os.stat(bron)
# Een echte stat_result met een andere eigenaar, zodat copystat blijft werken.
nep = os.stat_result((st.st_mode, st.st_ino, st.st_dev, st.st_nlink,
1000, 1000, st.st_size,
int(st.st_atime), int(st.st_mtime), int(st.st_ctime)))
monkeypatch.setattr(paden.os, "stat", lambda p, **kw: nep)
gevraagd = []
monkeypatch.setattr(paden.os, "chown",
lambda p, u, g, **kw: gevraagd.append((u, g)))
paden._neem_eigenaar_over(bron, doel, [])
assert gevraagd == [(1000, 1000)]
def test_chown_die_niet_mag_stopt_de_verhuizing_niet(env, tmp_path, monkeypatch):
"""Draai je niet als root, dan is alles toch al van jou. Dat mag geen fout
zijn — wel een melding, want dan kán het misgaan."""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo"})
doel = tmp_path / "nieuw"
monkeypatch.setattr(paden.os, "chown",
lambda *a, **k: (_ for _ in ()).throw(PermissionError("mag niet")))
regels = []
n = paden.verhuis(bron, doel, log_fn=regels.append)
assert n == 1 and (doel / "a.txt").read_text() == "hallo"
assert any("eigenaar" in r for r in regels), "geen waarschuwing over de rechten"
def test_symlinks_blijven_symlinks(env, tmp_path):
"""Een link volgen zou de inhoud dupliceren of buiten de boom kunnen wijzen."""
from core import paden
bron = _vul(tmp_path / "oud", {"echt.txt": "inhoud"})
os.symlink("echt.txt", bron / "link.txt")
doel = tmp_path / "nieuw"
paden.verhuis(bron, doel)
assert (doel / "link.txt").is_symlink()
assert os.readlink(doel / "link.txt") == "echt.txt"
# ── De stacks meeverhuizen ───────────────────────────────────────────────────
def test_paden_in_stacks_worden_omgeschreven(env, tmp_path):
"""Alleen bestanden verplaatsen laat elke stack naar zijn oude appdata
wijzen — die dan leeg is."""
from core import paden
lib = tmp_path / "stacks"
stack = lib / "sonarr"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text(
"services:\n sonarr:\n volumes:\n"
" - /opt/serverup/appdata/sonarr:/config\n", encoding="utf-8")
(stack / ".env").write_text("PAD=/opt/serverup/appdata/sonarr\n", encoding="utf-8")
(stack / ".serverup.json").write_text(json.dumps(
{"source": "sonarr", "values": {"appdata_dir": "/opt/serverup/appdata"}}))
n = paden.schrijf_paden_om(lib, {"/opt/serverup/appdata": "/srv/serverup/appdata"})
assert n == 3
assert "/srv/serverup/appdata/sonarr:/config" in (stack / "docker-compose.yml").read_text()
assert "/srv/serverup/appdata/sonarr" in (stack / ".env").read_text()
meta = json.loads((stack / ".serverup.json").read_text())
assert meta["values"]["appdata_dir"] == "/srv/serverup/appdata"
def test_een_pad_dat_er_op_lijkt_blijft_met_rust(env, tmp_path):
"""`/opt/serverup` vervangen mag `/opt/serverup-oud` niet aanraken."""
from core import paden
lib = tmp_path / "stacks"
stack = lib / "app"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text(
"services:\n a:\n volumes:\n"
" - /opt/serverup-oud/data:/config\n"
" - /opt/serverup/data:/data\n", encoding="utf-8")
paden.schrijf_paden_om(lib, {"/opt/serverup": "/srv/serverup"})
tekst = (stack / "docker-compose.yml").read_text()
assert "/opt/serverup-oud/data:/config" in tekst, "de buurmap is meegenomen"
assert "/srv/serverup/data:/data" in tekst
# ── Bereikbaarheid ───────────────────────────────────────────────────────────
def test_relatief_pad_wordt_geweigerd(env):
from core import paden
uit = paden.bereikbaar("relatief/pad")
assert not uit["ok"] and uit["reden"] == "geen_absoluut_pad"
def test_onschrijfbaar_pad_wordt_gemeld(env, tmp_path, monkeypatch):
"""Buiten een container komt de mountcontrole er niet aan te pas.
Dit expliciet zetten in plaats van op de omgeving vertrouwen: de CI draait
zelf in een container, en daar sloeg deze test aan op `buiten_mount` zonder
ooit bij de schrijftest te komen.
"""
from core import paden
monkeypatch.setattr(paden, "_in_container", lambda: False)
monkeypatch.setattr(Path, "mkdir",
lambda *a, **k: (_ for _ in ()).throw(OSError("alleen-lezen")))
uit = paden.bereikbaar(str(tmp_path / "kan-niet"))
assert not uit["ok"] and uit["reden"] == "niet_schrijfbaar"
assert "alleen-lezen" in uit["detail"]
def test_pad_buiten_de_mount_wordt_herkend(env, monkeypatch):
"""In de container bestaat /srv wel als pad, maar wat je erheen schrijft
belandt in de overlay en niet op de host. Dat moet je vóóraf horen."""
from core import paden
monkeypatch.setattr(paden, "_in_container", lambda: True)
monkeypatch.setattr(paden, "_mountpunten", lambda: ["/opt/serverup", "/data"])
uit = paden.bereikbaar("/srv/serverup/appdata")
assert not uit["ok"] and uit["reden"] == "buiten_mount"
# De melding moet zeggen wát je in .env zet, niet alleen dát het misging.
assert uit["voorstel_base"] == "/srv/serverup"
# Wat wél gekoppeld is, komt gewoon door de mountcontrole heen.
assert paden.bereikbaar("/opt/serverup/appdata").get("reden") != "buiten_mount"
def test_de_mount_wordt_niet_op_naam_verward(env, monkeypatch):
"""`/opt/serverup` gekoppeld maakt `/opt/serverup-oud` niet bereikbaar."""
from core import paden
monkeypatch.setattr(paden, "_in_container", lambda: True)
monkeypatch.setattr(paden, "_mountpunten", lambda: ["/opt/serverup"])
assert paden.bereikbaar("/opt/serverup-oud")["reden"] == "buiten_mount"
# ── Voortgang ────────────────────────────────────────────────────────────────
def test_voortgang_komt_door_de_api(client, env):
"""Bij tientallen gigabytes zegt "bezig…" niets; de balk komt hiervandaan."""
from core import jobs
login(client)
jid, _q = jobs.create("test")
jobs.voortgang(jid, 256, 1024, "bestand.txt")
d = client.get(f"/api/jobs/{jid}").get_json()
assert d["progress"] == {"done": 256, "total": 1024,
"label": "bestand.txt", "pct": 25}
def test_voortgang_deelt_niet_door_nul(env):
from core import jobs
jid, _q = jobs.create("test")
jobs.voortgang(jid, 0, 0, "")
assert jobs.stream(jid)["progress"]["pct"] == 0
# ── Het formulier stelt de ingestelde map voor ───────────────────────────────
def test_installatieformulier_gebruikt_de_ingestelde_appdata(client, env, monkeypatch):
"""266 sjablonen noemen /opt/serverup/appdata. Wat je ook instelde, dáár
kwam je data terecht.
De testomgeving zet DATA_DIR als omgevingsvariabele, en die wint van het
configuratiebestand — dus die verzetten we hier, niet de config.
"""
from core import git
csrf = login(client)
monkeypatch.setenv("DATA_DIR", "/srv/eigen/appdata")
bron = git.cache_dir("server-up") / "apps" / "testapp"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": "Test", "description": "d", "tags": ["t"]},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "testapp", "required": True},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": "/opt/serverup/appdata", "required": True},
]}],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: x\n"
" volumes:\n - << appdata_dir >>/<< service_name >>:/config\n",
encoding="utf-8")
d = client.post("/api/store/preview",
json={"stack": "testapp", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf}).get_json()
veld = next(f for f in d["fields"] if f["name"] == "appdata_dir")
assert veld["default"] == "/srv/eigen/appdata"
def test_puid_volgt_het_account_van_server_up(client, env):
"""44 sjablonen zetten PUID/PGID blind op 1000. Draait Server Up onder een
ander account, dan schrijft de app zijn data weg als een gebruiker die
Server Up daarna niet kan inpakken voor een backup."""
from core import git
csrf = login(client)
bron = git.cache_dir("server-up") / "apps" / "lsioapp"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": "Lsio", "description": "d", "tags": ["t"]},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "lsioapp", "required": True},
{"name": "puid", "type": "int", "title": "PUID", "default": 1000},
{"name": "pgid", "type": "int", "title": "PGID", "default": 1000},
]}],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: x\n"
" environment:\n - PUID=<< puid >>\n - PGID=<< pgid >>\n",
encoding="utf-8")
d = client.post("/api/store/preview",
json={"stack": "lsioapp", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf}).get_json()
velden = {f["name"]: f for f in d["fields"]}
assert velden["puid"]["default"] == os.getuid()
assert velden["pgid"]["default"] == os.getgid()
# ── Een pad opslaan dat de app niet kan bereiken ─────────────────────────────
# De controle bestond al voor de hoofdmap en voor het verhuizen, maar de drie
# padvelden schrijven rechtstreeks naar PUT /api/settings — dus liep het langs
# de wacht. Sla je daar /srv/... op terwijl alleen /opt/serverup gekoppeld is,
# dan kijkt de app in een map die binnen de container niet bestaat: lege
# appslijst, alle containers onder "handmatig gestart", nergens een melding.
def test_onbereikbaar_pad_wordt_niet_opgeslagen(client, env, monkeypatch):
from core import paden
csrf = login(client)
# De testomgeving zet LIBRARY_DIR als omgevingsvariabele, en die wint van
# het configuratiebestand — dan zou je niet zien of er iets is opgeslagen.
monkeypatch.delenv("LIBRARY_DIR", raising=False)
monkeypatch.setattr(paden, "_in_container", lambda: True)
monkeypatch.setattr(paden, "_mountpunten", lambda: ["/opt/serverup", "/data"])
r = client.put("/api/settings", json={"LIBRARY_DIR": "/srv/serverup/stacks"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400, r.get_json()
d = r.get_json()
assert d["veld"] == "LIBRARY_DIR"
assert "BASE_DIR=/srv/serverup" in d["msg"], d["msg"]
import core
assert core.load()["LIBRARY_DIR"] != "/srv/serverup/stacks", \
"het pad is toch opgeslagen"
def test_een_pad_binnen_de_koppeling_mag_gewoon(client, env, monkeypatch, tmp_path):
from core import paden
csrf = login(client)
monkeypatch.delenv("LIBRARY_DIR", raising=False)
monkeypatch.setattr(paden, "_in_container", lambda: True)
monkeypatch.setattr(paden, "_mountpunten", lambda: [str(tmp_path)])
doel = tmp_path / "serverup" / "stacks"
r = client.put("/api/settings", json={"LIBRARY_DIR": str(doel)},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
import core
assert core.load()["LIBRARY_DIR"] == str(doel)
def test_ongewijzigde_paden_blokkeren_het_opslaan_niet(client, env, monkeypatch):
"""Anders kun je met een al kapot pad geen enkele instelling meer wijzigen."""
from core import paden
import core
csrf = login(client)
huidig = core.load()["LIBRARY_DIR"]
monkeypatch.setattr(paden, "_in_container", lambda: True)
monkeypatch.setattr(paden, "_mountpunten", lambda: ["/nergens"])
r = client.put("/api/settings",
json={"LIBRARY_DIR": huidig, "TIMEZONE": "Europe/Amsterdam"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert core.load()["TIMEZONE"] == "Europe/Amsterdam"