523 lines
18 KiB
Python
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
|