"""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_201( 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 == 201, 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