fischer-agentkit/tests/integration/admin/test_skill_kb_routes.py

523 lines
18 KiB
Python

"""Integration tests for the skill/KB admin routes (U6).
Uses FastAPI TestClient with a test app that mounts the
``admin_router`` from ``routes.admin`` plus the public ``skills``
router from ``routes.skills`` (to verify disabled-skill filtering).
The ``_require_admin`` dependency is overridden via
``app.dependency_overrides`` so the tests don't need real JWTs.
The :class:`SkillRegistry` is a real instance with a test skill
loaded from a temp YAML file, so import/reload/update endpoints
exercise the real SkillLoader pipeline.
"""
from __future__ import annotations
import os
import uuid
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from agentkit.server.admin.kb_service import set_kb_service
from agentkit.server.admin.skill_service import set_skill_service
from agentkit.server.auth.models import init_auth_db
from agentkit.server.routes import admin as admin_routes_module
from agentkit.server.routes import skills as skills_routes_module
from agentkit.skills.registry import SkillRegistry
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_VALID_SKILL_YAML = """\
name: admin_test_skill
agent_type: simple_generation
version: "1.0.0"
description: "A test skill for admin route testing"
task_mode: llm_generate
execution_mode: direct
max_steps: 1
prompt:
identity: "Test"
instructions: "Handle test"
tools: []
"""
_VALID_SKILL_YAML_2 = """\
name: another_test_skill
agent_type: simple_generation
version: "1.0.0"
description: "Another test skill"
task_mode: llm_generate
execution_mode: direct
max_steps: 1
prompt:
identity: "Test2"
instructions: "Handle test 2"
tools: []
"""
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def tmp_auth_db(tmp_path: Path) -> Path:
db_path = tmp_path / "admin_skill_kb.db"
await init_auth_db(db_path)
return db_path
@pytest.fixture
def skills_dir(tmp_path: Path) -> str:
"""A temp skills directory for YAML files."""
d = tmp_path / "skills"
d.mkdir()
return str(d)
@pytest.fixture
def skill_registry() -> SkillRegistry:
return SkillRegistry()
@pytest.fixture(autouse=True)
def _reset_singletons():
"""Reset SkillService and KbService singletons before/after each test."""
set_skill_service(None)
set_kb_service(None)
yield
set_skill_service(None)
set_kb_service(None)
def _make_admin_user() -> dict[str, Any]:
return {"user_id": "admin-1", "username": "admin", "role": "admin"}
def _raise_forbidden() -> dict[str, Any]:
"""Dependency override that simulates a non-admin (403) response."""
raise HTTPException(status_code=403, detail="Admin permission required")
@pytest.fixture
def admin_app(
tmp_auth_db: Path,
skills_dir: str,
skill_registry: SkillRegistry,
) -> FastAPI:
"""A minimal FastAPI app with admin + skills routers mounted.
The ``_require_admin`` dependency is overridden to return a fake
admin user. The :class:`SkillRegistry` is set on ``app.state`` so
the admin skill endpoints can access it.
"""
app = FastAPI()
app.state.auth_db_path = str(tmp_auth_db)
app.state.skill_registry = skill_registry
# Build a minimal server_config with skill_paths pointing at the
# temp skills_dir, so the admin endpoints write YAML there.
class _FakeServerConfig:
skill_paths = [skills_dir]
app.state.server_config = _FakeServerConfig()
app.include_router(admin_routes_module.admin_router, prefix="/api/v1")
app.include_router(skills_routes_module.router, prefix="/api/v1")
# Default: allow admin access.
app.dependency_overrides[admin_routes_module._require_admin] = lambda: _make_admin_user()
# Override the department context to admin (bypass filtering) so
# GET /skills returns all skills (the disabled filter still applies).
from agentkit.server.admin.context import DepartmentContext
app.dependency_overrides[skills_routes_module.get_department_context] = lambda: (
DepartmentContext(user_id="admin-1", department_ids=[], is_admin=True)
)
return app
@pytest.fixture
def admin_client(admin_app: FastAPI) -> TestClient:
return TestClient(admin_app)
def _write_skill_yaml(skills_dir: str, name: str, content: str) -> str:
"""Write a skill YAML file to the skills dir and return the path."""
path = os.path.join(skills_dir, f"{name}.yaml")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return path
# ---------------------------------------------------------------------------
# Skill enable/disable
# ---------------------------------------------------------------------------
class TestSkillEnableDisable:
def test_disable_skill_returns_200(self, admin_client: TestClient):
resp = admin_client.post("/api/v1/admin/skills/admin_test_skill/disable")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["skill_name"] == "admin_test_skill"
assert body["is_disabled"] is True
def test_disable_then_get_skills_excludes_it(
self,
admin_client: TestClient,
skills_dir: str,
skill_registry: SkillRegistry,
):
# Write a skill YAML and register it.
_write_skill_yaml(skills_dir, "admin_test_skill", _VALID_SKILL_YAML)
from agentkit.skills.loader import SkillLoader
SkillLoader(skill_registry=skill_registry).load_from_file(
os.path.join(skills_dir, "admin_test_skill.yaml")
)
# Verify the skill is initially listed.
resp = admin_client.get("/api/v1/skills")
assert resp.status_code == 200
names = [s["name"] for s in resp.json()]
assert "admin_test_skill" in names
# Disable the skill.
resp = admin_client.post("/api/v1/admin/skills/admin_test_skill/disable")
assert resp.status_code == 200
# GET /skills should now exclude it.
resp = admin_client.get("/api/v1/skills")
assert resp.status_code == 200
names = [s["name"] for s in resp.json()]
assert "admin_test_skill" not in names
def test_enable_skill_returns_200(
self,
admin_client: TestClient,
skills_dir: str,
skill_registry: SkillRegistry,
):
# Write and register the skill, then disable it.
_write_skill_yaml(skills_dir, "admin_test_skill", _VALID_SKILL_YAML)
from agentkit.skills.loader import SkillLoader
SkillLoader(skill_registry=skill_registry).load_from_file(
os.path.join(skills_dir, "admin_test_skill.yaml")
)
admin_client.post("/api/v1/admin/skills/admin_test_skill/disable")
# Enable it.
resp = admin_client.post("/api/v1/admin/skills/admin_test_skill/enable")
assert resp.status_code == 200
assert resp.json()["enabled"] is True
# GET /skills should now include it.
resp = admin_client.get("/api/v1/skills")
names = [s["name"] for s in resp.json()]
assert "admin_test_skill" in names
def test_enable_skill_not_disabled_returns_200(self, admin_client: TestClient):
"""Enabling a skill that wasn't disabled returns enabled=False."""
resp = admin_client.post("/api/v1/admin/skills/never_disabled/enable")
assert resp.status_code == 200
assert resp.json()["enabled"] is False
def test_disable_skill_invalid_name_returns_400(self, admin_client: TestClient):
resp = admin_client.post("/api/v1/admin/skills/Has Spaces/disable")
assert resp.status_code == 400
def test_non_admin_cannot_disable_skill(self, admin_app: FastAPI):
admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden
client = TestClient(admin_app)
resp = client.post("/api/v1/admin/skills/some_skill/disable")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Skill import
# ---------------------------------------------------------------------------
class TestSkillImport:
def test_import_valid_yaml_returns_200(
self,
admin_client: TestClient,
skills_dir: str,
skill_registry: SkillRegistry,
):
resp = admin_client.post(
"/api/v1/admin/skills/import",
json={"yaml_content": _VALID_SKILL_YAML},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "admin_test_skill"
assert os.path.isfile(body["path"])
# Skill should be registered.
assert skill_registry.has_skill("admin_test_skill")
def test_import_invalid_yaml_returns_400(self, admin_client: TestClient):
resp = admin_client.post(
"/api/v1/admin/skills/import",
json={"yaml_content": "not: valid: yaml: ["},
)
assert resp.status_code == 400
def test_import_missing_name_returns_400(self, admin_client: TestClient):
resp = admin_client.post(
"/api/v1/admin/skills/import",
json={"yaml_content": "agent_type: test\n"},
)
assert resp.status_code == 400
def test_import_invalid_skill_name_returns_400(self, admin_client: TestClient):
bad_yaml = 'name: "Has Spaces"\nagent_type: test\n'
resp = admin_client.post(
"/api/v1/admin/skills/import",
json={"yaml_content": bad_yaml},
)
assert resp.status_code == 400
def test_non_admin_cannot_import(self, admin_app: FastAPI):
admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden
client = TestClient(admin_app)
resp = client.post(
"/api/v1/admin/skills/import",
json={"yaml_content": _VALID_SKILL_YAML},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Skill reload
# ---------------------------------------------------------------------------
class TestSkillReload:
def test_reload_existing_skill_returns_200(
self,
admin_client: TestClient,
skills_dir: str,
skill_registry: SkillRegistry,
):
# Write and register the skill first.
_write_skill_yaml(skills_dir, "admin_test_skill", _VALID_SKILL_YAML)
from agentkit.skills.loader import SkillLoader
SkillLoader(skill_registry=skill_registry).load_from_file(
os.path.join(skills_dir, "admin_test_skill.yaml")
)
resp = admin_client.post("/api/v1/admin/skills/admin_test_skill/reload")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "admin_test_skill"
assert body["status"] == "reloaded"
def test_reload_nonexistent_skill_returns_404(self, admin_client: TestClient):
resp = admin_client.post("/api/v1/admin/skills/nonexistent/reload")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Skill update (PATCH)
# ---------------------------------------------------------------------------
class TestSkillUpdate:
def test_update_skill_returns_200(
self,
admin_client: TestClient,
skills_dir: str,
skill_registry: SkillRegistry,
):
# Write and register the skill first.
_write_skill_yaml(skills_dir, "admin_test_skill", _VALID_SKILL_YAML)
from agentkit.skills.loader import SkillLoader
SkillLoader(skill_registry=skill_registry).load_from_file(
os.path.join(skills_dir, "admin_test_skill.yaml")
)
resp = admin_client.patch(
"/api/v1/admin/skills/admin_test_skill",
json={"config": {"description": "Updated via admin API"}},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "updated"
# Verify the YAML file was updated.
import yaml
with open(body["path"], encoding="utf-8") as f:
data = yaml.safe_load(f)
assert data["description"] == "Updated via admin API"
def test_update_nonexistent_skill_returns_404(self, admin_client: TestClient):
resp = admin_client.patch(
"/api/v1/admin/skills/nonexistent",
json={"config": {"description": "x"}},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# KB document endpoints
# ---------------------------------------------------------------------------
class TestKbDocumentRoutes:
def test_list_documents_returns_200(self, admin_client: TestClient):
resp = admin_client.get("/api/v1/admin/kb/documents")
assert resp.status_code == 200, resp.text
body = resp.json()
assert "documents" in body
assert isinstance(body["documents"], list)
def test_upload_document_returns_201_with_department_id(self, admin_client: TestClient):
dept_id = str(uuid.uuid4())
resp = admin_client.post(
"/api/v1/admin/kb/documents",
json={
"filename": "test.txt",
"content": "hello world",
"department_id": dept_id,
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["filename"] == "test.txt"
assert body["department_id"] == dept_id
assert "document_id" in body
def test_upload_document_without_department_id(self, admin_client: TestClient):
resp = admin_client.post(
"/api/v1/admin/kb/documents",
json={"filename": "global.txt", "content": "global content"},
)
assert resp.status_code == 201
body = resp.json()
assert body["department_id"] is None
def test_upload_then_delete_document(self, admin_client: TestClient):
# Upload
resp = admin_client.post(
"/api/v1/admin/kb/documents",
json={"filename": "delete_me.txt", "content": "x"},
)
assert resp.status_code == 201
doc_id = resp.json()["document_id"]
# Delete
resp = admin_client.delete(f"/api/v1/admin/kb/documents/{doc_id}")
assert resp.status_code == 200
assert resp.json() == {"deleted": True}
# Second delete should 404.
resp = admin_client.delete(f"/api/v1/admin/kb/documents/{doc_id}")
assert resp.status_code == 404
def test_delete_nonexistent_document_returns_404(self, admin_client: TestClient):
resp = admin_client.delete("/api/v1/admin/kb/documents/nonexistent-id")
assert resp.status_code == 404
def test_list_documents_filters_by_department_id(self, admin_client: TestClient):
dept_a = str(uuid.uuid4())
dept_b = str(uuid.uuid4())
# Upload 3 docs: one for dept_a, one for dept_b, one global.
admin_client.post(
"/api/v1/admin/kb/documents",
json={"filename": "a.txt", "content": "a", "department_id": dept_a},
)
admin_client.post(
"/api/v1/admin/kb/documents",
json={"filename": "b.txt", "content": "b", "department_id": dept_b},
)
admin_client.post(
"/api/v1/admin/kb/documents",
json={"filename": "global.txt", "content": "g"},
)
# Filter by dept_a: should see dept_a's doc + global doc.
resp = admin_client.get("/api/v1/admin/kb/documents", params={"department_id": dept_a})
assert resp.status_code == 200
docs = resp.json()["documents"]
dept_ids = {d["department_id"] for d in docs}
assert dept_a in dept_ids
assert None in dept_ids # global doc
assert dept_b not in dept_ids
def test_non_admin_cannot_list_documents(self, admin_app: FastAPI):
admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden
client = TestClient(admin_app)
resp = client.get("/api/v1/admin/kb/documents")
assert resp.status_code == 403
def test_non_admin_cannot_upload_document(self, admin_app: FastAPI):
admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden
client = TestClient(admin_app)
resp = client.post(
"/api/v1/admin/kb/documents",
json={"filename": "x.txt", "content": "x"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# KB source sync/rebuild
# ---------------------------------------------------------------------------
class TestKbSourceRoutes:
def test_sync_nonexistent_source_returns_404(self, admin_client: TestClient):
resp = admin_client.post("/api/v1/admin/kb/sources/nonexistent/sync")
assert resp.status_code == 404
def test_rebuild_nonexistent_source_returns_404(self, admin_client: TestClient):
resp = admin_client.post("/api/v1/admin/kb/sources/nonexistent/rebuild")
assert resp.status_code == 404
def test_sync_existing_source_returns_200(self, admin_client: TestClient):
# Add a source via the KbService (bypassing the route layer).
from agentkit.server.admin.kb_service import get_kb_service
svc = get_kb_service()
store = svc._resolve_store()
source = store.add_source("Test Source", "local", {})
resp = admin_client.post(f"/api/v1/admin/kb/sources/{source.id}/sync")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "syncing"
def test_rebuild_existing_source_returns_200(self, admin_client: TestClient):
from agentkit.server.admin.kb_service import get_kb_service
svc = get_kb_service()
store = svc._resolve_store()
source = store.add_source("Test Source", "local", {})
resp = admin_client.post(f"/api/v1/admin/kb/sources/{source.id}/rebuild")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "rebuilding"
def test_non_admin_cannot_sync_source(self, admin_app: FastAPI):
admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden
client = TestClient(admin_app)
resp = client.post("/api/v1/admin/kb/sources/any/sync")
assert resp.status_code == 403