mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
Merge branch '3.1' into api-issue-05-2xftmd
This commit is contained in:
46 files changed
+2108
-210
No files matched your search
@@ -161,3 +161,8 @@ class TestACLRoutes:
|
||||
|
||||
# the ACE should have been deleted after deleting the project
|
||||
assert not await rbac_repo.get_ace_by_path(path)
|
||||
|
||||
async def test_get_endpoints(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
response = await client.get(app.url_path_for("endpoints"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -68,6 +68,31 @@ class TestComputeRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) > 0
|
||||
|
||||
async def test_compute_list_filter_by_name(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
for name, port in (("compute-a", 85), ("compute-b", 86)):
|
||||
params = {
|
||||
"name": name,
|
||||
"protocol": "http",
|
||||
"host": "localhost",
|
||||
"port": port,
|
||||
"user": "julien",
|
||||
"password": "secure",
|
||||
}
|
||||
response = await client.post(app.url_path_for("create_compute"), json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
response = await client.get(app.url_path_for("get_computes"), params={"name": "compute-a"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [c["name"] for c in response.json()] == ["compute-a"]
|
||||
|
||||
response = await client.get(app.url_path_for("get_computes"), params={"name": "COMPUTE-A"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
response = await client.get(app.url_path_for("get_computes"))
|
||||
assert len(response.json()) >= 2
|
||||
|
||||
async def test_compute_get(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
|
||||
|
||||
response = await client.get(app.url_path_for("get_compute", compute_id=test_compute.compute_id))
|
||||
|
||||
@@ -70,3 +70,10 @@ class TestControllerRoutes:
|
||||
|
||||
response = await client.get(app.url_path_for("statistics"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
async def test_update_iou_license(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
license_settings = {"iourc_content": "[license]\ngns3vm = 1234567890abcdef;", "license_check": False}
|
||||
response = await client.put(app.url_path_for("update_iou_license"), json=license_settings)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == license_settings
|
||||
@@ -168,6 +168,21 @@ class TestImageRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) == 5 # 4 valid images uploaded before + 1 created
|
||||
|
||||
async def test_image_list_filter_by_name(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
||||
|
||||
image_name = os.path.basename(qcow2_image)
|
||||
response = await client.get(app.url_path_for("get_images"), params={"name": image_name})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [i["filename"] for i in response.json()] == [image_name]
|
||||
|
||||
response = await client.get(app.url_path_for("get_images"), params={"name": image_name.upper()})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
response = await client.get(app.url_path_for("get_images"), params={"name": image_name, "image_type": "iou"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
async def test_image_get(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
||||
|
||||
image_name = os.path.basename(qcow2_image)
|
||||
|
||||
@@ -154,6 +154,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:
|
||||
@@ -182,6 +286,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:
|
||||
@@ -374,6 +515,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.
|
||||
|
||||
@@ -34,7 +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 ControllerError
|
||||
from gns3server.controller.controller_error import ControllerError, ComputeError
|
||||
from gns3server.controller.node import Node
|
||||
from gns3server.controller.project import Project
|
||||
from gns3server.services import auth_service
|
||||
@@ -254,6 +254,54 @@ class TestNodeRoutes:
|
||||
else:
|
||||
assert len(response.json()) == 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, tags, expected_names",
|
||||
(
|
||||
(None, None, ["test", "test2"]),
|
||||
("test", None, ["test"]),
|
||||
("test2", None, ["test2"]),
|
||||
("TEST", None, []),
|
||||
("missing", None, []),
|
||||
("test", ["tag1"], ["test"]),
|
||||
("test", ["tag3"], []),
|
||||
("test2", ["tag3", "tag4"], ["test2"]),
|
||||
),
|
||||
)
|
||||
async def test_list_nodes_filter_by_name(
|
||||
self,
|
||||
app: FastAPI,
|
||||
client: AsyncClient,
|
||||
project: Project,
|
||||
compute: Compute,
|
||||
name: Optional[str],
|
||||
tags: Optional[list],
|
||||
expected_names: list,
|
||||
) -> None:
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
|
||||
for node_name, node_tags in (("test", ["tag1", "tag2"]), ("test2", ["tag3", "tag4"])):
|
||||
await client.post(
|
||||
app.url_path_for("create_node", project_id=project.id),
|
||||
json={
|
||||
"name": node_name,
|
||||
"node_type": "vpcs",
|
||||
"compute_id": "example.com",
|
||||
"tags": node_tags,
|
||||
"properties": {"startup_script": "echo test"},
|
||||
},
|
||||
)
|
||||
|
||||
params = {}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if tags is not None:
|
||||
params["tags"] = tags
|
||||
response = await client.get(app.url_path_for("get_nodes", project_id=project.id), params=params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert sorted(n["name"] for n in response.json()) == expected_names
|
||||
|
||||
async def test_get_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
@@ -300,6 +348,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()
|
||||
@@ -342,7 +461,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
|
||||
@@ -350,6 +470,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(
|
||||
@@ -358,7 +556,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
|
||||
@@ -388,7 +587,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:
|
||||
@@ -164,6 +191,26 @@ class TestControllerProjectRoutes:
|
||||
projects = response.json()
|
||||
assert projects[0]["name"] == "test"
|
||||
|
||||
async def test_list_projects_filter_by_name(
|
||||
self, app: FastAPI, client: AsyncClient, controller: Controller
|
||||
) -> None:
|
||||
|
||||
for name in ("test", "other"):
|
||||
params = {"name": name, "project_id": str(uuid.uuid4())}
|
||||
response = await client.post(app.url_path_for("create_project"), json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
response = await client.get(app.url_path_for("get_projects"), params={"name": "test"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [p["name"] for p in response.json()] == ["test"]
|
||||
|
||||
response = await client.get(app.url_path_for("get_projects"), params={"name": "TEST"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
response = await client.get(app.url_path_for("get_projects"))
|
||||
assert len(response.json()) == 2
|
||||
|
||||
async def test_get_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
response = await client.get(app.url_path_for("get_project", project_id=project.id))
|
||||
@@ -196,7 +243,7 @@ class TestControllerProjectRoutes:
|
||||
|
||||
with asyncio_patch("gns3server.controller.project.Project.open", return_value=True) as mock:
|
||||
response = await client.post(app.url_path_for("open_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert mock.called
|
||||
|
||||
async def test_load_project(self, app: FastAPI, client: AsyncClient, project: Project, config) -> 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
|
||||
@@ -72,7 +76,7 @@ class TestSnapshotRoutes:
|
||||
response = await client.post(
|
||||
app.url_path_for("restore_snapshot", project_id=project.id, snapshot_id=snapshot.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["name"] == project.name
|
||||
|
||||
async def test_create_snapshot(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
@@ -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"]
|
||||
@@ -20,6 +20,7 @@ import os
|
||||
import unittest.mock
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
@@ -89,6 +90,27 @@ class TestTemplateRoutes:
|
||||
else:
|
||||
assert len(response.json()) == 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, tags, expected_names",
|
||||
(
|
||||
("VPCS_TEST", None, ["VPCS_TEST"]),
|
||||
("vpcs_test", None, []),
|
||||
("missing", None, []),
|
||||
("VPCS_TEST", ["tag1"], ["VPCS_TEST"]),
|
||||
("VPCS_TEST", ["tag42"], []),
|
||||
),
|
||||
)
|
||||
async def test_template_list_filter_by_name(
|
||||
self, app: FastAPI, client: AsyncClient, name: str, tags: Optional[list], expected_names: list
|
||||
) -> None:
|
||||
|
||||
params = {"name": name}
|
||||
if tags is not None:
|
||||
params["tags"] = tags
|
||||
response = await client.get(app.url_path_for("get_templates"), params=params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [t["name"] for t in response.json()] == expected_names
|
||||
|
||||
async def test_template_get(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
template_id = str(uuid.uuid4())
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user