diff --git a/tests/api/routes/mcp/test_handlers.py b/tests/api/routes/mcp/test_handlers.py index c181c1c87..5cd8a8bc0 100644 --- a/tests/api/routes/mcp/test_handlers.py +++ b/tests/api/routes/mcp/test_handlers.py @@ -359,3 +359,158 @@ class TestTemplate: m.return_value = _mock_conn({}) result = delete_template_handler({"template_id": "t1"}, ctx) assert "deleted" in str(result).lower() + + +# ── Marker (traffic-insight) ──────────────────────────────────────────── + + +class TestLinkMarker: + """link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null).""" + + mod = "links" + + def test_update_direction_both_clears(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"direction": None}, + ) + + def test_update_direction_tx_sets(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"direction": "tx"}, + ) + + def test_update_direction_omitted_preserved(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "tag": 1}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"tag": 1}, + ) + + def test_create_direction_both_omitted(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "create", + "bpf": "icmp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + json_data={"bpf": "icmp"}, + ) + + def test_create_direction_tx(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "create", + "bpf": "icmp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + json_data={"bpf": "icmp", "direction": "tx"}, + ) + + +class TestMarkerDefinition: + """marker_definition_handler direction tri-state (same semantics as link markers).""" + + mod = "links" + + def test_update_direction_both_clears(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "update", + "def_name": "arp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"direction": None}, + ) + + def test_update_direction_tx_sets(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "update", + "def_name": "arp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"direction": "tx"}, + ) + + def test_update_direction_omitted_preserved(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "update", + "def_name": "arp", "tag": 1}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"tag": 1}, + ) + + def test_create_direction_both_omitted(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "create", + "bpf": "arp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + json_data={"bpf": "arp"}, + ) + + def test_create_direction_tx(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "create", + "bpf": "arp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + json_data={"bpf": "arp", "direction": "tx"}, + ) diff --git a/tests/compute/ubridge/__init__.py b/tests/compute/ubridge/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/compute/ubridge/test_hypervisor.py b/tests/compute/ubridge/test_hypervisor.py new file mode 100644 index 000000000..126278ab0 --- /dev/null +++ b/tests/compute/ubridge/test_hypervisor.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Tests for the uBridge ``Hypervisor`` wrapper — the configurable control-channel +transport (AF_UNIX ``-U`` vs TCP ``-H``), command building, the human-readable +``endpoint``, socket cleanup on stop, and the fail-fast detection of an +immediately-exiting uBridge process (e.g. an old build that rejects ``-U``). +""" + +import os +import re +import stat +import logging + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + +from gns3server.compute.ubridge.hypervisor import Hypervisor +from gns3server.compute.ubridge.ubridge_error import UbridgeError + + +def _make(transport, tmp_path, monkeypatch, node_id="abc123", host="127.0.0.1"): + """Build a Hypervisor with ``XDG_RUNTIME_DIR`` pinned to ``tmp_path``. + + The unix transport creates its socket dir under ``$XDG_RUNTIME_DIR/gns3``; + pinning it keeps creation predictable and avoids touching the real runtime + dir. ``host`` is unused for the unix transport but always accepted. + """ + + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) + return Hypervisor(MagicMock(), "ubridge", str(tmp_path), transport, host=host, node_id=node_id) + + +# --------------------------------------------------------------------------- +# __init__: transport selection +# --------------------------------------------------------------------------- + +def test_init_unix_creates_socket_dir_and_path(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch, node_id="abc123") + assert hyp._socket_path == str(tmp_path / "gns3" / "ubridge-abc123.sock") + + socket_dir = os.path.dirname(hyp._socket_path) + assert os.path.isdir(socket_dir) + # 0o700 regardless of umask — __init__ chmods explicitly. + assert stat.S_IMODE(os.stat(socket_dir).st_mode) == 0o700 + # TCP-only attributes are unused on the unix transport. + assert hyp._host is None + assert hyp._port is None + + +def test_init_unix_fallback_name_without_node_id(tmp_path, monkeypatch): + # node_id is normally always passed (one ubridge per node); the counter + # fallback only fires when it's missing. Match the numbered pattern so the + # assertion is independent of class-counter ordering across the suite. + hyp = _make("unix", tmp_path, monkeypatch, node_id=None) + assert re.search(r"ubridge-\d+\.sock$", hyp._socket_path) + + +def test_init_tcp_sets_host_port(tmp_path, monkeypatch): + + hyp = _make("tcp", tmp_path, monkeypatch) + assert hyp._socket_path is None + assert hyp._host == "127.0.0.1" + assert isinstance(hyp._port, int) and hyp._port > 0 + + +# --------------------------------------------------------------------------- +# _build_command + endpoint +# --------------------------------------------------------------------------- + +def test_build_command_unix(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + cmd = hyp._build_command() + assert cmd[0] == "ubridge" + assert "-U" in cmd + assert hyp._socket_path in cmd + assert "-H" not in cmd + assert "-d" not in cmd # debug flag only at DEBUG level + + +def test_build_command_tcp(tmp_path, monkeypatch): + + hyp = _make("tcp", tmp_path, monkeypatch) + cmd = hyp._build_command() + assert cmd[0] == "ubridge" + assert "-H" in cmd + assert f"{hyp._host}:{hyp._port}" in cmd + assert "-U" not in cmd + + +def test_build_command_debug_flag(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + logger = logging.getLogger("gns3server.compute.ubridge.hypervisor") + original = logger.level + logger.setLevel(logging.DEBUG) + try: + cmd = hyp._build_command() + assert "-d" in cmd and "1" in cmd + finally: + logger.setLevel(original) + + +def test_endpoint_unix(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + assert hyp.endpoint == hyp._socket_path + + +def test_endpoint_tcp(tmp_path, monkeypatch): + + hyp = _make("tcp", tmp_path, monkeypatch) + assert hyp.endpoint == f"{hyp._host}:{hyp._port}" + + +# --------------------------------------------------------------------------- +# stop: AF_UNIX socket cleanup +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_stop_unlinks_unix_socket(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + # Simulate the socket file ubridge would have created. + open(hyp._socket_path, "w").close() + # Stopped process => is_running() is False => skips UBridgeHypervisor.stop (no send). + hyp._process = MagicMock() + hyp._process.returncode = 0 + assert os.path.exists(hyp._socket_path) + + await hyp.stop() + + assert not os.path.exists(hyp._socket_path) + + +@pytest.mark.asyncio +async def test_stop_tcp_has_no_socket_to_unlink(tmp_path, monkeypatch): + # TCP transport: no socket_path, so stop must simply not raise. + hyp = _make("tcp", tmp_path, monkeypatch) + hyp._process = MagicMock() + hyp._process.returncode = 0 + await hyp.stop() + + +# --------------------------------------------------------------------------- +# start: fail-fast on an immediately-exiting uBridge +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_start_detects_immediate_exit(tmp_path, monkeypatch): + # An unsupported flag (e.g. -U on an old ubridge) makes the process exit at + # once. start() must surface that from ubridge.log instead of timing out in + # connect() with a confusing "couldn't connect" error. + hyp = _make("unix", tmp_path, monkeypatch) + proc = MagicMock() + proc.pid = 1234 + proc.returncode = 2 # already exited + with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \ + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc): + with pytest.raises(UbridgeError, match="exited immediately"): + await hyp.start() + + +@pytest.mark.asyncio +async def test_start_proceeds_when_process_keeps_running(tmp_path, monkeypatch): + # Healthy startup: the process stays up, so start() returns normally. + hyp = _make("unix", tmp_path, monkeypatch) + proc = MagicMock() + proc.pid = 1234 + proc.returncode = None # still running + with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \ + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc): + await hyp.start() # must NOT raise + assert hyp._process is proc diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 6348a7a48..d85f57121 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -378,3 +378,86 @@ async def test_markers_aggregation(project): assert agg[key]["highlight_duration"] == 800 assert agg[key]["link_id"] == link.id assert agg[key]["node_id"] == agg[key]["capture_node_id"] + + +# --------------------------------------------------------------------------- +# Direction clear/preserve semantics (sentinel _UNSET vs explicit None) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_update_marker_clears_direction(project): + # Explicit direction=None clears the filter back to "both directions" — + # distinct from omitting the kwarg (which preserves the stored value). + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp", direction="tx") + assert link.markers["m"]["direction"] == "tx" + await link.update_marker("m", direction=None) + assert link.markers["m"]["direction"] is None + + +@pytest.mark.asyncio +async def test_update_marker_preserves_direction_when_omitted(project): + # Omitting direction entirely is a partial update: the stored value stays. + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp", direction="tx") + await link.update_marker("m", tag=9) + assert link.markers["m"]["direction"] == "tx" + assert link.markers["m"]["tag"] == 9 + + +@pytest.mark.asyncio +async def test_update_marker_definition_clears_direction(project): + # Clearing a definition's direction must propagate to every inherited copy. + with _valid_bpf(): + link1 = await _make_link(project) + link2 = await _make_link(project) + await project.create_marker_definition("arp", "arp", direction="tx") + for link in (link1, link2): + assert link.markers["global-arp"]["direction"] == "tx" + await project.update_marker_definition("arp", direction=None) + + assert project.marker_definitions["arp"]["direction"] is None + for link in (link1, link2): + assert link.markers["global-arp"]["direction"] is None + + +# --------------------------------------------------------------------------- +# Capture-node routing + capability validation +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_pinned_marker_routes_only_to_chosen_node(project): + # The marker rides only the pinned capture node's NIO; the far endpoint sees nothing. + with _valid_bpf(): + link = await _make_link(project) + chosen = link._nodes[1]["node"] + other = link._nodes[0]["node"] + await link.start_marker("icmp", "icmp", capture_node_id=chosen.id) + + assert "icmp" in link._markers_for_node(chosen) + assert "icmp" not in link._markers_for_node(other) + + +@pytest.mark.asyncio +async def test_markers_for_node_carries_direction(project): + # The NIO-bound marker spec forwards direction so uBridge gets the dir token. + with _valid_bpf(): + link = await _make_link(project) + node = link._nodes[0]["node"] # auto-pick selects the first capable endpoint + await link.start_marker("m", "icmp", direction="rx") + + assert link._markers_for_node(node)["m"]["direction"] == "rx" + + +@pytest.mark.asyncio +async def test_start_marker_rejects_non_capable_capture_node(project): + # A NAT endpoint has no uBridge bridge. Pinning to it must fail even though + # it IS a link endpoint (distinct from the not-an-endpoint -> 404 case). + with _valid_bpf(): + link = await _make_link(project) + nat = Node(project, link._nodes[0]["node"].compute, "nat", node_type="nat") + link._nodes.append({"node": nat, "adapter_number": 0, "port_number": 0}) + with pytest.raises(ControllerError): + await link.start_marker("m", "icmp", capture_node_id=nat.id)