feat: Initial Hub implementation
Complete LetsBe Hub service for license management and telemetry: - Client and Instance CRUD APIs - License key generation and validation (lb_inst_ format) - Hub API key generation (hk_ format) for telemetry auth - Instance activation endpoint - Telemetry collection with privacy-first redactor - Key rotation and suspend/reactivate functionality - Alembic migrations for PostgreSQL - Docker Compose deployment ready - Comprehensive test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Hub test package."""
|
||||
82
tests/conftest.py
Normal file
82
tests/conftest.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Pytest fixtures for Hub tests."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Base
|
||||
|
||||
# Use SQLite for testing
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
||||
"""Create event loop for async tests."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create test database session."""
|
||||
async_session = async_sessionmaker(
|
||||
db_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test HTTP client."""
|
||||
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers() -> dict[str, str]:
|
||||
"""Return admin authentication headers."""
|
||||
return {"X-Admin-Api-Key": settings.ADMIN_API_KEY}
|
||||
163
tests/test_activation.py
Normal file
163
tests/test_activation.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Tests for instance activation endpoint."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_success(client: AsyncClient, admin_headers: dict):
|
||||
"""Test successful activation."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Activation Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
instance_response = await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "activation-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
license_key = instance_response.json()["license_key"]
|
||||
|
||||
# Activate
|
||||
response = await client.post(
|
||||
"/api/v1/instances/activate",
|
||||
json={
|
||||
"license_key": license_key,
|
||||
"instance_id": "activation-test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["instance_id"] == "activation-test"
|
||||
# Should return USE_EXISTING since key was pre-generated
|
||||
assert data["hub_api_key"] == "USE_EXISTING"
|
||||
assert "config" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_increments_count(client: AsyncClient, admin_headers: dict):
|
||||
"""Test that activation increments count."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Count Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
instance_response = await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "count-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
license_key = instance_response.json()["license_key"]
|
||||
|
||||
# Activate multiple times
|
||||
for i in range(3):
|
||||
await client.post(
|
||||
"/api/v1/instances/activate",
|
||||
json={
|
||||
"license_key": license_key,
|
||||
"instance_id": "count-test",
|
||||
},
|
||||
)
|
||||
|
||||
# Check count
|
||||
get_response = await client.get(
|
||||
"/api/v1/admin/instances/count-test",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert get_response.json()["activation_count"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_invalid_license(client: AsyncClient, admin_headers: dict):
|
||||
"""Test activation with invalid license key."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Invalid Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "invalid-license-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
# Try with wrong license
|
||||
response = await client.post(
|
||||
"/api/v1/instances/activate",
|
||||
json={
|
||||
"license_key": "lb_inst_wrongkey123456789012345678901234",
|
||||
"instance_id": "invalid-license-test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()["detail"]
|
||||
assert data["code"] == "invalid_license"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_unknown_instance(client: AsyncClient):
|
||||
"""Test activation with unknown instance_id."""
|
||||
response = await client.post(
|
||||
"/api/v1/instances/activate",
|
||||
json={
|
||||
"license_key": "lb_inst_somekey1234567890123456789012",
|
||||
"instance_id": "unknown-instance",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()["detail"]
|
||||
assert data["code"] == "instance_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_suspended_license(client: AsyncClient, admin_headers: dict):
|
||||
"""Test activation with suspended license."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Suspended Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
instance_response = await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "suspended-license-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
license_key = instance_response.json()["license_key"]
|
||||
|
||||
# Suspend instance
|
||||
await client.post(
|
||||
"/api/v1/admin/instances/suspended-license-test/suspend",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
# Try to activate
|
||||
response = await client.post(
|
||||
"/api/v1/instances/activate",
|
||||
json={
|
||||
"license_key": license_key,
|
||||
"instance_id": "suspended-license-test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()["detail"]
|
||||
assert data["code"] == "suspended"
|
||||
233
tests/test_admin.py
Normal file
233
tests/test_admin.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Tests for admin endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client(client: AsyncClient, admin_headers: dict):
|
||||
"""Test creating a new client."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={
|
||||
"name": "Acme Corp",
|
||||
"contact_email": "admin@acme.com",
|
||||
"billing_plan": "pro",
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Acme Corp"
|
||||
assert data["contact_email"] == "admin@acme.com"
|
||||
assert data["billing_plan"] == "pro"
|
||||
assert data["status"] == "active"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client_unauthorized(client: AsyncClient):
|
||||
"""Test creating client without auth fails."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Test Corp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Missing header
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_client_invalid_key(client: AsyncClient):
|
||||
"""Test creating client with invalid key fails."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Test Corp"},
|
||||
headers={"X-Admin-Api-Key": "invalid-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_clients(client: AsyncClient, admin_headers: dict):
|
||||
"""Test listing clients."""
|
||||
# Create a client first
|
||||
await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Test Corp 1"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Test Corp 2"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/clients",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_instance(client: AsyncClient, admin_headers: dict):
|
||||
"""Test creating an instance for a client."""
|
||||
# Create client first
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Instance Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
# Create instance
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={
|
||||
"instance_id": "test-orchestrator",
|
||||
"region": "eu-west-1",
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["instance_id"] == "test-orchestrator"
|
||||
assert data["region"] == "eu-west-1"
|
||||
assert data["license_status"] == "active"
|
||||
assert data["status"] == "pending"
|
||||
# Keys should be returned on creation
|
||||
assert "license_key" in data
|
||||
assert data["license_key"].startswith("lb_inst_")
|
||||
assert "hub_api_key" in data
|
||||
assert data["hub_api_key"].startswith("hk_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_instance(client: AsyncClient, admin_headers: dict):
|
||||
"""Test that duplicate instance_id fails."""
|
||||
# Create client
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Duplicate Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
# Create first instance
|
||||
await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "duplicate-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
# Try to create duplicate
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "duplicate-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_license_key(client: AsyncClient, admin_headers: dict):
|
||||
"""Test rotating a license key."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Rotate Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
instance_response = await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "rotate-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
original_key = instance_response.json()["license_key"]
|
||||
|
||||
# Rotate license
|
||||
response = await client.post(
|
||||
"/api/v1/admin/instances/rotate-test/rotate-license",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["license_key"].startswith("lb_inst_")
|
||||
assert data["license_key"] != original_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suspend_instance(client: AsyncClient, admin_headers: dict):
|
||||
"""Test suspending an instance."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Suspend Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "suspend-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
# Suspend
|
||||
response = await client.post(
|
||||
"/api/v1/admin/instances/suspend-test/suspend",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "suspended"
|
||||
|
||||
# Verify status
|
||||
get_response = await client.get(
|
||||
"/api/v1/admin/instances/suspend-test",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert get_response.json()["license_status"] == "suspended"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactivate_instance(client: AsyncClient, admin_headers: dict):
|
||||
"""Test reactivating a suspended instance."""
|
||||
# Create client and instance
|
||||
client_response = await client.post(
|
||||
"/api/v1/admin/clients",
|
||||
json={"name": "Reactivate Test Corp"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
client_id = client_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/admin/clients/{client_id}/instances",
|
||||
json={"instance_id": "reactivate-test"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
# Suspend
|
||||
await client.post(
|
||||
"/api/v1/admin/instances/reactivate-test/suspend",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
# Reactivate
|
||||
response = await client.post(
|
||||
"/api/v1/admin/instances/reactivate-test/reactivate",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "pending" # Not activated yet
|
||||
133
tests/test_redactor.py
Normal file
133
tests/test_redactor.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tests for telemetry redactor."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.redactor import redact_metadata, sanitize_error_code, validate_tool_name
|
||||
|
||||
|
||||
class TestRedactMetadata:
|
||||
"""Tests for redact_metadata function."""
|
||||
|
||||
def test_allows_safe_fields(self):
|
||||
"""Test that allowed fields pass through."""
|
||||
metadata = {
|
||||
"tool_name": "sysadmin.env_update",
|
||||
"duration_ms": 150,
|
||||
"status": "success",
|
||||
"error_code": "E001",
|
||||
}
|
||||
result = redact_metadata(metadata)
|
||||
|
||||
assert result == metadata
|
||||
|
||||
def test_removes_unknown_fields(self):
|
||||
"""Test that unknown fields are removed."""
|
||||
metadata = {
|
||||
"tool_name": "sysadmin.env_update",
|
||||
"password": "secret123",
|
||||
"file_content": "sensitive data",
|
||||
"custom_field": "value",
|
||||
}
|
||||
result = redact_metadata(metadata)
|
||||
|
||||
assert "password" not in result
|
||||
assert "file_content" not in result
|
||||
assert "custom_field" not in result
|
||||
assert result["tool_name"] == "sysadmin.env_update"
|
||||
|
||||
def test_removes_nested_objects(self):
|
||||
"""Test that nested objects are removed."""
|
||||
metadata = {
|
||||
"tool_name": "sysadmin.env_update",
|
||||
"nested": {"password": "secret"},
|
||||
}
|
||||
result = redact_metadata(metadata)
|
||||
|
||||
assert "nested" not in result
|
||||
|
||||
def test_handles_none(self):
|
||||
"""Test handling of None input."""
|
||||
assert redact_metadata(None) == {}
|
||||
|
||||
def test_handles_empty(self):
|
||||
"""Test handling of empty dict."""
|
||||
assert redact_metadata({}) == {}
|
||||
|
||||
def test_truncates_long_strings(self):
|
||||
"""Test that very long strings are removed."""
|
||||
metadata = {
|
||||
"tool_name": "a" * 200, # Too long
|
||||
"status": "success",
|
||||
}
|
||||
result = redact_metadata(metadata)
|
||||
|
||||
assert "tool_name" not in result
|
||||
assert result["status"] == "success"
|
||||
|
||||
def test_defense_in_depth_patterns(self):
|
||||
"""Test that sensitive patterns in field names are caught."""
|
||||
# Even if somehow in allowed list, sensitive patterns should be caught
|
||||
metadata = {
|
||||
"status": "success",
|
||||
"password_hash": "abc123", # Contains 'password'
|
||||
}
|
||||
result = redact_metadata(metadata)
|
||||
|
||||
assert "password_hash" not in result
|
||||
|
||||
|
||||
class TestValidateToolName:
|
||||
"""Tests for validate_tool_name function."""
|
||||
|
||||
def test_valid_sysadmin_tool(self):
|
||||
"""Test valid sysadmin tool name."""
|
||||
assert validate_tool_name("sysadmin.env_update") is True
|
||||
assert validate_tool_name("sysadmin.file_write") is True
|
||||
|
||||
def test_valid_browser_tool(self):
|
||||
"""Test valid browser tool name."""
|
||||
assert validate_tool_name("browser.navigate") is True
|
||||
assert validate_tool_name("browser.click") is True
|
||||
|
||||
def test_valid_gateway_tool(self):
|
||||
"""Test valid gateway tool name."""
|
||||
assert validate_tool_name("gateway.proxy") is True
|
||||
|
||||
def test_invalid_prefix(self):
|
||||
"""Test that unknown prefixes are rejected."""
|
||||
assert validate_tool_name("unknown.tool") is False
|
||||
assert validate_tool_name("custom.action") is False
|
||||
|
||||
def test_too_long(self):
|
||||
"""Test that very long names are rejected."""
|
||||
assert validate_tool_name("sysadmin." + "a" * 100) is False
|
||||
|
||||
def test_suspicious_chars(self):
|
||||
"""Test that suspicious characters are rejected."""
|
||||
assert validate_tool_name("sysadmin.tool;drop table") is False
|
||||
assert validate_tool_name("sysadmin.tool'or'1'='1") is False
|
||||
assert validate_tool_name("sysadmin.tool\ninjection") is False
|
||||
|
||||
|
||||
class TestSanitizeErrorCode:
|
||||
"""Tests for sanitize_error_code function."""
|
||||
|
||||
def test_valid_codes(self):
|
||||
"""Test valid error codes."""
|
||||
assert sanitize_error_code("E001") == "E001"
|
||||
assert sanitize_error_code("connection_timeout") == "connection_timeout"
|
||||
assert sanitize_error_code("AUTH-FAILED") == "AUTH-FAILED"
|
||||
|
||||
def test_none_input(self):
|
||||
"""Test None input."""
|
||||
assert sanitize_error_code(None) is None
|
||||
|
||||
def test_too_long(self):
|
||||
"""Test that long codes are rejected."""
|
||||
assert sanitize_error_code("a" * 60) is None
|
||||
|
||||
def test_invalid_chars(self):
|
||||
"""Test that invalid characters are rejected."""
|
||||
assert sanitize_error_code("error code") is None # space
|
||||
assert sanitize_error_code("error;drop") is None # semicolon
|
||||
assert sanitize_error_code("error\ntable") is None # newline
|
||||
Reference in New Issue
Block a user