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>
445 lines
16 KiB
Python
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"
|