feat: v3.1.0 — Live Platform Foundation (Track E.1-E.3)

Major release introducing the Live Platform architecture:

- SQLite database backend with async operations and migrations (E.1)
- AES-256-GCM credential encryption with HKDF key derivation (E.1)
- OAuth Social Login with GitHub and Google (E.2)
- Site management API with encrypted credential storage (E.3)
- Per-user MCP endpoints at /u/{user_id}/{alias}/mcp (E.3)
- User API keys with bcrypt hashing (E.3)
- Auto-generated config snippets for 5 MCP clients (E.3)
- Dashboard: dark/light mode, RBAC, My Sites, Connect page
- Active background health checks
- 452 tests (up from 303), all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-23 23:20:03 +03:30
parent 42ea75bcb3
commit 9e6f06d933
53 changed files with 8654 additions and 1820 deletions

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""
Quick test to verify per-project API key isolation.
This test checks that:
1. Per-project API key can access its own project
2. Per-project API key CANNOT access other projects
3. Global API key can access all projects
"""
import asyncio
import os
# Mock setup
os.environ["MASTER_API_KEY"] = "test_master_key_123"
os.environ["WORDPRESS_SITE1_URL"] = "https://site1.example.com"
os.environ["WORDPRESS_SITE1_USERNAME"] = "admin"
os.environ["WORDPRESS_SITE1_APP_PASSWORD"] = "password1"
os.environ["WORDPRESS_SITE4_URL"] = "https://site4.example.com"
os.environ["WORDPRESS_SITE4_USERNAME"] = "admin"
os.environ["WORDPRESS_SITE4_APP_PASSWORD"] = "password4"
# Import after env setup
from core.api_keys import get_api_key_manager
from core.project_manager import get_project_manager
from core.site_registry import get_site_registry
from core.unified_tools import UnifiedToolGenerator
print("=" * 60)
print("Testing Per-Project API Key Isolation")
print("=" * 60)
# Initialize
api_key_manager = get_api_key_manager()
project_manager = get_project_manager()
site_registry = get_site_registry()
# Discover sites
from plugins import registry as plugin_registry
plugin_types = plugin_registry.get_registered_types()
site_registry.discover_sites(plugin_types)
print(f"\nDiscovered sites: {list(site_registry.sites.keys())}")
# Create API keys
print("\n1. Creating API keys...")
# Global key
global_key = api_key_manager.create_key(
project_id="*", scope="admin", description="Global test key"
)
print(f" ✓ Global key created: {global_key}")
# Per-project key for wordpress_site4
site4_key = api_key_manager.create_key(
project_id="wordpress_site4", scope="admin", description="Site4 only key"
)
print(f" ✓ Site4 key created: {site4_key}")
# Create unified tool generator
unified_gen = UnifiedToolGenerator(project_manager)
unified_tools = unified_gen.generate_all_unified_tools()
print(f"\n2. Generated {len(unified_tools)} unified tools")
# Find wordpress_list_posts tool
list_posts_tool = None
for tool in unified_tools:
if tool["name"] == "wordpress_list_posts":
list_posts_tool = tool
break
if not list_posts_tool:
print(" ✗ wordpress_list_posts tool not found!")
exit(1)
print(" ✓ Found wordpress_list_posts tool")
async def test_access(key_token, site_id, expected_result):
"""Test if a key can access a site"""
from server import _api_key_context
# Validate key and set context (simulating middleware)
key_id = api_key_manager.validate_key(
key_token, project_id="*", required_scope="read", skip_project_check=True
)
if key_id:
key = api_key_manager.keys.get(key_id)
_api_key_context.set(
{
"key_id": key_id,
"project_id": key.project_id,
"scope": key.scope,
"is_global": key.project_id == "*",
}
)
# Try to call the handler
handler = list_posts_tool["handler"]
result = await handler(site=site_id, per_page=1)
# Check result
is_error = isinstance(result, str) and result.startswith("Error: Access denied")
if expected_result == "allowed":
if not is_error:
print(" ✓ Access allowed as expected")
return True
else:
print(" ✗ FAIL: Access denied but should be allowed!")
print(f" Result: {result[:100]}")
return False
else: # expected_result == "denied"
if is_error:
print(" ✓ Access denied as expected")
return True
else:
print(" ✗ FAIL: Access allowed but should be denied!")
print(f" Result: {result[:100] if isinstance(result, str) else str(result)[:100]}")
return False
async def run_tests():
"""Run all test cases"""
print("\n3. Testing access control...")
all_pass = True
# Test 1: Per-project key accessing its own project
print("\n Test 1: Per-project key (site4) → site4")
all_pass &= await test_access(site4_key, "site4", "allowed")
# Test 2: Per-project key accessing different project
print("\n Test 2: Per-project key (site4) → site1")
all_pass &= await test_access(site4_key, "site1", "denied")
# Test 3: Global key accessing any project
print("\n Test 3: Global key → site1")
all_pass &= await test_access(global_key, "site1", "allowed")
print("\n Test 4: Global key → site4")
all_pass &= await test_access(global_key, "site4", "allowed")
return all_pass
# Run tests
result = asyncio.run(run_tests())
print("\n" + "=" * 60)
if result:
print("✅ All tests PASSED!")
print("Per-project API key isolation is working correctly!")
else:
print("❌ Some tests FAILED!")
print("Per-project API key isolation has issues!")
print("=" * 60)
exit(0 if result else 1)

View File

@@ -100,8 +100,6 @@ class IntegrationTester:
# Check handlers initialized
assert hasattr(plugin, "posts"), "Missing posts handler"
assert hasattr(plugin, "media"), "Missing media handler"
assert hasattr(plugin, "products"), "Missing products handler"
assert hasattr(plugin, "orders"), "Missing orders handler"
self.add_result(
"wordpress_plugin_init", "passed", "WordPress plugin initializes correctly"
@@ -158,11 +156,6 @@ class IntegrationTester:
"CommentsHandler",
"UsersHandler",
"SiteHandler",
"ProductsHandler",
"OrdersHandler",
"CustomersHandler",
"ReportsHandler",
"CouponsHandler",
"SEOHandler",
"WPCLIHandler",
"MenusHandler",

View File

@@ -0,0 +1,154 @@
"""Tests for MCP client configuration snippet generation (core/config_snippets.py)."""
import json
import pytest
from core.config_snippets import generate_config, get_supported_clients
# ── Test Data ─────────────────────────────────────────────────
BASE_URL = "https://mcp.example.com"
USER_ID = "abc123-uuid"
ALIAS = "myblog"
API_KEY = "mhu_testapikey1234567890abcdefghijklmnopqr"
EXPECTED_ENDPOINT = f"{BASE_URL}/u/{USER_ID}/{ALIAS}/mcp"
# ── Supported Clients ────────────────────────────────────────
class TestSupportedClients:
"""Test get_supported_clients function."""
@pytest.mark.unit
def test_get_supported_clients(self):
"""Should return exactly 5 supported client types."""
clients = get_supported_clients()
assert len(clients) == 5
client_ids = [c["id"] for c in clients]
assert "claude_desktop" in client_ids
assert "claude_code" in client_ids
assert "cursor" in client_ids
assert "vscode" in client_ids
assert "chatgpt" in client_ids
@pytest.mark.unit
def test_supported_clients_have_labels(self):
"""Each client should have id, label, and description."""
for client in get_supported_clients():
assert "id" in client
assert "label" in client
assert "description" in client
assert len(client["label"]) > 0
assert len(client["description"]) > 0
# ── Claude Desktop / Claude Code Format ──────────────────────
class TestClaudeFormat:
"""Test Claude Desktop and Claude Code config generation."""
@pytest.mark.unit
def test_claude_desktop_format(self):
"""Claude Desktop config should be valid JSON with mcpServers."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_desktop")
config = json.loads(snippet)
assert "mcpServers" in config
server_name = f"mcphub-{ALIAS}"
assert server_name in config["mcpServers"]
server = config["mcpServers"][server_name]
assert server["url"] == EXPECTED_ENDPOINT
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
@pytest.mark.unit
def test_claude_code_format(self):
"""Claude Code config should use the same mcpServers format."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_code")
config = json.loads(snippet)
assert "mcpServers" in config
server_name = f"mcphub-{ALIAS}"
assert server_name in config["mcpServers"]
# ── Cursor / VS Code Format ─────────────────────────────────
class TestCursorVSCodeFormat:
"""Test Cursor and VS Code config generation."""
@pytest.mark.unit
def test_cursor_format(self):
"""Cursor config should be valid JSON with mcp.servers."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "cursor")
config = json.loads(snippet)
assert "mcp" in config
assert "servers" in config["mcp"]
server_name = f"mcphub-{ALIAS}"
assert server_name in config["mcp"]["servers"]
server = config["mcp"]["servers"][server_name]
assert server["url"] == EXPECTED_ENDPOINT
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
@pytest.mark.unit
def test_vscode_format(self):
"""VS Code config should use the same mcp.servers format as Cursor."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "vscode")
config = json.loads(snippet)
assert "mcp" in config
assert "servers" in config["mcp"]
# ── ChatGPT Format ───────────────────────────────────────────
class TestChatGPTFormat:
"""Test ChatGPT config generation."""
@pytest.mark.unit
def test_chatgpt_format(self):
"""ChatGPT config should return the raw endpoint URL only."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "chatgpt")
assert snippet == EXPECTED_ENDPOINT
# Should NOT be JSON
with pytest.raises(json.JSONDecodeError):
json.loads(snippet)
# ── Error Handling ───────────────────────────────────────────
class TestErrorHandling:
"""Test error conditions."""
@pytest.mark.unit
def test_invalid_client_type(self):
"""Unsupported client type should raise ValueError."""
with pytest.raises(ValueError, match="Unsupported client type"):
generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "unknown_client")
# ── URL Correctness ──────────────────────────────────────────
class TestURLCorrectness:
"""Test that generated configs contain the correct endpoint URL."""
@pytest.mark.unit
def test_config_contains_correct_url(self):
"""All config formats should contain /u/{user_id}/{alias}/mcp."""
for client_type in ("claude_desktop", "claude_code", "cursor", "vscode", "chatgpt"):
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, client_type)
assert (
f"/u/{USER_ID}/{ALIAS}/mcp" in snippet
), f"{client_type} config missing expected URL path"
@pytest.mark.unit
def test_trailing_slash_stripped(self):
"""Base URL with trailing slash should not produce double slashes."""
snippet = generate_config(
"https://mcp.example.com/", USER_ID, ALIAS, API_KEY, "claude_desktop"
)
assert "//u/" not in snippet
assert f"/u/{USER_ID}/{ALIAS}/mcp" in snippet

View File

@@ -371,3 +371,37 @@ class TestDashboardCookieManagement:
break
assert cookie_header is not None
assert "mcp_dashboard_session=" in cookie_header
def test_dashboard_connect_page(monkeypatch):
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
from server import create_multi_endpoint_app
from starlette.testclient import TestClient
import core.dashboard.routes
import core.site_api
import core.user_keys
app = create_multi_endpoint_app()
client = TestClient(app)
def mock_req(*args):
return {'user_id': 'abc', 'type': 'user'}, None
monkeypatch.setattr(core.dashboard.routes, '_require_user_session', mock_req)
async def mock_sites(*args):
return [{'alias': 'Test', 'plugin_type': 'dummy'}]
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}]
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

577
tests/test_database.py Normal file
View File

@@ -0,0 +1,577 @@
"""Tests for SQLite database backend (core/database.py)."""
import asyncio
import aiosqlite
import pytest
from core.database import SCHEMA_VERSION, Database, get_database, initialize_database
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def db(tmp_path):
"""Provide an initialized Database using a temp directory."""
db_path = str(tmp_path / "test.db")
database = Database(db_path)
await database.initialize()
yield database
await database.close()
@pytest.fixture
async def user_row(db):
"""Create and return a sample user dict."""
return await db.create_user(
email="alice@example.com",
name="Alice",
provider="github",
provider_id="gh-111",
avatar_url="https://example.com/alice.png",
)
@pytest.fixture
async def second_user(db):
"""Create and return a second sample user dict."""
return await db.create_user(
email="bob@example.com",
name="Bob",
provider="google",
provider_id="gg-222",
)
@pytest.fixture
async def site_row(db, user_row):
"""Create and return a sample site dict."""
return await db.create_site(
user_id=user_row["id"],
plugin_type="wordpress",
alias="myblog",
url="https://myblog.example.com",
credentials=b"encrypted-blob-data",
)
# ---------------------------------------------------------------------------
# Schema creation
# ---------------------------------------------------------------------------
class TestSchemaCreation:
"""Test that the database schema is created correctly."""
@pytest.mark.unit
async def test_tables_exist(self, db):
"""All expected tables should exist after initialization."""
rows = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
table_names = {row["name"] for row in rows}
expected = {"users", "sites", "user_api_keys", "connection_tokens", "schema_version"}
assert expected.issubset(table_names)
@pytest.mark.unit
async def test_schema_version_set(self, db):
"""schema_version table should contain version 1 after init."""
row = await db.fetchone("SELECT MAX(version) AS v FROM schema_version")
assert row is not None
assert row["v"] == SCHEMA_VERSION
# ---------------------------------------------------------------------------
# User CRUD
# ---------------------------------------------------------------------------
class TestUserCRUD:
"""Test user create, read, and update operations."""
@pytest.mark.unit
async def test_create_user(self, db):
"""Should create a user and return a dict with all fields."""
user = await db.create_user(
email="test@example.com",
name="Test User",
provider="github",
provider_id="gh-999",
avatar_url="https://example.com/avatar.png",
)
assert user["email"] == "test@example.com"
assert user["name"] == "Test User"
assert user["provider"] == "github"
assert user["provider_id"] == "gh-999"
assert user["avatar_url"] == "https://example.com/avatar.png"
assert user["role"] == "user"
assert user["id"] is not None
assert user["created_at"] is not None
@pytest.mark.unit
async def test_create_user_admin_role(self, db):
"""Should allow creating a user with admin role."""
user = await db.create_user(
email="admin@example.com",
name="Admin",
provider="github",
provider_id="gh-admin",
role="admin",
)
assert user["role"] == "admin"
@pytest.mark.unit
async def test_get_user_by_id(self, db, user_row):
"""Should retrieve a user by their UUID."""
fetched = await db.get_user_by_id(user_row["id"])
assert fetched is not None
assert fetched["email"] == "alice@example.com"
@pytest.mark.unit
async def test_get_user_by_id_not_found(self, db):
"""Should return None for a non-existent user ID."""
fetched = await db.get_user_by_id("non-existent-uuid")
assert fetched is None
@pytest.mark.unit
async def test_get_user_by_provider(self, db, user_row):
"""Should retrieve a user by provider and provider_id."""
fetched = await db.get_user_by_provider("github", "gh-111")
assert fetched is not None
assert fetched["id"] == user_row["id"]
@pytest.mark.unit
async def test_get_user_by_provider_not_found(self, db):
"""Should return None for a non-existent provider combo."""
fetched = await db.get_user_by_provider("github", "does-not-exist")
assert fetched is None
@pytest.mark.unit
async def test_update_last_login(self, db, user_row):
"""Should update the last_login timestamp."""
original_login = user_row["last_login"]
# Small delay so the timestamps differ
await asyncio.sleep(0.01)
await db.update_user_last_login(user_row["id"])
fetched = await db.get_user_by_id(user_row["id"])
assert fetched is not None
assert fetched["last_login"] != original_login
assert fetched["last_login"] > original_login
# ---------------------------------------------------------------------------
# User uniqueness constraints
# ---------------------------------------------------------------------------
class TestUserConstraints:
"""Test UNIQUE constraints on the users table."""
@pytest.mark.unit
async def test_duplicate_email_raises(self, db, user_row):
"""Inserting a user with a duplicate email should raise IntegrityError."""
with pytest.raises(aiosqlite.IntegrityError):
await db.create_user(
email="alice@example.com", # duplicate
name="Alice Clone",
provider="google",
provider_id="gg-unique",
)
@pytest.mark.unit
async def test_duplicate_provider_raises(self, db, user_row):
"""Inserting a user with duplicate provider+provider_id should raise."""
with pytest.raises(aiosqlite.IntegrityError):
await db.create_user(
email="other@example.com",
name="Other",
provider="github", # same provider
provider_id="gh-111", # same provider_id
)
# ---------------------------------------------------------------------------
# Site CRUD
# ---------------------------------------------------------------------------
class TestSiteCRUD:
"""Test site create, read, update, and delete operations."""
@pytest.mark.unit
async def test_create_site(self, db, user_row):
"""Should create a site and return a dict with all fields."""
site = await db.create_site(
user_id=user_row["id"],
plugin_type="wordpress",
alias="testsite",
url="https://test.example.com",
credentials=b"encrypted-data",
)
assert site["plugin_type"] == "wordpress"
assert site["alias"] == "testsite"
assert site["url"] == "https://test.example.com"
assert site["credentials"] == b"encrypted-data"
assert site["status"] == "pending"
assert site["user_id"] == user_row["id"]
assert site["id"] is not None
assert site["created_at"] is not None
@pytest.mark.unit
async def test_get_sites_by_user(self, db, user_row):
"""Should return all sites for a user."""
await db.create_site(
user_id=user_row["id"],
plugin_type="wordpress",
alias="site-a",
url="https://a.example.com",
credentials=b"cred-a",
)
await db.create_site(
user_id=user_row["id"],
plugin_type="gitea",
alias="site-b",
url="https://b.example.com",
credentials=b"cred-b",
)
sites = await db.get_sites_by_user(user_row["id"])
assert len(sites) == 2
aliases = {s["alias"] for s in sites}
assert aliases == {"site-a", "site-b"}
@pytest.mark.unit
async def test_get_sites_by_user_empty(self, db, user_row):
"""Should return empty list when user has no sites."""
sites = await db.get_sites_by_user(user_row["id"])
assert sites == []
@pytest.mark.unit
async def test_get_site(self, db, user_row, site_row):
"""Should retrieve a site by ID with user scoping."""
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
assert fetched["alias"] == "myblog"
@pytest.mark.unit
async def test_delete_site(self, db, user_row, site_row):
"""Should delete a site and return True."""
deleted = await db.delete_site(site_row["id"], user_row["id"])
assert deleted is True
# Verify it's gone
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is None
@pytest.mark.unit
async def test_delete_site_not_found(self, db, user_row):
"""Should return False when deleting a non-existent site."""
deleted = await db.delete_site("no-such-id", user_row["id"])
assert deleted is False
# ---------------------------------------------------------------------------
# Site isolation
# ---------------------------------------------------------------------------
class TestSiteIsolation:
"""Test that site access is properly scoped to the owning user."""
@pytest.mark.unit
async def test_get_site_wrong_user_returns_none(self, db, user_row, second_user, site_row):
"""get_site with wrong user_id should return None."""
fetched = await db.get_site(site_row["id"], second_user["id"])
assert fetched is None
@pytest.mark.unit
async def test_delete_site_wrong_user_returns_false(self, db, user_row, second_user, site_row):
"""delete_site with wrong user_id should return False and not delete."""
deleted = await db.delete_site(site_row["id"], second_user["id"])
assert deleted is False
# Verify it's still there for the real owner
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
# ---------------------------------------------------------------------------
# Site alias constraints
# ---------------------------------------------------------------------------
class TestSiteAliasConstraints:
"""Test UNIQUE(user_id, alias) constraint on the sites table."""
@pytest.mark.unit
async def test_duplicate_alias_same_user_raises(self, db, user_row, site_row):
"""Duplicate alias for the same user should raise IntegrityError."""
with pytest.raises(aiosqlite.IntegrityError):
await db.create_site(
user_id=user_row["id"],
plugin_type="gitea",
alias="myblog", # duplicate alias for same user
url="https://other.example.com",
credentials=b"cred",
)
@pytest.mark.unit
async def test_same_alias_different_users_succeeds(self, db, user_row, second_user):
"""Same alias for different users should succeed."""
site1 = await db.create_site(
user_id=user_row["id"],
plugin_type="wordpress",
alias="shared-alias",
url="https://a.example.com",
credentials=b"cred-a",
)
site2 = await db.create_site(
user_id=second_user["id"],
plugin_type="wordpress",
alias="shared-alias",
url="https://b.example.com",
credentials=b"cred-b",
)
assert site1["id"] != site2["id"]
assert site1["alias"] == site2["alias"] == "shared-alias"
# ---------------------------------------------------------------------------
# Cascade delete
# ---------------------------------------------------------------------------
class TestCascadeDelete:
"""Test that deleting a user cascades to their sites."""
@pytest.mark.unit
async def test_delete_user_cascades_sites(self, db, user_row, site_row):
"""Deleting a user should also delete their sites."""
# Verify site exists
site = await db.get_site(site_row["id"], user_row["id"])
assert site is not None
# Delete the user directly
await db.execute("DELETE FROM users WHERE id = ?", (user_row["id"],))
# Site should be gone
row = await db.fetchone("SELECT * FROM sites WHERE id = ?", (site_row["id"],))
assert row is None
# ---------------------------------------------------------------------------
# update_site_status
# ---------------------------------------------------------------------------
class TestUpdateSiteStatus:
"""Test site status updates."""
@pytest.mark.unit
async def test_update_site_status(self, db, user_row, site_row):
"""Should update status and status_msg."""
await db.update_site_status(site_row["id"], "active", "Connected successfully")
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
assert fetched["status"] == "active"
assert fetched["status_msg"] == "Connected successfully"
@pytest.mark.unit
async def test_update_site_status_to_error(self, db, user_row, site_row):
"""Should allow setting error status with a message."""
await db.update_site_status(site_row["id"], "error", "Connection refused")
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
assert fetched["status"] == "error"
assert fetched["status_msg"] == "Connection refused"
@pytest.mark.unit
async def test_update_site_status_clears_message(self, db, user_row, site_row):
"""Should clear status_msg when set to None."""
await db.update_site_status(site_row["id"], "active", "All good")
await db.update_site_status(site_row["id"], "disabled")
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
assert fetched["status"] == "disabled"
assert fetched["status_msg"] is None
@pytest.mark.unit
async def test_update_site_status_with_user_id(self, db, user_row, site_row):
"""Should update when user_id matches the site owner."""
await db.update_site_status(site_row["id"], "active", "OK", user_id=user_row["id"])
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
assert fetched["status"] == "active"
@pytest.mark.unit
async def test_update_site_status_wrong_user_id_no_effect(self, db, user_row, site_row):
"""Should not update when user_id doesn't match."""
await db.update_site_status(site_row["id"], "active", "OK", user_id="wrong-user-id")
fetched = await db.get_site(site_row["id"], user_row["id"])
assert fetched is not None
assert fetched["status"] == "pending" # unchanged
# ---------------------------------------------------------------------------
# Context manager
# ---------------------------------------------------------------------------
class TestContextManager:
"""Test async context manager support."""
@pytest.mark.unit
async def test_context_manager(self, tmp_path):
"""async with Database(...) as db: should work."""
db_path = str(tmp_path / "ctx_test.db")
async with Database(db_path) as db:
user = await db.create_user(
email="ctx@example.com",
name="Context",
provider="github",
provider_id="gh-ctx",
)
assert user["email"] == "ctx@example.com"
# After exit, connection should be closed
assert db._conn is None
# ---------------------------------------------------------------------------
# PRAGMA checks
# ---------------------------------------------------------------------------
class TestPragmas:
"""Test that WAL mode and foreign keys are enabled."""
@pytest.mark.unit
async def test_wal_mode_enabled(self, db):
"""PRAGMA journal_mode should be WAL."""
row = await db.fetchone("PRAGMA journal_mode")
assert row is not None
assert row["journal_mode"].lower() == "wal"
@pytest.mark.unit
async def test_foreign_keys_enabled(self, db):
"""PRAGMA foreign_keys should be ON (1)."""
row = await db.fetchone("PRAGMA foreign_keys")
assert row is not None
assert row["foreign_keys"] == 1
# ---------------------------------------------------------------------------
# Concurrent access
# ---------------------------------------------------------------------------
class TestConcurrentAccess:
"""Test that two Database instances on the same file don't deadlock."""
@pytest.mark.unit
async def test_concurrent_instances(self, tmp_path):
"""Two Database instances on the same file should not deadlock."""
db_path = str(tmp_path / "concurrent.db")
async with Database(db_path) as db1, Database(db_path) as db2:
user1 = await db1.create_user(
email="user1@example.com",
name="User 1",
provider="github",
provider_id="gh-1",
)
user2 = await db2.create_user(
email="user2@example.com",
name="User 2",
provider="google",
provider_id="gg-2",
)
# Both users should be visible from either connection
fetched1 = await db2.get_user_by_id(user1["id"])
fetched2 = await db1.get_user_by_id(user2["id"])
assert fetched1 is not None
assert fetched2 is not None
assert fetched1["email"] == "user1@example.com"
assert fetched2["email"] == "user2@example.com"
# ---------------------------------------------------------------------------
# Empty results
# ---------------------------------------------------------------------------
class TestEmptyResults:
"""Test behavior with empty or non-existent data."""
@pytest.mark.unit
async def test_get_user_by_id_nonexistent(self, db):
"""get_user_by_id with a fake UUID should return None."""
result = await db.get_user_by_id("00000000-0000-0000-0000-000000000000")
assert result is None
@pytest.mark.unit
async def test_get_user_by_provider_nonexistent(self, db):
"""get_user_by_provider with a fake combo should return None."""
result = await db.get_user_by_provider("unknown_provider", "fake_id")
assert result is None
@pytest.mark.unit
async def test_get_site_nonexistent(self, db):
"""get_site with fake IDs should return None."""
result = await db.get_site("fake-site-id", "fake-user-id")
assert result is None
@pytest.mark.unit
async def test_get_sites_by_user_nonexistent(self, db):
"""get_sites_by_user with a fake user ID should return empty list."""
result = await db.get_sites_by_user("fake-user-id")
assert result == []
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers."""
@pytest.mark.unit
async def test_get_database_before_init_raises(self, monkeypatch):
"""get_database() should raise RuntimeError if not initialized."""
# Reset the module-level singleton
import core.database as db_module
monkeypatch.setattr(db_module, "_database", None)
with pytest.raises(RuntimeError, match="Database not initialized"):
get_database()
@pytest.mark.unit
async def test_initialize_and_get_database(self, tmp_path, monkeypatch):
"""initialize_database() should set the singleton, get_database() returns it."""
import core.database as db_module
monkeypatch.setattr(db_module, "_database", None)
db_path = str(tmp_path / "singleton_test.db")
db = await initialize_database(db_path)
try:
assert db is get_database()
# Should be functional
user = await db.create_user(
email="singleton@example.com",
name="Singleton",
provider="github",
provider_id="gh-singleton",
)
assert user["email"] == "singleton@example.com"
finally:
await db.close()
monkeypatch.setattr(db_module, "_database", None)

View File

@@ -0,0 +1,112 @@
"""
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
import json
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"
}
# Mock Database
mock_db = AsyncMock()
mock_db.get_site_by_alias.return_value = {
"id": "site-123",
"user_id": "user-123",
"plugin_type": "wordpress",
"alias": "myblog",
"url": "https://example.com",
"credentials": b"encrypted-blob",
"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
}
@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"}
)
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"}
)
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."""
response = client.post(
"/u/user-123/myblog/mcp",
headers={"Authorization": "Bearer mhu_test-key"},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}
}
)
assert response.status_code == 200
data = response.json()
assert "result" in data
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"
}
response = client.post(
"/u/user-123/myblog/mcp",
headers={"Authorization": "Bearer mhu_test-key"},
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}
)
assert response.status_code == 403
data = response.json()
assert "does not match" in data["error"]["message"]

315
tests/test_encryption.py Normal file
View File

@@ -0,0 +1,315 @@
"""Tests for Credential Encryption (core/encryption.py)."""
import base64
import json
import os
import pytest
from cryptography.exceptions import InvalidTag
from core.encryption import (
CredentialEncryption,
get_credential_encryption,
initialize_credential_encryption,
)
# A valid base64-encoded 32-byte key for testing
TEST_KEY = base64.b64encode(os.urandom(32)).decode()
@pytest.fixture
def encryption():
"""Create a CredentialEncryption instance with a test key."""
return CredentialEncryption(encryption_key=TEST_KEY)
@pytest.fixture
def _clear_singleton():
"""Reset the global singleton before and after each test that uses it."""
import core.encryption as mod
original = mod._credential_encryption
mod._credential_encryption = None
yield
mod._credential_encryption = original
@pytest.mark.unit
class TestEncryptDecrypt:
"""Test basic encrypt/decrypt round-trips."""
def test_round_trip(self, encryption):
"""Encrypt then decrypt should return the original plaintext."""
plaintext = "Hello, World!"
site_id = "site_001"
cipherdata = encryption.encrypt(plaintext, site_id)
result = encryption.decrypt(cipherdata, site_id)
assert result == plaintext
def test_credentials_round_trip(self, encryption):
"""encrypt_credentials then decrypt_credentials should return the same dict."""
credentials = {
"username": "admin",
"app_password": "xxxx xxxx xxxx xxxx",
"api_key": "sk-1234567890",
}
site_id = "site_002"
cipherdata = encryption.encrypt_credentials(credentials, site_id)
result = encryption.decrypt_credentials(cipherdata, site_id)
assert result == credentials
def test_empty_credentials(self, encryption):
"""Empty dict should encrypt and decrypt correctly."""
credentials = {}
site_id = "site_empty"
cipherdata = encryption.encrypt_credentials(credentials, site_id)
result = encryption.decrypt_credentials(cipherdata, site_id)
assert result == {}
def test_unicode_credentials(self, encryption):
"""Non-ASCII characters in credentials should survive round-trip."""
credentials = {
"username": "\u06a9\u0627\u0631\u0628\u0631",
"password": "\u0631\u0645\u0632\u0639\u0628\u0648\u0631-\u0627\u06cc\u0645\u0646",
"display_name": "\u5f20\u4e09\u7684\u535a\u5ba2",
"notes": "Emoji \u2764\ufe0f test \U0001f680",
}
site_id = "site_unicode"
cipherdata = encryption.encrypt_credentials(credentials, site_id)
result = encryption.decrypt_credentials(cipherdata, site_id)
assert result == credentials
def test_large_payload(self, encryption):
"""A large JSON payload (~10KB) should encrypt and decrypt correctly."""
credentials = {f"key_{i}": f"value_{i}_{'x' * 100}" for i in range(85)}
json_size = len(json.dumps(credentials))
assert json_size > 10000, f"Payload should be >10KB, got {json_size}"
site_id = "site_large"
cipherdata = encryption.encrypt_credentials(credentials, site_id)
result = encryption.decrypt_credentials(cipherdata, site_id)
assert result == credentials
def test_empty_string_encrypt_decrypt(self, encryption):
"""Empty string should encrypt and decrypt correctly."""
cipherdata = encryption.encrypt("", "site_empty_str")
result = encryption.decrypt(cipherdata, "site_empty_str")
assert result == ""
@pytest.mark.unit
class TestSiteIsolation:
"""Test that different site_ids produce different ciphertext and keys."""
def test_different_site_ids_produce_different_ciphertext(self, encryption):
"""Same plaintext encrypted with different site_ids should differ."""
plaintext = "same-secret-value"
cipher_a = encryption.encrypt(plaintext, "site_alpha")
cipher_b = encryption.encrypt(plaintext, "site_beta")
# Ciphertext should differ (different derived keys + different nonces)
assert cipher_a != cipher_b
def test_wrong_site_id_fails_to_decrypt(self, encryption):
"""Decrypting with the wrong site_id should raise InvalidTag."""
plaintext = "secret-data"
cipherdata = encryption.encrypt(plaintext, "correct_site")
with pytest.raises(InvalidTag):
encryption.decrypt(cipherdata, "wrong_site")
def test_wrong_site_id_fails_credentials(self, encryption):
"""decrypt_credentials with wrong site_id should raise InvalidTag."""
credentials = {"username": "admin", "password": "secret"}
cipherdata = encryption.encrypt_credentials(credentials, "correct_site")
with pytest.raises(InvalidTag):
encryption.decrypt_credentials(cipherdata, "wrong_site")
@pytest.mark.unit
class TestTampering:
"""Test that tampered ciphertext is detected."""
def test_tampered_ciphertext_fails(self, encryption):
"""Modifying a byte in the ciphertext should cause decryption to fail."""
plaintext = "sensitive-data"
site_id = "site_tamper"
cipherdata = encryption.encrypt(plaintext, site_id)
# Tamper with a byte in the ciphertext portion (after version + nonce)
tampered = bytearray(cipherdata)
tampered[16] ^= 0xFF # Flip bits in a ciphertext byte
tampered = bytes(tampered)
with pytest.raises(InvalidTag):
encryption.decrypt(tampered, site_id)
def test_tampered_nonce_fails(self, encryption):
"""Modifying the nonce should cause decryption to fail."""
plaintext = "sensitive-data"
site_id = "site_nonce_tamper"
cipherdata = encryption.encrypt(plaintext, site_id)
tampered = bytearray(cipherdata)
tampered[1] ^= 0xFF # Flip bits in the nonce (byte 1, after version byte)
tampered = bytes(tampered)
with pytest.raises(InvalidTag):
encryption.decrypt(tampered, site_id)
def test_truncated_cipherdata_fails(self, encryption):
"""Truncated cipherdata should raise ValueError."""
with pytest.raises(ValueError, match="too short"):
encryption.decrypt(b"short", "site_trunc")
def test_unsupported_version_fails(self, encryption):
"""Cipherdata with wrong version byte should raise ValueError."""
cipherdata = encryption.encrypt("test", "site_ver")
# Replace version byte with 0x99
bad_version = b"\x99" + cipherdata[1:]
with pytest.raises(ValueError, match="Unsupported encryption format version"):
encryption.decrypt(bad_version, "site_ver")
@pytest.mark.unit
class TestKeyValidation:
"""Test encryption key validation."""
def test_missing_key_raises_valueerror(self, monkeypatch):
"""Missing ENCRYPTION_KEY should raise ValueError."""
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
CredentialEncryption()
def test_invalid_base64_raises_valueerror(self):
"""Non-base64 key should raise ValueError."""
with pytest.raises(ValueError, match="valid base64"):
CredentialEncryption(encryption_key="not-valid-base64!!!")
def test_wrong_length_key_raises_valueerror(self):
"""Key that decodes to wrong number of bytes should raise ValueError."""
short_key = base64.b64encode(b"too-short").decode()
with pytest.raises(ValueError, match="exactly 32 bytes"):
CredentialEncryption(encryption_key=short_key)
def test_16_byte_key_raises_valueerror(self):
"""A 16-byte key (AES-128) should be rejected; we require 32 bytes."""
key_16 = base64.b64encode(os.urandom(16)).decode()
with pytest.raises(ValueError, match="exactly 32 bytes"):
CredentialEncryption(encryption_key=key_16)
def test_valid_key_from_env(self, monkeypatch):
"""ENCRYPTION_KEY from env should be accepted when valid."""
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
enc = CredentialEncryption()
# Should work without error
cipherdata = enc.encrypt("test", "site")
assert enc.decrypt(cipherdata, "site") == "test"
@pytest.mark.unit
class TestKeyDerivation:
"""Test HKDF key derivation properties."""
def test_deterministic(self, encryption):
"""Same site_id should always produce the same derived key."""
key_a = encryption._derive_key("site_deterministic")
key_b = encryption._derive_key("site_deterministic")
assert key_a == key_b
def test_different_sites_different_keys(self, encryption):
"""Different site_ids should produce different derived keys."""
key_a = encryption._derive_key("site_one")
key_b = encryption._derive_key("site_two")
assert key_a != key_b
def test_derived_key_length(self, encryption):
"""Derived key should be 32 bytes."""
key = encryption._derive_key("any_site")
assert len(key) == 32
@pytest.mark.unit
class TestNonceUniqueness:
"""Test that random nonces ensure ciphertext uniqueness."""
def test_same_plaintext_different_ciphertext(self, encryption):
"""Two encryptions of the same plaintext should produce different ciphertext."""
plaintext = "identical-value"
site_id = "site_nonce"
cipher_a = encryption.encrypt(plaintext, site_id)
cipher_b = encryption.encrypt(plaintext, site_id)
# Both should decrypt to the same value
assert encryption.decrypt(cipher_a, site_id) == plaintext
assert encryption.decrypt(cipher_b, site_id) == plaintext
# But the ciphertext should differ (different random nonces)
assert cipher_a != cipher_b
def test_nonce_is_12_bytes(self, encryption):
"""The nonce prefix should be exactly 12 bytes (after version byte)."""
cipherdata = encryption.encrypt("test", "site_nonce_len")
# Minimum size: 1 (version) + 12 (nonce) + 0 (empty plaintext encrypted) + 16 (tag)
assert len(cipherdata) >= 29
# Version byte is 0x01
assert cipherdata[0:1] == b"\x01"
@pytest.mark.unit
class TestSingleton:
"""Test the module-level singleton getter."""
@pytest.mark.usefixtures("_clear_singleton")
def test_get_credential_encryption_returns_instance(self, monkeypatch):
"""get_credential_encryption should return a CredentialEncryption instance."""
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
enc = get_credential_encryption()
assert isinstance(enc, CredentialEncryption)
@pytest.mark.usefixtures("_clear_singleton")
def test_singleton_returns_same_instance(self, monkeypatch):
"""Calling get_credential_encryption twice should return the same object."""
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
enc_a = get_credential_encryption()
enc_b = get_credential_encryption()
assert enc_a is enc_b
@pytest.mark.usefixtures("_clear_singleton")
def test_singleton_raises_without_key(self, monkeypatch):
"""get_credential_encryption should raise ValueError if ENCRYPTION_KEY is missing."""
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
get_credential_encryption()
@pytest.mark.usefixtures("_clear_singleton")
def test_initialize_with_explicit_key(self, monkeypatch):
"""initialize_credential_encryption should accept an explicit key."""
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
key = base64.b64encode(os.urandom(32)).decode()
enc = initialize_credential_encryption(key)
assert isinstance(enc, CredentialEncryption)
# Should be the same as get_credential_encryption now
assert get_credential_encryption() is enc

354
tests/test_site_api.py Normal file
View File

@@ -0,0 +1,354 @@
"""Tests for Site Management API (core/site_api.py)."""
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from core.site_api import (
MAX_SITES_PER_USER,
create_user_site,
delete_user_site,
get_credential_fields,
get_user_sites,
validate_credentials,
validate_site_connection,
)
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def mock_db():
"""Create and patch a mock database instance."""
db = AsyncMock()
db.count_sites_by_user = AsyncMock(return_value=0)
db.get_site_by_alias = AsyncMock(return_value=None)
db.get_site = AsyncMock(
return_value={
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "active",
"status_msg": "Connection verified",
"created_at": "2026-02-19T12:00:00Z",
}
)
db.get_sites_by_user = AsyncMock(
return_value=[
{
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "active",
"created_at": "2026-02-19T12:00:00Z",
},
]
)
db.delete_site = AsyncMock(return_value=True)
db.execute = AsyncMock()
db.update_site_status = AsyncMock()
with patch("core.database.get_database", return_value=db):
yield db
@pytest.fixture
def mock_encryption():
"""Create and patch a mock encryption instance."""
enc = MagicMock()
enc.encrypt_credentials = MagicMock(return_value=b"encrypted-blob")
enc.decrypt_credentials = MagicMock(
return_value={
"username": "admin",
"app_password": "xxxx xxxx xxxx xxxx",
}
)
with patch("core.encryption.get_credential_encryption", return_value=enc):
yield enc
# ── Credential Field Definitions ─────────────────────────────
class TestCredentialFields:
"""Test credential field definitions and retrieval."""
@pytest.mark.unit
def test_get_credential_fields_all_plugins(self):
"""All 9 plugin types should return non-empty credential field lists."""
expected_plugins = [
"wordpress",
"woocommerce",
"wordpress_advanced",
"gitea",
"n8n",
"supabase",
"openpanel",
"appwrite",
"directus",
]
for plugin_type in expected_plugins:
fields = get_credential_fields(plugin_type)
assert len(fields) > 0, f"{plugin_type} returned empty fields"
for field in fields:
assert "name" in field
assert "label" in field
assert "type" in field
assert "required" in field
@pytest.mark.unit
def test_get_credential_fields_invalid(self):
"""Unknown plugin type should raise ValueError."""
with pytest.raises(ValueError, match="Unknown plugin type"):
get_credential_fields("nonexistent_plugin")
# ── Credential Validation ────────────────────────────────────
class TestCredentialValidation:
"""Test credential validation logic."""
@pytest.mark.unit
def test_validate_credentials_valid(self):
"""Valid WordPress credentials should pass validation."""
valid, errors = validate_credentials(
"wordpress",
{
"username": "admin",
"app_password": "xxxx xxxx xxxx xxxx",
},
)
assert valid is True
assert errors == []
@pytest.mark.unit
def test_validate_credentials_missing_required(self):
"""Missing required fields should return errors."""
valid, errors = validate_credentials(
"wordpress",
{
"username": "admin",
# app_password is missing
},
)
assert valid is False
assert len(errors) == 1
assert "Application Password" in errors[0]
@pytest.mark.unit
def test_validate_credentials_optional_field(self):
"""Optional fields (like container) should not cause errors when missing."""
valid, errors = validate_credentials(
"wordpress_advanced",
{
"username": "admin",
"app_password": "xxxx xxxx xxxx xxxx",
# container is optional — should not cause error
},
)
assert valid is True
assert errors == []
@pytest.mark.unit
def test_validate_credentials_empty_string_required(self):
"""Empty string for required field should fail validation."""
valid, errors = validate_credentials(
"wordpress",
{
"username": "admin",
"app_password": " ", # whitespace only
},
)
assert valid is False
assert len(errors) == 1
# ── Site Creation ────────────────────────────────────────────
class TestSiteCreation:
"""Test site creation flow."""
@pytest.mark.unit
async def test_create_site_success(self, mock_db, mock_encryption):
"""Creating a site with skip_validation should succeed."""
result = await create_user_site(
user_id="user-uuid-001",
plugin_type="wordpress",
alias="myblog",
url="https://myblog.example.com",
credentials={"username": "admin", "app_password": "xxxx xxxx xxxx xxxx"},
skip_validation=True,
)
assert result["alias"] == "myblog"
assert result["plugin_type"] == "wordpress"
# Credentials blob should be stripped from the response
assert "credentials" not in result
# DB execute should have been called to insert
mock_db.execute.assert_called_once()
# Encryption should have been used
mock_encryption.encrypt_credentials.assert_called_once()
@pytest.mark.unit
async def test_create_site_alias_too_short(self, mock_db, mock_encryption):
"""Alias shorter than 2 characters should raise ValueError."""
with pytest.raises(ValueError, match="2-50 characters"):
await create_user_site(
user_id="user-uuid-001",
plugin_type="wordpress",
alias="a",
url="https://example.com",
credentials={"username": "admin", "app_password": "xxxx"},
skip_validation=True,
)
@pytest.mark.unit
async def test_create_site_alias_invalid_chars(self, mock_db, mock_encryption):
"""Alias with invalid characters should raise ValueError."""
with pytest.raises(ValueError, match="letters, numbers, hyphens, and underscores"):
await create_user_site(
user_id="user-uuid-001",
plugin_type="wordpress",
alias="my blog!",
url="https://example.com",
credentials={"username": "admin", "app_password": "xxxx"},
skip_validation=True,
)
@pytest.mark.unit
async def test_create_site_max_limit(self, mock_db, mock_encryption):
"""Exceeding MAX_SITES_PER_USER should raise ValueError."""
mock_db.count_sites_by_user.return_value = MAX_SITES_PER_USER
with pytest.raises(ValueError, match="Site limit reached"):
await create_user_site(
user_id="user-uuid-001",
plugin_type="wordpress",
alias="toomany",
url="https://example.com",
credentials={"username": "admin", "app_password": "xxxx"},
skip_validation=True,
)
@pytest.mark.unit
async def test_create_site_duplicate_alias(self, mock_db, mock_encryption):
"""Duplicate alias for the same user should raise ValueError."""
mock_db.get_site_by_alias.return_value = {"id": "existing-site", "alias": "myblog"}
with pytest.raises(ValueError, match="already in use"):
await create_user_site(
user_id="user-uuid-001",
plugin_type="wordpress",
alias="myblog",
url="https://example.com",
credentials={"username": "admin", "app_password": "xxxx"},
skip_validation=True,
)
# ── Site Retrieval and Deletion ──────────────────────────────
class TestSiteRetrievalDeletion:
"""Test site list and delete operations."""
@pytest.mark.unit
async def test_get_user_sites(self, mock_db):
"""get_user_sites should return sites without credential blobs."""
sites = await get_user_sites("user-uuid-001")
assert len(sites) == 1
assert sites[0]["alias"] == "myblog"
# Credentials should be stripped
assert "credentials" not in sites[0]
@pytest.mark.unit
async def test_delete_user_site(self, mock_db):
"""delete_user_site should return True when site exists."""
deleted = await delete_user_site("site-uuid-001", "user-uuid-001")
assert deleted is True
mock_db.delete_site.assert_called_once_with("site-uuid-001", "user-uuid-001")
@pytest.mark.unit
async def test_delete_user_site_not_found(self, mock_db):
"""delete_user_site should return False when site doesn't exist."""
mock_db.delete_site.return_value = False
deleted = await delete_user_site("nonexistent", "user-uuid-001")
assert deleted is False
# ── Connection Validation ────────────────────────────────────
class TestConnectionValidation:
"""Test live connection validation with mocked HTTP."""
@pytest.mark.unit
async def test_validate_connection_success(self):
"""Successful HTTP response should return (True, 'OK')."""
mock_response = AsyncMock()
mock_response.status = 200
mock_response.text = AsyncMock(return_value="OK")
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=False)
mock_session = AsyncMock()
mock_session.get = MagicMock(return_value=mock_response)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
ok, msg = await validate_site_connection(
"wordpress",
"https://myblog.example.com",
{"username": "admin", "app_password": "xxxx"},
)
assert ok is True
assert msg == "OK"
@pytest.mark.unit
async def test_validate_connection_timeout(self):
"""Timeout should return (False, message about timeout)."""
mock_session = AsyncMock()
mock_session.get = MagicMock(side_effect=TimeoutError("timed out"))
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
ok, msg = await validate_site_connection(
"wordpress",
"https://slow-site.example.com",
{"username": "admin", "app_password": "xxxx"},
)
assert ok is False
assert "timed out" in msg.lower()
@pytest.mark.unit
async def test_validate_connection_auth_failure(self):
"""HTTP 401 should return (False, message about authentication)."""
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="Unauthorized")
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=False)
mock_session = AsyncMock()
mock_session.get = MagicMock(return_value=mock_response)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
ok, msg = await validate_site_connection(
"wordpress",
"https://myblog.example.com",
{"username": "admin", "app_password": "wrong"},
)
assert ok is False
assert "authentication" in msg.lower()

View File

@@ -0,0 +1,42 @@
"""Tests for tenant isolation in Dashboard routes."""
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"
))
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

444
tests/test_user_auth.py Normal file
View File

@@ -0,0 +1,444 @@
"""Tests for the user authentication system (OAuth Social Login)."""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from core.user_auth import (
OAuthProvider,
UserAuth,
get_user_auth,
initialize_user_auth,
)
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _reset_singleton():
"""Reset the global UserAuth singleton between tests."""
import core.user_auth as mod
mod._user_auth = None
yield
mod._user_auth = None
@pytest.fixture
def user_auth():
"""Create a UserAuth instance with test credentials."""
return UserAuth(
github_client_id="gh_test_id",
github_client_secret="gh_test_secret",
google_client_id="google_test_id",
google_client_secret="google_test_secret",
public_url="https://mcp.example.com",
)
# ── OAuth URL Generation ─────────────────────────────────────
class TestOAuthURLGeneration:
"""Test OAuth authorization URL generation."""
def test_github_auth_url(self, user_auth):
"""GitHub auth URL should point to correct endpoint."""
url, state = user_auth.get_authorization_url("github")
assert "github.com/login/oauth/authorize" in url
assert "client_id=gh_test_id" in url
assert "state=" in url
assert len(state) == 64 # 32 bytes hex
def test_google_auth_url(self, user_auth):
"""Google auth URL should point to correct endpoint."""
url, state = user_auth.get_authorization_url("google")
assert "accounts.google.com/o/oauth2/v2/auth" in url
assert "client_id=google_test_id" in url
assert "state=" in url
def test_github_callback_url(self, user_auth):
"""GitHub auth URL should include correct callback URL."""
from urllib.parse import unquote
url, _ = user_auth.get_authorization_url("github")
decoded = unquote(url)
assert "redirect_uri=" in url
assert "mcp.example.com" in decoded
assert "/auth/callback/github" in decoded
def test_google_callback_url(self, user_auth):
"""Google auth URL should include correct callback URL."""
from urllib.parse import unquote
url, _ = user_auth.get_authorization_url("google")
decoded = unquote(url)
assert "redirect_uri=" in url
assert "/auth/callback/google" in decoded
def test_google_scopes(self, user_auth):
"""Google auth URL should request openid, email, and profile scopes."""
url, _ = user_auth.get_authorization_url("google")
assert "openid" in url
assert "email" in url
assert "profile" in url
def test_invalid_provider(self, user_auth):
"""Unsupported provider should raise ValueError."""
with pytest.raises(ValueError, match="Unsupported provider"):
user_auth.get_authorization_url("twitter")
# ── State Validation ──────────────────────────────────────────
class TestStateValidation:
"""Test OAuth state parameter validation (CSRF protection)."""
def test_valid_state(self, user_auth):
"""A freshly generated state should validate successfully."""
_, state = user_auth.get_authorization_url("github")
assert user_auth.validate_state(state) is True
def test_invalid_state(self, user_auth):
"""An unknown state string should not validate."""
assert user_auth.validate_state("invalid_state_value") is False
def test_state_consumed_after_use(self, user_auth):
"""State token should be one-time use (consumed after validation)."""
_, state = user_auth.get_authorization_url("github")
assert user_auth.validate_state(state) is True
assert user_auth.validate_state(state) is False # Second use fails
def test_state_expiry(self, user_auth):
"""State token should be rejected after expiry (10 minutes)."""
_, state = user_auth.get_authorization_url("github")
# Manually expire the state (11+ minutes ago)
user_auth._pending_states[state] = time.time() - 700
assert user_auth.validate_state(state) is False
# ── Token Exchange (Mocked) ──────────────────────────────────
class TestTokenExchange:
"""Test OAuth token exchange with mocked HTTP responses."""
@pytest.mark.asyncio
async def test_github_token_exchange(self, user_auth):
"""GitHub code exchange should return correct user info."""
mock_token_response = MagicMock()
mock_token_response.status_code = 200
mock_token_response.json.return_value = {
"access_token": "gho_test_token",
}
mock_user_response = MagicMock()
mock_user_response.status_code = 200
mock_user_response.json.return_value = {
"id": 12345,
"login": "testuser",
"name": "Test User",
"email": "test@example.com",
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
}
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=mock_token_response)
mock_client.get = AsyncMock(return_value=mock_user_response)
mock_client_cls.return_value = mock_client
user_info = await user_auth.exchange_code("github", "test_code")
assert user_info["provider"] == "github"
assert user_info["provider_id"] == "12345"
assert user_info["email"] == "test@example.com"
assert user_info["name"] == "Test User"
assert user_info["avatar_url"] == ("https://avatars.githubusercontent.com/u/12345")
@pytest.mark.asyncio
async def test_github_email_fallback(self, user_auth):
"""When primary email is None, fetch from /user/emails endpoint."""
mock_token_response = MagicMock()
mock_token_response.status_code = 200
mock_token_response.json.return_value = {
"access_token": "gho_test_token",
}
mock_user_response = MagicMock()
mock_user_response.status_code = 200
mock_user_response.json.return_value = {
"id": 12345,
"login": "testuser",
"name": "Test User",
"email": None,
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
}
mock_emails_response = MagicMock()
mock_emails_response.status_code = 200
mock_emails_response.json.return_value = [
{
"email": "secondary@example.com",
"primary": False,
"verified": True,
},
{
"email": "primary@example.com",
"primary": True,
"verified": True,
},
]
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=mock_token_response)
mock_client.get = AsyncMock(side_effect=[mock_user_response, mock_emails_response])
mock_client_cls.return_value = mock_client
user_info = await user_auth.exchange_code("github", "test_code")
assert user_info["email"] == "primary@example.com"
@pytest.mark.asyncio
async def test_google_token_exchange(self, user_auth):
"""Google code exchange should return correct user info."""
mock_token_response = MagicMock()
mock_token_response.status_code = 200
mock_token_response.json.return_value = {
"access_token": "ya29.test_token",
}
mock_user_response = MagicMock()
mock_user_response.status_code = 200
mock_user_response.json.return_value = {
"sub": "google_67890",
"email": "test@gmail.com",
"name": "Test Google User",
"picture": "https://lh3.googleusercontent.com/photo.jpg",
"email_verified": True,
}
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=mock_token_response)
mock_client.get = AsyncMock(return_value=mock_user_response)
mock_client_cls.return_value = mock_client
user_info = await user_auth.exchange_code("google", "test_code")
assert user_info["provider"] == "google"
assert user_info["provider_id"] == "google_67890"
assert user_info["email"] == "test@gmail.com"
assert user_info["name"] == "Test Google User"
assert user_info["avatar_url"] == ("https://lh3.googleusercontent.com/photo.jpg")
@pytest.mark.asyncio
async def test_token_exchange_failure(self, user_auth):
"""Failed token exchange should raise ValueError."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Bad Request"
mock_response.json.return_value = {
"error": "bad_verification_code",
}
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=mock_response)
mock_client_cls.return_value = mock_client
with pytest.raises(ValueError, match="Failed to exchange"):
await user_auth.exchange_code("github", "bad_code")
@pytest.mark.asyncio
async def test_exchange_unsupported_provider(self, user_auth):
"""Unsupported provider in exchange_code should raise ValueError."""
with pytest.raises(ValueError, match="Unsupported provider"):
await user_auth.exchange_code("twitter", "code")
# ── Registration Rate Limiting ────────────────────────────────
class TestRegistrationRateLimit:
"""Test registration rate limiting (3 per IP per hour)."""
def test_under_limit(self, user_auth):
"""IP under the limit should be allowed."""
assert user_auth.check_registration_rate("1.2.3.4") is True
user_auth.record_registration("1.2.3.4")
assert user_auth.check_registration_rate("1.2.3.4") is True
def test_at_limit(self, user_auth):
"""IP at the limit (3 registrations) should be blocked."""
for _ in range(3):
user_auth.record_registration("1.2.3.4")
assert user_auth.check_registration_rate("1.2.3.4") is False
def test_different_ips(self, user_auth):
"""Rate limiting should be per-IP."""
for _ in range(3):
user_auth.record_registration("1.2.3.4")
assert user_auth.check_registration_rate("5.6.7.8") is True
def test_expired_records_cleaned(self, user_auth):
"""Expired records should be cleaned up, allowing new registrations."""
user_auth.record_registration("1.2.3.4")
# Manually expire the record (over 1 hour ago)
user_auth._registration_records["1.2.3.4"] = [time.time() - 3700]
assert user_auth.check_registration_rate("1.2.3.4") is True
# ── Session Creation ──────────────────────────────────────────
class TestUserSession:
"""Test user session JWT creation and validation."""
def test_create_user_session(self, user_auth):
"""create_user_session should return a non-empty JWT string."""
token = user_auth.create_user_session(
user_id="uuid-123",
email="test@example.com",
name="Test",
role="user",
)
assert isinstance(token, str)
assert len(token) > 0
def test_validate_user_session(self, user_auth):
"""validate_user_session should decode a valid token correctly."""
token = user_auth.create_user_session(
user_id="uuid-123",
email="test@example.com",
name="Test User",
role="user",
)
session = user_auth.validate_user_session(token)
assert session is not None
assert session["user_id"] == "uuid-123"
assert session["email"] == "test@example.com"
assert session["name"] == "Test User"
assert session["role"] == "user"
assert session["type"] == "oauth_user"
def test_invalid_token(self, user_auth):
"""validate_user_session should return None for garbage tokens."""
assert user_auth.validate_user_session("garbage.token.here") is None
def test_expired_token(self, user_auth):
"""validate_user_session should return None for expired tokens."""
import jwt as pyjwt
payload = {
"uid": "uuid-123",
"email": "test@example.com",
"name": "Test",
"role": "user",
"type": "oauth_user",
"iat": time.time() - 7200,
"exp": time.time() - 3600, # Expired 1 hour ago
}
token = pyjwt.encode(payload, user_auth._session_secret, algorithm="HS256")
assert user_auth.validate_user_session(token) is None
def test_session_with_none_name(self, user_auth):
"""Session with None name should store empty string."""
token = user_auth.create_user_session(
user_id="uuid-456",
email="noname@example.com",
name=None,
role="user",
)
session = user_auth.validate_user_session(token)
assert session is not None
assert session["name"] == ""
# ── Singleton Pattern ─────────────────────────────────────────
class TestSingleton:
"""Test module-level singleton pattern."""
def test_initialize_and_get(self):
"""initialize_user_auth should set the singleton retrievable by get."""
auth = initialize_user_auth(
github_client_id="gh_id",
github_client_secret="gh_secret",
google_client_id="g_id",
google_client_secret="g_secret",
public_url="https://example.com",
)
assert get_user_auth() is auth
def test_get_without_init_raises(self):
"""get_user_auth before init should raise RuntimeError."""
with pytest.raises(RuntimeError, match="not initialized"):
get_user_auth()
# ── Provider Config ───────────────────────────────────────────
class TestProviderConfig:
"""Test provider availability detection."""
def test_available_providers_both(self, user_auth):
"""Both providers configured should both appear."""
providers = user_auth.available_providers()
assert "github" in providers
assert "google" in providers
def test_no_github_if_missing_creds(self):
"""Missing GitHub creds should exclude github from providers."""
auth = UserAuth(
google_client_id="g_id",
google_client_secret="g_secret",
public_url="https://example.com",
)
providers = auth.available_providers()
assert "github" not in providers
assert "google" in providers
def test_no_google_if_missing_creds(self):
"""Missing Google creds should exclude google from providers."""
auth = UserAuth(
github_client_id="gh_id",
github_client_secret="gh_secret",
public_url="https://example.com",
)
providers = auth.available_providers()
assert "github" in providers
assert "google" not in providers
def test_no_providers_if_none_configured(self):
"""No credentials at all should return empty providers list."""
auth = UserAuth(public_url="https://example.com")
assert auth.available_providers() == []
# ── OAuthProvider Constants ───────────────────────────────────
class TestOAuthProviderConstants:
"""Test OAuthProvider class constants."""
def test_github_constant(self):
"""OAuthProvider.GITHUB should be 'github'."""
assert OAuthProvider.GITHUB == "github"
def test_google_constant(self):
"""OAuthProvider.GOOGLE should be 'google'."""
assert OAuthProvider.GOOGLE == "google"

View File

@@ -0,0 +1,366 @@
"""Tests for per-user MCP endpoint handler (core/user_endpoints.py)."""
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from starlette.requests import Request
from core.user_endpoints import (
USER_RATE_LIMIT_PER_MIN,
_rate_limits,
user_mcp_handler,
)
# ── Helpers ───────────────────────────────────────────────────
def _make_request(
user_id: str = "user-uuid-001",
alias: str = "myblog",
method_name: str = "initialize",
params: dict | None = None,
api_key: str = "mhu_validkey1234567890abcdefghijklmnopqrst",
req_id: int = 1,
) -> Request:
"""Build a mock Starlette Request for the user MCP endpoint."""
body = {
"jsonrpc": "2.0",
"id": req_id,
"method": method_name,
"params": params or {},
}
body_bytes = json.dumps(body).encode()
scope = {
"type": "http",
"method": "POST",
"path": f"/u/{user_id}/{alias}/mcp",
"path_params": {"user_id": user_id, "alias": alias},
"headers": [],
"query_string": b"",
}
if api_key:
scope["headers"].append((b"authorization", f"Bearer {api_key}".encode()))
async def receive():
return {"type": "http.request", "body": body_bytes}
return Request(scope, receive)
def _make_request_no_auth(
user_id: str = "user-uuid-001",
alias: str = "myblog",
) -> Request:
"""Build a mock Request without an Authorization header."""
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
scope = {
"type": "http",
"method": "POST",
"path": f"/u/{user_id}/{alias}/mcp",
"path_params": {"user_id": user_id, "alias": alias},
"headers": [],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": body}
return Request(scope, receive)
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _clear_rate_limits():
"""Clear the global rate limit tracking between tests."""
_rate_limits.clear()
yield
_rate_limits.clear()
@pytest.fixture(autouse=True)
def _clear_tool_cache():
"""Clear the tool schema cache between tests."""
import core.user_endpoints as mod
mod._tool_schema_cache.clear()
yield
mod._tool_schema_cache.clear()
@pytest.fixture
def mock_key_mgr():
"""Patch get_user_key_manager to return a mock."""
mgr = AsyncMock()
mgr.validate_key = AsyncMock(
return_value={
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "read write",
}
)
with patch("core.user_keys.get_user_key_manager", return_value=mgr):
yield mgr
@pytest.fixture
def mock_db():
"""Patch get_database to return a mock."""
db = AsyncMock()
db.get_site_by_alias = AsyncMock(
return_value={
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "active",
"status_msg": "OK",
}
)
with patch("core.database.get_database", return_value=db):
yield db
@pytest.fixture
def mock_encryption():
"""Patch get_credential_encryption to return a mock."""
enc = MagicMock()
enc.decrypt_credentials = MagicMock(
return_value={
"username": "admin",
"app_password": "xxxx xxxx xxxx xxxx",
}
)
with patch("core.encryption.get_credential_encryption", return_value=enc):
yield enc
@pytest.fixture
def mock_tool_registry():
"""Patch get_tool_registry to return a registry with a sample tool."""
tool_def = MagicMock()
tool_def.name = "wordpress_list_posts"
tool_def.description = "List WordPress posts"
tool_def.input_schema = {
"type": "object",
"properties": {
"site": {"type": "string", "description": "Site identifier"},
"status": {"type": "string", "description": "Post status"},
},
"required": ["site"],
}
registry = MagicMock()
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
with patch("core.tool_registry.get_tool_registry", return_value=registry):
yield registry
@pytest.fixture
def mock_plugin_registry():
"""Patch plugins.plugin_registry to return a mock."""
mock_reg = MagicMock()
mock_reg.is_registered = MagicMock(return_value=True)
mock_instance = MagicMock()
mock_instance.list_posts = AsyncMock(
return_value=[
{"id": 1, "title": "Hello World"},
]
)
mock_reg.create_instance = MagicMock(return_value=mock_instance)
with patch("plugins.plugin_registry", mock_reg, create=True):
yield mock_reg
# ── Authentication Tests ─────────────────────────────────────
class TestAuthentication:
"""Test authentication checks in user_mcp_handler."""
@pytest.mark.unit
async def test_missing_auth_header(self, mock_key_mgr, mock_db):
"""Request without Authorization header should return 401."""
request = _make_request_no_auth()
response = await user_mcp_handler(request)
assert response.status_code == 401
body = json.loads(response.body)
assert "error" in body
@pytest.mark.unit
async def test_invalid_api_key(self, mock_key_mgr, mock_db):
"""Invalid API key should return 401."""
mock_key_mgr.validate_key.return_value = None
request = _make_request(api_key="mhu_invalidkeyvalue")
response = await user_mcp_handler(request)
assert response.status_code == 401
body = json.loads(response.body)
assert "Invalid API key" in body["error"]["message"]
@pytest.mark.unit
async def test_user_id_mismatch(self, mock_key_mgr, mock_db):
"""API key user_id not matching URL user_id should return 403."""
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "different-user-id",
"scopes": "read write",
}
request = _make_request(user_id="user-uuid-001")
response = await user_mcp_handler(request)
assert response.status_code == 403
body = json.loads(response.body)
assert "does not match" in body["error"]["message"]
# ── Site Lookup Tests ────────────────────────────────────────
class TestSiteLookup:
"""Test site lookup behavior."""
@pytest.mark.unit
async def test_site_not_found(self, mock_key_mgr, mock_db):
"""Non-existent alias should return 404."""
mock_db.get_site_by_alias.return_value = None
request = _make_request(alias="nonexistent")
response = await user_mcp_handler(request)
assert response.status_code == 404
body = json.loads(response.body)
assert "not found" in body["error"]["message"]
@pytest.mark.unit
async def test_disabled_site(self, mock_key_mgr, mock_db):
"""Disabled site should return 403."""
mock_db.get_site_by_alias.return_value = {
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "disabled",
"status_msg": "Disabled by admin",
}
request = _make_request()
response = await user_mcp_handler(request)
assert response.status_code == 403
body = json.loads(response.body)
assert "disabled" in body["error"]["message"].lower()
# ── MCP Protocol Methods ─────────────────────────────────────
class TestMCPMethods:
"""Test MCP JSON-RPC method handling."""
@pytest.mark.unit
async def test_initialize_method(self, mock_key_mgr, mock_db):
"""initialize should return protocolVersion and capabilities."""
request = _make_request(method_name="initialize")
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
result = body["result"]
assert "protocolVersion" in result
assert "capabilities" in result
assert "tools" in result["capabilities"]
assert "serverInfo" in result
assert "myblog" in result["serverInfo"]["name"]
@pytest.mark.unit
async def test_notifications_initialized(self, mock_key_mgr, mock_db):
"""notifications/initialized should return 204 with no body."""
request = _make_request(method_name="notifications/initialized")
response = await user_mcp_handler(request)
assert response.status_code == 204
@pytest.mark.unit
async def test_tools_list(self, mock_key_mgr, mock_db, mock_tool_registry):
"""tools/list should return tools with site param removed."""
request = _make_request(method_name="tools/list")
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
tools = body["result"]["tools"]
assert len(tools) == 1
assert tools[0]["name"] == "wordpress_list_posts"
# 'site' should be removed from properties and required
schema = tools[0]["inputSchema"]
assert "site" not in schema.get("properties", {})
if "required" in schema:
assert "site" not in schema["required"]
@pytest.mark.unit
async def test_tools_call_invalid_tool(self, mock_key_mgr, mock_db):
"""Calling a tool with wrong plugin prefix should return error."""
request = _make_request(
method_name="tools/call",
params={"name": "gitea_list_repos", "arguments": {}},
)
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
assert "error" in body
assert "not available" in body["error"]["message"]
@pytest.mark.unit
async def test_tools_call_success(
self,
mock_key_mgr,
mock_db,
mock_encryption,
mock_tool_registry,
mock_plugin_registry,
):
"""Calling a valid tool should return content result."""
request = _make_request(
method_name="tools/call",
params={"name": "wordpress_list_posts", "arguments": {"status": "publish"}},
)
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
assert "result" in body
assert "content" in body["result"]
@pytest.mark.unit
async def test_unsupported_method(self, mock_key_mgr, mock_db):
"""Unknown MCP method should return -32601 error."""
request = _make_request(method_name="resources/list")
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
assert "error" in body
assert body["error"]["code"] == -32601
assert "not supported" in body["error"]["message"]
# ── Rate Limiting ────────────────────────────────────────────
class TestRateLimiting:
"""Test per-user rate limiting."""
@pytest.mark.unit
async def test_rate_limit_exceeded(self, mock_key_mgr, mock_db):
"""Exceeding per-minute rate limit should return 429."""
# Fill the rate limit bucket
now = time.time()
_rate_limits["user-uuid-001"] = [now - i for i in range(USER_RATE_LIMIT_PER_MIN)]
request = _make_request(method_name="initialize")
response = await user_mcp_handler(request)
assert response.status_code == 429
body = json.loads(response.body)
assert "Rate limit" in body["error"]["message"]

294
tests/test_user_keys.py Normal file
View File

@@ -0,0 +1,294 @@
"""Tests for User API Key management (core/user_keys.py)."""
import time
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from core.user_keys import (
KEY_PREFIX_TAG,
UserKeyManager,
get_user_key_manager,
initialize_user_key_manager,
)
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _reset_singleton():
"""Reset the global UserKeyManager singleton between tests."""
import core.user_keys as mod
original = mod._manager
mod._manager = None
yield
mod._manager = original
@pytest.fixture
def mock_db():
"""Create and patch a mock database instance."""
db = AsyncMock()
# Default return values for DB methods
db.create_api_key = AsyncMock(
return_value={
"id": "key-uuid-001",
"user_id": "user-uuid-001",
"key_hash": "$2b$12$fakehashvalue",
"key_prefix": "abcdefgh",
"name": "Claude Desktop",
"scopes": "read write",
"created_at": "2026-02-19T12:00:00Z",
"expires_at": None,
}
)
db.get_api_key_by_prefix = AsyncMock(return_value=None)
db.get_api_keys_by_user = AsyncMock(
return_value=[
{
"id": "key-uuid-001",
"name": "Claude Desktop",
"scopes": "read write",
"created_at": "2026-02-19T12:00:00Z",
"expires_at": None,
"last_used_at": None,
},
]
)
db.delete_api_key = AsyncMock(return_value=True)
db.update_api_key_usage = AsyncMock()
with patch("core.database.get_database", return_value=db):
yield db
@pytest.fixture
def key_mgr():
"""Create a fresh UserKeyManager instance."""
return UserKeyManager()
# ── Key Creation ─────────────────────────────────────────────
class TestKeyCreation:
"""Test API key creation."""
@pytest.mark.unit
async def test_create_key_format(self, key_mgr, mock_db):
"""Created key should start with 'mhu_' and have substantial length."""
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
raw_key = result["key"]
assert raw_key.startswith(KEY_PREFIX_TAG)
# mhu_ (4) + urlsafe_b64 of 32 bytes (43 chars) = 47 chars total
assert len(raw_key) == 47
@pytest.mark.unit
async def test_create_key_stores_bcrypt_hash(self, key_mgr, mock_db):
"""The key_hash passed to DB should be a bcrypt hash ($2b$ prefix)."""
await key_mgr.create_key("user-uuid-001", "Test Key")
call_kwargs = mock_db.create_api_key.call_args
key_hash = call_kwargs.kwargs.get("key_hash") or call_kwargs[1].get("key_hash")
assert key_hash.startswith("$2b$")
@pytest.mark.unit
async def test_create_key_returns_metadata(self, key_mgr, mock_db):
"""Create key should return key_id, name, scopes, and timestamps."""
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
assert "key_id" in result
assert result["name"] == "Claude Desktop"
assert result["scopes"] == "read write"
assert "created_at" in result
# ── Key Validation ───────────────────────────────────────────
class TestKeyValidation:
"""Test API key validation logic."""
@pytest.mark.unit
async def test_validate_key_success(self, key_mgr, mock_db):
"""Creating then validating a key should return valid info."""
import bcrypt
# Create a key and capture the raw key
result = await key_mgr.create_key("user-uuid-001", "Test Key")
raw_key = result["key"]
# Set up DB to return a matching row with correct bcrypt hash
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
mock_db.get_api_key_by_prefix.return_value = {
"id": "key-uuid-001",
"user_id": "user-uuid-001",
"key_hash": key_hash,
"scopes": "read write",
"expires_at": None,
}
info = await key_mgr.validate_key(raw_key)
assert info is not None
assert info["user_id"] == "user-uuid-001"
assert info["key_id"] == "key-uuid-001"
assert info["scopes"] == "read write"
@pytest.mark.unit
async def test_validate_key_wrong_key(self, key_mgr, mock_db):
"""A key that doesn't match the bcrypt hash should return None."""
import bcrypt
correct_key = "mhu_correctkeyvalue1234567890abcdefghijklmno"
key_hash = bcrypt.hashpw(correct_key.encode(), bcrypt.gensalt()).decode()
mock_db.get_api_key_by_prefix.return_value = {
"id": "key-uuid-001",
"user_id": "user-uuid-001",
"key_hash": key_hash,
"scopes": "read write",
"expires_at": None,
}
wrong_key = "mhu_wrongkeyvalue1234567890abcdefghijklmnopq"
info = await key_mgr.validate_key(wrong_key)
assert info is None
@pytest.mark.unit
async def test_validate_key_no_prefix(self, key_mgr, mock_db):
"""A key without the 'mhu_' prefix should immediately return None."""
info = await key_mgr.validate_key("bad_prefix_key")
assert info is None
# DB should not even be queried
mock_db.get_api_key_by_prefix.assert_not_called()
@pytest.mark.unit
async def test_validate_key_expired(self, key_mgr, mock_db):
"""A key with expires_at in the past should return None."""
import bcrypt
raw_key = "mhu_expiredkeyvalue1234567890abcdefghijklmno"
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
expired_at = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
mock_db.get_api_key_by_prefix.return_value = {
"id": "key-uuid-002",
"user_id": "user-uuid-001",
"key_hash": key_hash,
"scopes": "read",
"expires_at": expired_at,
}
info = await key_mgr.validate_key(raw_key)
assert info is None
@pytest.mark.unit
async def test_validate_key_cache(self, key_mgr, mock_db):
"""Second validation of the same key should use cache (no DB prefix lookup)."""
import bcrypt
result = await key_mgr.create_key("user-uuid-001", "Cache Test")
raw_key = result["key"]
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
mock_db.get_api_key_by_prefix.return_value = {
"id": "key-uuid-001",
"user_id": "user-uuid-001",
"key_hash": key_hash,
"scopes": "read write",
"expires_at": None,
}
# First validation — hits DB
info1 = await key_mgr.validate_key(raw_key)
assert info1 is not None
assert mock_db.get_api_key_by_prefix.call_count == 1
# Second validation — should use cache
info2 = await key_mgr.validate_key(raw_key)
assert info2 is not None
# get_api_key_by_prefix should NOT be called again
assert mock_db.get_api_key_by_prefix.call_count == 1
@pytest.mark.unit
async def test_validate_key_updates_usage(self, key_mgr, mock_db):
"""Successful validation should call update_api_key_usage."""
import bcrypt
result = await key_mgr.create_key("user-uuid-001", "Usage Test")
raw_key = result["key"]
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
mock_db.get_api_key_by_prefix.return_value = {
"id": "key-uuid-001",
"user_id": "user-uuid-001",
"key_hash": key_hash,
"scopes": "read write",
"expires_at": None,
}
await key_mgr.validate_key(raw_key)
mock_db.update_api_key_usage.assert_called_with("key-uuid-001")
# ── Key Listing ──────────────────────────────────────────────
class TestKeyListing:
"""Test key listing functionality."""
@pytest.mark.unit
async def test_list_keys_no_hash(self, key_mgr, mock_db):
"""Listed keys should not contain key_hash field."""
keys = await key_mgr.list_keys("user-uuid-001")
assert len(keys) == 1
assert "key_hash" not in keys[0]
assert keys[0]["name"] == "Claude Desktop"
# ── Key Deletion ─────────────────────────────────────────────
class TestKeyDeletion:
"""Test key deletion and cache invalidation."""
@pytest.mark.unit
async def test_delete_key_clears_cache(self, key_mgr, mock_db):
"""Deleting a key should remove it from the validation cache."""
# Populate cache manually
key_mgr._cache["mhu_test_cached_key"] = (
"key-uuid-001",
"user-uuid-001",
"read write",
time.time(),
)
assert "mhu_test_cached_key" in key_mgr._cache
deleted = await key_mgr.delete_key("key-uuid-001", "user-uuid-001")
assert deleted is True
assert "mhu_test_cached_key" not in key_mgr._cache
@pytest.mark.unit
async def test_delete_key_not_found(self, key_mgr, mock_db):
"""Deleting a non-existent key should return False."""
mock_db.delete_api_key.return_value = False
deleted = await key_mgr.delete_key("nonexistent-key", "user-uuid-001")
assert deleted is False
# ── Singleton Pattern ────────────────────────────────────────
class TestSingleton:
"""Test module-level singleton pattern."""
@pytest.mark.unit
def test_singleton_pattern(self):
"""initialize_user_key_manager should set the singleton retrievable by get."""
mgr = initialize_user_key_manager()
assert get_user_key_manager() is mgr
@pytest.mark.unit
def test_get_without_init_raises(self):
"""get_user_key_manager before init should raise RuntimeError."""
with pytest.raises(RuntimeError, match="not initialized"):
get_user_key_manager()