style: format 9 files with Black
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -162,4 +162,3 @@ pytest-cache-files-*/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
worktrees/
|
worktrees/
|
||||||
/.agents
|
/.agents
|
||||||
pytest-out.txt
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
"stats": stats,
|
||||||
"projects_by_type": projects_by_type,
|
"projects_by_type": projects_by_type,
|
||||||
"recent_activity": recent_activity,
|
"recent_activity": recent_activity,
|
||||||
"health_summary": health_summary,
|
"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,
|
"stats": user_stats,
|
||||||
"user_sites": user_sites,
|
"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
|
||||||
|
|||||||
@@ -262,7 +262,9 @@ class Database:
|
|||||||
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,7 +311,9 @@ 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(
|
||||||
|
|||||||
@@ -318,12 +318,11 @@ 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()
|
||||||
@@ -337,7 +336,7 @@ async def user_mcp_handler(request: Request) -> Response:
|
|||||||
_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
BIN
pytest-out.txt
Normal file
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
@@ -385,22 +385,24 @@ 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())
|
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
|
||||||
|
|
||||||
resp = client.get('/dashboard/connect')
|
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
|
||||||
|
|||||||
@@ -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,6 +13,7 @@ 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."""
|
||||||
@@ -25,7 +27,7 @@ def mock_managers():
|
|||||||
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
|
||||||
@@ -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."""
|
||||||
@@ -83,9 +86,9 @@ def test_dynamic_endpoint_initialize(mock_managers):
|
|||||||
"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()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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."""
|
||||||
@@ -15,11 +16,7 @@ async def test_get_all_projects_tenant_isolation(monkeypatch):
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user