gns3-server/tests/agent/test_gns3_client.py
YueGuobin 629bb9194f
refactor: sink shared REST handlers into gns3_client, drop gns3fy wrappers
- Slim custom_gns3fy.py to connector-only Gns3Connector and rename to
  connector.py; delete the unused Node/Link/Project dataclasses and
  endpoint wrapper methods (~3000 lines)
- Move the MCP node/link handler implementations into
  gns3_client/api_handlers.py as the shared REST client layer consumed
  by both the MCP service and copilot tools; add available_filters
  handler (exposed as link_available_filters MCP tool) and
  build_gns3_ctx() for copilot callers
- Rewrite the tools_v2 node/link tools on top of the handlers: batch
  lifecycle actions now run in parallel, node creation is a single
  POST, project-wide status reads replace per-node GETs
- Port Project.nodes_inventory/links_summary aggregation into
  project_inventory.py (output shape preserved) and rewrite the
  topology reader / project info tools on it, dropping the unused
  stats/snapshots/drawings calls
- Delete the dead mcp/nodes.py and mcp/links.py (NODE_TOOLS/LINK_TOOLS
  had no consumers; __init__ imports handlers from api_handlers)
- Retarget mcp handler tests to patch api_handlers._get_connector and
  replace test_custom_gns3fy.py with inventory contract tests
2026-08-25 09:15:32 +08:00

238 lines
8.6 KiB
Python

#!/usr/bin/env python
#
# 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/>.
"""
Tests for the shared GNS3 REST client layer (gns3_copilot.gns3_client):
- project_inventory: the nodes/links aggregation feeding the topology
context and the Nornir inventory — its output shape is consumer-visible
and must stay field-for-field stable
- get_gns3_device_port: netmiko device_type resolution and per-node
credentials over the topology inventory
"""
import pytest
# ── project_inventory ────────────────────────────────────────────────────
def test_nodes_inventory_emits_default_credentials():
"""
The inventory dict consumed by get_device_ports_from_topology must
carry the per-node default credentials.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
build_nodes_inventory,
)
nodes = [
{
"name": "R1",
"node_id": "0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
"node_type": "dynamips",
"console": 5000,
"console_type": "telnet",
"status": "started",
"x": 0,
"y": 0,
"default_username": "admin",
"default_password": "admin123",
},
]
inventory = build_nodes_inventory(nodes, "127.0.0.1")
assert inventory["R1"]["default_username"] == "admin"
assert inventory["R1"]["default_password"] == "admin123"
assert inventory["R1"]["console_port"] == 5000
assert inventory["R1"]["type"] == "dynamips"
assert inventory["R1"]["server"] == "127.0.0.1"
assert inventory["R1"]["tags"] == []
def test_links_summary_resolves_names_and_ports():
"""
links_summary maps raw link endpoint lists to
{link_id, node_a, port_a, node_b, port_b} using port/adapter numbers.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
build_links_summary,
)
nodes = [
{
"name": "R1",
"node_id": "n1",
"ports": [
{"name": "GigabitEthernet0/0", "port_number": 0, "adapter_number": 0},
{"name": "GigabitEthernet0/1", "port_number": 1, "adapter_number": 0},
],
},
{
"name": "R2",
"node_id": "n2",
"ports": [
{"name": "Ethernet0", "port_number": 0, "adapter_number": 0},
],
},
]
links = [
{
"link_id": "l1",
"nodes": [
{"node_id": "n1", "port_number": 0, "adapter_number": 0},
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
],
},
# endpoint not resolvable → skipped, not an error
{
"link_id": "l2",
"nodes": [
{"node_id": "missing", "port_number": 0, "adapter_number": 0},
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
],
},
]
summary = build_links_summary(nodes, links)
assert summary == [
{
"link_id": "l1",
"node_a": "R1",
"port_a": "GigabitEthernet0/0",
"node_b": "R2",
"port_b": "Ethernet0",
}
]
# ── get_gns3_device_port (over the topology inventory) ──────────────────
def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch):
"""
netmiko_device_type on the node wins over the device_type:<type> tag;
the tag stays as fallback when the field is missing.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
from gns3server.agent.gns3_copilot import gns3_client
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
"SR1": {
"console_port": 5000,
"tags": ["device_type:cisco_ios_telnet"],
"netmiko_device_type": "nokia_srl",
},
"R1": {
"console_port": 5001,
"tags": ["device_type:cisco_ios_telnet", "platform:cisco_ios"],
"netmiko_device_type": None,
},
}
}
# the function does a lazy from-import inside the body
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["SR1", "R1"])
# field wins over tag
assert hosts["SR1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "nokia_srl"
# tag fallback when the field is absent
assert hosts["R1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "cisco_ios_telnet"
assert hosts["R1"]["platform"] == "cisco_ios"
def test_device_ports_error_without_any_device_type(monkeypatch):
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
from gns3server.agent.gns3_copilot import gns3_client
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
"R2": {
"console_port": 5002,
"tags": ["platform:cisco_ios"],
},
}
}
# the function does a lazy from-import inside the body
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["R2"])
assert "error" in hosts["R2"]
assert "netmiko_device_type" in hosts["R2"]["error"]
def test_device_ports_inject_default_credentials(monkeypatch):
"""
Per-node default credentials become host-level nornir values (which
override the group's empty fallback); missing or cleared ("") values
keep inheriting from the group.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
from gns3server.agent.gns3_copilot import gns3_client
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
"R1": {
"console_port": 5000,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
"default_username": "admin",
"default_password": "admin123",
},
# credentials cleared via PUT arrive as empty strings
"R2": {
"console_port": 5001,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
"default_username": "",
"default_password": "",
},
# never seeded
"R3": {
"console_port": 5002,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
},
}
}
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3"])
# set credentials land at host level
assert hosts["R1"]["username"] == "admin"
assert hosts["R1"]["password"] == "admin123"
# cleared ("") and absent credentials do not override the group fallback
assert "username" not in hosts["R2"]
assert "password" not in hosts["R2"]
assert "username" not in hosts["R3"]
assert "password" not in hosts["R3"]