fischer-agentkit/tests/unit/server/test_settings_routes.py

401 lines
14 KiB
Python

"""Tests for Settings API routes"""
from __future__ import annotations
import os
import unittest.mock
import pytest
import yaml
from fastapi import Request
from fastapi.testclient import TestClient
from agentkit.server.app import create_app
from agentkit.server.config import ServerConfig
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def config_file(tmp_path):
"""Create a temporary agentkit.yaml config file."""
config_data = {
"server": {
"host": "0.0.0.0",
"port": 8001,
"workers": 1,
"rate_limit": 60,
"cors_origins": ["*"],
},
"llm": {
"providers": {
"openai": {
"type": "openai",
"api_key": "sk-test-12345678",
"base_url": "https://api.openai.com/v1",
"models": {"gpt-4o": {}},
"max_tokens": 4096,
"timeout": 120.0,
},
"anthropic": {
"type": "anthropic",
"api_key": "sk-ant-test-abcd1234",
"base_url": "https://api.anthropic.com",
"models": {"claude-sonnet-4-20250514": {}},
"max_tokens": 4096,
"timeout": 120.0,
},
},
"model_aliases": {"gpt4": "openai/gpt-4o"},
"fallbacks": {},
},
"skills": {
"paths": ["/tmp/skills"],
"auto_discover": True,
},
"logging": {
"level": "INFO",
"format": "text",
},
"memory": {
"semantic": {
"enabled": True,
"base_url": "http://localhost:8080",
"knowledge_base_ids": ["kb-1", "kb-2"],
"search_mode": "standard",
"top_k": 5,
},
},
}
config_path = tmp_path / "agentkit.yaml"
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
return str(config_path)
@pytest.fixture
def server_config(config_file):
"""Create ServerConfig from the temp config file."""
return ServerConfig.from_yaml(config_file)
@pytest.fixture
def app(server_config):
application = create_app(
server_config=server_config,
)
# Inject a dev-mode admin user so the SYSTEM_CONFIG permission checks
# on PUT endpoints pass. In production, AuthMiddleware sets this from JWT.
@application.middleware("http")
async def _set_dev_admin_user(request: Request, call_next):
request.state.current_user = {
"user_id": "dev-admin",
"username": "dev-admin",
"role": "admin",
"dev_mode": True,
}
return await call_next(request)
return application
@pytest.fixture
def client(app):
return TestClient(app)
# ---------------------------------------------------------------------------
# GET /settings/llm
# ---------------------------------------------------------------------------
class TestGetLlmSettings:
def test_returns_llm_config(self, client):
response = client.get("/api/v1/settings/llm")
assert response.status_code == 200
data = response.json()
assert "providers" in data
assert "model_aliases" in data
assert "fallbacks" in data
def test_api_keys_are_masked(self, client):
response = client.get("/api/v1/settings/llm")
data = response.json()
for provider in data["providers"]:
# API keys should be masked: ****xxxx
api_key = provider["api_key"]
if api_key:
assert api_key.startswith("****")
# Should show only last 4 chars
assert len(api_key) <= 8 # "****" + 4 chars
def test_providers_have_correct_fields(self, client):
response = client.get("/api/v1/settings/llm")
data = response.json()
assert len(data["providers"]) >= 1
provider = data["providers"][0]
assert "name" in provider
assert "type" in provider
assert "api_key" in provider
assert "base_url" in provider
assert "models" in provider
assert "max_tokens" in provider
assert "timeout" in provider
def test_model_aliases_returned(self, client):
"""model_aliases are built from model alias fields in providers."""
response = client.get("/api/v1/settings/llm")
data = response.json()
# model_aliases is populated from model "alias" fields, not the top-level key
assert isinstance(data["model_aliases"], dict)
# ---------------------------------------------------------------------------
# PUT /settings/llm
# ---------------------------------------------------------------------------
class TestUpdateLlmSettings:
def test_update_llm_config(self, client):
response = client.put(
"/api/v1/settings/llm",
json={
"providers": [
{
"name": "openai",
"type": "openai",
"api_key": "sk-new-key-9999",
"base_url": "https://api.openai.com/v1",
"models": {"gpt-4o-mini": {}},
"max_tokens": 2048,
"timeout": 60.0,
}
],
},
)
assert response.status_code == 200
data = response.json()
assert "providers" in data
def test_update_preserves_existing_key_when_masked(self, client, config_file):
"""When user sends back a masked key (****xxxx), the real key should be preserved."""
response = client.put(
"/api/v1/settings/llm",
json={
"providers": [
{
"name": "openai",
"type": "openai",
"api_key": "****5678", # masked key
"base_url": "https://api.openai.com/v1",
}
],
},
)
assert response.status_code == 200
# Verify the config file still has the original key
with open(config_file, encoding="utf-8") as f:
saved = yaml.safe_load(f)
# The original key should be preserved, not replaced with the masked value
assert saved["llm"]["providers"]["openai"]["api_key"] == "sk-test-12345678"
def test_update_writes_to_config_file(self, client, config_file):
response = client.put(
"/api/v1/settings/llm",
json={
"model_aliases": {"mini": "openai/gpt-4o-mini"},
},
)
assert response.status_code == 200
with open(config_file, encoding="utf-8") as f:
saved = yaml.safe_load(f)
assert saved["llm"]["model_aliases"]["mini"] == "openai/gpt-4o-mini"
# ---------------------------------------------------------------------------
# GET /settings/skills
# ---------------------------------------------------------------------------
class TestGetSkillsSettings:
def test_returns_skills_config(self, client):
response = client.get("/api/v1/settings/skills")
assert response.status_code == 200
data = response.json()
assert "paths" in data
assert "auto_discover" in data
def test_skills_paths_returned(self, client):
response = client.get("/api/v1/settings/skills")
data = response.json()
assert "/tmp/skills" in data["paths"]
assert data["auto_discover"] is True
# ---------------------------------------------------------------------------
# PUT /settings/skills
# ---------------------------------------------------------------------------
class TestUpdateSkillsSettings:
def test_update_skills_paths(self, client, config_file):
response = client.put(
"/api/v1/settings/skills",
json={
"paths": ["/tmp/skills", "/tmp/new-skills"],
"auto_discover": False,
},
)
assert response.status_code == 200
with open(config_file, encoding="utf-8") as f:
saved = yaml.safe_load(f)
assert "/tmp/new-skills" in saved["skills"]["paths"]
assert saved["skills"]["auto_discover"] is False
# ---------------------------------------------------------------------------
# GET /settings/kb
# ---------------------------------------------------------------------------
class TestGetKbSettings:
def test_returns_kb_config(self, client):
response = client.get("/api/v1/settings/kb")
assert response.status_code == 200
data = response.json()
assert "memory" in data
def test_kb_memory_config_returned(self, client):
response = client.get("/api/v1/settings/kb")
data = response.json()
assert "semantic" in data["memory"]
assert data["memory"]["semantic"]["enabled"] is True
# ---------------------------------------------------------------------------
# PUT /settings/kb
# ---------------------------------------------------------------------------
class TestUpdateKbSettings:
def test_update_kb_config(self, client, config_file):
response = client.put(
"/api/v1/settings/kb",
json={
"memory": {
"semantic": {
"enabled": True,
"base_url": "http://localhost:9090",
"knowledge_base_ids": ["kb-new"],
"search_mode": "rerank",
"top_k": 10,
},
},
},
)
assert response.status_code == 200
with open(config_file, encoding="utf-8") as f:
saved = yaml.safe_load(f)
assert saved["memory"]["semantic"]["base_url"] == "http://localhost:9090"
# ---------------------------------------------------------------------------
# GET /settings/general
# ---------------------------------------------------------------------------
class TestGetGeneralSettings:
def test_returns_general_config(self, client):
response = client.get("/api/v1/settings/general")
assert response.status_code == 200
data = response.json()
assert "host" in data
assert "port" in data
assert "workers" in data
assert "log_level" in data
assert "log_format" in data
assert "rate_limit" in data
assert "cors_origins" in data
def test_general_config_values(self, client):
response = client.get("/api/v1/settings/general")
data = response.json()
assert data["host"] == "0.0.0.0"
assert data["port"] == 8001
assert data["log_level"] == "INFO"
assert data["rate_limit"] == 60
# ---------------------------------------------------------------------------
# PUT /settings/general
# ---------------------------------------------------------------------------
class TestUpdateGeneralSettings:
def test_update_general_config(self, client, config_file):
response = client.put(
"/api/v1/settings/general",
json={
"log_level": "DEBUG",
"rate_limit": 100,
},
)
assert response.status_code == 200
with open(config_file, encoding="utf-8") as f:
saved = yaml.safe_load(f)
assert saved["logging"]["level"] == "DEBUG"
assert saved["server"]["rate_limit"] == 100
def test_update_cors_origins(self, client, config_file):
response = client.put(
"/api/v1/settings/general",
json={
"cors_origins": ["http://localhost:3000", "http://localhost:5173"],
},
)
assert response.status_code == 200
with open(config_file, encoding="utf-8") as f:
saved = yaml.safe_load(f)
assert "http://localhost:3000" in saved["server"]["cors_origins"]
# ---------------------------------------------------------------------------
# No config file path available
# ---------------------------------------------------------------------------
class TestNoConfigPath:
def test_put_without_config_path_returns_400(self):
"""When server_config has no _config_path, PUT should return 400."""
from agentkit.llm.gateway import LLMGateway
# Mock os.environ to prevent auto-discovery of agentkit.yaml in CWD
with unittest.mock.patch.dict(os.environ, {}, clear=True):
# Also ensure no agentkit.yaml is found in CWD
with unittest.mock.patch("pathlib.Path.exists", return_value=False):
app = create_app(llm_gateway=LLMGateway())
# server_config is None in this case
# Inject dev-admin user so the SYSTEM_CONFIG permission check passes
# and the request reaches the config-path check (which returns 400).
@app.middleware("http")
async def _set_dev_admin_user(request: Request, call_next):
request.state.current_user = {
"user_id": "dev-admin",
"username": "dev-admin",
"role": "admin",
"dev_mode": True,
}
return await call_next(request)
client = TestClient(app)
response = client.put(
"/api/v1/settings/llm",
json={"providers": [{"name": "test", "type": "openai"}]},
)
assert response.status_code == 400