Archon/python/tests/test_async_credential_service.py

415 lines
17 KiB
Python
Raw Normal View History

"""
Comprehensive Tests for Async Credential Service
Tests the credential service async functions after sync function removal.
Covers credential storage, retrieval, encryption/decryption, and caching.
"""
import asyncio
import os
from unittest.mock import MagicMock, patch
import pytest
from src.server.services.credential_service import (
credential_service,
get_credential,
initialize_credentials,
set_credential,
)
class TestAsyncCredentialService:
"""Test suite for async credential service functions"""
@pytest.fixture(autouse=True)
def setup_credential_service(self):
"""Setup clean credential service for each test"""
# Clear cache and reset state
credential_service._cache.clear()
credential_service._cache_initialized = False
yield
# Cleanup after test
credential_service._cache.clear()
credential_service._cache_initialized = False
@pytest.fixture
def mock_supabase_client(self):
"""Mock Supabase client"""
mock_client = MagicMock()
mock_table = MagicMock()
mock_client.table.return_value = mock_table
return mock_client, mock_table
@pytest.fixture
def sample_credentials_data(self):
"""Sample credentials data from database"""
return [
{
"id": 1,
"key": "OPENAI_API_KEY",
"encrypted_value": "encrypted_openai_key",
"value": None,
"is_encrypted": True,
"category": "api_keys",
"description": "OpenAI API key for LLM access",
},
{
"id": 2,
"key": "MODEL_CHOICE",
"value": "gpt-4.1-nano",
"encrypted_value": None,
"is_encrypted": False,
"category": "rag_strategy",
"description": "Default model choice",
},
{
"id": 3,
"key": "MAX_TOKENS",
"value": "1000",
"encrypted_value": None,
"is_encrypted": False,
"category": "rag_strategy",
"description": "Maximum tokens per request",
},
]
def test_deprecated_functions_removed(self):
"""Test that deprecated sync functions are no longer available"""
import src.server.services.credential_service as cred_module
# The sync function should no longer exist
assert not hasattr(cred_module, "get_credential_sync")
# The async versions should be the primary functions
assert hasattr(cred_module, "get_credential")
assert hasattr(cred_module, "set_credential")
@pytest.mark.asyncio
async def test_get_credential_from_cache(self):
"""Test getting credential from initialized cache"""
# Setup cache
credential_service._cache = {"TEST_KEY": "test_value", "NUMERIC_KEY": "123"}
credential_service._cache_initialized = True
result = await get_credential("TEST_KEY", "default")
assert result == "test_value"
result = await get_credential("NUMERIC_KEY", "default")
assert result == "123"
result = await get_credential("MISSING_KEY", "default_value")
assert result == "default_value"
@pytest.mark.asyncio
async def test_get_credential_encrypted_value(self):
"""Test getting encrypted credential"""
# Setup cache with encrypted value
encrypted_data = {"encrypted_value": "encrypted_test_value", "is_encrypted": True}
credential_service._cache = {"SECRET_KEY": encrypted_data}
credential_service._cache_initialized = True
with patch.object(credential_service, "_decrypt_value", return_value="decrypted_value"):
result = await get_credential("SECRET_KEY", "default")
assert result == "decrypted_value"
credential_service._decrypt_value.assert_called_once_with("encrypted_test_value")
@pytest.mark.asyncio
async def test_get_credential_cache_not_initialized(self, mock_supabase_client):
"""Test getting credential when cache is not initialized"""
mock_client, mock_table = mock_supabase_client
# Mock database response for load_all_credentials (gets ALL settings)
mock_response = MagicMock()
mock_response.data = [
{
"key": "TEST_KEY",
"value": "db_value",
"encrypted_value": None,
"is_encrypted": False,
"category": "test",
"description": "Test key",
}
]
mock_table.select().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await credential_service.get_credential("TEST_KEY", "default")
assert result == "db_value"
# Should have called database to load all credentials
mock_table.select.assert_called_with("*")
# Should have called execute on the query
assert mock_table.select().execute.called
@pytest.mark.asyncio
async def test_get_credential_not_found_in_db(self, mock_supabase_client):
"""Test getting credential that doesn't exist in database"""
mock_client, mock_table = mock_supabase_client
# Mock empty database response
mock_response = MagicMock()
mock_response.data = []
mock_table.select().eq().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await credential_service.get_credential("MISSING_KEY", "default_value")
assert result == "default_value"
@pytest.mark.asyncio
async def test_set_credential_new(self, mock_supabase_client):
"""Test setting a new credential"""
mock_client, mock_table = mock_supabase_client
# Mock successful insert
mock_response = MagicMock()
mock_response.data = [{"id": 1, "key": "NEW_KEY", "value": "new_value"}]
mock_table.insert().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await set_credential("NEW_KEY", "new_value", is_encrypted=False)
assert result is True
# Should have attempted insert
mock_table.insert.assert_called_once()
@pytest.mark.asyncio
async def test_set_credential_encrypted(self, mock_supabase_client):
"""Test setting an encrypted credential"""
mock_client, mock_table = mock_supabase_client
# Mock successful insert
mock_response = MagicMock()
mock_response.data = [{"id": 1, "key": "SECRET_KEY"}]
mock_table.insert().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
with patch.object(credential_service, "_encrypt_value", return_value="encrypted_value"):
result = await set_credential("SECRET_KEY", "secret_value", is_encrypted=True)
assert result is True
# Should have encrypted the value
credential_service._encrypt_value.assert_called_once_with("secret_value")
@pytest.mark.asyncio
async def test_load_all_credentials(self, mock_supabase_client, sample_credentials_data):
"""Test loading all credentials from database"""
mock_client, mock_table = mock_supabase_client
# Mock database response
mock_response = MagicMock()
mock_response.data = sample_credentials_data
mock_table.select().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await credential_service.load_all_credentials()
# Should have loaded credentials into cache
assert credential_service._cache_initialized is True
assert "OPENAI_API_KEY" in credential_service._cache
assert "MODEL_CHOICE" in credential_service._cache
assert "MAX_TOKENS" in credential_service._cache
# Should have stored encrypted values as dict objects (not decrypted yet)
openai_key_cache = credential_service._cache["OPENAI_API_KEY"]
assert isinstance(openai_key_cache, dict)
assert openai_key_cache["encrypted_value"] == "encrypted_openai_key"
assert openai_key_cache["is_encrypted"] is True
# Plain text values should be stored directly
assert credential_service._cache["MODEL_CHOICE"] == "gpt-4.1-nano"
@pytest.mark.asyncio
async def test_get_credentials_by_category(self, mock_supabase_client):
"""Test getting credentials filtered by category"""
mock_client, mock_table = mock_supabase_client
# Mock database response for rag_strategy category
rag_data = [
{
"key": "MODEL_CHOICE",
"value": "gpt-4.1-nano",
"is_encrypted": False,
"description": "Model choice",
},
{
"key": "MAX_TOKENS",
"value": "1000",
"is_encrypted": False,
"description": "Max tokens",
},
]
mock_response = MagicMock()
mock_response.data = rag_data
mock_table.select().eq().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await credential_service.get_credentials_by_category("rag_strategy")
# Should only return rag_strategy credentials
assert "MODEL_CHOICE" in result
assert "MAX_TOKENS" in result
assert result["MODEL_CHOICE"] == "gpt-4.1-nano"
assert result["MAX_TOKENS"] == "1000"
@pytest.mark.asyncio
async def test_get_active_provider_llm(self, mock_supabase_client):
"""Test getting active LLM provider configuration"""
mock_client, mock_table = mock_supabase_client
# Setup cache directly instead of mocking complex database responses
credential_service._cache = {
"LLM_PROVIDER": "openai",
"MODEL_CHOICE": "gpt-4.1-nano",
"OPENAI_API_KEY": {
"encrypted_value": "encrypted_key",
"is_encrypted": True,
"category": "api_keys",
"description": "API key",
},
}
credential_service._cache_initialized = True
# Mock rag_strategy category response
rag_response = MagicMock()
rag_response.data = [
{
"key": "LLM_PROVIDER",
"value": "openai",
"is_encrypted": False,
"description": "LLM provider",
},
{
"key": "MODEL_CHOICE",
"value": "gpt-4.1-nano",
"is_encrypted": False,
"description": "Model choice",
},
]
mock_table.select().eq().execute.return_value = rag_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
with patch.object(credential_service, "_decrypt_value", return_value="decrypted_key"):
result = await credential_service.get_active_provider("llm")
assert result["provider"] == "openai"
assert result["api_key"] == "decrypted_key"
assert result["chat_model"] == "gpt-4.1-nano"
@pytest.mark.asyncio
async def test_get_active_provider_basic(self, mock_supabase_client):
"""Test basic provider configuration retrieval"""
mock_client, mock_table = mock_supabase_client
# Simple mock response
mock_response = MagicMock()
mock_response.data = []
mock_table.select().eq().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await credential_service.get_active_provider("llm")
# Should return default values when no settings found
assert "provider" in result
assert "api_key" in result
@pytest.mark.asyncio
async def test_initialize_credentials(self, mock_supabase_client, sample_credentials_data):
"""Test initialize_credentials function"""
mock_client, mock_table = mock_supabase_client
# Mock database response
mock_response = MagicMock()
mock_response.data = sample_credentials_data
mock_table.select().execute.return_value = mock_response
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
with patch.object(credential_service, "_decrypt_value", return_value="decrypted_key"):
with patch.dict(os.environ, {}, clear=True): # Clear environment
await initialize_credentials()
# Should have loaded credentials
assert credential_service._cache_initialized is True
# Should have set infrastructure env vars (like OPENAI_API_KEY)
# Note: This tests the logic, actual env var setting depends on implementation
@pytest.mark.asyncio
async def test_error_handling_database_failure(self, mock_supabase_client):
"""Test error handling when database fails"""
mock_client, mock_table = mock_supabase_client
# Mock database error
mock_table.select().eq().execute.side_effect = Exception("Database connection failed")
with patch.object(credential_service, "_get_supabase_client", return_value=mock_client):
result = await credential_service.get_credential("TEST_KEY", "default_value")
assert result == "default_value"
@pytest.mark.asyncio
async def test_encryption_decryption_error_handling(self):
"""Test error handling for encryption/decryption failures"""
# Setup cache with encrypted value that fails to decrypt
encrypted_data = {"encrypted_value": "corrupted_encrypted_value", "is_encrypted": True}
credential_service._cache = {"CORRUPTED_KEY": encrypted_data}
credential_service._cache_initialized = True
with patch.object(
credential_service, "_decrypt_value", side_effect=Exception("Decryption failed")
):
# Should fall back to default when decryption fails
result = await credential_service.get_credential("CORRUPTED_KEY", "fallback_value")
assert result == "fallback_value"
def test_direct_cache_access_fallback(self):
"""Test direct cache access pattern used in converted sync functions"""
# Setup cache
credential_service._cache = {
"MODEL_CHOICE": "gpt-4.1-nano",
"OPENAI_API_KEY": {"encrypted_value": "encrypted_key", "is_encrypted": True},
}
credential_service._cache_initialized = True
# Test simple cache access
if credential_service._cache_initialized and "MODEL_CHOICE" in credential_service._cache:
result = credential_service._cache["MODEL_CHOICE"]
assert result == "gpt-4.1-nano"
# Test encrypted value access
if credential_service._cache_initialized and "OPENAI_API_KEY" in credential_service._cache:
cached_key = credential_service._cache["OPENAI_API_KEY"]
if isinstance(cached_key, dict) and cached_key.get("is_encrypted"):
# Would need to call credential_service._decrypt_value(cached_key["encrypted_value"])
assert cached_key["encrypted_value"] == "encrypted_key"
assert cached_key["is_encrypted"] is True
@pytest.mark.asyncio
async def test_concurrent_access(self):
"""Test concurrent access to credential service"""
credential_service._cache = {"SHARED_KEY": "shared_value"}
credential_service._cache_initialized = True
async def get_credential_task():
return await get_credential("SHARED_KEY", "default")
# Run multiple concurrent requests
tasks = [get_credential_task() for _ in range(10)]
results = await asyncio.gather(*tasks)
# All should return the same value
assert all(result == "shared_value" for result in results)
@pytest.mark.asyncio
async def test_cache_persistence(self):
"""Test that cache persists across calls"""
credential_service._cache = {"PERSISTENT_KEY": "persistent_value"}
credential_service._cache_initialized = True
# First call
result1 = await get_credential("PERSISTENT_KEY", "default")
assert result1 == "persistent_value"
# Second call should use same cache
result2 = await get_credential("PERSISTENT_KEY", "default")
assert result2 == "persistent_value"
assert result1 == result2