mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-08 09:53:05 +02:00
Add admin interface for managing node tags
Implement CRUD operations for NodeTags in the admin interface:
- Add NodeTagMove schema for moving tags between nodes
- Add PUT /nodes/{public_key}/tags/{key}/move API endpoint
- Add web routes at /a/node-tags for tag management
- Create admin templates with node selector and tag management UI
- Support editing, adding, moving, and deleting tags via API calls
- Add comprehensive tests for new functionality
The interface allows selecting a node from a dropdown, viewing its
tags, and performing all CRUD operations including moving a tag
to a different node without having to delete and recreate it.
This commit is contained in:
@@ -158,3 +158,133 @@ class TestNodeTags:
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
|
||||
|
||||
class TestMoveNodeTag:
|
||||
"""Tests for PUT /nodes/{public_key}/tags/{key}/move endpoint."""
|
||||
|
||||
# 64-character public key for testing
|
||||
DEST_PUBLIC_KEY = "xyz789xyz789xyz789xyz789xyz789xyabc123abc123abc123abc123abc123ab"
|
||||
|
||||
def test_move_node_tag_success(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test successfully moving a tag to another node."""
|
||||
from meshcore_hub.common.models import Node
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create a second node with 64-char public key
|
||||
second_node = Node(
|
||||
public_key=self.DEST_PUBLIC_KEY,
|
||||
name="Second Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_node)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": second_node.public_key},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == sample_node_tag.key
|
||||
assert data["value"] == sample_node_tag.value
|
||||
|
||||
# Verify tag is no longer on original node
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Verify tag is now on new node
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{second_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_move_node_tag_source_not_found(self, client_no_auth):
|
||||
"""Test moving a tag from a non-existent node."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/nodes/nonexistent123/tags/somekey/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Source node not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_tag_not_found(self, client_no_auth, sample_node):
|
||||
"""Test moving a non-existent tag."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/nonexistent/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Tag not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_dest_not_found(
|
||||
self, client_no_auth, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test moving a tag to a non-existent destination node."""
|
||||
# 64-character nonexistent public key
|
||||
nonexistent_key = (
|
||||
"1111111111111111111111111111111122222222222222222222222222222222"
|
||||
)
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": nonexistent_key},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Destination node not found" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_conflict(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test moving a tag when destination already has that key."""
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Create second node with same tag key
|
||||
second_node = Node(
|
||||
public_key=self.DEST_PUBLIC_KEY,
|
||||
name="Second Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(second_node)
|
||||
api_db_session.commit()
|
||||
|
||||
# Add the same tag key to second node
|
||||
existing_tag = NodeTag(
|
||||
node_id=second_node.id,
|
||||
key=sample_node_tag.key, # Same key
|
||||
value="different value",
|
||||
)
|
||||
api_db_session.add(existing_tag)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": second_node.public_key},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already exists on destination" in response.json()["detail"]
|
||||
|
||||
def test_move_node_tag_requires_admin(
|
||||
self, client_with_auth, sample_node, sample_node_tag
|
||||
):
|
||||
"""Test that move operation requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
|
||||
json={"new_public_key": self.DEST_PUBLIC_KEY},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user