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>
355 lines
13 KiB
Python
355 lines
13 KiB
Python
"""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()
|