server-up/tests/test_network_detect.py
Ramon cbb232d63f
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m3s
v0.5.42-beta - netwerk toevoegen: twee blokkades weg
Gemeld met twee screenshots: een foutmelding dat de IP-range buiten het subnet
lag terwijl 10.0.20.240/28 gewoon binnen 10.0.20.0/24 valt, en een geval zonder
melding waarbij aanmaken toch niet lukte. Twee losse oorzaken.

1. Verouderd beoordelingsantwoord
   De live beoordeling gaat af bij elke toetsaanslag. Tijdens het typen van
   /28 kom je langs /2, en 10.0.20.240/2 normaliseert naar 0.0.0.0/2 - buiten
   het subnet. Kwam dat antwoord na het goede binnen, dan bleef die fout staan
   en bleef de knop uit.
   - Volgnummer op de verzoeken; alleen het antwoord bij de laatste vraag telt.
   - Knop niet meer uitgeschakeld op clientstatus; de server beslist bij het
     opslaan, zodat een verouderde beoordeling nooit blokkeert.

2. CIDR met hostbits
   10.0.20.200/28 leest Python met strict=False als 10.0.20.192/28, maar Docker
   weigert het met "has host bits set". We valideerden soepel en stuurden de
   ruwe tekst door, dus de interface keurde iets goed dat bij het aanmaken
   alsnog strandde.
   - normalize() zet subnet en ip_range om naar canonieke vorm voor ze naar
     Docker gaan en voor ze worden opgeslagen.
   - Waarschuwing die toont wat er van de invoer gemaakt is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-27 00:06:12 +02:00

292 lines
12 KiB
Python

"""Netwerkdetectie: /proc/net/route parsen, ranges voorstellen en beoordelen."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import networks
# Zoals /proc/net/route eruitziet op een host met één LAN-interface.
# Adressen staan als little-endian hex: 0100A8C0 = 192.168.0.1
ROUTE_ENKEL = """Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0
eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0
docker0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0
"""
def test_parse_vindt_lan_interface():
n = networks.parse_proc_route(ROUTE_ENKEL)
assert len(n) == 1
assert n[0] == {"interface": "eth0", "subnet": "192.168.1.0/24",
"gateway": "192.168.1.1"}
def test_parse_negeert_interfaces_zonder_default_route():
"""docker0 heeft wel een subnet maar geen default route — geen LAN dus."""
assert all(x["interface"] != "docker0"
for x in networks.parse_proc_route(ROUTE_ENKEL))
def test_parse_meerdere_interfaces():
tekst = ROUTE_ENKEL + "eth1\t00000000\t0102A8C0\t0003\t0\t0\t200\t00000000\t0\t0\t0\n" \
"eth1\t0002A8C0\t00000000\t0001\t0\t0\t200\t00FFFFFF\t0\t0\t0\n"
n = networks.parse_proc_route(tekst)
assert [x["interface"] for x in n] == ["eth0", "eth1"]
assert n[1]["subnet"] == "192.168.2.0/24"
def test_parse_lege_of_onzinnige_invoer():
assert networks.parse_proc_route("") == []
assert networks.parse_proc_route("kop\nonzin regel\n") == []
# ── Voorgestelde range ───────────────────────────────────────────────────────
@pytest.mark.parametrize("subnet,gateway,verwacht", [
("192.168.1.0/24", "192.168.1.1", "192.168.1.240/28"),
("10.0.0.0/24", "10.0.0.1", "10.0.0.240/28"),
("192.168.0.0/16", "192.168.0.1", "192.168.255.240/28"),
])
def test_suggest_range_pakt_het_einde_van_het_subnet(subnet, gateway, verwacht):
assert networks.suggest_range(subnet, gateway) == verwacht
def test_suggest_range_vermijdt_de_gateway():
"""Een gateway aan het eind van het subnet mag niet in de range vallen."""
r = networks.suggest_range("192.168.1.0/24", "192.168.1.250")
import ipaddress
assert ipaddress.ip_address("192.168.1.250") not in ipaddress.ip_network(r)
def test_suggest_range_bij_klein_subnet():
assert networks.suggest_range("192.168.1.0/28", "192.168.1.1") == ""
def test_suggest_range_bij_onzin():
assert networks.suggest_range("geen-subnet") == ""
# ── Beoordeling ──────────────────────────────────────────────────────────────
BASIS = {"name": "lan", "driver": "macvlan", "parent": "eth0",
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1",
"ip_range": "192.168.1.240/28"}
def test_review_goede_definitie():
r = networks.review(BASIS)
assert r["ok"] is True
assert r["errors"] == []
assert r["info"]["count"] == 14 # .241 t/m .254
assert r["info"]["first"] == "192.168.1.241"
assert r["info"]["last"] == "192.168.1.254"
def test_review_waarschuwt_bij_gateway_binnen_een_expliciete_range():
r = networks.review({**BASIS, "ip_range": "192.168.1.0/25"})
assert r["ok"] is True # Docker houdt het adres vrij, dus geen fout
assert any("gateway" in w for w in r["warnings"])
def test_review_ziet_gateway_in_het_subnet_niet_als_probleem():
"""Zonder eigen range is 'gateway zit in het subnet' de normale situatie."""
r = networks.review({**BASIS, "ip_range": ""})
assert not any("gateway" in w.lower() for w in r["warnings"])
def test_review_waarschuwt_zonder_range():
r = networks.review({**BASIS, "ip_range": ""})
assert r["ok"] is True
assert any("DHCP" in w for w in r["warnings"])
def test_review_waarschuwt_zonder_gateway():
r = networks.review({**BASIS, "gateway": ""})
assert r["ok"] is True
assert any("internet" in w for w in r["warnings"])
def test_review_waarschuwt_bij_erg_kleine_range():
r = networks.review({**BASIS, "ip_range": "192.168.1.252/30"})
assert r["ok"] is True
assert any("adressen" in w for w in r["warnings"])
def test_review_loopt_niet_vast_op_een_enorm_bereik():
"""Tijdens het typen van '192.168.1.0/24' is '192.168.1.0/2' een geldig
netwerk met ruim een miljard adressen. Dat mag geen lijst worden: het
verzoek liep vast en de browser kreeg geen JSON meer terug."""
import time
start = time.time()
r = networks.review({**BASIS, "subnet": "192.168.1.0/2", "ip_range": ""})
duur = time.time() - start
assert duur < 1.0, f"duurde {duur:.1f}s — bouwt vermoedelijk weer een lijst"
# 2^30 adressen, min netwerk- en broadcastadres, min de gateway die er
# toevallig binnen valt.
assert r["info"]["count"] == 1073741821
@pytest.mark.parametrize("prefix", ["1", "2", "8", "12", "16"])
def test_review_is_snel_bij_elk_prefix(prefix):
"""Elke tussenstand tijdens het typen moet direct antwoord geven."""
import time
start = time.time()
networks.review({**BASIS, "subnet": f"10.0.0.0/{prefix}", "ip_range": ""})
assert time.time() - start < 1.0
def test_suggest_range_is_snel_bij_een_groot_subnet():
import time
start = time.time()
r = networks.suggest_range("10.0.0.0/8", "10.0.0.1")
assert time.time() - start < 1.0
assert r == "10.255.255.240/28"
def test_review_waarschuwt_bij_een_verdacht_groot_bereik():
r = networks.review({**BASIS, "subnet": "10.0.0.0/8",
"gateway": "10.0.0.1", "ip_range": ""})
assert any("prefix" in w for w in r["warnings"])
@pytest.mark.parametrize("bereik,eerste,laatste,aantal", [
("192.168.1.0/24", "192.168.1.1", "192.168.1.254", 254),
("192.168.1.240/28", "192.168.1.241", "192.168.1.254", 14),
("192.168.1.0/30", "192.168.1.1", "192.168.1.2", 2),
("192.168.1.0/31", "192.168.1.0", "192.168.1.1", 2),
("192.168.1.5/32", "192.168.1.5", "192.168.1.5", 1),
])
def test_bruikbaar_bereik_rekent_goed(bereik, eerste, laatste, aantal):
"""Zelfde uitkomst als hosts() zou geven, maar zonder die te doorlopen."""
import ipaddress
net = ipaddress.ip_network(bereik)
e, l, n = networks._bruikbaar_bereik(net)
assert (str(e), str(l), n) == (eerste, laatste, aantal)
assert n == len(list(net.hosts())) # controle tegen de stdlib
def test_bruikbaar_bereik_trekt_de_gateway_eraf():
import ipaddress
net = ipaddress.ip_network("192.168.1.0/24")
e, l, n = networks._bruikbaar_bereik(net, "192.168.1.1")
assert (str(e), n) == ("192.168.1.2", 253)
e, l, n = networks._bruikbaar_bereik(net, "192.168.1.254")
assert (str(l), n) == ("192.168.1.253", 253)
# Gateway in het midden verschuift de randen niet.
e, l, n = networks._bruikbaar_bereik(net, "192.168.1.100")
assert (str(e), str(l), n) == ("192.168.1.1", "192.168.1.254", 253)
def test_bruikbaar_bereik_negeert_gateway_buiten_het_bereik():
import ipaddress
net = ipaddress.ip_network("192.168.1.240/28")
assert networks._bruikbaar_bereik(net, "192.168.1.1")[2] == 14
# ── Normalisatie ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("ingevoerd,verwacht", [
("10.0.20.200/28", "10.0.20.192/28"), # hostbits gezet
("10.0.20.240/28", "10.0.20.240/28"), # al uitgelijnd
("192.168.1.5/24", "192.168.1.0/24"),
])
def test_normalize_lijnt_de_range_uit(ingevoerd, verwacht):
"""Docker weigert een CIDR met hostbits ('has host bits set'), terwijl wij
hem soepel inlezen. Zonder omzetting faalt `docker network create` op iets
dat de interface wél accepteerde."""
n = networks.normalize({**BASIS, "ip_range": ingevoerd})
assert n["ip_range"] == verwacht
def test_normalize_laat_onzin_staan_voor_validate():
n = networks.normalize({**BASIS, "ip_range": "geen-cidr"})
assert n["ip_range"] == "geen-cidr"
def test_normalize_raakt_de_rest_niet_aan():
n = networks.normalize(BASIS)
assert n["name"] == "lan" and n["driver"] == "macvlan" and n["parent"] == "eth0"
def test_review_waarschuwt_bij_niet_uitgelijnde_range():
r = networks.review({**BASIS, "ip_range": "10.0.20.200/28",
"subnet": "10.0.20.0/24", "gateway": "10.0.20.1"})
assert r["ok"] is True
assert any("10.0.20.192/28" in w for w in r["warnings"])
def test_niet_uitgelijnde_range_uit_het_formulier_wordt_opgeslagen_als_canoniek(client, monkeypatch):
"""Wat we bewaren moet gelijk zijn aan wat Docker heeft gekregen."""
from conftest import login
from core import networks as nw
csrf = login(client)
monkeypatch.setattr(nw, "exists", lambda naam: False)
monkeypatch.setattr(nw, "create", lambda net: (True, "ok"))
client.post("/api/networks",
json={**BASIS, "subnet": "10.0.20.0/24", "gateway": "10.0.20.1",
"ip_range": "10.0.20.200/28"},
headers={"X-CSRF-Token": csrf})
opgeslagen = client.get("/api/networks").get_json()["networks"]
assert opgeslagen[0]["ip_range"] == "10.0.20.192/28"
def test_scherm_uit_de_melding_valt_niet_ten_onrechte_buiten_het_subnet():
"""De combinatie uit de foutmelding van de gebruiker: 10.0.20.240/28 in
10.0.20.0/24 is geldig en mag geen fout geven."""
r = networks.review({"name": "lan", "driver": "macvlan", "parent": "ens18",
"subnet": "10.0.20.0/24", "gateway": "10.0.20.1",
"ip_range": "10.0.20.240/28"})
assert r["ok"] is True
assert r["errors"] == []
assert r["info"]["count"] == 14
def test_review_geeft_blokkerende_validatiefout_door():
r = networks.review({**BASIS, "subnet": "onzin"})
assert r["ok"] is False
assert any("subnet" in e.lower() for e in r["errors"])
def test_review_range_buiten_subnet():
r = networks.review({**BASIS, "ip_range": "10.9.9.0/28"})
assert r["ok"] is False
# ── Endpoints ────────────────────────────────────────────────────────────────
def test_review_endpoint(client):
from conftest import login
csrf = login(client)
r = client.post("/api/networks/review", json=BASIS,
headers={"X-CSRF-Token": csrf})
d = r.get_json()
assert d["ok"] is True
assert d["info"]["count"] == 14
def test_networks_endpoint_geeft_detectie_terug(client, monkeypatch):
from conftest import login
from core import networks as nw
login(client)
monkeypatch.setattr(nw, "detect_host_networks", lambda force=False: {
"networks": [{"interface": "eth0", "subnet": "192.168.1.0/24",
"gateway": "192.168.1.1", "suggested_range": "192.168.1.240/28",
"driver": "macvlan", "name": "lan"}],
"error": ""})
d = client.get("/api/networks").get_json()
assert d["detected"][0]["interface"] == "eth0"
assert d["interfaces"] == ["eth0"]
def test_detectiefout_wordt_doorgegeven(client, monkeypatch):
from conftest import login
from core import networks as nw
login(client)
monkeypatch.setattr(nw, "detect_host_networks", lambda force=False: {
"networks": [], "error": "docker niet bereikbaar"})
d = client.get("/api/networks").get_json()
assert d["detect_error"] == "docker niet bereikbaar"
assert d["detected"] == []