420 lines
14 KiB
Python
420 lines
14 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.api.v1 import settings as settings_api
|
|
from app.api.v1.settings import (
|
|
AIProviderIntegrationUpdate,
|
|
BarentsWatchIntegrationUpdate,
|
|
ExternalIntegrationsUpdate,
|
|
OCRIntegrationUpdate,
|
|
WebSearchIntegrationUpdate,
|
|
_build_ai_provider_payload,
|
|
_build_ocr_payload,
|
|
_can_reveal_integration_secrets,
|
|
_ensure_secret_reveal_allowed,
|
|
_mask_secret,
|
|
_normalize_ai_provider_payload,
|
|
_normalize_ocr_payload,
|
|
_record_integration_secret_reveal,
|
|
_resolve_provider_api_key,
|
|
get_runtime_ai_provider_config,
|
|
save_external_integrations_payload,
|
|
)
|
|
from app.services.llm_provider_catalog import get_fallback_llm_provider_preset
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
|
|
env_file = tmp_path / ".env"
|
|
monkeypatch.setattr(settings_api, "AI_PROVIDER_ENV_FILE", env_file)
|
|
for name in (
|
|
"AI_PROVIDER",
|
|
"AI_API_KEY",
|
|
"MINIMAX_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"DEEPSEEK_API_KEY",
|
|
"DASHSCOPE_API_KEY",
|
|
"MOONSHOT_API_KEY",
|
|
"OPENROUTER_API_KEY",
|
|
"OPENCODE_GO_API_KEY",
|
|
):
|
|
monkeypatch.delenv(name, raising=False)
|
|
return env_file
|
|
|
|
|
|
def test_legacy_ai_provider_payload_maps_to_provider_config():
|
|
payload = _normalize_ai_provider_payload(
|
|
{
|
|
"provider": "openai",
|
|
"provider_api": "openai-completions",
|
|
"base_url": "https://api.openai.example/v1",
|
|
"model": "gpt-test",
|
|
"api_key": "old-openai-key",
|
|
"max_tokens": 2048,
|
|
"anthropic_version": "2023-06-01",
|
|
}
|
|
)
|
|
|
|
assert payload["default_provider"] == "openai"
|
|
assert payload["providers"]["openai"]["api_key"] == "old-openai-key"
|
|
assert payload["providers"]["openai"]["model"] == "gpt-test"
|
|
assert payload["providers"]["openai"]["base_url"] == "https://api.openai.example/v1"
|
|
|
|
|
|
def test_provider_key_prefers_specific_env_file_key(isolated_ai_provider_env_file):
|
|
isolated_ai_provider_env_file.write_text(
|
|
"OPENAI_API_KEY=openai-env-file-key\nAI_API_KEY=generic-env-file-key\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
|
|
|
assert value == "openai-env-file-key"
|
|
assert source == "env_file"
|
|
|
|
|
|
def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_file):
|
|
isolated_ai_provider_env_file.write_text(
|
|
"AI_PROVIDER=openai\nAI_API_KEY=generic-env-file-key\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
|
|
|
assert value == "generic-env-file-key"
|
|
assert source == "env_file"
|
|
|
|
|
|
def test_generic_ai_api_key_only_applies_to_selected_env_provider(isolated_ai_provider_env_file):
|
|
isolated_ai_provider_env_file.write_text(
|
|
"AI_PROVIDER=minimax\nAI_API_KEY=generic-env-file-key\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
openai_value, openai_source = _resolve_provider_api_key("openai", {"api_key": ""})
|
|
minimax_value, minimax_source = _resolve_provider_api_key("minimax", {"api_key": ""})
|
|
|
|
assert openai_value == ""
|
|
assert openai_source == ""
|
|
assert minimax_value == "generic-env-file-key"
|
|
assert minimax_source == "env_file"
|
|
|
|
|
|
def test_opencode_go_provider_preset_is_openai_compatible():
|
|
preset = get_fallback_llm_provider_preset("opencode-go")
|
|
|
|
assert preset["label"] == "OpenCode Go"
|
|
assert preset["provider_api"] == "openai-completions"
|
|
assert preset["base_url"] == "https://opencode.ai/zen/go/v1"
|
|
assert preset["model"] == "glm-5.1"
|
|
assert "glm-5.1" in preset["models"]
|
|
assert "deepseek-v4-flash" in preset["models"]
|
|
assert preset["model_provider_apis"]["minimax-m2.7"] == "anthropic-messages"
|
|
assert preset["api_key_env"] == "OPENCODE_GO_API_KEY"
|
|
|
|
|
|
def test_mask_secret_without_prefix_is_fully_masked():
|
|
assert _mask_secret("plainsecret")["preview"] == "***********"
|
|
assert _mask_secret("sk-prefixed")["preview"] == "sk-********"
|
|
|
|
|
|
def test_secret_reveal_permission_is_admin_only():
|
|
assert _can_reveal_integration_secrets(SimpleNamespace(role="super_admin")) is True
|
|
assert _can_reveal_integration_secrets(SimpleNamespace(role="admin")) is True
|
|
assert _can_reveal_integration_secrets(SimpleNamespace(role="viewer")) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_secret_reveal_denial_is_audited(monkeypatch):
|
|
events = []
|
|
|
|
async def fake_record_audit_log(**payload):
|
|
events.append(payload)
|
|
|
|
monkeypatch.setattr(settings_api, "record_audit_log", fake_record_audit_log)
|
|
user = SimpleNamespace(id=7, username="viewer", email="viewer@example.test", role="viewer")
|
|
|
|
with pytest.raises(settings_api.HTTPException) as exc:
|
|
await _ensure_secret_reveal_allowed(
|
|
current_user=user,
|
|
request=None,
|
|
target_id="ai_provider:openai",
|
|
details={"kind": "ai_provider", "provider": "openai"},
|
|
)
|
|
|
|
assert exc.value.status_code == 403
|
|
assert events[0]["action"] == "settings.integration_secret.reveal"
|
|
assert events[0]["result"] == "denied"
|
|
assert events[0]["actor_id"] == 7
|
|
assert events[0]["target_id"] == "ai_provider:openai"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_secret_reveal_audit_does_not_store_plaintext(monkeypatch):
|
|
events = []
|
|
|
|
async def fake_record_audit_log(**payload):
|
|
events.append(payload)
|
|
|
|
monkeypatch.setattr(settings_api, "record_audit_log", fake_record_audit_log)
|
|
user = SimpleNamespace(id=1, username="admin", email="admin@example.test", role="admin")
|
|
|
|
await _record_integration_secret_reveal(
|
|
current_user=user,
|
|
request=None,
|
|
target_id="ai_provider:minimax",
|
|
result="success",
|
|
details={
|
|
"kind": "ai_provider",
|
|
"provider": "minimax",
|
|
"api_key_configured": True,
|
|
"api_key_source": "env_file",
|
|
"service_token_configured": True,
|
|
"service_token_source": "runtime",
|
|
},
|
|
)
|
|
|
|
serialized = str(events[0])
|
|
assert "secret-value" not in serialized
|
|
assert events[0]["details"]["api_key_source"] == "env_file"
|
|
assert events[0]["details"]["service_token_configured"] is True
|
|
|
|
|
|
def test_build_payload_updates_only_selected_provider_key():
|
|
current = {
|
|
"ai_provider": {
|
|
"default_provider": "openai",
|
|
"providers": {
|
|
"openai": {
|
|
"provider": "openai",
|
|
"provider_api": "openai-completions",
|
|
"base_url": "https://api.openai.com/v1",
|
|
"model": "gpt-old",
|
|
"api_key": "openai-old-key",
|
|
"max_tokens": 4096,
|
|
"anthropic_version": "2023-06-01",
|
|
},
|
|
"minimax": {
|
|
"provider": "minimax",
|
|
"api_key": "minimax-old-key",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
update = AIProviderIntegrationUpdate(
|
|
provider="openai",
|
|
provider_api="openai-completions",
|
|
base_url="https://api.openai.com/v1",
|
|
model="gpt-new",
|
|
api_key="openai-new-key",
|
|
max_tokens=8192,
|
|
)
|
|
|
|
payload = _build_ai_provider_payload(current, update)
|
|
|
|
assert payload["default_provider"] == "openai"
|
|
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
|
|
assert payload["providers"]["openai"]["model"] == "gpt-new"
|
|
assert payload["providers"]["minimax"]["api_key"] == "minimax-old-key"
|
|
|
|
|
|
def test_build_payload_saves_provider_without_changing_default():
|
|
current = {
|
|
"ai_provider": {
|
|
"default_provider": "minimax",
|
|
"providers": {
|
|
"minimax": {"provider": "minimax", "api_key": "minimax-key"},
|
|
"openai": {"provider": "openai", "api_key": ""},
|
|
},
|
|
}
|
|
}
|
|
update = AIProviderIntegrationUpdate(
|
|
provider="openai",
|
|
provider_api="openai-completions",
|
|
base_url="https://api.openai.com/v1",
|
|
model="gpt-test",
|
|
api_key="openai-new-key",
|
|
)
|
|
|
|
payload = _build_ai_provider_payload(current, update)
|
|
|
|
assert payload["default_provider"] == "minimax"
|
|
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
|
|
|
|
|
|
def test_build_payload_keeps_saved_key_when_preview_submitted():
|
|
current = {
|
|
"ai_provider": {
|
|
"providers": {
|
|
"openai": {
|
|
"provider": "openai",
|
|
"api_key": "sk-old-secret",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
update = AIProviderIntegrationUpdate(
|
|
provider="openai",
|
|
provider_api="openai-completions",
|
|
base_url="https://api.openai.com/v1",
|
|
model="gpt-test",
|
|
api_key="sk-*********",
|
|
)
|
|
|
|
payload = _build_ai_provider_payload(current, update)
|
|
|
|
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
|
|
|
|
|
|
def test_normalize_ocr_payload_adds_defaults():
|
|
payload = _normalize_ocr_payload({})
|
|
|
|
assert payload["enabled"] is False
|
|
assert payload["provider"] == "paddleocr"
|
|
assert payload["languages"] == ["zh", "en"]
|
|
assert payload["output_format"] == "markdown"
|
|
|
|
|
|
def test_build_ocr_payload_keeps_saved_key_when_preview_submitted():
|
|
current = {
|
|
"ocr": {
|
|
"enabled": True,
|
|
"provider": "custom",
|
|
"base_url": "http://localhost:8020",
|
|
"api_key": "ocr-old-secret",
|
|
}
|
|
}
|
|
update = OCRIntegrationUpdate(
|
|
enabled=True,
|
|
provider="custom",
|
|
base_url="http://localhost:8020",
|
|
api_key="**************",
|
|
model="ocr-model",
|
|
languages=["zh", "en"],
|
|
timeout_seconds=45,
|
|
max_file_size_mb=50,
|
|
output_format="json",
|
|
)
|
|
|
|
payload = _build_ocr_payload(current, update)
|
|
|
|
assert payload["api_key"] == "ocr-old-secret"
|
|
assert payload["model"] == "ocr-model"
|
|
assert payload["output_format"] == "json"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
|
|
record = SimpleNamespace(
|
|
payload={
|
|
"ai_provider": {
|
|
"default_provider": "minimax",
|
|
"providers": {
|
|
"openai": {
|
|
"provider": "openai",
|
|
"api_key": "openai-key",
|
|
"provider_api": "openai-completions",
|
|
"base_url": "https://api.openai.com/v1",
|
|
"model": "gpt-test",
|
|
},
|
|
"minimax": {
|
|
"provider": "minimax",
|
|
"api_key": "minimax-key",
|
|
"provider_api": "anthropic-messages",
|
|
"base_url": "https://api.minimaxi.com/anthropic",
|
|
"model": "MiniMax-test",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
)
|
|
|
|
async def fake_get_setting_record(_db, category):
|
|
assert category == "external_integrations"
|
|
return record
|
|
|
|
monkeypatch.setattr(settings_api, "get_setting_record", fake_get_setting_record)
|
|
|
|
runtime_config = await get_runtime_ai_provider_config(object())
|
|
|
|
assert runtime_config["llm_config"]["provider"] == "minimax"
|
|
assert runtime_config["llm_config"]["api_key"] == "minimax-key"
|
|
assert runtime_config["llm_config"]["model"] == "MiniMax-test"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saving_ai_provider_does_not_run_connection_validation(monkeypatch):
|
|
calls = {"validated": 0, "saved": 0}
|
|
|
|
async def fake_get_setting_payload(_db, category):
|
|
assert category == "external_integrations"
|
|
return {
|
|
"ai_provider": {
|
|
"default_provider": "minimax",
|
|
"providers": {
|
|
"minimax": {
|
|
"provider": "minimax",
|
|
"api_key": "minimax-key",
|
|
},
|
|
},
|
|
},
|
|
"web_search": {},
|
|
"ocr": {},
|
|
}
|
|
|
|
async def fake_validate(_payload):
|
|
calls["validated"] += 1
|
|
raise AssertionError("save should not run AI provider connection validation")
|
|
|
|
async def fake_save_setting_payload(_db, category, payload):
|
|
assert category == "external_integrations"
|
|
calls["saved"] += 1
|
|
return payload
|
|
|
|
async def fake_get_barentswatch_config_record(_db):
|
|
return SimpleNamespace(
|
|
endpoint="",
|
|
auth_config={},
|
|
)
|
|
|
|
async def fake_serialize_external_integrations(_db):
|
|
return {"ai_provider": {"default_provider": "minimax"}}
|
|
|
|
monkeypatch.setattr(settings_api, "get_setting_payload", fake_get_setting_payload)
|
|
monkeypatch.setattr(settings_api, "_validate_ai_provider_full_connection", fake_validate)
|
|
monkeypatch.setattr(settings_api, "save_setting_payload", fake_save_setting_payload)
|
|
monkeypatch.setattr(settings_api, "get_barentswatch_config_record", fake_get_barentswatch_config_record)
|
|
monkeypatch.setattr(settings_api, "serialize_external_integrations", fake_serialize_external_integrations)
|
|
|
|
update = ExternalIntegrationsUpdate(
|
|
ai_provider=AIProviderIntegrationUpdate(
|
|
provider="minimax",
|
|
default_provider="minimax",
|
|
provider_api="anthropic-messages",
|
|
base_url="https://api.minimaxi.com/anthropic",
|
|
model="MiniMax-M2.7",
|
|
api_key="sk-new-key",
|
|
),
|
|
barentswatch=BarentsWatchIntegrationUpdate(),
|
|
web_search=WebSearchIntegrationUpdate(),
|
|
ocr=OCRIntegrationUpdate(),
|
|
)
|
|
|
|
async def fake_commit():
|
|
return None
|
|
|
|
async def fake_refresh(_record):
|
|
return None
|
|
|
|
db = SimpleNamespace(
|
|
add=lambda _record: None,
|
|
commit=fake_commit,
|
|
refresh=fake_refresh,
|
|
)
|
|
|
|
await save_external_integrations_payload(db, update)
|
|
|
|
assert calls == {"validated": 0, "saved": 1}
|