Merge branch '3.1' into api-issue-10-0eztih

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-08 19:21:07 +02:00
commit aeac649c7f
58 files changed
+2455 -266

No files matched your search

@@ -101,3 +101,33 @@ class TestApplianceRoutes:
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)
assert response.status_code == status.HTTP_409_CONFLICT
async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)
assert response.status_code == status.HTTP_201_CREATED
assert new_version in response.json()["versions"]
async def test_add_version_appliance_with_registry_version(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
stored_version = {"name": "explicit-99G", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id),
json=dict(stored_version, registry_version=3),
)
assert response.status_code == status.HTTP_201_CREATED
assert stored_version in response.json()["versions"]
async def test_add_version_appliance_unknown_registry_version(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id),
json={"name": "bad", "registry_version": 7, "images": {"hda_disk_image": "empty99G.qcow2"}},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+234
View File
@@ -115,6 +115,110 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_409_CONFLICT
assert len(project.links) == 0
async def test_create_link_by_port_name(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "port_name": "E0"},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert response.status_code == status.HTTP_201_CREATED
first, second = response.json()["nodes"]
assert (first["adapter_number"], first["port_number"]) == (0, 3)
assert (second["adapter_number"], second["port_number"]) == (2, 4)
async def test_create_link_unknown_port_name(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "port_name": "nope"},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert len(project.links) == 0
async def test_create_link_auto_port(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
node1._ports = [EthernetPort("E1", 0, 0, 5), EthernetPort("E0", 0, 0, 3)]
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={"nodes": [{"node_id": node1.id, "port": "auto"}, {"node_id": node2.id, "port": "auto"}]},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["nodes"][0]["port_number"] == 3
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={"nodes": [{"node_id": node1.id, "port": "auto"}, {"node_id": node2.id, "port": "auto"}]},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json()["code"] == "no_free_port"
assert len(project.links) == 1
async def test_create_link_port_in_use_code(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
body = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
first = await client.post(app.url_path_for("create_link", project_id=project.id), json=body)
second = await client.post(app.url_path_for("create_link", project_id=project.id), json=body)
assert first.status_code == status.HTTP_201_CREATED
assert second.status_code == status.HTTP_409_CONFLICT
assert second.json()["code"] == "port_in_use"
assert len(project.links) == 1
async def test_create_link_invalid_port_selector(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
valid = {"node_id": node2.id, "adapter_number": 2, "port_number": 4}
for bad in (
{"node_id": node1.id},
{"node_id": node1.id, "adapter_number": 0},
{"node_id": node1.id, "port_name": "E0", "port": "auto"},
{"node_id": node1.id, "adapter_number": 0, "port_number": 3, "port_name": "E0"},
):
response = await client.post(
app.url_path_for("create_link", project_id=project.id), json={"nodes": [bad, valid]}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_get_link(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
@@ -143,6 +247,43 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["nodes"][0]["label"]["x"] == 42
async def test_link_etag_and_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
link_id = response.json()["link_id"]
url = app.url_path_for("get_link", project_id=project.id, link_id=link_id)
response = await client.get(url)
assert response.status_code == status.HTTP_200_OK
etag = response.headers["ETag"]
assert (await client.get(url)).headers["ETag"] == etag
response = await client.put(url, json={"show_filters_icon": False}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_200_OK
assert response.headers["ETag"] != etag
assert (await client.get(url)).headers["ETag"] == response.headers["ETag"]
response = await client.put(url, json={"show_filters_icon": True}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
assert "message" in response.json()
assert (await client.get(url)).json()["show_filters_icon"] is False
response = await client.put(url, json={"show_filters_icon": True})
assert response.status_code == status.HTTP_200_OK
assert response.json()["show_filters_icon"] is True
async def test_update_link_suspend(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
@@ -335,6 +476,97 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json() == FILTERS
async def test_update_link_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
filters = {
"frequency_drop": [50],
"packet_loss": [10],
"delay": [10, 5],
"corrupt": [3],
"bpf": ["icmp"],
}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == filters
assert link.filters == filters
async def test_update_link_disabled_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"delay": [0, 0], "packet_loss": [0], "corrupt": [], "bpf": [""]}},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == {}
@pytest.mark.parametrize(
"filters",
[
{"packet_loss": [101]},
{"packet_loss": [-1]},
{"corrupt": [101]},
{"frequency_drop": [-2]},
{"frequency_drop": [32768]},
{"delay": [32768, 0]},
{"delay": [10, -1]},
{"packet_loss": [1, 2]},
{"bpf": [1]},
{"packet_loss": ["abc"]},
],
)
async def test_update_link_invalid_filter_values(
self, app: FastAPI, client: AsyncClient, project: Project, filters: dict
) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_unknown_filter_type(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"unknown": [1]}},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": {"delay": [0, 5]}}
)
assert response.status_code == status.HTTP_409_CONFLICT
async def test_openapi_link_filters(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
link_filters = schemas["LinkFilters"]
assert link_filters["additionalProperties"] is False
assert set(link_filters["properties"]) == {"frequency_drop", "packet_loss", "delay", "corrupt", "bpf"}
assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
"$ref": "#/components/schemas/LinkFilterParameter"
}
path = "/v3/projects/{project_id}/links/{link_id}/available_filters"
responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"}
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
Test getting UDP tunnel interface information from a link.
@@ -376,6 +608,7 @@ class TestLinkRoutes:
assert result["rhost"] == "127.0.0.1"
assert result["rport"] == 30000
assert result["type"] == "udp"
assert result["kind"] == "udp"
async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
@@ -406,3 +639,4 @@ class TestLinkRoutes:
assert result["node_id"] == cloud_node.id
assert result["interface"] == "eth0"
assert result["type"] == "ethernet"
assert result["kind"] == "ethernet"
+156 -3
View File
@@ -34,6 +34,7 @@ from pydantic import SecretStr
from gns3server.api.routes.controller.nodes import vnc_console, ws_console
from gns3server.config import Config
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import ComputeError
from gns3server.controller.node import Node
from gns3server.controller.project import Project
from gns3server.services import auth_service
@@ -250,6 +251,77 @@ class TestNodeRoutes:
assert "name" not in response.json()["properties"]
assert response.json()["tags"] == ["tag1", "tag2"]
async def test_node_etag_on_get_and_put(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
response = await client.get(url)
assert response.status_code == status.HTTP_200_OK
etag = response.headers["ETag"]
assert etag.startswith('"') and etag.endswith('"')
assert (await client.get(url)).headers["ETag"] == etag
response = await client.put(url, json={"name": "renamed"})
assert response.status_code == status.HTTP_200_OK
assert response.headers["ETag"] != etag
assert (await client.get(url)).headers["ETag"] == response.headers["ETag"]
async def test_update_node_with_matching_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
etag = (await client.get(url)).headers["ETag"]
response = await client.put(url, json={"name": "renamed"}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "renamed"
assert response.headers["ETag"] != etag
async def test_update_node_with_stale_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
stale_etag = (await client.get(url)).headers["ETag"]
assert (await client.put(url, json={"name": "first"})).status_code == status.HTTP_200_OK
response = await client.put(url, json={"name": "second"}, headers={"If-Match": stale_etag})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
assert "message" in response.json()
assert node.name == "first"
async def test_update_node_without_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
assert (await client.put(url, json={"name": "first"})).status_code == status.HTTP_200_OK
assert (await client.put(url, json={"name": "second"})).status_code == status.HTTP_200_OK
assert node.name == "second"
async def test_update_node_with_wildcard_and_list_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
etag = (await client.get(url)).headers["ETag"]
response = await client.put(url, json={"name": "a"}, headers={"If-Match": "*"})
assert response.status_code == status.HTTP_200_OK
etag = (await client.get(url)).headers["ETag"]
response = await client.put(url, json={"name": "b"}, headers={"If-Match": f'"other", {etag}'})
assert response.status_code == status.HTTP_200_OK
async def test_start_all_nodes(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
compute.post = AsyncioMagicMock()
@@ -292,7 +364,8 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={})
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "started"
async def test_stop_node(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
@@ -300,6 +373,84 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "stopped"
async def test_start_node_wait(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.post = AsyncioMagicMock()
response = await client.post(
app.url_path_for("start_node", project_id=project.id, node_id=node.id) + "?wait=true&timeout=5", json={}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "started"
response = await client.get(app.url_path_for("get_node", project_id=project.id, node_id=node.id))
assert response.json()["status"] == "started"
async def test_start_node_reports_starting_while_in_flight(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
seen = []
async def post(*args, **kwargs):
seen.append(node.asdict()["status"])
compute.post = post
response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={})
assert response.status_code == status.HTTP_200_OK
assert seen == ["starting"]
assert response.json()["status"] == "started"
async def test_stop_node_reports_stopping_while_in_flight(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
seen = []
async def post(*args, **kwargs):
seen.append(node.asdict()["status"])
compute.post = post
node._status = "started"
response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_200_OK
assert seen == ["stopping"]
assert response.json()["status"] == "stopped"
async def test_start_node_wait_timeout(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
async def post(*args, **kwargs):
await asyncio.sleep(10)
compute.post = post
response = await client.post(
app.url_path_for("start_node", project_id=project.id, node_id=node.id) + "?wait=true&timeout=1", json={}
)
assert response.status_code == status.HTTP_408_REQUEST_TIMEOUT
assert "message" in response.json()
assert node.asdict()["status"] == "stopped"
async def test_stop_node_reports_compute_error(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.post = AsyncioMagicMock(side_effect=ComputeError("compute unreachable"))
node._status = "started"
response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_409_CONFLICT
assert node.asdict()["status"] == "started"
async def test_stop_all_nodes_tolerates_compute_error(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.post = AsyncioMagicMock(side_effect=ComputeError("compute unreachable"))
response = await client.post(app.url_path_for("stop_all_nodes", project_id=project.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_suspend_node(
@@ -308,7 +459,8 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "suspended"
async def test_suspend_node_unsupported(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
@@ -338,7 +490,8 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("reload_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "started"
async def test_isolate_node(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
@@ -0,0 +1,88 @@
#
# 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 json
import uuid
import pytest
from fastapi import FastAPI
from pydantic import ValidationError
from gns3server import schemas
from gns3server.utils.notification_queue import NotificationQueue
STREAM_PATHS = ["/v3/notifications", "/v3/projects/{project_id}/notifications"]
class TestNotificationOpenAPI:
def test_notification_schema_in_spec(self, app: FastAPI) -> None:
spec = app.openapi()
schema = spec["components"]["schemas"]["Notification"]
assert set(schema["required"]) == {"action", "event"}
assert {"action", "event", "project_id", "compute_id"} <= set(schema["properties"])
action_schema = spec["components"]["schemas"]["NotificationAction"]
assert {"ping", "node.created", "node.updated", "link.created", "marker.match"} <= set(action_schema["enum"])
@pytest.mark.parametrize("path", STREAM_PATHS)
def test_stream_responses_reference_schema(self, app: FastAPI, path: str) -> None:
content = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]
assert list(content) == ["application/x-ndjson"]
assert content["application/x-ndjson"]["schema"] == {"$ref": "#/components/schemas/Notification"}
class TestNotificationSchema:
def test_ping(self) -> None:
message = {
"action": "ping",
"event": {"cpu_usage_percent": 1.5, "memory_usage_percent": 40.0, "disk_usage_percent": 10.0},
}
notification = schemas.Notification.model_validate(message)
assert notification.action == schemas.NotificationAction.ping
def test_unknown_action(self) -> None:
with pytest.raises(ValidationError):
schemas.Notification.model_validate({"action": "unknown.action", "event": {}})
def test_optional_ids(self) -> None:
project_id = str(uuid.uuid4())
notification = schemas.Notification.model_validate(
{"action": "log.info", "event": {"message": "hello"}, "project_id": project_id, "compute_id": "local"}
)
assert str(notification.project_id) == project_id
assert notification.compute_id == "local"
def test_generic_event(self) -> None:
notification = schemas.Notification.model_validate(
{"action": "template.deleted", "event": {"template_id": str(uuid.uuid4())}}
)
assert notification.action == schemas.NotificationAction.template_deleted
@pytest.mark.asyncio
async def test_queue_message_matches_schema(self) -> None:
queue = NotificationQueue()
queue.put_nowait(("log.warning", {"message": "disk space is low"}, {"compute_id": "local"}))
await queue.get_json(5)
message = json.loads(await queue.get_json(5))
notification = schemas.Notification.model_validate(message)
assert notification.action == schemas.NotificationAction.log_warning
assert notification.compute_id == "local"
@@ -139,6 +139,33 @@ class TestControllerProjectRoutes:
assert response.json()["path"] == path
assert response.json()["project_id"] == "10010203-0405-0607-0809-0a0b0c0d0e0f"
async def test_project_etag_and_if_match(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None:
params = {"name": "test", "project_id": "10010203-0405-0607-0809-0a0b0c0d0e0f"}
assert (await client.post(app.url_path_for("create_project"), json=params)).status_code == 201
get_url = app.url_path_for("get_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f")
put_url = app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f")
response = await client.get(get_url)
assert response.status_code == status.HTTP_200_OK
etag = response.headers["ETag"]
assert (await client.get(get_url)).headers["ETag"] == etag
response = await client.put(put_url, json={"name": "test2"}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_200_OK
new_etag = response.headers["ETag"]
assert new_etag != etag
assert (await client.get(get_url)).headers["ETag"] == new_etag
response = await client.put(put_url, json={"name": "test3"}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
assert "message" in response.json()
assert (await client.get(get_url)).json()["name"] == "test2"
response = await client.put(put_url, json={"name": "test4"})
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "test4"
async def test_update_project_with_variables(
self, app: FastAPI, client: AsyncClient, controller: Controller
) -> None:
@@ -17,6 +17,7 @@
import os
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
@@ -54,6 +55,9 @@ class TestSnapshotRoutes:
response = await client.get(app.url_path_for("get_snapshots", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1
data = response.json()[0]
assert isinstance(data["created_at"], int)
assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc)
async def test_delete_snapshot(
self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot
@@ -80,3 +84,15 @@ class TestSnapshotRoutes:
response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"})
assert response.status_code == status.HTTP_201_CREATED
assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2
data = response.json()
assert isinstance(data["created_at"], int)
assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc)
async def test_snapshot_schema_timestamps(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
assert schemas["Snapshot"]["properties"]["created"]["format"] == "date-time"
assert schemas["Snapshot"]["properties"]["created_at"]["type"] == "integer"
assert schemas["Snapshot"]["properties"]["created_at"]["deprecated"] is True
for field in ("created_at", "modified_at"):
assert {"type": "string", "format": "date-time"} in schemas["NodeFile"]["properties"][field]["anyOf"]
+68
View File
@@ -446,6 +446,74 @@ class TestLogout:
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestTokenLifetime:
async def _issue(self, app, client, user, endpoint):
credentials = {"username": user.username, "password": "user1_password"}
if endpoint == "login":
return await client.post(
app.url_path_for("login"),
data=credentials,
headers={"content-type": "application/x-www-form-urlencoded"},
)
auth = await client.post(app.url_path_for("authenticate"), json=credentials)
if endpoint == "authenticate":
return auth
return await client.post(
app.url_path_for("refresh_access_token"), json={"refresh_token": auth.json()["refresh_token"]}
)
@pytest.mark.parametrize("endpoint", ("login", "authenticate", "refresh_access_token"))
async def test_token_response_includes_lifetimes(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
config: Config,
endpoint: str,
) -> None:
response = await self._issue(app, unauthorized_client, test_user, endpoint)
assert response.status_code == status.HTTP_200_OK
body = response.json()
controller = config.settings.Controller
assert body["expires_in"] == controller.jwt_access_token_expire_minutes * 60
assert body["refresh_expires_in"] == controller.jwt_refresh_token_expire_minutes * 60
assert body["access_token"]
assert body["token_type"] == "bearer"
assert body["refresh_token"]
@pytest.mark.parametrize("endpoint", ("login", "authenticate", "refresh_access_token"))
async def test_expires_in_matches_jwt_claims(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
config: Config,
endpoint: str,
) -> None:
response = await self._issue(app, unauthorized_client, test_user, endpoint)
body = response.json()
key = OctKey.import_key(config.settings.Controller.jwt_secret_key)
access = jwt.decode(body["access_token"], key, algorithms=["HS256"]).claims
refresh = jwt.decode(body["refresh_token"], key, algorithms=["HS256"]).claims
assert access["exp"] - access["iat"] == body["expires_in"]
assert refresh["exp"] - refresh["iat"] == body["refresh_expires_in"]
async def test_openapi_declares_token_lifetimes_as_integers(self, app: FastAPI) -> None:
schema = app.openapi()["components"]["schemas"]["Token"]
assert schema["properties"]["expires_in"]["type"] == "integer"
refresh_expires_in = schema["properties"]["refresh_expires_in"]
assert "integer" in str(refresh_expires_in)
assert "expires_in" in schema["required"]
async def test_openapi_security_scheme_mentions_api_keys(self, app: FastAPI) -> None:
schemes = app.openapi()["components"]["securitySchemes"]
assert any("API key" in scheme.get("description", "") for scheme in schemes.values())
class TestRefreshToken:
async def test_login_returns_refresh_token(
self,
+4 -1
View File
@@ -42,7 +42,10 @@ class TestVersionRoutes:
params = {"version": "0.4.2"}
response = await client.post(app.url_path_for("check_version"), json=params)
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json() == {"message": f"Client version 0.4.2 is not the same as server version {__version__}"}
assert response.json() == {
"message": f"Client version 0.4.2 is not the same as server version {__version__}",
"code": "conflict",
}
async def test_version_invalid_input_schema(self, app: FastAPI, client: AsyncClient) -> None:
+221
View File
@@ -0,0 +1,221 @@
#
# 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 uuid
from typing import Tuple
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.api.errors import code_for_status
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import ComputeConflictError, ControllerError
from gns3server.controller.node import Node
from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.controller.project import Project
from gns3server.db.models import User
from gns3server.schemas import ErrorMessage
from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
from tests.utils import AsyncioMagicMock, asyncio_patch
pytestmark = pytest.mark.asyncio
class TestErrorResponses:
@pytest_asyncio.fixture
async def nodes(self, compute: Compute, project: Project) -> Tuple[Node, Node]:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node1 = await project.add_node(compute, "node1", None, node_type="qemu")
node1._ports = [EthernetPort("E0", 0, 0, 3)]
node2 = await project.add_node(compute, "node2", None, node_type="qemu")
node2._ports = [EthernetPort("E0", 0, 2, 4)]
return node1, node2
async def test_unauthorized(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
response = await client.get(
app.url_path_for("get_project", project_id=project.id),
headers={"Authorization": "Bearer invalid_token"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
body = response.json()
assert isinstance(body["message"], str)
assert body["code"] == "unauthorized"
assert "detail" not in body
async def test_forbidden(self, app: FastAPI, client: AsyncClient, test_user: User, project: Project) -> None:
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
response = await client.delete(
app.url_path_for("delete_project", project_id=project.id),
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
body = response.json()
assert isinstance(body["message"], str)
assert body["code"] == "forbidden"
async def test_unknown_node(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
node_id = str(uuid.uuid4())
response = await client.get(app.url_path_for("get_node", project_id=project.id, node_id=node_id))
assert response.status_code == status.HTTP_404_NOT_FOUND
body = response.json()
assert body["message"] == f"Node ID {node_id} doesn't exist"
assert body["code"] == "node_not_found"
assert body["details"] == {"node_id": node_id}
async def test_unknown_project(self, app: FastAPI, client: AsyncClient) -> None:
project_id = str(uuid.uuid4())
response = await client.get(app.url_path_for("get_project", project_id=project_id))
assert response.status_code == status.HTTP_404_NOT_FOUND
body = response.json()
assert body["code"] == "project_not_found"
assert body["details"] == {"project_id": project_id}
async def test_unknown_route(self, client: AsyncClient) -> None:
response = await client.get("/v3/no/such/route")
assert response.status_code == status.HTTP_404_NOT_FOUND
body = response.json()
assert body["message"] == "Not Found"
assert body["code"] == "not_found"
assert "detail" not in body
async def test_port_in_use(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
payload = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
first = await client.post(app.url_path_for("create_link", project_id=project.id), json=payload)
assert first.status_code == status.HTTP_201_CREATED
second = await client.post(app.url_path_for("create_link", project_id=project.id), json=payload)
assert second.status_code == status.HTTP_409_CONFLICT
body = second.json()
assert body["message"] == "Port is already used"
assert body["code"] == "port_in_use"
assert body["details"]["node_id"] == node1.id
assert body["details"]["adapter_number"] == 0
assert body["details"]["port_number"] == 3
assert body["details"]["link_id"] == first.json()["link_id"]
async def test_generic_conflict(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, _ = nodes
node1._ports = [EthernetPort("E0", 0, 0, 3), EthernetPort("E1", 0, 0, 4)]
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node1.id, "adapter_number": 0, "port_number": 4},
]
},
)
assert response.status_code == status.HTTP_409_CONFLICT
body = response.json()
assert body["message"] == "Cannot connect to itself"
assert body["code"] == "conflict"
assert "details" not in body
async def test_validation_error(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
response = await client.post(app.url_path_for("create_link", project_id=project.id), json={"nodes": "bad"})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
body = response.json()
assert isinstance(body["message"], str)
assert body["code"] == "validation_error"
assert body["details"]["errors"]
assert {"loc", "msg", "type"} <= set(body["details"]["errors"][0])
assert "detail" not in body
async def test_message_only_clients_keep_working(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(app.url_path_for("get_project", project_id=str(uuid.uuid4())))
assert ErrorMessage.model_validate({"message": response.json()["message"]}).code is None
assert isinstance(response.json()["message"], str)
class TestErrorCodes:
async def test_default_codes(self) -> None:
assert ControllerError("x").code == "conflict"
assert ControllerError("x", code="custom", details={"a": 1}).details == {"a": 1}
async def test_compute_conflict_code(self) -> None:
error = ComputeConflictError("http://c/x", {"message": "boom", "code": "port_in_use"})
assert error.code == "port_in_use"
assert ComputeConflictError("http://c/x", {"message": "boom"}).code == "compute_conflict"
@pytest.mark.parametrize(
"status_code,code",
[(401, "unauthorized"), (403, "forbidden"), (404, "not_found"), (409, "conflict"), (501, "not_implemented")],
)
async def test_code_for_status(self, status_code: int, code: str) -> None:
assert code_for_status(status_code) == code
class TestOpenAPIErrorSchemas:
async def test_error_schemas(self, client: AsyncClient) -> None:
response = await client.get("/openapi.json")
assert response.status_code == status.HTTP_200_OK
spec = response.json()
schemas = spec["components"]["schemas"]
assert "HTTPValidationError" not in schemas
assert "ValidationError" not in schemas
assert set(schemas["ErrorMessage"]["properties"]) == {"message", "code", "details"}
assert schemas["ErrorMessage"]["required"] == ["message"]
error_ref = {"$ref": "#/components/schemas/ErrorMessage"}
authenticated = 0
for path, path_item in spec["paths"].items():
for method, operation in path_item.items():
responses = operation["responses"]
if "422" in responses:
assert responses["422"]["content"]["application/json"]["schema"] == error_ref, (method, path)
if operation.get("security"):
authenticated += 1
for code in ("401", "403", "422"):
assert responses[code]["content"]["application/json"]["schema"] == error_ref, (
method,
path,
code,
)
assert authenticated > 0
assert "HTTPValidationError" not in response.text
+68
View File
@@ -0,0 +1,68 @@
#
# 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
pytestmark = pytest.mark.asyncio
STREAM_AND_BINARY_RESPONSES = [
("/v3/notifications", "get", "200", "application/x-ndjson"),
("/v3/projects/{project_id}/notifications", "get", "200", "application/x-ndjson"),
("/v3/projects/{project_id}/export", "get", "200", "application/zip"),
("/v3/projects/{project_id}/gns3file", "get", "200", "application/json"),
("/v3/projects/{project_id}/files/{file_path}", "get", "200", "application/octet-stream"),
("/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}", "get", "200", "application/octet-stream"),
("/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}", "post", "201", "application/json"),
("/v3/projects/{project_id}/links/{link_id}/capture/stream", "get", "200", "application/vnd.tcpdump.pcap"),
("/v3/symbols/{symbol_id}/raw", "get", "200", "application/octet-stream"),
]
RAW_BODY_REQUESTS = [
("/v3/images/upload/{image_path}", "application/octet-stream"),
("/v3/symbols/{symbol_id}/raw", "application/octet-stream"),
("/v3/projects/{project_id}/files/{file_path}", "application/octet-stream"),
("/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}", "application/octet-stream"),
("/v3/projects/{project_id}/import", "application/zip"),
]
class TestOpenAPIBinaryRoutes:
@pytest.mark.parametrize("path, method, status_code, media_type", STREAM_AND_BINARY_RESPONSES)
async def test_response_content(
self, app: FastAPI, path: str, method: str, status_code: str, media_type: str
) -> None:
content = app.openapi()["paths"][path][method]["responses"][status_code]["content"]
assert media_type in content
assert content[media_type]["schema"] != {}
if media_type != "application/json":
assert "application/json" not in content
@pytest.mark.parametrize("path, media_type", RAW_BODY_REQUESTS)
async def test_request_body(self, app: FastAPI, path: str, media_type: str) -> None:
request_body = app.openapi()["paths"][path]["post"]["requestBody"]
assert request_body["required"] is True
assert request_body["content"][media_type]["schema"] == {"type": "string", "format": "binary"}
async def test_binary_responses_use_binary_schema(self, app: FastAPI) -> None:
paths = app.openapi()["paths"]
for path, method, status_code, media_type in STREAM_AND_BINARY_RESPONSES:
if media_type in ("application/octet-stream", "application/zip", "application/vnd.tcpdump.pcap"):
schema = paths[path][method]["responses"][status_code]["content"][media_type]["schema"]
assert schema == {"type": "string", "format": "binary"}