- Add LITELLM = "litellm" to LLMProviderType enum (already in HEAD from prev commit) - Add comment in LLMClient.__init__ explaining standard Bearer auth for LiteLLM - Add regression test test_provider_type_enum_values — guard against value drift - Add test_litellm_client_uses_default_bearer_auth — verify no extra headers - Clean up duplicate @RELATION lines in models.py - All 20 backend + 4 frontend tests pass
213 lines
7.7 KiB
Python
213 lines
7.7 KiB
Python
# region test_llm_provider [TYPE Module]
|
|
# @RELATION: VERIFIES -> [src.services.llm_provider:Module]
|
|
# @SEMANTICS: tests, llm-provider, encryption, contract
|
|
# @PURPOSE: Contract testing for LLMProviderService and EncryptionManager
|
|
# endregion test_llm_provider
|
|
|
|
import os
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.models.llm import LLMProvider
|
|
from src.plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
|
from src.services.llm_provider import EncryptionManager, LLMProviderService
|
|
|
|
# region _test_encryption_key_fixture [TYPE Global]
|
|
# @PURPOSE: Ensure encryption-dependent provider tests run with a valid Fernet key.
|
|
# @RELATION: DEPENDS_ON -> [pytest:Module]
|
|
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
|
# endregion _test_encryption_key_fixture
|
|
|
|
|
|
# region test_provider_type_enum_values [TYPE Function]
|
|
# @RELATION: VERIFIES -> [LLMProviderType]
|
|
# @PURPOSE: Regression guard — prevent accidental value drift when enum variants are added or renamed.
|
|
# @INVARIANT: Every LLMProviderType member must have a value matching its lowercase name.
|
|
def test_provider_type_enum_values():
|
|
"""Verify all LLMProviderType enum values match their expected strings."""
|
|
assert LLMProviderType.OPENAI.value == "openai"
|
|
assert LLMProviderType.OPENROUTER.value == "openrouter"
|
|
assert LLMProviderType.KILO.value == "kilo"
|
|
assert LLMProviderType.LITELLM.value == "litellm"
|
|
# If new providers are added, extend this list — enum value drift breaks string comparisons
|
|
# across executor.py, preview.py, ProviderConfig.svelte, and the DB layer.
|
|
expected_count = 4
|
|
assert len(LLMProviderType) == expected_count, (
|
|
f"Expected {expected_count} provider types, got {len(LLMProviderType)}. "
|
|
"If you added a new one, add its value assertion above and update expected_count."
|
|
)
|
|
# endregion test_provider_type_enum_values
|
|
|
|
|
|
# @TEST_CONTRACT: EncryptionManagerModel -> Invariants
|
|
# @TEST_INVARIANT: symmetric_encryption
|
|
# region test_encryption_cycle [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Verify EncryptionManager round-trip encryption/decryption invariant for non-empty secrets.
|
|
def test_encryption_cycle():
|
|
"""Verify encrypted data can be decrypted back to original string."""
|
|
manager = EncryptionManager()
|
|
original = "secret_api_key_123"
|
|
encrypted = manager.encrypt(original)
|
|
assert encrypted != original
|
|
assert manager.decrypt(encrypted) == original
|
|
|
|
|
|
# @TEST_EDGE: empty_string_encryption
|
|
# endregion test_encryption_cycle
|
|
|
|
|
|
# region test_empty_string_encryption [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Verify EncryptionManager preserves empty-string payloads through encrypt/decrypt cycle.
|
|
def test_empty_string_encryption():
|
|
manager = EncryptionManager()
|
|
original = ""
|
|
encrypted = manager.encrypt(original)
|
|
assert manager.decrypt(encrypted) == ""
|
|
|
|
|
|
# @TEST_EDGE: decrypt_invalid_data
|
|
# endregion test_empty_string_encryption
|
|
|
|
|
|
# region test_decrypt_invalid_data [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Ensure decrypt rejects invalid ciphertext input by raising an exception.
|
|
def test_decrypt_invalid_data():
|
|
manager = EncryptionManager()
|
|
with pytest.raises(Exception):
|
|
manager.decrypt("not-encrypted-string")
|
|
|
|
|
|
# @TEST_FIXTURE: mock_db_session
|
|
# endregion test_decrypt_invalid_data
|
|
|
|
|
|
# region mock_db [TYPE Fixture]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: MagicMock(spec=Session) fixture providing a constrained DB session double for LLMProviderService tests.
|
|
# @INVARIANT: Chained calls beyond Session spec create unconstrained intermediate mocks; only top-level query/add/commit are spec-enforced.
|
|
@pytest.fixture
|
|
def mock_db():
|
|
# @RISK: query() returns unconstrained MagicMock — chain beyond query() has no spec protection. Consider create_autospec(Session) for full chain safety.
|
|
return MagicMock(spec=Session)
|
|
|
|
|
|
# endregion mock_db
|
|
|
|
|
|
# region service [TYPE Fixture]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: LLMProviderService fixture wired to mock_db for provider CRUD tests.
|
|
@pytest.fixture
|
|
def service(mock_db):
|
|
return LLMProviderService(db=mock_db)
|
|
|
|
|
|
# endregion service
|
|
|
|
|
|
# region test_get_all_providers [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Verify provider list retrieval issues query/all calls on the backing DB session.
|
|
def test_get_all_providers(service, mock_db):
|
|
service.get_all_providers()
|
|
mock_db.query.assert_called()
|
|
mock_db.query().all.assert_called()
|
|
|
|
|
|
# endregion test_get_all_providers
|
|
|
|
|
|
# region test_create_provider [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Ensure provider creation persists entity and stores API key in encrypted form.
|
|
def test_create_provider(service, mock_db):
|
|
config = LLMProviderConfig(
|
|
provider_type=LLMProviderType.OPENAI,
|
|
name="Test OpenAI",
|
|
base_url="https://api.openai.com",
|
|
api_key="sk-test",
|
|
default_model="gpt-4",
|
|
is_active=True,
|
|
)
|
|
|
|
provider = service.create_provider(config)
|
|
|
|
mock_db.add.assert_called()
|
|
mock_db.commit.assert_called()
|
|
# Verify API key was encrypted
|
|
assert provider.api_key != "sk-test"
|
|
# Decrypt to verify it matches
|
|
assert EncryptionManager().decrypt(provider.api_key) == "sk-test"
|
|
|
|
|
|
# endregion test_create_provider
|
|
|
|
|
|
# region test_get_decrypted_api_key [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Verify service decrypts stored provider API key for an existing provider record.
|
|
def test_get_decrypted_api_key(service, mock_db):
|
|
# Setup mock provider
|
|
encrypted_key = EncryptionManager().encrypt("secret-value")
|
|
mock_provider = LLMProvider(id="p1", api_key=encrypted_key)
|
|
mock_db.query().filter().first.return_value = mock_provider
|
|
|
|
key = service.get_decrypted_api_key("p1")
|
|
assert key == "secret-value"
|
|
|
|
|
|
# endregion test_get_decrypted_api_key
|
|
|
|
|
|
# region test_get_decrypted_api_key_not_found [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Verify missing provider lookup returns None instead of attempting decryption.
|
|
def test_get_decrypted_api_key_not_found(service, mock_db):
|
|
mock_db.query().filter().first.return_value = None
|
|
assert service.get_decrypted_api_key("missing") is None
|
|
|
|
|
|
# endregion test_get_decrypted_api_key_not_found
|
|
|
|
|
|
# region test_update_provider_ignores_masked_placeholder_api_key [TYPE Function]
|
|
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
|
# @PURPOSE: Ensure masked placeholder API keys do not overwrite previously encrypted provider secrets.
|
|
def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
|
|
existing_encrypted = EncryptionManager().encrypt("secret-value")
|
|
mock_provider = LLMProvider(
|
|
id="p1",
|
|
provider_type="openai",
|
|
name="Existing",
|
|
base_url="https://api.openai.com/v1",
|
|
api_key=existing_encrypted,
|
|
default_model="gpt-4o",
|
|
is_active=True,
|
|
)
|
|
mock_db.query().filter().first.return_value = mock_provider
|
|
config = LLMProviderConfig(
|
|
id="p1",
|
|
provider_type=LLMProviderType.OPENAI,
|
|
name="Existing",
|
|
base_url="https://api.openai.com/v1",
|
|
api_key="********",
|
|
default_model="gpt-4o",
|
|
is_active=False,
|
|
)
|
|
|
|
updated = service.update_provider("p1", config)
|
|
|
|
assert updated is mock_provider
|
|
assert updated.api_key == existing_encrypted
|
|
assert EncryptionManager().decrypt(updated.api_key) == "secret-value"
|
|
assert updated.is_active is False
|
|
|
|
|
|
# endregion test_update_provider_ignores_masked_placeholder_api_key
|