"""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"