363 lines
15 KiB
Python
363 lines
15 KiB
Python
"""Het invulmenu: stapindeling, inloggegevens en zelf bewerken.
|
|
|
|
Het formulier was één lange lijst. Dat werkt slecht aan beide uiteinden: de
|
|
mediane app heeft vijf velden in één groep, arr-stack heeft er tweeënvijftig in
|
|
eenentwintig groepen. Vandaar vaste stappen in plaats van één stap per groep.
|
|
"""
|
|
import json
|
|
import stat
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
|
|
|
from conftest import login
|
|
|
|
APPS = Path(__file__).resolve().parent.parent / "apps"
|
|
|
|
|
|
@pytest.fixture
|
|
def bp(env):
|
|
from core import boilerplates
|
|
return boilerplates
|
|
|
|
|
|
def _veld(bp, **kw):
|
|
"""Eén los veld door fields() halen, om de indeling te toetsen."""
|
|
from core import git
|
|
d = git.cache_dir("server-up") / "apps" / "los"
|
|
(d / "files").mkdir(parents=True, exist_ok=True)
|
|
item = {"name": "x", "type": "str", "title": "X"}
|
|
item.update(kw)
|
|
(d / "template.json").write_text(json.dumps({
|
|
"kind": "compose", "metadata": {"name": "los"},
|
|
"variables": [{"title": "Algemeen", "items": [item]}]}), encoding="utf-8")
|
|
(d / "files" / "compose.yaml").write_text("services:\n a:\n image: nginx\n",
|
|
encoding="utf-8")
|
|
return bp.fields(d)[0]
|
|
|
|
|
|
# ── Indeling in stappen ──────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("kw,stap", [
|
|
({"name": "service_name"}, "basis"),
|
|
({"name": "port", "type": "int"}, "basis"),
|
|
({"name": "web_port", "type": "int"}, "basis"),
|
|
({"name": "appdata_dir"}, "basis"),
|
|
({"name": "data_root"}, "basis"),
|
|
({"name": "timezone"}, "basis"),
|
|
({"name": "db_password"}, "geheimen"),
|
|
({"name": "jwt_secret"}, "geheimen"),
|
|
({"name": "admin_user"}, "geheimen"), # hoort bij het wachtwoord
|
|
({"name": "admin_email"}, "geheimen"),
|
|
({"name": "retention"}, "instellingen"),
|
|
({"name": "allow_signup", "type": "bool"}, "instellingen"),
|
|
({"name": "broker", "connect": {"app": "mosquitto"}}, "verbinden"),
|
|
])
|
|
def test_velden_komen_in_de_juiste_stap(bp, kw, stap):
|
|
assert _veld(bp, **kw)["step"] == stap
|
|
|
|
|
|
def test_sjabloon_mag_de_stap_overrulen(bp):
|
|
"""Een app-maker moet een veld ergens anders kunnen neerzetten."""
|
|
assert _veld(bp, name="retention", step="basis")["step"] == "basis"
|
|
|
|
|
|
def test_onbekende_stap_valt_terug(bp):
|
|
assert _veld(bp, name="retention", step="verzonnen")["step"] == "instellingen"
|
|
|
|
|
|
@pytest.mark.parametrize("app,verwacht", [
|
|
("grafana", {"basis", "geheimen"}),
|
|
("vaultwarden", {"basis", "instellingen", "geheimen"}),
|
|
])
|
|
def test_echte_apps_krijgen_zinnige_stappen(bp, app, verwacht):
|
|
stappen = {f["step"] for f in bp.fields(APPS / app)}
|
|
assert stappen == verwacht
|
|
|
|
|
|
def test_de_vangnetstap_slokt_niet_alles_op(bp):
|
|
"""Een app waarvan alle velden in 'instellingen' belanden, betekent dat de
|
|
indeling niets heeft herkend. Alles in 'basis' is juist prima: de meeste
|
|
apps vragen alleen om een naam, een poort en een map."""
|
|
mislukt = []
|
|
for d in sorted(APPS.iterdir()):
|
|
if not (d / "template.json").is_file():
|
|
continue
|
|
velden = bp.fields(d)
|
|
if len(velden) > 3 and {f["step"] for f in velden} == {"instellingen"}:
|
|
mislukt.append(d.name)
|
|
assert not mislukt, mislukt
|
|
|
|
|
|
def test_eenvoudige_apps_krijgen_maar_een_paar_stappen(bp):
|
|
"""Voor de mediane app hoort het invulmenu kort te zijn."""
|
|
velden = bp.fields(APPS / "freshrss")
|
|
assert {f["step"] for f in velden} == {"basis"}
|
|
|
|
|
|
# ── Inloggegevens ────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("naam,soort", [
|
|
("admin_password", "password"),
|
|
("web_password", "password"),
|
|
("admin_user", "username"),
|
|
("admin_email", "username"),
|
|
("superuser_name", "username"),
|
|
("jwt_secret", ""), # een sleutel is geen inloggegeven
|
|
("api_key", ""),
|
|
("port", ""),
|
|
])
|
|
def test_inloggegevens_worden_herkend(bp, naam, soort):
|
|
assert _veld(bp, name=naam)["credential"] == soort
|
|
|
|
|
|
def test_sjabloon_mag_credential_overrulen(bp):
|
|
assert _veld(bp, name="iets", credential="username")["credential"] == "username"
|
|
|
|
|
|
def _bron(env, naam="inlogapp"):
|
|
from core import git
|
|
d = git.cache_dir("server-up") / "apps" / naam
|
|
(d / "files").mkdir(parents=True, exist_ok=True)
|
|
(d / "template.json").write_text(json.dumps({
|
|
"kind": "compose", "metadata": {"name": naam},
|
|
"variables": [{"title": "Algemeen", "items": [
|
|
{"name": "service_name", "type": "str", "title": "Naam",
|
|
"default": naam, "required": True},
|
|
{"name": "port", "type": "int", "title": "Poort", "default": 8123},
|
|
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
|
|
"default": "admin"},
|
|
{"name": "admin_password", "type": "str", "title": "Wachtwoord",
|
|
"default": "", "required": True},
|
|
]}]}), encoding="utf-8")
|
|
(d / "files" / "compose.yaml").write_text(
|
|
"services:\n << service_name >>:\n image: nginx\n"
|
|
" ports:\n - \"<< port >>:80\"\n"
|
|
" environment:\n"
|
|
" - ADMIN_USER=<< admin_user >>\n"
|
|
" - ADMIN_PASSWORD=<< admin_password >>\n", encoding="utf-8")
|
|
return d
|
|
|
|
|
|
def _wacht(client, jid, tijdslimiet=10):
|
|
einde = time.time() + tijdslimiet
|
|
while time.time() < einde:
|
|
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
|
if d.get("status") in ("done", "error"):
|
|
return d
|
|
time.sleep(0.05)
|
|
return {}
|
|
|
|
|
|
def _installeer(client, env, csrf, inst="ia", **waarden):
|
|
_bron(env)
|
|
v = {"service_name": inst, "admin_user": "ramon",
|
|
"admin_password": "MijnWachtwoord1"}
|
|
v.update(waarden)
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "inlogapp", "repo_id": "server-up",
|
|
"instance": inst, "values": v},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
_wacht(client, r.get_json()["job_id"])
|
|
return r
|
|
|
|
|
|
def test_inloggegevens_tonen_naam_en_wachtwoord(client, env):
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf)
|
|
d = client.get("/api/stacks/ia/credentials").get_json()
|
|
assert d["ok"] is True
|
|
soorten = {i["kind"]: i["value"] for i in d["items"]}
|
|
assert soorten["username"] == "ramon"
|
|
assert soorten["password"] == "MijnWachtwoord1"
|
|
assert d["self_signup"] is False
|
|
|
|
|
|
def test_wachtwoord_is_als_geheim_gemarkeerd(client, env):
|
|
"""De interface verbergt het achter een oog-knop."""
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf, inst="ib")
|
|
d = client.get("/api/stacks/ib/credentials").get_json()
|
|
wachtwoord = next(i for i in d["items"] if i["kind"] == "password")
|
|
naam = next(i for i in d["items"] if i["kind"] == "username")
|
|
assert wachtwoord["secret"] is True
|
|
assert naam["secret"] is False
|
|
|
|
|
|
def test_app_zonder_inlogvelden_zegt_dat(client, env):
|
|
"""Vaultwarden en Immich laten je bij het eerste bezoek zelf een account
|
|
aanmaken; dan valt hier niets te tonen."""
|
|
from core import git
|
|
csrf = login(client)
|
|
d = git.cache_dir("server-up") / "apps" / "geeninlog"
|
|
(d / "files").mkdir(parents=True, exist_ok=True)
|
|
(d / "template.json").write_text(json.dumps({
|
|
"kind": "compose", "metadata": {"name": "geeninlog"},
|
|
"variables": [{"title": "Algemeen", "items": [
|
|
{"name": "service_name", "type": "str", "title": "Naam",
|
|
"default": "geeninlog", "required": True}]}]}), encoding="utf-8")
|
|
(d / "files" / "compose.yaml").write_text(
|
|
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "geeninlog", "repo_id": "server-up",
|
|
"instance": "gi", "values": {}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht(client, r.get_json()["job_id"])
|
|
|
|
res = client.get("/api/stacks/gi/credentials").get_json()
|
|
assert res["items"] == []
|
|
assert res["self_signup"] is True
|
|
|
|
|
|
def test_inloggegevens_alleen_voor_beheerders(client, env):
|
|
"""Ze staan gelijk aan toegang tot de app zelf."""
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf, inst="ic")
|
|
client.post("/api/auth/users",
|
|
json={"username": "op", "password": "eenlangwachtwoord",
|
|
"role": "operator"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
client.post("/api/auth/login",
|
|
json={"username": "op", "password": "eenlangwachtwoord"})
|
|
r = client.get("/api/stacks/ic/credentials")
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_tonen_komt_in_het_auditlog(client, env):
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf, inst="id")
|
|
from core import audit
|
|
audit.init()
|
|
client.get("/api/stacks/id/credentials")
|
|
assert any(r["action"] == "credentials_view" for r in audit.query())
|
|
|
|
|
|
def test_onbekende_stack(client, env):
|
|
login(client)
|
|
assert client.get("/api/stacks/bestaatniet/credentials").status_code == 404
|
|
|
|
|
|
# ── Zelf bewerken vóór installeren ───────────────────────────────────────────
|
|
|
|
def test_eigen_compose_wordt_gebruikt(client, env):
|
|
csrf = login(client)
|
|
_bron(env)
|
|
eigen = ("services:\n handmatig:\n image: nginx:1.27\n"
|
|
" container_name: zelf-verzonnen\n")
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "inlogapp", "repo_id": "server-up",
|
|
"instance": "ie",
|
|
"values": {"admin_password": "MijnWachtwoord1"},
|
|
"compose_override": eigen},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
_wacht(client, r.get_json()["job_id"])
|
|
assert "zelf-verzonnen" in (env["lib"] / "ie" / "docker-compose.yml").read_text()
|
|
|
|
|
|
def test_eigen_env_wordt_gebruikt(client, env):
|
|
csrf = login(client)
|
|
_bron(env)
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "inlogapp", "repo_id": "server-up",
|
|
"instance": "if",
|
|
"values": {"admin_password": "MijnWachtwoord1"},
|
|
"env_override": "HANDMATIG=ja\n"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht(client, r.get_json()["job_id"])
|
|
assert "HANDMATIG=ja" in (env["lib"] / "if" / ".env").read_text()
|
|
|
|
|
|
def test_kapotte_yaml_wordt_geweigerd_voordat_er_iets_gemaakt_is(client, env):
|
|
"""Een typefout hoort hier te stranden, niet halverwege een installatie."""
|
|
csrf = login(client)
|
|
_bron(env)
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "inlogapp", "repo_id": "server-up",
|
|
"instance": "ig",
|
|
"values": {"admin_password": "MijnWachtwoord1"},
|
|
"compose_override": "services: [kapot: ja: nee"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
assert "YAML" in r.get_json()["msg"]
|
|
assert not (env["lib"] / "ig").exists()
|
|
|
|
|
|
# ── De .env-editor ───────────────────────────────────────────────────────────
|
|
|
|
def test_env_editor_weigert_een_regel_zonder_isgelijkteken(client, env):
|
|
"""Compose negeert zo'n regel stilzwijgend; dan mist er ineens een
|
|
wachtwoord zonder dat je het merkt."""
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf, inst="ih")
|
|
r = client.put("/api/stacks/ih/env",
|
|
json={"content": "GOED=1\ndit is onzin\n"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
assert "Regel 2" in r.get_json()["msg"]
|
|
|
|
|
|
def test_env_editor_accepteert_commentaar_en_lege_regels(client, env):
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf, inst="ii")
|
|
r = client.put("/api/stacks/ii/env",
|
|
json={"content": "# uitleg\n\nGOED=1\n"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
assert stat.S_IMODE((env["lib"] / "ii" / ".env").stat().st_mode) == 0o600
|
|
|
|
|
|
def test_env_bewerken_komt_in_het_auditlog(client, env):
|
|
csrf = login(client)
|
|
_installeer(client, env, csrf, inst="ij")
|
|
from core import audit
|
|
audit.init()
|
|
client.put("/api/stacks/ij/env", json={"content": "X=1\n"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert any(r["action"] == "env_edit" for r in audit.query())
|
|
|
|
|
|
# ── Voorvertoning met eigen waarden ──────────────────────────────────────────
|
|
|
|
def test_voorvertoning_gebruikt_de_ingevulde_waarden(client, env):
|
|
"""Anders toont de laatste stap iets anders dan er straks komt te staan."""
|
|
csrf = login(client)
|
|
_bron(env)
|
|
r = client.post("/api/store/preview",
|
|
json={"stack": "inlogapp", "repo_id": "server-up",
|
|
"values": {"service_name": "eigennaam", "port": 9999,
|
|
"admin_password": "MijnWachtwoord1"}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
d = r.get_json()
|
|
assert "eigennaam" in d["compose_preview"]
|
|
assert "9999" in d["compose_preview"]
|
|
# Het geheim hoort al in de .env-voorvertoning te staan, niet in compose.
|
|
assert "MijnWachtwoord1" not in d["compose_preview"]
|
|
assert "MijnWachtwoord1" in d["env_preview"]
|
|
|
|
|
|
def test_webadres_gebruikt_https_en_de_juiste_containerpoort(client):
|
|
import app as app_module
|
|
compose = ("services:\n database:\n image: mongo\n"
|
|
" ports:\n - \"27017:27017\"\n"
|
|
" unifi:\n image: unifi\n"
|
|
" ports:\n - \"9443:8443\"\n - \"8080:8080\"\n")
|
|
bron = {"web": {"scheme": "https", "port": 8443}}
|
|
|
|
assert app_module._stack_webadres({}, bron, compose, "server.local") == \
|
|
"https://server.local:9443"
|
|
assert app_module._stack_webadres({"ip": "192.168.1.50"}, bron, compose,
|
|
"server.local") == \
|
|
"https://192.168.1.50:8443"
|
|
|
|
|
|
def test_webadres_zonder_metadata_blijft_http(client):
|
|
import app as app_module
|
|
compose = "services:\n app:\n image: nginx\n ports:\n - \"8123:80\"\n"
|
|
assert app_module._stack_webadres({}, {}, compose, "server.local") == \
|
|
"http://server.local:8123"
|