From c9bc6359960b612dee9829e688bd0633cb61bb76 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 21:29:53 +0800 Subject: [PATCH] fix: propagate 405 when suspending a node without suspend support Suspending a single VPCS/IOU node returned a fake 204: the controller route swallowed the compute 405 that the node types honestly raise, so callers saw success while the node stayed started. Surface the 405 instead. The best-effort swallow on suspend_all is kept (and now covered by a test) since mixed projects legitimately contain always-running node types. --- gns3server/api/routes/controller/nodes.py | 9 +++--- tests/api/routes/controller/test_nodes.py | 38 +++++++++++++++++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index de29db5dc..47f9e57aa 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -372,14 +372,13 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None: """ Suspend a node. + Node types without suspend support return a 405 error instead of a + silent no-op, so the caller cannot mistake it for a suspended node. + Required privilege: Node.PowerMgmt """ - try: - await node.suspend() - except HTTPException as e: - if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: - raise + await node.suspend() @router.post( diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py index 4fd84d0ad..bded58728 100644 --- a/tests/api/routes/controller/test_nodes.py +++ b/tests/api/routes/controller/test_nodes.py @@ -18,7 +18,7 @@ import pytest -from fastapi import FastAPI, status +from fastapi import FastAPI, HTTPException, status from httpx import AsyncClient from unittest.mock import MagicMock @@ -295,10 +295,44 @@ class TestNodeRoutes: compute: Compute, node: Node ) -> None: - + 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 + + async def test_suspend_node_unsupported( + self, + app: FastAPI, + client: AsyncClient, + project: Project, + compute: Compute, + node: Node + ) -> None: + + # node types without suspend support (e.g. VPCS, IOU) must surface the + # compute 405 instead of reporting a fake success + compute.post = AsyncioMagicMock( + side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported") + ) + response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id)) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + async def test_suspend_all_nodes_tolerates_unsupported( + self, + app: FastAPI, + client: AsyncClient, + project: Project, + compute: Compute, + node: Node + ) -> None: + + # suspending all nodes of a mixed project stays best-effort: nodes + # without suspend support are skipped without failing the request + compute.post = AsyncioMagicMock( + side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported") + ) + response = await client.post(app.url_path_for("suspend_all_nodes", project_id=project.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT async def test_reload_node(