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:
577
tests/test_database.py
Normal file
577
tests/test_database.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""Tests for SQLite database backend (core/database.py)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from core.database import SCHEMA_VERSION, Database, get_database, initialize_database
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db(tmp_path):
|
||||
"""Provide an initialized Database using a temp directory."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
database = Database(db_path)
|
||||
await database.initialize()
|
||||
yield database
|
||||
await database.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_row(db):
|
||||
"""Create and return a sample user dict."""
|
||||
return await db.create_user(
|
||||
email="alice@example.com",
|
||||
name="Alice",
|
||||
provider="github",
|
||||
provider_id="gh-111",
|
||||
avatar_url="https://example.com/alice.png",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def second_user(db):
|
||||
"""Create and return a second sample user dict."""
|
||||
return await db.create_user(
|
||||
email="bob@example.com",
|
||||
name="Bob",
|
||||
provider="google",
|
||||
provider_id="gg-222",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def site_row(db, user_row):
|
||||
"""Create and return a sample site dict."""
|
||||
return await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="myblog",
|
||||
url="https://myblog.example.com",
|
||||
credentials=b"encrypted-blob-data",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSchemaCreation:
|
||||
"""Test that the database schema is created correctly."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_tables_exist(self, db):
|
||||
"""All expected tables should exist after initialization."""
|
||||
rows = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
table_names = {row["name"] for row in rows}
|
||||
expected = {"users", "sites", "user_api_keys", "connection_tokens", "schema_version"}
|
||||
assert expected.issubset(table_names)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_schema_version_set(self, db):
|
||||
"""schema_version table should contain version 1 after init."""
|
||||
row = await db.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||
assert row is not None
|
||||
assert row["v"] == SCHEMA_VERSION
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserCRUD:
|
||||
"""Test user create, read, and update operations."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_user(self, db):
|
||||
"""Should create a user and return a dict with all fields."""
|
||||
user = await db.create_user(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
provider="github",
|
||||
provider_id="gh-999",
|
||||
avatar_url="https://example.com/avatar.png",
|
||||
)
|
||||
assert user["email"] == "test@example.com"
|
||||
assert user["name"] == "Test User"
|
||||
assert user["provider"] == "github"
|
||||
assert user["provider_id"] == "gh-999"
|
||||
assert user["avatar_url"] == "https://example.com/avatar.png"
|
||||
assert user["role"] == "user"
|
||||
assert user["id"] is not None
|
||||
assert user["created_at"] is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_user_admin_role(self, db):
|
||||
"""Should allow creating a user with admin role."""
|
||||
user = await db.create_user(
|
||||
email="admin@example.com",
|
||||
name="Admin",
|
||||
provider="github",
|
||||
provider_id="gh-admin",
|
||||
role="admin",
|
||||
)
|
||||
assert user["role"] == "admin"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_id(self, db, user_row):
|
||||
"""Should retrieve a user by their UUID."""
|
||||
fetched = await db.get_user_by_id(user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["email"] == "alice@example.com"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_id_not_found(self, db):
|
||||
"""Should return None for a non-existent user ID."""
|
||||
fetched = await db.get_user_by_id("non-existent-uuid")
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_provider(self, db, user_row):
|
||||
"""Should retrieve a user by provider and provider_id."""
|
||||
fetched = await db.get_user_by_provider("github", "gh-111")
|
||||
assert fetched is not None
|
||||
assert fetched["id"] == user_row["id"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_provider_not_found(self, db):
|
||||
"""Should return None for a non-existent provider combo."""
|
||||
fetched = await db.get_user_by_provider("github", "does-not-exist")
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_last_login(self, db, user_row):
|
||||
"""Should update the last_login timestamp."""
|
||||
original_login = user_row["last_login"]
|
||||
# Small delay so the timestamps differ
|
||||
await asyncio.sleep(0.01)
|
||||
await db.update_user_last_login(user_row["id"])
|
||||
|
||||
fetched = await db.get_user_by_id(user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["last_login"] != original_login
|
||||
assert fetched["last_login"] > original_login
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User uniqueness constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserConstraints:
|
||||
"""Test UNIQUE constraints on the users table."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_duplicate_email_raises(self, db, user_row):
|
||||
"""Inserting a user with a duplicate email should raise IntegrityError."""
|
||||
with pytest.raises(aiosqlite.IntegrityError):
|
||||
await db.create_user(
|
||||
email="alice@example.com", # duplicate
|
||||
name="Alice Clone",
|
||||
provider="google",
|
||||
provider_id="gg-unique",
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_duplicate_provider_raises(self, db, user_row):
|
||||
"""Inserting a user with duplicate provider+provider_id should raise."""
|
||||
with pytest.raises(aiosqlite.IntegrityError):
|
||||
await db.create_user(
|
||||
email="other@example.com",
|
||||
name="Other",
|
||||
provider="github", # same provider
|
||||
provider_id="gh-111", # same provider_id
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSiteCRUD:
|
||||
"""Test site create, read, update, and delete operations."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site(self, db, user_row):
|
||||
"""Should create a site and return a dict with all fields."""
|
||||
site = await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="testsite",
|
||||
url="https://test.example.com",
|
||||
credentials=b"encrypted-data",
|
||||
)
|
||||
assert site["plugin_type"] == "wordpress"
|
||||
assert site["alias"] == "testsite"
|
||||
assert site["url"] == "https://test.example.com"
|
||||
assert site["credentials"] == b"encrypted-data"
|
||||
assert site["status"] == "pending"
|
||||
assert site["user_id"] == user_row["id"]
|
||||
assert site["id"] is not None
|
||||
assert site["created_at"] is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_sites_by_user(self, db, user_row):
|
||||
"""Should return all sites for a user."""
|
||||
await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="site-a",
|
||||
url="https://a.example.com",
|
||||
credentials=b"cred-a",
|
||||
)
|
||||
await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="gitea",
|
||||
alias="site-b",
|
||||
url="https://b.example.com",
|
||||
credentials=b"cred-b",
|
||||
)
|
||||
|
||||
sites = await db.get_sites_by_user(user_row["id"])
|
||||
assert len(sites) == 2
|
||||
aliases = {s["alias"] for s in sites}
|
||||
assert aliases == {"site-a", "site-b"}
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_sites_by_user_empty(self, db, user_row):
|
||||
"""Should return empty list when user has no sites."""
|
||||
sites = await db.get_sites_by_user(user_row["id"])
|
||||
assert sites == []
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_site(self, db, user_row, site_row):
|
||||
"""Should retrieve a site by ID with user scoping."""
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["alias"] == "myblog"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_site(self, db, user_row, site_row):
|
||||
"""Should delete a site and return True."""
|
||||
deleted = await db.delete_site(site_row["id"], user_row["id"])
|
||||
assert deleted is True
|
||||
|
||||
# Verify it's gone
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_site_not_found(self, db, user_row):
|
||||
"""Should return False when deleting a non-existent site."""
|
||||
deleted = await db.delete_site("no-such-id", user_row["id"])
|
||||
assert deleted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSiteIsolation:
|
||||
"""Test that site access is properly scoped to the owning user."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_site_wrong_user_returns_none(self, db, user_row, second_user, site_row):
|
||||
"""get_site with wrong user_id should return None."""
|
||||
fetched = await db.get_site(site_row["id"], second_user["id"])
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_site_wrong_user_returns_false(self, db, user_row, second_user, site_row):
|
||||
"""delete_site with wrong user_id should return False and not delete."""
|
||||
deleted = await db.delete_site(site_row["id"], second_user["id"])
|
||||
assert deleted is False
|
||||
|
||||
# Verify it's still there for the real owner
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site alias constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSiteAliasConstraints:
|
||||
"""Test UNIQUE(user_id, alias) constraint on the sites table."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_duplicate_alias_same_user_raises(self, db, user_row, site_row):
|
||||
"""Duplicate alias for the same user should raise IntegrityError."""
|
||||
with pytest.raises(aiosqlite.IntegrityError):
|
||||
await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="gitea",
|
||||
alias="myblog", # duplicate alias for same user
|
||||
url="https://other.example.com",
|
||||
credentials=b"cred",
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_same_alias_different_users_succeeds(self, db, user_row, second_user):
|
||||
"""Same alias for different users should succeed."""
|
||||
site1 = await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="shared-alias",
|
||||
url="https://a.example.com",
|
||||
credentials=b"cred-a",
|
||||
)
|
||||
site2 = await db.create_site(
|
||||
user_id=second_user["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="shared-alias",
|
||||
url="https://b.example.com",
|
||||
credentials=b"cred-b",
|
||||
)
|
||||
assert site1["id"] != site2["id"]
|
||||
assert site1["alias"] == site2["alias"] == "shared-alias"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCascadeDelete:
|
||||
"""Test that deleting a user cascades to their sites."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_user_cascades_sites(self, db, user_row, site_row):
|
||||
"""Deleting a user should also delete their sites."""
|
||||
# Verify site exists
|
||||
site = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert site is not None
|
||||
|
||||
# Delete the user directly
|
||||
await db.execute("DELETE FROM users WHERE id = ?", (user_row["id"],))
|
||||
|
||||
# Site should be gone
|
||||
row = await db.fetchone("SELECT * FROM sites WHERE id = ?", (site_row["id"],))
|
||||
assert row is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_site_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateSiteStatus:
|
||||
"""Test site status updates."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status(self, db, user_row, site_row):
|
||||
"""Should update status and status_msg."""
|
||||
await db.update_site_status(site_row["id"], "active", "Connected successfully")
|
||||
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "active"
|
||||
assert fetched["status_msg"] == "Connected successfully"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_to_error(self, db, user_row, site_row):
|
||||
"""Should allow setting error status with a message."""
|
||||
await db.update_site_status(site_row["id"], "error", "Connection refused")
|
||||
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "error"
|
||||
assert fetched["status_msg"] == "Connection refused"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_clears_message(self, db, user_row, site_row):
|
||||
"""Should clear status_msg when set to None."""
|
||||
await db.update_site_status(site_row["id"], "active", "All good")
|
||||
await db.update_site_status(site_row["id"], "disabled")
|
||||
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "disabled"
|
||||
assert fetched["status_msg"] is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_with_user_id(self, db, user_row, site_row):
|
||||
"""Should update when user_id matches the site owner."""
|
||||
await db.update_site_status(site_row["id"], "active", "OK", user_id=user_row["id"])
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "active"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_wrong_user_id_no_effect(self, db, user_row, site_row):
|
||||
"""Should not update when user_id doesn't match."""
|
||||
await db.update_site_status(site_row["id"], "active", "OK", user_id="wrong-user-id")
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "pending" # unchanged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
"""Test async context manager support."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_context_manager(self, tmp_path):
|
||||
"""async with Database(...) as db: should work."""
|
||||
db_path = str(tmp_path / "ctx_test.db")
|
||||
async with Database(db_path) as db:
|
||||
user = await db.create_user(
|
||||
email="ctx@example.com",
|
||||
name="Context",
|
||||
provider="github",
|
||||
provider_id="gh-ctx",
|
||||
)
|
||||
assert user["email"] == "ctx@example.com"
|
||||
|
||||
# After exit, connection should be closed
|
||||
assert db._conn is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRAGMA checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPragmas:
|
||||
"""Test that WAL mode and foreign keys are enabled."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_wal_mode_enabled(self, db):
|
||||
"""PRAGMA journal_mode should be WAL."""
|
||||
row = await db.fetchone("PRAGMA journal_mode")
|
||||
assert row is not None
|
||||
assert row["journal_mode"].lower() == "wal"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_foreign_keys_enabled(self, db):
|
||||
"""PRAGMA foreign_keys should be ON (1)."""
|
||||
row = await db.fetchone("PRAGMA foreign_keys")
|
||||
assert row is not None
|
||||
assert row["foreign_keys"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrent access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConcurrentAccess:
|
||||
"""Test that two Database instances on the same file don't deadlock."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_concurrent_instances(self, tmp_path):
|
||||
"""Two Database instances on the same file should not deadlock."""
|
||||
db_path = str(tmp_path / "concurrent.db")
|
||||
|
||||
async with Database(db_path) as db1, Database(db_path) as db2:
|
||||
user1 = await db1.create_user(
|
||||
email="user1@example.com",
|
||||
name="User 1",
|
||||
provider="github",
|
||||
provider_id="gh-1",
|
||||
)
|
||||
user2 = await db2.create_user(
|
||||
email="user2@example.com",
|
||||
name="User 2",
|
||||
provider="google",
|
||||
provider_id="gg-2",
|
||||
)
|
||||
|
||||
# Both users should be visible from either connection
|
||||
fetched1 = await db2.get_user_by_id(user1["id"])
|
||||
fetched2 = await db1.get_user_by_id(user2["id"])
|
||||
assert fetched1 is not None
|
||||
assert fetched2 is not None
|
||||
assert fetched1["email"] == "user1@example.com"
|
||||
assert fetched2["email"] == "user2@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyResults:
|
||||
"""Test behavior with empty or non-existent data."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_id_nonexistent(self, db):
|
||||
"""get_user_by_id with a fake UUID should return None."""
|
||||
result = await db.get_user_by_id("00000000-0000-0000-0000-000000000000")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_provider_nonexistent(self, db):
|
||||
"""get_user_by_provider with a fake combo should return None."""
|
||||
result = await db.get_user_by_provider("unknown_provider", "fake_id")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_site_nonexistent(self, db):
|
||||
"""get_site with fake IDs should return None."""
|
||||
result = await db.get_site("fake-site-id", "fake-user-id")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_sites_by_user_nonexistent(self, db):
|
||||
"""get_sites_by_user with a fake user ID should return empty list."""
|
||||
result = await db.get_sites_by_user("fake-user-id")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModuleHelpers:
|
||||
"""Test get_database() and initialize_database() helpers."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_database_before_init_raises(self, monkeypatch):
|
||||
"""get_database() should raise RuntimeError if not initialized."""
|
||||
# Reset the module-level singleton
|
||||
import core.database as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "_database", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Database not initialized"):
|
||||
get_database()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_initialize_and_get_database(self, tmp_path, monkeypatch):
|
||||
"""initialize_database() should set the singleton, get_database() returns it."""
|
||||
import core.database as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "_database", None)
|
||||
|
||||
db_path = str(tmp_path / "singleton_test.db")
|
||||
db = await initialize_database(db_path)
|
||||
try:
|
||||
assert db is get_database()
|
||||
|
||||
# Should be functional
|
||||
user = await db.create_user(
|
||||
email="singleton@example.com",
|
||||
name="Singleton",
|
||||
provider="github",
|
||||
provider_id="gh-singleton",
|
||||
)
|
||||
assert user["email"] == "singleton@example.com"
|
||||
finally:
|
||||
await db.close()
|
||||
monkeypatch.setattr(db_module, "_database", None)
|
||||
Reference in New Issue
Block a user