- Remove _ProviderConfigStub from services/ai_config.py (replaced by real ProviderConfig) - Add module-level import: from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS - load_provider_config() now returns ProviderConfig (no lazy import inside function body) - classifier.py: replace inline _settings dict with load_provider_config(session) call (D-06) - Per-user override path builds ProviderConfig from PROVIDER_DEFAULTS (no api_key — T-07-06) - Fallback to app_settings defaults when load_provider_config returns None (D-15) - Truncation delegated to provider._truncate() — no more text slices in classifier (D-12/D-13) - Promote test_api_key_encrypt_decrypt to passing (round-trip + domain salt isolation) - Update test_classifier.py: test_per_user_provider + test_default_provider_fallback use ProviderConfig assertions
101 lines
4.0 KiB
Python
101 lines
4.0 KiB
Python
"""
|
|
Tests for Phase 7 AI config service.
|
|
|
|
Covers:
|
|
- D-04: load_provider_config() reads from system_settings table
|
|
- D-05: API key HKDF encryption round-trip
|
|
|
|
Plan 07-03 promotes: test_api_key_encrypt_decrypt, test_load_provider_config.
|
|
"""
|
|
import os
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# test_api_key_encrypt_decrypt — D-05 HKDF encryption round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_api_key_encrypt_decrypt():
|
|
"""encrypt_api_key + decrypt_api_key round-trip returns original plaintext.
|
|
|
|
Also verifies domain salt isolation: different provider_ids produce different
|
|
ciphertexts from the same master key and plaintext (T-07-02).
|
|
"""
|
|
from services.ai_config import encrypt_api_key, decrypt_api_key
|
|
|
|
# Use a 32-byte master key (CLOUD_CREDS_KEY is Base64-encoded in production,
|
|
# but the helpers accept raw bytes — use ASCII bytes for simplicity here)
|
|
master_key = b"a" * 32 # 32 bytes deterministic test key
|
|
|
|
# Round-trip: encrypt then decrypt must return original plaintext
|
|
plaintext = "sk-test-api-key-abc123"
|
|
ciphertext = encrypt_api_key(master_key, "openai", plaintext)
|
|
recovered = decrypt_api_key(master_key, "openai", ciphertext)
|
|
assert recovered == plaintext, "Decrypted value must match original plaintext"
|
|
|
|
# Domain salt isolation: same key + same plaintext, different provider_id → different ciphertext
|
|
ciphertext_anthropic = encrypt_api_key(master_key, "anthropic", plaintext)
|
|
assert ciphertext != ciphertext_anthropic, (
|
|
"Different provider_ids must produce different ciphertexts "
|
|
"(HKDF salt isolation — T-07-02)"
|
|
)
|
|
|
|
# Cross-provider decrypt must fail (Fernet raises InvalidToken)
|
|
from cryptography.fernet import InvalidToken
|
|
with pytest.raises(InvalidToken):
|
|
decrypt_api_key(master_key, "anthropic", ciphertext) # openai key, anthropic provider_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# test_load_provider_config — D-04 DB integration (skipped without PostgreSQL)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.skipif(
|
|
not os.getenv("INTEGRATION"),
|
|
reason="needs PostgreSQL — set INTEGRATION=1 to run",
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_load_provider_config(db_session):
|
|
"""load_provider_config() reads the active row from system_settings and decrypts the api_key.
|
|
|
|
Requires a live PostgreSQL session (db_session fixture from conftest.py).
|
|
Run with: INTEGRATION=1 pytest tests/test_ai_config.py::test_load_provider_config
|
|
"""
|
|
pytest.importorskip("psycopg")
|
|
|
|
from services.ai_config import encrypt_api_key, load_provider_config
|
|
from db.models import SystemSettings
|
|
import uuid
|
|
|
|
master_key = b"b" * 32 # deterministic test key
|
|
test_api_key = "sk-integration-test-key"
|
|
provider_id = f"test-provider-{uuid.uuid4().hex[:8]}"
|
|
|
|
# Encrypt the API key
|
|
api_key_enc = encrypt_api_key(master_key, provider_id, test_api_key)
|
|
|
|
# Insert a SystemSettings row with is_active=True
|
|
row = SystemSettings(
|
|
provider_id=provider_id,
|
|
api_key_enc=api_key_enc,
|
|
base_url="https://api.example.com/v1",
|
|
model_name="test-model",
|
|
context_chars=64000,
|
|
is_active=True,
|
|
)
|
|
db_session.add(row)
|
|
await db_session.flush()
|
|
|
|
# Patch settings.cloud_creds_key to match our test master key
|
|
from unittest.mock import patch
|
|
with patch("services.ai_config.settings") as mock_settings:
|
|
mock_settings.cloud_creds_key = master_key.decode("utf-8")
|
|
result = await load_provider_config(db_session)
|
|
|
|
assert result is not None, "load_provider_config must return a ProviderConfig"
|
|
assert result.provider_id == provider_id
|
|
assert result.api_key == test_api_key, "api_key must be decrypted correctly"
|
|
assert result.base_url == "https://api.example.com/v1"
|
|
assert result.model == "test-model"
|
|
assert result.context_chars == 64000
|