Merge branch '3.1' into base-configs-3.0

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-08-08 22:11:39 +02:00
commit 909ccf8fcd
177 files changed
+16013 -1745

No files matched your search

@@ -21,34 +21,48 @@ import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import call
from unittest.mock import call, MagicMock
from gns3server.compute.project import Project
# The builtin Ethernet switch talks to uBridge (brctl/bridge modules) instead of
# the Dynamips hypervisor. These are the seams we stub so the routes can be
# exercised without launching a real uBridge / creating kernel interfaces.
_NODE = "gns3server.compute.builtin.nodes.ethernet_switch.EthernetSwitch"
pytestmark = pytest.mark.asyncio
class TestEthernetSwitchNodesRoutes:
@pytest_asyncio.fixture(autouse=True)
async def stub_ubridge(self):
"""Keep uBridge from really starting and capture every command."""
with asyncio_patch(f"{_NODE}._start_ubridge"), asyncio_patch(f"{_NODE}._stop_ubridge"), \
asyncio_patch(f"{_NODE}._ubridge_send"):
yield
@pytest_asyncio.fixture
async def ethernet_switch(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> dict:
params = {"name": "Ethernet Switch"}
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert response.status_code == status.HTTP_201_CREATED
json_response = response.json()
node = compute_project.get_node(json_response["node_id"])
node._hypervisor = AsyncioMagicMock()
node._hypervisor.send = AsyncioMagicMock()
node._hypervisor.version = "0.2.16"
# Pretend uBridge is up so the is_running() guards in remove/close pass.
node._ubridge_hypervisor = MagicMock()
node._ubridge_hypervisor.is_running.return_value = True
node._ubridge_send.reset_mock()
return json_response
@staticmethod
def _udp_params() -> dict:
return {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}
async def test_ethernet_switch_create(
self, app: FastAPI,
@@ -57,16 +71,24 @@ class TestEthernetSwitchNodesRoutes:
) -> None:
params = {"name": "Ethernet Switch 1"}
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "Ethernet Switch 1"
assert response.json()["project_id"] == compute_project.id
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "Ethernet Switch 1"
assert response.json()["project_id"] == compute_project.id
assert response.json()["status"] == "started"
# creation stands up the kernel bridge with VLAN filtering
node = compute_project.get_node(response.json()["node_id"])
br = node._bridge_name
node._ubridge_send.assert_has_calls([
call(f'brctl delete "{br}"'),
call(f'brctl create "{br}"'),
call(f'link set "{br}" up'),
call(f'brctl vlanfiltering "{br}" on'),
])
async def test_ethernet_switch_get(
self, app: FastAPI,
@@ -87,7 +109,6 @@ class TestEthernetSwitchNodesRoutes:
assert response.json()["project_id"] == compute_project.id
assert response.json()["status"] == "started"
async def test_ethernet_switch_duplicate(
self,
app: FastAPI,
@@ -98,15 +119,11 @@ class TestEthernetSwitchNodesRoutes:
# create destination switch first
params = {"name": "Ethernet Switch 2"}
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
response = await compute_client.post(
app.url_path_for(
"compute:create_ethernet_switch",
project_id=compute_project.id),
json=params
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert response.status_code == status.HTTP_201_CREATED
params = {"destination_node_id": response.json()["node_id"]}
response = await compute_client.post(
@@ -117,7 +134,6 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_201_CREATED
async def test_ethernet_switch_update(
self,
app: FastAPI,
@@ -126,10 +142,7 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
params = {
"name": "test",
"console_type": "telnet"
}
params = {"name": "test", "console_type": "none"}
response = await compute_client.put(
app.url_path_for(
@@ -141,11 +154,12 @@ class TestEthernetSwitchNodesRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "test"
# renaming a builtin switch does not touch uBridge (the kernel bridge is
# name-independent); nothing should have been sent.
node = compute_project.get_node(ethernet_switch["node_id"])
node._hypervisor.send.assert_called_with("ethsw rename \"Ethernet Switch\" \"test\"")
node._ubridge_send.assert_not_called()
async def test_ethernet_switch_update_ports(
async def test_ethernet_switch_update_ports_qinq_proto(
self,
app: FastAPI,
compute_client: AsyncClient,
@@ -153,33 +167,11 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
# a QinQ port with the 802.1ad ethertype must switch the bridge protocol
port_params = {
"ports_mapping": [
{
"name": "Ethernet0",
"port_number": 0,
"type": "qinq",
"vlan": 1
},
{
"name": "Ethernet1",
"port_number": 1,
"type": "qinq",
"vlan": 2,
"ethertype": "0x88A8"
},
{
"name": "Ethernet2",
"port_number": 2,
"type": "dot1q",
"vlan": 3,
},
{
"name": "Ethernet3",
"port_number": 3,
"type": "access",
"vlan": 4,
}
{"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 2, "ethertype": "0x88A8"},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 4},
],
}
@@ -192,90 +184,20 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_200_OK
nio_params = {
"type": "nio_udp",
"lport": 4242,
"rport": 4343,
"rhost": "127.0.0.1"
}
for port_mapping in port_params["ports_mapping"]:
port_number = port_mapping["port_number"]
vlan = port_mapping["vlan"]
port_type = port_mapping["type"]
ethertype = port_mapping.get("ethertype", "")
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number=f"{port_number}"
)
await compute_client.post(url, json=nio_params)
node = compute_project.get_node(ethernet_switch["node_id"])
nio = node.get_nio(port_number)
calls = [
call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'),
call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'),
call.send(f'ethsw set_{port_type}_port "Ethernet Switch" {nio.name} {vlan} {ethertype}'.strip())
]
node._hypervisor.send.assert_has_calls(calls)
node._hypervisor.send.reset_mock()
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.assert_any_call(f'brctl setvlanproto "{node._bridge_name}" 0x88a8')
@pytest.mark.parametrize(
"ports_settings",
(
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "dot42q", # invalid port type
"vlan": 1,
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "access", # missing vlan field
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "dot1q",
"vlan": 1,
"ethertype": "0x88A8" # EtherType is only for QinQ
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "qinq",
"vlan": 1,
"ethertype": "0x4242" # not a valid EtherType
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "access",
"vlan": 0, # minimum vlan number is 1
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "access",
"vlan": 4242, # maximum vlan number is 4094
}
),
{"name": "Ethernet0", "port_number": 0, "type": "dot42q", "vlan": 1}, # bad type
{"name": "Ethernet0", "port_number": 0, "type": "access"}, # missing vlan
{"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 1,
"ethertype": "0x88A8"}, # ethertype only for qinq
{"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 1,
"ethertype": "0x4242"}, # bad ethertype
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 0}, # vlan < 1
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 4242}, # vlan > 4094
)
)
async def test_ethernet_switch_update_ports_invalid(
@@ -286,20 +208,15 @@ class TestEthernetSwitchNodesRoutes:
ports_settings: dict,
) -> None:
port_params = {
"ports_mapping": [ports_settings]
}
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"]),
json=port_params
json={"ports_mapping": [ports_settings]}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
async def test_ethernet_switch_delete(
self, app: FastAPI,
compute_client: AsyncClient,
@@ -315,12 +232,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_ethernet_switch_start(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_start(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@@ -331,12 +243,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_stop(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_stop(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@@ -347,12 +254,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_suspend(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_suspend(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@@ -363,12 +265,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_reload(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_reload(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@@ -379,8 +276,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_create_udp(
async def test_ethernet_switch_create_udp_access(
self,
app: FastAPI,
compute_client: AsyncClient,
@@ -388,13 +284,6 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
params = {
"type": "nio_udp",
"lport": 4242,
"rport": 4343,
"rhost": "127.0.0.1"
}
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
@@ -402,19 +291,66 @@ class TestEthernetSwitchNodesRoutes:
adapter_number="0",
port_number="0"
)
response = await compute_client.post(url, json=params)
response = await compute_client.post(url, json=self._udp_params())
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "nio_udp"
node = compute_project.get_node(ethernet_switch["node_id"])
nio = node.get_nio(0)
calls = [
call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'),
call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'),
call.send(f'ethsw set_access_port "Ethernet Switch" {nio.name} 1')
]
node._hypervisor.send.assert_has_calls(calls)
br = node._bridge_name
tap = f"{br}-0"
relay = f"{node.id}-0"
# access VLAN 1 (default): drop default PVID 1, re-add 1 as PVID/untagged
node._ubridge_send.assert_has_calls([
call(f"bridge create {relay}"),
call(f'bridge add_nio_tap {relay} "{tap}"'),
call(f'brctl addif "{br}" "{tap}"'),
call(f'brctl vlan_del "{br}" "{tap}" 1'),
call(f'brctl vlan_add "{br}" "{tap}" 1 pvid untagged'),
call(f"bridge add_nio_udp {relay} {nio.lport} {nio.rhost} {nio.rport}"),
call(f"bridge reset_packet_filters {relay}"),
call(f"bridge start {relay}"),
])
async def test_ethernet_switch_create_udp_dot1q(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
# make port 0 a dot1q trunk with native VLAN 10
await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"]),
json={"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 10},
]}
)
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
response = await compute_client.post(url, json=self._udp_params())
assert response.status_code == status.HTTP_201_CREATED
br = node._bridge_name
tap = f"{br}-0"
# trunk: drop default 1, admit all VIDs tagged, mark native 10 PVID/untagged
node._ubridge_send.assert_has_calls([
call(f'brctl vlan_del "{br}" "{tap}" 1'),
call(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094'),
call(f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged'),
])
async def test_ethernet_switch_delete_nio(
self,
@@ -424,13 +360,6 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
params = {
"type": "nio_udp",
"lport": 4242,
"rport": 4343,
"rhost": "127.0.0.1"
}
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
@@ -438,11 +367,10 @@ class TestEthernetSwitchNodesRoutes:
adapter_number="0",
port_number="0"
)
await compute_client.post(url, json=params)
await compute_client.post(url, json=self._udp_params())
node = compute_project.get_node(ethernet_switch["node_id"])
node._hypervisor.send.reset_mock()
nio = node.get_nio(0)
node._ubridge_send.reset_mock()
url = app.url_path_for(
"compute:delete_ethernet_switch_nio",
@@ -454,52 +382,86 @@ class TestEthernetSwitchNodesRoutes:
response = await compute_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
calls = [
call(f'ethsw remove_nio "Ethernet Switch" {nio.name}'),
call(f'nio delete {nio.name}')
]
node._hypervisor.send.assert_has_calls(calls)
br = node._bridge_name
tap = f"{br}-0"
relay = f"{node.id}-0"
node._ubridge_send.assert_has_calls([
call(f'brctl delif "{br}" "{tap}"'),
call(f"bridge delete {relay}"),
])
async def test_ethernet_switch_start_capture(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
params = {
"capture_file_name": "test.pcap",
"data_link_type": "DLT_EN10MB"
}
# capture needs a wired port
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
await compute_client.post(url, json=self._udp_params())
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}
url = app.url_path_for("compute:start_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0")
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.start_capture") as mock:
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_200_OK
assert mock.called
assert "test.pcap" in response.json()["pcap_file_path"]
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_200_OK
assert "test.pcap" in response.json()["pcap_file_path"]
relay = f"{node.id}-0"
node._ubridge_send.assert_any_call(f'bridge start_capture {relay} "{node.get_nio(0).pcap_output_file}"')
async def test_ethernet_switch_stop_capture(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
url = app.url_path_for("compute:stop_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0")
# start a capture first
await compute_client.post(
app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
),
json=self._udp_params()
)
await compute_client.post(
app.url_path_for("compute:start_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"),
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}
)
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.stop_capture") as mock:
response = await compute_client.post(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert mock.called
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
relay = f"{node.id}-0"
response = await compute_client.post(
app.url_path_for("compute:stop_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0")
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")
+49
View File
@@ -0,0 +1,49 @@
#
# Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch
pytestmark = pytest.mark.asyncio
class TestImagesRoutes:
async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"),
json={"image": "nginx:latest"}
)
mock.assert_called_once_with("nginx:latest", force=True)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("image", ["", " ", "nginx latest"])
async def test_pull_docker_image_rejects_invalid_name(
self, app: FastAPI, compute_client: AsyncClient, image: str
) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"),
json={"image": image}
)
mock.assert_not_called()
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
+17 -2
View File
@@ -17,6 +17,7 @@
import uuid
import pytest
from unittest.mock import MagicMock, patch
from fastapi import FastAPI, status
from httpx import AsyncClient
@@ -25,8 +26,7 @@ from gns3server.schemas.controller.computes import Compute
pytestmark = pytest.mark.asyncio
import unittest
from tests.utils import asyncio_patch
from tests.utils import asyncio_patch, AsyncioMagicMock
class TestComputeRoutes:
@@ -128,6 +128,21 @@ class TestComputeFeatures:
mock.assert_called_with("GET", "docker", "images")
assert response.json() == [{"image": "docker1"}, {"image": "docker2"}]
async def test_compute_pull_docker_image(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
compute = MagicMock()
compute.forward = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.post(
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
json={"image": "nginx:latest"}
)
compute.forward.assert_called_with("POST", "docker", "images/pull", data={"image": "nginx:latest"})
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None:
params = {
+267
View File
@@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
#
# 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 <http://www.gnu.org/licenses/>.
"""
HTTP-route tests for the traffic-insight marker endpoints: per-link markers,
project-level definitions, and the project-wide aggregation view.
"""
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch
from gns3server.controller.project import Project
from gns3server.controller.udp_link import UDPLink
pytestmark = pytest.mark.asyncio
def _inherited(link, name="arp"):
"""Inject an inherited marker so the controller's inheritance guard can fire."""
link._markers[f"global-{name}"] = {
"bpf": name, "tag": None, "enabled": True, "color": None,
"highlight_duration": None, "capture_node_id": "node-id",
"inherited_from": name,
}
class TestMarkerRoutes:
# -----------------------------------------------------------------------
# Per-link markers
# -----------------------------------------------------------------------
async def test_create_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
)
assert response.status_code == status.HTTP_201_CREATED
mock.assert_called_once()
_, kwargs = mock.call_args
assert kwargs["bpf"] == "icmp"
assert kwargs["tag"] == 3
assert kwargs["color"] == "#ff5722"
assert kwargs["highlight_duration"] == 800
assert kwargs["name"].startswith("marker-")
async def test_create_marker_with_explicit_name(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "web", "bpf": "tcp port 80"},
)
assert response.status_code == status.HTTP_201_CREATED
_, kwargs = mock.call_args
assert kwargs["name"] == "web"
async def test_create_marker_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "global-x", "bpf": "icmp"},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert not mock.called # rejected before reaching the controller
async def test_create_marker_bad_format_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "bad name!", "bpf": "icmp"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_create_marker_name_too_long_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "x" * 33, "bpf": "icmp"}, # max_length is 32
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_get_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
link._markers["web"] = {"bpf": "tcp port 80", "tag": None, "enabled": True,
"color": None, "highlight_duration": 800, "capture_node_id": "n1"}
project._links = {link.id: link}
response = await client.get(
app.url_path_for("get_markers", project_id=project.id, link_id=link.id)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["web"]["highlight_duration"] == 800
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock:
response = await client.put(
app.url_path_for("update_marker", project_id=project.id, link_id=link.id, marker_name="web"),
json={"bpf": "udp port 53", "highlight_duration": 1500},
)
assert response.status_code == status.HTTP_200_OK
_, kwargs = mock.call_args
assert kwargs["bpf"] == "udp port 53"
assert kwargs["highlight_duration"] == 1500
async def test_update_inherited_marker_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
_inherited(link, "arp")
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_marker", project_id=project.id, link_id=link.id, marker_name="global-arp"),
json={"name": "global-arp", "bpf": "arp"},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert "inherited" in response.json()["message"]
async def test_delete_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.stop_marker") as mock:
response = await client.delete(
app.url_path_for("delete_marker", project_id=project.id, link_id=link.id, marker_name="web")
)
assert response.status_code == status.HTTP_204_NO_CONTENT
mock.assert_called_once_with("web")
async def test_delete_inherited_marker_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
_inherited(link, "arp")
project._links = {link.id: link}
response = await client.delete(
app.url_path_for("delete_marker", project_id=project.id, link_id=link.id, marker_name="global-arp")
)
assert response.status_code == status.HTTP_409_CONFLICT
# -----------------------------------------------------------------------
# Project-level marker definitions
# -----------------------------------------------------------------------
async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
response = await client.post(
app.url_path_for("create_marker_definition", project_id=project.id),
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
)
assert response.status_code == status.HTTP_201_CREATED
_, kwargs = mock.call_args
assert kwargs["name"] == "arp"
assert kwargs["bpf"] == "arp"
assert kwargs["highlight_duration"] == 1200
async def test_create_marker_definition_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
response = await client.post(
app.url_path_for("create_marker_definition", project_id=project.id),
json={"name": "global-x", "bpf": "arp"},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert not mock.called
async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock:
response = await client.put(
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
json={"bpf": "arp or rarp", "highlight_duration": 900},
)
assert response.status_code == status.HTTP_200_OK
_, kwargs = mock.call_args
assert kwargs["bpf"] == "arp or rarp"
assert kwargs["highlight_duration"] == 900
async def test_delete_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
with asyncio_patch("gns3server.controller.project.Project.delete_marker_definition") as mock:
response = await client.delete(
app.url_path_for("delete_marker_definition", project_id=project.id, def_name="arp")
)
assert response.status_code == status.HTTP_204_NO_CONTENT
mock.assert_called_once_with("arp")
async def test_get_marker_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
project._marker_definitions = {
"arp": {"bpf": "arp", "tag": 5, "color": None, "highlight_duration": 1200},
}
project._links = {}
response = await client.get(
app.url_path_for("get_marker_definitions", project_id=project.id)
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["arp"]["bpf"] == "arp"
assert body["arp"]["highlight_duration"] == 1200
assert body["arp"]["link_ids"] == []
# -----------------------------------------------------------------------
# Aggregation
# -----------------------------------------------------------------------
async def test_get_project_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
link._markers["icmp"] = {"bpf": "icmp", "tag": 1, "enabled": True, "color": "#ff5722",
"highlight_duration": 800, "capture_node_id": "node-1"}
project._links = {link.id: link}
response = await client.get(
app.url_path_for("get_project_markers", project_id=project.id)
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
key = f"{link.id}/icmp"
assert key in body
assert body[key]["highlight_duration"] == 800
assert body[key]["link_id"] == link.id
assert body[key]["node_id"] == "node-1"
+27 -11
View File
@@ -553,24 +553,33 @@ class TestNodeRoutes:
compute: Compute,
node: Node
) -> None:
response = MagicMock()
response.body = b"world"
response.status = status.HTTP_200_OK
compute.http_query = AsyncioMagicMock(return_value=response)
# Mock the streaming response
async def mock_iter_chunked(chunk_size):
yield b"world"
mock_stream = AsyncioMagicMock()
mock_stream.iter_chunked = mock_iter_chunked
mock_stream.close = MagicMock()
mock_response = AsyncioMagicMock()
mock_response.status = status.HTTP_200_OK
mock_response.content = mock_stream
compute.http_query = AsyncioMagicMock(return_value=mock_response)
response = await client.get(app.url_path_for("get_file", project_id=project.id, node_id=node.id, file_path="hello"))
assert response.status_code == status.HTTP_200_OK
assert response.content == b'world'
compute.http_query.assert_called_with(
"GET",
"/projects/{project_id}/files/project-files/vpcs/{node_id}/hello".format(
project_id=project.id,
node_id=node.id),
timeout=None,
raw=True)
stream=True)
response = await client.get(app.url_path_for(
"get_file",
project_id=project.id,
@@ -595,8 +604,15 @@ class TestNodeRoutes:
node_id=node.id,
file_path="hello"), content=b"hello")
assert response.status_code == status.HTTP_201_CREATED
compute.http_query.assert_called_with("POST", "/projects/{project_id}/files/project-files/vpcs/{node_id}/hello".format(project_id=project.id, node_id=node.id), data=b'hello', timeout=None, raw=True)
# Verify http_query was called with stream parameter
compute.http_query.assert_called_once()
call_args = compute.http_query.call_args
assert call_args[0][0] == "POST"
assert call_args[0][1] == "/projects/{project_id}/files/project-files/vpcs/{node_id}/hello".format(project_id=project.id, node_id=node.id)
assert call_args[1]["timeout"] is None
# data should be an async generator from request.stream()
assert hasattr(call_args[1]["data"], "__aiter__")
response = await client.get("/projects/{project_id}/nodes/{node_id}/files/../hello".format(project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_404_NOT_FOUND
+175
View File
@@ -255,6 +255,10 @@ class TestUserLogin:
assert "token_type" in response.json()
assert response.json().get("token_type") == "bearer"
# check that refresh token is returned
assert "refresh_token" in response.json()
assert response.json().get("refresh_token") is not None
@pytest.mark.parametrize(
"username, password, status_code",
(
@@ -311,6 +315,7 @@ class TestUnauthorizedUser:
response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
assert response.status_code == status.HTTP_200_OK
assert response.json().get("access_token")
assert response.json().get("refresh_token") is not None
token = response.json().get("access_token")
response = await unauthorized_client.get(app.url_path_for("statistics"), params={"token": token})
@@ -485,6 +490,176 @@ class TestLogout:
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestRefreshToken:
async def test_login_returns_refresh_token(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
credentials = {"username": test_user.username, "password": "user1_password"}
response = await unauthorized_client.post(app.url_path_for("login"),
data=credentials,
headers={"content-type": "application/x-www-form-urlencoded"})
assert response.status_code == status.HTTP_200_OK
assert "refresh_token" in response.json()
assert response.json().get("refresh_token") is not None
async def test_authenticate_returns_refresh_token(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
credentials = {"username": test_user.username, "password": "user1_password"}
response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
assert response.status_code == status.HTTP_200_OK
assert "refresh_token" in response.json()
assert response.json().get("refresh_token") is not None
async def test_refresh_endpoint_returns_new_tokens(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
# authenticate to get a refresh token
credentials = {"username": test_user.username, "password": "user1_password"}
auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
refresh_token = auth_response.json()["refresh_token"]
# use the refresh token at /refresh
response = await unauthorized_client.post(
app.url_path_for("refresh_access_token"),
json={"refresh_token": refresh_token},
)
assert response.status_code == status.HTTP_200_OK
assert "access_token" in response.json()
assert response.json().get("token_type") == "bearer"
assert "refresh_token" in response.json()
assert response.json().get("refresh_token") is not None
async def test_new_access_token_from_refresh_works_on_protected_route(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
credentials = {"username": test_user.username, "password": "user1_password"}
auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
refresh_token = auth_response.json()["refresh_token"]
# refresh to get a new access token
refresh_response = await unauthorized_client.post(
app.url_path_for("refresh_access_token"),
json={"refresh_token": refresh_token},
)
new_access_token = refresh_response.json()["access_token"]
# the new access token must work on a protected route
response = await unauthorized_client.get(
app.url_path_for("get_logged_in_user"),
headers={"Authorization": f"Bearer {new_access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["username"] == test_user.username
async def test_refresh_with_stale_token_after_logout(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
# authenticate and get a refresh token
credentials = {"username": test_user.username, "password": "user1_password"}
auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
refresh_token = auth_response.json()["refresh_token"]
# logout — bumps token_version, invalidating the refresh token
access_token = auth_response.json()["access_token"]
await unauthorized_client.post(
app.url_path_for("logout"),
headers={"Authorization": f"Bearer {access_token}"}
)
# /refresh must now reject the stale refresh token
response = await unauthorized_client.post(
app.url_path_for("refresh_access_token"),
json={"refresh_token": refresh_token},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_refresh_rejects_access_token(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
# an access token presented at /refresh must be rejected
credentials = {"username": test_user.username, "password": "user1_password"}
auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
access_token = auth_response.json()["access_token"]
response = await unauthorized_client.post(
app.url_path_for("refresh_access_token"),
json={"refresh_token": access_token},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_refresh_rejects_expired_token(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
# a refresh token with an already-expired timestamp
expired_refresh = auth_service.create_refresh_token(test_user.username, expires_in=-1)
response = await unauthorized_client.post(
app.url_path_for("refresh_access_token"),
json={"refresh_token": expired_refresh},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_refresh_rejects_invalid_token(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
) -> None:
response = await unauthorized_client.post(
app.url_path_for("refresh_access_token"),
json={"refresh_token": "not-a-valid-token"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_refresh_token_rejected_as_bearer(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
# a refresh token used as a bearer access token must be rejected
credentials = {"username": test_user.username, "password": "user1_password"}
auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
refresh_token = auth_response.json()["refresh_token"]
response = await unauthorized_client.get(
app.url_path_for("get_logged_in_user"),
headers={"Authorization": f"Bearer {refresh_token}"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestSuperAdmin:
async def test_super_admin_exists(
+12
View File
@@ -0,0 +1,12 @@
"""MCP test fixtures."""
import pytest
@pytest.fixture
def ctx():
"""Standard gns3_ctx for handlers."""
return {
"server_url": "http://192.168.1.3:3080",
"jwt_token": "test-token",
"jwt_username": "admin",
}
+515
View File
@@ -0,0 +1,515 @@
"""
MCP handler unit tests with mocked Gns3Connector.
Tests that handlers correctly transform tool parameters into HTTP calls.
"""
import pytest
from unittest.mock import MagicMock, patch
def _mock_conn(json_result=None):
"""Create a mocked Gns3Connector with base_url and http_call."""
conn = MagicMock()
conn.base_url = "http://192.168.1.3:3080/v3"
conn.http_call.return_value.json.return_value = json_result or {"status": "ok"}
return conn
BASE = "gns3server.api.routes.mcp"
@pytest.fixture
def ctx():
return {"server_url": "http://192.168.1.3:3080", "jwt_token": "token", "jwt_username": "admin"}
# ── Project ─────────────────────────────────────────────────────────────
class TestProject:
mod = "projects"
def test_list(self, ctx):
from gns3server.api.routes.mcp.projects import list_projects_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn([{"project_id": "p1", "name": "Test", "status": "opened"}])
result = list_projects_handler({}, ctx)
assert result["count"] == 1
def test_get(self, ctx):
from gns3server.api.routes.mcp.projects import get_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"project_id": "p1"})
result = get_project_handler({"project_id": "p1"}, ctx)
assert result["project_id"] == "p1"
def test_get_missing_id(self, ctx):
from gns3server.api.routes.mcp.projects import get_project_handler
assert "error" in get_project_handler({}, ctx)
def test_create(self, ctx):
from gns3server.api.routes.mcp.projects import create_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"project_id": "p1"})
result = create_project_handler({"name": "New"}, ctx)
assert result["project_id"] == "p1"
def test_delete(self, ctx):
from gns3server.api.routes.mcp.projects import delete_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({})
result = delete_project_handler({"project_id": "p1"}, ctx)
assert "message" in result
def test_open(self, ctx):
from gns3server.api.routes.mcp.projects import open_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"status": "opened"})
result = open_project_handler({"project_id": "p1"}, ctx)
assert result["status"] == "opened"
def test_close(self, ctx):
from gns3server.api.routes.mcp.projects import close_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"status": "closed"})
result = close_project_handler({"project_id": "p1"}, ctx)
assert "error" not in result
def test_update(self, ctx):
from gns3server.api.routes.mcp.projects import update_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"name": "Updated"})
result = update_project_handler({"project_id": "p1", "name": "Updated"}, ctx)
assert result["name"] == "Updated"
def test_stats(self, ctx):
from gns3server.api.routes.mcp.projects import get_project_stats_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"nodes": 5, "links": 3})
result = get_project_stats_handler({"project_id": "p1"}, ctx)
assert result["nodes"] == 5
# ── Node ────────────────────────────────────────────────────────────────
class TestNode:
mod = "nodes"
def test_list_fields(self, ctx):
from gns3server.api.routes.mcp.nodes import get_nodes_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn([
{"node_id": "n1", "name": "R1", "status": "started", "node_type": "qemu", "console": 5000},
])
result = get_nodes_handler({"project_id": "p1", "fields": ["name", "status"]}, ctx)
assert result == {"nodes": [{"name": "R1", "status": "started"}], "count": 1}
def test_list_invalid_fields(self, ctx):
from gns3server.api.routes.mcp.nodes import get_nodes_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn([])
result = get_nodes_handler({"project_id": "p1", "fields": "not-a-list"}, ctx)
assert "error" in result
def test_get(self, ctx):
from gns3server.api.routes.mcp.nodes import get_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"node_id": "n1", "name": "R1"})
result = get_node_handler({"project_id": "p1", "node_id": "n1"}, ctx)
assert result["name"] == "R1"
def test_create_single_passes_name(self, ctx):
from gns3server.api.routes.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"node_id": "n1", "name": "MyRouter"})
m.return_value = conn
result = create_node_handler({
"project_id": "p1", "template_id": "t1",
"name": "MyRouter", "x": 100, "y": 200,
}, ctx)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p1/templates/t1",
json_data={"x": 100, "y": 200, "compute_id": "local", "name": "MyRouter"},
)
assert result == {"node_id": "n1", "name": "MyRouter"}
def test_create_fields_filter(self, ctx):
from gns3server.api.routes.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"node_id": "n1", "name": "R1", "status": "started"})
result = create_node_handler({
"project_id": "p1", "template_id": "t1",
"fields": ["node_id", "name"],
}, ctx)
assert result == {"node_id": "n1", "name": "R1"}
def test_create_fields_validation(self, ctx):
from gns3server.api.routes.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
m.return_value = conn
result = create_node_handler({
"project_id": "p1", "template_id": "t1", "fields": "not-a-list",
}, ctx)
assert "error" in result
assert "fields must be a list" in result["error"]
conn.http_call.assert_not_called()
def test_create_batch_inherits_template_id(self, ctx):
from gns3server.api.routes.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"node_id": "n1", "name": "R1"})
result = create_node_handler({
"project_id": "p1", "template_id": "default-tpl",
"nodes": [{"name": "R1", "x": 0, "y": 0}],
}, ctx)
assert result[0]["status"] == "success"
def test_create_missing_project_id(self, ctx):
from gns3server.api.routes.mcp.nodes import create_node_handler
assert create_node_handler({}, ctx) == {"error": "project_id is required"}
def test_delete_batch(self, ctx):
from gns3server.api.routes.mcp.nodes import delete_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({})
result = delete_node_handler({"project_id": "p1", "node_ids": ["n1", "n2"]}, ctx)
assert len(result) == 2
def test_start_batch(self, ctx):
from gns3server.api.routes.mcp.nodes import start_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"status": "started"})
result = start_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
assert result[0]["status"] == "success"
def test_stop_batch(self, ctx):
from gns3server.api.routes.mcp.nodes import stop_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"status": "stopped"})
result = stop_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
assert result[0]["status"] == "success"
def test_suspend_batch(self, ctx):
from gns3server.api.routes.mcp.nodes import suspend_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"status": "suspended"})
result = suspend_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
assert result[0]["status"] == "success"
def test_console(self, ctx):
from gns3server.api.routes.mcp.nodes import get_node_console_info_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"console_url": "ws://host/console"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
assert "command" in result
# ── Link ────────────────────────────────────────────────────────────────
class TestLink:
mod = "links"
def test_list(self, ctx):
from gns3server.api.routes.mcp.links import get_links_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn([{"link_id": "l1", "link_type": "ethernet"}])
result = get_links_handler({"project_id": "p1", "fields": ["link_id"]}, ctx)
assert result["links"] == [{"link_id": "l1"}]
def test_get(self, ctx):
from gns3server.api.routes.mcp.links import get_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"link_id": "l1", "link_type": "ethernet"})
result = get_link_handler({"project_id": "p1", "link_id": "l1"}, ctx)
assert result["link_id"] == "l1"
def test_create_compact_format(self, ctx):
from gns3server.api.routes.mcp.links import create_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"link_id": "l1", "link_type": "ethernet", "nodes": []})
m.return_value = conn
result = create_link_handler({
"project_id": "p1",
"nodes": ["n1", 0, 0, "n2", 0, 0],
}, ctx)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p1/links",
json_data={"nodes": [
{"node_id": "n1", "adapter_number": 0, "port_number": 0},
{"node_id": "n2", "adapter_number": 0, "port_number": 0},
]},
)
def test_create_standard_format(self, ctx):
from gns3server.api.routes.mcp.links import create_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"link_id": "l1"})
result = create_link_handler({
"project_id": "p1",
"nodes": [
{"node_id": "n1", "adapter_number": 0, "port_number": 0},
{"node_id": "n2", "adapter_number": 0, "port_number": 0},
],
}, ctx)
assert result["link_id"] == "l1"
def test_create_fields_validation(self, ctx):
from gns3server.api.routes.mcp.links import create_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
m.return_value = conn
result = create_link_handler({
"project_id": "p1", "fields": "bad",
"nodes": ["n1", 0, 0, "n2", 0, 0],
}, ctx)
assert "error" in result
assert "fields must be a list" in result["error"]
conn.http_call.assert_not_called()
def test_delete_batch(self, ctx):
from gns3server.api.routes.mcp.links import delete_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({})
result = delete_link_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx)
assert len(result) == 2
def test_update(self, ctx):
from gns3server.api.routes.mcp.links import update_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"link_id": "l1", "suspend": True})
result = update_link_handler({
"project_id": "p1", "link_id": "l1", "suspend": True,
}, ctx)
assert result["suspend"] is True
# ── Appliance ───────────────────────────────────────────────────────────
class TestAppliance:
mod = "appliances"
def test_get(self, ctx):
from gns3server.api.routes.mcp.appliances import get_appliance_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"appliance_id": "a1", "name": "Cisco ISE"})
result = get_appliance_handler({"appliance_id": "a1"}, ctx)
assert result["name"] == "Cisco ISE"
def test_install_with_version(self, ctx):
from gns3server.api.routes.mcp.appliances import install_appliance_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"status": "installed"})
m.return_value = conn
result = install_appliance_handler({
"appliance_id": "a1", "version": "2.7.0.356",
}, ctx)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/appliances/a1/install",
params={"version": "2.7.0.356"},
)
def test_install_missing_id(self, ctx):
from gns3server.api.routes.mcp.appliances import install_appliance_handler
result = install_appliance_handler({}, ctx)
assert "error" in result
# ── Template ────────────────────────────────────────────────────────────
class TestTemplate:
mod = "templates"
def test_list_fields(self, ctx):
from gns3server.api.routes.mcp.templates import list_templates_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn([
{"template_id": "t1", "name": "Cisco 7200", "template_type": "dynamips",
"category": "router", "default_name_format": "{name}-{0}"},
])
result = list_templates_handler({"fields": ["template_id", "name"]}, ctx)
assert result["templates"] == [{"template_id": "t1", "name": "Cisco 7200"}]
def test_list_invalid_field(self, ctx):
from gns3server.api.routes.mcp.templates import list_templates_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn()
result = list_templates_handler({"fields": ["does_not_exist"]}, ctx)
assert "error" in result
def test_get(self, ctx):
from gns3server.api.routes.mcp.templates import get_template_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
m.return_value = _mock_conn({"template_id": "t1", "name": "Test"})
result = get_template_handler({"template_id": "t1"}, ctx)
assert result["name"] == "Test"
def test_delete(self, ctx):
from gns3server.api.routes.mcp.templates import delete_template_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
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 build create/update bodies.
A definition has NO direction: it fans out to every link and auto-selects its
capture node on each, so tx/rx (relative to that node) has no consistent
meaning — any direction passed is ignored, never reaching the request body.
"""
mod = "links"
def test_create_builds_body(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", "tag": 1, "color": "#fff"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions",
json_data={"bpf": "arp", "tag": 1, "color": "#fff"},
)
def test_create_ignores_direction(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"},
)
def test_update_builds_body(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_update_ignores_direction(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, "direction": "rx"}, 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_update_requires_a_field(self, ctx):
from gns3server.api.routes.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector"):
result = marker_definition_handler(
{"project_id": "p", "action": "update", "def_name": "arp"}, ctx,
)
assert "error" in result
+254
View File
@@ -0,0 +1,254 @@
"""
MCP tool parameter consistency tests.
Verifies that the parameters defined in each MCP tool function (in __init__.py)
match what the corresponding handler function actually reads via params.get().
This catches issues like:
- A tool parameter is defined but never passed to the handler
- A handler reads a param that was never defined or passed
"""
import ast
import os
import sys
from pathlib import Path
import pytest
MCP_DIR = Path(__file__).resolve().parents[4] / "gns3server" / "api" / "routes" / "mcp"
TOOL_FILE = MCP_DIR / "__init__.py"
HANDLER_FILES = {
"list_projects_handler": "projects.py",
"get_project_handler": "projects.py",
"create_project_handler": "projects.py",
"delete_project_handler": "projects.py",
"open_project_handler": "projects.py",
"close_project_handler": "projects.py",
"get_project_stats_handler": "projects.py",
"update_project_handler": "projects.py",
"duplicate_project_handler": "projects.py",
"get_project_readme_handler": "projects.py",
"update_project_readme_handler": "projects.py",
"lock_project_handler": "projects.py",
"unlock_project_handler": "projects.py",
"get_locked_project_handler": "projects.py",
"load_project_handler": "projects.py",
"get_nodes_handler": "nodes.py",
"get_node_handler": "nodes.py",
"start_node_handler": "nodes.py",
"stop_node_handler": "nodes.py",
"suspend_node_handler": "nodes.py",
"create_node_handler": "nodes.py",
"delete_node_handler": "nodes.py",
"update_node_handler": "nodes.py",
"get_node_console_info_handler": "nodes.py",
"list_node_files_handler": "nodes.py",
"get_node_file_handler": "nodes.py",
"write_node_file_handler": "nodes.py",
"delete_node_file_handler": "nodes.py",
"start_all_nodes_handler": "nodes.py",
"stop_all_nodes_handler": "nodes.py",
"suspend_all_nodes_handler": "nodes.py",
"duplicate_node_handler": "nodes.py",
"isolate_node_handler": "nodes.py",
"unisolate_node_handler": "nodes.py",
"get_node_links_handler": "nodes.py",
"get_links_handler": "links.py",
"get_link_handler": "links.py",
"create_link_handler": "links.py",
"delete_link_handler": "links.py",
"update_link_handler": "links.py",
"reset_link_handler": "links.py",
"start_capture_handler": "links.py",
"stop_capture_handler": "links.py",
"download_capture_file_handler": "links.py",
"list_templates_handler": "templates.py",
"get_template_handler": "templates.py",
"create_template_handler": "templates.py",
"update_template_handler": "templates.py",
"delete_template_handler": "templates.py",
"list_computes_handler": "computes.py",
"get_compute_handler": "computes.py",
"get_compute_images_handler": "computes.py",
"get_snapshots_handler": "snapshots.py",
"create_snapshot_handler": "snapshots.py",
"delete_snapshot_handler": "snapshots.py",
"restore_snapshot_handler": "snapshots.py",
"get_drawings_handler": "drawings.py",
"create_drawing_handler": "drawings.py",
"get_drawing_handler": "drawings.py",
"update_drawing_handler": "drawings.py",
"delete_drawing_handler": "drawings.py",
"get_symbols_handler": "symbols.py",
"get_symbol_handler": "symbols.py",
"get_symbol_dimensions_handler": "symbols.py",
"get_default_symbols_handler": "symbols.py",
"upload_symbol_handler": "symbols.py",
"delete_symbol_handler": "symbols.py",
"get_appliances_handler": "appliances.py",
"get_appliance_handler": "appliances.py",
"install_appliance_handler": "appliances.py",
"get_version_handler": "server.py",
"get_statistics_handler": "server.py",
"get_images_handler": "images.py",
"get_image_handler": "images.py",
"delete_image_handler": "images.py",
"prune_images_handler": "images.py",
"install_images_handler": "images.py",
"device_config_send_handler": "device_config.py",
"device_show_run_handler": "device_config.py",
"vpcs_config_set_handler": "device_config.py",
}
def _get_handler_params(handler_name):
"""Parse handler file and extract all params.get('xxx') calls."""
filename = HANDLER_FILES.get(handler_name)
if not filename:
return None
filepath = MCP_DIR / filename
if not filepath.exists():
return None
tree = ast.parse(filepath.read_text())
params = set()
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
if node.name != handler_name:
continue
# Found the handler function, search for params.get("xxx")
for sub in ast.walk(node):
if not isinstance(sub, ast.Call):
continue
if not hasattr(sub.func, "attr") or sub.func.attr != "get":
continue
# params.get("xxx") or params_data.get("xxx")
func_obj = sub.func
if (hasattr(func_obj.value, "id") and func_obj.value.id in ("params", "params_data", "link_data", "node_data")) or \
(hasattr(func_obj.value, "attr") and func_obj.value.attr == "get"):
if sub.args and isinstance(sub.args[0], ast.Constant) and isinstance(sub.args[0].value, str):
params.add(sub.args[0].value)
return params
def _get_tool_params(tool_name, tool_file=TOOL_FILE):
"""Parse __init__.py and extract params passed to _run_handler_sync for a given tool.
Returns the dict literal keys from the _run_handler_sync call.
"""
tree = ast.parse(tool_file.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
if node.name != tool_name:
continue
# Search for _run_handler_sync calls inside this function
for sub in ast.walk(node):
if not isinstance(sub, ast.Call):
continue
if not hasattr(sub.func, "id") or sub.func.id != "_run_handler_sync":
continue
# _run_handler_sync(handler, {dict}) or _run_handler_sync(handler, params)
if len(sub.args) >= 2:
second_arg = sub.args[1]
if isinstance(second_arg, ast.Dict):
keys = set()
for k in second_arg.keys:
if isinstance(k, ast.Constant) and isinstance(k.value, str):
keys.add(k.value)
return keys
elif isinstance(second_arg, ast.Name) and second_arg.id == "params":
return {"*params*"} # special marker for all params passed through
return None
def test_handler_params_all_readable():
"""Every handler registered in __init__.py should have a corresponding file."""
# Extract all handler names from __init__.py by looking for _run_handler_sync calls
tree = ast.parse(TOOL_FILE.read_text())
handlers_found = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call) and hasattr(node.func, "id") and node.func.id == "_run_handler_sync":
if node.args and isinstance(node.args[0], ast.Name):
handlers_found.add(node.args[0].id)
unknown = [h for h in handlers_found if h not in HANDLER_FILES]
assert not unknown, f"Handlers not mapped in HANDLER_FILES: {unknown}"
def _get_tool_fn_name(handler_name):
"""Reverse lookup: find which MCP tool function calls this handler."""
tree = ast.parse(TOOL_FILE.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Call) and hasattr(node.func, "id") and node.func.id == "_run_handler_sync":
if node.args and isinstance(node.args[0], ast.Name) and node.args[0].id == handler_name:
# Find enclosing function
for parent in ast.walk(tree):
if isinstance(parent, ast.FunctionDef):
for child in ast.walk(parent):
if child is node:
return parent.name
return None
def test_tool_handler_param_consistency():
"""For each tool, the params passed to the handler should match what the handler reads."""
tree = ast.parse(TOOL_FILE.read_text())
# Collect all _run_handler_sync calls with dict literals
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if not hasattr(node.func, "id") or node.func.id != "_run_handler_sync":
continue
if len(node.args) < 2:
continue
handler_name = node.args[0].id if isinstance(node.args[0], ast.Name) else None
if not handler_name:
continue
# Find the tool function name (enclosing function)
tool_name = None
for parent in ast.walk(tree):
if isinstance(parent, ast.FunctionDef):
for child in ast.walk(parent):
if child is node:
tool_name = parent.name
break
if not tool_name:
continue
second_arg = node.args[1]
if isinstance(second_arg, ast.Dict):
passed_keys = set()
for k in second_arg.keys:
if isinstance(k, ast.Constant) and isinstance(k.value, str):
passed_keys.add(k.value)
handler_params = _get_handler_params(handler_name)
if handler_params is None:
continue
# Check: every passed key is read by the handler
extra_passed = passed_keys - handler_params
assert not extra_passed, (
f"[{tool_name}] Params passed to handler '{handler_name}' but not read: {extra_passed}"
)
# Check: every handler param is passed (except common/optional ones)
missing = handler_params - passed_keys
# Filter out well-known optional params that handlers check
known_optional = {"fields", "template", "name", "version", "compute_id",
"x", "y", "link_type", "filters", "suspend", "link_style",
"show_filters_icon", "label"}
truly_missing = missing - known_optional
if truly_missing:
pytest.fail(
f"[{tool_name}] Handler '{handler_name}' reads params not passed: {truly_missing}"
)
+3 -1
View File
@@ -38,10 +38,12 @@ ALLOWED_CONTROLLER_ENDPOINTS = [
("/v3/version", "POST"),
("/v3/access/users/login", "POST"),
("/v3/access/users/authenticate", "POST"),
("/v3/access/users/refresh", "POST"),
("/v3/symbols", "GET"),
("/v3/symbols/{symbol_id:path}/raw", "GET"),
("/v3/symbols/{symbol_id:path}/dimensions", "GET"),
("/v3/symbols/default_symbols", "GET")
("/v3/symbols/default_symbols", "GET"),
("/v3/mcp/", "GET"),
]
class TestRoutes:
+6 -6
View File
@@ -72,9 +72,9 @@ async def test_json_with_ports(on_gns3vm, compute_project, manager):
}
],
"interfaces": [
{'name': 'eth0', 'special': False, 'type': 'ethernet'},
{'name': 'eth1', 'special': False, 'type': 'ethernet'},
{'name': 'virbr0', 'special': True, 'type': 'ethernet'}
{'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']},
{'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']},
{'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}
]
}
@@ -111,9 +111,9 @@ async def test_json_without_ports(on_gns3vm, compute_project, manager):
}
],
"interfaces": [
{'name': 'eth0', 'special': False, 'type': 'ethernet'},
{'name': 'eth1', 'special': False, 'type': 'ethernet'},
{'name': 'virbr0', 'special': True, 'type': 'ethernet'}
{'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']},
{'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']},
{'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}
]
}
+55 -31
View File
@@ -37,7 +37,15 @@ def test_json_gns3vm(on_gns3vm, compute_project):
"port_number": 0,
"type": "ethernet"
}
]
],
"interfaces": [
{
"name": "virbr0",
"type": "ethernet",
"special": True,
"ip_addresses": [],
},
],
}
@@ -47,39 +55,55 @@ def test_json_darwin(darwin_platform, compute_project):
{"name": "eth0", "special": False, "type": "ethernet"},
{"name": "vmnet8", "special": True, "type": "ethernet"}]):
nat = Nat("nat1", str(uuid.uuid4()), compute_project, MagicMock())
assert nat.asdict() == {
"name": "nat1",
"usage": "",
"node_id": nat.id,
"project_id": compute_project.id,
"status": "started",
"ports_mapping": [
{
"interface": "vmnet8",
"name": "nat0",
"port_number": 0,
"type": "ethernet"
}
]
}
assert nat.asdict() == {
"name": "nat1",
"usage": "",
"node_id": nat.id,
"project_id": compute_project.id,
"status": "started",
"ports_mapping": [
{
"interface": "vmnet8",
"name": "nat0",
"port_number": 0,
"type": "ethernet"
}
],
"interfaces": [
{
"name": "vmnet8",
"type": "ethernet",
"special": True,
"ip_addresses": [],
},
],
}
def test_json_windows_with_full_name_of_interface(windows_platform, project):
with patch("gns3server.utils.interfaces.interfaces", return_value=[
{"name": "VMware Network Adapter VMnet8", "special": True, "type": "ethernet"}]):
nat = Nat("nat1", str(uuid.uuid4()), project, MagicMock())
assert nat.asdict() == {
"name": "nat1",
"usage": "",
"node_id": nat.id,
"project_id": project.id,
"status": "started",
"ports_mapping": [
{
"interface": "VMware Network Adapter VMnet8",
"name": "nat0",
"port_number": 0,
"type": "ethernet"
}
]
}
assert nat.asdict() == {
"name": "nat1",
"usage": "",
"node_id": nat.id,
"project_id": project.id,
"status": "started",
"ports_mapping": [
{
"interface": "VMware Network Adapter VMnet8",
"name": "nat0",
"port_number": 0,
"type": "ethernet"
}
],
"interfaces": [
{
"name": "VMware Network Adapter VMnet8",
"type": "ethernet",
"special": True,
"ip_addresses": [],
},
],
}
+86
View File
@@ -169,6 +169,92 @@ async def test_pull_image():
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
async def test_pull_image_skips_image_available_locally():
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "existing"}) as query_mock:
with asyncio_patch("gns3server.compute.docker.Docker.http_query") as pull_mock:
await Docker.instance().pull_image("ubuntu")
query_mock.assert_called_once_with("GET", "images/ubuntu/json")
pull_mock.assert_not_called()
@pytest.mark.asyncio
async def test_force_pull_image():
response = MagicMock()
response.content.read = AsyncioMagicMock(return_value=b"")
with asyncio_patch("gns3server.compute.docker.Docker.query") as query_mock:
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image("ubuntu", force=True)
query_mock.assert_not_called()
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
async def test_pull_image_error():
class Content:
def __init__(self):
self._chunks = [b'{"error": "image not found"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="image not found"):
await Docker.instance().pull_image("missing")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_rejects_incomplete_response():
class Content:
def __init__(self):
self._read = False
async def read(self, size):
if self._read:
return b""
self._read = True
return b'{"status": "Pulling"'
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="Invalid response"):
await Docker.instance().pull_image("ubuntu")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_propagates_timeout():
class Content:
async def read(self, size):
raise asyncio.TimeoutError
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="Timeout while pulling"):
await Docker.instance().pull_image("ubuntu")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_docker_check_connection_docker_minimum_version(vm):
+20 -2
View File
@@ -1735,9 +1735,10 @@ async def test_fix_permission(vm):
vm._volumes = ["/etc"]
vm._get_container_state = AsyncioMagicMock(return_value="running")
process = MagicMock()
process.returncode = 0
with asyncio_patch("asyncio.subprocess.create_subprocess_exec", return_value=process) as mock_exec:
await vm._fix_permissions()
mock_exec.assert_called_with('docker', 'exec', 'e90e34656842', '/gns3/bin/busybox', 'sh', '-c', '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format(os.getuid(), os.getgid()))
mock_exec.assert_called_with('docker', 'exec', 'e90e34656842', '/gns3/bin/busybox', 'sh', '-c', '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format(os.getuid(), os.getgid()), stderr=asyncio.subprocess.PIPE)
assert process.wait.called
@@ -1747,10 +1748,11 @@ async def test_fix_permission_not_running(vm):
vm._volumes = ["/etc"]
vm._get_container_state = AsyncioMagicMock(return_value="stopped")
process = MagicMock()
process.returncode = 0
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_start:
with asyncio_patch("asyncio.subprocess.create_subprocess_exec", return_value=process) as mock_exec:
await vm._fix_permissions()
mock_exec.assert_called_with('docker', 'exec', 'e90e34656842', '/gns3/bin/busybox', 'sh', '-c', '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format(os.getuid(), os.getgid()))
mock_exec.assert_called_with('docker', 'exec', 'e90e34656842', '/gns3/bin/busybox', 'sh', '-c', '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format(os.getuid(), os.getgid()), stderr=asyncio.subprocess.PIPE)
assert mock_start.called
assert process.wait.called
@@ -1880,3 +1882,19 @@ async def test_memory(compute_project, manager):
"Cmd": ["/bin/sh"]
})
assert vm._cid == "e90e34656806"
@pytest.mark.asyncio
async def test_stop_exited_container_no_stop_query(vm):
vm._ubridge_hypervisor = None
vm._fix_permissions = MagicMock()
with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="exited"):
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query:
vm._permissions_fixed = False
await vm.stop()
assert not any(
call.args[:2] == ("POST", "containers/e90e34656842/stop")
for call in mock_query.mock_calls
)
assert vm.status == "stopped"
+190 -4
View File
@@ -21,13 +21,14 @@ import asyncio
import os
import stat
import socket
import struct
import uuid
import shutil
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import MagicMock
from gns3server.compute.iou.iou_vm import IOUVM
from unittest.mock import MagicMock, call
from gns3server.compute.iou.iou_vm import IOUL1KeepaliveProtocol, IOUVM
from gns3server.compute.iou.iou_error import IOUError
from gns3server.compute.iou import IOU
@@ -76,6 +77,7 @@ def test_vm(compute_project, manager):
vm = IOUVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
assert vm.name == "test"
assert vm.id == "00010203-0405-0607-0809-0a0b0c0d0e0f"
assert vm.l1_keepalives is False
def test_vm_startup_config_content(compute_project, manager):
@@ -111,6 +113,45 @@ async def test_start(vm):
vm._ubridge_send.assert_any_call("iol_bridge start IOL-BRIDGE-513")
@pytest.mark.asyncio
async def test_start_does_not_start_l1_responder_without_l_option(vm):
process = MagicMock(returncode=None)
process.communicate = AsyncioMagicMock(return_value=(None, None))
vm.l1_keepalives = True
vm._check_requirements = AsyncioMagicMock(return_value=True)
vm._check_iou_license = AsyncioMagicMock(return_value=True)
vm._start_ubridge = AsyncioMagicMock(return_value=True)
vm._ubridge_send = AsyncioMagicMock()
vm._build_command = AsyncioMagicMock(return_value=[vm.path, str(vm.application_id)])
vm._start_l1_keepalive_responder = AsyncioMagicMock()
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process):
await vm.start()
vm._start_l1_keepalive_responder.assert_not_called()
@pytest.mark.asyncio
async def test_start_failure_stops_l1_responder(vm):
vm.l1_keepalives = True
vm._check_requirements = AsyncioMagicMock(return_value=True)
vm._check_iou_license = AsyncioMagicMock(return_value=True)
vm._start_ubridge = AsyncioMagicMock(return_value=True)
vm._ubridge_send = AsyncioMagicMock()
vm._build_command = AsyncioMagicMock(return_value=[vm.path, "-l", str(vm.application_id)])
vm._start_l1_keepalive_responder = AsyncioMagicMock()
vm._stop_l1_keepalive_responder = MagicMock()
with asyncio_patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("missing image")):
with pytest.raises(IOUError):
await vm.start()
vm._start_l1_keepalive_responder.assert_called_once_with()
vm._stop_l1_keepalive_responder.assert_called_once_with()
@pytest.mark.asyncio
async def test_start_with_iourc(vm, tmpdir, config):
@@ -261,10 +302,155 @@ def test_create_netmap_config(vm):
assert "513:15/3 1:15/3" in content
@pytest.mark.parametrize(
"adapter_number,port_number,interface",
[
(0, 0, 0x00),
(0, 1, 0x10),
(0, 2, 0x20),
(0, 3, 0x30),
(1, 0, 0x01),
(1, 3, 0x31),
(15, 3, 0x3F),
],
)
def test_l1_keepalive_interface_encoding(adapter_number, port_number, interface):
assert IOUL1KeepaliveProtocol.encode_interface(adapter_number, port_number) == interface
assert IOUL1KeepaliveProtocol.decode_interface(interface) == (adapter_number, port_number)
def test_l1_keepalive_response_for_connected_interface(vm):
vm._adapters[0].add_nio(1, MagicMock())
transport = MagicMock()
protocol = IOUL1KeepaliveProtocol(vm)
protocol.connection_made(transport)
keepalive = struct.pack("!HHBBBB", 513, 1, 0x10, 0x10, 3, 0)
protocol.datagram_received(keepalive, None)
transport.sendto.assert_called_once_with(
struct.pack("!HHBBBB", 1, 513, 0x10, 0x10, 3, 0),
vm.l1_iou_socket_path,
)
def test_l1_keepalive_sent_for_connected_interface(vm):
vm._adapters[0].add_nio(2, MagicMock())
vm._adapters[0].add_nio(3, MagicMock())
vm._adapters[1].add_nio(2, MagicMock())
transport = MagicMock()
protocol = IOUL1KeepaliveProtocol(vm)
protocol.connection_made(transport)
protocol.send_keepalives()
assert transport.sendto.call_args_list == [
call(struct.pack("!HHBBBB", 1, 513, 0x20, 0x20, 3, 0), vm.l1_iou_socket_path),
call(struct.pack("!HHBBBB", 1, 513, 0x30, 0x30, 3, 0), vm.l1_iou_socket_path),
call(struct.pack("!HHBBBB", 1, 513, 0x21, 0x21, 3, 0), vm.l1_iou_socket_path),
]
def test_l1_keepalives_preserve_mixed_iou_interface_numbers(vm):
vm.ethernet_adapters = 4
vm.serial_adapters = 2
vm._adapters[0].add_nio(0, MagicMock()) # Ethernet0/0
vm._adapters[1].add_nio(1, MagicMock()) # Ethernet1/1
vm._adapters[2].add_nio(2, MagicMock()) # Ethernet2/2
vm._adapters[4].add_nio(0, MagicMock()) # Serial4/0
transport = MagicMock()
protocol = IOUL1KeepaliveProtocol(vm)
protocol.connection_made(transport)
protocol.send_keepalives()
assert transport.sendto.call_args_list == [
call(struct.pack("!HHBBBB", 1, 513, 0x00, 0x00, 3, 0), vm.l1_iou_socket_path),
call(struct.pack("!HHBBBB", 1, 513, 0x11, 0x11, 3, 0), vm.l1_iou_socket_path),
call(struct.pack("!HHBBBB", 1, 513, 0x22, 0x22, 3, 0), vm.l1_iou_socket_path),
call(struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0), vm.l1_iou_socket_path),
]
def test_l1_keepalive_response_for_serial4_0(vm):
vm.ethernet_adapters = 4
vm.serial_adapters = 2
vm._adapters[4].add_nio(0, MagicMock())
transport = MagicMock()
protocol = IOUL1KeepaliveProtocol(vm)
protocol.connection_made(transport)
protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0), None)
transport.sendto.assert_called_once_with(
struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0),
vm.l1_iou_socket_path,
)
def test_stop_l1_keepalive_responder_cleans_up(vm):
task = MagicMock()
transport = MagicMock()
vm._l1_keepalive_task = task
vm._l1_keepalive_transport = transport
with asyncio_patch("os.path.lexists", return_value=False):
vm._stop_l1_keepalive_responder()
vm._stop_l1_keepalive_responder()
task.cancel.assert_called_once_with()
transport.close.assert_called_once_with()
assert vm._l1_keepalive_task is None
assert vm._l1_keepalive_transport is None
def test_l1_keepalive_ignored_for_disconnected_interface(vm):
transport = MagicMock()
protocol = IOUL1KeepaliveProtocol(vm)
protocol.connection_made(transport)
protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 3, 0), None)
transport.sendto.assert_not_called()
@pytest.mark.parametrize(
"keepalive",
[
b"invalid",
struct.pack("!HHBBBB", 514, 1, 0x00, 0x00, 3, 0),
struct.pack("!HHBBBB", 513, 2, 0x00, 0x00, 3, 0),
struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 2, 0),
struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0),
struct.pack("!HHBBBB", 513, 1, 0x40, 0x40, 3, 0),
],
)
def test_invalid_l1_keepalive_is_ignored(vm, keepalive):
vm._adapters[0].add_nio(0, MagicMock())
transport = MagicMock()
protocol = IOUL1KeepaliveProtocol(vm)
protocol.connection_made(transport)
protocol.datagram_received(keepalive, None)
transport.sendto.assert_not_called()
@pytest.mark.asyncio
async def test_build_command(vm):
assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", str(vm.application_id)]
vm.l1_keepalives = True
help_output = "-l\t\tEnable Layer 1 keepalive messages\n"
with asyncio_patch("gns3server.utils.asyncio.subprocess_check_output", return_value=help_output):
assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", "-l", str(vm.application_id)]
def test_get_startup_config(vm):
@@ -350,7 +536,7 @@ async def test_enable_l1_keepalives(vm):
command = ["test"]
with pytest.raises(IOUError):
await vm._enable_l1_keepalives(command)
assert command == ["test"]
assert command == ["test"]
@pytest.mark.asyncio
View File
Whitespace-only changes.
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
import asyncio
import pytest
from gns3server.compute.marker.marker_manager import MarkerManager
from gns3server.compute.marker.marker_listener import MarkerListener
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TestMarkerRegistry:
def test_register_and_lookup(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
mgr.register("proj1", "node1", "filter1", "link1", tag=5)
pid, lid, tag = mgr.lookup("node1", "filter1")
assert pid == "proj1"
assert lid == "link1"
assert tag == 5
def test_miss_returns_none(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
pid, lid, tag = mgr.lookup("no-such-node", "no-such-filter")
assert pid is None
def test_reregister_updates(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
mgr.register("p", "n", "f", "l", tag=1)
mgr.register("p", "n", "f", "l", tag=99)
_, _, tag = mgr.lookup("n", "f")
assert tag == 99
def test_unregister(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
mgr.register("p", "n", "f", "l")
assert mgr.unregister("n", "f") is True
pid, _, _ = mgr.lookup("n", "f")
assert pid is None
assert mgr.unregister("n", "f") is False
def test_unregister_project(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
mgr.register("p1", "n1", "f1", "l1")
mgr.register("p1", "n2", "f2", "l2")
mgr.register("p2", "n3", "f3", "l3")
mgr.unregister_project("p1")
assert mgr.lookup("n1", "f1") == (None, None, None)
assert mgr.lookup("n2", "f2") == (None, None, None)
assert mgr.lookup("n3", "f3")[0] == "p2"
def test_re_add_after_project_clear(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
mgr.register("p", "n", "f", "l")
mgr.unregister_project("p")
mgr.register("p", "n", "f", "l2", tag=42)
pid, lid, tag = mgr.lookup("n", "f")
assert pid == "p" and lid == "l2" and tag == 42
# ---------------------------------------------------------------------------
# MarkerListener parsing
# ---------------------------------------------------------------------------
class FakeMarkerManager:
def __init__(self):
self.events = []
self._entries = {}
def lookup(self, node_id, filter_name):
e = self._entries.get((node_id, filter_name))
if e is None:
return None, None, None
return e["project_id"], e["link_id"], e["tag"]
def emit_match(self, project_id, event):
self.events.append((project_id, event))
def register(self, project_id, node_id, filter_name, link_id, tag):
self._entries[(node_id, filter_name)] = {
"project_id": project_id, "link_id": link_id, "tag": tag
}
class TestMarkerListener:
def test_parses_valid_mark_datagram(self):
fmgr = FakeMarkerManager()
fmgr.register("p1", "n1", "f1", "l1", tag=7)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(
b"MARK 1700000000.123456 node=n1 filter=f1 tag=7 len=98\n",
("127.0.0.1", 9999),
)
assert len(fmgr.events) == 1
_, ev = fmgr.events[0]
assert ev["node_id"] == "n1"
assert ev["link_id"] == "l1"
assert ev["filter"] == "f1"
assert ev["tag"] == "7"
assert ev["ts"] == pytest.approx(1700000000.123456)
assert ev["len"] == 98
# No dir= in the signal (legacy uBridge) → undirected.
assert ev["dir"] is None
def test_unknown_node_dropped(self):
fmgr = FakeMarkerManager()
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 1.0 node=bad filter=bad len=10\n", None)
assert fmgr.events == []
def test_bad_timestamp_ignored(self):
fmgr = FakeMarkerManager()
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK badts node=n filter=f len=1\n", None)
assert fmgr.events == []
def test_not_mark_line_ignored(self):
fmgr = FakeMarkerManager()
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"HELLO world\n", None)
assert fmgr.events == []
def test_missing_node_ignored(self):
fmgr = FakeMarkerManager()
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 1.0 filter=f len=1\n", None)
assert fmgr.events == []
def test_tag_dash_falls_back_to_registered(self):
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "l", tag=42)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None)
assert fmgr.events[0][1]["tag"] == 42
def test_link_in_signal_overrides_registry_link(self):
# Per-link attribution (contract §3.2/§3.3): the signal's `link=` is
# authoritative and must disambiguate links sharing a node+filter —
# e.g. several links on one IOU node under the same global marker name.
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "registry-link", tag=1)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(
b"MARK 3.0 node=n filter=f link=signal-link tag=1 len=42\n", None
)
assert fmgr.events[0][1]["link_id"] == "signal-link"
def test_link_dash_falls_back_to_registry_link(self):
# Legacy signals that carry no link fall back to the registry's link_id.
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "registry-link", tag=1)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 3.0 node=n filter=f link=- tag=1 len=42\n", None)
assert fmgr.events[0][1]["link_id"] == "registry-link"
def test_dir_tx_passthrough(self):
# dir=tx = capture node sending (matched packet ingressed the device-side NIO).
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "l", tag=1)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10 dir=tx\n", None)
assert fmgr.events[0][1]["dir"] == "tx"
def test_dir_rx_passthrough(self):
# dir=rx = capture node receiving (matched packet ingressed the link-side NIO).
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "l", tag=1)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10 dir=rx\n", None)
assert fmgr.events[0][1]["dir"] == "rx"
def test_dir_absent_is_none(self):
# Older uBridge builds omit dir; the event then carries None so the UI
# falls back to undirected rendering.
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "l", tag=1)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10\n", None)
assert fmgr.events[0][1]["dir"] is None
def test_exception_does_not_kill_listener(self):
fmgr = FakeMarkerManager()
lis = MarkerListener(fmgr)
lis.connection_made(None)
# Non-decodable bytes
lis.datagram_received(b"\xff\xfe\xfd", None)
# The listener swallows exceptions; reaching here proves it survived.
assert True
# ---------------------------------------------------------------------------
# UDP round-trip
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestMarkerManagerUDP:
async def test_listener_receives_and_dispatches(self):
MarkerManager.reset()
mgr = MarkerManager.instance()
captured = []
original_emit = mgr.emit_match
mgr.emit_match = lambda pid, ev: captured.append((pid, ev))
await mgr.start("127.0.0.1", 0)
assert mgr.running
assert mgr.port is not None
mgr.register("proj-rt", "node-rt", "filt-rt", "link-rt", tag=10)
loop = asyncio.get_running_loop()
class SendProto(asyncio.DatagramProtocol):
def connection_made(self, transport):
self.transport = transport
sp = SendProto()
transport, _ = await loop.create_datagram_endpoint(
lambda: sp, remote_addr=("127.0.0.1", mgr.port)
)
transport.sendto(
b"MARK 123.456 node=node-rt filter=filt-rt tag=10 len=88\n"
)
await asyncio.sleep(0.15)
transport.close()
mgr.emit_match = original_emit
await mgr.stop()
assert len(captured) == 1
pid, ev = captured[0]
assert pid == "proj-rt"
assert ev["link_id"] == "link-rt"
assert ev["len"] == 88
+203
View File
@@ -15,12 +15,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from collections import OrderedDict
import pytest
import pytest_asyncio
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import patch, MagicMock
from gns3server.compute.vpcs.vpcs_vm import VPCSVM
from gns3server.compute.docker.docker_vm import DockerVM
@@ -172,3 +174,204 @@ async def test_ubridge_apply_bpf_filters(node):
node._ubridge_send.assert_any_call("bridge reset_packet_filters VPCS-10")
node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter0 bpf \"icmp[icmptype] == 8\"")
node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter1 bpf \"tcp src port 53\"")
@pytest.mark.asyncio
async def test_set_marker_filter_state_off(compute_project, manager):
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._marker_filter_bridges["m", "L"] = "VPCS-10"
await node._ubridge_set_marker_filter_state("m", False)
node._ubridge_send.assert_called_with("bridge enable_packet_filter VPCS-10 m off")
@pytest.mark.asyncio
async def test_set_marker_filter_state_on(compute_project, manager):
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._marker_filter_bridges["m", "L"] = "VPCS-10"
await node._ubridge_set_marker_filter_state("m", True)
node._ubridge_send.assert_called_with("bridge enable_packet_filter VPCS-10 m on")
@pytest.mark.asyncio
async def test_marker_pause_sends_command(compute_project, manager):
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_hypervisor = AsyncioMagicMock()
await node._ubridge_marker_pause()
node._ubridge_hypervisor.send.assert_called_with("marker pause")
@pytest.mark.asyncio
async def test_marker_resume_sends_command(compute_project, manager):
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_hypervisor = AsyncioMagicMock()
await node._ubridge_marker_resume()
node._ubridge_hypervisor.send.assert_called_with("marker resume")
@pytest.mark.asyncio
async def test_apply_markers_turns_disabled_filter_off(compute_project, manager):
# Part A: a disabled marker is installed (add_packet_filter) then turned off
# with enable_packet_filter … off, and its bridge is recorded for toggling.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", "direction": None, "enabled": False}}
with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm:
mm.instance.return_value.register = MagicMock()
await node._ubridge_apply_markers("VPCS-10", nio)
node._ubridge_send.assert_any_call("bridge enable_packet_filter VPCS-10 m off")
assert node._marker_filter_bridges["m", "L1"] == "VPCS-10"
def test_marker_linktype_normalizes():
# Ethernet / unset → None (linktype omitted; uBridge defaults to EN10MB).
assert VPCSVM._marker_linktype(None) is None
assert VPCSVM._marker_linktype("") is None
assert VPCSVM._marker_linktype("DLT_EN10MB") is None
# Serial DLTs from SerialPort.data_link_types, DLT_ prefix stripped.
assert VPCSVM._marker_linktype("DLT_C_HDLC") == "C_HDLC"
assert VPCSVM._marker_linktype("DLT_PPP_SERIAL") == "PPP_SERIAL"
assert VPCSVM._marker_linktype("DLT_FRELAY") == "FRELAY"
assert VPCSVM._marker_linktype("DLT_ATM_RFC1483") == "ATM_RFC1483"
# Case-insensitive input → canonical uppercase (pcap_datalink_name_to_val is
# case-sensitive and expects the uppercase form). Shared by base_node and IOU.
assert VPCSVM._marker_linktype("dlt_c_hdlc") == "C_HDLC"
@pytest.mark.asyncio
async def test_apply_markers_appends_linktype_for_serial(compute_project, manager):
# A serial data_link_type reaches the uBridge mark command as `linktype C_HDLC`
# so the BPF offsets and pcap decode match the WAN encapsulation.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1",
"direction": None, "data_link_type": "DLT_C_HDLC", "enabled": True}}
with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm:
mm.instance.return_value.register = MagicMock()
await node._ubridge_apply_markers("VPCS-10", nio)
sent = [c.args[0] for c in node._ubridge_send.call_args_list]
assert any("linktype C_HDLC" in s for s in sent)
@pytest.mark.asyncio
async def test_apply_markers_omits_linktype_for_ethernet(compute_project, manager):
# Ethernet (DLT_EN10MB) → no linktype keyword; uBridge defaults to EN10MB.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1",
"direction": None, "data_link_type": "DLT_EN10MB", "enabled": True}}
with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm:
mm.instance.return_value.register = MagicMock()
await node._ubridge_apply_markers("VPCS-10", nio)
sent = [c.args[0] for c in node._ubridge_send.call_args_list]
assert not any("linktype" in s for s in sent)
@pytest.mark.asyncio
async def test_delete_marker_capture_removes_pcap_and_entry(compute_project, manager):
# Deleting a marker's capture removes its pcap and forgets the bridge entry.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
markers_dir = compute_project.markers_working_directory()
os.makedirs(markers_dir, exist_ok=True)
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
pcap = os.path.join(markers_dir, f"{node.id}_L1_m.pcap")
open(pcap, "wb").write(b"data")
await node.delete_marker_capture("m", "L1")
assert not os.path.exists(pcap)
assert ("m", "L1") not in node._marker_filter_bridges
@pytest.mark.asyncio
async def test_delete_marker_capture_idempotent_when_missing(compute_project, manager):
# No file on disk → must not raise, and still clears the entry.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
await node.delete_marker_capture("m", "L1")
assert ("m", "L1") not in node._marker_filter_bridges
@pytest.mark.asyncio
async def test_delete_marker_capture_sends_delete_filter(compute_project, manager):
# With uBridge running, removing a marker issues a fine-grained
# delete_packet_filter (not a bridge-wide reset) so sibling pcaps stay open.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._ubridge_hypervisor = MagicMock()
node._ubridge_hypervisor.is_running.return_value = True
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
await node.delete_marker_capture("m", "L1")
node._ubridge_send.assert_any_call("bridge delete_packet_filter VPCS-10 m")
assert ("m", "L1") not in node._marker_filter_bridges
@pytest.mark.asyncio
async def test_delete_marker_capture_drops_from_nio_markers(compute_project, manager):
# The marker spec cached on the port NIO (nio.markers) is what
# _ubridge_apply_markers reads on node start. delete_marker_capture must drop
# it, else deleting a marker while the node is stopped leaves the spec in
# nio.markers and starting the node reinstalls it (empty pcap reappears).
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1",
"direction": None, "enabled": True}}
await node.delete_marker_capture("m", "L1", nio)
assert "m" not in nio.markers
@pytest.mark.asyncio
async def test_rebuild_marker_filter_delete_then_add(compute_project, manager):
# rebuild re-installs a single filter (delete_packet_filter + add) with the
# new params, no bridge reset; enabled=False turns it off after re-add.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._ubridge_hypervisor = MagicMock()
node._ubridge_hypervisor.is_running.return_value = True
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
await node.rebuild_marker_filter("m", "L1", "tcp", tag=7, direction="rx", enabled=False)
cmds = [c.args[0] for c in node._ubridge_send.call_args_list]
assert any("delete_packet_filter VPCS-10 m" in c for c in cmds)
assert any("add_packet_filter VPCS-10 m mark" in c and "tcp" in c for c in cmds)
assert any("enable_packet_filter VPCS-10 m off" in c for c in cmds)
@pytest.mark.asyncio
async def test_apply_markers_skips_already_installed(compute_project, manager):
# Incremental apply: a marker already in _marker_filter_bridges is not
# re-added (uBridge keeps it across reset), so its pcap isn't reopened.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._marker_filter_bridges["m", "L1"] = "VPCS-10" # already installed
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", "direction": None, "enabled": True}}
with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm:
mm.instance.return_value.register = MagicMock()
await node._ubridge_apply_markers("VPCS-10", nio)
cmds = [c.args[0] for c in node._ubridge_send.call_args_list]
assert not any("add_packet_filter" in c for c in cmds) # skipped, not re-added
@pytest.mark.asyncio
async def test_stop_ubridge_clears_marker_bridges(compute_project, manager):
# uBridge stopping drops every marker filter — the map must clear so the next
# apply re-installs them instead of skipping as "already installed".
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
await node._stop_ubridge()
assert node._marker_filter_bridges == {}
View File
Whitespace-only changes.
+191
View File
@@ -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 <http://www.gnu.org/licenses/>.
"""
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
+18 -3
View File
@@ -8,6 +8,8 @@ import uuid
import configparser
import base64
import stat
import resource
import platform
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@@ -35,6 +37,19 @@ sys._called_from_test = True
sys.original_platform = sys.platform
def pytest_configure(config):
"""
Increases the maximum number of open file descriptors before running tests (Unix only).
"""
if platform.system() != "Windows":
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
# Set soft limit to hard limit, or a safe high number like 4096
new_soft = min(4096, hard)
if soft < new_soft:
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
@pytest_asyncio.fixture(loop_scope="class", scope="class")
async def app() -> AsyncGenerator[Any, Any]:
@@ -325,9 +340,9 @@ def on_gns3vm(linux_platform):
"""
with patch("gns3server.utils.interfaces.interfaces", return_value=[
{"name": "eth0", "special": False, "type": "ethernet"},
{"name": "eth1", "special": False, "type": "ethernet"},
{"name": "virbr0", "special": True, "type": "ethernet"}]):
{"name": "eth0", "special": False, "type": "ethernet", "ip_addresses": [], "status": "up", "speed": 1000, "mtu": 1500, "flags": ["up", "broadcast", "running", "multicast"]},
{"name": "eth1", "special": False, "type": "ethernet", "ip_addresses": [], "status": "down", "speed": 0, "mtu": 1500, "flags": ["broadcast"]},
{"name": "virbr0", "special": True, "type": "ethernet", "ip_addresses": [], "status": "up", "speed": 10000, "mtu": 1500, "flags": ["up", "broadcast", "running", "multicast"]}]):
with patch("socket.gethostname", return_value="gns3vm"):
yield
+10 -10
View File
@@ -87,7 +87,7 @@ async def test_compute_httpQuery(compute):
response.status = 200
await compute.post("/projects", {"a": "b"})
await compute.close()
mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", data=b'{"a": "b"}', headers={'content-type': 'application/json'}, auth=None, chunked=None, timeout=120)
mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", headers={'content-type': 'application/json'}, data=b'{"a": "b"}', auth=None, params=None, chunked=None, timeout=120)
assert compute._auth is None
@@ -102,7 +102,7 @@ async def test_compute_httpQueryAuth(compute):
compute.password = SecretStr("toor")
await compute.post("/projects", {"a": "b"})
await compute.close()
mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", data=b'{"a": "b"}', headers={'content-type': 'application/json'}, auth=compute._auth, chunked=None, timeout=120)
mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", headers={'content-type': 'application/json'}, data=b'{"a": "b"}', auth=compute._auth, params=None, chunked=None, timeout=120)
assert compute._auth.login == "root"
assert compute._auth.password == "toor"
@@ -162,7 +162,7 @@ async def test_compute_httpQueryNotConnectedInvalidVersion(compute):
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
with pytest.raises(ControllerError):
await compute.post("/projects", {"a": "b"})
mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, chunked=None, timeout=120)
mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120)
await compute.close()
@@ -176,7 +176,7 @@ async def test_compute_httpQueryNotConnectedNonGNS3Server(compute):
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
with pytest.raises(ControllerError):
await compute.post("/projects", {"a": "b"})
mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, chunked=None, timeout=120)
mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120)
await compute.close()
@@ -190,7 +190,7 @@ async def test_compute_httpQueryNotConnectedNonGNS3Server2(compute):
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
with pytest.raises(ControllerError):
await compute.post("/projects", {"a": "b"})
mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, chunked=None, timeout=120)
mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120)
@pytest.mark.asyncio
@@ -228,7 +228,7 @@ async def test_compute_httpQuery_project(compute):
project = Project(name="Test")
mock_notification.assert_called()
await compute.post("/projects", project)
mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", data=json.dumps(project.asdict()), headers={'content-type': 'application/json'}, auth=None, chunked=None, timeout=120)
mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", headers={'content-type': 'application/json'}, data=json.dumps(project.asdict()), auth=None, params=None, chunked=None, timeout=120)
await compute.close()
# FIXME: https://github.com/aio-libs/aiohttp/issues/2525
@@ -372,7 +372,7 @@ async def test_forward_get(compute):
response.status = 200
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
await compute.forward("GET", "qemu", "images")
mock.assert_called_with("GET", "https://example.com:84/v3/compute/qemu/images", auth=None, data=None, headers={'content-type': 'application/json'}, chunked=None, timeout=None)
mock.assert_called_with("GET", "https://example.com:84/v3/compute/qemu/images", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=None)
await compute.close()
@@ -395,7 +395,7 @@ async def test_forward_post(compute):
response.status = 200
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
await compute.forward("POST", "qemu", "img", data={"id": 42})
mock.assert_called_with("POST", "https://example.com:84/v3/compute/qemu/img", auth=None, data=b'{"id": 42}', headers={'content-type': 'application/json'}, chunked=None, timeout=None)
mock.assert_called_with("POST", "https://example.com:84/v3/compute/qemu/img", headers={'content-type': 'application/json'}, data=b'{"id": 42}', auth=None, params=None, chunked=None, timeout=None)
await compute.close()
@@ -408,7 +408,7 @@ async def test_list_files(project, compute):
response.status = 200
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
assert await compute.list_files(project) == res
mock.assert_any_call("GET", "https://example.com:84/v3/compute/projects/{}/files".format(project.id), auth=None, chunked=None, data=None, headers={'content-type': 'application/json'}, timeout=None)
mock.assert_any_call("GET", "https://example.com:84/v3/compute/projects/{}/files".format(project.id), headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=None)
await compute.close()
@@ -430,7 +430,7 @@ async def test_interfaces(compute):
response.status = 200
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
assert await compute.interfaces() == res
mock.assert_any_call("GET", "https://example.com:84/v3/compute/network/interfaces", auth=None, chunked=None, data=None, headers={'content-type': 'application/json'}, timeout=120)
mock.assert_any_call("GET", "https://example.com:84/v3/compute/network/interfaces", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120)
await compute.close()
+74 -15
View File
@@ -20,6 +20,7 @@ import os
import uuid
import json
import zipfile
import pytest
from pathlib import Path
from tests.utils import asyncio_patch, AsyncioMagicMock
@@ -27,6 +28,7 @@ from unittest.mock import patch, MagicMock
from gns3server.utils.asyncio import aiozipstream
from gns3server.controller.project import Project
from gns3server.controller.controller_error import ControllerError
from gns3server.controller.export_project import export_project
from gns3server.controller.import_project import import_project, _move_files_to_compute
from gns3server.version import __version__
@@ -122,24 +124,49 @@ async def write_file(path, z):
f.write(chunk)
@pytest.mark.asyncio
async def test_import_project_containing_symlink(tmpdir, controller):
@pytest.fixture
def export_project_with_symlink(tmpdir, controller):
async def _export(symlink_target):
project = Project(controller=controller, name="test")
project.dump = MagicMock()
project = Project(controller=controller, name="test")
project.dump = MagicMock()
path = project.path
topology = {
"project_id": str(uuid.uuid4()),
"name": "test",
"auto_open": True,
"auto_start": True,
"topology": {
},
"version": "2.0.0"
}
with open(os.path.join(project.path, "project.gns3"), 'w+') as f:
json.dump(topology, f)
os.makedirs(os.path.join(project.path, "vm1", "dynamips"))
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
os.symlink(symlink_target, symlink_path)
zip_path = str(tmpdir / "project.zip")
with aiozipstream.ZipFile() as z:
with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),):
await export_project(z, project, str(tmpdir), include_images=False)
await write_file(zip_path, z)
return zip_path
return _export
@pytest.mark.asyncio
async def test_import_project_containing_symlink(controller, export_project_with_symlink):
"""
Test importing a project containing a valid symlink (target inside the project directory).
"""
project_id = str(uuid.uuid4())
os.makedirs(os.path.join(path, "vm1", "dynamips"))
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
symlink_target = "/tmp/anywhere"
os.symlink(symlink_target, symlink_path)
zip_path = str(tmpdir / "project.zip")
with aiozipstream.ZipFile() as z:
with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),):
await export_project(z, project, str(tmpdir), include_images=False)
await write_file(zip_path, z)
symlink_target = "../symlink_target"
zip_path = await export_project_with_symlink(symlink_target)
with open(zip_path, "rb") as f:
project = await import_project(controller, project_id, f)
@@ -151,6 +178,38 @@ async def test_import_project_containing_symlink(tmpdir, controller):
assert os.readlink(symlink_path) == symlink_target
@pytest.mark.asyncio
async def test_import_project_containing_absolute_symlink(controller, export_project_with_symlink):
"""
Test importing a project containing an absolute symlink.
This should fail because absolute symlinks are not allowed for security reasons.
"""
project_id = str(uuid.uuid4())
symlink_target = "/tmp/anywhere"
zip_path = await export_project_with_symlink(symlink_target)
with pytest.raises(ControllerError):
with open(zip_path, "rb") as f:
await import_project(controller, project_id, f)
@pytest.mark.asyncio
async def test_import_project_containing_escaping_symlink(controller, export_project_with_symlink):
"""
Test importing a project containing a symlink that escapes the project directory.
This should fail because symlinks that escape the project directory are not allowed for security reasons.
"""
project_id = str(uuid.uuid4())
symlink_target = "../../../../symlink_target"
zip_path = await export_project_with_symlink(symlink_target)
with pytest.raises(ControllerError):
with open(zip_path, "rb") as f:
await import_project(controller, project_id, f)
@pytest.mark.asyncio
async def test_import_upgrade(tmpdir, controller):
"""
+2
View File
@@ -221,6 +221,7 @@ async def test_json(project, compute):
}
],
"filters": {},
"markers": {},
"show_filters_icon": True,
"link_style": {},
"suspend": False,
@@ -255,6 +256,7 @@ async def test_json(project, compute):
],
"link_style": {},
"filters": {},
"markers": {},
"show_filters_icon": True,
"suspend": False
}
+859
View File
@@ -0,0 +1,859 @@
#!/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 <http://www.gnu.org/licenses/>.
"""
Controller-layer tests for the traffic-insight marker feature:
* UDPLink.start_marker / stop_marker / update_marker — storage, guards,
inheritance bypass, and partial-update preservation of render hints.
* Project.create/update/delete_marker_definition — fan-out, sync, cleanup.
* Project.apply_defs_to_new_link and the markers aggregation property.
"""
import uuid
import pytest
from unittest.mock import MagicMock, patch
from contextlib import ExitStack
from tests.utils import AsyncioMagicMock
from gns3server.controller.udp_link import UDPLink
from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.controller.ports.serial_port import SerialPort
from gns3server.controller.node import Node
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
def _valid_bpf():
"""Bypass tcpdump-based BPF validation so tests don't depend on tcpdump.
Patches both namespaces that import ``validate_bpf_syntax`` by name: the
per-link ``udp_link`` (private marker create/update) and the project layer
(definition create/update/load), which is now the single validation point
for inherited copies.
"""
stack = ExitStack()
for target in (
"gns3server.controller.udp_link.validate_bpf_syntax",
"gns3server.controller.project.validate_bpf_syntax",
):
stack.enter_context(patch(target, return_value={"valid": True, "error": None}))
return stack
async def _make_link(project, port_cls=EthernetPort):
"""Build a created UDPLink between two VPCS nodes on a mocked compute.
``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link
(the link's link_type follows the port).
"""
compute = MagicMock()
compute.id = "local"
compute.host = "example.com"
node1 = Node(project, compute, "n1", node_type="vpcs")
node1._ports = [port_cls("E0", 0, 0, 0)]
node2 = Node(project, compute, "n2", node_type="vpcs")
node2._ports = [port_cls("E0", 0, 0, 1)]
async def subnet(_other):
return ("192.168.1.1", "192.168.1.2")
async def udp_cb(path, data={}, **kwargs):
response = MagicMock()
response.json = {"udp_port": 1234}
return response
compute.get_ip_on_same_subnet.side_effect = subnet
compute.post.side_effect = udp_cb
# start_marker / update_marker push via node.put -> compute.put; make it awaitable.
compute.put = AsyncioMagicMock()
compute.delete = AsyncioMagicMock()
link = UDPLink(project)
await link.add_node(node1, 0, 0)
await link.add_node(node2, 0, 1)
# Register with the project so definition fan-out (which iterates _links) reaches it.
project._links[link.id] = link
return link
# ---------------------------------------------------------------------------
# UDPLink.start_marker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_marker_stores_entry(project):
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("icmp", "icmp", tag=7, color="#ff5722", highlight_duration=800)
entry = link.markers["icmp"]
assert entry["bpf"] == "icmp"
assert entry["tag"] == 7
assert entry["color"] == "#ff5722"
assert entry["highlight_duration"] == 800
assert entry["enabled"] is True
assert entry["capture_node_id"] in {n["node"].id for n in link._nodes}
assert "inherited_from" not in entry
@pytest.mark.asyncio
async def test_start_marker_stores_data_link_type(project):
# data_link_type is stored on the marker and flows into the per-node spec
# (the compute-side source for the uBridge `linktype` keyword). Serial-only;
# defaults to DLT_EN10MB when omitted (Ethernet → linktype omitted).
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("ospf", "ospf", data_link_type="DLT_C_HDLC")
assert link.markers["ospf"]["data_link_type"] == "DLT_C_HDLC"
capture_id = link.markers["ospf"]["capture_node_id"]
capture_side = next(n for n in link._nodes if n["node"].id == capture_id)
assert link._markers_for_node(capture_side["node"])["ospf"]["data_link_type"] == "DLT_C_HDLC"
# Default when omitted = Ethernet.
with _valid_bpf():
link2 = await _make_link(project)
await link2.start_marker("icmp", "icmp")
cid = link2.markers["icmp"]["capture_node_id"]
cside = next(n for n in link2._nodes if n["node"].id == cid)
assert link2._markers_for_node(cside["node"])["icmp"]["data_link_type"] == "DLT_EN10MB"
@pytest.mark.asyncio
async def test_start_marker_pins_capture_node(project):
# Auto-pick would choose node1 (first endpoint); pin to node2 explicitly.
with _valid_bpf():
link = await _make_link(project)
chosen = link._nodes[1]["node"].id
auto = link._nodes[0]["node"].id
assert chosen != auto # sanity: the pin must actually mean something
await link.start_marker("icmp", "icmp", capture_node_id=chosen)
assert link.markers["icmp"]["capture_node_id"] == chosen
@pytest.mark.asyncio
async def test_start_marker_rejects_non_endpoint_capture_node(project):
with _valid_bpf():
link = await _make_link(project)
with pytest.raises(ControllerNotFoundError):
await link.start_marker("icmp", "icmp", capture_node_id="11111111-2222-3333-4444-555555555555")
@pytest.mark.asyncio
async def test_start_marker_capture_node_ignored_for_inherited(project):
# Definitions are link-agnostic: an inherited marker must auto-pick even
# if a capture_node_id leaks through, never trusting the caller's pin.
with _valid_bpf():
link = await _make_link(project)
leaked = link._nodes[1]["node"].id
await link.start_marker("m", "icmp", capture_node_id=leaked, inherited_from="arp")
assert link.markers["m"]["capture_node_id"] == link._nodes[0]["node"].id
@pytest.mark.asyncio
async def test_start_marker_rejects_duplicate(project):
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("icmp", "icmp")
with pytest.raises(ControllerError):
await link.start_marker("icmp", "tcp")
@pytest.mark.asyncio
async def test_start_marker_rejects_invalid_bpf(project):
link = await _make_link(project)
with patch("gns3server.controller.udp_link.validate_bpf_syntax",
return_value={"valid": False, "error": "bad expression"}):
with pytest.raises(ControllerError):
await link.start_marker("bad", "not a real bpf")
# ---------------------------------------------------------------------------
# UDPLink.stop_marker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_marker_removes(project):
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("icmp", "icmp")
assert "icmp" in link.markers
await link.stop_marker("icmp")
assert "icmp" not in link.markers
@pytest.mark.asyncio
async def test_stop_marker_rejects_inherited(project):
with _valid_bpf():
link = await _make_link(project)
await link.inherit_marker("arp", {"bpf": "arp"})
# Per-link delete of an inherited marker must be refused (use the def API).
with pytest.raises(ControllerError):
await link.stop_marker("global-arp")
assert "global-arp" in link.markers # still present
@pytest.mark.asyncio
async def test_stop_marker_inherited_bypass(project):
"""The def-delete path passes inherited=True to remove inherited copies."""
with _valid_bpf():
link = await _make_link(project)
await link.inherit_marker("arp", {"bpf": "arp"})
await link.stop_marker("global-arp", inherited=True)
assert "global-arp" not in link.markers
@pytest.mark.asyncio
async def test_stop_marker_unknown_raises(project):
link = await _make_link(project)
with pytest.raises(ControllerNotFoundError):
await link.stop_marker("nope")
# ---------------------------------------------------------------------------
# UDPLink.update_marker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_marker_preserves_render_hints(project):
"""A partial update (bpf only) must not reset color/highlight_duration/tag."""
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("m", "icmp", tag=1, color="#ff5722", highlight_duration=800)
await link.update_marker("m", bpf="tcp port 80")
entry = link.markers["m"]
assert entry["bpf"] == "tcp port 80"
assert entry["color"] == "#ff5722" # preserved
assert entry["highlight_duration"] == 800 # preserved
assert entry["tag"] == 1 # preserved
@pytest.mark.asyncio
async def test_update_marker_changes_fields(project):
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("m", "icmp", highlight_duration=800)
await link.update_marker("m", highlight_duration=1500, enabled=False, tag=9)
entry = link.markers["m"]
assert entry["highlight_duration"] == 1500
assert entry["enabled"] is False
assert entry["tag"] == 9
@pytest.mark.asyncio
async def test_update_marker_rejects_inherited(project):
with _valid_bpf():
link = await _make_link(project)
await link.inherit_marker("arp", {"bpf": "arp"})
with pytest.raises(ControllerError):
await link.update_marker("global-arp", bpf="tcp")
@pytest.mark.asyncio
async def test_update_marker_inherited_bypass(project):
"""The def-sync path passes inherited=True to update inherited copies."""
with _valid_bpf():
link = await _make_link(project)
await link.inherit_marker("arp", {"bpf": "arp", "highlight_duration": 500})
await link.update_marker("global-arp", highlight_duration=1200, inherited=True)
assert link.markers["global-arp"]["highlight_duration"] == 1200
# ---------------------------------------------------------------------------
# Link.inherit_marker + persistence
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inherit_marker_creates_global_copy(project):
with _valid_bpf():
link = await _make_link(project)
await link.inherit_marker("arp", {"bpf": "arp", "tag": 3, "color": "#111", "highlight_duration": 400})
entry = link.markers["global-arp"]
assert entry["bpf"] == "arp"
assert entry["tag"] == 3
assert entry["color"] == "#111"
assert entry["highlight_duration"] == 400
assert entry["inherited_from"] == "arp"
@pytest.mark.asyncio
async def test_persist_markers_excludes_inherited(project):
"""Inherited markers are re-created from definitions on load, never persisted."""
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("private", "icmp", highlight_duration=800)
await link.inherit_marker("arp", {"bpf": "arp"})
persisted = link._persist_markers()
assert set(persisted.keys()) == {"private"}
assert "global-arp" not in persisted
@pytest.mark.asyncio
async def test_load_marker_preserves_direction_and_highlight_duration(project):
"""Regression: a private marker's direction + highlight_duration must survive
a close/reopen round-trip through the topology file.
_create_link_from_topology_data previously restored only bpf/tag/enabled/
color/capture_node_id, silently dropping direction (→ reverted to "both")
and highlight_duration.
"""
compute = MagicMock()
compute.id = "local"
compute.host = "example.com"
async def subnet(_other):
return ("192.168.1.1", "192.168.1.2")
async def udp_cb(path, data={}, **kwargs):
response = MagicMock()
response.json = {"udp_port": 1234}
return response
compute.get_ip_on_same_subnet.side_effect = subnet
compute.post.side_effect = udp_cb
# Attaching the 2nd node auto-creates the link (NIO round-trips).
compute.put = AsyncioMagicMock()
compute.delete = AsyncioMagicMock()
node1 = Node(project, compute, "n1", node_type="vpcs")
node1._ports = [EthernetPort("E0", 0, 0, 0)]
node2 = Node(project, compute, "n2", node_type="vpcs")
node2._ports = [EthernetPort("E0", 0, 0, 1)]
# _create_link_from_topology_data resolves nodes via project.get_node().
project._nodes[node1.id] = node1
project._nodes[node2.id] = node2
capture_node_id = str(uuid.uuid4())
link_id = str(uuid.uuid4())
link_data = {
"link_id": link_id,
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 0, "label": "a"},
{"node_id": node2.id, "adapter_number": 0, "port_number": 1, "label": "b"},
],
"markers": {
"icmp": {
"bpf": "icmp",
"direction": "rx",
"highlight_duration": 800,
"tag": 7,
"color": "#ff5722",
"enabled": True,
"capture_node_id": capture_node_id,
}
},
}
with patch(
"gns3server.controller.project.validate_bpf_syntax",
return_value={"valid": True, "error": None},
):
await project._create_link_from_topology_data(link_data)
# The link survives (2 attached nodes); pull it back from the project.
link = project._links[link_id]
entry = link._markers["icmp"]
assert entry["direction"] == "rx" # dropped before the fix
assert entry["highlight_duration"] == 800 # dropped before the fix
assert entry["tag"] == 7
assert entry["color"] == "#ff5722"
assert entry["capture_node_id"] == capture_node_id
assert entry["enabled"] is True
@pytest.mark.asyncio
async def test_asdict_markers_runtime_vs_dump(project):
"""Runtime asdict exposes all markers; topology dump drops inherited ones."""
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("private", "icmp")
await link.inherit_marker("arp", {"bpf": "arp"})
runtime = link.asdict()
assert set(runtime["markers"].keys()) == {"private", "global-arp"}
dumped = link.asdict(topology_dump=True)
assert set(dumped["markers"].keys()) == {"private"}
# ---------------------------------------------------------------------------
# Project-level marker definitions
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_marker_definition_fans_out(project):
with _valid_bpf():
link1 = await _make_link(project)
link2 = await _make_link(project)
await project.create_marker_definition("arp", "arp", tag=5, highlight_duration=1200)
for link in (link1, link2):
entry = link.markers["global-arp"]
assert entry["inherited_from"] == "arp"
assert entry["bpf"] == "arp"
assert entry["highlight_duration"] == 1200
assert project.marker_definitions["arp"]["highlight_duration"] == 1200
@pytest.mark.asyncio
async def test_definition_fans_out_over_many_links(project):
# The fan-out is concurrent (bounded) — a large topology must not serialize
# N compute round-trips — but behaviorally every link still receives the
# marker and per-link failures stay isolated.
with _valid_bpf():
links = [await _make_link(project) for _ in range(20)]
await project.create_marker_definition("arp", "arp", highlight_duration=700)
for link in links:
assert link.markers["global-arp"]["highlight_duration"] == 700
assert link.markers["global-arp"]["inherited_from"] == "arp"
assert project.marker_definitions["arp"]["highlight_duration"] == 700
@pytest.mark.asyncio
async def test_update_marker_definition_syncs(project):
with _valid_bpf():
link1 = await _make_link(project)
link2 = await _make_link(project)
await project.create_marker_definition("arp", "arp", highlight_duration=500)
await project.update_marker_definition("arp", highlight_duration=1500, bpf="arp or rarp")
for link in (link1, link2):
assert link.markers["global-arp"]["highlight_duration"] == 1500
assert link.markers["global-arp"]["bpf"] == "arp or rarp"
assert project.marker_definitions["arp"]["highlight_duration"] == 1500
@pytest.mark.asyncio
async def test_definition_serial_dlt_fans_out_to_serial_link(project):
# A definition with a serial data_link_type covers serial links with that
# encapsulation AND ethernet links with EN10MB (one definition, mixed topo).
with _valid_bpf():
serial_link = await _make_link(project, SerialPort)
eth_link = await _make_link(project)
await project.create_marker_definition("ospf", "ospf", data_link_type="DLT_C_HDLC")
assert serial_link._link_type == "serial"
assert serial_link.markers["global-ospf"]["data_link_type"] == "DLT_C_HDLC"
assert eth_link.markers["global-ospf"]["data_link_type"] == "DLT_EN10MB"
@pytest.mark.asyncio
async def test_definition_default_skips_serial_link(project):
# Default (EN10MB) definition is Ethernet-only: serial links are skipped
# (an EN10MB pcap on a serial link would be undecodable).
with _valid_bpf():
serial_link = await _make_link(project, SerialPort)
eth_link = await _make_link(project)
await project.create_marker_definition("arp", "arp")
assert "global-arp" not in serial_link.markers
assert "global-arp" in eth_link.markers
@pytest.mark.asyncio
async def test_update_definition_data_link_type_refans_out(project):
# Changing data_link_type re-evaluates which links host the marker: a serial
# link skipped under the default gains the marker once a WAN DLT is chosen.
with _valid_bpf():
serial_link = await _make_link(project, SerialPort)
await project.create_marker_definition("ospf", "ospf")
assert "global-ospf" not in serial_link.markers # default → serial skipped
await project.update_marker_definition("ospf", data_link_type="DLT_PPP_SERIAL")
assert serial_link.markers["global-ospf"]["data_link_type"] == "DLT_PPP_SERIAL"
@pytest.mark.asyncio
async def test_delete_marker_definition_clears(project):
"""Regression: deleting a def must remove inherited copies from every link."""
with _valid_bpf():
link1 = await _make_link(project)
link2 = await _make_link(project)
await project.create_marker_definition("arp", "arp")
assert "global-arp" in link1.markers
await project.delete_marker_definition("arp")
assert "global-arp" not in link1.markers
assert "global-arp" not in link2.markers
assert "arp" not in project.marker_definitions
@pytest.mark.asyncio
async def test_apply_defs_to_new_link(project):
"""A link created after a definition exists inherits it automatically."""
with _valid_bpf():
await project.create_marker_definition("arp", "arp")
new_link = await _make_link(project)
assert "global-arp" in new_link.markers
assert new_link.markers["global-arp"]["inherited_from"] == "arp"
@pytest.mark.asyncio
async def test_create_marker_definition_validates_bpf_once(project):
# A definition validates its BPF once (project layer); the inherited fan-out
# to every link must NOT re-validate — no tcpdump subprocess per link.
with patch("gns3server.controller.project.validate_bpf_syntax",
return_value={"valid": True, "error": None}) as proj_val, \
patch("gns3server.controller.udp_link.validate_bpf_syntax",
return_value={"valid": True, "error": None}) as link_val:
await _make_link(project)
await _make_link(project)
await project.create_marker_definition("arp", "arp")
assert proj_val.call_count == 1 # validated once at the def layer
assert link_val.call_count == 0 # fan-out skipped per-link validation
@pytest.mark.asyncio
async def test_create_marker_definition_rejects_invalid_bpf(project):
with patch("gns3server.controller.project.validate_bpf_syntax",
return_value={"valid": False, "error": "syntax error"}):
with pytest.raises(ControllerError):
await project.create_marker_definition("arp", "not a real bpf")
assert "arp" not in project.marker_definitions
@pytest.mark.asyncio
async def test_update_marker_definition_skips_per_link_validation(project):
# Updating a def's BPF validates once more (project); the per-link sync
# (update_marker with inherited=True) must NOT re-validate.
with patch("gns3server.controller.project.validate_bpf_syntax",
return_value={"valid": True, "error": None}) as proj_val, \
patch("gns3server.controller.udp_link.validate_bpf_syntax",
return_value={"valid": True, "error": None}) as link_val:
await _make_link(project)
await _make_link(project)
await project.create_marker_definition("arp", "arp")
await project.update_marker_definition("arp", bpf="arp or rarp")
assert proj_val.call_count == 2 # once on create, once on update
assert link_val.call_count == 0 # sync skipped per-link validation
@pytest.mark.asyncio
async def test_start_marker_skips_validation_for_inherited(project):
# An inherited marker rides an already-validated definition BPF, so
# start_marker must not call validate_bpf_syntax.
with patch("gns3server.controller.udp_link.validate_bpf_syntax",
return_value={"valid": True, "error": None}) as link_val:
link = await _make_link(project)
await link.inherit_marker("arp", {"bpf": "arp"})
assert link_val.call_count == 0
assert link.markers["global-arp"]["bpf"] == "arp"
@pytest.mark.asyncio
async def test_start_marker_validates_for_private(project):
# A private (non-inherited) marker still validates inline.
with patch("gns3server.controller.udp_link.validate_bpf_syntax",
return_value={"valid": True, "error": None}) as link_val:
link = await _make_link(project)
await link.start_marker("icmp", "icmp")
assert link_val.call_count == 1
@pytest.mark.asyncio
async def test_markers_aggregation(project):
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("icmp", "icmp", highlight_duration=800)
agg = project.markers
key = f"{link.id}/icmp"
assert key in agg
assert agg[key]["bpf"] == "icmp"
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.
# New defs can't carry tx/rx, but a legacy def loaded from an old topology
# could — so inject one and confirm a clear syncs every copy.
with _valid_bpf():
link1 = await _make_link(project)
link2 = await _make_link(project)
await project.create_marker_definition("arp", "arp")
# Simulate a legacy directional value persisted before the restriction.
project._marker_definitions["arp"]["direction"] = "tx"
await link1.update_marker("global-arp", direction="tx", inherited=True)
await link2.update_marker("global-arp", direction="tx", inherited=True)
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)
# ---------------------------------------------------------------------------
# Part A/B: enabled reaches uBridge + instant per-filter toggle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_markers_for_node_keeps_disabled_and_carries_enabled(project):
# Part A: a disabled marker is NOT dropped from the NIO payload (so uBridge
# can install it then turn it off) and the spec carries `enabled`.
with _valid_bpf():
link = await _make_link(project)
node = link._nodes[0]["node"]
await link.start_marker("m", "icmp")
await link.update_marker("m", enabled=False)
spec = link._markers_for_node(node).get("m")
assert spec is not None
assert spec["enabled"] is False
@pytest.mark.asyncio
async def test_update_marker_enabled_only_hits_toggle_route(project):
# Part B: an enabled-only change routes to the per-marker toggle endpoint,
# not a full NIO reset+reapply.
with _valid_bpf():
link = await _make_link(project)
node = link._nodes[0]["node"]
await link.start_marker("m", "icmp")
compute = node.compute
compute.put.reset_mock()
await link.update_marker("m", enabled=False)
paths = [c.args[0] for c in compute.put.call_args_list]
assert any("/markers/m" in p for p in paths)
assert not any(p.endswith("/nio") for p in paths)
@pytest.mark.asyncio
async def test_update_marker_with_bpf_rebuilds_single_filter(project):
# A bpf change rebuilds just this marker's filter (delete + add), NOT a full
# NIO reapply, so sibling markers' pcaps stay open.
with _valid_bpf():
link = await _make_link(project)
node = link._nodes[0]["node"]
await link.start_marker("m", "icmp")
compute = node.compute
compute.put.reset_mock()
await link.update_marker("m", bpf="tcp")
paths = [c.args[0] for c in compute.put.call_args_list]
assert any(p.endswith("/markers/m/rebuild") for p in paths)
assert not any(p.endswith("/nio") for p in paths) # no full NIO reapply
@pytest.mark.asyncio
async def test_update_marker_ui_only_does_not_push(project):
# color/highlight_duration are UI-only — stored, never pushed to uBridge.
with _valid_bpf():
link = await _make_link(project)
node = link._nodes[0]["node"]
await link.start_marker("m", "icmp")
compute = node.compute
compute.put.reset_mock()
await link.update_marker("m", color="#ffffff", highlight_duration=1500)
assert compute.put.call_args_list == [] # nothing pushed to uBridge
assert link.markers["m"]["color"] == "#ffffff"
assert link.markers["m"]["highlight_duration"] == 1500
# ---------------------------------------------------------------------------
# Per-definition pause/resume (toggle every inherited global-{name} copy)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_marker_definition_toggles_copies_off(project):
with _valid_bpf():
link1 = await _make_link(project)
link2 = await _make_link(project)
await project.create_marker_definition("arp", "arp")
assert link1.markers["global-arp"]["enabled"] is True
assert link2.markers["global-arp"]["enabled"] is True
await project.pause_marker_definition("arp")
assert project.marker_definitions["arp"]["paused"] is True
assert link1.markers["global-arp"]["enabled"] is False
assert link2.markers["global-arp"]["enabled"] is False
@pytest.mark.asyncio
async def test_resume_marker_definition_toggles_copies_on(project):
with _valid_bpf():
link = await _make_link(project)
await project.create_marker_definition("arp", "arp")
await project.pause_marker_definition("arp")
assert link.markers["global-arp"]["enabled"] is False
await project.resume_marker_definition("arp")
assert project.marker_definitions["arp"]["paused"] is False
assert link.markers["global-arp"]["enabled"] is True
@pytest.mark.asyncio
async def test_paused_definition_inherited_as_disabled(project):
# A link created after the definition was paused inherits it already off.
with _valid_bpf():
await project.create_marker_definition("arp", "arp")
await project.pause_marker_definition("arp")
new_link = await _make_link(project)
assert new_link.markers["global-arp"]["enabled"] is False
# ---------------------------------------------------------------------------
# Marker definition direction (tx/rx rejected — it is capture-node-relative)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_marker_definition_rejects_directional(project):
# tx/rx is relative to the auto-selected capture node → rejected at the def level.
with pytest.raises(ControllerError):
await project.create_marker_definition("arp", "arp", direction="tx")
with pytest.raises(ControllerError):
await project.create_marker_definition("arp", "arp", direction="rx")
assert "arp" not in project.marker_definitions # nothing created
@pytest.mark.asyncio
async def test_create_marker_definition_allows_both(project):
await project.create_marker_definition("arp", "arp") # default both
await project.create_marker_definition("icmp", "icmp", direction=None)
assert project.marker_definitions["arp"]["direction"] is None
assert project.marker_definitions["icmp"]["direction"] is None
@pytest.mark.asyncio
async def test_update_marker_definition_rejects_directional(project):
await project.create_marker_definition("arp", "arp") # both
with pytest.raises(ControllerError):
await project.update_marker_definition("arp", direction="tx")
# omitted direction and explicit clear-to-both are both fine
await project.update_marker_definition("arp", color="#ffffff")
await project.update_marker_definition("arp", direction=None)
assert project.marker_definitions["arp"]["direction"] is None
@pytest.mark.asyncio
async def test_stop_marker_deletes_capture_pcap(project):
# Removing a marker asks the capture node's compute to delete its pcap, so
# the file is cleaned up even with the node stopped (the NIO reapply path
# only runs while uBridge is up).
with _valid_bpf():
link = await _make_link(project)
capture = link._nodes[0]["node"]
await link.start_marker("icmp", "icmp", capture_node_id=capture.id)
capture.delete = AsyncioMagicMock()
await link.stop_marker("icmp")
capture.delete.assert_called_once_with(
"/adapters/0/ports/0/markers/icmp", params={"link_id": link.id}
)
+15
View File
@@ -82,6 +82,7 @@ async def test_json():
"drawing_grid_size": 25,
"supplier": None,
"variables": None,
"marker_definitions": {},
"created_by": None
}
@@ -698,6 +699,20 @@ async def test_delete(project):
assert not os.path.exists(project.path)
@pytest.mark.asyncio
async def test_delete_does_not_start_nodes(project):
"""
Deleting a project must not start its nodes, even when auto_start is enabled.
"""
project.auto_start = True
project.dump()
await project.close()
project.start_all = AsyncioMagicMock()
await project.delete()
assert not project.start_all.called
@pytest.mark.asyncio
async def test_dump(projects_dir):
+1
View File
@@ -60,6 +60,7 @@ async def test_project_to_topology_empty(tmpdir):
"supplier": None,
"variables": None,
"version": __version__,
"marker_definitions": {},
"created_by": None
}
+9
View File
@@ -78,6 +78,7 @@ async def test_create(project):
"rport": 2048,
"type": "nio_udp",
"filters": {"delay": [10, 0]},
"markers": {},
"suspend": False,
}, timeout=120)
@@ -87,6 +88,7 @@ async def test_create(project):
"rport": 1024,
"type": "nio_udp",
"filters": {},
"markers": {},
"suspend": False,
}, timeout=120)
@@ -146,6 +148,7 @@ async def test_create_one_side_failure(project):
"rport": 2048,
"type": "nio_udp",
"filters": {},
"markers": {},
"suspend": False,
}, timeout=120)
@@ -155,6 +158,7 @@ async def test_create_one_side_failure(project):
"rport": 1024,
"type": "nio_udp",
"filters": {},
"markers": {},
"suspend": False,
}, timeout=120)
# The link creation has failed we rollback the nio
@@ -345,6 +349,7 @@ async def test_update(project):
"rport": 2048,
"type": "nio_udp",
"suspend": False,
"markers": {},
"filters": {"delay": [10, 0]}
}, timeout=120)
@@ -354,6 +359,7 @@ async def test_update(project):
"rport": 1024,
"type": "nio_udp",
"suspend": False,
"markers": {},
"filters": {}
}, timeout=120)
@@ -365,6 +371,7 @@ async def test_update(project):
"rport": 2048,
"type": "nio_udp",
"suspend": False,
"markers": {},
"filters": {
"frequency_drop": [5],
"bpf": ["icmp[icmptype] == 8"]
@@ -425,6 +432,7 @@ async def test_update_suspend(project):
"rport": 2048,
"type": "nio_udp",
"filters": {"frequency_drop": [-1]},
"markers": {},
"suspend": True
}, timeout=120)
@@ -434,5 +442,6 @@ async def test_update_suspend(project):
"rport": 1024,
"type": "nio_udp",
"filters": {},
"markers": {},
"suspend": True
}, timeout=120)
+116
View File
@@ -16,10 +16,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import socket
import collections
from unittest.mock import patch
import psutil
from gns3server.utils.interfaces import interfaces, is_interface_up, has_netmask
# psutil returns snicaddr namedtuples; mirror that shape for the mocks below.
snicaddr = collections.namedtuple("snicaddr", ["family", "address", "netmask", "broadcast", "ptp"])
# psutil.net_if_stats() returns snicstats namedtuples.
snicstats = collections.namedtuple("snicstats", ["isup", "duplex", "speed", "mtu", "flags"])
def test_interfaces():
# This test should pass on all platforms without crash
@@ -35,6 +46,111 @@ def test_interfaces():
assert "mac_address" in interface
assert "type" in interface
assert "netmask" in interface
assert "ip_addresses" in interface
assert "status" in interface
assert "speed" in interface
assert "mtu" in interface
assert "flags" in interface
def _fake_net_if_addrs():
# A representative set of host interfaces exercising the address-collection logic.
return {
# several IPv4 and several IPv6 addresses on the same interface
"eth0": [
snicaddr(socket.AF_INET, "192.168.1.5", "255.255.255.0", "192.168.1.255", None),
snicaddr(socket.AF_INET, "10.0.0.5", "255.255.255.0", "10.0.0.255", None),
snicaddr(socket.AF_INET6, "fe80::1", "ffff:ffff:ffff:ffff::", None, None),
snicaddr(socket.AF_INET6, "2001:db8::1", "ffff:ffff:ffff:ffff::", None, None),
snicaddr(psutil.AF_LINK, "00:11:22:33:44:55", None, None, None),
],
# no IP address at all (only a MAC)
"eth1": [
snicaddr(psutil.AF_LINK, "00:11:22:33:44:66", None, None, None),
],
# IPv6 only
"eth2": [
snicaddr(socket.AF_INET6, "2001:db8::2", "ffff:ffff:ffff:ffff::", None, None),
snicaddr(psutil.AF_LINK, "00:11:22:33:44:77", None, None, None),
],
# present in net_if_addrs but absent from net_if_stats (exercises the fallback)
"eth3": [
snicaddr(psutil.AF_LINK, "00:11:22:33:44:88", None, None, None),
],
}
def _fake_net_if_stats():
# flags are returned as a comma-separated string by psutil >= 6.0
return {
"eth0": snicstats(True, 0, 1000, 1500, "up,broadcast,running,multicast"),
"eth1": snicstats(False, 0, 0, 1500, "broadcast"),
"eth2": snicstats(True, 0, 0, 1500, "up,running"),
}
@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats)
@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs)
def test_interfaces_collects_all_ip_addresses(config):
result = {iface["name"]: iface for iface in interfaces()}
eth0 = result["eth0"]
# every IPv4 and IPv6 address is reported, preserving order
assert eth0["ip_addresses"] == [
{"family": "ipv4", "address": "192.168.1.5", "netmask": "255.255.255.0"},
{"family": "ipv4", "address": "10.0.0.5", "netmask": "255.255.255.0"},
{"family": "ipv6", "address": "fe80::1", "netmask": "ffff:ffff:ffff:ffff::"},
{"family": "ipv6", "address": "2001:db8::1", "netmask": "ffff:ffff:ffff:ffff::"},
]
# legacy single-IPv4 fields are kept for backward compatibility
assert eth0["ip_address"] == "10.0.0.5"
assert eth0["netmask"] == "255.255.255.0"
# link attributes and up status come from net_if_stats (flags split into a list)
assert eth0["status"] == "up"
assert eth0["speed"] == 1000
assert eth0["mtu"] == 1500
assert eth0["flags"] == ["up", "broadcast", "running", "multicast"]
@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats)
@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs)
def test_interfaces_with_no_address(config):
result = {iface["name"]: iface for iface in interfaces()}
eth1 = result["eth1"]
assert eth1["ip_addresses"] == []
assert eth1["ip_address"] == ""
assert eth1["netmask"] == ""
assert eth1["status"] == "down"
assert eth1["flags"] == ["broadcast"]
@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats)
@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs)
def test_interfaces_with_ipv6_only(config):
result = {iface["name"]: iface for iface in interfaces()}
eth2 = result["eth2"]
assert eth2["ip_addresses"] == [
{"family": "ipv6", "address": "2001:db8::2", "netmask": "ffff:ffff:ffff:ffff::"},
]
assert eth2["ip_address"] == ""
assert eth2["status"] == "up"
assert eth2["mtu"] == 1500
@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats)
@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs)
def test_interfaces_without_stats_entry(config):
# an interface present in net_if_addrs but missing from net_if_stats falls
# back to neutral defaults instead of crashing
result = {iface["name"]: iface for iface in interfaces()}
eth3 = result["eth3"]
assert eth3["status"] == "down"
assert eth3["speed"] == 0
assert eth3["mtu"] == 0
assert eth3["flags"] == []
def test_has_netmask(config):