mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 14:01:15 +02:00
Merge branch '3.1' into base-configs-3.0
This commit is contained in:
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}")
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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']}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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 == {}
|
||||
Whitespace-only changes.
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ async def test_project_to_topology_empty(tmpdir):
|
||||
"supplier": None,
|
||||
"variables": None,
|
||||
"version": __version__,
|
||||
"marker_definitions": {},
|
||||
"created_by": None
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user