server-up/tests/test_pangolin.py
Ramon ea9b1eb668
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m10s
v0.7.80-beta - Volgende-knop, tijdzones, netwerken per groepje, Pangolin
De knop Volgende deed niets. Alpine haalt een boolean-attribuut alleen weg bij
null, undefined of false; het getal 0 zet het juist AAN. Mijn
:disabled="stepMissing().length" gaf bij een ingevuld formulier 0 en zette de
knop dus permanent uit. De logica klopte, de binding niet — met losse logica
niet te vinden, dus er draait nu een test die de echte modal met Alpine in een
DOM rendert en erop klikt.

Tijdzones:
- Nieuwe instelling TIMEZONE, standaard die van de server (TZ, /etc/timezone,
  /etc/localtime). In alle 77 sjablonen stond Europe/Amsterdam hard ingevuld.
- Apps zonder tijdzoneveld krijgen TZ er automatisch bij; die draaiden in UTC.

Poorten:
- _bezette_poorten() telt nu ook mee waar de host zelf op luistert, gelezen uit
  /proc/net/tcp in een hulpcontainer met --network host. Een nginx buiten
  Docker zag Server Up eerder niet.
- GET /api/ports/check geeft vrij/bezet plus een alternatief, en meldt of de
  host echt gecontroleerd kon worden.

Netwerken:
- APP_NETWORKS: meerdere gedeelde bridge-netwerken in plaats van één waar élke
  app aan hing. Per app kies je welke; aanmaken kan vanuit het invulmenu, met
  eigen naam of een voorstel (su-<appnaam>).
- add_shared_network() accepteert een lijst; één naam blijft werken.
- GET /api/networks/<n>/ip-check controleert subnet, bereik, Docker-toewijzing
  en de ARP-tabel van de host (vangt een fysiek apparaat met DHCP-adres) en
  stelt een vrij adres voor.

Pangolin (nieuw, core/pangolin.py):
- Publieke URL per app via een tunnel, zonder poorten open te zetten.
- De API veranderde in 1.9 (resource hing onder een site, staat nu los); beide
  routes worden geprobeerd en bij een fout krijg je beide meldingen.
- API-sleutel is write-only. Getest tegen een nagebouwde API, niet tegen een
  echte server.

Nieuw: docs/pangolin.md; docs/netwerken.md uitgebreid. 2369 tests groen.

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

274 lines
11 KiB
Python

"""Pangolin: publieke URL's voor je apps.
Getest tegen een nagebouwde API, niet tegen een echte Pangolin — die heb ik
hier niet. Wat hier vastligt is dus dat wij de juiste verzoeken sturen en met
alle antwoorden overweg kunnen, niet dat de echte server ze accepteert.
De API veranderde in 1.9: een resource hing eerst onder een site en staat nu
los. Beide routes worden geprobeerd; zie fosrl/pangolin#1344.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
@pytest.fixture
def pg(env):
from core import pangolin
env["core"].patch({"PANGOLIN_URL": "https://pangolin.test",
"PANGOLIN_API_KEY": "sleutel123",
"PANGOLIN_ORG_ID": "mijnorg"})
return pangolin
@pytest.fixture
def nep(pg, monkeypatch):
"""Vangt de verzoeken op en beantwoordt ze volgens een script."""
verzonden = []
antwoorden = {}
def _verzoek(methode, pad, body=None):
verzonden.append({"methode": methode, "pad": pad, "body": body})
for sleutel, waarde in antwoorden.items():
if sleutel[0] == methode and pad.startswith(sleutel[1]):
return waarde
return 404, {"message": "niet gevonden"}
monkeypatch.setattr(pg, "_verzoek", _verzoek)
return {"verzonden": verzonden, "antwoorden": antwoorden}
# ── Instellingen ─────────────────────────────────────────────────────────────
def test_zonder_instellingen_uitgeschakeld(env):
from core import pangolin
assert pangolin.ingesteld() is False
def test_met_instellingen_ingeschakeld(pg):
assert pg.ingesteld() is True
def test_sleutel_lekt_niet_via_de_api(client, env):
csrf = login(client)
client.put("/api/settings",
json={"PANGOLIN_URL": "https://pangolin.test",
"PANGOLIN_API_KEY": "zeergeheim", "PANGOLIN_ORG_ID": "o"},
headers={"X-CSRF-Token": csrf})
body = client.get("/api/pangolin").get_data(as_text=True)
assert "zeergeheim" not in body
assert '"has_key":true' in body.replace(" ", "")
assert "zeergeheim" not in client.get("/api/settings").get_data(as_text=True)
assert env["core"].load()["PANGOLIN_API_KEY"] == "zeergeheim"
# ── Verbinding testen ────────────────────────────────────────────────────────
def test_test_zonder_organisatie(pg, env):
env["core"].patch({"PANGOLIN_ORG_ID": ""})
ok, msg = pg.test()
assert ok is False
assert "organisatie" in msg
def test_test_meldt_een_geweigerde_sleutel(pg, nep):
nep["antwoorden"][("GET", "/org/")] = (401, {"message": "unauthorized"})
ok, msg = pg.test()
assert ok is False
assert "401" in msg
def test_test_slaagt(pg, nep):
nep["antwoorden"][("GET", "/org/")] = (200, {"sites": [{"siteId": 1, "name": "thuis"}]})
ok, msg = pg.test()
assert ok is True
assert "1 site" in msg
def test_netwerkfout_wordt_gemeld(pg, monkeypatch):
def _stuk(*a, **kw):
raise pg.PangolinError("TimeoutError: te traag")
monkeypatch.setattr(pg, "_verzoek", _stuk)
ok, msg = pg.test()
assert ok is False
assert "Timeout" in msg
# ── Lijsten uitpakken ────────────────────────────────────────────────────────
@pytest.mark.parametrize("antwoord", [
{"sites": [{"siteId": 7, "name": "thuis"}]},
{"data": {"sites": [{"siteId": 7, "name": "thuis"}]}},
{"data": [{"siteId": 7, "name": "thuis"}]},
[{"siteId": 7, "name": "thuis"}],
])
def test_sites_uit_verschillende_verpakkingen(pg, nep, antwoord):
"""Pangolin verpakt lijsten wisselend in `data`; allebei aankunnen."""
nep["antwoorden"][("GET", "/org/")] = (200, antwoord)
assert pg.sites() == [{"id": 7, "name": "thuis"}]
def test_domeinen(pg, nep):
nep["antwoorden"][("GET", "/org/")] = (
200, {"domains": [{"domainId": "d1", "baseDomain": "voorbeeld.nl"}]})
assert pg.domeinen() == [{"id": "d1", "domain": "voorbeeld.nl"}]
# ── Publiceren ───────────────────────────────────────────────────────────────
def _standaard(nep):
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (
200, {"resourceId": 42, "fullDomain": "app.voorbeeld.nl"})
nep["antwoorden"][("PUT", "/resource/42/target")] = (200, {"targetId": 1})
def test_publiceren_maakt_resource_en_target(pg, nep):
_standaard(nep)
res = pg.publiceer("mijnapp", "mijnapp", "192.168.1.50", 8080,
site_id=3, domein_id="d1")
assert res["url"] == "https://app.voorbeeld.nl"
assert res["resource_id"] == 42
resource = nep["verzonden"][0]
assert resource["methode"] == "PUT"
assert resource["body"]["name"] == "mijnapp"
assert resource["body"]["http"] is True
assert resource["body"]["domainId"] == "d1"
target = nep["verzonden"][1]
assert target["pad"] == "/resource/42/target"
assert target["body"] == {"siteId": 3, "ip": "192.168.1.50", "port": 8080,
"method": "http", "enabled": True}
def test_valt_terug_op_de_oude_route(pg, nep):
"""Tot 1.9 hing een resource onder een site en moest siteId in de body."""
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (
400, {"message": "Validation error: Unrecognized key(s) in object: 'siteId'"})
nep["antwoorden"][("PUT", "/org/mijnorg/site/3/resource")] = (
200, {"resourceId": 42, "fullDomain": "app.voorbeeld.nl"})
nep["antwoorden"][("PUT", "/resource/42/target")] = (200, {})
res = pg.publiceer("mijnapp", "mijnapp", "192.168.1.50", 8080,
site_id=3, domein_id="d1")
assert res["resource_id"] == 42
assert nep["verzonden"][1]["pad"] == "/org/mijnorg/site/3/resource"
assert nep["verzonden"][1]["body"]["siteId"] == 3
def test_beide_routes_mislukken_geeft_beide_meldingen(pg, nep):
"""Anders sta je met een foutmelding die maar de helft van het verhaal is."""
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (400, {"message": "nieuw stuk"})
nep["antwoorden"][("PUT", "/org/mijnorg/site/3/resource")] = (
500, {"message": "oud ook stuk"})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3, domein_id="d1")
assert "nieuw stuk" in str(exc.value)
assert "oud ook stuk" in str(exc.value)
def test_zonder_resource_id_stopt_hij(pg, nep):
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (200, {"ok": True})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3, domein_id="d1")
assert "resource-id" in str(exc.value)
def test_domein_wordt_opgehaald_als_het_niet_is_ingesteld(pg, nep):
nep["antwoorden"][("GET", "/org/mijnorg/domains")] = (
200, {"domains": [{"domainId": "auto", "baseDomain": "x.nl"}]})
_standaard(nep)
pg.publiceer("mijnapp", "mijnapp", "1.2.3.4", 80, site_id=3)
resource = next(v for v in nep["verzonden"] if v["pad"].endswith("/resource"))
assert resource["body"]["domainId"] == "auto"
def test_zonder_domein_nette_fout(pg, nep):
nep["antwoorden"][("GET", "/org/mijnorg/domains")] = (200, {"domains": []})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3)
assert "domein" in str(exc.value).lower()
def test_zonder_site_wijst_hij_naar_newt(pg, nep):
nep["antwoorden"][("GET", "/org/mijnorg/sites")] = (200, {"sites": []})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, domein_id="d1")
assert "Newt" in str(exc.value)
def test_mislukt_target_wordt_gemeld(pg, nep):
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (200, {"resourceId": 42})
nep["antwoorden"][("PUT", "/resource/42/target")] = (400, {"message": "poort bezet"})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3, domein_id="d1")
assert "poort bezet" in str(exc.value)
# ── Via de API ───────────────────────────────────────────────────────────────
def test_publiceren_zonder_instellingen(client, env):
csrf = login(client)
(env["lib"] / "web").mkdir(parents=True, exist_ok=True)
(env["lib"] / "web" / "docker-compose.yml").write_text(
"services:\n web:\n image: nginx\n", encoding="utf-8")
r = client.post("/api/stacks/web/pangolin", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "niet ingesteld" in r.get_json()["msg"]
def test_publiceren_van_onbekende_stack(client, env):
csrf = login(client)
r = client.post("/api/stacks/bestaatniet/pangolin", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_poort_wordt_uit_compose_gehaald(client, env, monkeypatch):
from core import pangolin
csrf = login(client)
env["core"].patch({"PANGOLIN_URL": "https://p.test", "PANGOLIN_API_KEY": "k",
"PANGOLIN_ORG_ID": "o"})
d = env["lib"] / "web"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
'services:\n web:\n image: nginx\n ports:\n - "8123:80"\n',
encoding="utf-8")
(d / ".serverup.json").write_text(json.dumps({"ip": "192.168.1.60"}),
encoding="utf-8")
gezien = {}
def _publiceer(naam, subdomein, ip, poort, site_id=None, domein_id=""):
gezien.update({"ip": ip, "poort": poort, "sub": subdomein})
return {"url": "https://web.voorbeeld.nl", "resource_id": 1}
monkeypatch.setattr(pangolin, "publiceer", _publiceer)
r = client.post("/api/stacks/web/pangolin", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert gezien["ip"] == "192.168.1.60" # eigen IP gaat voor
assert gezien["poort"] == 8123
# En de URL wordt bewaard bij de stack.
meta = json.loads((d / ".serverup.json").read_text())
assert meta["pangolin_url"] == "https://web.voorbeeld.nl"
def test_alleen_beheerders(client, env):
csrf = login(client)
client.post("/api/auth/users",
json={"username": "op", "password": "eenlangwachtwoord",
"role": "operator"},
headers={"X-CSRF-Token": csrf})
op = client.post("/api/auth/login",
json={"username": "op", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
r = client.post("/api/pangolin/test", headers={"X-CSRF-Token": op})
assert r.status_code == 403