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/
/.agents
pytest-out.txt

View File

@@ -91,7 +91,9 @@ class DashboardAuth:
api_key_clean = api_key.strip()
# 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
# 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)
stats["sites_count"] = len(sites)
stats["active_sites_count"] = len(
[s for s in sites if s.get("status") == "active"]
)
stats["active_sites_count"] = len([s for s in sites if s.get("status") == "active"])
except Exception as 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}")
return response
# Alias for backwards compatibility with __init__.py and other routes
dashboard_logout = auth_logout
@@ -620,20 +619,24 @@ async def dashboard_home(request: Request) -> Response:
projects_by_type = await get_projects_by_type()
recent_activity = await get_recent_activity(limit=5)
health_summary = await get_health_summary()
context.update({
"stats": stats,
"projects_by_type": projects_by_type,
"recent_activity": recent_activity,
"health_summary": health_summary,
})
context.update(
{
"stats": stats,
"projects_by_type": projects_by_type,
"recent_activity": recent_activity,
"health_summary": health_summary,
}
)
else:
# User dashboard — personal stats
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 []
context.update({
"stats": user_stats,
"user_sites": user_sites,
})
context.update(
{
"stats": user_stats,
"user_sites": user_sites,
}
)
return templates.TemplateResponse("dashboard/index.html", context)
@@ -704,7 +707,7 @@ def get_cached_health_status(project_id: str) -> dict:
"status": status,
"last_check": latest.last_check.isoformat() if latest.last_check else None,
"error_rate": latest.error_rate_percent,
"reason": reason
"reason": reason,
}
# 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:
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:
logger.warning(f"Error getting cached health for {project_id}: {e}")
return {"status": "unknown", "last_check": None, "error_rate": 0}
@@ -2371,7 +2379,9 @@ async def auth_callback(request: Request) -> Response:
user = user_by_email
logger.info(
"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:
# New registration -- check rate limit
@@ -2386,12 +2396,12 @@ async def auth_callback(request: Request) -> Response:
)
user = await db.create_user(
email=user_info["email"],
name=user_info.get("name"),
provider=user_info["provider"],
provider_id=user_info["provider_id"],
avatar_url=user_info.get("avatar_url"),
)
email=user_info["email"],
name=user_info.get("name"),
provider=user_info["provider"],
provider_id=user_info["provider_id"],
avatar_url=user_info.get("avatar_url"),
)
user_auth.record_registration(client_ip)
logger.info(
"New user registered: %s via %s",

View File

@@ -260,9 +260,11 @@ class Database:
async def _create_schema(self) -> None:
"""Create all tables if they do not already exist."""
conn = self._require_conn()
# 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
await conn.executescript(_SCHEMA_SQL)
@@ -299,7 +301,9 @@ class Database:
except Exception as e:
if "duplicate column name" not in str(e).lower():
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:
migration_sql = _MIGRATIONS.get(version)
if migration_sql is not None:
@@ -307,8 +311,10 @@ class Database:
await conn.executescript(migration_sql)
logger.info("Migration to version %d applied", version)
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
await self.execute(
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",

View File

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

View File

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

View File

@@ -385,23 +385,25 @@ def test_dashboard_connect_page(monkeypatch):
client = TestClient(app)
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):
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:
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 "Test" in resp.text
assert "Key" in resp.text

View File

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

View File

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