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:
2025-12-22 14:09:32 +01:00
commit adc02e176b
39 changed files with 2968 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Hub test package."""

82
tests/conftest.py Normal file
View 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
View 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
View 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
View 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