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:
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()
|
||||
Reference in New Issue
Block a user