Merge branch '3.1' into fix/duplicate-device-configs-merge

Resolve conflicts against the upstream wave (PRs #2952-#2965, incl. the
typed-response/schema hardening and the drop of Python 3.10):

- controller/node.py: keep upstream's _transitioning/_confirm_status
  lifecycle guard; the kernel-anchor link wiring (node_started) runs
  after the transition completes.
- controller/link.py: keep upstream's add_node rollback (release_ports
  on create failure) and delete via release_ports; release_ports now
  resolves ports live (same staleness reason as _bind_members) and
  discards the node link unconditionally.
- schemas/controller/links.py: extend LinkFilterType with the eight
  kernel-datapath filter types and LinkFilterParameter.type with "str"
  so the newly typed /available_filters response validates; Link.filters
  stays a free-form dict — the vocabulary is capability-gated per link
  and validated by the controller (409), not by a strict request schema.
- schemas/controller/topology.py: drop the type: ignore made redundant
  by the dict filters field.
- tests: route-level filter validation now expects the controller's 409
  (unknown types, out-of-range values); packet_loss correlation is a
  valid kernel parameter (positive test added); OpenAPI assertions match
  the free-form filters property.
This commit is contained in:
YueGuobin committed 2026-10-09 10:03:50 +08:00
commit 1e0d123f4b
60 files changed
+2654 -271

No files matched your search

@@ -101,3 +101,33 @@ class TestApplianceRoutes:
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)
assert response.status_code == status.HTTP_409_CONFLICT
async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)
assert response.status_code == status.HTTP_201_CREATED
assert new_version in response.json()["versions"]
async def test_add_version_appliance_with_registry_version(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
stored_version = {"name": "explicit-99G", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id),
json=dict(stored_version, registry_version=3),
)
assert response.status_code == status.HTTP_201_CREATED
assert stored_version in response.json()["versions"]
async def test_add_version_appliance_unknown_registry_version(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id),
json={"name": "bad", "registry_version": 7, "images": {"hda_disk_image": "empty99G.qcow2"}},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+256
View File
@@ -115,6 +115,110 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_409_CONFLICT
assert len(project.links) == 0
async def test_create_link_by_port_name(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "port_name": "E0"},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert response.status_code == status.HTTP_201_CREATED
first, second = response.json()["nodes"]
assert (first["adapter_number"], first["port_number"]) == (0, 3)
assert (second["adapter_number"], second["port_number"]) == (2, 4)
async def test_create_link_unknown_port_name(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "port_name": "nope"},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert len(project.links) == 0
async def test_create_link_auto_port(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
node1._ports = [EthernetPort("E1", 0, 0, 5), EthernetPort("E0", 0, 0, 3)]
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={"nodes": [{"node_id": node1.id, "port": "auto"}, {"node_id": node2.id, "port": "auto"}]},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["nodes"][0]["port_number"] == 3
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={"nodes": [{"node_id": node1.id, "port": "auto"}, {"node_id": node2.id, "port": "auto"}]},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json()["code"] == "no_free_port"
assert len(project.links) == 1
async def test_create_link_port_in_use_code(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
body = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
first = await client.post(app.url_path_for("create_link", project_id=project.id), json=body)
second = await client.post(app.url_path_for("create_link", project_id=project.id), json=body)
assert first.status_code == status.HTTP_201_CREATED
assert second.status_code == status.HTTP_409_CONFLICT
assert second.json()["code"] == "port_in_use"
assert len(project.links) == 1
async def test_create_link_invalid_port_selector(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
valid = {"node_id": node2.id, "adapter_number": 2, "port_number": 4}
for bad in (
{"node_id": node1.id},
{"node_id": node1.id, "adapter_number": 0},
{"node_id": node1.id, "port_name": "E0", "port": "auto"},
{"node_id": node1.id, "adapter_number": 0, "port_number": 3, "port_name": "E0"},
):
response = await client.post(
app.url_path_for("create_link", project_id=project.id), json={"nodes": [bad, valid]}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_get_link(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
@@ -170,6 +274,43 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["kernel_datapath"] is True
async def test_link_etag_and_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
link_id = response.json()["link_id"]
url = app.url_path_for("get_link", project_id=project.id, link_id=link_id)
response = await client.get(url)
assert response.status_code == status.HTTP_200_OK
etag = response.headers["ETag"]
assert (await client.get(url)).headers["ETag"] == etag
response = await client.put(url, json={"show_filters_icon": False}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_200_OK
assert response.headers["ETag"] != etag
assert (await client.get(url)).headers["ETag"] == response.headers["ETag"]
response = await client.put(url, json={"show_filters_icon": True}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
assert "message" in response.json()
assert (await client.get(url)).json()["show_filters_icon"] is False
response = await client.put(url, json={"show_filters_icon": True})
assert response.status_code == status.HTTP_200_OK
assert response.json()["show_filters_icon"] is True
async def test_update_link_suspend(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
@@ -362,6 +503,119 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json() == FILTERS
async def test_update_link_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
filters = {
"frequency_drop": [50],
"packet_loss": [10],
"delay": [10, 5],
"corrupt": [3],
"bpf": ["icmp"],
}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == filters
assert link.filters == filters
async def test_update_link_disabled_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"delay": [0, 0], "packet_loss": [0], "corrupt": [], "bpf": [""]}},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == {}
@pytest.mark.parametrize(
"filters",
[
{"packet_loss": [101]},
{"packet_loss": [-1]},
{"corrupt": [101]},
{"frequency_drop": [-2]},
{"frequency_drop": [32768]},
{"delay": [32768, 0]},
{"delay": [10, -1]},
# a second packet_loss value (correlation) is valid — it is a
# kernel-datapath parameter the controller accepts; gemodel out
# of range stands in for the invalid slot instead
{"gemodel": [101, 50, 50]},
{"bpf": [1]},
{"packet_loss": ["abc"]},
],
)
async def test_update_link_invalid_filter_values(
self, app: FastAPI, client: AsyncClient, project: Project, filters: dict
) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
# The filter vocabulary is capability-gated per link, so values are
# validated by the controller (409), not by a strict request schema
assert response.status_code == status.HTTP_409_CONFLICT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_unknown_filter_type(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"unknown": [1]}},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_packet_loss_correlation(
self, app: FastAPI, client: AsyncClient, project: Project
) -> None:
# The optional packet_loss correlation (second value) is a
# kernel-datapath parameter accepted by the controller
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"packet_loss": [1, 2]}},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == {"packet_loss": [1, 2]}
async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": {"delay": [0, 5]}}
)
assert response.status_code == status.HTTP_409_CONFLICT
async def test_openapi_link_filters(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
# The filters vocabulary is capability-gated per link and validated by
# the controller, so the Link schema carries it as a free-form object
# (no strict LinkFilters model on the request path)
assert "LinkFilters" not in schemas
filters_property = schemas["Link"]["properties"]["filters"]
assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"}
assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
"$ref": "#/components/schemas/LinkFilterParameter"
}
path = "/v3/projects/{project_id}/links/{link_id}/available_filters"
responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"}
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
Test getting UDP tunnel interface information from a link.
@@ -403,6 +657,7 @@ class TestLinkRoutes:
assert result["rhost"] == "127.0.0.1"
assert result["rport"] == 30000
assert result["type"] == "udp"
assert result["kind"] == "udp"
async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
@@ -433,3 +688,4 @@ class TestLinkRoutes:
assert result["node_id"] == cloud_node.id
assert result["interface"] == "eth0"
assert result["type"] == "ethernet"
assert result["kind"] == "ethernet"
+156 -3
View File
@@ -34,6 +34,7 @@ from pydantic import SecretStr
from gns3server.api.routes.controller.nodes import vnc_console, ws_console
from gns3server.config import Config
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import ComputeError
from gns3server.controller.node import Node
from gns3server.controller.project import Project
from gns3server.services import auth_service
@@ -250,6 +251,77 @@ class TestNodeRoutes:
assert "name" not in response.json()["properties"]
assert response.json()["tags"] == ["tag1", "tag2"]
async def test_node_etag_on_get_and_put(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
response = await client.get(url)
assert response.status_code == status.HTTP_200_OK
etag = response.headers["ETag"]
assert etag.startswith('"') and etag.endswith('"')
assert (await client.get(url)).headers["ETag"] == etag
response = await client.put(url, json={"name": "renamed"})
assert response.status_code == status.HTTP_200_OK
assert response.headers["ETag"] != etag
assert (await client.get(url)).headers["ETag"] == response.headers["ETag"]
async def test_update_node_with_matching_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
etag = (await client.get(url)).headers["ETag"]
response = await client.put(url, json={"name": "renamed"}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "renamed"
assert response.headers["ETag"] != etag
async def test_update_node_with_stale_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
stale_etag = (await client.get(url)).headers["ETag"]
assert (await client.put(url, json={"name": "first"})).status_code == status.HTTP_200_OK
response = await client.put(url, json={"name": "second"}, headers={"If-Match": stale_etag})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
assert "message" in response.json()
assert node.name == "first"
async def test_update_node_without_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
assert (await client.put(url, json={"name": "first"})).status_code == status.HTTP_200_OK
assert (await client.put(url, json={"name": "second"})).status_code == status.HTTP_200_OK
assert node.name == "second"
async def test_update_node_with_wildcard_and_list_if_match(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048}))
url = app.url_path_for("update_node", project_id=project.id, node_id=node.id)
etag = (await client.get(url)).headers["ETag"]
response = await client.put(url, json={"name": "a"}, headers={"If-Match": "*"})
assert response.status_code == status.HTTP_200_OK
etag = (await client.get(url)).headers["ETag"]
response = await client.put(url, json={"name": "b"}, headers={"If-Match": f'"other", {etag}'})
assert response.status_code == status.HTTP_200_OK
async def test_start_all_nodes(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
compute.post = AsyncioMagicMock()
@@ -292,7 +364,8 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={})
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "started"
async def test_stop_node(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
@@ -300,6 +373,84 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "stopped"
async def test_start_node_wait(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.post = AsyncioMagicMock()
response = await client.post(
app.url_path_for("start_node", project_id=project.id, node_id=node.id) + "?wait=true&timeout=5", json={}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "started"
response = await client.get(app.url_path_for("get_node", project_id=project.id, node_id=node.id))
assert response.json()["status"] == "started"
async def test_start_node_reports_starting_while_in_flight(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
seen = []
async def post(*args, **kwargs):
seen.append(node.asdict()["status"])
compute.post = post
response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={})
assert response.status_code == status.HTTP_200_OK
assert seen == ["starting"]
assert response.json()["status"] == "started"
async def test_stop_node_reports_stopping_while_in_flight(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
seen = []
async def post(*args, **kwargs):
seen.append(node.asdict()["status"])
compute.post = post
node._status = "started"
response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_200_OK
assert seen == ["stopping"]
assert response.json()["status"] == "stopped"
async def test_start_node_wait_timeout(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
async def post(*args, **kwargs):
await asyncio.sleep(10)
compute.post = post
response = await client.post(
app.url_path_for("start_node", project_id=project.id, node_id=node.id) + "?wait=true&timeout=1", json={}
)
assert response.status_code == status.HTTP_408_REQUEST_TIMEOUT
assert "message" in response.json()
assert node.asdict()["status"] == "stopped"
async def test_stop_node_reports_compute_error(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.post = AsyncioMagicMock(side_effect=ComputeError("compute unreachable"))
node._status = "started"
response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_409_CONFLICT
assert node.asdict()["status"] == "started"
async def test_stop_all_nodes_tolerates_compute_error(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
) -> None:
compute.post = AsyncioMagicMock(side_effect=ComputeError("compute unreachable"))
response = await client.post(app.url_path_for("stop_all_nodes", project_id=project.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_suspend_node(
@@ -308,7 +459,8 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "suspended"
async def test_suspend_node_unsupported(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
@@ -338,7 +490,8 @@ class TestNodeRoutes:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("reload_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "started"
async def test_isolate_node(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node
@@ -0,0 +1,88 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import uuid
import pytest
from fastapi import FastAPI
from pydantic import ValidationError
from gns3server import schemas
from gns3server.utils.notification_queue import NotificationQueue
STREAM_PATHS = ["/v3/notifications", "/v3/projects/{project_id}/notifications"]
class TestNotificationOpenAPI:
def test_notification_schema_in_spec(self, app: FastAPI) -> None:
spec = app.openapi()
schema = spec["components"]["schemas"]["Notification"]
assert set(schema["required"]) == {"action", "event"}
assert {"action", "event", "project_id", "compute_id"} <= set(schema["properties"])
action_schema = spec["components"]["schemas"]["NotificationAction"]
assert {"ping", "node.created", "node.updated", "link.created", "marker.match"} <= set(action_schema["enum"])
@pytest.mark.parametrize("path", STREAM_PATHS)
def test_stream_responses_reference_schema(self, app: FastAPI, path: str) -> None:
content = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]
assert list(content) == ["application/x-ndjson"]
assert content["application/x-ndjson"]["schema"] == {"$ref": "#/components/schemas/Notification"}
class TestNotificationSchema:
def test_ping(self) -> None:
message = {
"action": "ping",
"event": {"cpu_usage_percent": 1.5, "memory_usage_percent": 40.0, "disk_usage_percent": 10.0},
}
notification = schemas.Notification.model_validate(message)
assert notification.action == schemas.NotificationAction.ping
def test_unknown_action(self) -> None:
with pytest.raises(ValidationError):
schemas.Notification.model_validate({"action": "unknown.action", "event": {}})
def test_optional_ids(self) -> None:
project_id = str(uuid.uuid4())
notification = schemas.Notification.model_validate(
{"action": "log.info", "event": {"message": "hello"}, "project_id": project_id, "compute_id": "local"}
)
assert str(notification.project_id) == project_id
assert notification.compute_id == "local"
def test_generic_event(self) -> None:
notification = schemas.Notification.model_validate(
{"action": "template.deleted", "event": {"template_id": str(uuid.uuid4())}}
)
assert notification.action == schemas.NotificationAction.template_deleted
@pytest.mark.asyncio
async def test_queue_message_matches_schema(self) -> None:
queue = NotificationQueue()
queue.put_nowait(("log.warning", {"message": "disk space is low"}, {"compute_id": "local"}))
await queue.get_json(5)
message = json.loads(await queue.get_json(5))
notification = schemas.Notification.model_validate(message)
assert notification.action == schemas.NotificationAction.log_warning
assert notification.compute_id == "local"
@@ -139,6 +139,33 @@ class TestControllerProjectRoutes:
assert response.json()["path"] == path
assert response.json()["project_id"] == "10010203-0405-0607-0809-0a0b0c0d0e0f"
async def test_project_etag_and_if_match(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None:
params = {"name": "test", "project_id": "10010203-0405-0607-0809-0a0b0c0d0e0f"}
assert (await client.post(app.url_path_for("create_project"), json=params)).status_code == 201
get_url = app.url_path_for("get_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f")
put_url = app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f")
response = await client.get(get_url)
assert response.status_code == status.HTTP_200_OK
etag = response.headers["ETag"]
assert (await client.get(get_url)).headers["ETag"] == etag
response = await client.put(put_url, json={"name": "test2"}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_200_OK
new_etag = response.headers["ETag"]
assert new_etag != etag
assert (await client.get(get_url)).headers["ETag"] == new_etag
response = await client.put(put_url, json={"name": "test3"}, headers={"If-Match": etag})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
assert "message" in response.json()
assert (await client.get(get_url)).json()["name"] == "test2"
response = await client.put(put_url, json={"name": "test4"})
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "test4"
async def test_update_project_with_variables(
self, app: FastAPI, client: AsyncClient, controller: Controller
) -> None:
@@ -17,6 +17,7 @@
import os
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
@@ -54,6 +55,9 @@ class TestSnapshotRoutes:
response = await client.get(app.url_path_for("get_snapshots", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1
data = response.json()[0]
assert isinstance(data["created_at"], int)
assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc)
async def test_delete_snapshot(
self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot
@@ -80,3 +84,15 @@ class TestSnapshotRoutes:
response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"})
assert response.status_code == status.HTTP_201_CREATED
assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2
data = response.json()
assert isinstance(data["created_at"], int)
assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc)
async def test_snapshot_schema_timestamps(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
assert schemas["Snapshot"]["properties"]["created"]["format"] == "date-time"
assert schemas["Snapshot"]["properties"]["created_at"]["type"] == "integer"
assert schemas["Snapshot"]["properties"]["created_at"]["deprecated"] is True
for field in ("created_at", "modified_at"):
assert {"type": "string", "format": "date-time"} in schemas["NodeFile"]["properties"][field]["anyOf"]
+68
View File
@@ -446,6 +446,74 @@ class TestLogout:
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestTokenLifetime:
async def _issue(self, app, client, user, endpoint):
credentials = {"username": user.username, "password": "user1_password"}
if endpoint == "login":
return await client.post(
app.url_path_for("login"),
data=credentials,
headers={"content-type": "application/x-www-form-urlencoded"},
)
auth = await client.post(app.url_path_for("authenticate"), json=credentials)
if endpoint == "authenticate":
return auth
return await client.post(
app.url_path_for("refresh_access_token"), json={"refresh_token": auth.json()["refresh_token"]}
)
@pytest.mark.parametrize("endpoint", ("login", "authenticate", "refresh_access_token"))
async def test_token_response_includes_lifetimes(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
config: Config,
endpoint: str,
) -> None:
response = await self._issue(app, unauthorized_client, test_user, endpoint)
assert response.status_code == status.HTTP_200_OK
body = response.json()
controller = config.settings.Controller
assert body["expires_in"] == controller.jwt_access_token_expire_minutes * 60
assert body["refresh_expires_in"] == controller.jwt_refresh_token_expire_minutes * 60
assert body["access_token"]
assert body["token_type"] == "bearer"
assert body["refresh_token"]
@pytest.mark.parametrize("endpoint", ("login", "authenticate", "refresh_access_token"))
async def test_expires_in_matches_jwt_claims(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
config: Config,
endpoint: str,
) -> None:
response = await self._issue(app, unauthorized_client, test_user, endpoint)
body = response.json()
key = OctKey.import_key(config.settings.Controller.jwt_secret_key)
access = jwt.decode(body["access_token"], key, algorithms=["HS256"]).claims
refresh = jwt.decode(body["refresh_token"], key, algorithms=["HS256"]).claims
assert access["exp"] - access["iat"] == body["expires_in"]
assert refresh["exp"] - refresh["iat"] == body["refresh_expires_in"]
async def test_openapi_declares_token_lifetimes_as_integers(self, app: FastAPI) -> None:
schema = app.openapi()["components"]["schemas"]["Token"]
assert schema["properties"]["expires_in"]["type"] == "integer"
refresh_expires_in = schema["properties"]["refresh_expires_in"]
assert "integer" in str(refresh_expires_in)
assert "expires_in" in schema["required"]
async def test_openapi_security_scheme_mentions_api_keys(self, app: FastAPI) -> None:
schemes = app.openapi()["components"]["securitySchemes"]
assert any("API key" in scheme.get("description", "") for scheme in schemes.values())
class TestRefreshToken:
async def test_login_returns_refresh_token(
self,
+4 -1
View File
@@ -42,7 +42,10 @@ class TestVersionRoutes:
params = {"version": "0.4.2"}
response = await client.post(app.url_path_for("check_version"), json=params)
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json() == {"message": f"Client version 0.4.2 is not the same as server version {__version__}"}
assert response.json() == {
"message": f"Client version 0.4.2 is not the same as server version {__version__}",
"code": "conflict",
}
async def test_version_invalid_input_schema(self, app: FastAPI, client: AsyncClient) -> None: