All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m1s
Ik sprong met tientallen (0.7.90 → 0.8.00 → … → 0.9.00 → 0.10.00) en liep daarmee de reeks uit: dit schema rolt over bij .90, dus na 0.9.90 hoort 1.0.00 te komen. "0.10.00" bestond niet, en schond ook het formaat v0.0.00 uit de projectafspraken. De vijftien uitgaven van deze sessie zijn hernummerd naar 0.7.81 t/m 0.7.95, aaneengesloten. In één doorloop met een tabel, want 0.8.81 wordt 0.7.90 en 0.7.90 wordt 0.7.81 — achtereenvolgende vervangingen zouden elkaar overschrijven. Meegenomen: verwijzingen in de documentatie, .env.example, install.sh en de tests. Verkorte vormen als "vanaf v0.10" zijn vervangen door het volledige nummer, want die waren niet automatisch te herleiden. De commit-onderwerpen in de geschiedenis dragen nog de oude nummers; VERSION en CHANGELOG zijn leidend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
1009 lines
40 KiB
Python
1009 lines
40 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
|