Initial commit: MCP Hub Community Edition v3.0.0

Community edition generated from private repo via sync pipeline.
Includes 9 plugins (WordPress, WooCommerce, WP Advanced, Gitea, n8n,
Supabase, OpenPanel, Appwrite, Directus) with ~587 tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-17 08:34:44 +03:30
commit cf62e65c55
237 changed files with 87596 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Test API Key Access Control Fix
Validates that:
1. Site-level keys get proper error message for system tools (not validation failure)
2. Global keys work for all tools
3. Per-project keys work for their own unified tools
"""
def test_system_tool_detection():
"""Test that system tools are detected correctly."""
SYSTEM_TOOLS = [
"list_projects",
"get_project_info",
"check_all_projects_health",
"get_project_health",
"get_system_metrics",
"get_system_uptime",
"get_rate_limit_stats",
"export_health_metrics",
"manage_api_keys_list",
"manage_api_keys_get_info",
]
# Test with and without MCP prefix
test_cases = [
("list_projects", True),
("mcp__coolify-projects__list_projects", True),
("get_project_info", True),
("wordpress_list_posts", False),
("woocommerce_list_orders", False),
("unknown", False),
]
for tool_name, expected in test_cases:
is_system_tool = any(tool_name.endswith(st) for st in SYSTEM_TOOLS)
assert (
is_system_tool == expected
), f"Failed for {tool_name}: expected {expected}, got {is_system_tool}"
print(f"{tool_name}: is_system_tool={is_system_tool}")
def test_unified_tool_detection():
"""Test that unified tools are detected correctly."""
test_cases = [
("wordpress_list_posts", True),
("woocommerce_list_orders", True),
("mcp__coolify-projects__wordpress_list_posts", True),
("mcp__coolify-projects__woocommerce_list_orders", True),
("list_projects", False),
("get_project_info", False),
("unknown", True), # Falls to fallback
]
for tool_name, expected in test_cases:
if tool_name == "unknown":
is_unified_tool = True # Fallback
else:
is_unified_tool = (
tool_name.startswith("wordpress_")
or tool_name.startswith("woocommerce_")
or tool_name.startswith("mcp__coolify-projects__wordpress_")
or tool_name.startswith("mcp__coolify-projects__woocommerce_")
)
assert (
is_unified_tool == expected
), f"Failed for {tool_name}: expected {expected}, got {is_unified_tool}"
print(f"{tool_name}: is_unified_tool={is_unified_tool}")
def test_skip_project_check_logic():
"""Test that skip_project_check is set correctly."""
SYSTEM_TOOLS = [
"list_projects",
"get_project_info",
"check_all_projects_health",
"get_project_health",
"get_system_metrics",
"get_system_uptime",
"get_rate_limit_stats",
"export_health_metrics",
"manage_api_keys_list",
"manage_api_keys_get_info",
]
test_cases = [
# (tool_name, expected_skip)
("list_projects", True), # System tool
("wordpress_list_posts", True), # Unified tool
("woocommerce_list_orders", True), # Unified tool
("mcp__coolify-projects__list_projects", True), # System tool with prefix
("mcp__coolify-projects__wordpress_list_posts", True), # Unified tool with prefix
("unknown", True), # Fallback to unified
]
for tool_name, expected_skip in test_cases:
# Determine is_system_tool
is_system_tool = any(tool_name.endswith(st) for st in SYSTEM_TOOLS)
# Determine is_unified_tool
if tool_name == "unknown":
is_unified_tool = True
else:
is_unified_tool = (
tool_name.startswith("wordpress_")
or tool_name.startswith("woocommerce_")
or tool_name.startswith("mcp__coolify-projects__wordpress_")
or tool_name.startswith("mcp__coolify-projects__woocommerce_")
)
# Calculate skip_project_check
skip_project_check = is_unified_tool or is_system_tool
assert skip_project_check == expected_skip, (
f"Failed for {tool_name}: expected skip={expected_skip}, got {skip_project_check} "
f"(unified={is_unified_tool}, system={is_system_tool})"
)
print(
f"{tool_name}: skip_project_check={skip_project_check} (unified={is_unified_tool}, system={is_system_tool})"
)
if __name__ == "__main__":
print("\n=== Testing System Tool Detection ===")
test_system_tool_detection()
print("\n=== Testing Unified Tool Detection ===")
test_unified_tool_detection()
print("\n=== Testing skip_project_check Logic ===")
test_skip_project_check_logic()
print("\n✅ All tests passed!")
print("\nExpected behavior after fix:")
print("1. Site-level key + list_projects → Validation passes, then gets proper error:")
print(" 'System tools require global API key (project_id=\"*\")'")
print(
"2. Site-level key + wordpress_list_posts(site=other) → Validation passes, handler blocks with:"
)
print(" 'Access denied. This API key is restricted to project X'")
print("3. Global key + any tool → Works")

View File

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

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""
Test context-based API key isolation after fixing circular import.
"""
from core.context import clear_api_key_context, get_api_key_context, set_api_key_context
print("=" * 60)
print("Testing Context API")
print("=" * 60)
# Test 1: Set and get context
print("\nTest 1: Set and get context")
set_api_key_context(
key_id="key_test123", project_id="wordpress_site4", scope="admin", is_global=False
)
ctx = get_api_key_context()
assert ctx["key_id"] == "key_test123"
assert ctx["project_id"] == "wordpress_site4"
assert not ctx["is_global"]
print(" ✓ PASS: Context set and retrieved correctly")
# Test 2: Check access logic
print("\nTest 2: Access logic for per-project key")
allowed_project = ctx["project_id"]
target_project = "wordpress_site4"
if allowed_project == target_project:
print(" ✓ PASS: Access allowed (same project)")
else:
print(" ✗ FAIL: Should allow access")
# Test 3: Different project access
print("\nTest 3: Access logic for different project")
target_project = "wordpress_site1"
if allowed_project != target_project:
print(" ✓ PASS: Access denied (different project)")
else:
print(" ✗ FAIL: Should deny access")
# Test 4: Global key
print("\nTest 4: Global key access")
set_api_key_context(key_id="key_global", project_id="*", scope="admin", is_global=True)
ctx = get_api_key_context()
if ctx["is_global"]:
print(" ✓ PASS: Global key bypasses project check")
else:
print(" ✗ FAIL: Should be global")
# Test 5: Clear context
print("\nTest 5: Clear context")
clear_api_key_context()
ctx = get_api_key_context()
if ctx is None:
print(" ✓ PASS: Context cleared")
else:
print(" ✗ FAIL: Context should be None")
print("\n" + "=" * 60)
print("✅ All context tests passed!")
print("=" * 60)

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Test script for Phase 7.2 Enhanced Health Monitoring
Tests:
1. Health monitor initialization
2. Metrics recording
3. Health checks
4. System metrics
5. Alert thresholds
6. Metrics export
"""
import asyncio
import json
import os
import sys
# Fix Windows console encoding
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from core import AuditLogger, ProjectManager, initialize_health_monitor
async def test_health_monitor():
"""Test health monitoring system."""
print("=" * 60)
print("Phase 7.2 - Enhanced Health Monitoring Tests")
print("=" * 60)
# Initialize components
print("\n1. Initializing components...")
project_manager = ProjectManager()
audit_logger = AuditLogger()
health_monitor = initialize_health_monitor(
project_manager=project_manager,
audit_logger=audit_logger,
metrics_retention_hours=24,
max_metrics_per_project=1000,
)
print("✅ Health monitor initialized")
print(" Retention: 24 hours")
print(" Max metrics per project: 1000")
# Test 2: Record some sample metrics
print("\n2. Recording sample metrics...")
# Simulate successful requests
for i in range(5):
health_monitor.record_request(
project_id="wordpress_site1", response_time_ms=100.0 + (i * 10), success=True
)
print("✅ Recorded 5 successful requests for wordpress_site1")
# Simulate failed requests
for i in range(2):
health_monitor.record_request(
project_id="wordpress_site1",
response_time_ms=500.0,
success=False,
error_message="Connection timeout",
)
print("✅ Recorded 2 failed requests for wordpress_site1")
# Record for another project
health_monitor.record_request(project_id="wordpress_site2", response_time_ms=80.0, success=True)
print("✅ Recorded 1 successful request for wordpress_site2")
# Test 3: Get project metrics
print("\n3. Getting project metrics...")
metrics = health_monitor.get_project_metrics("wordpress_site1", hours=1)
print("✅ Metrics for wordpress_site1:")
print(f" Total requests: {metrics['total_requests']}")
print(f" Successful: {metrics['successful_requests']}")
print(f" Failed: {metrics['failed_requests']}")
print(f" Error rate: {metrics['error_rate_percent']}%")
print(f" Avg response time: {metrics['response_time']['average_ms']}ms")
# Test 4: Get system metrics
print("\n4. Getting system metrics...")
system_metrics = health_monitor.get_system_metrics()
print("✅ System metrics:")
print(f" Uptime: {system_metrics.uptime_seconds:.2f}s")
print(f" Total requests: {system_metrics.total_requests}")
print(f" Successful: {system_metrics.successful_requests}")
print(f" Failed: {system_metrics.failed_requests}")
print(f" Error rate: {system_metrics.error_rate_percent}%")
print(f" Avg response time: {system_metrics.average_response_time_ms}ms")
# Test 5: Get uptime
print("\n5. Getting uptime...")
uptime = health_monitor.get_uptime()
print(f"✅ Uptime: {uptime['uptime_formatted']}")
# Test 6: Test alert thresholds
print("\n6. Testing alert thresholds...")
# Record a slow request to trigger alert
health_monitor.record_request(
project_id="wordpress_site3", response_time_ms=6000.0, success=True # > 5000ms threshold
)
print("✅ Recorded slow request (6000ms)")
# Record many failures to trigger error rate alert
for i in range(10):
health_monitor.record_request(
project_id="wordpress_site3",
response_time_ms=200.0,
success=False,
error_message="API error",
)
print("✅ Recorded 10 failures to trigger error rate alert")
# Test 7: Check if project exists in manager
print("\n7. Checking project health (simulation)...")
# Since we don't have actual WordPress sites running,
# we'll just show the metrics we collected
site3_metrics = health_monitor.get_project_metrics("wordpress_site3", hours=1)
print("✅ wordpress_site3 metrics:")
print(f" Total requests: {site3_metrics['total_requests']}")
print(f" Error rate: {site3_metrics['error_rate_percent']}%")
print(f" Max response time: {site3_metrics['response_time']['max_ms']}ms")
# Check for alerts
alert_data = {
"response_time_ms": site3_metrics["response_time"]["max_ms"],
"error_rate_percent": site3_metrics["error_rate_percent"],
}
alerts = health_monitor._check_alerts("wordpress_site3", alert_data)
if alerts:
print("⚠️ Alerts triggered:")
for alert in alerts:
print(f" {alert}")
else:
print("✅ No alerts")
# Test 8: Export metrics
print("\n8. Exporting metrics...")
export_path = "logs/test_metrics_export.json"
exported_file = health_monitor.export_metrics(output_path=export_path)
print(f"✅ Metrics exported to: {exported_file}")
# Verify export file
if os.path.exists(export_path):
with open(export_path, encoding="utf-8") as f:
export_data = json.load(f)
print(" Export contains:")
print(" - System metrics: ✅")
print(" - Uptime info: ✅")
print(f" - {len(export_data['projects'])} projects")
# Test 9: Custom alert threshold
print("\n9. Testing custom alert thresholds...")
health_monitor.add_alert_threshold(
project_id="wordpress_site1",
name="Custom Response Time",
metric="response_time_ms",
threshold=150.0,
comparison="gt",
severity="warning",
)
print("✅ Added custom alert threshold for wordpress_site1")
# Summary
print("\n" + "=" * 60)
print("TEST SUMMARY")
print("=" * 60)
print("✅ Health monitor initialization - PASS")
print("✅ Metrics recording - PASS")
print("✅ Project metrics retrieval - PASS")
print("✅ System metrics retrieval - PASS")
print("✅ Uptime tracking - PASS")
print("✅ Alert threshold checking - PASS")
print("✅ Metrics export - PASS")
print("✅ Custom alert thresholds - PASS")
print("\n🎉 All tests passed!")
print("=" * 60)
return True
if __name__ == "__main__":
try:
asyncio.run(test_health_monitor())
sys.exit(0)
except Exception as e:
print(f"\n❌ Test failed with error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Test OAuth metadata endpoints locally
"""
import requests
BASE_URL = "http://localhost:8000"
print("Testing OAuth Metadata Endpoints")
print("=" * 60)
# Test 1: OAuth Authorization Server Metadata
print("\n1. Testing /.well-known/oauth-authorization-server")
try:
response = requests.get(f"{BASE_URL}/.well-known/oauth-authorization-server")
print(f" Status: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f" ✓ Issuer: {data.get('issuer')}")
print(f" ✓ Authorization Endpoint: {data.get('authorization_endpoint')}")
print(f" ✓ Token Endpoint: {data.get('token_endpoint')}")
print(f" ✓ Registration Endpoint: {data.get('registration_endpoint')}")
if data.get("registration_endpoint"):
print(" ✅ RFC 7591 Dynamic Client Registration SUPPORTED")
else:
print(" ❌ RFC 7591 NOT FOUND in metadata")
else:
print(f" ❌ Failed: {response.text}")
except Exception as e:
print(f" ❌ Error: {e}")
# Test 2: OAuth Protected Resource Metadata
print("\n2. Testing /.well-known/oauth-protected-resource")
try:
response = requests.get(f"{BASE_URL}/.well-known/oauth-protected-resource")
print(f" Status: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f" ✓ Resource: {data.get('resource')}")
print(f" ✓ Scopes: {data.get('scopes_supported')}")
print(" ✅ Protected Resource metadata available")
else:
print(f" ❌ Failed: {response.text}")
except Exception as e:
print(f" ❌ Error: {e}")
# Test 3: Registration Endpoint
print("\n3. Testing POST /oauth/register")
try:
payload = {
"client_name": "Test Client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "read write",
}
response = requests.post(f"{BASE_URL}/oauth/register", json=payload)
print(f" Status: {response.status_code}")
if response.status_code == 201:
data = response.json()
print(f" ✓ Client ID: {data.get('client_id')}")
print(f" ✓ Client Secret: {data.get('client_secret')[:20]}...")
print(" ✅ Dynamic Client Registration WORKING")
else:
print(f" ❌ Failed: {response.text}")
except Exception as e:
print(f" ❌ Error: {e}")
print("\n" + "=" * 60)
print("Test Complete!")

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Test OAuth client registration endpoint security.
Verifies that:
1. Registration endpoint requires Master API Key
2. Unauthorized requests are rejected with 401
3. Valid Master API Key allows registration
"""
import os
import requests
BASE_URL = os.getenv("BASE_URL", "http://localhost:8000")
MASTER_API_KEY = os.getenv("MASTER_API_KEY", "your_master_key_here")
print("Testing OAuth Client Registration Security")
print("=" * 60)
# Test 1: Registration without Authorization header
print("\n1. Testing registration WITHOUT Authorization header")
try:
payload = {
"client_name": "Unauthorized Test Client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"scope": "read",
}
response = requests.post(f"{BASE_URL}/oauth/register", json=payload)
print(f" Status: {response.status_code}")
if response.status_code == 401:
data = response.json()
print(f" ✅ Correctly rejected: {data.get('error')}")
print(f" Message: {data.get('error_description')}")
else:
print(f" ❌ SECURITY ISSUE: Should return 401, got {response.status_code}")
print(f" Response: {response.text}")
except Exception as e:
print(f" ❌ Error: {e}")
# Test 2: Registration with invalid API key
print("\n2. Testing registration WITH invalid API key")
try:
payload = {
"client_name": "Unauthorized Test Client 2",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"scope": "read",
}
headers = {"Authorization": "Bearer invalid_api_key_12345"}
response = requests.post(f"{BASE_URL}/oauth/register", json=payload, headers=headers)
print(f" Status: {response.status_code}")
if response.status_code == 401:
data = response.json()
print(f" ✅ Correctly rejected: {data.get('error')}")
print(f" Message: {data.get('error_description')}")
else:
print(f" ❌ SECURITY ISSUE: Should return 401, got {response.status_code}")
print(f" Response: {response.text}")
except Exception as e:
print(f" ❌ Error: {e}")
# Test 3: Registration with valid Master API Key
print("\n3. Testing registration WITH valid Master API Key")
try:
payload = {
"client_name": "Authorized Test Client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "read write",
}
headers = {"Authorization": f"Bearer {MASTER_API_KEY}"}
response = requests.post(f"{BASE_URL}/oauth/register", json=payload, headers=headers)
print(f" Status: {response.status_code}")
if response.status_code == 201:
data = response.json()
print(" ✅ Successfully registered client")
print(f" Client ID: {data.get('client_id')}")
print(f" Client Secret: {data.get('client_secret', '')[:20]}...")
print(f" Client Name: {data.get('client_name')}")
else:
print(f" ❌ Registration failed: {response.status_code}")
print(f" Response: {response.text}")
except Exception as e:
print(f" ❌ Error: {e}")
# Test 4: Attempt registration with wrong Authorization format
print("\n4. Testing registration with wrong Authorization format")
try:
payload = {
"client_name": "Test Client Wrong Format",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"scope": "read",
}
headers = {"Authorization": MASTER_API_KEY} # Missing "Bearer " prefix
response = requests.post(f"{BASE_URL}/oauth/register", json=payload, headers=headers)
print(f" Status: {response.status_code}")
if response.status_code == 401:
data = response.json()
print(f" ✅ Correctly rejected: {data.get('error')}")
print(f" Message: {data.get('error_description')}")
else:
print(f" ❌ Should return 401, got {response.status_code}")
except Exception as e:
print(f" ❌ Error: {e}")
print("\n" + "=" * 60)
print("Security Test Complete!")
print("\nExpected Results:")
print(" ✅ Test 1 & 2 & 4: Should return 401 Unauthorized")
print(" ✅ Test 3: Should return 201 Created with client credentials")

View File

@@ -0,0 +1,506 @@
"""
Test suite for Rate Limiter (Phase 7.3)
Tests the Token Bucket algorithm, rate limiting logic,
and middleware integration.
Run with: python test_rate_limiter.py
"""
import time
import unittest
from unittest.mock import patch
from core.rate_limiter import (
ClientRateLimitState,
RateLimitConfig,
RateLimiter,
TokenBucket,
get_rate_limiter,
)
class TestTokenBucket(unittest.TestCase):
"""Test Token Bucket algorithm implementation."""
def test_initialization(self):
"""Test bucket initializes with full capacity."""
bucket = TokenBucket(capacity=10, refill_rate=1.0)
self.assertEqual(bucket.capacity, 10)
self.assertEqual(bucket.refill_rate, 1.0)
self.assertEqual(bucket.tokens, 10.0)
def test_consume_tokens(self):
"""Test consuming tokens from bucket."""
bucket = TokenBucket(capacity=10, refill_rate=1.0)
# Should successfully consume 5 tokens
self.assertTrue(bucket.consume(5))
self.assertEqual(bucket.get_available_tokens(), 5)
# Should successfully consume 5 more tokens
self.assertTrue(bucket.consume(5))
self.assertEqual(bucket.get_available_tokens(), 0)
# Should fail to consume when empty
self.assertFalse(bucket.consume(1))
def test_token_refill(self):
"""Test tokens refill over time."""
bucket = TokenBucket(capacity=10, refill_rate=10.0) # 10 tokens/second
# Consume all tokens
bucket.consume(10)
self.assertEqual(bucket.get_available_tokens(), 0)
# Wait 0.5 seconds, should refill 5 tokens
time.sleep(0.5)
available = bucket.get_available_tokens()
# Allow some tolerance for timing variations
self.assertGreaterEqual(available, 4)
self.assertLessEqual(available, 6)
def test_refill_cap(self):
"""Test refill doesn't exceed capacity."""
bucket = TokenBucket(capacity=10, refill_rate=10.0)
# Wait long enough to refill many times over
time.sleep(2.0)
# Should be capped at capacity
self.assertEqual(bucket.get_available_tokens(), 10)
def test_wait_time_calculation(self):
"""Test wait time calculation when tokens unavailable."""
bucket = TokenBucket(capacity=10, refill_rate=2.0) # 2 tokens/second
# Consume all tokens
bucket.consume(10)
# Need 1 token, should take 0.5 seconds
wait_time = bucket.get_wait_time(1)
self.assertAlmostEqual(wait_time, 0.5, delta=0.1)
# Need 10 tokens, should take 5 seconds
wait_time = bucket.get_wait_time(10)
self.assertAlmostEqual(wait_time, 5.0, delta=0.1)
class TestRateLimitConfig(unittest.TestCase):
"""Test rate limit configuration."""
def test_default_values(self):
"""Test default configuration values."""
config = RateLimitConfig()
self.assertEqual(config.per_minute, 60)
self.assertEqual(config.per_hour, 1000)
self.assertEqual(config.per_day, 10000)
def test_custom_values(self):
"""Test custom configuration values."""
config = RateLimitConfig(per_minute=100, per_hour=2000, per_day=20000)
self.assertEqual(config.per_minute, 100)
self.assertEqual(config.per_hour, 2000)
self.assertEqual(config.per_day, 20000)
def test_from_env(self):
"""Test loading configuration from environment."""
with patch.dict(
"os.environ",
{
"RATE_LIMIT_PER_MINUTE": "120",
"RATE_LIMIT_PER_HOUR": "2000",
"RATE_LIMIT_PER_DAY": "15000",
},
):
config = RateLimitConfig.from_env()
self.assertEqual(config.per_minute, 120)
self.assertEqual(config.per_hour, 2000)
self.assertEqual(config.per_day, 15000)
def test_from_env_with_prefix(self):
"""Test loading configuration with prefix."""
with patch.dict(
"os.environ",
{
"WORDPRESS_RATE_LIMIT_PER_MINUTE": "80",
"WORDPRESS_RATE_LIMIT_PER_HOUR": "1500",
"WORDPRESS_RATE_LIMIT_PER_DAY": "12000",
},
):
config = RateLimitConfig.from_env(prefix="WORDPRESS")
self.assertEqual(config.per_minute, 80)
self.assertEqual(config.per_hour, 1500)
self.assertEqual(config.per_day, 12000)
class TestClientRateLimitState(unittest.TestCase):
"""Test client rate limit state tracking."""
def test_initialization(self):
"""Test client state initialization."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(60, 1.0),
hour_bucket=TokenBucket(1000, 1.0),
day_bucket=TokenBucket(10000, 1.0),
)
self.assertEqual(state.client_id, "test_client")
self.assertEqual(state.total_requests, 0)
self.assertEqual(state.rejected_requests, 0)
def test_successful_request(self):
"""Test successful request consumes tokens."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(60, 1.0),
hour_bucket=TokenBucket(1000, 1.0),
day_bucket=TokenBucket(10000, 1.0),
)
allowed, message, retry_after = state.check_and_consume()
self.assertTrue(allowed)
self.assertEqual(message, "")
self.assertEqual(retry_after, 0.0)
self.assertEqual(state.total_requests, 1)
self.assertEqual(state.rejected_requests, 0)
def test_minute_limit_exceeded(self):
"""Test rejection when per-minute limit exceeded."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(2, 1.0), # Only 2 requests/minute
hour_bucket=TokenBucket(1000, 1.0),
day_bucket=TokenBucket(10000, 1.0),
)
# First 2 requests should succeed
state.check_and_consume()
state.check_and_consume()
# Third request should fail
allowed, message, retry_after = state.check_and_consume()
self.assertFalse(allowed)
self.assertIn("per minute", message.lower())
self.assertGreater(retry_after, 0)
self.assertEqual(state.rejected_requests, 1)
def test_hour_limit_exceeded(self):
"""Test rejection when per-hour limit exceeded."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(100, 100.0), # High minute limit
hour_bucket=TokenBucket(2, 1.0), # Only 2 requests/hour
day_bucket=TokenBucket(10000, 1.0),
)
# First 2 requests should succeed
state.check_and_consume()
state.check_and_consume()
# Third request should fail at hour limit
allowed, message, retry_after = state.check_and_consume()
self.assertFalse(allowed)
self.assertIn("per hour", message.lower())
def test_day_limit_exceeded(self):
"""Test rejection when daily limit exceeded."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(100, 100.0),
hour_bucket=TokenBucket(100, 100.0),
day_bucket=TokenBucket(2, 1.0), # Only 2 requests/day
)
# First 2 requests should succeed
state.check_and_consume()
state.check_and_consume()
# Third request should fail at day limit
allowed, message, retry_after = state.check_and_consume()
self.assertFalse(allowed)
self.assertIn("daily", message.lower())
def test_token_refund_on_rejection(self):
"""Test tokens are refunded when request rejected at later stage."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(10, 1.0),
hour_bucket=TokenBucket(2, 1.0), # Will fail here
day_bucket=TokenBucket(10, 1.0),
)
# Consume hour bucket
state.check_and_consume()
state.check_and_consume()
# Check minute bucket before rejection
minute_before = state.minute_bucket.get_available_tokens()
# This should fail at hour limit
allowed, message, retry_after = state.check_and_consume()
self.assertFalse(allowed)
# Minute bucket should be refunded
minute_after = state.minute_bucket.get_available_tokens()
self.assertEqual(minute_before, minute_after)
def test_get_stats(self):
"""Test statistics generation."""
state = ClientRateLimitState(
client_id="test_client",
minute_bucket=TokenBucket(60, 1.0),
hour_bucket=TokenBucket(1000, 1.0),
day_bucket=TokenBucket(10000, 1.0),
)
# Make some requests
state.check_and_consume()
state.check_and_consume()
stats = state.get_stats()
self.assertEqual(stats["client_id"], "test_client")
self.assertEqual(stats["total_requests"], 2)
self.assertEqual(stats["rejected_requests"], 0)
self.assertEqual(stats["success_rate"], 1.0)
self.assertIn("available_tokens", stats)
self.assertIn("limits", stats)
class TestRateLimiter(unittest.TestCase):
"""Test RateLimiter class."""
def setUp(self):
"""Create a fresh rate limiter for each test."""
# Reset singleton
import core.rate_limiter
core.rate_limiter._rate_limiter = None
self.limiter = RateLimiter()
def test_initialization(self):
"""Test rate limiter initialization."""
self.assertIsNotNone(self.limiter.default_config)
self.assertEqual(len(self.limiter.clients), 0)
self.assertEqual(self.limiter.global_stats["total_requests"], 0)
def test_singleton_pattern(self):
"""Test get_rate_limiter returns singleton."""
limiter1 = get_rate_limiter()
limiter2 = get_rate_limiter()
self.assertIs(limiter1, limiter2)
def test_client_creation(self):
"""Test automatic client state creation."""
allowed, message, retry_after = self.limiter.check_rate_limit(
client_id="client1", tool_name="test_tool"
)
self.assertTrue(allowed)
self.assertIn("client1", self.limiter.clients)
def test_successful_rate_limit_check(self):
"""Test successful rate limit check."""
allowed, message, retry_after = self.limiter.check_rate_limit(
client_id="client1", tool_name="test_tool"
)
self.assertTrue(allowed)
self.assertEqual(message, "")
self.assertEqual(retry_after, 0.0)
self.assertEqual(self.limiter.global_stats["total_requests"], 1)
self.assertEqual(self.limiter.global_stats["total_rejected"], 0)
def test_rate_limit_exceeded(self):
"""Test rate limit exceeded scenario."""
# Configure very low limits
self.limiter.configure_limits("test_plugin", per_minute=2, per_hour=2, per_day=2)
# First 2 requests should succeed
self.limiter.check_rate_limit("client1", "test", "test_plugin")
self.limiter.check_rate_limit("client1", "test", "test_plugin")
# Third should fail
allowed, message, retry_after = self.limiter.check_rate_limit(
"client1", "test", "test_plugin"
)
self.assertFalse(allowed)
self.assertGreater(retry_after, 0)
self.assertEqual(self.limiter.global_stats["total_rejected"], 1)
def test_plugin_specific_limits(self):
"""Test plugin-specific rate limits."""
# Configure different limits for wordpress
self.limiter.configure_limits("wordpress", per_minute=5, per_hour=5, per_day=5)
# Make 5 wordpress requests
for _i in range(5):
allowed, _, _ = self.limiter.check_rate_limit("client1", "wordpress_tool", "wordpress")
self.assertTrue(allowed)
# 6th should fail
allowed, message, _ = self.limiter.check_rate_limit(
"client1", "wordpress_tool", "wordpress"
)
self.assertFalse(allowed)
def test_multiple_clients(self):
"""Test multiple clients tracked separately."""
# Configure low limits
self.limiter.configure_limits("test", per_minute=2, per_hour=2, per_day=2)
# Client 1 makes 2 requests
self.limiter.check_rate_limit("client1", "test", "test")
self.limiter.check_rate_limit("client1", "test", "test")
# Client 1's third request should fail
allowed, _, _ = self.limiter.check_rate_limit("client1", "test", "test")
self.assertFalse(allowed)
# But client 2's first request should succeed
allowed, _, _ = self.limiter.check_rate_limit("client2", "test", "test")
self.assertTrue(allowed)
def test_get_client_stats(self):
"""Test getting client statistics."""
# Make some requests
self.limiter.check_rate_limit("client1", "test")
self.limiter.check_rate_limit("client1", "test")
stats = self.limiter.get_client_stats("client1")
self.assertIsNotNone(stats)
self.assertEqual(stats["client_id"], "client1")
self.assertEqual(stats["total_requests"], 2)
def test_get_client_stats_not_found(self):
"""Test getting stats for non-existent client."""
stats = self.limiter.get_client_stats("nonexistent")
self.assertIsNone(stats)
def test_get_all_stats(self):
"""Test getting all statistics."""
# Make requests from multiple clients
self.limiter.check_rate_limit("client1", "test")
self.limiter.check_rate_limit("client2", "test")
stats = self.limiter.get_all_stats()
self.assertIn("global", stats)
self.assertIn("default_limits", stats)
self.assertIn("plugin_limits", stats)
self.assertIn("clients", stats)
self.assertEqual(stats["global"]["total_requests"], 2)
self.assertEqual(len(stats["clients"]), 2)
def test_reset_client(self):
"""Test resetting specific client."""
# Make some requests
self.limiter.check_rate_limit("client1", "test")
self.assertIn("client1", self.limiter.clients)
# Reset client
success = self.limiter.reset_client("client1")
self.assertTrue(success)
self.assertNotIn("client1", self.limiter.clients)
def test_reset_client_not_found(self):
"""Test resetting non-existent client."""
success = self.limiter.reset_client("nonexistent")
self.assertFalse(success)
def test_reset_all(self):
"""Test resetting all clients."""
# Make requests from multiple clients
self.limiter.check_rate_limit("client1", "test")
self.limiter.check_rate_limit("client2", "test")
self.limiter.check_rate_limit("client3", "test")
# Reset all
count = self.limiter.reset_all()
self.assertEqual(count, 3)
self.assertEqual(len(self.limiter.clients), 0)
self.assertEqual(self.limiter.global_stats["total_requests"], 0)
def test_configure_limits(self):
"""Test dynamic limit configuration."""
self.limiter.configure_limits("custom_plugin", per_minute=100, per_hour=2000, per_day=20000)
config = self.limiter.plugin_configs["custom_plugin"]
self.assertEqual(config.per_minute, 100)
self.assertEqual(config.per_hour, 2000)
self.assertEqual(config.per_day, 20000)
class TestIntegration(unittest.TestCase):
"""Integration tests simulating real-world usage."""
def setUp(self):
"""Create fresh rate limiter."""
import core.rate_limiter
core.rate_limiter._rate_limiter = None
self.limiter = RateLimiter()
def test_burst_traffic(self):
"""Test handling of burst traffic within limits."""
# Configure to allow 10/minute
self.limiter.configure_limits("test", per_minute=10, per_hour=100, per_day=1000)
# Simulate burst of 10 requests
success_count = 0
for _i in range(10):
allowed, _, _ = self.limiter.check_rate_limit("client1", "test", "test")
if allowed:
success_count += 1
# All 10 should succeed (burst allowed)
self.assertEqual(success_count, 10)
# 11th should fail
allowed, _, _ = self.limiter.check_rate_limit("client1", "test", "test")
self.assertFalse(allowed)
def test_sustained_traffic(self):
"""Test handling of sustained traffic with refill."""
# Configure high refill rate for testing
self.limiter.configure_limits("test", per_minute=5, per_hour=100, per_day=1000)
# Make 5 requests (consume all minute tokens)
for _i in range(5):
self.limiter.check_rate_limit("client1", "test", "test")
# Should be rate limited now
allowed, _, _ = self.limiter.check_rate_limit("client1", "test", "test")
self.assertFalse(allowed)
# Wait for refill (should get ~2-3 tokens in 0.5 seconds at 5/min rate)
time.sleep(0.5)
# Should be able to make 1-2 more requests
allowed, _, _ = self.limiter.check_rate_limit("client1", "test", "test")
# May or may not succeed depending on exact timing
# Just verify no crash and reasonable behavior
def run_tests():
"""Run all tests."""
# Create test suite
loader = unittest.TestLoader()
suite = unittest.TestSuite()
# Add all test classes
suite.addTests(loader.loadTestsFromTestCase(TestTokenBucket))
suite.addTests(loader.loadTestsFromTestCase(TestRateLimitConfig))
suite.addTests(loader.loadTestsFromTestCase(TestClientRateLimitState))
suite.addTests(loader.loadTestsFromTestCase(TestRateLimiter))
suite.addTests(loader.loadTestsFromTestCase(TestIntegration))
# Run tests
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# Return exit code
return 0 if result.wasSuccessful() else 1
if __name__ == "__main__":
import sys
sys.exit(run_tests())

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Simple standalone test to verify the API key isolation logic.
"""
# Simulate the context and validation logic
from contextvars import ContextVar
# Create a mock context var
_api_key_context = ContextVar("api_key_context", default=None)
def test_case_1():
"""Per-project key accessing its own project"""
print("\nTest 1: Per-project key (site4) accessing site4")
# Set context for per-project key
_api_key_context.set(
{"key_id": "key_123", "project_id": "wordpress_site4", "scope": "admin", "is_global": False}
)
# Check access
api_key_info = _api_key_context.get()
full_id = "wordpress_site4" # Target project
if api_key_info and not api_key_info.get("is_global"):
allowed_project = api_key_info.get("project_id")
if allowed_project != full_id:
print(" ✗ FAIL: Access denied (should be allowed)")
return False
else:
print(" ✓ PASS: Access allowed")
return True
else:
print(" ✓ PASS: Access allowed (global key)")
return True
def test_case_2():
"""Per-project key accessing different project"""
print("\nTest 2: Per-project key (site4) accessing site1")
# Set context for per-project key
_api_key_context.set(
{"key_id": "key_123", "project_id": "wordpress_site4", "scope": "admin", "is_global": False}
)
# Check access
api_key_info = _api_key_context.get()
full_id = "wordpress_site1" # Different project
if api_key_info and not api_key_info.get("is_global"):
allowed_project = api_key_info.get("project_id")
if allowed_project != full_id:
print(" ✓ PASS: Access denied (as expected)")
return True
else:
print(" ✗ FAIL: Access allowed (should be denied)")
return False
else:
print(" ✗ FAIL: Access allowed (should be denied)")
return False
def test_case_3():
"""Global key accessing any project"""
print("\nTest 3: Global key accessing site1")
# Set context for global key
_api_key_context.set(
{"key_id": "key_456", "project_id": "*", "scope": "admin", "is_global": True}
)
# Check access
api_key_info = _api_key_context.get()
full_id = "wordpress_site1"
if api_key_info and not api_key_info.get("is_global"):
allowed_project = api_key_info.get("project_id")
if allowed_project != full_id:
print(" ✗ FAIL: Access denied (should be allowed)")
return False
else:
print(" ✓ PASS: Access allowed")
return True
else:
print(" ✓ PASS: Access allowed (global key)")
return True
def test_case_4():
"""Global key accessing different project"""
print("\nTest 4: Global key accessing site4")
# Set context for global key
_api_key_context.set(
{"key_id": "key_456", "project_id": "*", "scope": "admin", "is_global": True}
)
# Check access
api_key_info = _api_key_context.get()
full_id = "wordpress_site4"
if api_key_info and not api_key_info.get("is_global"):
allowed_project = api_key_info.get("project_id")
if allowed_project != full_id:
print(" ✗ FAIL: Access denied (should be allowed)")
return False
else:
print(" ✓ PASS: Access allowed")
return True
else:
print(" ✓ PASS: Access allowed (global key)")
return True
# Run all tests
print("=" * 60)
print("Testing API Key Isolation Logic")
print("=" * 60)
results = [test_case_1(), test_case_2(), test_case_3(), test_case_4()]
print("\n" + "=" * 60)
if all(results):
print("✅ All tests PASSED!")
print("The isolation logic is correct!")
else:
print("❌ Some tests FAILED!")
print("The isolation logic has issues!")
print("=" * 60)
exit(0 if all(results) else 1)