mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-03 16:31:23 +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:
@@ -32,71 +32,11 @@ def cli(ctx: click.Context, log_level: str) -> None:
|
||||
# Import and register component CLIs
|
||||
from meshcore_hub.interface.cli import interface
|
||||
from meshcore_hub.collector.cli import collector
|
||||
from meshcore_hub.api.cli import api
|
||||
|
||||
cli.add_command(interface)
|
||||
cli.add_command(collector)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--host",
|
||||
type=str,
|
||||
default="0.0.0.0",
|
||||
envvar="API_HOST",
|
||||
help="API server host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
envvar="API_PORT",
|
||||
help="API server port",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default="sqlite:///./meshcore.db",
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
@click.option(
|
||||
"--read-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_READ_KEY",
|
||||
help="Read-only API key",
|
||||
)
|
||||
@click.option(
|
||||
"--admin-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_ADMIN_KEY",
|
||||
help="Admin API key",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable auto-reload for development",
|
||||
)
|
||||
def api(
|
||||
host: str,
|
||||
port: int,
|
||||
database_url: str,
|
||||
read_key: str | None,
|
||||
admin_key: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
"""Run the REST API server.
|
||||
|
||||
Provides REST API endpoints for querying data and sending commands.
|
||||
"""
|
||||
click.echo("Starting API server...")
|
||||
click.echo(f"Listening on: {host}:{port}")
|
||||
click.echo(f"Database: {database_url}")
|
||||
click.echo(f"Read key configured: {read_key is not None}")
|
||||
click.echo(f"Admin key configured: {admin_key is not None}")
|
||||
click.echo("API component not yet implemented.")
|
||||
cli.add_command(api)
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""FastAPI application for MeshCore Hub API."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global database manager (set during startup)
|
||||
_db_manager: DatabaseManager | None = None
|
||||
|
||||
|
||||
def get_db_manager() -> DatabaseManager:
|
||||
"""Get the global database manager."""
|
||||
if _db_manager is None:
|
||||
raise RuntimeError("Database not initialized")
|
||||
return _db_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
global _db_manager
|
||||
|
||||
# Get database URL from app state
|
||||
database_url = getattr(app.state, "database_url", "sqlite:///./meshcore.db")
|
||||
|
||||
# Initialize database
|
||||
logger.info(f"Initializing database: {database_url}")
|
||||
_db_manager = DatabaseManager(database_url)
|
||||
_db_manager.create_tables()
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
if _db_manager:
|
||||
_db_manager.dispose()
|
||||
_db_manager = None
|
||||
logger.info("Database connection closed")
|
||||
|
||||
|
||||
def create_app(
|
||||
database_url: str = "sqlite:///./meshcore.db",
|
||||
read_key: str | None = None,
|
||||
admin_key: str | None = None,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
cors_origins: list[str] | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
Args:
|
||||
database_url: Database connection URL
|
||||
read_key: Read-only API key
|
||||
admin_key: Admin API key
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
cors_origins: Allowed CORS origins
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="MeshCore Hub API",
|
||||
description="REST API for querying MeshCore network data and sending commands",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
# Store configuration in app state
|
||||
app.state.database_url = database_url
|
||||
app.state.read_key = read_key
|
||||
app.state.admin_key = admin_key
|
||||
app.state.mqtt_host = mqtt_host
|
||||
app.state.mqtt_port = mqtt_port
|
||||
app.state.mqtt_prefix = mqtt_prefix
|
||||
|
||||
# Configure CORS
|
||||
if cors_origins is None:
|
||||
cors_origins = ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
from meshcore_hub.api.routes import api_router
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
# Health check endpoints
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
"""Basic health check."""
|
||||
return {"status": "healthy", "version": __version__}
|
||||
|
||||
@app.get("/health/ready", tags=["Health"])
|
||||
async def health_ready() -> dict:
|
||||
"""Readiness check including database."""
|
||||
try:
|
||||
db = get_db_manager()
|
||||
with db.session_scope() as session:
|
||||
session.execute("SELECT 1")
|
||||
return {"status": "ready", "database": "connected"}
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "database": str(e)}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Authentication middleware for the API."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_api_keys(request: Request) -> tuple[str | None, str | None]:
|
||||
"""Get API keys from app state.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
Tuple of (read_key, admin_key)
|
||||
"""
|
||||
return (
|
||||
getattr(request.app.state, "read_key", None),
|
||||
getattr(request.app.state, "admin_key", None),
|
||||
)
|
||||
|
||||
|
||||
async def get_current_token(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
|
||||
) -> str | None:
|
||||
"""Extract bearer token from request.
|
||||
|
||||
Args:
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
Returns:
|
||||
Token string or None
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
async def require_read(
|
||||
request: Request,
|
||||
token: Annotated[str | None, Depends(get_current_token)],
|
||||
) -> str | None:
|
||||
"""Require read-level authentication.
|
||||
|
||||
Allows access if:
|
||||
- No API keys are configured (open access)
|
||||
- Token matches read key
|
||||
- Token matches admin key
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
token: Bearer token
|
||||
|
||||
Returns:
|
||||
Token string
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
read_key, admin_key = get_api_keys(request)
|
||||
|
||||
# If no keys configured, allow access
|
||||
if not read_key and not admin_key:
|
||||
return token
|
||||
|
||||
# Require token if keys are configured
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if token matches any key
|
||||
if token == read_key or token == admin_key:
|
||||
return token
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
token: Annotated[str | None, Depends(get_current_token)],
|
||||
) -> str:
|
||||
"""Require admin-level authentication.
|
||||
|
||||
Allows access if:
|
||||
- No admin key is configured (open access)
|
||||
- Token matches admin key
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
token: Bearer token
|
||||
|
||||
Returns:
|
||||
Token string
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
read_key, admin_key = get_api_keys(request)
|
||||
|
||||
# If no admin key configured, allow access
|
||||
if not admin_key:
|
||||
return token or ""
|
||||
|
||||
# Require token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if token matches admin key
|
||||
if token == admin_key:
|
||||
return token
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
|
||||
|
||||
# Dependency types for use in routes
|
||||
RequireRead = Annotated[str | None, Depends(require_read)]
|
||||
RequireAdmin = Annotated[str, Depends(require_admin)]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""API CLI commands."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--host",
|
||||
type=str,
|
||||
default="0.0.0.0",
|
||||
envvar="API_HOST",
|
||||
help="API server host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
envvar="API_PORT",
|
||||
help="API server port",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default="sqlite:///./meshcore.db",
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
@click.option(
|
||||
"--read-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_READ_KEY",
|
||||
help="Read-only API key (optional, enables read-level auth)",
|
||||
)
|
||||
@click.option(
|
||||
"--admin-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_ADMIN_KEY",
|
||||
help="Admin API key (optional, enables admin-level auth)",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host for commands",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_TOPIC_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
@click.option(
|
||||
"--cors-origins",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="CORS_ORIGINS",
|
||||
help="Comma-separated list of allowed CORS origins",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable auto-reload for development",
|
||||
)
|
||||
@click.pass_context
|
||||
def api(
|
||||
ctx: click.Context,
|
||||
host: str,
|
||||
port: int,
|
||||
database_url: str,
|
||||
read_key: str | None,
|
||||
admin_key: str | None,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_prefix: str,
|
||||
cors_origins: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
"""Run the REST API server.
|
||||
|
||||
Provides REST API endpoints for querying mesh network data and sending
|
||||
commands to devices via MQTT.
|
||||
|
||||
Examples:
|
||||
|
||||
# Run with defaults (no auth)
|
||||
meshcore-hub api
|
||||
|
||||
# Run with authentication
|
||||
meshcore-hub api --read-key secret --admin-key supersecret
|
||||
|
||||
# Run with CORS for web frontend
|
||||
meshcore-hub api --cors-origins "http://localhost:8080,http://localhost:3000"
|
||||
|
||||
# Development mode with auto-reload
|
||||
meshcore-hub api --reload
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from meshcore_hub.api.app import create_app
|
||||
|
||||
click.echo("=" * 50)
|
||||
click.echo("MeshCore Hub API Server")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Host: {host}")
|
||||
click.echo(f"Port: {port}")
|
||||
click.echo(f"Database: {database_url}")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {mqtt_prefix})")
|
||||
click.echo(f"Read key configured: {read_key is not None}")
|
||||
click.echo(f"Admin key configured: {admin_key is not None}")
|
||||
click.echo(f"CORS origins: {cors_origins or 'none'}")
|
||||
click.echo(f"Reload mode: {reload}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
# Parse CORS origins
|
||||
origins_list: list[str] | None = None
|
||||
if cors_origins:
|
||||
origins_list = [o.strip() for o in cors_origins.split(",")]
|
||||
|
||||
if reload:
|
||||
# For development, use uvicorn's reload feature
|
||||
# We need to pass app as string for reload to work
|
||||
click.echo("\nStarting in development mode with auto-reload...")
|
||||
click.echo("Note: Using default settings for reload mode.")
|
||||
|
||||
uvicorn.run(
|
||||
"meshcore_hub.api.app:create_app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
factory=True,
|
||||
)
|
||||
else:
|
||||
# For production, create app directly
|
||||
app = create_app(
|
||||
database_url=database_url,
|
||||
read_key=read_key,
|
||||
admin_key=admin_key,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
cors_origins=origins_list,
|
||||
)
|
||||
|
||||
click.echo("\nStarting API server...")
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""FastAPI dependencies for the API."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_db_manager(request: Request) -> DatabaseManager:
|
||||
"""Get database manager from app.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
DatabaseManager instance
|
||||
"""
|
||||
from meshcore_hub.api.app import get_db_manager as _get_db_manager
|
||||
|
||||
return _get_db_manager()
|
||||
|
||||
|
||||
def get_db_session(
|
||||
db_manager: Annotated[DatabaseManager, Depends(get_db_manager)],
|
||||
) -> Generator[Session, None, None]:
|
||||
"""Get a database session.
|
||||
|
||||
Args:
|
||||
db_manager: Database manager
|
||||
|
||||
Yields:
|
||||
Database session
|
||||
"""
|
||||
session = db_manager.get_session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_mqtt_client(request: Request) -> MQTTClient:
|
||||
"""Get an MQTT client for publishing commands.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
MQTTClient instance
|
||||
"""
|
||||
mqtt_host = getattr(request.app.state, "mqtt_host", "localhost")
|
||||
mqtt_port = getattr(request.app.state, "mqtt_port", 1883)
|
||||
mqtt_prefix = getattr(request.app.state, "mqtt_prefix", "meshcore")
|
||||
|
||||
config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
prefix=mqtt_prefix,
|
||||
client_id="meshcore-api",
|
||||
)
|
||||
|
||||
client = MQTTClient(config)
|
||||
return client
|
||||
|
||||
|
||||
# Dependency types for use in routes
|
||||
DbSession = Annotated[Session, Depends(get_db_session)]
|
||||
MqttClient = Annotated[MQTTClient, Depends(get_mqtt_client)]
|
||||
@@ -1 +1,28 @@
|
||||
"""API route handlers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.api.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.api.routes.node_tags import router as node_tags_router
|
||||
from meshcore_hub.api.routes.messages import router as messages_router
|
||||
from meshcore_hub.api.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.api.routes.trace_paths import router as trace_paths_router
|
||||
from meshcore_hub.api.routes.telemetry import router as telemetry_router
|
||||
from meshcore_hub.api.routes.commands import router as commands_router
|
||||
from meshcore_hub.api.routes.dashboard import router as dashboard_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include all routers
|
||||
api_router.include_router(nodes_router, prefix="/nodes", tags=["Nodes"])
|
||||
api_router.include_router(node_tags_router, tags=["Node Tags"])
|
||||
api_router.include_router(messages_router, prefix="/messages", tags=["Messages"])
|
||||
api_router.include_router(
|
||||
advertisements_router, prefix="/advertisements", tags=["Advertisements"]
|
||||
)
|
||||
api_router.include_router(
|
||||
trace_paths_router, prefix="/trace-paths", tags=["Trace Paths"]
|
||||
)
|
||||
api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"])
|
||||
api_router.include_router(commands_router, prefix="/commands", tags=["Commands"])
|
||||
api_router.include_router(dashboard_router, tags=["Dashboard"])
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Advertisement API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
from meshcore_hub.common.schemas.messages import AdvertisementList, AdvertisementRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AdvertisementList)
|
||||
async def list_advertisements(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: Optional[str] = Query(None, description="Filter by public key"),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> AdvertisementList:
|
||||
"""List advertisements with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Advertisement)
|
||||
|
||||
if public_key:
|
||||
query = query.where(Advertisement.public_key == public_key)
|
||||
|
||||
if since:
|
||||
query = query.where(Advertisement.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(Advertisement.received_at <= until)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
advertisements = session.execute(query).scalars().all()
|
||||
|
||||
return AdvertisementList(
|
||||
items=[AdvertisementRead.model_validate(a) for a in advertisements],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{advertisement_id}", response_model=AdvertisementRead)
|
||||
async def get_advertisement(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
advertisement_id: str,
|
||||
) -> AdvertisementRead:
|
||||
"""Get a single advertisement by ID."""
|
||||
query = select(Advertisement).where(Advertisement.id == advertisement_id)
|
||||
advertisement = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not advertisement:
|
||||
raise HTTPException(status_code=404, detail="Advertisement not found")
|
||||
|
||||
return AdvertisementRead.model_validate(advertisement)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Command API routes for sending messages to the mesh network."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin
|
||||
from meshcore_hub.api.dependencies import MqttClient
|
||||
from meshcore_hub.common.schemas.commands import (
|
||||
CommandResponse,
|
||||
SendAdvertCommand,
|
||||
SendChannelMessageCommand,
|
||||
SendMessageCommand,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/send-message", response_model=CommandResponse)
|
||||
async def send_message(
|
||||
_: RequireAdmin,
|
||||
mqtt: MqttClient,
|
||||
command: SendMessageCommand,
|
||||
) -> CommandResponse:
|
||||
"""Send a direct message to a node.
|
||||
|
||||
Publishes a send_msg command to MQTT for the sender interface to process.
|
||||
"""
|
||||
try:
|
||||
# Connect to MQTT
|
||||
mqtt.connect()
|
||||
mqtt.start_background()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"destination": command.destination,
|
||||
"text": command.text,
|
||||
"timestamp": command.timestamp or int(time.time()),
|
||||
}
|
||||
|
||||
# Publish to wildcard topic (any sender can pick it up)
|
||||
mqtt.publish_command("+", "send_msg", payload)
|
||||
|
||||
# Cleanup
|
||||
mqtt.stop()
|
||||
mqtt.disconnect()
|
||||
|
||||
logger.info(f"Published send_msg command to {command.destination[:12]}...")
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Message queued for {command.destination[:12]}...",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message: {e}")
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Failed to send message: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-channel-message", response_model=CommandResponse)
|
||||
async def send_channel_message(
|
||||
_: RequireAdmin,
|
||||
mqtt: MqttClient,
|
||||
command: SendChannelMessageCommand,
|
||||
) -> CommandResponse:
|
||||
"""Send a message to a channel.
|
||||
|
||||
Publishes a send_channel_msg command to MQTT for the sender interface to process.
|
||||
"""
|
||||
try:
|
||||
# Connect to MQTT
|
||||
mqtt.connect()
|
||||
mqtt.start_background()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"channel_idx": command.channel_idx,
|
||||
"text": command.text,
|
||||
"timestamp": command.timestamp or int(time.time()),
|
||||
}
|
||||
|
||||
# Publish to wildcard topic
|
||||
mqtt.publish_command("+", "send_channel_msg", payload)
|
||||
|
||||
# Cleanup
|
||||
mqtt.stop()
|
||||
mqtt.disconnect()
|
||||
|
||||
logger.info(f"Published send_channel_msg command to channel {command.channel_idx}")
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Message queued for channel {command.channel_idx}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel message: {e}")
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Failed to send channel message: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-advertisement", response_model=CommandResponse)
|
||||
async def send_advertisement(
|
||||
_: RequireAdmin,
|
||||
mqtt: MqttClient,
|
||||
command: SendAdvertCommand,
|
||||
) -> CommandResponse:
|
||||
"""Send a node advertisement.
|
||||
|
||||
Publishes a send_advert command to MQTT for the sender interface to process.
|
||||
"""
|
||||
try:
|
||||
# Connect to MQTT
|
||||
mqtt.connect()
|
||||
mqtt.start_background()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"flood": command.flood,
|
||||
}
|
||||
|
||||
# Publish to wildcard topic
|
||||
mqtt.publish_command("+", "send_advert", payload)
|
||||
|
||||
# Cleanup
|
||||
mqtt.stop()
|
||||
mqtt.disconnect()
|
||||
|
||||
logger.info(f"Published send_advert command (flood={command.flood})")
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Advertisement queued (flood={command.flood})",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send advertisement: {e}")
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Failed to send advertisement: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Dashboard API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Advertisement, Message, Node
|
||||
from meshcore_hub.common.schemas.messages import DashboardStats
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def get_stats(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
) -> DashboardStats:
|
||||
"""Get dashboard statistics."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
|
||||
# Total nodes
|
||||
total_nodes = session.execute(
|
||||
select(func.count()).select_from(Node)
|
||||
).scalar() or 0
|
||||
|
||||
# Active nodes (last 24h)
|
||||
active_nodes = session.execute(
|
||||
select(func.count()).select_from(Node).where(Node.last_seen >= yesterday)
|
||||
).scalar() or 0
|
||||
|
||||
# Total messages
|
||||
total_messages = session.execute(
|
||||
select(func.count()).select_from(Message)
|
||||
).scalar() or 0
|
||||
|
||||
# Messages today
|
||||
messages_today = session.execute(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= today_start)
|
||||
).scalar() or 0
|
||||
|
||||
# Total advertisements
|
||||
total_advertisements = session.execute(
|
||||
select(func.count()).select_from(Advertisement)
|
||||
).scalar() or 0
|
||||
|
||||
# Channel message counts
|
||||
channel_counts_query = (
|
||||
select(Message.channel_idx, func.count())
|
||||
.where(Message.message_type == "channel")
|
||||
.where(Message.channel_idx.isnot(None))
|
||||
.group_by(Message.channel_idx)
|
||||
)
|
||||
channel_results = session.execute(channel_counts_query).all()
|
||||
channel_message_counts = {
|
||||
int(channel): int(count) for channel, count in channel_results
|
||||
}
|
||||
|
||||
return DashboardStats(
|
||||
total_nodes=total_nodes,
|
||||
active_nodes=active_nodes,
|
||||
total_messages=total_messages,
|
||||
messages_today=messages_today,
|
||||
total_advertisements=total_advertisements,
|
||||
channel_message_counts=channel_message_counts,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
session: DbSession,
|
||||
) -> HTMLResponse:
|
||||
"""Simple HTML dashboard page."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
|
||||
# Get stats
|
||||
total_nodes = session.execute(
|
||||
select(func.count()).select_from(Node)
|
||||
).scalar() or 0
|
||||
|
||||
active_nodes = session.execute(
|
||||
select(func.count()).select_from(Node).where(Node.last_seen >= yesterday)
|
||||
).scalar() or 0
|
||||
|
||||
total_messages = session.execute(
|
||||
select(func.count()).select_from(Message)
|
||||
).scalar() or 0
|
||||
|
||||
messages_today = session.execute(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= today_start)
|
||||
).scalar() or 0
|
||||
|
||||
# Get recent nodes
|
||||
recent_nodes = session.execute(
|
||||
select(Node).order_by(Node.last_seen.desc()).limit(10)
|
||||
).scalars().all()
|
||||
|
||||
# Get recent messages
|
||||
recent_messages = session.execute(
|
||||
select(Message).order_by(Message.received_at.desc()).limit(10)
|
||||
).scalars().all()
|
||||
|
||||
# Build HTML
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MeshCore Hub Dashboard</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}}
|
||||
h1 {{ color: #2c3e50; }}
|
||||
.container {{ max-width: 1200px; margin: 0 auto; }}
|
||||
.stats {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.stat-card {{
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.stat-card h3 {{ margin: 0 0 10px 0; color: #666; font-size: 14px; }}
|
||||
.stat-card .value {{ font-size: 32px; font-weight: bold; color: #2c3e50; }}
|
||||
.section {{
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #eee; }}
|
||||
th {{ background: #f8f9fa; font-weight: 600; }}
|
||||
.text-muted {{ color: #666; }}
|
||||
.truncate {{ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>MeshCore Hub Dashboard</h1>
|
||||
<p class="text-muted">Last updated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3>Total Nodes</h3>
|
||||
<div class="value">{total_nodes}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Active Nodes (24h)</h3>
|
||||
<div class="value">{active_nodes}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Total Messages</h3>
|
||||
<div class="value">{total_messages}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Messages Today</h3>
|
||||
<div class="value">{messages_today}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Nodes</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Type</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{"".join(f'''
|
||||
<tr>
|
||||
<td>{n.name or '-'}</td>
|
||||
<td class="truncate">{n.public_key[:16]}...</td>
|
||||
<td>{n.adv_type or '-'}</td>
|
||||
<td>{n.last_seen.strftime('%Y-%m-%d %H:%M') if n.last_seen else '-'}</td>
|
||||
</tr>
|
||||
''' for n in recent_nodes)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Messages</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>From/Channel</th>
|
||||
<th>Text</th>
|
||||
<th>Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{"".join(f'''
|
||||
<tr>
|
||||
<td>{m.message_type}</td>
|
||||
<td>{m.pubkey_prefix or f'Ch {m.channel_idx}' or '-'}</td>
|
||||
<td class="truncate">{m.text[:50]}{'...' if len(m.text) > 50 else ''}</td>
|
||||
<td>{m.received_at.strftime('%Y-%m-%d %H:%M') if m.received_at else '-'}</td>
|
||||
</tr>
|
||||
''' for m in recent_messages)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Message API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Message
|
||||
from meshcore_hub.common.schemas.messages import MessageList, MessageRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=MessageList)
|
||||
async def list_messages(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
type: Optional[str] = Query(None, description="Filter by message type"),
|
||||
pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
|
||||
channel_idx: Optional[int] = Query(None, description="Filter by channel"),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
search: Optional[str] = Query(None, description="Search in message text"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> MessageList:
|
||||
"""List messages with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Message)
|
||||
|
||||
if type:
|
||||
query = query.where(Message.message_type == type)
|
||||
|
||||
if pubkey_prefix:
|
||||
query = query.where(Message.pubkey_prefix == pubkey_prefix)
|
||||
|
||||
if channel_idx is not None:
|
||||
query = query.where(Message.channel_idx == channel_idx)
|
||||
|
||||
if since:
|
||||
query = query.where(Message.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(Message.received_at <= until)
|
||||
|
||||
if search:
|
||||
query = query.where(Message.text.ilike(f"%{search}%"))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
messages = session.execute(query).scalars().all()
|
||||
|
||||
return MessageList(
|
||||
items=[MessageRead.model_validate(m) for m in messages],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{message_id}", response_model=MessageRead)
|
||||
async def get_message(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
message_id: str,
|
||||
) -> MessageRead:
|
||||
"""Get a single message by ID."""
|
||||
query = select(Message).where(Message.id == message_id)
|
||||
message = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
return MessageRead.model_validate(message)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Node tag API routes."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}/tags", response_model=list[NodeTagRead])
|
||||
async def list_node_tags(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> list[NodeTagRead]:
|
||||
"""List all tags for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return [NodeTagRead.model_validate(t) for t in node.tags]
|
||||
|
||||
|
||||
@router.post("/nodes/{public_key}/tags", response_model=NodeTagRead, status_code=201)
|
||||
async def create_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
tag: NodeTagCreate,
|
||||
) -> NodeTagRead:
|
||||
"""Create a new tag for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Check if tag already exists
|
||||
existing_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == node.id) & (NodeTag.key == tag.key)
|
||||
)
|
||||
existing = session.execute(existing_query).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Tag already exists")
|
||||
|
||||
# Create tag
|
||||
node_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag.key,
|
||||
value=tag.value,
|
||||
value_type=tag.value_type,
|
||||
)
|
||||
session.add(node_tag)
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.put("/nodes/{public_key}/tags/{key}", response_model=NodeTagRead)
|
||||
async def update_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
tag: NodeTagUpdate,
|
||||
) -> NodeTagRead:
|
||||
"""Update a node tag."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Find tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Update tag
|
||||
if tag.value is not None:
|
||||
node_tag.value = tag.value
|
||||
if tag.value_type is not None:
|
||||
node_tag.value_type = tag.value_type
|
||||
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
|
||||
async def delete_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Delete a node tag."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Find and delete tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
session.delete(node_tag)
|
||||
session.commit()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Node API routes."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node
|
||||
from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=NodeList)
|
||||
async def list_nodes(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
search: Optional[str] = Query(None, description="Search in name or public key"),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
"""List all nodes with pagination and filtering."""
|
||||
# Build query
|
||||
query = select(Node)
|
||||
|
||||
if search:
|
||||
query = query.where(
|
||||
(Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
if adv_type:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
nodes = session.execute(query).scalars().all()
|
||||
|
||||
return NodeList(
|
||||
items=[NodeRead.model_validate(n) for n in nodes],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> NodeRead:
|
||||
"""Get a single node by public key."""
|
||||
query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return NodeRead.model_validate(node)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Telemetry API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Telemetry
|
||||
from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=TelemetryList)
|
||||
async def list_telemetry(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
node_public_key: Optional[str] = Query(None, description="Filter by node"),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> TelemetryList:
|
||||
"""List telemetry records with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Telemetry)
|
||||
|
||||
if node_public_key:
|
||||
query = query.where(Telemetry.node_public_key == node_public_key)
|
||||
|
||||
if since:
|
||||
query = query.where(Telemetry.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(Telemetry.received_at <= until)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Telemetry.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
records = session.execute(query).scalars().all()
|
||||
|
||||
return TelemetryList(
|
||||
items=[TelemetryRead.model_validate(t) for t in records],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{telemetry_id}", response_model=TelemetryRead)
|
||||
async def get_telemetry(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
telemetry_id: str,
|
||||
) -> TelemetryRead:
|
||||
"""Get a single telemetry record by ID."""
|
||||
query = select(Telemetry).where(Telemetry.id == telemetry_id)
|
||||
telemetry = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not telemetry:
|
||||
raise HTTPException(status_code=404, detail="Telemetry record not found")
|
||||
|
||||
return TelemetryRead.model_validate(telemetry)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Trace path API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import TracePath
|
||||
from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=TracePathList)
|
||||
async def list_trace_paths(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> TracePathList:
|
||||
"""List trace paths with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(TracePath)
|
||||
|
||||
if since:
|
||||
query = query.where(TracePath.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(TracePath.received_at <= until)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(TracePath.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
trace_paths = session.execute(query).scalars().all()
|
||||
|
||||
return TracePathList(
|
||||
items=[TracePathRead.model_validate(t) for t in trace_paths],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{trace_path_id}", response_model=TracePathRead)
|
||||
async def get_trace_path(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
trace_path_id: str,
|
||||
) -> TracePathRead:
|
||||
"""Get a single trace path by ID."""
|
||||
query = select(TracePath).where(TracePath.id == trace_path_id)
|
||||
trace_path = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not trace_path:
|
||||
raise HTTPException(status_code=404, detail="Trace path not found")
|
||||
|
||||
return TracePathRead.model_validate(trace_path)
|
||||
@@ -56,7 +56,6 @@ class Telemetry(Base, UUIDMixin, TimestampMixin):
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_telemetry_node_public_key", "node_public_key"),
|
||||
Index("ix_telemetry_received_at", "received_at"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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