server-up/tests/test_install_script.py
Ramon fc2e80fbda
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m8s
v0.10.00-beta - Server Up draait niet meer als root
- bij de installatie wordt gevraagd onder welk account Server Up draait: een
  nieuw systeemaccount 'serverup', het account waarmee je werkt, een bestaand
  account, of root zoals voorheen (--user NAAM slaat de vraag over)
- nieuw docker-entrypoint.sh: start als root, zet /data, stacks en backups
  klaar, en zakt met setpriv af naar SU_UID:SU_GID met de capabilities die
  nodig blijven (chown, dac_override, fowner - alle drie al in Docker's
  standaardset)
- appdata blijft daarbij bewust ongemoeid; die mappen zijn van de apps zelf
- de gid van de docker-socket wordt uit de socket zelf gelezen, zodat het
  account niet in de docker-groep hoeft (dat zou root op de host geven)
- installatieformulier vult PUID/PGID met de ids waaronder Server Up draait in
  plaats van de 1000 die 44 sjablonen blind noemen
- een onleesbaar bestand laat de rest van de backup niet meer sneuvelen; wat
  ontbreekt komt in het log en in de metadata (skipped)
- terugzetten herstelt het eigenaarschap van appdata, dat tarfile met
  filter="data" laat vallen
- CI controleert in een echte container dat setpriv bestaat, dat er wordt
  afgezakt, dat de socket-gid meekomt, dat /data schrijfbaar is en dat SU_UID=0
  root laat blijven
- SU_UID=0 in .env is de ontsnappingsklep naar het oude gedrag

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

616 lines
24 KiB
Python

"""Het installatiescript: syntaxis, opties en de dingen die stuk mogen gaan.
Een installer die je met `curl | sh` draait moet in elk geval syntactisch
kloppen onder de shells die distributies als /bin/sh gebruiken, en niet
stilzwijgend het verkeerde doen bij een typefout in de opties.
"""
import re
import shutil
import subprocess
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
SCRIPT = WORTEL / "install.sh"
def _draai(*args, **kw):
return subprocess.run(["sh", str(SCRIPT), *args], capture_output=True,
text=True, timeout=60, **kw)
def test_script_bestaat_en_is_uitvoerbaar():
assert SCRIPT.is_file()
assert SCRIPT.stat().st_mode & 0o111, "niet uitvoerbaar"
@pytest.mark.parametrize("shell", ["sh", "dash", "bash"])
def test_syntaxis_klopt_onder(shell):
"""Debian en Ubuntu gebruiken dash als /bin/sh; bashismen breken daar."""
if not shutil.which(shell):
pytest.skip(f"{shell} niet aanwezig")
r = subprocess.run([shell, "-n", str(SCRIPT)], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
def test_geen_bashismen():
tekst = SCRIPT.read_text(encoding="utf-8")
for patroon, uitleg in [
(r"\[\[", "[[ ]] is bash-only"),
(r"^\s*declare ", "declare is bash-only"),
(r"\$\{[A-Za-z_]+\[", "arrays zijn bash-only"),
(r"==", "== in test is bash-only"),
]:
treffers = [l for l in tekst.splitlines()
if re.search(patroon, l) and not l.strip().startswith("#")]
assert not treffers, f"{uitleg}: {treffers[:2]}"
def test_help_werkt_en_noemt_alle_opties():
r = _draai("--help")
assert r.returncode == 0
for optie in ("--dir", "--port", "--bind", "--branch", "--token",
"--update", "--uninstall", "--yes", "--dry-run",
"--admin", "--admin-password-file", "--create-admin"):
assert optie in r.stdout, f"{optie} niet gedocumenteerd in --help"
def test_onbekende_optie_stopt():
r = _draai("--bestaatniet")
assert r.returncode != 0
assert "Onbekende optie" in r.stderr
@pytest.mark.parametrize("poort", ["abc", "", "80a", "-1"])
def test_ongeldige_poort_stopt(poort):
r = _draai("--port", poort)
assert r.returncode != 0
# Een lege waarde valt al af bij het uitlezen van de optie, de rest bij de
# controle daarna; beide meldingen gaan over de poort.
assert "poort" in r.stderr.lower() or "port" in r.stderr.lower()
def test_update_zonder_installatie_zegt_dat(tmp_path):
r = _draai("--update", "--dir", str(tmp_path / "bestaatniet"))
assert r.returncode != 0
assert "Geen installatie gevonden" in r.stderr
def test_dry_run_wijzigt_niets(tmp_path):
doel = tmp_path / "server-up"
r = _draai("--dry-run", "--dir", str(doel), "--yes")
assert not doel.exists(), "proefdraai heeft toch iets aangemaakt"
assert "[droog]" in r.stdout
def test_dry_run_toont_de_hele_gang_van_zaken(tmp_path):
"""Ook op een machine zonder Docker moet je de voorvertoning kunnen zien."""
r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes")
for kop in ("Systeem controleren", "Broncode ophalen", "Instellingen",
"Bouwen en starten"):
assert kop in r.stdout, f"stap ontbreekt: {kop}"
def _pad_zonder(tmp_path, *weglaten):
"""Een PATH met alles erin behalve de genoemde commando's."""
nep = tmp_path / "bin"
nep.mkdir()
import os
for map_ in os.environ.get("PATH", "").split(os.pathsep):
p = Path(map_)
if not p.is_dir():
continue
for f in p.iterdir():
if f.name in weglaten or (nep / f.name).exists():
continue
try:
(nep / f.name).symlink_to(f)
except OSError:
pass
return str(nep)
def test_proefdraai_werkt_zonder_curl_en_tar(tmp_path):
"""De CI-runner draait in python:3.12-slim, waar curl ontbreekt. Een
proefdraai hoort dan nog steeds de hele voorvertoning te tonen: dat is juist
het moment waarop je wil zien wat er zou gebeuren."""
r = subprocess.run(["sh", str(SCRIPT), "--dry-run", "--dir",
str(tmp_path / "su"), "--yes"],
capture_output=True, text=True, timeout=60,
env={"PATH": _pad_zonder(tmp_path, "curl", "tar"),
"HOME": str(tmp_path)})
assert r.returncode == 0, r.stderr
assert "[droog]" in r.stdout
assert "Bouwen en starten" in r.stdout
assert not (tmp_path / "su").exists()
# ── Beheerdersaccount ────────────────────────────────────────────────────────
def test_wachtwoord_mag_niet_op_de_opdrachtregel():
"""Argumenten zijn voor elke gebruiker op de server zichtbaar met 'ps' en
blijven in de shell-geschiedenis staan."""
r = _draai("--admin-password", "geheimgeheim")
assert r.returncode != 0
assert "ps" in r.stderr
def test_create_admin_zonder_naam_stopt(tmp_path):
r = _draai("--create-admin", "--dir", str(tmp_path), "--yes")
assert r.returncode != 0
assert "--admin" in r.stderr
def test_dry_run_toont_het_aanmaken_van_het_account(tmp_path):
r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes", "--admin", "ramon")
assert "/api/auth/setup" in r.stdout
assert "ramon" in r.stdout
def test_wachtwoord_staat_niet_in_de_curl_argumenten():
"""Het wachtwoord gaat via stdin naar curl (--data-binary @-), niet als
argument. Anders is het alsnog zichtbaar in 'ps'."""
tekst = SCRIPT.read_text(encoding="utf-8")
setup = tekst[tekst.index("maak_beheerder()"):]
setup = setup[:setup.index("\nafsluiting()")]
assert "--data-binary @-" in setup
assert not re.search(r'-d\s+["\']?\{', setup), "JSON als curl-argument"
@pytest.mark.parametrize("adres", ["localhost", "0.0.0.0;rm -rf /", "mijn server"])
def test_ongeldig_bind_adres_stopt(adres):
r = _draai("--bind", adres)
assert r.returncode != 0
assert "bind" in r.stderr.lower()
@pytest.mark.parametrize("adres", ["127.0.0.1", "0.0.0.0", "10.0.20.22", "::1"])
def test_geldig_bind_adres_wordt_geaccepteerd(adres, tmp_path):
r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes", "--bind", adres)
assert r.returncode == 0, r.stderr
assert f"BIND={adres}" in r.stdout
# ── De vragen die het script stelt ───────────────────────────────────────────
# Interactief is de normale manier waarop iemand dit script draait, en juist
# daar bepaalt een verkeerd antwoord waar de server straks op luistert. Daarom
# worden de vragen hier echt gesteld, via een pseudo-terminal.
def _in_terminal(fragment, invoer, tmp_path, tijdslimiet=20):
"""Draait een stukje sh dat install.sh inlaadt, met een echte terminal."""
import os
import pty
import re as _re
import select
import signal
import time
script = tmp_path / "fragment.sh"
script.write_text(f"set --\nSU_INSTALL_ALLEEN_FUNCTIES=1 . {SCRIPT}\n{fragment}\n")
pid, fd = pty.fork()
if pid == 0: # kindproces
os.execvp("sh", ["sh", str(script)])
os.write(fd, invoer.encode())
uit, einde = b"", time.time() + tijdslimiet
while time.time() < einde:
klaar, _, _ = select.select([fd], [], [], 0.5)
if klaar:
try:
blok = os.read(fd, 4096)
except OSError: # terminal dicht: proces weg
break
if not blok:
break
uit += blok
elif os.waitpid(pid, os.WNOHANG)[0]:
break
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.waitpid(pid, 0)
except ChildProcessError:
pass
return _re.sub(r"\x1b\[[0-9;]*m", "", uit.decode(errors="replace").replace("\r", ""))
BIND_FRAGMENT = ('DROOG=0; JA=0; BIND_EXPLICIET=0; DOEL="$MAP"\n'
'kies_bind\n'
'printf "UITKOMST BIND=%s\\n" "$BIND"\n')
@pytest.mark.parametrize("invoer,verwacht", [
("1\n", "127.0.0.1"),
("2\n", "0.0.0.0"),
("3\n10.0.20.99\n", "10.0.20.99"),
("\n", "127.0.0.1"), # enter zonder bestaande .env
])
def test_keuzemenu_voor_het_bind_adres(invoer, verwacht, tmp_path):
leeg = tmp_path / "leeg"
leeg.mkdir()
uit = _in_terminal(f'MAP="{leeg}"\n' + BIND_FRAGMENT, invoer, tmp_path)
assert f"UITKOMST BIND={verwacht}" in uit, uit
def test_enter_behoudt_de_bestaande_instelling(tmp_path):
"""Opnieuw installeren over een bestaande map mag je server niet ongemerkt
terugzetten op loopback."""
bestaand = tmp_path / "bestaand"
bestaand.mkdir()
(bestaand / ".env").write_text("BIND=10.0.20.5\nPORT=8080\n")
uit = _in_terminal(f'MAP="{bestaand}"\n' + BIND_FRAGMENT, "\n", tmp_path)
assert "UITKOMST BIND=10.0.20.5" in uit, uit
assert "Nu ingesteld: 10.0.20.5" in uit
@pytest.mark.parametrize("invoer,melding", [
("9\n", "Onbekende keuze"),
("3\nlocalhost\n", "Ongeldig bind-adres"),
])
def test_menu_weigert_onzin(invoer, melding, tmp_path):
leeg = tmp_path / "leeg"
leeg.mkdir()
uit = _in_terminal(f'MAP="{leeg}"\n' + BIND_FRAGMENT, invoer, tmp_path)
assert melding in uit
assert "UITKOMST" not in uit
WW_FRAGMENT = ('DROOG=0; JA=0; BEHEERDER=""\n'
'beheerder_vooraf\n'
'printf "UITKOMST naam=%s lengte=%s zelf=%s\\n" '
'"$BEHEERDER" "${#BEHEERDER_WW}" "$WW_GEGENEREERD"\n')
def test_account_aanmaken_kan_geweigerd_worden(tmp_path):
uit = _in_terminal(WW_FRAGMENT, "n\n", tmp_path)
assert "UITKOMST naam= lengte=0 zelf=0" in uit
def test_wachtwoord_twee_keer_intypen(tmp_path):
uit = _in_terminal(WW_FRAGMENT, "j\nramon\neenlangwachtwoord\neenlangwachtwoord\n", tmp_path)
assert "UITKOMST naam=ramon lengte=17 zelf=0" in uit, uit
@pytest.mark.parametrize("invoer,melding", [
("j\nramon\neenlangwachtwoord\nietsanders\neenlangwachtwoord\neenlangwachtwoord\n",
"verschillen"),
("j\nramon\nkort\nkort\neenlangwachtwoord\neenlangwachtwoord\n",
"Minstens 10 tekens"),
])
def test_wachtwoord_mag_opnieuw_na_een_fout(invoer, melding, tmp_path):
uit = _in_terminal(WW_FRAGMENT, invoer, tmp_path)
assert melding in uit
assert "UITKOMST naam=ramon lengte=17 zelf=0" in uit, uit
def test_na_drie_pogingen_maakt_het_script_er_zelf_een(tmp_path):
"""Blijven hangen op een prompt is erger dan een gegenereerd wachtwoord."""
uit = _in_terminal(WW_FRAGMENT, "j\nramon\na\na\nb\nb\nc\nc\n", tmp_path)
assert "UITKOMST naam=ramon lengte=24 zelf=1" in uit, uit
def test_lege_gebruikersnaam_slaat_over(tmp_path):
uit = _in_terminal(WW_FRAGMENT, "j\n\n", tmp_path)
assert "Geen naam opgegeven" in uit
assert "UITKOMST naam= lengte=0 zelf=0" in uit
def test_proefdraai_vraagt_niets_en_gebruikt_geen_sudo(tmp_path):
"""Een proefdraai beloofde dat er niets gebeurt, maar riep wél 'sudo docker'
aan. Op een server waar sudo een wachtwoord wil, liep hij daarop vast."""
uit = _in_terminal(
f'DROOG=1; SUDO="sudo"; DOEL="{tmp_path}"\ncontroleer_docker\nprintf "KLAAR\\n"\n',
"", tmp_path, tijdslimiet=15)
assert "KLAAR" in uit, f"controleer_docker liep vast:\n{uit}"
assert "password" not in uit.lower()
# ── .env ─────────────────────────────────────────────────────────────────────
def test_bind_wordt_doorgevoerd_in_een_bestaande_env(tmp_path):
"""Eerder bleef een bestaande .env altijd ongemoeid, ook als je --bind
meegaf. Dan leek de installatie te lukken maar bleef de server onbereikbaar
op het oude adres."""
doel = tmp_path / "su"
doel.mkdir()
(doel / ".env").write_text("BIND=127.0.0.1\nPORT=5000\nSU_TAG=0.7.30\n")
r = _draai("--dry-run", "--dir", str(doel), "--yes", "--bind", "0.0.0.0")
assert "BIND=0.0.0.0 in bestaande .env zetten" in r.stdout
def test_env_blijft_ongemoeid_zonder_expliciete_opties(tmp_path):
"""Anders zet een --update je instellingen elke keer terug."""
doel = tmp_path / "su"
doel.mkdir()
(doel / ".env").write_text("BIND=0.0.0.0\nPORT=8080\n")
r = _draai("--dry-run", "--dir", str(doel), "--yes")
assert "ongemoeid" in r.stdout
# En hij rekent verder met wat er écht staat, niet met de standaardwaarden.
assert "BIND=0.0.0.0, PORT=8080" in r.stdout
def test_poort_uit_env_wordt_gebruikt_voor_het_slotadres(tmp_path):
doel = tmp_path / "su"
doel.mkdir()
(doel / ".env").write_text("BIND=0.0.0.0\nPORT=8080\n")
r = _draai("--dry-run", "--dir", str(doel), "--yes")
assert ":8080" in r.stdout
# ── Tegen een echt draaiende server ──────────────────────────────────────────
# Deze tests starten de app zelf en laten het script er een account op
# aanmaken. Dat is het enige wat aantoont dat de JSON-opbouw, de poortdetectie
# en de foutafhandeling in samenhang kloppen.
geen_curl = pytest.mark.skipif(not shutil.which("curl"),
reason="curl ontbreekt (o.a. in python:3.12-slim)")
def _vrije_poort():
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
poort = s.getsockname()[1]
s.close()
return poort
@pytest.fixture
def draaiende_app(tmp_path):
"""De echte app op een vrije poort, met een lege configuratie."""
import os
import sys
import time
import urllib.error
import urllib.request
poort = _vrije_poort()
data = tmp_path / "data"
data.mkdir()
omgeving = dict(os.environ,
SU_CONFIG=str(data / "config.json"),
SU_AUDIT=str(data / "audit.db"),
SU_GIT_CACHE=str(data / "git"),
SU_SECRET=str(data / "secret.key"),
LIBRARY_DIR=str(data / "lib"),
DATA_DIR=str(tmp_path / "appdata"),
BACKUP_DIR=str(tmp_path / "backups"),
PORT=str(poort))
proces = subprocess.Popen([sys.executable, str(WORTEL / "server-up" / "app.py")],
env=omgeving, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
for _ in range(80):
if proces.poll() is not None:
pytest.skip(f"app start niet: {proces.communicate()[0][-500:]}")
try:
urllib.request.urlopen(f"http://127.0.0.1:{poort}/healthz", timeout=1)
break
except (urllib.error.URLError, OSError):
time.sleep(0.25)
else:
pytest.skip("app reageerde niet op tijd")
yield poort
finally:
proces.terminate()
try:
proces.wait(timeout=10)
except subprocess.TimeoutExpired:
proces.kill()
def _inloggen(poort, naam, wachtwoord):
import json
import urllib.error
import urllib.request
verzoek = urllib.request.Request(
f"http://127.0.0.1:{poort}/api/auth/login",
data=json.dumps({"username": naam, "password": wachtwoord}).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(verzoek, timeout=5) as a:
return a.status
except urllib.error.HTTPError as e:
return e.code
@geen_curl
def test_script_maakt_een_werkend_beheerdersaccount(draaiende_app, tmp_path):
poort = draaiende_app
inst = tmp_path / "inst"
inst.mkdir()
# De poort komt uit .env, niet uit een optie: zo werkt het ook als je
# destijds met --port hebt geïnstalleerd.
(inst / ".env").write_text(f"BIND=127.0.0.1\nPORT={poort}\n")
ww = tmp_path / "ww"
ww.write_text("een-heel-lang-wachtwoord\n")
r = _draai("--create-admin", "--dir", str(inst), "--admin", "ramon",
"--admin-password-file", str(ww), "--yes")
assert r.returncode == 0, r.stderr
assert "aangemaakt" in r.stdout
assert _inloggen(poort, "ramon", "een-heel-lang-wachtwoord") == 200
@geen_curl
def test_wachtwoord_met_aanhalingstekens_en_backslashes(draaiende_app, tmp_path):
"""Het wachtwoord gaat als JSON over de lijn; zonder escapen levert dit een
kapot verzoek op of — erger — een ander wachtwoord dan je denkt."""
poort = draaiende_app
inst = tmp_path / "inst"
inst.mkdir()
(inst / ".env").write_text(f"PORT={poort}\n")
lastig = 'pa"ss\\wo/rd$`{}[]&|;<>#!\'x'
ww = tmp_path / "ww"
ww.write_text(lastig + "\n")
r = _draai("--create-admin", "--dir", str(inst), "--admin", "lastig",
"--admin-password-file", str(ww), "--yes")
assert r.returncode == 0, r.stderr
assert "aangemaakt" in r.stdout
assert _inloggen(poort, "lastig", lastig) == 200
@geen_curl
def test_zelf_aangemaakt_wachtwoord_wordt_getoond_en_werkt(draaiende_app, tmp_path):
"""Zonder bestand, omgevingsvariabele of terminal maakt het script er zelf
één aan. Die moet dan wel op het scherm komen, want hij staat nergens."""
poort = draaiende_app
inst = tmp_path / "inst"
inst.mkdir()
(inst / ".env").write_text(f"PORT={poort}\n")
r = _draai("--create-admin", "--dir", str(inst), "--admin", "auto", "--yes")
assert r.returncode == 0, r.stderr
getoond = re.search(r"Wachtwoord: (\S+)", r.stdout)
assert getoond, f"geen wachtwoord getoond in:\n{r.stdout}"
assert _inloggen(poort, "auto", getoond.group(1)) == 200
@geen_curl
def test_tweede_account_wordt_netjes_geweigerd(draaiende_app, tmp_path):
poort = draaiende_app
inst = tmp_path / "inst"
inst.mkdir()
(inst / ".env").write_text(f"PORT={poort}\n")
ww = tmp_path / "ww"
ww.write_text("een-heel-lang-wachtwoord\n")
_draai("--create-admin", "--dir", str(inst), "--admin", "eerste",
"--admin-password-file", str(ww), "--yes")
r = _draai("--create-admin", "--dir", str(inst), "--admin", "tweede",
"--admin-password-file", str(ww), "--yes")
assert r.returncode == 0, r.stderr
assert "bestond al" in r.stdout
assert _inloggen(poort, "tweede", "een-heel-lang-wachtwoord") != 200
@geen_curl
def test_onbereikbare_server_meldt_dat_zonder_te_ontsporen(tmp_path):
inst = tmp_path / "inst"
inst.mkdir()
(inst / ".env").write_text(f"PORT={_vrije_poort()}\n")
ww = tmp_path / "ww"
ww.write_text("een-heel-lang-wachtwoord\n")
r = _draai("--create-admin", "--dir", str(inst), "--admin", "ramon",
"--admin-password-file", str(ww), "--yes")
assert r.returncode == 0, r.stderr
assert "niet bereikbaar" in r.stdout
def test_verwijst_naar_het_juiste_git_domein():
tekst = SCRIPT.read_text(encoding="utf-8")
assert 'GIT_HOST="git.ramonbesselink.nl"' in tekst
def test_compose_wordt_met_expliciet_bestand_aangeroepen():
"""`--project-directory` alleen laat compose het bestand nog in de huidige
map zoeken; met `curl | sh` is dat je thuismap."""
for regel in SCRIPT.read_text(encoding="utf-8").splitlines():
if "docker compose" in regel and ("up -d" in regel or "down " in regel):
assert "-f " in regel, f"geen expliciet compose-bestand: {regel.strip()}"
def test_readme_en_docs_noemen_dezelfde_installatieregel():
regel = ("curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/"
"raw/branch/main/install.sh")
assert regel in (WORTEL / "README.md").read_text(encoding="utf-8")
assert regel in (WORTEL / "docs" / "installeren.md").read_text(encoding="utf-8")
# ── Onder welk account draait Server Up? ─────────────────────────────────────
# Vanaf v0.10 draait Server Up niet meer als root. Welke gebruiker het wordt is
# een keuze bij de installatie, en die keuze mag niet stilzwijgend verkeerd
# uitpakken: een typefout in de naam hoort te stoppen, en 'root' moet blijven
# kunnen voor wie problemen krijgt.
GEBRUIKER_FRAGMENT = (
'DROOG=0; JA=0; GEBRUIKER_EXPLICIET=0; SU_UID=""; DOEL="$MAP"\n'
'uid_van() { case "$1" in ramon) echo 1000 ;; serverup) echo 990 ;; esac; }\n'
'gid_van() { case "$1" in ramon) echo 1000 ;; serverup) echo 990 ;; esac; }\n'
'ingelogde_gebruiker() { printf "%s" "${NEP_INGELOGD-ramon}"; }\n'
'bruikbare_gebruikers() { echo "ramon (uid 1000)"; }\n'
'kies_gebruiker\n'
'printf "UITKOMST uid=%s gid=%s naam=%s\\n" "$SU_UID" "$SU_GID" "$SU_NAAM"\n'
)
@pytest.mark.parametrize("invoer,verwacht", [
("\n", "uid=990 gid=990 naam=serverup"), # enter = aanbevolen
("1\n", "uid=990 gid=990 naam=serverup"),
("2\n", "uid=1000 gid=1000 naam=ramon"), # de ingelogde gebruiker
("3\nramon\n", "uid=1000 gid=1000 naam=ramon"), # bestaand account
("4\n", "uid=0 gid=0 naam=root"), # de oude situatie
])
def test_keuzemenu_voor_het_account(invoer, verwacht, tmp_path):
leeg = tmp_path / "leeg"
leeg.mkdir()
uit = _in_terminal(f'MAP="{leeg}"\n' + GEBRUIKER_FRAGMENT, invoer, tmp_path)
assert f"UITKOMST {verwacht}" in uit, uit
def test_de_ingelogde_gebruiker_staat_erbij(tmp_path):
leeg = tmp_path / "leeg"
leeg.mkdir()
uit = _in_terminal(f'MAP="{leeg}"\n' + GEBRUIKER_FRAGMENT, "1\n", tmp_path)
assert "ramon — het account waarmee je nu werkt" in uit, uit
def test_zonder_ingelogde_gebruiker_geen_loze_keuze(tmp_path):
"""Draai je als root zonder sudo, dan is er niemand om voor te stellen."""
leeg = tmp_path / "leeg"
leeg.mkdir()
uit = _in_terminal(f'MAP="{leeg}"\nNEP_INGELOGD=""\n' + GEBRUIKER_FRAGMENT,
"2\n", tmp_path)
assert "geen ingelogd account gevonden" in uit
assert "UITKOMST" not in uit, "keuze 2 werd geaccepteerd zonder account"
def test_een_naam_die_niet_bestaat_stopt(tmp_path):
leeg = tmp_path / "leeg"
leeg.mkdir()
uit = _in_terminal(f'MAP="{leeg}"\n' + GEBRUIKER_FRAGMENT, "3\ntypfout\n", tmp_path)
assert "bestaat niet" in uit
assert "UITKOMST" not in uit
def test_een_eerdere_keuze_wordt_niet_opnieuw_gevraagd(tmp_path):
"""Bij --update mag het script je niet elke keer opnieuw lastigvallen."""
bestaand = tmp_path / "bestaand"
bestaand.mkdir()
# Bewust een uid die geen enkele menukeuze oplevert: met 990 (serverup) zou
# deze test ook slagen als het menu tóch verscheen en enter werd gekozen.
(bestaand / ".env").write_text("BIND=127.0.0.1\nPORT=5000\nSU_UID=1234\nSU_GID=1234\n")
uit = _in_terminal(f'MAP="{bestaand}"\n' + GEBRUIKER_FRAGMENT, "", tmp_path)
assert "UITKOMST uid=1234 gid=1234" in uit, uit
assert "Onder welk account" not in uit, "er werd toch gevraagd"
def test_de_docker_groep_wordt_bewust_overgeslagen():
"""Wie in de docker-groep zit kan een container starten die / mount, en is
daarmee root op de host. De container heeft het niet nodig: die leest de gid
van de socket zelf uit."""
tekst = SCRIPT.read_text(encoding="utf-8")
for regel in tekst.splitlines():
kaal = regel.strip()
if kaal.startswith("#") or kaal.startswith("log ") or kaal.startswith("printf"):
continue
assert "usermod" not in kaal or "docker" not in kaal, \
f"gebruiker wordt in de docker-groep gezet: {kaal}"
assert "gpasswd" not in kaal, f"groepswijziging: {kaal}"
def test_appdata_wordt_niet_van_eigenaar_veranderd():
"""De appdata-mappen zijn van de apps zelf — postgres draait als 999, een
linuxserver-image als jouw PUID. Een chown daaroverheen breekt ze."""
tekst = SCRIPT.read_text(encoding="utf-8")
for regel in tekst.splitlines():
kaal = regel.strip()
if kaal.startswith("#") or kaal.startswith("printf"):
continue
if "chown" in kaal:
assert "appdata" not in kaal, f"appdata wordt gechownd: {kaal}"