Files
OPNexus/backend/tests/test_outbound_nat.py
T
Julian Boldt e26b26eca6
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release v1.10.1-rc2
Siehe CHANGELOG.md
2026-10-07 09:34:26 +02:00

161 lines
7.4 KiB
Python

import httpx
import pytest
pytestmark = pytest.mark.usefixtures("outbound_mode_hybrid")
from httpx import AsyncClient
from tests.test_rules import _create_host
def _nat_payload(target_host_ids: list[str], **overrides) -> dict:
payload = {
"description": "Web to internal server",
"interface": "wan",
"protocol": "tcp",
"source_net": "any",
"destination_net": "wan_address",
"destination_port": "443",
"target": "10.0.1.20",
"target_port": "8443",
"target_host_ids": target_host_ids,
}
payload.update(overrides)
return payload
def _mock_nat_push(respx_mock, base_url: str, uuid_: str = "pf-uuid-1"):
respx_mock.post(f"{base_url}/api/firewall/source_nat/addRule").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": uuid_})
)
respx_mock.post(f"{base_url}/api/firewall/source_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
@pytest.mark.asyncio
async def test_create_outbound_nat_rule(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/outbound-nat", json=_nat_payload([host["id"]]))
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["target"] == "10.0.1.20"
assert body["target_port"] == "8443"
assert body["target_host_ids"] == [host["id"]]
assert body["deployments"] == []
assert body["workflow_state"] == "draft"
@pytest.mark.asyncio
async def test_deploy_outbound_nat_success_and_confirm(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/outbound-nat", json=_nat_payload([host["id"]]))
rule_id = resp.json()["id"]
_mock_nat_push(respx_mock, "https://fw1.test")
resp = await client.post(f"/api/outbound-nat/{rule_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "pending"
assert dep["revert_deadline"] is not None
confirmed = await client.post(f"/api/outbound-nat/{rule_id}/confirm")
assert confirmed.json()["deployments"][0]["status"] == "success"
@pytest.mark.asyncio
async def test_payload_fields(client, respx_mock):
import json
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
h = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/outbound-nat", json=_nat_payload([h["id"]], enabled=False, source_not=True))).json()
_mock_nat_push(respx_mock, "https://fw1.test")
assert (await client.post(f"/api/outbound-nat/{r['id']}/deploy")).status_code == 200
sent = json.loads(next(c.request.content for c in respx_mock.calls if c.request.url.path.endswith("addRule")))["rule"]
assert sent["enabled"] == "0"
assert sent["source_not"] == "1"
assert sent["source_net"] == "any"
assert sent["description"] == "Web to internal server"
assert "descr" not in sent and "disabled" not in sent and "source" not in sent
@pytest.mark.asyncio
async def test_deploy_outbound_nat_mixed_host_failure(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
respx_mock.get(url__regex=r".*/firewall/filter/getRule").mock(return_value=httpx.Response(200, json={"rule": {"interface": {"wan": {"value": "WAN"}}}}))
respx_mock.get(url__regex=r".*/firewall/alias/searchItem").mock(return_value=httpx.Response(200, json={"rows": []}))
host_ok = await _create_host(client, "fw-ok", "https://fw-ok.test")
host_down = await _create_host(client, "fw-down", "https://fw-down.test")
resp = await client.post("/api/outbound-nat", json=_nat_payload([host_ok["id"], host_down["id"]]))
rule_id = resp.json()["id"]
_mock_nat_push(respx_mock, "https://fw-ok.test")
respx_mock.post("https://fw-down.test/api/firewall/source_nat/addRule").mock(side_effect=httpx.ConnectError("connection refused"))
resp = await client.post(f"/api/outbound-nat/{rule_id}/deploy")
assert resp.status_code == 200, resp.text
deployments = {d["host_id"]: d for d in resp.json()["deployments"]}
assert deployments[host_ok["id"]]["status"] == "pending"
assert deployments[host_down["id"]]["status"] == "error"
assert deployments[host_down["id"]]["message"]
@pytest.mark.asyncio
async def test_outbound_nat_edit_creates_revision_history(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/outbound-nat", json=_nat_payload([host["id"]]))).json()
updated = await client.put(
f"/api/outbound-nat/{r['id']}",
json=_nat_payload([host["id"]], target="10.0.1.99", expected_updated_at=r["updated_at"]),
)
assert updated.status_code == 200, updated.text
assert updated.json()["target"] == "10.0.1.99"
history = (await client.get(f"/api/outbound-nat/{r['id']}/revisions")).json()
assert len(history) == 2
assert history[1]["snapshot"]["target"] == "10.0.1.20"
@pytest.mark.asyncio
async def test_stale_outbound_nat_editor_rejected(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/outbound-nat", json=_nat_payload([host["id"]]))).json()
stale = await client.put(f"/api/outbound-nat/{r['id']}", json=_nat_payload([host["id"]], expected_updated_at=r["updated_at"]))
assert stale.status_code == 200
conflict = await client.put(f"/api/outbound-nat/{r['id']}", json=_nat_payload([host["id"]], expected_updated_at=r["updated_at"]))
assert conflict.status_code == 409
@pytest.mark.asyncio
async def test_delete_outbound_nat_rule(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/outbound-nat", json=_nat_payload([host["id"]]))).json()
resp = await client.delete(f"/api/outbound-nat/{r['id']}")
assert resp.status_code == 204
assert (await client.get("/api/outbound-nat")).json() == []
@pytest.mark.asyncio
async def test_deploy_outbound_nat_without_targets_rejected(client: AsyncClient):
r = (await client.post("/api/outbound-nat", json=_nat_payload([]))).json()
resp = await client.post(f"/api/outbound-nat/{r['id']}/deploy")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_outbound_nat_stale_preview_token_rejected(client: AsyncClient, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/outbound-nat", json=_nat_payload([host["id"]]))).json()
resp = await client.post(f"/api/outbound-nat/{r['id']}/deploy", json={"preview_token": "0" * 64})
assert resp.status_code == 409