mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 14:01:15 +02:00
Merge branch '3.1' into api-issue-10-0eztih
This commit is contained in:
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
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user