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:
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
154
tests/test_config_snippets.py
Normal file
154
tests/test_config_snippets.py
Normal 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
|
||||
@@ -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
577
tests/test_database.py
Normal 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)
|
||||
112
tests/test_dynamic_endpoints_integration.py
Normal file
112
tests/test_dynamic_endpoints_integration.py
Normal 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
315
tests/test_encryption.py
Normal 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
354
tests/test_site_api.py
Normal 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()
|
||||
42
tests/test_tenant_isolation.py
Normal file
42
tests/test_tenant_isolation.py
Normal 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
444
tests/test_user_auth.py
Normal 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"
|
||||
366
tests/test_user_endpoints.py
Normal file
366
tests/test_user_endpoints.py
Normal 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
294
tests/test_user_keys.py
Normal 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()
|
||||
Reference in New Issue
Block a user