style: format 9 files with Black

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-23 23:22:52 +03:30
parent 9e6f06d933
commit 1fcc539093
11 changed files with 111 additions and 91 deletions

1
.gitignore vendored
View File

@@ -162,4 +162,3 @@ pytest-cache-files-*/
.worktrees/ .worktrees/
worktrees/ worktrees/
/.agents /.agents
pytest-out.txt

View File

@@ -91,7 +91,9 @@ class DashboardAuth:
api_key_clean = api_key.strip() api_key_clean = api_key.strip()
# Check master API key (from env var) # Check master API key (from env var)
if self.master_api_key and secrets.compare_digest(api_key_clean, self.master_api_key.strip()): if self.master_api_key and secrets.compare_digest(
api_key_clean, self.master_api_key.strip()
):
return True, "master", None return True, "master", None
# Check AuthManager's master key (covers auto-generated temp keys) # Check AuthManager's master key (covers auto-generated temp keys)

View File

@@ -329,9 +329,7 @@ async def get_user_dashboard_stats(user_id: str) -> dict:
sites = await get_user_sites(user_id) sites = await get_user_sites(user_id)
stats["sites_count"] = len(sites) stats["sites_count"] = len(sites)
stats["active_sites_count"] = len( stats["active_sites_count"] = len([s for s in sites if s.get("status") == "active"])
[s for s in sites if s.get("status") == "active"]
)
except Exception as e: except Exception as e:
logger.warning(f"Error getting user sites count: {e}") logger.warning(f"Error getting user sites count: {e}")
@@ -580,6 +578,7 @@ async def auth_logout(request: Request) -> Response:
logger.info(f"Dashboard logout from {client_ip}") logger.info(f"Dashboard logout from {client_ip}")
return response return response
# Alias for backwards compatibility with __init__.py and other routes # Alias for backwards compatibility with __init__.py and other routes
dashboard_logout = auth_logout dashboard_logout = auth_logout
@@ -620,20 +619,24 @@ async def dashboard_home(request: Request) -> Response:
projects_by_type = await get_projects_by_type() projects_by_type = await get_projects_by_type()
recent_activity = await get_recent_activity(limit=5) recent_activity = await get_recent_activity(limit=5)
health_summary = await get_health_summary() health_summary = await get_health_summary()
context.update({ context.update(
"stats": stats, {
"projects_by_type": projects_by_type, "stats": stats,
"recent_activity": recent_activity, "projects_by_type": projects_by_type,
"health_summary": health_summary, "recent_activity": recent_activity,
}) "health_summary": health_summary,
}
)
else: else:
# User dashboard — personal stats # User dashboard — personal stats
user_stats = await get_user_dashboard_stats(user_id) if user_id else {} user_stats = await get_user_dashboard_stats(user_id) if user_id else {}
user_sites = await get_user_sites_summary(user_id) if user_id else [] user_sites = await get_user_sites_summary(user_id) if user_id else []
context.update({ context.update(
"stats": user_stats, {
"user_sites": user_sites, "stats": user_stats,
}) "user_sites": user_sites,
}
)
return templates.TemplateResponse("dashboard/index.html", context) return templates.TemplateResponse("dashboard/index.html", context)
@@ -704,7 +707,7 @@ def get_cached_health_status(project_id: str) -> dict:
"status": status, "status": status,
"last_check": latest.last_check.isoformat() if latest.last_check else None, "last_check": latest.last_check.isoformat() if latest.last_check else None,
"error_rate": latest.error_rate_percent, "error_rate": latest.error_rate_percent,
"reason": reason "reason": reason,
} }
# Fallback to cached metrics (last 24 hours) if no active check exists yet # Fallback to cached metrics (last 24 hours) if no active check exists yet
@@ -730,7 +733,12 @@ def get_cached_health_status(project_id: str) -> dict:
if history: if history:
last_check = history[-1].timestamp.isoformat() last_check = history[-1].timestamp.isoformat()
return {"status": status, "last_check": last_check, "error_rate": error_rate, "reason": None} return {
"status": status,
"last_check": last_check,
"error_rate": error_rate,
"reason": None,
}
except Exception as e: except Exception as e:
logger.warning(f"Error getting cached health for {project_id}: {e}") logger.warning(f"Error getting cached health for {project_id}: {e}")
return {"status": "unknown", "last_check": None, "error_rate": 0} return {"status": "unknown", "last_check": None, "error_rate": 0}
@@ -2371,7 +2379,9 @@ async def auth_callback(request: Request) -> Response:
user = user_by_email user = user_by_email
logger.info( logger.info(
"User %s logged in with alternate provider %s (original: %s)", "User %s logged in with alternate provider %s (original: %s)",
user_info["email"], provider, user_by_email["provider"] user_info["email"],
provider,
user_by_email["provider"],
) )
else: else:
# New registration -- check rate limit # New registration -- check rate limit
@@ -2386,12 +2396,12 @@ async def auth_callback(request: Request) -> Response:
) )
user = await db.create_user( user = await db.create_user(
email=user_info["email"], email=user_info["email"],
name=user_info.get("name"), name=user_info.get("name"),
provider=user_info["provider"], provider=user_info["provider"],
provider_id=user_info["provider_id"], provider_id=user_info["provider_id"],
avatar_url=user_info.get("avatar_url"), avatar_url=user_info.get("avatar_url"),
) )
user_auth.record_registration(client_ip) user_auth.record_registration(client_ip)
logger.info( logger.info(
"New user registered: %s via %s", "New user registered: %s via %s",

View File

@@ -260,9 +260,11 @@ class Database:
async def _create_schema(self) -> None: async def _create_schema(self) -> None:
"""Create all tables if they do not already exist.""" """Create all tables if they do not already exist."""
conn = self._require_conn() conn = self._require_conn()
# Check if it's a completely fresh DB (no users table) # Check if it's a completely fresh DB (no users table)
row = await self.fetchone("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") row = await self.fetchone(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
is_fresh = row is None is_fresh = row is None
await conn.executescript(_SCHEMA_SQL) await conn.executescript(_SCHEMA_SQL)
@@ -299,7 +301,9 @@ class Database:
except Exception as e: except Exception as e:
if "duplicate column name" not in str(e).lower(): if "duplicate column name" not in str(e).lower():
raise raise
await self.execute("CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix)") await self.execute(
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix)"
)
else: else:
migration_sql = _MIGRATIONS.get(version) migration_sql = _MIGRATIONS.get(version)
if migration_sql is not None: if migration_sql is not None:
@@ -307,8 +311,10 @@ class Database:
await conn.executescript(migration_sql) await conn.executescript(migration_sql)
logger.info("Migration to version %d applied", version) logger.info("Migration to version %d applied", version)
else: else:
logger.warning("No migration SQL for version %d, recording version only", version) logger.warning(
"No migration SQL for version %d, recording version only", version
)
# Always record version to avoid infinite retry # Always record version to avoid infinite retry
await self.execute( await self.execute(
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)", "INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",

View File

@@ -774,15 +774,15 @@ class HealthMonitor:
self.request_timestamps.clear() self.request_timestamps.clear()
self.latest_health_status.clear() self.latest_health_status.clear()
logger.warning("All metrics have been reset") logger.warning("All metrics have been reset")
async def start_background_checks(self, interval_seconds: int = 60): async def start_background_checks(self, interval_seconds: int = 60):
"""Start background health checks for all projects.""" """Start background health checks for all projects."""
if self._is_running: if self._is_running:
return return
self._is_running = True self._is_running = True
logger.info(f"Starting background health checks every {interval_seconds} seconds") logger.info(f"Starting background health checks every {interval_seconds} seconds")
async def _loop(): async def _loop():
# Initial wait to let server start up fully # Initial wait to let server start up fully
await asyncio.sleep(5) await asyncio.sleep(5)
@@ -791,15 +791,15 @@ class HealthMonitor:
await self.check_all_projects_health(include_metrics=True) await self.check_all_projects_health(include_metrics=True)
except Exception as e: except Exception as e:
logger.error(f"Error in background health check loop: {e}") logger.error(f"Error in background health check loop: {e}")
# Sleep interval, check _is_running periodically # Sleep interval, check _is_running periodically
for _ in range(interval_seconds): for _ in range(interval_seconds):
if not self._is_running: if not self._is_running:
break break
await asyncio.sleep(1) await asyncio.sleep(1)
self._bg_task = asyncio.create_task(_loop()) self._bg_task = asyncio.create_task(_loop())
async def stop_background_checks(self): async def stop_background_checks(self):
"""Stop background health checks.""" """Stop background health checks."""
self._is_running = False self._is_running = False

View File

@@ -318,26 +318,25 @@ async def user_mcp_handler(request: Request) -> Response:
# Check required scope # Check required scope
from core.tool_registry import get_tool_registry from core.tool_registry import get_tool_registry
registry = get_tool_registry() registry = get_tool_registry()
tool_def = registry.get_by_name(tool_name) tool_def = registry.get_by_name(tool_name)
if not tool_def: if not tool_def:
return JSONResponse( return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found")
)
required_scope = tool_def.required_scope required_scope = tool_def.required_scope
key_scopes = key_info.get("scopes", "").split() key_scopes = key_info.get("scopes", "").split()
scope_hierarchy = {"read": 1, "write": 2, "admin": 3} scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0) required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0]) key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
if key_level < required_level: if key_level < required_level:
return JSONResponse( return JSONResponse(
_jsonrpc_error( _jsonrpc_error(
req_id, req_id,
-32600, -32600,
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope." f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
) )
) )

BIN
pytest-out.txt Normal file

Binary file not shown.

View File

@@ -4469,6 +4469,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
# Start health monitor background checks # Start health monitor background checks
from core.health import get_health_monitor from core.health import get_health_monitor
hm = get_health_monitor() hm = get_health_monitor()
if hm: if hm:
await hm.start_background_checks(interval_seconds=60) await hm.start_background_checks(interval_seconds=60)

View File

@@ -385,23 +385,25 @@ def test_dashboard_connect_page(monkeypatch):
client = TestClient(app) client = TestClient(app)
def mock_req(*args): def mock_req(*args):
return {'user_id': 'abc', 'type': 'user'}, None return {"user_id": "abc", "type": "user"}, None
monkeypatch.setattr(core.dashboard.routes, '_require_user_session', mock_req) monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
async def mock_sites(*args): async def mock_sites(*args):
return [{'alias': 'Test', 'plugin_type': 'dummy'}] return [{"alias": "Test", "plugin_type": "dummy"}]
monkeypatch.setattr(core.site_api, 'get_user_sites', mock_sites) monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
class MockKeyMgr: class MockKeyMgr:
async def list_keys(self, *a): async def list_keys(self, *a):
return [{'id': '1', 'name': 'Key', 'key_prefix': 'prefix', 'scopes': 'all', 'use_count': 0}] return [
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
]
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
resp = client.get("/dashboard/connect")
monkeypatch.setattr(core.user_keys, 'get_user_key_manager', lambda: MockKeyMgr())
resp = client.get('/dashboard/connect')
assert resp.status_code == 200 assert resp.status_code == 200
assert "Test" in resp.text assert "Test" in resp.text
assert "Key" in resp.text assert "Key" in resp.text

View File

@@ -2,6 +2,7 @@
Integration tests for the per-user dynamic MCP endpoints. Integration tests for the per-user dynamic MCP endpoints.
Uses Starlette TestClient to simulate real HTTP requests through the server. Uses Starlette TestClient to simulate real HTTP requests through the server.
""" """
import pytest import pytest
from starlette.testclient import TestClient from starlette.testclient import TestClient
from unittest.mock import patch, MagicMock, AsyncMock from unittest.mock import patch, MagicMock, AsyncMock
@@ -12,22 +13,23 @@ from server import create_multi_endpoint_app
app = create_multi_endpoint_app() app = create_multi_endpoint_app()
client = TestClient(app) client = TestClient(app)
@pytest.fixture @pytest.fixture
def mock_managers(): def mock_managers():
"""Mock the core managers to avoid needing a real database or keys.""" """Mock the core managers to avoid needing a real database or keys."""
# Mock Site Manager # Mock Site Manager
mock_site_manager = MagicMock() mock_site_manager = MagicMock()
mock_site_manager.list_all_sites.return_value = [] mock_site_manager.list_all_sites.return_value = []
# Mock Key Manager # Mock Key Manager
mock_key_manager = AsyncMock() mock_key_manager = AsyncMock()
mock_key_manager.validate_key.return_value = { mock_key_manager.validate_key.return_value = {
"key_id": "test-key-123", "key_id": "test-key-123",
"user_id": "user-123", "user_id": "user-123",
"scopes": "read write" "scopes": "read write",
} }
# Mock Database # Mock Database
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.get_site_by_alias.return_value = { mock_db.get_site_by_alias.return_value = {
@@ -37,39 +39,40 @@ def mock_managers():
"alias": "myblog", "alias": "myblog",
"url": "https://example.com", "url": "https://example.com",
"credentials": b"encrypted-blob", "credentials": b"encrypted-blob",
"status": "active" "status": "active",
} }
with patch("server.get_site_manager", return_value=mock_site_manager), \ with (
patch("core.user_keys.get_user_key_manager", return_value=mock_key_manager), \ patch("server.get_site_manager", return_value=mock_site_manager),
patch("core.database.get_database", return_value=mock_db): patch("core.user_keys.get_user_key_manager", return_value=mock_key_manager),
yield { patch("core.database.get_database", return_value=mock_db),
"key_manager": mock_key_manager, ):
"db": mock_db yield {"key_manager": mock_key_manager, "db": mock_db}
}
@pytest.mark.integration @pytest.mark.integration
def test_dynamic_endpoint_unauthorized(): def test_dynamic_endpoint_unauthorized():
"""Test that requests without API key are rejected.""" """Test that requests without API key are rejected."""
response = client.post( response = client.post(
"/u/user-123/myblog/mcp", "/u/user-123/myblog/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}
) )
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.integration @pytest.mark.integration
def test_dynamic_endpoint_invalid_method(mock_managers): def test_dynamic_endpoint_invalid_method(mock_managers):
"""Test that unauthorized methods or valid requests with bad structure return JSON-RPC errors.""" """Test that unauthorized methods or valid requests with bad structure return JSON-RPC errors."""
response = client.post( response = client.post(
"/u/user-123/myblog/mcp", "/u/user-123/myblog/mcp",
headers={"Authorization": "Bearer mhu_test-key"}, headers={"Authorization": "Bearer mhu_test-key"},
json={"jsonrpc": "2.0", "id": 1, "method": "invalid_method"} json={"jsonrpc": "2.0", "id": 1, "method": "invalid_method"},
) )
assert response.status_code == 200 # JSON-RPC errors are 200 OK assert response.status_code == 200 # JSON-RPC errors are 200 OK
data = response.json() data = response.json()
assert "error" in data assert "error" in data
assert "not supported" in data["error"]["message"].lower() assert "not supported" in data["error"]["message"].lower()
@pytest.mark.integration @pytest.mark.integration
def test_dynamic_endpoint_initialize(mock_managers): def test_dynamic_endpoint_initialize(mock_managers):
"""Test standard MCP initialize on the dynamic endpoint.""" """Test standard MCP initialize on the dynamic endpoint."""
@@ -77,15 +80,15 @@ def test_dynamic_endpoint_initialize(mock_managers):
"/u/user-123/myblog/mcp", "/u/user-123/myblog/mcp",
headers={"Authorization": "Bearer mhu_test-key"}, headers={"Authorization": "Bearer mhu_test-key"},
json={ json={
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 1, "id": 1,
"method": "initialize", "method": "initialize",
"params": { "params": {
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"} "clientInfo": {"name": "test-client", "version": "1.0.0"},
} },
} },
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -93,19 +96,20 @@ def test_dynamic_endpoint_initialize(mock_managers):
assert "capabilities" in data["result"] assert "capabilities" in data["result"]
assert "serverInfo" in data["result"] assert "serverInfo" in data["result"]
@pytest.mark.integration @pytest.mark.integration
def test_dynamic_endpoint_mismatched_user(mock_managers): def test_dynamic_endpoint_mismatched_user(mock_managers):
"""Test that if the key belongs to a different user, it's rejected.""" """Test that if the key belongs to a different user, it's rejected."""
mock_managers["key_manager"].validate_key.return_value = { mock_managers["key_manager"].validate_key.return_value = {
"key_id": "test-key-123", "key_id": "test-key-123",
"user_id": "different-user-456", "user_id": "different-user-456",
"scopes": "read write" "scopes": "read write",
} }
response = client.post( response = client.post(
"/u/user-123/myblog/mcp", "/u/user-123/myblog/mcp",
headers={"Authorization": "Bearer mhu_test-key"}, headers={"Authorization": "Bearer mhu_test-key"},
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"} json={"jsonrpc": "2.0", "id": 1, "method": "initialize"},
) )
assert response.status_code == 403 assert response.status_code == 403
data = response.json() data = response.json()

View File

@@ -4,39 +4,36 @@ import pytest
from core.dashboard.routes import get_all_projects from core.dashboard.routes import get_all_projects
from core.site_manager import SiteManager, SiteConfig from core.site_manager import SiteManager, SiteConfig
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_projects_tenant_isolation(monkeypatch): async def test_get_all_projects_tenant_isolation(monkeypatch):
"""Normal user should only see their own sites, ignoring global ones.""" """Normal user should only see their own sites, ignoring global ones."""
# Mock SiteManager with some global and user sites # Mock SiteManager with some global and user sites
mgr = SiteManager() mgr = SiteManager()
# Global site (no user_id) # Global site (no user_id)
mgr.register_site(SiteConfig(site_id="global1", plugin_type="wordpress")) mgr.register_site(SiteConfig(site_id="global1", plugin_type="wordpress"))
# User 123's site # User 123's site
mgr.register_site(SiteConfig( mgr.register_site(SiteConfig(site_id="user1site", plugin_type="wordpress", user_id="user-123"))
site_id="user1site",
plugin_type="wordpress",
user_id="user-123"
))
monkeypatch.setattr("core.site_manager.get_site_manager", lambda: mgr) monkeypatch.setattr("core.site_manager.get_site_manager", lambda: mgr)
# Normal user 456 (has no sites) # Normal user 456 (has no sites)
session_456 = {"user_id": "user-456", "type": "user"} session_456 = {"user_id": "user-456", "type": "user"}
res = await get_all_projects(user_session=session_456) res = await get_all_projects(user_session=session_456)
assert len(res["projects"]) == 0 assert len(res["projects"]) == 0
# Normal user 123 (has 1 site) # Normal user 123 (has 1 site)
session_123 = {"user_id": "user-123", "type": "user"} session_123 = {"user_id": "user-123", "type": "user"}
res = await get_all_projects(user_session=session_123) res = await get_all_projects(user_session=session_123)
assert len(res["projects"]) == 1 assert len(res["projects"]) == 1
assert res["projects"][0]["site_id"] == "user1site" assert res["projects"][0]["site_id"] == "user1site"
# Master user (sees all) # Master user (sees all)
class DummyMaster: class DummyMaster:
user_type = "master" user_type = "master"
res = await get_all_projects(user_session=DummyMaster()) res = await get_all_projects(user_session=DummyMaster())
assert len(res["projects"]) == 2 assert len(res["projects"]) == 2