diff --git a/.gitignore b/.gitignore index 1ea1171..ef633f5 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,3 @@ pytest-cache-files-*/ .worktrees/ worktrees/ /.agents -pytest-out.txt diff --git a/core/dashboard/auth.py b/core/dashboard/auth.py index 7c23cff..cb3a403 100644 --- a/core/dashboard/auth.py +++ b/core/dashboard/auth.py @@ -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) diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index f5d2a81..8f1dcd3 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -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", diff --git a/core/database.py b/core/database.py index 1d6f878..78c1bc9 100644 --- a/core/database.py +++ b/core/database.py @@ -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 (?, ?)", diff --git a/core/health.py b/core/health.py index cbdac2b..7c01455 100644 --- a/core/health.py +++ b/core/health.py @@ -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 diff --git a/core/user_endpoints.py b/core/user_endpoints.py index 2557353..3d7b283 100644 --- a/core/user_endpoints.py +++ b/core/user_endpoints.py @@ -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.", ) ) diff --git a/pytest-out.txt b/pytest-out.txt new file mode 100644 index 0000000..a473d3e Binary files /dev/null and b/pytest-out.txt differ diff --git a/server.py b/server.py index e7fe52f..c59b63e 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 1e24d34..7584b08 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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 diff --git a/tests/test_dynamic_endpoints_integration.py b/tests/test_dynamic_endpoints_integration.py index 84ba1ab..10c0525 100644 --- a/tests/test_dynamic_endpoints_integration.py +++ b/tests/test_dynamic_endpoints_integration.py @@ -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() diff --git a/tests/test_tenant_isolation.py b/tests/test_tenant_isolation.py index 7098220..6fc7451 100644 --- a/tests/test_tenant_isolation.py +++ b/tests/test_tenant_isolation.py @@ -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