"""Validator-rule unit tests + deployer precondition integration.""" from __future__ import annotations from unittest.mock import patch import pytest from decnet.engine.deployer import deploy_topology from decnet.topology.config import TopologyConfig from decnet.topology.generator import generate from decnet.topology.persistence import hydrate, persist from decnet.topology.status import TopologyStatus from decnet.topology.validate import ( ValidationError, check_gateway_homed_in_dmz, errors, validate, ) from decnet.web.db.factory import get_repository def _cfg(**kw) -> TopologyConfig: base = dict( name="val", depth=1, branching_factor=1, deckies_per_lan_min=1, deckies_per_lan_max=1, cross_edge_probability=0.0, randomize_services=False, services_explicit=["ssh"], seed=9, ) base.update(kw) return TopologyConfig(**base) @pytest.fixture async def repo(tmp_path): r = get_repository(db_path=str(tmp_path / "val.db")) await r.initialize() return r async def _hydrate_plan(repo, plan) -> dict: tid = await persist(repo, plan) return await hydrate(repo, tid), tid # --------------------------------------------------------------------- rules @pytest.mark.anyio async def test_valid_topology_has_no_errors(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) assert errors(validate(h)) == [] @pytest.mark.anyio async def test_dmz_missing(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) for lan in h["lans"]: lan["is_dmz"] = False codes = [i.code for i in validate(h) if i.severity == "error"] # DMZ_MISSING plus cascaded DMZ_ORPHAN checks are both acceptable; # the specific rule must fire at minimum. assert "DMZ_MISSING" in codes @pytest.mark.anyio async def test_dmz_multiple(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) for lan in h["lans"]: lan["is_dmz"] = True assert "DMZ_MULTIPLE" in [i.code for i in validate(h)] @pytest.mark.anyio async def test_orphan_decky(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) h["edges"] = [e for e in h["edges"] if e["decky_uuid"] != h["deckies"][0]["uuid"]] assert "DECKY_ORPHAN" in [i.code for i in validate(h)] @pytest.mark.anyio async def test_ip_collision(repo): plan = generate(_cfg(deckies_per_lan_max=2, deckies_per_lan_min=2)) h, _ = await _hydrate_plan(repo, plan) # Force two deckies in the same LAN to claim the same IP. deckies = [ d for d in h["deckies"] if any( e["decky_uuid"] == d["uuid"] for e in h["edges"] if e["lan_id"] == h["lans"][0]["id"] ) ] assert len(deckies) >= 2 shared_ip = next(iter(deckies[0]["decky_config"]["ips_by_lan"].values())) deckies[1]["decky_config"]["ips_by_lan"][h["lans"][0]["name"]] = shared_ip assert "IP_COLLISION" in [i.code for i in validate(h)] @pytest.mark.anyio async def test_ip_out_of_subnet(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) d = h["deckies"][0] lan_name = next(iter(d["decky_config"]["ips_by_lan"])) d["decky_config"]["ips_by_lan"][lan_name] = "10.99.99.99" assert "IP_OUT_OF_SUBNET" in [i.code for i in validate(h)] @pytest.mark.anyio async def test_subnet_overlap(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) # Shrink two LANs onto overlapping /16s. h["lans"][0]["subnet"] = "10.0.0.0/16" if len(h["lans"]) > 1: h["lans"][1]["subnet"] = "10.0.5.0/24" codes = [i.code for i in validate(h)] assert "SUBNET_OVERLAP" in codes @pytest.mark.anyio async def test_unknown_service(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) h["deckies"][0]["services"].append("teleporter-xyz") assert "UNKNOWN_SERVICE" in [i.code for i in validate(h)] @pytest.mark.anyio async def test_service_config_undeclared(repo): plan = generate(_cfg()) h, _ = await _hydrate_plan(repo, plan) h["deckies"][0]["decky_config"]["service_config"] = { "rdp": {"password": "no"} } # "rdp" is not in the decky's services list (which is ["ssh"]). assert "SERVICE_CFG_UNDECLARED" in [i.code for i in validate(h)] # --------------------------------------------------------------------- deployer hook @pytest.mark.anyio async def test_deploy_aborts_on_validation_error(repo, tmp_path, monkeypatch): """Broken topology must be rejected before any Docker call.""" monkeypatch.chdir(tmp_path) plan = generate(_cfg()) tid = await persist(repo, plan) # Corrupt the persisted state: strip the DMZ flag. lan = (await repo.list_lans_for_topology(tid))[0] # Use raw repo path — SQLModel UPDATE via get + setattr. from sqlmodel import select from decnet.web.db.models import LAN async with repo._session() as s: row = (await s.execute(select(LAN).where(LAN.id == lan.id))).scalar_one() row.is_dmz = False s.add(row) await s.commit() class _ShouldNotCall: def from_env(self): # noqa: D401 raise AssertionError("docker must not be called on a rejected topology") with patch("decnet.engine.deployer.docker", _ShouldNotCall()): with pytest.raises(ValidationError): await deploy_topology(repo, tid) topo = await repo.get_topology(tid) assert topo.status == TopologyStatus.PENDING # --------------------------------------------------------------------- gateway-in-dmz def _make_hydrated(*, dmz_id="dmz-id", internal_id="int-id") -> dict: """Tiny hand-rolled hydrated dict for hermetic check_* unit tests.""" return { "topology": {"id": "t", "status": "pending"}, "lans": [ {"id": dmz_id, "name": "dmz", "subnet": "10.0.0.0/24", "is_dmz": True}, {"id": internal_id, "name": "internal", "subnet": "10.0.1.0/24", "is_dmz": False}, ], "deckies": [], "edges": [], } def test_check_gateway_homed_in_dmz_passes_when_gateway_is_in_dmz() -> None: h = _make_hydrated() h["deckies"].append({ "uuid": "d1", "name": "gw", "decky_config": {"name": "gw", "forwards_l3": True}, "services": ["ssh"], }) h["edges"].append({ "decky_uuid": "d1", "lan_id": "dmz-id", "is_bridge": False, "forwards_l3": True, }) assert check_gateway_homed_in_dmz(h) == [] def test_check_gateway_homed_in_dmz_fails_when_gateway_is_internal() -> None: h = _make_hydrated() h["deckies"].append({ "uuid": "d1", "name": "gw", "decky_config": {"name": "gw", "forwards_l3": True}, "services": ["ssh"], }) # Home edge points at the internal LAN, not the DMZ. h["edges"].append({ "decky_uuid": "d1", "lan_id": "int-id", "is_bridge": False, "forwards_l3": True, }) issues = check_gateway_homed_in_dmz(h) assert len(issues) == 1 assert issues[0].code == "GATEWAY_NOT_IN_DMZ" def test_check_gateway_homed_in_dmz_ignores_non_gateway_deckies() -> None: h = _make_hydrated() h["deckies"].append({ "uuid": "d1", "name": "web", "decky_config": {"name": "web"}, # forwards_l3 absent "services": ["ssh"], }) h["edges"].append({ "decky_uuid": "d1", "lan_id": "int-id", "is_bridge": False, }) assert check_gateway_homed_in_dmz(h) == [] def test_check_gateway_homed_in_dmz_uses_non_bridge_edge_as_home() -> None: """Multi-homed gateway: home is the non-bridge edge, not the bridge edge.""" h = _make_hydrated() h["deckies"].append({ "uuid": "d1", "name": "gw", "decky_config": {"name": "gw", "forwards_l3": True}, "services": ["ssh"], }) # Bridge edge first (would be picked by a naive 'first edge' rule). h["edges"].append({ "decky_uuid": "d1", "lan_id": "int-id", "is_bridge": True, "forwards_l3": False, }) h["edges"].append({ "decky_uuid": "d1", "lan_id": "dmz-id", "is_bridge": False, "forwards_l3": True, }) # Home is the DMZ via the non-bridge edge → no issue. assert check_gateway_homed_in_dmz(h) == []