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>
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""Tests for User API Key management (core/user_keys.py)."""
|
|
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from core.user_keys import (
|
|
KEY_PREFIX_TAG,
|
|
UserKeyManager,
|
|
get_user_key_manager,
|
|
initialize_user_key_manager,
|
|
)
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_singleton():
|
|
"""Reset the global UserKeyManager singleton between tests."""
|
|
import core.user_keys as mod
|
|
|
|
original = mod._manager
|
|
mod._manager = None
|
|
yield
|
|
mod._manager = original
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
"""Create and patch a mock database instance."""
|
|
db = AsyncMock()
|
|
# Default return values for DB methods
|
|
db.create_api_key = AsyncMock(
|
|
return_value={
|
|
"id": "key-uuid-001",
|
|
"user_id": "user-uuid-001",
|
|
"key_hash": "$2b$12$fakehashvalue",
|
|
"key_prefix": "abcdefgh",
|
|
"name": "Claude Desktop",
|
|
"scopes": "read write",
|
|
"created_at": "2026-02-19T12:00:00Z",
|
|
"expires_at": None,
|
|
}
|
|
)
|
|
db.get_api_key_by_prefix = AsyncMock(return_value=None)
|
|
db.get_api_keys_by_user = AsyncMock(
|
|
return_value=[
|
|
{
|
|
"id": "key-uuid-001",
|
|
"name": "Claude Desktop",
|
|
"scopes": "read write",
|
|
"created_at": "2026-02-19T12:00:00Z",
|
|
"expires_at": None,
|
|
"last_used_at": None,
|
|
},
|
|
]
|
|
)
|
|
db.delete_api_key = AsyncMock(return_value=True)
|
|
db.update_api_key_usage = AsyncMock()
|
|
with patch("core.database.get_database", return_value=db):
|
|
yield db
|
|
|
|
|
|
@pytest.fixture
|
|
def key_mgr():
|
|
"""Create a fresh UserKeyManager instance."""
|
|
return UserKeyManager()
|
|
|
|
|
|
# ── Key Creation ─────────────────────────────────────────────
|
|
|
|
|
|
class TestKeyCreation:
|
|
"""Test API key creation."""
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_key_format(self, key_mgr, mock_db):
|
|
"""Created key should start with 'mhu_' and have substantial length."""
|
|
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
|
|
raw_key = result["key"]
|
|
assert raw_key.startswith(KEY_PREFIX_TAG)
|
|
# mhu_ (4) + urlsafe_b64 of 32 bytes (43 chars) = 47 chars total
|
|
assert len(raw_key) == 47
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_key_stores_bcrypt_hash(self, key_mgr, mock_db):
|
|
"""The key_hash passed to DB should be a bcrypt hash ($2b$ prefix)."""
|
|
await key_mgr.create_key("user-uuid-001", "Test Key")
|
|
call_kwargs = mock_db.create_api_key.call_args
|
|
key_hash = call_kwargs.kwargs.get("key_hash") or call_kwargs[1].get("key_hash")
|
|
assert key_hash.startswith("$2b$")
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_key_returns_metadata(self, key_mgr, mock_db):
|
|
"""Create key should return key_id, name, scopes, and timestamps."""
|
|
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
|
|
assert "key_id" in result
|
|
assert result["name"] == "Claude Desktop"
|
|
assert result["scopes"] == "read write"
|
|
assert "created_at" in result
|
|
|
|
|
|
# ── Key Validation ───────────────────────────────────────────
|
|
|
|
|
|
class TestKeyValidation:
|
|
"""Test API key validation logic."""
|
|
|
|
@pytest.mark.unit
|
|
async def test_validate_key_success(self, key_mgr, mock_db):
|
|
"""Creating then validating a key should return valid info."""
|
|
import bcrypt
|
|
|
|
# Create a key and capture the raw key
|
|
result = await key_mgr.create_key("user-uuid-001", "Test Key")
|
|
raw_key = result["key"]
|
|
|
|
# Set up DB to return a matching row with correct bcrypt hash
|
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
|
mock_db.get_api_key_by_prefix.return_value = {
|
|
"id": "key-uuid-001",
|
|
"user_id": "user-uuid-001",
|
|
"key_hash": key_hash,
|
|
"scopes": "read write",
|
|
"expires_at": None,
|
|
}
|
|
|
|
info = await key_mgr.validate_key(raw_key)
|
|
assert info is not None
|
|
assert info["user_id"] == "user-uuid-001"
|
|
assert info["key_id"] == "key-uuid-001"
|
|
assert info["scopes"] == "read write"
|
|
|
|
@pytest.mark.unit
|
|
async def test_validate_key_wrong_key(self, key_mgr, mock_db):
|
|
"""A key that doesn't match the bcrypt hash should return None."""
|
|
import bcrypt
|
|
|
|
correct_key = "mhu_correctkeyvalue1234567890abcdefghijklmno"
|
|
key_hash = bcrypt.hashpw(correct_key.encode(), bcrypt.gensalt()).decode()
|
|
|
|
mock_db.get_api_key_by_prefix.return_value = {
|
|
"id": "key-uuid-001",
|
|
"user_id": "user-uuid-001",
|
|
"key_hash": key_hash,
|
|
"scopes": "read write",
|
|
"expires_at": None,
|
|
}
|
|
|
|
wrong_key = "mhu_wrongkeyvalue1234567890abcdefghijklmnopq"
|
|
info = await key_mgr.validate_key(wrong_key)
|
|
assert info is None
|
|
|
|
@pytest.mark.unit
|
|
async def test_validate_key_no_prefix(self, key_mgr, mock_db):
|
|
"""A key without the 'mhu_' prefix should immediately return None."""
|
|
info = await key_mgr.validate_key("bad_prefix_key")
|
|
assert info is None
|
|
# DB should not even be queried
|
|
mock_db.get_api_key_by_prefix.assert_not_called()
|
|
|
|
@pytest.mark.unit
|
|
async def test_validate_key_expired(self, key_mgr, mock_db):
|
|
"""A key with expires_at in the past should return None."""
|
|
import bcrypt
|
|
|
|
raw_key = "mhu_expiredkeyvalue1234567890abcdefghijklmno"
|
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
|
|
|
expired_at = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
|
mock_db.get_api_key_by_prefix.return_value = {
|
|
"id": "key-uuid-002",
|
|
"user_id": "user-uuid-001",
|
|
"key_hash": key_hash,
|
|
"scopes": "read",
|
|
"expires_at": expired_at,
|
|
}
|
|
|
|
info = await key_mgr.validate_key(raw_key)
|
|
assert info is None
|
|
|
|
@pytest.mark.unit
|
|
async def test_validate_key_cache(self, key_mgr, mock_db):
|
|
"""Second validation of the same key should use cache (no DB prefix lookup)."""
|
|
import bcrypt
|
|
|
|
result = await key_mgr.create_key("user-uuid-001", "Cache Test")
|
|
raw_key = result["key"]
|
|
|
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
|
mock_db.get_api_key_by_prefix.return_value = {
|
|
"id": "key-uuid-001",
|
|
"user_id": "user-uuid-001",
|
|
"key_hash": key_hash,
|
|
"scopes": "read write",
|
|
"expires_at": None,
|
|
}
|
|
|
|
# First validation — hits DB
|
|
info1 = await key_mgr.validate_key(raw_key)
|
|
assert info1 is not None
|
|
assert mock_db.get_api_key_by_prefix.call_count == 1
|
|
|
|
# Second validation — should use cache
|
|
info2 = await key_mgr.validate_key(raw_key)
|
|
assert info2 is not None
|
|
# get_api_key_by_prefix should NOT be called again
|
|
assert mock_db.get_api_key_by_prefix.call_count == 1
|
|
|
|
@pytest.mark.unit
|
|
async def test_validate_key_updates_usage(self, key_mgr, mock_db):
|
|
"""Successful validation should call update_api_key_usage."""
|
|
import bcrypt
|
|
|
|
result = await key_mgr.create_key("user-uuid-001", "Usage Test")
|
|
raw_key = result["key"]
|
|
|
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
|
mock_db.get_api_key_by_prefix.return_value = {
|
|
"id": "key-uuid-001",
|
|
"user_id": "user-uuid-001",
|
|
"key_hash": key_hash,
|
|
"scopes": "read write",
|
|
"expires_at": None,
|
|
}
|
|
|
|
await key_mgr.validate_key(raw_key)
|
|
mock_db.update_api_key_usage.assert_called_with("key-uuid-001")
|
|
|
|
|
|
# ── Key Listing ──────────────────────────────────────────────
|
|
|
|
|
|
class TestKeyListing:
|
|
"""Test key listing functionality."""
|
|
|
|
@pytest.mark.unit
|
|
async def test_list_keys_no_hash(self, key_mgr, mock_db):
|
|
"""Listed keys should not contain key_hash field."""
|
|
keys = await key_mgr.list_keys("user-uuid-001")
|
|
assert len(keys) == 1
|
|
assert "key_hash" not in keys[0]
|
|
assert keys[0]["name"] == "Claude Desktop"
|
|
|
|
|
|
# ── Key Deletion ─────────────────────────────────────────────
|
|
|
|
|
|
class TestKeyDeletion:
|
|
"""Test key deletion and cache invalidation."""
|
|
|
|
@pytest.mark.unit
|
|
async def test_delete_key_clears_cache(self, key_mgr, mock_db):
|
|
"""Deleting a key should remove it from the validation cache."""
|
|
# Populate cache manually
|
|
key_mgr._cache["mhu_test_cached_key"] = (
|
|
"key-uuid-001",
|
|
"user-uuid-001",
|
|
"read write",
|
|
time.time(),
|
|
)
|
|
assert "mhu_test_cached_key" in key_mgr._cache
|
|
|
|
deleted = await key_mgr.delete_key("key-uuid-001", "user-uuid-001")
|
|
assert deleted is True
|
|
assert "mhu_test_cached_key" not in key_mgr._cache
|
|
|
|
@pytest.mark.unit
|
|
async def test_delete_key_not_found(self, key_mgr, mock_db):
|
|
"""Deleting a non-existent key should return False."""
|
|
mock_db.delete_api_key.return_value = False
|
|
deleted = await key_mgr.delete_key("nonexistent-key", "user-uuid-001")
|
|
assert deleted is False
|
|
|
|
|
|
# ── Singleton Pattern ────────────────────────────────────────
|
|
|
|
|
|
class TestSingleton:
|
|
"""Test module-level singleton pattern."""
|
|
|
|
@pytest.mark.unit
|
|
def test_singleton_pattern(self):
|
|
"""initialize_user_key_manager should set the singleton retrievable by get."""
|
|
mgr = initialize_user_key_manager()
|
|
assert get_user_key_manager() is mgr
|
|
|
|
@pytest.mark.unit
|
|
def test_get_without_init_raises(self):
|
|
"""get_user_key_manager before init should raise RuntimeError."""
|
|
with pytest.raises(RuntimeError, match="not initialized"):
|
|
get_user_key_manager()
|