gns3-server/tests/agent/mcp/test_handlers.py
YueGuobin 6d6b5351fa
fix: issue short-lived console tickets instead of JWTs in node_console MCP tool
LLM clients transcribing the console WebSocket URL into shell commands
reliably corrupted the ~200-char JWT embedded in it (dropped header
segment -> "MissingAlgorithmError: Missing 'alg' value in header" on
every connection attempt). The node_console tool now mints a short
random ticket ("gns3t_" + 16 urlsafe chars, 10 min TTL, multi-use)
stored server-side and bound to the node's console endpoints:

- new ConsoleTicketService (gns3server/services/console_tickets.py),
  in-memory store with lazy expiry sweeps
- get_current_active_user_from_websocket redeems tickets through the
  existing "token" query parameter, gated on websocket.path_params so a
  ticket only authenticates the console/ws and console/vnc routes of
  the node it was minted for; the JWT path is unchanged
- redemption reuses the existing user lookup, token_version revocation
  and is_active checks, so logging out invalidates outstanding tickets
- vnc_url no longer embeds the full session JWT
- the tool docstring now tells clients to run the returned command
  verbatim instead of reconstructing the URL
2026-08-29 01:00:12 +08:00

799 lines
38 KiB
Python

"""
MCP handler unit tests with mocked Gns3Connector.
Tests that handlers correctly transform tool parameters into HTTP calls.
"""
import json
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"}
conn.http_call.return_value.content = b"{}" # non-empty body by default
return conn
BASE = "gns3server.agent.mcp"
AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers" # node/link handlers sunk here
@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.agent.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.agent.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.agent.mcp.projects import get_project_handler
assert "error" in get_project_handler({}, ctx)
def test_create(self, ctx):
from gns3server.agent.mcp.projects import create_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"project_id": "p1"})
m.return_value = conn
result = create_project_handler({"name": "New", "auto_close": False}, ctx)
assert result["project_id"] == "p1"
conn.http_call.assert_called_once_with(
"post", f"{conn.base_url}/projects", json_data={"name": "New", "auto_close": False}
)
def test_create_without_auto_close(self, ctx):
from gns3server.agent.mcp.projects import create_project_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"project_id": "p2"})
m.return_value = conn
create_project_handler({"name": "New"}, ctx)
conn.http_call.assert_called_once_with(
"post", f"{conn.base_url}/projects", json_data={"name": "New"}
)
def test_delete(self, ctx):
from gns3server.agent.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.agent.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.agent.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.agent.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.agent.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:
def test_list_fields(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import get_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._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_batch_preserves_submission_order(self, ctx):
import time
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
def _http_call(method, url, json_data=None, **kwargs):
# first submissions sleep longest so completion order is reversed
time.sleep({"slow": 0.25, "mid": 0.1}.get(json_data.get("name"), 0.0))
resp = MagicMock()
resp.json.return_value = {"node_id": "n1", "name": json_data["name"]}
return resp
conn.http_call.side_effect = _http_call
m.return_value = conn
result = create_node_handler({
"project_id": "p1", "template_id": "t1",
"nodes": [{"name": "slow"}, {"name": "mid"}, {"name": "fast"}],
}, ctx)
assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"]
def test_create_batch_default_names_created_sequentially(self, ctx):
import threading
import time
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
def _run(nodes_param):
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
lock = threading.Lock()
active = [0, 0] # in-flight requests, high-water mark
counter = [0]
def _http_call(method, url, json_data=None, **kwargs):
with lock:
active[0] += 1
active[1] = max(active[1], active[0])
counter[0] += 1
seq = counter[0]
time.sleep(0.05) # wide enough that parallel calls would overlap
with lock:
active[0] -= 1
resp = MagicMock()
resp.json.return_value = {"node_id": f"n{seq}", "name": json_data.get("name", f"R-{seq}")}
return resp
conn.http_call.side_effect = _http_call
m.return_value = conn
result = create_node_handler({"project_id": "p1", "template_id": "t1", "nodes": nodes_param}, ctx)
return result, active[1]
# nodes relying on default naming are created one at a time so the
# server assigns default names/console ports in submission order
result, max_active = _run([{}, {}, {}])
assert [r["node"]["name"] for r in result] == ["R-1", "R-2", "R-3"]
assert max_active == 1
# one nameless node is enough to serialize the whole batch
result, max_active = _run([{"name": "explicit"}, {}])
assert [r["node"]["name"] for r in result] == ["explicit", "R-2"]
assert max_active == 1
def test_create_missing_project_id(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
assert create_node_handler({}, ctx) == {"error": "project_id is required"}
def test_delete_batch(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_node_handler
with patch(f"{AH}._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
# same status vocabulary as create/start/stop batches
assert all(r["status"] == "success" for r in result)
def test_start_batch(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import start_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import stop_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import suspend_node_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
from gns3server.services import console_ticket_service
with patch(f"{AH}._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
# the URL embeds a short console ticket (not a JWT) redeemable for this node
assert "?token=gns3t_" in result["ws_url"]
ticket = result["ws_url"].split("token=")[1]
assert ticket in result["command"]
assert result["token_ttl_seconds"] == 600
assert len(result["token_sha256_prefix"]) == 8
redeemed = console_ticket_service.redeem(ticket, {"project_id": "p1", "node_id": "n1"})
assert redeemed is not None and redeemed.username == "admin"
assert "vnc_url" not in result # console_type is not vnc
def test_console_vnc_uses_same_ticket(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"console_type": "vnc"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
ticket = result["ws_url"].split("token=")[1]
# one node-bound ticket covers both console endpoints (identical path params)
assert result["vnc_url"] == f"/v3/projects/p1/nodes/n1/console/vnc?token={ticket}"
def test_console_without_username_omits_token(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
unauthenticated_ctx = {k: v for k, v in ctx.items() if k != "jwt_username"}
with patch(f"{AH}._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"}, unauthenticated_ctx)
assert "token=" not in result["ws_url"]
assert "token_sha256_prefix" not in result
@staticmethod
def _file_conn(text):
conn = _mock_conn()
conn.http_call.return_value.text = text
return conn
def test_file_get_keeps_trailing_newline(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\nline2\n")
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
assert result["content"] == "line1\nline2\n"
assert result["metadata"]["total_bytes"] == 12
assert result["metadata"]["returned_bytes"] == 12
assert result["metadata"]["has_more"] is False
def test_file_get_keeps_crlf_endings(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\r\nline2\r\n")
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
assert result["content"] == "line1\r\nline2\r\n"
assert result["metadata"]["returned_bytes"] == 14
def test_file_get_without_trailing_newline(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\nline2")
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
assert result["content"] == "line1\nline2"
def test_file_get_pagination(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\nline2\nline3\n")
result = get_node_file_handler(
{"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg", "offset": 1, "limit": 1}, ctx
)
assert result["content"] == "line2\n"
assert result["metadata"]["total_lines"] == 3
assert result["metadata"]["returned_lines"] == 1
assert result["metadata"]["has_more"] is True
# ── Link ────────────────────────────────────────────────────────────────
class TestLink:
def test_list(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_links_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import get_link_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import delete_link_handler
with patch(f"{AH}._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
# same status vocabulary as create batches
assert all(r["status"] == "success" for r in result)
def test_create_batch_preserves_submission_order(self, ctx):
import time
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
def _http_call(method, url, json_data=None, **kwargs):
# first submission sleeps longest so completion order is reversed
first_node = json_data["nodes"][0]["node_id"]
time.sleep(0.25 if first_node == "n1" else 0.0)
resp = MagicMock()
resp.json.return_value = {"link_id": f"link-{first_node}"}
return resp
conn.http_call.side_effect = _http_call
m.return_value = conn
result = create_link_handler({
"project_id": "p1",
"links": [
{"nodes": ["n1", 0, 0, "n2", 0, 0]},
{"nodes": ["n3", 0, 0, "n4", 0, 0]},
],
"fields": ["link_id"],
}, ctx)
assert [r["link"]["link_id"] for r in result] == ["link-n1", "link-n3"]
def test_update(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import update_link_handler
with patch(f"{AH}._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.agent.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.agent.mcp.appliances import install_appliance_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker"})
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"},
)
assert result["template"] == {
"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker",
}
def test_install_empty_body(self, ctx):
# a 204-style empty response must not blow up with a JSON decode error
# (the template is still created server-side)
from gns3server.agent.mcp.appliances import install_appliance_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
conn.http_call.return_value.content = b""
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
m.return_value = conn
result = install_appliance_handler({"appliance_id": "a1"}, ctx)
assert "template" not in result
assert result["message"] == "Appliance a1 installed"
def test_install_missing_id(self, ctx):
from gns3server.agent.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.agent.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.agent.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.agent.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.agent.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()
# ── Image ───────────────────────────────────────────────────────────────
class TestImage:
mod = "images"
def test_install_manifest(self, ctx):
from gns3server.agent.mcp.images import install_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({
"created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}],
"skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}],
})
m.return_value = conn
result = install_images_handler({}, ctx)
assert result["created"][0]["name"] == "Empty VM"
assert result["skipped"][0]["name"] == "csr1000v.qcow2"
def test_install_empty_body(self, ctx):
from gns3server.agent.mcp.images import install_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
conn.http_call.return_value.content = b""
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
m.return_value = conn
result = install_images_handler({}, ctx)
assert result == {"message": "Image installation completed"}
class TestCompute:
mod = "computes"
def test_get_local_by_default(self, ctx):
from gns3server.agent.mcp.computes import get_compute_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"compute_id": "local", "name": "local"})
m.return_value = conn
result = get_compute_handler({}, ctx)
assert result["compute_id"] == "local"
url = conn.http_call.call_args[0][1]
assert url.endswith("/computes/local")
def test_get_explicit_compute_id(self, ctx):
from gns3server.agent.mcp.computes import get_compute_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"})
m.return_value = conn
get_compute_handler({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}, ctx)
url = conn.http_call.call_args[0][1]
assert url.endswith("/computes/4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6")
def test_images_local_by_default(self, ctx):
from gns3server.agent.mcp.computes import get_compute_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn(["img1.qcow2"])
m.return_value = conn
result = get_compute_images_handler({"emulator": "qemu"}, ctx)
assert result["count"] == 1
url = conn.http_call.call_args[0][1]
assert url.endswith("/computes/local/qemu/images")
def test_images_requires_emulator(self, ctx):
from gns3server.agent.mcp.computes import get_compute_images_handler
assert "error" in get_compute_images_handler({}, ctx)
# ── Marker (traffic-insight) ────────────────────────────────────────────
class TestLinkMarker:
"""link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null)."""
def test_update_direction_both_clears(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._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_data_link_type_passthrough(self, ctx):
"""create passes a serial WAN encapsulation through; update ignores it."""
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._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", "data_link_type": "DLT_C_HDLC"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
json_data={"bpf": "icmp", "data_link_type": "DLT_C_HDLC"},
)
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, "data_link_type": "DLT_PPP_SERIAL"}, ctx,
)
# create-only: dropped from the update body
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_tx(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._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.
"""
def test_create_builds_body(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._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.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._get_connector"):
result = marker_definition_handler(
{"project_id": "p", "action": "update", "def_name": "arp"}, ctx,
)
assert "error" in result