Files
mcphub/tests/test_dashboard.py
airano-ir 43fd2201a0 feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:33:20 +02:00

445 lines
16 KiB
Python

"""Tests for Dashboard routes and auth (core/dashboard/).
Tests covering dashboard authentication, session management, rate limiting,
language detection, translations, and utility functions.
"""
import inspect
import time
from datetime import UTC, datetime, timedelta
import jwt
import pytest
from starlette.requests import Request
from core.dashboard.auth import DashboardAuth, DashboardSession
from core.dashboard.routes import (
DASHBOARD_TRANSLATIONS,
PLUGIN_DISPLAY_NAMES,
detect_language,
get_plugin_display_name,
get_translations,
)
def _make_request(path: str, *, query_string: str = "", path_params: dict[str, str] | None = None):
"""Construct a minimal GET Request for direct route-handler tests."""
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"scheme": "http",
"method": "GET",
"path": path,
"raw_path": path.encode(),
"query_string": query_string.encode(),
"headers": [],
"path_params": path_params or {},
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
return Request(scope, receive)
# --- Plugin Display Names ---
class TestPluginDisplayNames:
"""Test plugin name formatting."""
def test_known_plugins(self):
"""Should return proper display names for known plugins."""
assert get_plugin_display_name("wordpress") == "WordPress"
assert get_plugin_display_name("woocommerce") == "WooCommerce"
assert get_plugin_display_name("n8n") == "n8n"
assert get_plugin_display_name("gitea") == "Gitea"
assert get_plugin_display_name("supabase") == "Supabase"
assert get_plugin_display_name("openpanel") == "OpenPanel"
assert get_plugin_display_name("appwrite") == "Appwrite"
assert get_plugin_display_name("directus") == "Directus"
def test_wordpress_specialist(self):
"""Should handle underscore-separated names."""
assert get_plugin_display_name("wordpress_specialist") == "WordPress Specialist"
def test_unknown_plugin_titlecased(self):
"""Should title-case unknown plugin types."""
assert get_plugin_display_name("my_custom_plugin") == "My Custom Plugin"
def test_all_plugins_mapped(self):
"""Every registered plugin type must be in the display names map."""
expected = {
"wordpress",
"woocommerce",
"wordpress_specialist",
"gitea",
"n8n",
"supabase",
"openpanel",
"appwrite",
"directus",
"coolify",
}
assert expected == set(PLUGIN_DISPLAY_NAMES.keys())
# --- Language Detection ---
class TestLanguageDetection:
"""Test language detection from query params (default English)."""
def test_default_english(self):
"""Should default to English."""
assert detect_language(None) == "en"
def test_header_ignored(self):
"""Accept-Language header should be ignored (always default to English)."""
assert detect_language("fa-IR,fa;q=0.9,en;q=0.8") == "en"
def test_query_param_farsi(self):
"""Should detect Farsi from explicit query parameter."""
assert detect_language("en-US", query_lang="fa") == "fa"
def test_query_param_english(self):
"""Should respect English query parameter."""
assert detect_language("fa-IR", query_lang="en") == "en"
def test_invalid_query_lang_ignored(self):
"""Invalid query lang should be ignored, default to English."""
assert detect_language("fa-IR", query_lang="de") == "en"
def test_english_header(self):
"""Should return English for English header."""
assert detect_language("en-US,en;q=0.9") == "en"
# --- Translations ---
class TestTranslations:
"""Test translation system."""
def test_english_translations_exist(self):
"""English translations should be available."""
trans = get_translations("en")
assert trans["dashboard"] == "Dashboard"
assert trans["projects"] == "Projects"
assert trans["login_title"] == "Dashboard Login"
def test_farsi_translations_exist(self):
"""Farsi translations should be available."""
trans = get_translations("fa")
assert trans["dashboard"] == "داشبورد"
assert trans["projects"] == "پروژه‌ها"
assert trans["login_title"] == "ورود به داشبورد"
def test_unknown_lang_falls_back_to_english(self):
"""Unknown language should fall back to English."""
trans = get_translations("de")
assert trans["dashboard"] == "Dashboard"
def test_both_languages_have_same_keys(self):
"""English and Farsi should have the same translation keys."""
en_keys = set(DASHBOARD_TRANSLATIONS["en"].keys())
fa_keys = set(DASHBOARD_TRANSLATIONS["fa"].keys())
assert (
en_keys == fa_keys
), f"Missing keys in fa: {en_keys - fa_keys}, extra in fa: {fa_keys - en_keys}"
def test_no_empty_translations(self):
"""No translation value should be empty."""
for lang, translations in DASHBOARD_TRANSLATIONS.items():
for key, value in translations.items():
assert value.strip() != "", f"Empty translation: {lang}.{key}"
# --- Dashboard Auth ---
class TestDashboardAuthInit:
"""Test DashboardAuth initialization."""
def test_init_with_explicit_key(self):
"""Should use explicit secret key."""
auth = DashboardAuth(secret_key="test-secret", master_api_key="sk-master")
assert auth.secret_key == "test-secret"
assert auth.master_api_key == "sk-master"
def test_init_generates_random_key(self, monkeypatch):
"""Should generate random key when none provided."""
monkeypatch.delenv("DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.delenv("OAUTH_JWT_SECRET_KEY", raising=False)
auth = DashboardAuth(master_api_key="sk-master")
assert auth.secret_key is not None
assert len(auth.secret_key) == 64 # hex of 32 bytes
def test_default_session_expiry(self):
"""Default session expiry should be 24 hours."""
auth = DashboardAuth(secret_key="test", master_api_key="sk-master")
assert auth.session_expiry_hours == 24
def test_custom_session_expiry(self, monkeypatch):
"""Should respect DASHBOARD_SESSION_EXPIRY_HOURS env var."""
monkeypatch.setenv("DASHBOARD_SESSION_EXPIRY_HOURS", "48")
auth = DashboardAuth(secret_key="test", master_api_key="sk-master")
assert auth.session_expiry_hours == 48
class TestDashboardAuthValidation:
"""Test API key validation for dashboard login."""
@pytest.fixture
def auth(self):
return DashboardAuth(secret_key="test-secret-key", master_api_key="sk-master-key-123")
def test_valid_master_key(self, auth):
"""Should accept valid master API key."""
is_valid, user_type, key_id = auth.validate_api_key("sk-master-key-123")
assert is_valid is True
assert user_type == "master"
assert key_id is None
def test_temp_key_via_auth_manager(self, auth, monkeypatch):
"""Should accept temp key from AuthManager when no env MASTER_API_KEY."""
from core.auth import AuthManager
# Simulate AuthManager with a temp key that DashboardAuth doesn't know about
temp_key = "temp-generated-key-12345"
mgr = AuthManager.__new__(AuthManager)
mgr.master_api_key = temp_key
mgr._is_temporary_key = True
mgr.project_keys = {}
monkeypatch.setattr("core.auth.get_auth_manager", lambda: mgr)
# DashboardAuth has master_api_key=None (no env var)
auth_no_env = DashboardAuth(secret_key="test-secret", master_api_key=None)
is_valid, user_type, key_id = auth_no_env.validate_api_key(temp_key)
assert is_valid is True
assert user_type == "master"
assert key_id is None
def test_invalid_key_rejected(self, auth):
"""Should reject invalid API key."""
is_valid, user_type, key_id = auth.validate_api_key("wrong-key")
assert is_valid is False
assert user_type == ""
def test_empty_key_rejected(self, auth):
"""Should reject empty API key."""
is_valid, user_type, key_id = auth.validate_api_key("")
assert is_valid is False
def test_none_key_rejected(self, auth):
"""Should reject None API key."""
is_valid, user_type, key_id = auth.validate_api_key(None)
assert is_valid is False
class TestDashboardRateLimiting:
"""Test login rate limiting."""
@pytest.fixture
def auth(self):
return DashboardAuth(
secret_key="test-secret",
master_api_key="sk-master",
)
def test_within_limit(self, auth):
"""Should allow requests within rate limit."""
assert auth.check_rate_limit("192.168.1.1") is True
def test_exceeds_limit(self, auth):
"""Should block after exceeding rate limit."""
ip = "192.168.1.100"
# Record max_login_attempts (default 5)
for _ in range(auth.max_login_attempts):
auth.record_login_attempt(ip)
assert auth.check_rate_limit(ip) is False
def test_different_ips_independent(self, auth):
"""Different IPs should have independent rate limits."""
ip1 = "10.0.0.1"
ip2 = "10.0.0.2"
for _ in range(auth.max_login_attempts):
auth.record_login_attempt(ip1)
assert auth.check_rate_limit(ip1) is False
assert auth.check_rate_limit(ip2) is True
def test_attempts_expire(self, auth):
"""Old attempts should expire after 1 minute window."""
ip = "10.0.0.50"
# Manually add old timestamps
old_time = datetime.now(UTC) - timedelta(minutes=2)
auth._login_attempts[ip] = [old_time] * 10
# Old attempts should be cleaned up
assert auth.check_rate_limit(ip) is True
class TestDashboardSessionManagement:
"""Test session creation and validation."""
@pytest.fixture
def auth(self):
return DashboardAuth(secret_key="test-session-secret", master_api_key="sk-master")
def test_create_session_returns_token(self, auth):
"""Should create a JWT session token."""
token = auth.create_session("master")
assert isinstance(token, str)
assert len(token) > 0
def test_validate_valid_session(self, auth):
"""Should validate a freshly created session."""
token = auth.create_session("master")
session = auth.validate_session(token)
assert session is not None
assert isinstance(session, DashboardSession)
assert session.user_type == "master"
assert session.session_id is not None
def test_session_contains_key_id(self, auth):
"""API key sessions should include key_id."""
token = auth.create_session("api_key", key_id="key_abc123")
session = auth.validate_session(token)
assert session.key_id == "key_abc123"
def test_master_session_no_key_id(self, auth):
"""Master sessions should have no key_id."""
token = auth.create_session("master")
session = auth.validate_session(token)
assert session.key_id is None
def test_expired_session_rejected(self, auth):
"""Should reject expired sessions."""
# Create a token that's already expired
now = datetime.now(UTC)
payload = {
"sid": "test-session",
"type": "master",
"iat": (now - timedelta(hours=48)).timestamp(),
"exp": (now - timedelta(hours=1)).timestamp(),
}
token = jwt.encode(payload, auth.secret_key, algorithm="HS256")
session = auth.validate_session(token)
assert session is None
def test_invalid_token_rejected(self, auth):
"""Should reject malformed tokens."""
assert auth.validate_session("not-a-jwt-token") is None
def test_wrong_secret_rejected(self, auth):
"""Should reject tokens signed with different secret."""
token = jwt.encode(
{"sid": "x", "type": "master", "iat": time.time(), "exp": time.time() + 3600},
"wrong-secret",
algorithm="HS256",
)
assert auth.validate_session(token) is None
def test_empty_token_rejected(self, auth):
"""Should handle empty token."""
assert auth.validate_session("") is None
assert auth.validate_session(None) is None
def test_session_expiry_matches_config(self, auth):
"""Session expiry should match configured hours."""
token = auth.create_session("master")
session = auth.validate_session(token)
# validate_session uses datetime.fromtimestamp() which returns local time (naive)
# So we compare with local datetime.now() (also naive)
expected_expiry = datetime.now() + timedelta(hours=24)
assert abs((session.expires_at - expected_expiry).total_seconds()) < 5
class TestDashboardCookieManagement:
"""Test session cookie handling."""
@pytest.fixture
def auth(self):
return DashboardAuth(secret_key="cookie-secret", master_api_key="sk-master")
def test_set_session_cookie(self, auth):
"""Should set httpOnly cookie on response."""
from starlette.responses import Response
response = Response("OK")
token = auth.create_session("master")
auth.set_session_cookie(response, token)
# Verify cookie was set (check raw headers)
cookie_header = None
for key, value in response.raw_headers:
if key == b"set-cookie":
cookie_header = value.decode()
break
assert cookie_header is not None
assert "mcp_dashboard_session=" in cookie_header
assert "httponly" in cookie_header.lower()
def test_clear_session_cookie(self, auth):
"""Should clear session cookie."""
from starlette.responses import Response
response = Response("OK")
auth.clear_session_cookie(response)
cookie_header = None
for key, value in response.raw_headers:
if key == b"set-cookie":
cookie_header = value.decode()
break
assert cookie_header is not None
assert "mcp_dashboard_session=" in cookie_header
@pytest.mark.asyncio
async def test_dashboard_connect_page(monkeypatch):
"""Legacy /dashboard-legacy/connect should redirect to legacy keys."""
from core.dashboard.routes import register_dashboard_routes
class FakeMCP:
def __init__(self):
self.routes = {}
def custom_route(self, path, methods=None):
def decorator(func):
self.routes[(path, tuple(methods or []))] = func
return func
return decorator
mcp = FakeMCP()
register_dashboard_routes(mcp)
handler = mcp.routes[("/dashboard-legacy/connect", ("GET",))]
response = handler(_make_request("/dashboard-legacy/connect"))
if inspect.isawaitable(response):
response = await response
assert response.status_code == 301
assert response.headers["location"] == "/dashboard-legacy/keys"
@pytest.mark.asyncio
async def test_dashboard_v2_redirects_to_dashboard(monkeypatch):
"""Old SPA URLs should 308 redirect to the cutover /dashboard prefix."""
from core.dashboard.spa_routes import redirect_dashboard_v2
response = await redirect_dashboard_v2(
_make_request(
"/dashboard-v2/overview",
query_string="lang=fa",
path_params={"path": "overview"},
)
)
assert response.status_code == 308
assert response.headers["location"] == "/dashboard/overview?lang=fa"