mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 14:01:15 +02:00
Merge branch '3.1' into fix/duplicate-device-configs-merge
Resolve conflicts against the upstream wave (PRs #2952-#2965, incl. the typed-response/schema hardening and the drop of Python 3.10): - controller/node.py: keep upstream's _transitioning/_confirm_status lifecycle guard; the kernel-anchor link wiring (node_started) runs after the transition completes. - controller/link.py: keep upstream's add_node rollback (release_ports on create failure) and delete via release_ports; release_ports now resolves ports live (same staleness reason as _bind_members) and discards the node link unconditionally. - schemas/controller/links.py: extend LinkFilterType with the eight kernel-datapath filter types and LinkFilterParameter.type with "str" so the newly typed /available_filters response validates; Link.filters stays a free-form dict — the vocabulary is capability-gated per link and validated by the controller (409), not by a strict request schema. - schemas/controller/topology.py: drop the type: ignore made redundant by the dict filters field. - tests: route-level filter validation now expects the controller's 409 (unknown types, out-of-range values); packet_loss correlation is a valid kernel parameter (positive test added); OpenAPI assertions match the free-form filters property.
This commit is contained in:
60 files changed
+2654
-271
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
|
||||
@@ -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:
|
||||
@@ -170,6 +274,43 @@ class TestLinkRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["kernel_datapath"] is True
|
||||
|
||||
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:
|
||||
@@ -362,6 +503,119 @@ 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]},
|
||||
# a second packet_loss value (correlation) is valid — it is a
|
||||
# kernel-datapath parameter the controller accepts; gemodel out
|
||||
# of range stands in for the invalid slot instead
|
||||
{"gemodel": [101, 50, 50]},
|
||||
{"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}
|
||||
)
|
||||
# The filter vocabulary is capability-gated per link, so values are
|
||||
# validated by the controller (409), not by a strict request schema
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
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_409_CONFLICT
|
||||
assert "message" in response.json()
|
||||
assert link.filters == {}
|
||||
|
||||
async def test_update_link_packet_loss_correlation(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project
|
||||
) -> None:
|
||||
# The optional packet_loss correlation (second value) is a
|
||||
# kernel-datapath parameter accepted by the controller
|
||||
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": {"packet_loss": [1, 2]}},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["filters"] == {"packet_loss": [1, 2]}
|
||||
|
||||
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"]
|
||||
# The filters vocabulary is capability-gated per link and validated by
|
||||
# the controller, so the Link schema carries it as a free-form object
|
||||
# (no strict LinkFilters model on the request path)
|
||||
assert "LinkFilters" not in schemas
|
||||
filters_property = schemas["Link"]["properties"]["filters"]
|
||||
assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"}
|
||||
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.
|
||||
@@ -403,6 +657,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:
|
||||
"""
|
||||
@@ -433,3 +688,4 @@ class TestLinkRoutes:
|
||||
assert result["node_id"] == cloud_node.id
|
||||
assert result["interface"] == "eth0"
|
||||
assert result["type"] == "ethernet"
|
||||
assert result["kind"] == "ethernet"
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user