From 213738f0a58a68260b1a40ed25a9b98dff245d7c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 00:42:58 +0800 Subject: [PATCH] test(marker): add controller + API route tests for traffic-insight 19 controller-layer tests covering start/stop/update_marker (storage, inheritance guards + bypasses, partial-update preservation of render hints), project-def CRUD (fan-out, sync, delete-cleanup regression), apply_defs_to_new_link, persist_markers/asdict, and aggregation. 15 API-route tests covering per-link create/update/delete (201/200/204), global-prefix rejection on create (409 regression), bad-format rejection (422), PUT/DELETE-on-inherited guard (409 regression), project-def CRUD endpoints, and the aggregation view. All 34 tests pass when run as part of the full suite. --- tests/api/routes/controller/test_markers.py | 256 +++++++++++++++ tests/controller/test_marker.py | 346 ++++++++++++++++++++ 2 files changed, 602 insertions(+) create mode 100644 tests/api/routes/controller/test_markers.py create mode 100644 tests/controller/test_marker.py diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py new file mode 100644 index 000000000..d9fc27b3b --- /dev/null +++ b/tests/api/routes/controller/test_markers.py @@ -0,0 +1,256 @@ +# -*- 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-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_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" diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py new file mode 100644 index 000000000..7dc0ebe13 --- /dev/null +++ b/tests/controller/test_marker.py @@ -0,0 +1,346 @@ +#!/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 . + +""" +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 pytest +from unittest.mock import MagicMock, patch + +from tests.utils import AsyncioMagicMock + +from gns3server.controller.udp_link import UDPLink +from gns3server.controller.ports.ethernet_port import EthernetPort +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.""" + return patch( + "gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": True, "error": None}, + ) + + +async def _make_link(project): + """Build a created UDPLink between two VPCS nodes on a mocked compute.""" + + compute = MagicMock() + compute.id = "local" + compute.host = "example.com" + + 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)] + + 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_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_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_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_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_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"]