mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
Phase 4: Implement REST API component
- Add FastAPI application with lifespan management - Implement bearer token authentication (read/admin levels) - Create comprehensive REST API routes: - Nodes: list, get by public key - Node tags: CRUD operations - Messages: list with filters, get by ID - Advertisements: list with filters, get by ID - Telemetry: list with filters, get by ID - Trace paths: list with filters, get by ID - Commands: send message, channel message, advertisement - Dashboard: stats API and HTML dashboard - Add API CLI command for running the server - Create API test suite with 44 passing tests Routes use proper RESTful status codes (201 Created, 204 No Content). Authentication is optional - when keys not configured, endpoints are open.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
"""API test fixtures."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.api.app import create_app
|
||||
from meshcore_hub.api.dependencies import get_db_session, get_mqtt_client, get_db_manager
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
Base,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_path():
|
||||
"""Create a temporary database file path."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
yield path
|
||||
# Cleanup
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_db_engine(test_db_path):
|
||||
"""Create a SQLite database engine for API testing."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_db_session(api_db_engine):
|
||||
"""Create a database session for API testing."""
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mqtt():
|
||||
"""Create a mock MQTT client."""
|
||||
mock = MagicMock()
|
||||
mock.connect.return_value = None
|
||||
mock.start_background.return_value = None
|
||||
mock.stop.return_value = None
|
||||
mock.disconnect.return_value = None
|
||||
mock.publish_command.return_value = None
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_manager(api_db_engine):
|
||||
"""Create a mock database manager using the test engine."""
|
||||
manager = MagicMock(spec=DatabaseManager)
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
manager.get_session = lambda: Session()
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_no_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
|
||||
"""Create a FastAPI app with no authentication required."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
# Patch the global db_manager to avoid lifespan issues
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key=None,
|
||||
admin_key=None,
|
||||
)
|
||||
|
||||
# Create session maker for this test engine
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
|
||||
"""Create a FastAPI app with authentication enabled."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key="test-read-key",
|
||||
admin_key="test-admin-key",
|
||||
)
|
||||
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_no_auth(app_no_auth, mock_db_manager):
|
||||
"""Create a test client with no authentication.
|
||||
|
||||
Uses raise_server_exceptions=False to skip lifespan events.
|
||||
"""
|
||||
# Don't use context manager to skip lifespan
|
||||
client = TestClient(app_no_auth, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_auth(app_with_auth, mock_db_manager):
|
||||
"""Create a test client with authentication enabled.
|
||||
|
||||
Uses raise_server_exceptions=False to skip lifespan events.
|
||||
"""
|
||||
client = TestClient(app_with_auth, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node(api_db_session):
|
||||
"""Create a sample node in the database."""
|
||||
node = Node(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="Test Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_tag(api_db_session, sample_node):
|
||||
"""Create a sample node tag in the database."""
|
||||
tag = NodeTag(
|
||||
node_id=sample_node.id,
|
||||
key="environment",
|
||||
value="production",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message(api_db_session):
|
||||
"""Create a sample message in the database."""
|
||||
message = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="abc123",
|
||||
text="Hello World",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_advertisement(api_db_session):
|
||||
"""Create a sample advertisement in the database."""
|
||||
advert = Advertisement(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="TestNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_telemetry(api_db_session):
|
||||
"""Create a sample telemetry record in the database."""
|
||||
telemetry = Telemetry(
|
||||
node_public_key="abc123def456abc123def456abc123de",
|
||||
parsed_data={
|
||||
"battery_level": 85.5,
|
||||
"temperature": 25.3,
|
||||
},
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(telemetry)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(telemetry)
|
||||
return telemetry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_trace_path(api_db_session):
|
||||
"""Create a sample trace path in the database."""
|
||||
trace = TracePath(
|
||||
initiator_tag=12345,
|
||||
path_hashes=["abc123", "def456", "ghi789"],
|
||||
hop_count=3,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(trace)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(trace)
|
||||
return trace
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for advertisement API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListAdvertisements:
|
||||
"""Tests for GET /advertisements endpoint."""
|
||||
|
||||
def test_list_advertisements_empty(self, client_no_auth):
|
||||
"""Test listing advertisements when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/advertisements")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_advertisements_with_data(self, client_no_auth, sample_advertisement):
|
||||
"""Test listing advertisements with data in database."""
|
||||
response = client_no_auth.get("/api/v1/advertisements")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["public_key"] == sample_advertisement.public_key
|
||||
assert data["items"][0]["adv_type"] == sample_advertisement.adv_type
|
||||
|
||||
def test_list_advertisements_filter_by_public_key(
|
||||
self, client_no_auth, sample_advertisement
|
||||
):
|
||||
"""Test filtering advertisements by public key."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?public_key={sample_advertisement.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/advertisements?public_key=nonexistent"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetAdvertisement:
|
||||
"""Tests for GET /advertisements/{id} endpoint."""
|
||||
|
||||
def test_get_advertisement_success(self, client_no_auth, sample_advertisement):
|
||||
"""Test getting a specific advertisement."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements/{sample_advertisement.id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_advertisement.id
|
||||
assert data["public_key"] == sample_advertisement.public_key
|
||||
|
||||
def test_get_advertisement_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent advertisement."""
|
||||
response = client_no_auth.get("/api/v1/advertisements/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for API authentication."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAuthenticationFlow:
|
||||
"""Tests for authentication behavior."""
|
||||
|
||||
def test_no_auth_when_keys_not_configured(self, client_no_auth):
|
||||
"""Test that no auth is required when keys are not configured."""
|
||||
# All endpoints should work without auth
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client_no_auth.get("/api/v1/messages")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_read_endpoints_accept_read_key(self, client_with_auth):
|
||||
"""Test that read endpoints accept read key."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_read_endpoints_accept_admin_key(self, client_with_auth):
|
||||
"""Test that read endpoints accept admin key."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_admin_endpoints_reject_read_key(self, client_with_auth):
|
||||
"""Test that admin endpoints reject read key."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Test",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_admin_endpoints_accept_admin_key(self, client_with_auth):
|
||||
"""Test that admin endpoints accept admin key."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Test",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_invalid_key_rejected(self, client_with_auth):
|
||||
"""Test that invalid keys are rejected."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer invalid-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_missing_bearer_prefix_rejected(self, client_with_auth):
|
||||
"""Test that tokens without Bearer prefix are rejected."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "test-read-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_empty_auth_header_rejected(self, client_with_auth):
|
||||
"""Test that empty auth headers are rejected."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": ""},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Tests for health check endpoint."""
|
||||
|
||||
def test_health_no_auth(self, client_no_auth):
|
||||
"""Test health endpoint without auth."""
|
||||
response = client_no_auth.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_health_with_auth_configured(self, client_with_auth):
|
||||
"""Test health endpoint works even when auth is configured."""
|
||||
# Health endpoint should always be accessible
|
||||
response = client_with_auth.get("/health")
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for command API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSendMessage:
|
||||
"""Tests for POST /commands/send-message endpoint."""
|
||||
|
||||
def test_send_message_success(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending a direct message."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello World",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "queued" in data["message"].lower()
|
||||
|
||||
def test_send_message_requires_admin(self, client_with_auth):
|
||||
"""Test sending message requires admin authentication."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestSendChannelMessage:
|
||||
"""Tests for POST /commands/send-channel-message endpoint."""
|
||||
|
||||
def test_send_channel_message_success(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending a channel message."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-channel-message",
|
||||
json={
|
||||
"channel_idx": 1,
|
||||
"text": "Hello Channel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "channel 1" in data["message"].lower()
|
||||
|
||||
def test_send_channel_message_requires_admin(self, client_with_auth):
|
||||
"""Test sending channel message requires admin authentication."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-channel-message",
|
||||
json={
|
||||
"channel_idx": 1,
|
||||
"text": "Hello",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestSendAdvertisement:
|
||||
"""Tests for POST /commands/send-advertisement endpoint."""
|
||||
|
||||
def test_send_advertisement_success(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending an advertisement."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-advertisement",
|
||||
json={"flood": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "advertisement" in data["message"].lower()
|
||||
|
||||
def test_send_advertisement_with_flood(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending an advertisement with flood enabled."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-advertisement",
|
||||
json={"flood": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "flood=True" in data["message"]
|
||||
|
||||
def test_send_advertisement_requires_admin(self, client_with_auth):
|
||||
"""Test sending advertisement requires admin authentication."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-advertisement",
|
||||
json={"flood": False},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for dashboard API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboardStats:
|
||||
"""Tests for GET /dashboard/stats endpoint."""
|
||||
|
||||
def test_get_stats_empty(self, client_no_auth):
|
||||
"""Test getting stats with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_nodes"] == 0
|
||||
assert data["active_nodes"] == 0
|
||||
assert data["total_messages"] == 0
|
||||
assert data["messages_today"] == 0
|
||||
assert data["total_advertisements"] == 0
|
||||
assert data["channel_message_counts"] == {}
|
||||
|
||||
def test_get_stats_with_data(
|
||||
self, client_no_auth, sample_node, sample_message, sample_advertisement
|
||||
):
|
||||
"""Test getting stats with data in database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_nodes"] == 1
|
||||
assert data["active_nodes"] == 1 # Node was just created
|
||||
assert data["total_messages"] == 1
|
||||
assert data["total_advertisements"] == 1
|
||||
|
||||
|
||||
class TestDashboardHtml:
|
||||
"""Tests for GET /dashboard/dashboard endpoint."""
|
||||
|
||||
def test_dashboard_html_response(self, client_no_auth):
|
||||
"""Test dashboard returns HTML."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "<!DOCTYPE html>" in response.text
|
||||
assert "MeshCore Hub Dashboard" in response.text
|
||||
|
||||
def test_dashboard_contains_stats(
|
||||
self, client_no_auth, sample_node, sample_message
|
||||
):
|
||||
"""Test dashboard HTML contains stat values."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/dashboard")
|
||||
assert response.status_code == 200
|
||||
# Check that stats are present
|
||||
assert "Total Nodes" in response.text
|
||||
assert "Active Nodes" in response.text
|
||||
assert "Total Messages" in response.text
|
||||
|
||||
def test_dashboard_contains_recent_data(self, client_no_auth, sample_node):
|
||||
"""Test dashboard HTML contains recent nodes."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "Recent Nodes" in response.text
|
||||
# The node name should appear in the table
|
||||
assert sample_node.name in response.text
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for message API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListMessages:
|
||||
"""Tests for GET /messages endpoint."""
|
||||
|
||||
def test_list_messages_empty(self, client_no_auth):
|
||||
"""Test listing messages when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/messages")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_messages_with_data(self, client_no_auth, sample_message):
|
||||
"""Test listing messages with data in database."""
|
||||
response = client_no_auth.get("/api/v1/messages")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["text"] == sample_message.text
|
||||
assert data["items"][0]["message_type"] == sample_message.message_type
|
||||
|
||||
def test_list_messages_filter_by_type(self, client_no_auth, sample_message):
|
||||
"""Test filtering messages by type."""
|
||||
response = client_no_auth.get("/api/v1/messages?message_type=direct")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
response = client_no_auth.get("/api/v1/messages?message_type=channel")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_list_messages_pagination(self, client_no_auth):
|
||||
"""Test message list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/messages?limit=25&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 25
|
||||
assert data["offset"] == 10
|
||||
|
||||
|
||||
class TestGetMessage:
|
||||
"""Tests for GET /messages/{id} endpoint."""
|
||||
|
||||
def test_get_message_success(self, client_no_auth, sample_message):
|
||||
"""Test getting a specific message."""
|
||||
response = client_no_auth.get(f"/api/v1/messages/{sample_message.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_message.id
|
||||
assert data["text"] == sample_message.text
|
||||
|
||||
def test_get_message_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent message."""
|
||||
response = client_no_auth.get("/api/v1/messages/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for node API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListNodes:
|
||||
"""Tests for GET /nodes endpoint."""
|
||||
|
||||
def test_list_nodes_empty(self, client_no_auth):
|
||||
"""Test listing nodes when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_nodes_with_data(self, client_no_auth, sample_node):
|
||||
"""Test listing nodes with data in database."""
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["public_key"] == sample_node.public_key
|
||||
assert data["items"][0]["name"] == sample_node.name
|
||||
|
||||
def test_list_nodes_pagination(self, client_no_auth, sample_node):
|
||||
"""Test node list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/nodes?limit=10&offset=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 10
|
||||
assert data["offset"] == 0
|
||||
|
||||
def test_list_nodes_with_auth_required(self, client_with_auth):
|
||||
"""Test listing nodes requires auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""Tests for GET /nodes/{public_key} endpoint."""
|
||||
|
||||
def test_get_node_success(self, client_no_auth, sample_node):
|
||||
"""Test getting a specific node."""
|
||||
response = client_no_auth.get(f"/api/v1/nodes/{sample_node.public_key}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
assert data["name"] == sample_node.name
|
||||
|
||||
def test_get_node_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent node."""
|
||||
response = client_no_auth.get("/api/v1/nodes/nonexistent123")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestNodeTags:
|
||||
"""Tests for node tag endpoints."""
|
||||
|
||||
def test_create_node_tag(self, client_no_auth, sample_node):
|
||||
"""Test creating a node tag."""
|
||||
response = client_no_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "location", "value": "building-a"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
data = response.json()
|
||||
assert data["key"] == "location"
|
||||
assert data["value"] == "building-a"
|
||||
|
||||
def test_get_node_tag(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test getting a specific node tag."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == sample_node_tag.key
|
||||
assert data["value"] == sample_node_tag.value
|
||||
|
||||
def test_update_node_tag(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test updating a node tag."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}",
|
||||
json={"value": "staging"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["value"] == "staging"
|
||||
|
||||
def test_delete_node_tag(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test deleting a node tag."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 204 # No Content
|
||||
|
||||
# Verify it's deleted
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_tag_crud_requires_admin(self, client_with_auth, sample_node):
|
||||
"""Test that tag CRUD operations require admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for telemetry API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListTelemetry:
|
||||
"""Tests for GET /telemetry endpoint."""
|
||||
|
||||
def test_list_telemetry_empty(self, client_no_auth):
|
||||
"""Test listing telemetry when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/telemetry")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_telemetry_with_data(self, client_no_auth, sample_telemetry):
|
||||
"""Test listing telemetry with data in database."""
|
||||
response = client_no_auth.get("/api/v1/telemetry")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["node_public_key"] == sample_telemetry.node_public_key
|
||||
assert data["items"][0]["parsed_data"] == sample_telemetry.parsed_data
|
||||
|
||||
def test_list_telemetry_filter_by_node(self, client_no_auth, sample_telemetry):
|
||||
"""Test filtering telemetry by node public key."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/telemetry?node_public_key={sample_telemetry.node_public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/telemetry?node_public_key=nonexistent"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetTelemetry:
|
||||
"""Tests for GET /telemetry/{id} endpoint."""
|
||||
|
||||
def test_get_telemetry_success(self, client_no_auth, sample_telemetry):
|
||||
"""Test getting a specific telemetry record."""
|
||||
response = client_no_auth.get(f"/api/v1/telemetry/{sample_telemetry.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_telemetry.id
|
||||
assert data["node_public_key"] == sample_telemetry.node_public_key
|
||||
|
||||
def test_get_telemetry_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent telemetry record."""
|
||||
response = client_no_auth.get("/api/v1/telemetry/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for trace path API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListTracePaths:
|
||||
"""Tests for GET /trace-paths endpoint."""
|
||||
|
||||
def test_list_trace_paths_empty(self, client_no_auth):
|
||||
"""Test listing trace paths when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_trace_paths_with_data(self, client_no_auth, sample_trace_path):
|
||||
"""Test listing trace paths with data in database."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["path_hashes"] == sample_trace_path.path_hashes
|
||||
assert data["items"][0]["hop_count"] == sample_trace_path.hop_count
|
||||
|
||||
|
||||
class TestGetTracePath:
|
||||
"""Tests for GET /trace-paths/{id} endpoint."""
|
||||
|
||||
def test_get_trace_path_success(self, client_no_auth, sample_trace_path):
|
||||
"""Test getting a specific trace path."""
|
||||
response = client_no_auth.get(f"/api/v1/trace-paths/{sample_trace_path.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_trace_path.id
|
||||
assert data["path_hashes"] == sample_trace_path.path_hashes
|
||||
|
||||
def test_get_trace_path_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent trace path."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user