All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m55s
De runner draait in python:3.12-slim, zonder curl. Daar faalde elke poging en toetsten deze tests niet meer dan "curl ontbreekt". Ze krijgen nu een eigen curl-schilletje op $PATH, zodat ze overal vastleggen wat ze horen vast te leggen: welk adres het script opvraagt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
789 lines
31 KiB
Python
789 lines
31 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}"
|
|
|
|
|
|
# ── Waar controleert het script of de server draait? ─────────────────────────
|
|
# Compose publiceert op $BIND. Het script vroeg altijd 127.0.0.1, dus met een
|
|
# eigen bind-adres meldde het "reageerde niet" en sloeg het het
|
|
# beheerdersaccount over — terwijl alles gewoon draaide. Dat laat je installatie
|
|
# zonder account open staan, precies waar het script voor waarschuwt.
|
|
|
|
@pytest.mark.parametrize("bind,verwacht", [
|
|
("127.0.0.1", "127.0.0.1"),
|
|
("0.0.0.0", "127.0.0.1"), # alle interfaces: loopback voldoet
|
|
("10.0.20.5", "10.0.20.5"), # specifiek adres: dáár moet je zijn
|
|
("::", "[::1]"),
|
|
("2001:db8::1", "[2001:db8::1]"), # IPv6 hoort tussen blokhaken in een URL
|
|
])
|
|
def test_het_controleadres_volgt_het_bind_adres(bind, verwacht, tmp_path):
|
|
uit = _sh(f'BIND="{bind}"\nprobe_adres\nprintf "\\n"\n', tmp_path)
|
|
assert uit.strip().splitlines()[-1] == verwacht, uit
|
|
|
|
|
|
def _sh(fragment, tmp_path, met_nep_curl=False):
|
|
"""Laadt install.sh en draait een fragment; geen terminal in het spel."""
|
|
import os
|
|
script = tmp_path / "frag.sh"
|
|
script.write_text(f"set --\nSU_INSTALL_ALLEEN_FUNCTIES=1 . {SCRIPT}\n{fragment}\n")
|
|
env = dict(os.environ)
|
|
if met_nep_curl:
|
|
env["PATH"] = f"{_nep_curl(tmp_path)}{os.pathsep}{env.get('PATH', '')}"
|
|
r = subprocess.run(["sh", str(script)], capture_output=True, text=True,
|
|
timeout=60, stdin=subprocess.DEVNULL, env=env)
|
|
return r.stdout + r.stderr
|
|
|
|
|
|
def _nep_curl(tmp_path):
|
|
"""Een map met een eigen `curl`, zodat deze tests overal hetzelfde doen.
|
|
|
|
De CI-runner draait in python:3.12-slim en heeft geen curl. Zonder dit
|
|
faalde elke poging daar en toetsten deze tests niets meer dan "curl
|
|
ontbreekt". Wat ze wél moeten toetsen is welk adres het script opvraagt, en
|
|
dat legt dit schilletje vast.
|
|
"""
|
|
bin_dir = tmp_path / "nepbin"
|
|
if bin_dir.exists():
|
|
return str(bin_dir)
|
|
bin_dir.mkdir()
|
|
shim = bin_dir / "curl"
|
|
shim.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import sys, urllib.request\n"
|
|
"url = next((a for a in sys.argv[1:] if a.startswith('http')), None)\n"
|
|
"if not url:\n"
|
|
" sys.exit(2)\n"
|
|
"try:\n"
|
|
" with urllib.request.urlopen(url, timeout=2) as r:\n"
|
|
" sys.stdout.write(r.read().decode('utf-8', 'replace'))\n"
|
|
"except Exception:\n"
|
|
" sys.exit(22)\n",
|
|
encoding="utf-8")
|
|
shim.chmod(0o755)
|
|
return str(bin_dir)
|
|
|
|
|
|
def _server(poort_uit, pad="/healthz", inhoud=b'{"version":"9.9.9"}'):
|
|
"""Een minimale HTTP-server op 127.0.0.1 die één pad kent."""
|
|
import http.server
|
|
import threading
|
|
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path == pad:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(inhoud)))
|
|
self.end_headers()
|
|
self.wfile.write(inhoud)
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
srv = http.server.HTTPServer(("127.0.0.1", 0), H)
|
|
poort_uit.append(srv.server_address[1])
|
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
return srv
|
|
|
|
|
|
def test_wachten_slaagt_zodra_de_server_antwoordt(tmp_path):
|
|
poort = []
|
|
srv = _server(poort)
|
|
try:
|
|
uit = _sh(f'DROOG=0; BIND="127.0.0.1"; POORT={poort[0]}; DOEL="{tmp_path}"\n'
|
|
'SU_WACHT_POGINGEN=3 wacht_op_gereed && echo "UITKOMST=gereed"\n',
|
|
tmp_path, met_nep_curl=True)
|
|
finally:
|
|
srv.shutdown()
|
|
assert "UITKOMST=gereed" in uit, uit
|
|
assert "9.9.9" in uit, "de versie uit /healthz wordt niet getoond"
|
|
|
|
|
|
def test_wachten_kijkt_naar_het_ingestelde_adres_niet_naar_loopback(tmp_path):
|
|
"""De server luistert op 127.0.0.1, maar we doen alsof BIND iets anders is.
|
|
Vroeg het script tóch 127.0.0.1, dan zou dit ten onrechte slagen."""
|
|
poort = []
|
|
srv = _server(poort)
|
|
try:
|
|
uit = _sh(f'DROOG=0; BIND="10.255.255.1"; POORT={poort[0]}; DOEL="{tmp_path}"\n'
|
|
'SU_WACHT_POGINGEN=1 wacht_op_gereed || echo "UITKOMST=niet-gereed"\n',
|
|
tmp_path, met_nep_curl=True)
|
|
finally:
|
|
srv.shutdown()
|
|
assert "UITKOMST=niet-gereed" in uit, uit
|
|
assert "10.255.255.1" in uit, "de melding noemt het gevraagde adres niet"
|
|
|
|
|
|
def test_wachten_faalt_als_er_niets_luistert(tmp_path):
|
|
uit = _sh(f'DROOG=0; BIND="127.0.0.1"; POORT=1; DOEL="{tmp_path}"\n'
|
|
'SU_WACHT_POGINGEN=1 wacht_op_gereed || echo "UITKOMST=niet-gereed"\n',
|
|
tmp_path, met_nep_curl=True)
|
|
assert "UITKOMST=niet-gereed" in uit, uit
|
|
assert "logs --tail" in uit, "geen aanwijzing hoe je verder kijkt"
|
|
|
|
|
|
def test_een_mislukte_update_meldt_zich_als_mislukt():
|
|
"""`wacht_op_gereed || true` gevolgd door "Bijgewerkt." maakte een kapotte
|
|
update niet te onderscheiden van een geslaagde, ook niet aan de exitcode."""
|
|
regels = SCRIPT.read_text(encoding="utf-8").splitlines()
|
|
for i, regel in enumerate(regels):
|
|
if "wacht_op_gereed" not in regel or regel.strip().startswith("#"):
|
|
continue
|
|
assert "|| true" not in regel, \
|
|
f"regel {i+1} negeert of de server opkwam: {regel.strip()}"
|
|
|
|
|
|
# ── Vragen bij `curl | sh` ───────────────────────────────────────────────────
|
|
# De README noemt `curl … | sh` als de manier om te installeren. Daarbij is
|
|
# stdin de pipe, niet de terminal. `vraag()` testte op stdin en beantwoordde
|
|
# zichzelf dus met "ja": Docker werd van get.docker.com gehaald zonder dat het
|
|
# gevraagd was, en --uninstall brak je installatie af zonder bevestiging.
|
|
|
|
# `</dev/null` bootst de pipe na; /dev/tty is in deze harnas de pseudo-terminal.
|
|
PIPE_FRAGMENT = (
|
|
'JA=0\n'
|
|
'if vraag "Doorgaan?" </dev/null; then echo "UITKOMST=ja"; else echo "UITKOMST=nee"; fi\n'
|
|
)
|
|
|
|
|
|
def test_de_vraag_wordt_ook_gesteld_als_stdin_geen_terminal_is(tmp_path):
|
|
uit = _in_terminal(PIPE_FRAGMENT, "n\n", tmp_path)
|
|
assert "Doorgaan? [J/n]" in uit, f"de vraag is niet eens gesteld: {uit}"
|
|
assert "UITKOMST=nee" in uit, uit
|
|
|
|
|
|
def test_ja_blijft_ja(tmp_path):
|
|
uit = _in_terminal(PIPE_FRAGMENT, "\n", tmp_path)
|
|
assert "UITKOMST=ja" in uit, uit
|
|
|
|
|
|
def test_zonder_enige_terminal_gaat_hij_door(tmp_path):
|
|
"""Geen terminal én geen stdin: dan blijft "ja" het antwoord, want anders
|
|
kan een onbeheerde installatie nergens meer verder."""
|
|
uit = _sh(PIPE_FRAGMENT, tmp_path)
|
|
assert "UITKOMST=ja" in uit, uit
|
|
|
|
|
|
def test_zonder_terminal_valt_het_script_niet_om(tmp_path):
|
|
"""`{ : </dev/tty; }` lijkt onschuldig, maar ':' is een special builtin en
|
|
een mislukte redirect daarop beëindigt volgens POSIX de hele shell — exit 2,
|
|
zonder melding, nog vóór de eerste stap."""
|
|
uit = _sh('if terminal_beschikbaar; then echo "UITKOMST=tty"; '
|
|
'else echo "UITKOMST=geen-tty"; fi\necho "EINDE"\n', tmp_path)
|
|
assert "UITKOMST=geen-tty" in uit, uit
|
|
assert "EINDE" in uit, "het script stopte halverwege"
|