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}