server-up/tests/test_geheimen_env.py
Ramon bc55252267
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m34s
v0.7.70-beta - invulmenu bij installeren, geheimen naar .env
Invulmenu:
- Vaste stappen (Basis, Verbinden, Instellingen, Toegang, Controleren) in
  plaats van één lange lijst. Eén stap per groep werkt niet: de mediane app
  heeft één groep, arr-stack eenentwintig. Lege stappen worden overgeslagen.
- Volgende controleert de verplichte velden van die stap; die melding kwam
  eerder pas bij het installeren.
- Geavanceerde opties achter één schakelaar die op elke stap zichtbaar is,
  plus 'Alles op één pagina' voor het oude gedrag. Beide onthouden.

Geheimen naar .env:
- compose_transform.geheimen_naar_env() vervangt geheime waarden door
  ${NAAM} en schrijft ze naar .env met rechten 0600. Werkt ook midden in
  een database-URL. Poorten en paden blijven leesbaar in compose.
- .serverup.json bevat geen geheimen meer en krijgt ook 0600; daar stonden
  ze wereldleesbaar in. Het herconfiguratieformulier leest ze terug uit
  .env via een bewaarde veld-naar-sleutel-koppeling.
- docs/beveiliging.md legt uit wat dit niet oplost: docker inspect toont de
  waarde nog steeds.

Bewerken:
- Laatste stap toont het gerenderde resultaat met jouw waarden en is
  bewerkbaar; compose_override/env_override gaan mee bij het installeren.
  Ongeldige YAML wordt geweigerd voordat er een map bestaat.
- De bestaande editor heeft tabbladen (compose/.env) en biedt herstarten na
  opslaan. De .env-PUT weigert een regel zonder '=' en logt in audit.

Inloggegevens:
- /api/stacks/<n>/credentials plus paneel op de stackkaart: adres,
  gebruikersnaam, wachtwoord achter een oog-knop, kopieerknop. Alleen voor
  beheerders, elk bekijken komt in het auditlog.
- Nieuw 'credential'-kenmerk in template.json met naamherkenning als
  terugval, zodat de bestaande 96 sjablonen meteen werken.

Herstel van een regressie uit v0.7.60: acht inlogwachtwoorden hadden hun
genereerknop verloren toen die aan 'generate' werd gehangen. Terug, behalve
waar een verzonnen waarde fout is (WireGuard-sleutel, externe tokens).

De menulogica wordt getest door de echte component uit index.html in Node uit
te voeren; slaat zichzelf over waar node ontbreekt. 2295 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-28 21:31:51 +02:00

239 lines
9.2 KiB
Python

"""Geheimen horen in `.env`, niet in het compose-bestand.
Compose vult `${NAAM}` in bij het inlezen, dus functioneel verandert er niets.
Wat je wint: het compose-bestand is te delen en te committen, en alle geheimen
staan op één plek die met 0600 weggeschreven kan worden — tot v0.7.60 stonden
ze zowel in `docker-compose.yml` als wereldleesbaar in `.serverup.json`.
"""
import json
import stat
import sys
import time
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
VELDEN = [{"name": "secret_key", "secret": True},
{"name": "db_password", "secret": True},
{"name": "timezone", "secret": False},
{"name": "port", "secret": False}]
WAARDEN = {"secret_key": "Xq2mB7vK9pL4nR8t", "db_password": "aB3dE6gH9jK2mN5p",
"timezone": "Europe/Amsterdam", "port": 8000}
COMPOSE = """services:
app:
image: voorbeeld:latest
ports:
- "8000:8000"
environment:
- APP_SECRET_KEY=Xq2mB7vK9pL4nR8t
- APP_DBPASS=aB3dE6gH9jK2mN5p
- TZ=Europe/Amsterdam
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: aB3dE6gH9jK2mN5p
worker:
image: worker:latest
environment:
- DATABASE_URL=postgres://app:aB3dE6gH9jK2mN5p@db:5432/app
"""
@pytest.fixture
def ct(env):
from core import compose_transform
return compose_transform
# ── De transformatie ─────────────────────────────────────────────────────────
def test_geheimen_verdwijnen_uit_compose(ct):
nieuw, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert "Xq2mB7vK9pL4nR8t" not in nieuw
assert "aB3dE6gH9jK2mN5p" not in nieuw
assert set(env_regels.values()) == {"Xq2mB7vK9pL4nR8t", "aB3dE6gH9jK2mN5p"}
def test_niet_geheime_waarden_blijven_leesbaar(ct):
"""Poorten, paden en tijdzone horen zichtbaar te blijven; anders moet je
twee bestanden naast elkaar lezen om te snappen wat er draait."""
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert "Europe/Amsterdam" in nieuw
assert "8000" in nieuw
def test_sleutelnaam_komt_uit_de_omgevingsvariabele(ct):
"""`.env` moet leesbaar zijn: APP_SECRET_KEY zegt meer dan SECRET_KEY_1."""
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert "APP_SECRET_KEY" in env_regels
def test_dezelfde_waarde_krijgt_een_sleutel(ct):
"""Het databasewachtwoord staat op drie plekken; dat wordt één regel."""
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert list(env_regels.values()).count("aB3dE6gH9jK2mN5p") == 1
def test_geheim_midden_in_een_url(ct):
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
doc = yaml.safe_load(nieuw)
url = doc["services"]["worker"]["environment"][0]
assert url.startswith("DATABASE_URL=postgres://app:${")
assert url.endswith("@db:5432/app")
def test_koppeling_wijst_veld_naar_sleutel(ct):
"""Zonder die koppeling kunnen we de waarde later niet exact terugvinden."""
_, env_regels, koppeling = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
assert set(koppeling) == {"secret_key", "db_password"}
for veld, sleutel in koppeling.items():
assert env_regels[sleutel] == WAARDEN[veld]
def test_zonder_geheimen_verandert_er_niets(ct):
nieuw, env_regels, koppeling = ct.geheimen_naar_env(
COMPOSE, [{"name": "port", "secret": False}], {"port": 8000})
assert nieuw == COMPOSE
assert env_regels == {} and koppeling == {}
def test_leeg_geheim_wordt_overgeslagen(ct):
"""Anders zou een lege waarde overal in het bestand 'gevonden' worden."""
nieuw, env_regels, _ = ct.geheimen_naar_env(
COMPOSE, [{"name": "leeg", "secret": True}], {"leeg": ""})
assert nieuw == COMPOSE and env_regels == {}
def test_ongeldige_yaml_geeft_een_nette_fout(ct):
with pytest.raises(ct.TransformError):
ct.geheimen_naar_env("dit: [is: geen: yaml", VELDEN, WAARDEN)
def test_compose_blijft_geldig(ct):
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
doc = yaml.safe_load(nieuw)
assert set(doc["services"]) == {"app", "db", "worker"}
# ── env samenvoegen ──────────────────────────────────────────────────────────
def test_env_samenvoegen_behoudt_bestaande_regels(ct):
uit = ct.env_samenvoegen("BIND=0.0.0.0\nPORT=5000\n", {"DB_PASS": "x"})
assert "BIND=0.0.0.0" in uit and "PORT=5000" in uit and "DB_PASS=x" in uit
def test_env_samenvoegen_vervangt_zonder_te_verdubbelen(ct):
uit = ct.env_samenvoegen("DB_PASS=oud\nPORT=5000\n", {"DB_PASS": "nieuw"})
assert uit.count("DB_PASS=") == 1
assert "DB_PASS=nieuw" in uit and "oud" not in uit
def test_env_samenvoegen_houdt_commentaar(ct):
uit = ct.env_samenvoegen("# van de installatie\nPORT=5000\n", {"X": "1"})
assert "# van de installatie" in uit
# ── Rechten ──────────────────────────────────────────────────────────────────
def test_env_wordt_met_beperkte_rechten_geschreven(env, tmp_path):
"""Hier staan de wachtwoorden in; standaard maakt Python het bestand
wereldleesbaar."""
from core import docker
docker.write_env(tmp_path, "DB_PASS=geheim\n")
modus = stat.S_IMODE((tmp_path / ".env").stat().st_mode)
assert modus == 0o600, oct(modus)
# ── Van begin tot eind ───────────────────────────────────────────────────────
def _bron(env, naam="geheimapp"):
from core import git
wortel = git.cache_dir("server-up") / "apps" / naam
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "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": "timezone", "type": "str", "title": "Tijdzone",
"default": "Europe/Amsterdam"},
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
"default": "", "required": True, "generate": "auto"},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n"
" << service_name >>:\n"
" image: nginx\n"
" environment:\n"
" - TZ=<< timezone >>\n"
" - DB_PASSWORD=<< db_password >>\n", encoding="utf-8")
return wortel
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 test_installatie_zet_het_geheim_in_env(client, env):
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "geheimapp", "repo_id": "server-up",
"instance": "ga", "values": {"db_password": "zeergeheim123"}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
d = env["lib"] / "ga"
compose = (d / "docker-compose.yml").read_text()
assert "zeergeheim123" not in compose, compose
assert "${" in compose
assert "zeergeheim123" in (d / ".env").read_text()
# De tijdzone hoort gewoon leesbaar te blijven.
assert "Europe/Amsterdam" in compose
def test_installatie_bewaart_geen_geheim_in_de_metadata(client, env):
"""`.serverup.json` stond wereldleesbaar vol wachtwoorden."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "geheimapp", "repo_id": "server-up",
"instance": "gb", "values": {"db_password": "zeergeheim123"}},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
mf = env["lib"] / "gb" / ".serverup.json"
ruw = mf.read_text()
assert "zeergeheim123" not in ruw, ruw
meta = json.loads(ruw)
assert meta["secret_fields"] == ["db_password"]
assert meta["secret_keys"]["db_password"] == "DB_PASSWORD"
assert stat.S_IMODE(mf.stat().st_mode) == 0o600
def test_formulier_toont_het_geheim_weer_bij_wijzigen(client, env):
"""Anders staat het veld leeg en wist opslaan het wachtwoord."""
csrf = login(client)
_bron(env)
r = client.post("/api/store/install",
json={"stack": "geheimapp", "repo_id": "server-up",
"instance": "gc", "values": {"db_password": "zeergeheim123"}},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
d = client.get("/api/stacks/gc/config").get_json()
assert d["ok"] is True
assert d["values"]["db_password"] == "zeergeheim123"