Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled
De koppeling van BASE_DIR kan intact zijn terwijl de container er toch niets ziet. Docker bindt met propagation rprivate: een koppeling die op de host na het aanmaken van de container ontstaat komt daar niet doorheen, en dan ziet de container de lege map eronder. - --doctor loopt met findmnt alle koppelingen onder BASE_DIR langs en vergelijkt met de mountlijst van de container - ontbreekt er een, dan staat erbij welke en hoe je hem meekoppelt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
1215 lines
50 KiB
Python
1215 lines
50 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"),
|
|
# Botweg overal verboden, ook in een awk-programma waar het prima mag.
|
|
# Dat is één regel die je omschrijft, tegenover een controle die precies
|
|
# moet weten wanneer je binnen [ ] zit.
|
|
(r"==", "== in test is bash-only (ook in awk: schrijf het anders)"),
|
|
]:
|
|
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.7.93 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"
|
|
|
|
|
|
# ── Bijwerken van een vastgepinde versie ─────────────────────────────────────
|
|
|
|
def _repo_met_tag(tmp_path):
|
|
"""Een lokale repo met een tag, en een kloon daarvan zoals install.sh die maakt."""
|
|
import subprocess as sp
|
|
bron = tmp_path / "bron"
|
|
bron.mkdir()
|
|
def g(*a, cwd=bron):
|
|
return sp.run(["git", *a], cwd=str(cwd), capture_output=True, text=True, check=True)
|
|
g("init", "-q", ".")
|
|
g("config", "user.email", "t@t"); g("config", "user.name", "t")
|
|
(bron / "f").write_text("eerst")
|
|
g("add", "f"); g("commit", "-qm", "eerst"); g("tag", "v1.0.0")
|
|
(bron / "f").write_text("later")
|
|
g("commit", "-qam", "later"); g("tag", "v1.1.0")
|
|
return bron
|
|
|
|
|
|
@pytest.mark.parametrize("naar_tag", [True, False])
|
|
def test_bijwerken_werkt_voor_een_tag_en_voor_een_tak(naar_tag, tmp_path):
|
|
"""`git reset --hard origin/<tag>` bestaat niet: tags krijgen geen
|
|
origin/-ref. Installeren op een vaste versie was daardoor een
|
|
eenrichtingsstraat — bijwerken faalde met "ambiguous argument".
|
|
|
|
Toetsen op de exitcode zou niets zeggen: `haal_broncode && …` zet errexit
|
|
uit, dus de functie geeft ook na een mislukte git-opdracht 0 terug. Daarom
|
|
kijken we naar wat er in de werkmap staat.
|
|
"""
|
|
import subprocess as sp
|
|
if not shutil.which("git"):
|
|
pytest.skip("git niet aanwezig")
|
|
bron = _repo_met_tag(tmp_path)
|
|
doel = tmp_path / "installatie"
|
|
tak = sp.run(["git", "-C", str(bron), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
# Installeren op de oude versie, daarna bijwerken naar de nieuwe.
|
|
sp.run(["git", "clone", "-q", "--depth", "1", "--branch", "v1.0.0",
|
|
f"file://{bron}", str(doel)], check=True, capture_output=True)
|
|
assert (doel / "f").read_text() == "eerst"
|
|
|
|
doel_ref = "v1.1.0" if naar_tag else tak
|
|
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{doel}"; BRANCH="{doel_ref}"\n'
|
|
'haal_broncode\n', tmp_path)
|
|
|
|
assert "ambiguous argument" not in uit, uit
|
|
assert (doel / "f").read_text() == "later", \
|
|
f"de installatie is niet bijgewerkt naar {doel_ref}: {uit}"
|
|
|
|
|
|
# ── De token mag nergens blijven hangen ──────────────────────────────────────
|
|
# In de URL schrijft git hem verbatim in .git/config, waar hij blijft staan. Als
|
|
# argument staat hij in /proc/<pid>/cmdline en leest elke gebruiker op de server
|
|
# hem met 'ps'. Dat laatste is precies waarom dit script --admin-password
|
|
# weigert; voor --token gold hetzelfde.
|
|
|
|
def test_de_bron_url_bevat_geen_token(tmp_path):
|
|
uit = _sh('TOKEN="geheimtoken123"\nbron_url\nprintf "\\n"\n', tmp_path)
|
|
assert "geheimtoken123" not in uit, uit
|
|
assert uit.strip().endswith(".git")
|
|
|
|
|
|
def test_de_token_staat_niet_in_de_git_argumenten(tmp_path):
|
|
"""Het pad naar het bestand mag op de opdrachtregel, de inhoud niet."""
|
|
uit = _sh('TOKEN="geheimtoken123"; SUDO=""; DROOG=1\n'
|
|
'zet_token_klaar\n'
|
|
'git_doe clone --depth 1 https://voorbeeld/x.git /tmp/x\n', tmp_path)
|
|
assert "geheimtoken123" not in uit, f"de token staat in het commando: {uit}"
|
|
assert "credential.helper=store" in uit, uit
|
|
|
|
|
|
def test_het_tokenbestand_is_afgeschermd_en_verdwijnt(tmp_path):
|
|
uit = _sh('TOKEN="geheimtoken123"\n'
|
|
'zet_token_klaar\n'
|
|
'bewaard="$TOKEN_BESTAND"\n'
|
|
'ls -l "$bewaard" | cut -c1-10\n'
|
|
'grep -q geheimtoken123 "$bewaard" && echo "TOKEN-STAAT-ERIN"\n'
|
|
'opruimen\n'
|
|
'[ -e "$bewaard" ] && echo "BLIJFT-STAAN" || echo "OPGERUIMD"\n',
|
|
tmp_path)
|
|
assert "-rw-------" in uit, f"het tokenbestand is te ruim: {uit}"
|
|
assert "TOKEN-STAAT-ERIN" in uit, uit
|
|
assert "OPGERUIMD" in uit, f"het tokenbestand blijft achter: {uit}"
|
|
|
|
|
|
def test_de_token_komt_daadwerkelijk_bij_git_aan(tmp_path):
|
|
"""Niet lekken is de helft; hij moet ook nog wérken.
|
|
|
|
Een git-server die 401 antwoordt tot er inloggegevens komen. Lukt het
|
|
uitlezen van de refs, dan heeft de credential-helper de token geleverd —
|
|
zonder dat hij in de opdrachtregel of in .git/config stond.
|
|
"""
|
|
import http.server
|
|
import subprocess as sp
|
|
import threading
|
|
if not shutil.which("git"):
|
|
pytest.skip("git niet aanwezig")
|
|
|
|
bron = _repo_met_tag(tmp_path)
|
|
sp.run(["git", "-C", str(bron), "update-server-info"], check=True,
|
|
capture_output=True)
|
|
gezien = []
|
|
|
|
class H(http.server.SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=str(bron / ".git"), **kw)
|
|
|
|
def do_GET(self):
|
|
kop = self.headers.get("Authorization")
|
|
gezien.append(kop)
|
|
if not kop:
|
|
self.send_response(401)
|
|
self.send_header("WWW-Authenticate", 'Basic realm="prive"')
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
super().do_GET()
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
srv = http.server.HTTPServer(("127.0.0.1", 0), H)
|
|
poort = srv.server_address[1]
|
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
try:
|
|
uit = _sh(f'TOKEN="geheimtoken123"; SUDO=""; DROOG=0\n'
|
|
f'bron_url() {{ printf "http://127.0.0.1:{poort}/"; }}\n'
|
|
'zet_token_klaar\n'
|
|
f'git_doe ls-remote "http://127.0.0.1:{poort}/"\n', tmp_path)
|
|
finally:
|
|
srv.shutdown()
|
|
|
|
assert "geheimtoken123" not in uit, f"de token staat in de uitvoer: {uit}"
|
|
assert any(a and a.startswith("Basic") for a in gezien), \
|
|
f"de server kreeg nooit inloggegevens: {gezien}"
|
|
assert "refs/tags/v1.0.0" in uit, f"de refs zijn niet opgehaald: {uit}"
|
|
|
|
|
|
# ── --base-dir ───────────────────────────────────────────────────────────────
|
|
# BASE_DIR is sinds v0.7.92 de hoofdknop voor je paden, maar je kon hem alleen
|
|
# achteraf in .env zetten — en dan moet je de data alsnog verhuizen.
|
|
|
|
def test_base_dir_belandt_in_env_en_in_de_mappen(tmp_path):
|
|
r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes",
|
|
"--base-dir", "/srv/serverup", "--user", "root")
|
|
assert r.returncode == 0, r.stderr
|
|
assert "BASE_DIR=/srv/serverup" in r.stdout, r.stdout
|
|
|
|
|
|
@pytest.mark.parametrize("pad", ["relatief/pad", "srv", "."])
|
|
def test_base_dir_moet_een_volledig_pad_zijn(pad):
|
|
"""Die map wordt in de container op zichzelf gemount; een relatief pad komt
|
|
daar ergens anders uit dan op de host."""
|
|
r = _draai("--base-dir", pad, "--dry-run", "--yes")
|
|
assert r.returncode != 0
|
|
assert "volledig pad" in r.stderr
|
|
|
|
|
|
@pytest.mark.parametrize("pad", ["/", "/etc", "/usr", "/var", "/root"])
|
|
def test_base_dir_weigert_systeemmappen(pad):
|
|
r = _draai("--base-dir", pad, "--dry-run", "--yes")
|
|
assert r.returncode != 0
|
|
assert "mag niet" in r.stderr
|
|
|
|
|
|
def test_afsluitende_schuine_streep_verdwijnt(tmp_path):
|
|
r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes",
|
|
"--base-dir", "/srv/serverup/", "--user", "root")
|
|
assert "BASE_DIR=/srv/serverup," in r.stdout or "BASE_DIR=/srv/serverup " in r.stdout, r.stdout
|
|
assert "/srv/serverup//" not in r.stdout
|
|
|
|
|
|
# ── --doctor ─────────────────────────────────────────────────────────────────
|
|
# De fouten die dit script had waren allemaal van dezelfde soort: iets klopte
|
|
# niet en niemand keek na. Dit is het tegengif.
|
|
|
|
def test_doctor_meldt_een_ontbrekende_installatie(tmp_path):
|
|
r = _draai("--doctor", "--dir", str(tmp_path / "bestaatniet"))
|
|
assert r.returncode != 0, "een ontbrekende installatie is geen succes"
|
|
assert "Geen installatie" in r.stdout
|
|
|
|
|
|
def test_doctor_kijkt_naar_de_rechten_van_env(tmp_path):
|
|
"""In .env staan tokens en wachtwoordhashes."""
|
|
doel = tmp_path / "su"
|
|
doel.mkdir()
|
|
(doel / "docker-compose.yml").write_text("services: {}\n")
|
|
env = doel / ".env"
|
|
env.write_text("BIND=127.0.0.1\nPORT=5000\n")
|
|
env.chmod(0o644)
|
|
r = _draai("--doctor", "--dir", str(doel))
|
|
assert "644" in r.stdout and "600" in r.stdout, r.stdout
|
|
|
|
env.chmod(0o600)
|
|
r = _draai("--doctor", "--dir", str(doel))
|
|
assert ".env staat op 600" in r.stdout, r.stdout
|
|
|
|
|
|
def test_doctor_wijzigt_niets(tmp_path):
|
|
"""Doorlichten is kijken. Draait iemand dit op een productieserver, dan mag
|
|
er niets veranderen — ook geen map aangemaakt."""
|
|
doel = tmp_path / "su"
|
|
doel.mkdir()
|
|
(doel / "docker-compose.yml").write_text("services: {}\n")
|
|
(doel / ".env").write_text("BASE_DIR=/tmp/su-doctor-mag-dit-niet-maken\n")
|
|
(doel / ".env").chmod(0o600)
|
|
voor = sorted(p.name for p in doel.iterdir())
|
|
_draai("--doctor", "--dir", str(doel))
|
|
assert sorted(p.name for p in doel.iterdir()) == voor
|
|
assert not Path("/tmp/su-doctor-mag-dit-niet-maken").exists()
|
|
|
|
|
|
def test_doctor_staat_in_de_hulptekst():
|
|
r = _draai("--help")
|
|
assert "--doctor" in r.stdout
|
|
assert "--base-dir" in r.stdout
|
|
|
|
|
|
# ── Terugrollen ──────────────────────────────────────────────────────────────
|
|
# Een update die niet opkomt liet je met een stilstaande server zitten en een
|
|
# rijtje commando's om zelf uit te voeren.
|
|
|
|
def test_bijwerken_onthoudt_waar_het_vandaan_kwam(tmp_path):
|
|
"""Zonder de vorige commit valt er niets terug te rollen."""
|
|
import subprocess as sp
|
|
if not shutil.which("git"):
|
|
pytest.skip("git niet aanwezig")
|
|
bron = _repo_met_tag(tmp_path)
|
|
doel = tmp_path / "installatie"
|
|
sp.run(["git", "clone", "-q", "--depth", "1", "--branch", "v1.0.0",
|
|
f"file://{bron}", str(doel)], check=True, capture_output=True)
|
|
oude = sp.run(["git", "-C", str(doel), "rev-parse", "HEAD"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
|
|
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{doel}"; BRANCH="v1.1.0"\n'
|
|
'haal_broncode\n'
|
|
'printf "VORIGE=%s\\n" "$VORIGE_COMMIT"\n', tmp_path)
|
|
assert f"VORIGE={oude}" in uit, uit
|
|
assert (doel / "f").read_text() == "later", "niet bijgewerkt"
|
|
|
|
|
|
def test_terugrollen_zet_de_broncode_terug(tmp_path):
|
|
import subprocess as sp
|
|
if not shutil.which("git"):
|
|
pytest.skip("git niet aanwezig")
|
|
bron = _repo_met_tag(tmp_path)
|
|
doel = tmp_path / "installatie"
|
|
sp.run(["git", "clone", "-q", "--branch", "v1.1.0",
|
|
f"file://{bron}", str(doel)], check=True, capture_output=True)
|
|
oude = sp.run(["git", "-C", str(doel), "rev-parse", "v1.0.0"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
|
|
# start en wacht_op_gereed uitschakelen: hier gaat het om de broncode.
|
|
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{doel}"; VORIGE_COMMIT="{oude}"\n'
|
|
'start() { echo " (start overgeslagen)"; }\n'
|
|
'wacht_op_gereed() { return 0; }\n'
|
|
'terugrollen && echo "UITKOMST=teruggerold"\n', tmp_path)
|
|
assert "UITKOMST=teruggerold" in uit, uit
|
|
assert (doel / "f").read_text() == "eerst", "de broncode staat niet terug"
|
|
|
|
|
|
def test_zonder_vorige_versie_wordt_er_niet_teruggerold(tmp_path):
|
|
"""Bij een verse installatie is er niets om naar terug te gaan."""
|
|
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{tmp_path}"; VORIGE_COMMIT=""\n'
|
|
'terugrollen || echo "UITKOMST=niets-te-doen"\n', tmp_path)
|
|
assert "UITKOMST=niets-te-doen" in uit, uit
|
|
assert "Terugrollen" not in uit
|
|
|
|
|
|
def test_een_mislukte_update_rolt_terug_in_plaats_van_uitleggen():
|
|
"""Vroeger drukte hij een rijtje commando's af en liet hij je zitten."""
|
|
tekst = SCRIPT.read_text(encoding="utf-8")
|
|
update = tekst[tekst.index("\n update)"):]
|
|
update = update[:update.index("\n install)")]
|
|
assert "terugrollen" in update, "de update-tak rolt niet terug"
|
|
|
|
|
|
# ── Pullen in plaats van bouwen ──────────────────────────────────────────────
|
|
# Elke installatie bouwde het image lokaal — minuten werk — terwijl compose
|
|
# SU_IMAGE/SU_TAG al kent.
|
|
|
|
def _installatie(tmp_path, env_inhoud=""):
|
|
doel = tmp_path / "su"
|
|
doel.mkdir()
|
|
(doel / "docker-compose.yml").write_text("services: {}\n")
|
|
if env_inhoud:
|
|
(doel / ".env").write_text(env_inhoud)
|
|
return doel
|
|
|
|
|
|
def test_met_een_registry_image_wordt_er_gepulld(tmp_path):
|
|
doel = _installatie(tmp_path, "SU_IMAGE=10.0.20.22:3000/bes-r/server-up\nSU_TAG=1.0\n")
|
|
uit = _sh(f'DROOG=1; SUDO=""; DOEL="{doel}"\nstart\n', tmp_path)
|
|
assert " pull" in uit, uit
|
|
assert "--build" not in uit, "hij bouwt alsnog"
|
|
|
|
|
|
def test_zonder_registry_image_bouwt_hij(tmp_path):
|
|
"""Een lokaal gebouwd image bestaat nergens anders, dus er valt niets te halen."""
|
|
doel = _installatie(tmp_path, "SU_TAG=1.0\n")
|
|
uit = _sh(f'DROOG=1; SUDO=""; DOEL="{doel}"\nstart\n', tmp_path)
|
|
assert "--build" in uit, uit
|
|
assert " pull" not in uit, uit
|
|
|
|
|
|
def test_een_image_zonder_registrypad_telt_niet(tmp_path):
|
|
"""`SU_IMAGE=server-up` is geen registry: geen schuine streep, niets te halen."""
|
|
doel = _installatie(tmp_path, "SU_IMAGE=server-up\n")
|
|
uit = _sh(f'DROOG=1; SUDO=""; DOEL="{doel}"\nstart\n', tmp_path)
|
|
assert "--build" in uit, uit
|
|
|
|
|
|
# ── Kleinigheden ─────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("pad", ["relatief/pad", "su", "."])
|
|
def test_dir_moet_een_volledig_pad_zijn(pad):
|
|
"""Met `curl … | sh` is de werkmap je thuismap, en de compose-aanroepen
|
|
gebruiken dit pad letterlijk."""
|
|
r = _draai("--dir", pad, "--dry-run", "--yes")
|
|
assert r.returncode != 0
|
|
assert "volledig pad" in r.stderr
|
|
|
|
|
|
def test_geen_voorspelbaar_tijdelijk_bestand():
|
|
"""/tmp is voor iedereen schrijfbaar; een vaste naam laat zich vooraf
|
|
kapen met een symlink."""
|
|
tekst = SCRIPT.read_text(encoding="utf-8")
|
|
assert "/tmp/su-setup.$$" not in tekst
|
|
assert 'mktemp "${TMPDIR:-/tmp}/su-setup.XXXXXX"' in tekst
|
|
|
|
|
|
def test_de_daemon_wordt_een_keer_bevraagd():
|
|
"""`docker info` gevolgd door `docker version` zijn twee rondgangen naar de
|
|
daemon terwijl er één nodig is: zonder daemon faalt version net zo goed."""
|
|
tekst = SCRIPT.read_text(encoding="utf-8")
|
|
blok = tekst[tekst.index("controleer_docker()"):]
|
|
blok = blok[:blok.index("\n# \u2500\u2500 Broncode")]
|
|
aanroepen = [l.strip() for l in blok.splitlines()
|
|
if "$SUDO docker " in l and not l.strip().startswith("#")]
|
|
detectie = [l for l in aanroepen if "docker info" in l or "docker version" in l]
|
|
assert len(detectie) == 1, f"meer dan een rondgang naar de daemon: {detectie}"
|
|
|
|
|
|
# ── --uninstall en --purge ───────────────────────────────────────────────────
|
|
|
|
def test_uninstall_noemt_alles_wat_blijft_staan(tmp_path):
|
|
doel = _installatie(tmp_path, "BASE_DIR=/tmp/su-proef-basis\nSU_UID=0\n")
|
|
uit = _sh(f'DROOG=1; SUDO=""; JA=1; PURGE=0; DOEL="{doel}"\nverwijderen\n', tmp_path)
|
|
for stuk in ("su-data", "/tmp/su-proef-basis", str(doel), "--purge"):
|
|
assert stuk in uit, f"{stuk!r} wordt niet genoemd: {uit}"
|
|
|
|
|
|
def test_purge_vraagt_per_onderdeel(tmp_path):
|
|
basis = tmp_path / "gegevens"
|
|
(basis / "appdata").mkdir(parents=True)
|
|
doel = _installatie(tmp_path, f"BASE_DIR={basis}\nSU_UID=0\n")
|
|
uit = _sh(f'DROOG=1; SUDO=""; JA=1; PURGE=1; DOEL="{doel}"\nverwijderen\n', tmp_path)
|
|
assert "[droog]" in uit
|
|
assert "volume rm su-data" in uit, uit
|
|
assert f"rm -rf {basis}" in uit, uit
|
|
assert basis.is_dir(), "een proefdraai heeft toch verwijderd"
|
|
|
|
|
|
def test_purge_waarschuwt_voor_de_gegevensmap(tmp_path):
|
|
basis = tmp_path / "gegevens"
|
|
(basis / "appdata").mkdir(parents=True)
|
|
doel = _installatie(tmp_path, f"BASE_DIR={basis}\n")
|
|
uit = _sh(f'DROOG=1; SUDO=""; JA=1; PURGE=1; DOEL="{doel}"\nverwijderen\n', tmp_path)
|
|
assert "gegevens van al je apps" in uit, uit
|
|
|
|
|
|
def test_purge_staat_in_de_hulptekst():
|
|
r = _draai("--help")
|
|
assert "--purge" in r.stdout and "--reconfigure" in r.stdout
|
|
|
|
|
|
def test_reconfigure_zonder_installatie_stopt(tmp_path):
|
|
r = _draai("--reconfigure", "--dir", str(tmp_path / "bestaatniet"))
|
|
assert r.returncode != 0
|
|
assert "Geen installatie gevonden" in r.stderr
|
|
|
|
|
|
# ── Een koppeling die er is maar niets laat zien ─────────────────────────────
|
|
# Een bind mount wijst naar een inode, niet naar een pad. Wordt de map op de
|
|
# host vervangen nadat de container is aangemaakt, dan kijkt de container naar
|
|
# de oude — meestal lege — map. Het pad staat dan nog gewoon in
|
|
# /proc/self/mountinfo, dus alleen daarop controleren zegt niets.
|
|
|
|
def test_doctor_vergelijkt_beide_kanten_van_de_koppeling():
|
|
tekst = SCRIPT.read_text(encoding="utf-8")
|
|
blok = tekst[tekst.index("Wat Server Up zelf ziet"):]
|
|
blok = blok[:blok.index("\n printf ")]
|
|
assert "ls -1A" in blok, "de inhoud wordt nergens geteld"
|
|
assert blok.count("ls -1A") >= 2, \
|
|
"er wordt maar aan één kant geteld; juist het verschil is de diagnose"
|
|
assert "--force-recreate" in blok, "er staat geen uitweg bij"
|
|
|
|
|
|
def test_doctor_onderscheidt_ontbrekend_van_leeg():
|
|
""""De map bestaat niet in de container" en "de map is leeg" hebben een
|
|
andere oorzaak en een andere oplossing."""
|
|
tekst = SCRIPT.read_text(encoding="utf-8")
|
|
blok = tekst[tekst.index("Wat Server Up zelf ziet"):]
|
|
blok = blok[:blok.index("\n printf ")]
|
|
assert "test -d" in blok, "er wordt niet gekeken of de map er is"
|
|
assert "bestaat niet binnen de container" in blok
|
|
assert "nog geen enkele app" in blok
|
|
|
|
|
|
def test_doctor_kijkt_naar_koppelingen_onder_base_dir():
|
|
"""Docker bindt met propagation "rprivate". Een koppeling die op de host ná
|
|
het aanmaken van de container ontstaat, komt daar niet doorheen: de
|
|
container ziet de lege map eronder, terwijl de koppeling van BASE_DIR zelf
|
|
intact is en er dus niets aan te zien is."""
|
|
tekst = SCRIPT.read_text(encoding="utf-8")
|
|
blok = tekst[tekst.index("Wat Server Up zelf ziet"):]
|
|
blok = blok[:blok.index("\n printf ")]
|
|
assert "findmnt" in blok, "er wordt niet naar koppelingen gekeken"
|
|
assert "/proc/self/mountinfo" in blok, "de containerkant wordt niet vergeleken"
|
|
assert "eigen koppeling, maar niet in de container" in blok
|
|
# De uitweg hoort erbij te staan, anders weet je nog niets.
|
|
assert "docker-compose.yml" in blok
|