"""Integration tests for the user admin routes (U3). Uses FastAPI TestClient with a test app that mounts only the ``admin_router`` from ``routes.admin``. The ``_require_admin`` dependency is overridden via ``app.dependency_overrides`` so the tests don't need real JWTs — they can simulate admin and non-admin callers directly. The SessionService singleton is also installed against the temp DB so that ``reset_password`` can revoke sessions end-to-end. """ from __future__ import annotations import sqlite3 import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any import pytest from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from agentkit.server.auth.models import init_auth_db from agentkit.server.auth.session_service import SessionService, set_session_service from agentkit.server.routes import admin as admin_routes_module # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture async def tmp_auth_db(tmp_path: Path) -> Path: db_path = tmp_path / "admin_users.db" await init_auth_db(db_path) return db_path @pytest.fixture def session_service(tmp_auth_db: Path): """Install a SessionService singleton backed by the temp DB. Required so that ``UserService.reset_password`` can find the SessionService via ``get_session_service()`` and revoke sessions. """ svc = SessionService(db_path=tmp_auth_db) set_session_service(svc) yield svc set_session_service(None) def _make_admin_user() -> dict[str, Any]: return {"user_id": "admin-1", "username": "admin", "role": "admin"} @pytest.fixture def admin_app(tmp_auth_db: Path) -> FastAPI: """A minimal FastAPI app with only the admin router mounted. The ``_require_admin`` dependency is overridden to return a fake admin user. Individual tests can re-override it to simulate a non-admin. """ app = FastAPI() app.state.auth_db_path = str(tmp_auth_db) app.include_router(admin_routes_module.admin_router, prefix="/api/v1") # Default: allow admin access. app.dependency_overrides[admin_routes_module._require_admin] = lambda: _make_admin_user() return app @pytest.fixture def admin_client( admin_app: FastAPI, session_service: SessionService ) -> TestClient: """TestClient with admin access and SessionService installed. The ``session_service`` fixture is listed as a dependency so that the singleton is installed before any request runs. """ return TestClient(admin_app) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _create_department(client: TestClient, name: str, description: str = "") -> dict: resp = client.post( "/api/v1/admin/departments", json={"name": name, "description": description}, ) assert resp.status_code == 201, resp.text return resp.json() def _create_user( client: TestClient, *, username: str = "alice", email: str = "alice@example.com", password: str = "Secret123!", role: str = "member", department_ids: list[str] | None = None, ) -> dict: payload: dict[str, Any] = { "username": username, "email": email, "password": password, "role": role, } if department_ids is not None: payload["department_ids"] = department_ids resp = client.post("/api/v1/admin/users", json=payload) assert resp.status_code == 201, resp.text return resp.json() def _insert_session(db_path: Path, user_id: str, session_id: str | None = None) -> str: """Insert a minimal active auth_sessions row synchronously.""" session_id = session_id or str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() with sqlite3.connect(str(db_path)) as db: db.execute( "INSERT INTO auth_sessions " "(id, user_id, refresh_token_hash, device_fingerprint, device_label, " " ip, user_agent, auth_provider, created_at, last_active_at, expires_at, " " revoked, revoked_reason, previous_session_id) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( session_id, user_id, f"hash-{session_id[:8]}", "fp-test", "Test device", "127.0.0.1", "test-agent", "local", now, now, now, 0, None, None, ), ) db.commit() return session_id def _count_active_sessions(db_path: Path, user_id: str) -> int: with sqlite3.connect(str(db_path)) as db: cursor = db.execute( "SELECT COUNT(*) FROM auth_sessions WHERE user_id = ? AND revoked = 0", (user_id,), ) return int(cursor.fetchone()[0]) # --------------------------------------------------------------------------- # POST /admin/users # --------------------------------------------------------------------------- class TestCreateUser: def test_create_returns_201_with_user_dict(self, admin_client: TestClient): resp = admin_client.post( "/api/v1/admin/users", json={ "username": "alice", "email": "alice@example.com", "password": "Secret123!", "role": "member", }, ) assert resp.status_code == 201 body = resp.json() assert body["id"] assert body["username"] == "alice" assert body["email"] == "alice@example.com" assert body["role"] == "member" assert body["is_active"] is True assert body["departments"] == [] # password_hash must NOT be in the response. assert "password_hash" not in body def test_create_with_department_ids_assigns_departments( self, admin_client: TestClient ): eng = _create_department(admin_client, "Engineering") resp = admin_client.post( "/api/v1/admin/users", json={ "username": "alice", "email": "alice@example.com", "password": "Secret123!", "department_ids": [eng["id"]], }, ) assert resp.status_code == 201 body = resp.json() assert len(body["departments"]) == 1 assert body["departments"][0]["id"] == eng["id"] def test_create_duplicate_username_returns_409(self, admin_client: TestClient): _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.post( "/api/v1/admin/users", json={ "username": "alice", "email": "other@example.com", "password": "Secret123!", }, ) assert resp.status_code == 409 def test_create_duplicate_email_returns_409(self, admin_client: TestClient): _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.post( "/api/v1/admin/users", json={ "username": "alice2", "email": "alice@example.com", "password": "Secret123!", }, ) assert resp.status_code == 409 def test_create_with_nonexistent_department_returns_404( self, admin_client: TestClient ): resp = admin_client.post( "/api/v1/admin/users", json={ "username": "alice", "email": "alice@example.com", "password": "Secret123!", "department_ids": [str(uuid.uuid4())], }, ) assert resp.status_code == 404 def test_non_admin_returns_403(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/users", json={ "username": "alice", "email": "alice@example.com", "password": "Secret123!", }, ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # GET /admin/users # --------------------------------------------------------------------------- class TestListUsers: def test_list_returns_all_users(self, admin_client: TestClient): _create_user(admin_client, username="alice", email="alice@example.com") _create_user(admin_client, username="bob", email="bob@example.com") resp = admin_client.get("/api/v1/admin/users") assert resp.status_code == 200 names = {u["username"] for u in resp.json()} assert names == {"alice", "bob"} def test_list_excludes_inactive_when_asked(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") _create_user(admin_client, username="bob", email="bob@example.com") # Soft-delete alice. admin_client.delete(f"/api/v1/admin/users/{alice['id']}") resp = admin_client.get( "/api/v1/admin/users", params={"include_inactive": False} ) assert resp.status_code == 200 names = {u["username"] for u in resp.json()} assert names == {"bob"} def test_list_filtered_by_department(self, admin_client: TestClient): eng = _create_department(admin_client, "Engineering") hr = _create_department(admin_client, "HR") alice = _create_user( admin_client, username="alice", email="alice@example.com" ) bob = _create_user(admin_client, username="bob", email="bob@example.com") admin_client.post(f"/api/v1/admin/users/{alice['id']}/departments/{eng['id']}") admin_client.post(f"/api/v1/admin/users/{bob['id']}/departments/{hr['id']}") resp = admin_client.get( "/api/v1/admin/users", params={"department_id": eng["id"]} ) assert resp.status_code == 200 users = resp.json() assert len(users) == 1 assert users[0]["username"] == "alice" def test_non_admin_returns_403(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/users") assert resp.status_code == 403 # --------------------------------------------------------------------------- # GET /admin/users/{user_id} # --------------------------------------------------------------------------- class TestGetUser: def test_get_returns_user_with_departments(self, admin_client: TestClient): eng = _create_department(admin_client, "Engineering") alice = _create_user( admin_client, username="alice", email="alice@example.com", department_ids=[eng["id"]], ) resp = admin_client.get(f"/api/v1/admin/users/{alice['id']}") assert resp.status_code == 200 body = resp.json() assert body["username"] == "alice" assert len(body["departments"]) == 1 assert body["departments"][0]["name"] == "Engineering" def test_get_unknown_id_returns_404(self, admin_client: TestClient): resp = admin_client.get(f"/api/v1/admin/users/{uuid.uuid4()}") assert resp.status_code == 404 def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.get(f"/api/v1/admin/users/{uuid.uuid4()}") assert resp.status_code == 403 # --------------------------------------------------------------------------- # PATCH /admin/users/{user_id} # --------------------------------------------------------------------------- class TestUpdateUser: def test_update_role(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.patch( f"/api/v1/admin/users/{alice['id']}", json={"role": "admin"}, ) assert resp.status_code == 200 assert resp.json()["role"] == "admin" def test_update_is_active(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.patch( f"/api/v1/admin/users/{alice['id']}", json={"is_active": False}, ) assert resp.status_code == 200 assert resp.json()["is_active"] is False def test_update_terminal_authorized_flags(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.patch( f"/api/v1/admin/users/{alice['id']}", json={ "is_terminal_authorized": True, "is_server_terminal_authorized": True, }, ) assert resp.status_code == 200 body = resp.json() assert body["is_terminal_authorized"] is True assert body["is_server_terminal_authorized"] is True def test_update_unknown_id_returns_404(self, admin_client: TestClient): resp = admin_client.patch( f"/api/v1/admin/users/{uuid.uuid4()}", json={"role": "admin"}, ) assert resp.status_code == 404 def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.patch( f"/api/v1/admin/users/{uuid.uuid4()}", json={"role": "admin"}, ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # DELETE /admin/users/{user_id} # --------------------------------------------------------------------------- class TestDeleteUser: def test_delete_returns_200(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.delete(f"/api/v1/admin/users/{alice['id']}") assert resp.status_code == 200 assert resp.json() == {"deleted": True} def test_delete_is_soft(self, admin_client: TestClient, tmp_auth_db: Path): alice = _create_user(admin_client, username="alice", email="alice@example.com") admin_client.delete(f"/api/v1/admin/users/{alice['id']}") # The row must still exist (soft delete). with sqlite3.connect(str(tmp_auth_db)) as db: cursor = db.execute( "SELECT is_active FROM users WHERE id = ?", (alice["id"],) ) row = cursor.fetchone() assert row is not None assert bool(row[0]) is False def test_delete_unknown_id_returns_404(self, admin_client: TestClient): resp = admin_client.delete(f"/api/v1/admin/users/{uuid.uuid4()}") assert resp.status_code == 404 def test_delete_already_inactive_returns_404(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") # First delete succeeds. first = admin_client.delete(f"/api/v1/admin/users/{alice['id']}") assert first.status_code == 200 # Second delete on the same (now-inactive) user returns 404. second = admin_client.delete(f"/api/v1/admin/users/{alice['id']}") assert second.status_code == 404 def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.delete(f"/api/v1/admin/users/{uuid.uuid4()}") assert resp.status_code == 403 # --------------------------------------------------------------------------- # POST /admin/users/{user_id}/reset-password # --------------------------------------------------------------------------- class TestResetPassword: def test_reset_returns_200(self, admin_client: TestClient): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.post( f"/api/v1/admin/users/{alice['id']}/reset-password", json={"new_password": "NewSecret456!"}, ) assert resp.status_code == 200 assert resp.json() == {"reset": True} def test_reset_revokes_sessions( self, admin_client: TestClient, tmp_auth_db: Path ): alice = _create_user(admin_client, username="alice", email="alice@example.com") _insert_session(tmp_auth_db, alice["id"]) _insert_session(tmp_auth_db, alice["id"]) assert _count_active_sessions(tmp_auth_db, alice["id"]) == 2 resp = admin_client.post( f"/api/v1/admin/users/{alice['id']}/reset-password", json={"new_password": "NewSecret456!"}, ) assert resp.status_code == 200 assert _count_active_sessions(tmp_auth_db, alice["id"]) == 0 def test_reset_unknown_id_returns_404(self, admin_client: TestClient): resp = admin_client.post( f"/api/v1/admin/users/{uuid.uuid4()}/reset-password", json={"new_password": "NewSecret456!"}, ) assert resp.status_code == 404 def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.post( f"/api/v1/admin/users/{uuid.uuid4()}/reset-password", json={"new_password": "NewSecret456!"}, ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # POST /admin/users/{user_id}/departments/{department_id} # --------------------------------------------------------------------------- class TestAssignDepartment: def test_assign_returns_201(self, admin_client: TestClient): eng = _create_department(admin_client, "Engineering") alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.post( f"/api/v1/admin/users/{alice['id']}/departments/{eng['id']}" ) assert resp.status_code == 201 assert resp.json() == {"assigned": True} def test_assign_duplicate_returns_409(self, admin_client: TestClient): eng = _create_department(admin_client, "Engineering") alice = _create_user(admin_client, username="alice", email="alice@example.com") admin_client.post(f"/api/v1/admin/users/{alice['id']}/departments/{eng['id']}") resp = admin_client.post( f"/api/v1/admin/users/{alice['id']}/departments/{eng['id']}" ) assert resp.status_code == 409 def test_assign_nonexistent_department_returns_404( self, admin_client: TestClient ): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.post( f"/api/v1/admin/users/{alice['id']}/departments/{uuid.uuid4()}" ) assert resp.status_code == 404 def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.post( f"/api/v1/admin/users/{uuid.uuid4()}/departments/{uuid.uuid4()}" ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # DELETE /admin/users/{user_id}/departments/{department_id} # --------------------------------------------------------------------------- class TestRemoveDepartment: def test_remove_returns_200(self, admin_client: TestClient): eng = _create_department(admin_client, "Engineering") alice = _create_user( admin_client, username="alice", email="alice@example.com", department_ids=[eng["id"]], ) resp = admin_client.delete( f"/api/v1/admin/users/{alice['id']}/departments/{eng['id']}" ) assert resp.status_code == 200 assert resp.json() == {"removed": True} def test_remove_nonexistent_assignment_returns_404( self, admin_client: TestClient ): eng = _create_department(admin_client, "Engineering") alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.delete( f"/api/v1/admin/users/{alice['id']}/departments/{eng['id']}" ) assert resp.status_code == 404 def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.delete( f"/api/v1/admin/users/{uuid.uuid4()}/departments/{uuid.uuid4()}" ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # GET /admin/users/{user_id}/departments # --------------------------------------------------------------------------- class TestListUserDepartments: def test_list_returns_departments(self, admin_client: TestClient): eng = _create_department(admin_client, "Engineering") hr = _create_department(admin_client, "HR") alice = _create_user( admin_client, username="alice", email="alice@example.com", department_ids=[eng["id"], hr["id"]], ) resp = admin_client.get(f"/api/v1/admin/users/{alice['id']}/departments") assert resp.status_code == 200 depts = resp.json() assert len(depts) == 2 names = {d["name"] for d in depts} assert names == {"Engineering", "HR"} def test_list_returns_empty_for_user_with_no_departments( self, admin_client: TestClient ): alice = _create_user(admin_client, username="alice", email="alice@example.com") resp = admin_client.get(f"/api/v1/admin/users/{alice['id']}/departments") assert resp.status_code == 200 assert resp.json() == [] def test_non_admin_returns_403(self, admin_app: FastAPI): admin_app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(admin_app) resp = client.get(f"/api/v1/admin/users/{uuid.uuid4()}/departments") assert resp.status_code == 403 # --------------------------------------------------------------------------- # Helpers for non-admin simulation # --------------------------------------------------------------------------- def _raise_forbidden() -> dict[str, Any]: """Dependency override that simulates a non-admin (403) response.""" raise HTTPException(status_code=403, detail="Admin permission required")