feat(admin): U3 — user CRUD + password reset + multi-department

Add create_user method to LocalAuthProvider (bcrypt hash + INSERT,
raises ValueError on duplicate username/email).

Add UserService with 9 async methods: create/list/get/update/delete
(soft)/reset_password/assign_department/remove_department/list_user_departments. reset_password revokes all sessions via SessionService.
delete_user is soft (is_active=0, row preserved).

Add 9 user endpoints to routes/admin.py: POST/GET/PATCH/DELETE users,
reset-password, assign/remove department, list departments. All
guarded by _require_admin.

Tests: 40 unit + 37 integration = 77 new tests. Full admin suite
170 tests pass, no regressions.
This commit is contained in:
chiguyong 2026-06-21 13:45:42 +08:00
parent 54be47d9ba
commit 6dca9ba4f2
5 changed files with 2590 additions and 2 deletions

View File

@ -0,0 +1,527 @@
"""UserService — business logic for ``users`` + multi-department assignment (U3).
This module is the single owner of user CRUD operations that span the
``users`` and ``user_departments`` tables. Web UI routes
(``/api/v1/admin/users/*``) and the CLI ``agentkit admin user`` sub-app
both call into :class:`UserService` rather than touching the tables
directly, keeping the validation rules (duplicate-username, department
existence) in one place.
The service is a module-level singleton (see :func:`get_user_service`)
so tests can inject a custom instance via :func:`set_user_service`.
Password hashing uses bcrypt (cost factor 12) via
:func:`agentkit.server.auth.password.hash_password`. Password resets
also revoke all of the user's active sessions via :class:`SessionService`.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from agentkit.server.auth.models import user_row_to_dict
from agentkit.server.auth.password import hash_password
from agentkit.server.auth.providers.local import LocalAuthProvider
from agentkit.server.auth.session_service import (
REVOKE_REASON_PASSWORD_CHANGED,
get_session_service,
)
logger = logging.getLogger(__name__)
def _now_iso() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).isoformat()
class UserService:
"""CRUD + multi-department assignment operations for users.
All async methods take ``db_path: Path`` as the first argument
(after ``self``). Each method opens its own short-lived
:class:`aiosqlite.Connection` there is no shared connection state,
which keeps the service safe to call from any async context.
The :meth:`create_user` method delegates the actual user-row insert
to :class:`LocalAuthProvider` so that the password-hashing and
IntegrityError-handling logic stays in one place. The other methods
(list/get/update/delete/reset_password/department assignment) operate
directly on the DB.
"""
# ------------------------------------------------------------------
# User CRUD
# ------------------------------------------------------------------
async def create_user(
self,
db_path: Path,
username: str,
email: str,
password: str,
role: str = "member",
department_ids: list[str] | None = None,
created_by: str | None = None,
) -> dict[str, Any]:
"""Create a new user, optionally assigning to departments.
Args:
db_path: Path to the auth SQLite DB.
username: Unique username.
email: Unique email address.
password: Plain-text password (bcrypt-hashed before storage).
role: Role name (``member`` / ``operator`` / ``admin``).
Defaults to ``member``.
department_ids: Optional list of department ids to assign
the user to. Each id must exist in the ``departments``
table; duplicate or non-existent ids raise ``ValueError``.
created_by: Optional user id of the admin who created this
user (audit trail).
Returns:
The newly-created user dict, including a ``departments``
list (each item is ``{id, name}``).
Raises:
ValueError: If a user with the same username or email
already exists, or if any of the ``department_ids``
does not exist or is already assigned.
"""
provider = LocalAuthProvider(db_path=db_path)
user = await provider.create_user(
username=username,
email=email,
password=password,
role=role,
created_by=created_by,
)
user_id = user["id"]
if department_ids:
for dept_id in department_ids:
# assign_department validates department existence and
# duplicate-assignment; raise on the first failure.
await self.assign_department(db_path, user_id, dept_id)
# Re-fetch with departments populated so the caller sees the
# full picture (matches the shape returned by get_user).
result = await self.get_user(db_path, user_id)
assert result is not None # we just created it
return result
async def list_users(
self,
db_path: Path,
department_id: str | None = None,
include_inactive: bool = True,
) -> list[dict[str, Any]]:
"""List users, optionally filtered by department.
Args:
db_path: Path to the auth SQLite DB.
department_id: When provided, only users assigned to this
department are returned (via ``user_departments`` join).
include_inactive: When ``False``, only users with
``is_active=1`` are returned.
Returns:
List of user dicts, each including a ``departments`` list
(each item is ``{id, name}``). Ordered by ``created_at``.
"""
async with aiosqlite.connect(str(db_path)) as db:
db.row_factory = aiosqlite.Row
if department_id is not None:
# JOIN through user_departments to filter. DISTINCT
# avoids duplicates when a user is in the department
# (the join would otherwise produce one row per
# user_department pair, but we filter to a single
# department here so each user appears at most once).
if include_inactive:
sql = (
"SELECT DISTINCT u.id, u.username, u.email, u.password_hash, "
"u.role, u.is_active, u.is_terminal_authorized, "
"u.is_server_terminal_authorized, u.created_at, u.updated_at, "
"u.last_login_at, u.created_by "
"FROM users u "
"INNER JOIN user_departments ud ON ud.user_id = u.id "
"WHERE ud.department_id = ? "
"ORDER BY u.created_at ASC"
)
args: tuple[Any, ...] = (department_id,)
else:
sql = (
"SELECT DISTINCT u.id, u.username, u.email, u.password_hash, "
"u.role, u.is_active, u.is_terminal_authorized, "
"u.is_server_terminal_authorized, u.created_at, u.updated_at, "
"u.last_login_at, u.created_by "
"FROM users u "
"INNER JOIN user_departments ud ON ud.user_id = u.id "
"WHERE ud.department_id = ? AND u.is_active = 1 "
"ORDER BY u.created_at ASC"
)
args = (department_id,)
elif include_inactive:
sql = (
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users ORDER BY created_at ASC"
)
args = ()
else:
sql = (
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users WHERE is_active = 1 ORDER BY created_at ASC"
)
args = ()
cursor = await db.execute(sql, args)
rows = await cursor.fetchall()
users = [user_row_to_dict(row) for row in rows]
# Batch-fetch departments for each user in the same
# connection to avoid N+1 round-trips.
for user in users:
user["departments"] = await self._fetch_departments(db, user["id"])
return users
async def get_user(
self,
db_path: Path,
user_id: str,
) -> dict[str, Any] | None:
"""Return a single user by id, or ``None`` if not found.
The returned dict includes a ``departments`` list (each item is
``{id, name}``).
"""
async with aiosqlite.connect(str(db_path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users WHERE id = ?",
(user_id,),
)
row = await cursor.fetchone()
if row is None:
return None
user = user_row_to_dict(row)
user["departments"] = await self._fetch_departments(db, user_id)
return user
async def update_user(
self,
db_path: Path,
user_id: str,
role: str | None = None,
is_active: bool | None = None,
is_terminal_authorized: bool | None = None,
is_server_terminal_authorized: bool | None = None,
) -> dict[str, Any]:
"""Partially update a user.
Only the provided fields are updated. ``password_hash`` is not
touched here use :meth:`reset_password` for that.
Args:
db_path: Path to the auth SQLite DB.
user_id: User id to update.
role: New role, or ``None`` to skip.
is_active: New active flag, or ``None`` to skip.
is_terminal_authorized: New terminal-authorized flag, or
``None`` to skip.
is_server_terminal_authorized: New server-terminal-authorized
flag, or ``None`` to skip.
Returns:
The updated user dict (including ``departments``).
Raises:
ValueError: If the user does not exist.
"""
existing = await self.get_user(db_path, user_id)
if existing is None:
raise ValueError(f"User {user_id!r} not found")
# Build the SET clause dynamically based on which fields were
# provided. This avoids overwriting columns with NULL when the
# caller only wants to update a subset.
set_parts: list[str] = []
args: list[Any] = []
if role is not None:
set_parts.append("role = ?")
args.append(role)
if is_active is not None:
set_parts.append("is_active = ?")
args.append(1 if is_active else 0)
if is_terminal_authorized is not None:
set_parts.append("is_terminal_authorized = ?")
args.append(1 if is_terminal_authorized else 0)
if is_server_terminal_authorized is not None:
set_parts.append("is_server_terminal_authorized = ?")
args.append(1 if is_server_terminal_authorized else 0)
if set_parts:
set_parts.append("updated_at = ?")
args.append(_now_iso())
args.append(user_id)
sql = f"UPDATE users SET {', '.join(set_parts)} WHERE id = ?"
async with aiosqlite.connect(str(db_path)) as db:
await db.execute(sql, tuple(args))
await db.commit()
updated = await self.get_user(db_path, user_id)
assert updated is not None # we checked existence above
return updated
async def delete_user(
self,
db_path: Path,
user_id: str,
) -> bool:
"""Soft-delete a user (set ``is_active = 0``).
The row is NOT actually deleted this preserves audit trails
and foreign-key references. Use :meth:`update_user` with
``is_active=True`` to re-enable.
Args:
db_path: Path to the auth SQLite DB.
user_id: User id to soft-delete.
Returns:
``True`` if the user was updated, ``False`` if the user did
not exist (or was already inactive).
"""
async with aiosqlite.connect(str(db_path)) as db:
cursor = await db.execute(
"UPDATE users SET is_active = 0, updated_at = ? WHERE id = ? AND is_active = 1",
(_now_iso(), user_id),
)
await db.commit()
return cursor.rowcount > 0
async def reset_password(
self,
db_path: Path,
user_id: str,
new_password: str,
) -> bool:
"""Reset a user's password and revoke all their sessions.
The new password is bcrypt-hashed (cost factor 12) before
storage. All active sessions for the user are then revoked
(via :class:`SessionService`) so that any stolen refresh tokens
become invalid immediately.
Args:
db_path: Path to the auth SQLite DB.
user_id: User id whose password should be reset.
new_password: New plain-text password.
Returns:
``True`` if the password was updated, ``False`` if the user
did not exist.
"""
# Use the shared hash_password helper so the cost factor (12)
# is configured in one place (auth.password).
password_hash = hash_password(new_password)
async with aiosqlite.connect(str(db_path)) as db:
cursor = await db.execute(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
(password_hash, _now_iso(), user_id),
)
await db.commit()
updated = cursor.rowcount > 0
if not updated:
return False
# Revoke all active sessions for this user. We use the
# process-wide SessionService singleton (which reads the same
# auth DB) so that the revocation takes effect immediately for
# any subsequent request that checks session validity.
try:
session_svc = get_session_service()
await session_svc.revoke_all_for_user(user_id, reason=REVOKE_REASON_PASSWORD_CHANGED)
except Exception: # noqa: BLE001 — never block password reset on session revocation
logger.exception(
"Failed to revoke sessions for user %s after password reset",
user_id,
)
logger.info("Reset password for user %s and revoked active sessions", user_id)
return True
# ------------------------------------------------------------------
# Department assignment
# ------------------------------------------------------------------
async def assign_department(
self,
db_path: Path,
user_id: str,
department_id: str,
) -> dict[str, Any]:
"""Assign a user to a department.
Args:
db_path: Path to the auth SQLite DB.
user_id: User id to assign.
department_id: Department id to assign the user to.
Returns:
Dict with ``{user_id, department_id, created_at}``.
Raises:
ValueError: If the department does not exist, or if the
user is already assigned to this department (duplicate
``user_departments`` row).
"""
# Verify the department exists. We don't verify the user exists
# here — the INSERT below will fail with a row-constraint
# violation if the user_id is bogus, and the caller is expected
# to have created/fetched the user before calling this method.
async with aiosqlite.connect(str(db_path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT id FROM departments WHERE id = ?",
(department_id,),
)
dept_row = await cursor.fetchone()
if dept_row is None:
raise ValueError(f"Department {department_id!r} not found")
now = _now_iso()
try:
await db.execute(
"INSERT INTO user_departments (user_id, department_id, created_at) "
"VALUES (?, ?, ?)",
(user_id, department_id, now),
)
await db.commit()
except aiosqlite.IntegrityError as exc:
# The composite PK (user_id, department_id) is the only
# UNIQUE constraint on this table, so an IntegrityError
# here means the assignment already exists.
raise ValueError(
f"User {user_id!r} is already assigned to department {department_id!r}"
) from exc
return {
"user_id": user_id,
"department_id": department_id,
"created_at": now,
}
async def remove_department(
self,
db_path: Path,
user_id: str,
department_id: str,
) -> bool:
"""Remove a user's department assignment.
Args:
db_path: Path to the auth SQLite DB.
user_id: User id.
department_id: Department id to remove.
Returns:
``True`` if a row was deleted, ``False`` if the assignment
did not exist.
"""
async with aiosqlite.connect(str(db_path)) as db:
cursor = await db.execute(
"DELETE FROM user_departments WHERE user_id = ? AND department_id = ?",
(user_id, department_id),
)
await db.commit()
return cursor.rowcount > 0
async def list_user_departments(
self,
db_path: Path,
user_id: str,
) -> list[dict[str, Any]]:
"""Return the list of departments a user is assigned to.
Each item is ``{id, name, is_active}`` the ``is_active`` flag
reflects the department's active state (not the user's).
"""
async with aiosqlite.connect(str(db_path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT d.id, d.name, d.is_active "
"FROM departments d "
"INNER JOIN user_departments ud ON ud.department_id = d.id "
"WHERE ud.user_id = ? "
"ORDER BY d.name ASC",
(user_id,),
)
rows = await cursor.fetchall()
return [
{
"id": row["id"],
"name": row["name"],
"is_active": bool(row["is_active"]),
}
for row in rows
]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _fetch_departments(
self,
db: aiosqlite.Connection,
user_id: str,
) -> list[dict[str, Any]]:
"""Fetch the departments for a user (helper for list/get).
Reuses the caller's open connection to avoid an extra round-trip.
Returns a list of ``{id, name}`` dicts.
"""
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT d.id, d.name "
"FROM departments d "
"INNER JOIN user_departments ud ON ud.department_id = d.id "
"WHERE ud.user_id = ? "
"ORDER BY d.name ASC",
(user_id,),
)
rows = await cursor.fetchall()
return [{"id": row["id"], "name": row["name"]} for row in rows]
# ---------------------------------------------------------------------------
# Module-level singleton (overridable in tests via set_user_service)
# ---------------------------------------------------------------------------
_user_service: UserService | None = None
def get_user_service() -> UserService:
"""Return the process-wide :class:`UserService` (lazy singleton)."""
global _user_service
if _user_service is None:
_user_service = UserService()
return _user_service
def set_user_service(service: UserService | None) -> None:
"""Inject a custom :class:`UserService` (used by tests)."""
global _user_service
_user_service = service

View File

@ -22,12 +22,14 @@ from __future__ import annotations
import logging import logging
import os import os
import uuid
from pathlib import Path from pathlib import Path
from typing import Any
import aiosqlite import aiosqlite
from ..models import DEFAULT_AUTH_DB_PATH from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
from ..password import verify_password from ..password import hash_password, verify_password
from .user import User from .user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -138,6 +140,93 @@ class LocalAuthProvider:
await db.commit() await db.commit()
logger.info(f"Revoked user {user_id} via LocalAuthProvider") logger.info(f"Revoked user {user_id} via LocalAuthProvider")
async def create_user(
self,
username: str,
email: str,
password: str,
role: str = "member",
is_terminal_authorized: bool = False,
is_server_terminal_authorized: bool = False,
created_by: str | None = None,
) -> dict[str, Any]:
"""Create a new user in the local ``users`` table.
Args:
username: Unique username.
email: Unique email address.
password: Plain-text password (will be bcrypt-hashed with
cost factor 12 before storage).
role: Role name (``member`` / ``operator`` / ``admin``).
Defaults to ``member``.
is_terminal_authorized: Whether the user may use the local
terminal. Defaults to ``False``.
is_server_terminal_authorized: Whether the user may use the
server terminal. Defaults to ``False``.
created_by: Optional user id of the admin who created this
user (audit trail).
Returns:
The newly-created user as a dict (via
:func:`agentkit.server.auth.models.user_row_to_dict`).
Raises:
ValueError: If a user with the same username or email
already exists (catches SQLite ``IntegrityError`` on the
``username`` / ``email`` UNIQUE constraints).
"""
user_id = str(uuid.uuid4())
password_hash = hash_password(password)
now = _now_iso()
try:
async with aiosqlite.connect(str(self._db_path)) as db:
db.row_factory = aiosqlite.Row
await db.execute(
"INSERT INTO users "
"(id, username, email, password_hash, role, is_active, "
" is_terminal_authorized, is_server_terminal_authorized, "
" created_at, updated_at, created_by) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
user_id,
username,
email,
password_hash,
role,
1,
1 if is_terminal_authorized else 0,
1 if is_server_terminal_authorized else 0,
now,
now,
created_by,
),
)
await db.commit()
cursor = await db.execute(
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users WHERE id = ?",
(user_id,),
)
row = await cursor.fetchone()
except aiosqlite.IntegrityError as exc:
# SQLite IntegrityError message includes the column name; we
# inspect it to give the caller a useful error. If for some
# reason the message is unparseable, fall back to a generic
# "duplicate" message.
msg = str(exc).lower()
if "username" in msg:
raise ValueError(f"User with username {username!r} already exists") from exc
if "email" in msg:
raise ValueError(f"User with email {email!r} already exists") from exc
raise ValueError(f"User already exists: {exc}") from exc
assert row is not None # we just inserted it
logger.info(f"Created user {username!r} (id={user_id}) via LocalAuthProvider")
return user_row_to_dict(row)
def _row_to_user(row: aiosqlite.Row) -> User: def _row_to_user(row: aiosqlite.Row) -> User:
"""Convert a ``users`` row to a :class:`User` value object.""" """Convert a ``users`` row to a :class:`User` value object."""

View File

@ -0,0 +1,552 @@
"""Admin REST routes — department CRUD + skill/KB bindings (U2).
This module hosts the *department-scoped* admin endpoints under
``/api/v1/admin/departments/*``. It is a sibling of the existing
``admin_router`` in :mod:`agentkit.server.routes.auth` (which hosts
session-management endpoints). The two routers coexist independently:
``auth.admin_router`` keeps its session endpoints, this module adds
department endpoints, and ``app.py`` mounts both under ``/api/v1``.
All endpoints here require the ``USER_MANAGE`` permission (admin role),
enforced by the :func:`_require_admin` dependency. The dependency is
re-defined locally to avoid a hard dependency on ``routes.auth`` at
import time (keeps the module self-contained and test-friendly).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, ConfigDict
from agentkit.server.admin.department_service import get_department_service
from agentkit.server.admin.user_service import get_user_service
from agentkit.server.auth.dependencies import require_authenticated
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db
from agentkit.server.auth.permissions import Permission, has_permission
logger = logging.getLogger(__name__)
admin_router = APIRouter(prefix="/admin", tags=["admin"])
# ---------------------------------------------------------------------------
# Local helpers (mirror routes.auth — kept local to avoid import cycles)
# ---------------------------------------------------------------------------
async def _require_admin(
request: Request,
user: dict[str, Any] = Depends(require_authenticated),
) -> dict[str, Any]:
"""Require the ``USER_MANAGE`` permission (admin role)."""
if not has_permission(user, Permission.USER_MANAGE):
raise HTTPException(status_code=403, detail="Admin permission required")
return user
def _resolve_db_path(request: Request) -> Path:
"""Resolve the auth DB path from ``app.state`` (mirrors routes.auth)."""
path = getattr(request.app.state, "auth_db_path", None)
return Path(path) if path else DEFAULT_AUTH_DB_PATH
async def _ensure_db(request: Request) -> Path:
"""Ensure the auth DB exists; returns the resolved path."""
db_path = _resolve_db_path(request)
if not db_path.exists():
await init_auth_db(db_path)
return db_path
# ---------------------------------------------------------------------------
# Pydantic request bodies
# ---------------------------------------------------------------------------
class DepartmentCreateRequest(BaseModel):
"""Body for ``POST /admin/departments``."""
model_config = ConfigDict(extra="forbid")
name: str
description: str = ""
class DepartmentUpdateRequest(BaseModel):
"""Body for ``PATCH /admin/departments/{id}``."""
model_config = ConfigDict(extra="forbid")
name: str | None = None
description: str | None = None
# ---------------------------------------------------------------------------
# Department CRUD endpoints
# ---------------------------------------------------------------------------
@admin_router.post("/departments", status_code=201)
async def create_department(
payload: DepartmentCreateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Create a new department.
Returns 201 with the department dict on success, 409 if a department
with the same name already exists.
"""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.create_department(db_path, payload.name, payload.description)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@admin_router.get("/departments")
async def list_departments(
request: Request,
include_inactive: bool = True,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List all departments."""
db_path = await _ensure_db(request)
svc = get_department_service()
return await svc.list_departments(db_path, include_inactive=include_inactive)
@admin_router.get("/departments/{department_id}")
async def get_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Return a single department by id. 404 if not found."""
db_path = await _ensure_db(request)
svc = get_department_service()
dept = await svc.get_department(db_path, department_id)
if dept is None:
raise HTTPException(status_code=404, detail="Department not found")
return dept
@admin_router.patch("/departments/{department_id}")
async def update_department(
department_id: str,
payload: DepartmentUpdateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Partially update a department.
Returns 200 with the updated dict, 404 if not found, 409 on duplicate
name.
"""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.update_department(
db_path,
department_id,
name=payload.name,
description=payload.description,
)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.post("/departments/{department_id}/disable")
async def disable_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set ``is_active=False`` on a department."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.set_department_active(db_path, department_id, is_active=False)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.post("/departments/{department_id}/enable")
async def enable_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set ``is_active=True`` on a department."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.set_department_active(db_path, department_id, is_active=True)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.delete("/departments/{department_id}")
async def delete_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Delete a department.
Returns 200 ``{deleted: true}`` on success. Returns 400 if the
department still has users assigned (admin must remove them first).
Returns 404 if the department does not exist.
"""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
deleted = await svc.delete_department(db_path, department_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Department not found")
return {"deleted": True}
# ---------------------------------------------------------------------------
# Department skill bindings
# ---------------------------------------------------------------------------
@admin_router.post(
"/departments/{department_id}/skills/{skill_name}",
status_code=201,
)
async def bind_skill(
department_id: str,
skill_name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Bind a skill to a department. 409 on duplicate binding."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.bind_skill(db_path, department_id, skill_name)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.delete(
"/departments/{department_id}/skills/{skill_name}",
)
async def unbind_skill(
department_id: str,
skill_name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Remove a skill binding. Idempotent — returns 200 even if no row existed."""
db_path = await _ensure_db(request)
svc = get_department_service()
await svc.unbind_skill(db_path, department_id, skill_name)
return {"unbound": True}
@admin_router.get("/departments/{department_id}/skills")
async def list_department_skills(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[str]:
"""List skill names bound to the department."""
db_path = await _ensure_db(request)
svc = get_department_service()
return await svc.list_department_skills(db_path, department_id)
# ---------------------------------------------------------------------------
# Department KB bindings
# ---------------------------------------------------------------------------
@admin_router.post(
"/departments/{department_id}/kb/{kb_source_id}",
status_code=201,
)
async def bind_kb(
department_id: str,
kb_source_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Bind a KB source to a department. 409 on duplicate binding."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.bind_kb(db_path, department_id, kb_source_id)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.delete(
"/departments/{department_id}/kb/{kb_source_id}",
)
async def unbind_kb(
department_id: str,
kb_source_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Remove a KB binding. Idempotent — returns 200 even if no row existed."""
db_path = await _ensure_db(request)
svc = get_department_service()
await svc.unbind_kb(db_path, department_id, kb_source_id)
return {"unbound": True}
@admin_router.get("/departments/{department_id}/kb")
async def list_department_kbs(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[str]:
"""List KB source ids bound to the department."""
db_path = await _ensure_db(request)
svc = get_department_service()
return await svc.list_department_kbs(db_path, department_id)
# ---------------------------------------------------------------------------
# User CRUD endpoints (U3)
# ---------------------------------------------------------------------------
class UserCreateRequest(BaseModel):
"""Body for ``POST /admin/users``."""
model_config = ConfigDict(extra="forbid")
username: str
email: str
password: str
role: str = "member"
department_ids: list[str] | None = None
class UserUpdateRequest(BaseModel):
"""Body for ``PATCH /admin/users/{user_id}``."""
model_config = ConfigDict(extra="forbid")
role: str | None = None
is_active: bool | None = None
is_terminal_authorized: bool | None = None
is_server_terminal_authorized: bool | None = None
class ResetPasswordRequest(BaseModel):
"""Body for ``POST /admin/users/{user_id}/reset-password``."""
model_config = ConfigDict(extra="forbid")
new_password: str
@admin_router.post("/users", status_code=201)
async def create_user(
payload: UserCreateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Create a new user.
Returns 201 with the user dict (including ``departments``) on
success. Returns 409 on duplicate username or email, or if any of
the ``department_ids`` is already assigned. Returns 404 if any of
the ``department_ids`` does not exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
try:
return await svc.create_user(
db_path,
username=payload.username,
email=payload.email,
password=payload.password,
role=payload.role,
department_ids=payload.department_ids,
created_by=admin.get("user_id"),
)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.get("/users")
async def list_users(
request: Request,
department_id: str | None = None,
include_inactive: bool = True,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List users, optionally filtered by department."""
db_path = await _ensure_db(request)
svc = get_user_service()
return await svc.list_users(
db_path,
department_id=department_id,
include_inactive=include_inactive,
)
@admin_router.get("/users/{user_id}")
async def get_user(
user_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Return a single user by id (including departments). 404 if not found."""
db_path = await _ensure_db(request)
svc = get_user_service()
user = await svc.get_user(db_path, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@admin_router.patch("/users/{user_id}")
async def update_user(
user_id: str,
payload: UserUpdateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Partially update a user.
Returns 200 with the updated dict, 404 if the user does not exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
try:
return await svc.update_user(
db_path,
user_id,
role=payload.role,
is_active=payload.is_active,
is_terminal_authorized=payload.is_terminal_authorized,
is_server_terminal_authorized=payload.is_server_terminal_authorized,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Soft-delete a user (set ``is_active=0``).
Returns 200 ``{deleted: true}`` on success, 404 if the user does
not exist or is already inactive.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
deleted = await svc.delete_user(db_path, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="User not found")
return {"deleted": True}
@admin_router.post("/users/{user_id}/reset-password")
async def reset_password(
user_id: str,
payload: ResetPasswordRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Reset a user's password and revoke all their sessions.
Returns 200 ``{reset: true}`` on success, 404 if the user does not
exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
reset = await svc.reset_password(db_path, user_id, payload.new_password)
if not reset:
raise HTTPException(status_code=404, detail="User not found")
return {"reset": True}
@admin_router.post(
"/users/{user_id}/departments/{department_id}",
status_code=201,
)
async def assign_department(
user_id: str,
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Assign a user to a department.
Returns 201 ``{assigned: true}`` on success. Returns 404 if the
department does not exist. Returns 409 if the user is already
assigned to this department.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
try:
await svc.assign_department(db_path, user_id, department_id)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
return {"assigned": True}
@admin_router.delete("/users/{user_id}/departments/{department_id}")
async def remove_department(
user_id: str,
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Remove a user's department assignment.
Returns 200 ``{removed: true}`` on success, 404 if the assignment
does not exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
removed = await svc.remove_department(db_path, user_id, department_id)
if not removed:
raise HTTPException(status_code=404, detail="Assignment not found")
return {"removed": True}
@admin_router.get("/users/{user_id}/departments")
async def list_user_departments(
user_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List the departments a user is assigned to."""
db_path = await _ensure_db(request)
svc = get_user_service()
return await svc.list_user_departments(db_path, user_id)

View File

@ -0,0 +1,614 @@
"""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")

View File

@ -0,0 +1,806 @@
"""Unit tests for UserService (U3 — user CRUD + multi-department assignment).
Covers:
- Happy path: create_user list_users returns it get_user update_user
reset_password (verify old sessions revoked) assign_department
list_user_departments remove_department delete_user (soft)
- Edge case: create duplicate username ValueError
- Edge case: create duplicate email ValueError
- Edge case: get/update/delete non-existent user None/ValueError/False
- Edge case: assign duplicate department ValueError
- Edge case: assign non-existent department ValueError
- Edge case: list_users filtered by department_id
- Edge case: delete_user is soft (is_active=0, row still exists)
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
import bcrypt
import pytest
from agentkit.server.admin.department_service import DepartmentService
from agentkit.server.admin.user_service import UserService
from agentkit.server.auth.models import init_auth_db
from agentkit.server.auth.session_service import (
REVOKE_REASON_PASSWORD_CHANGED,
SessionService,
set_session_service,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def fresh_db(tmp_path: Path) -> Path:
"""A brand-new auth DB on a fresh path (no data)."""
db_path = tmp_path / "auth.db"
await init_auth_db(db_path)
return db_path
@pytest.fixture
def service() -> UserService:
return UserService()
@pytest.fixture
def department_service() -> DepartmentService:
return DepartmentService()
@pytest.fixture
def session_service(fresh_db: Path) -> Iterator[SessionService]:
"""A SessionService backed by the same temp DB as fresh_db.
Installed as the process-wide singleton so that
``UserService.reset_password`` can find it via
``get_session_service()``.
"""
svc = SessionService(db_path=fresh_db)
set_session_service(svc)
yield svc
# Restore the lazy default after the test.
set_session_service(None)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _create_test_department(
dept_service: DepartmentService, db_path: Path, name: str = "Engineering"
) -> dict[str, Any]:
"""Create a department for use in user-assignment tests."""
return await dept_service.create_department(db_path, name)
async def _create_session_for_user(
db_path: Path, user_id: str, session_id: str | None = None
) -> str:
"""Insert a minimal active auth_sessions row and return its id."""
session_id = session_id or str(uuid.uuid4())
now = _now_iso()
expires = datetime.now(timezone.utc).isoformat()
async with aiosqlite.connect(str(db_path)) as db:
await 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,
expires,
0,
None,
None,
),
)
await db.commit()
return session_id
async def _count_active_sessions(db_path: Path, user_id: str) -> int:
"""Count active (non-revoked) sessions for a user."""
async with aiosqlite.connect(str(db_path)) as db:
cursor = await db.execute(
"SELECT COUNT(*) FROM auth_sessions WHERE user_id = ? AND revoked = 0",
(user_id,),
)
row = await cursor.fetchone()
return int(row[0]) if row else 0
async def _fetch_user_row(db_path: Path, user_id: str) -> dict[str, Any] | None:
"""Fetch a raw user row (including is_active) directly from the DB."""
async with aiosqlite.connect(str(db_path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
row = await cursor.fetchone()
return dict(row) if row else None
# ---------------------------------------------------------------------------
# create_user happy path
# ---------------------------------------------------------------------------
class TestCreateUserHappyPath:
async def test_create_returns_user_dict(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
assert user["id"]
assert user["username"] == "alice"
assert user["email"] == "alice@example.com"
assert user["role"] == "member"
assert user["is_active"] is True
assert user["is_terminal_authorized"] is False
assert user["is_server_terminal_authorized"] is False
assert user["created_by"] is None
assert "created_at" in user
assert "updated_at" in user
assert "password_hash" not in user # password_hash must not leak
assert user["departments"] == []
async def test_create_with_role_and_flags(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db,
"bob",
"bob@example.com",
"Secret123!",
role="admin",
)
assert user["role"] == "admin"
async def test_create_with_created_by(self, service: UserService, fresh_db: Path):
admin_id = str(uuid.uuid4())
user = await service.create_user(
fresh_db,
"carol",
"carol@example.com",
"Secret123!",
created_by=admin_id,
)
assert user["created_by"] == admin_id
async def test_create_with_department_ids_assigns_departments(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
hr = await _create_test_department(department_service, fresh_db, "HR")
user = await service.create_user(
fresh_db,
"dave",
"dave@example.com",
"Secret123!",
department_ids=[eng["id"], hr["id"]],
)
dept_ids = {d["id"] for d in user["departments"]}
assert dept_ids == {eng["id"], hr["id"]}
async def test_password_is_bcrypt_hashed(
self, service: UserService, fresh_db: Path
):
"""The stored password_hash must be a bcrypt hash, not plaintext."""
user = await service.create_user(
fresh_db, "eve", "eve@example.com", "MyPassword123!"
)
row = await _fetch_user_row(fresh_db, user["id"])
assert row is not None
stored_hash = row["password_hash"]
# bcrypt hashes start with $2b$ (or $2a$/$2y$).
assert stored_hash.startswith("$2")
# And the hash must verify against the original password.
assert bcrypt.checkpw(b"MyPassword123!", stored_hash.encode())
# ---------------------------------------------------------------------------
# create_user edge cases
# ---------------------------------------------------------------------------
class TestCreateUserEdgeCases:
async def test_duplicate_username_raises_value_error(
self, service: UserService, fresh_db: Path
):
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
with pytest.raises(ValueError, match="username"):
await service.create_user(
fresh_db, "alice", "other@example.com", "Secret123!"
)
async def test_duplicate_email_raises_value_error(
self, service: UserService, fresh_db: Path
):
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
with pytest.raises(ValueError, match="email"):
await service.create_user(
fresh_db, "alice2", "alice@example.com", "Secret123!"
)
async def test_create_with_nonexistent_department_id_raises(
self,
service: UserService,
fresh_db: Path,
):
bogus_dept_id = str(uuid.uuid4())
with pytest.raises(ValueError, match="Department.*not found"):
await service.create_user(
fresh_db,
"frank",
"frank@example.com",
"Secret123!",
department_ids=[bogus_dept_id],
)
async def test_create_with_duplicate_department_id_raises(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
with pytest.raises(ValueError, match="already assigned"):
await service.create_user(
fresh_db,
"gina",
"gina@example.com",
"Secret123!",
department_ids=[eng["id"], eng["id"]],
)
# ---------------------------------------------------------------------------
# list_users
# ---------------------------------------------------------------------------
class TestListUsers:
async def test_list_returns_all_users(self, service: UserService, fresh_db: Path):
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.create_user(fresh_db, "bob", "bob@example.com", "Secret123!")
users = await service.list_users(fresh_db)
assert len(users) == 2
names = {u["username"] for u in users}
assert names == {"alice", "bob"}
async def test_list_excludes_inactive_when_asked(
self, service: UserService, fresh_db: Path
):
alice = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.create_user(fresh_db, "bob", "bob@example.com", "Secret123!")
# Soft-delete alice.
await service.delete_user(fresh_db, alice["id"])
active_only = await service.list_users(fresh_db, include_inactive=False)
assert len(active_only) == 1
assert active_only[0]["username"] == "bob"
all_users = await service.list_users(fresh_db, include_inactive=True)
assert len(all_users) == 2
async def test_list_filtered_by_department(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
hr = await _create_test_department(department_service, fresh_db, "HR")
alice = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
bob = await service.create_user(
fresh_db, "bob", "bob@example.com", "Secret123!"
)
await service.assign_department(fresh_db, alice["id"], eng["id"])
await service.assign_department(fresh_db, bob["id"], hr["id"])
eng_users = await service.list_users(fresh_db, department_id=eng["id"])
assert len(eng_users) == 1
assert eng_users[0]["username"] == "alice"
hr_users = await service.list_users(fresh_db, department_id=hr["id"])
assert len(hr_users) == 1
assert hr_users[0]["username"] == "bob"
async def test_list_includes_departments_for_each_user(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
alice = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.assign_department(fresh_db, alice["id"], eng["id"])
users = await service.list_users(fresh_db)
assert len(users) == 1
assert len(users[0]["departments"]) == 1
assert users[0]["departments"][0]["id"] == eng["id"]
assert users[0]["departments"][0]["name"] == "Engineering"
# ---------------------------------------------------------------------------
# get_user
# ---------------------------------------------------------------------------
class TestGetUser:
async def test_get_returns_user_with_departments(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.assign_department(fresh_db, user["id"], eng["id"])
fetched = await service.get_user(fresh_db, user["id"])
assert fetched is not None
assert fetched["username"] == "alice"
assert len(fetched["departments"]) == 1
assert fetched["departments"][0]["name"] == "Engineering"
async def test_get_returns_none_for_unknown_id(
self, service: UserService, fresh_db: Path
):
result = await service.get_user(fresh_db, str(uuid.uuid4()))
assert result is None
# ---------------------------------------------------------------------------
# update_user
# ---------------------------------------------------------------------------
class TestUpdateUser:
async def test_update_role(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
updated = await service.update_user(fresh_db, user["id"], role="admin")
assert updated["role"] == "admin"
async def test_update_is_active(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
updated = await service.update_user(fresh_db, user["id"], is_active=False)
assert updated["is_active"] is False
async def test_update_terminal_authorized_flags(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
updated = await service.update_user(
fresh_db,
user["id"],
is_terminal_authorized=True,
is_server_terminal_authorized=True,
)
assert updated["is_terminal_authorized"] is True
assert updated["is_server_terminal_authorized"] is True
async def test_update_partial_only_changes_provided_fields(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
# Only update role; other fields should be preserved.
updated = await service.update_user(fresh_db, user["id"], role="admin")
assert updated["role"] == "admin"
assert updated["is_active"] is True
assert updated["is_terminal_authorized"] is False
async def test_update_nonexistent_raises_value_error(
self, service: UserService, fresh_db: Path
):
with pytest.raises(ValueError, match="not found"):
await service.update_user(fresh_db, str(uuid.uuid4()), role="admin")
async def test_update_no_fields_provided_is_noop(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
updated = await service.update_user(fresh_db, user["id"])
assert updated["username"] == "alice"
# ---------------------------------------------------------------------------
# delete_user (soft delete)
# ---------------------------------------------------------------------------
class TestDeleteUser:
async def test_delete_returns_true_for_active_user(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
deleted = await service.delete_user(fresh_db, user["id"])
assert deleted is True
async def test_delete_is_soft(self, service: UserService, fresh_db: Path):
"""delete_user must set is_active=0 but NOT remove the row."""
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.delete_user(fresh_db, user["id"])
# Row must still exist.
row = await _fetch_user_row(fresh_db, user["id"])
assert row is not None
# But is_active must be 0.
assert bool(row["is_active"]) is False
async def test_delete_returns_false_for_unknown_id(
self, service: UserService, fresh_db: Path
):
deleted = await service.delete_user(fresh_db, str(uuid.uuid4()))
assert deleted is False
async def test_delete_returns_false_for_already_inactive(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
first = await service.delete_user(fresh_db, user["id"])
assert first is True
# Second delete on the same (now-inactive) user must return False.
second = await service.delete_user(fresh_db, user["id"])
assert second is False
# ---------------------------------------------------------------------------
# reset_password
# ---------------------------------------------------------------------------
class TestResetPassword:
async def test_reset_password_returns_true(
self,
service: UserService,
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
reset = await service.reset_password(fresh_db, user["id"], "NewPassword456!")
assert reset is True
async def test_reset_password_updates_hash(
self,
service: UserService,
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
old_row = await _fetch_user_row(fresh_db, user["id"])
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
new_row = await _fetch_user_row(fresh_db, user["id"])
assert new_row["password_hash"] != old_row["password_hash"]
# New hash must verify against the new password.
assert bcrypt.checkpw(b"NewPassword456!", new_row["password_hash"].encode())
async def test_reset_password_revokes_all_sessions(
self,
service: UserService,
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
# Create two active sessions for the user.
await _create_session_for_user(fresh_db, user["id"])
await _create_session_for_user(fresh_db, user["id"])
assert await _count_active_sessions(fresh_db, user["id"]) == 2
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
# All sessions must be revoked.
assert await _count_active_sessions(fresh_db, user["id"]) == 0
async def test_reset_password_records_revoked_reason(
self,
service: UserService,
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
session_id = await _create_session_for_user(fresh_db, user["id"])
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
async with aiosqlite.connect(str(fresh_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT revoked, revoked_reason FROM auth_sessions WHERE id = ?",
(session_id,),
)
row = await cursor.fetchone()
assert row is not None
assert bool(row["revoked"]) is True
assert row["revoked_reason"] == REVOKE_REASON_PASSWORD_CHANGED
async def test_reset_password_returns_false_for_unknown_user(
self,
service: UserService,
session_service: SessionService,
fresh_db: Path,
):
reset = await service.reset_password(
fresh_db, str(uuid.uuid4()), "NewPassword456!"
)
assert reset is False
# ---------------------------------------------------------------------------
# Department assignment
# ---------------------------------------------------------------------------
class TestAssignDepartment:
async def test_assign_returns_assignment_dict(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
result = await service.assign_department(fresh_db, user["id"], eng["id"])
assert result["user_id"] == user["id"]
assert result["department_id"] == eng["id"]
assert "created_at" in result
async def test_assign_duplicate_raises_value_error(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.assign_department(fresh_db, user["id"], eng["id"])
with pytest.raises(ValueError, match="already assigned"):
await service.assign_department(fresh_db, user["id"], eng["id"])
async def test_assign_nonexistent_department_raises_value_error(
self,
service: UserService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
with pytest.raises(ValueError, match="Department.*not found"):
await service.assign_department(fresh_db, user["id"], str(uuid.uuid4()))
class TestRemoveDepartment:
async def test_remove_returns_true(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.assign_department(fresh_db, user["id"], eng["id"])
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
assert removed is True
# Confirm the assignment is gone.
depts = await service.list_user_departments(fresh_db, user["id"])
assert depts == []
async def test_remove_returns_false_for_nonexistent_assignment(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
assert removed is False
class TestListUserDepartments:
async def test_list_returns_departments_with_active_flag(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
hr = await _create_test_department(department_service, fresh_db, "HR")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.assign_department(fresh_db, user["id"], eng["id"])
await service.assign_department(fresh_db, user["id"], hr["id"])
depts = await service.list_user_departments(fresh_db, user["id"])
assert len(depts) == 2
# Ordered by name.
assert depts[0]["name"] == "Engineering"
assert depts[1]["name"] == "HR"
assert all("is_active" in d for d in depts)
assert all(d["is_active"] is True for d in depts)
async def test_list_returns_empty_for_user_with_no_departments(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
depts = await service.list_user_departments(fresh_db, user["id"])
assert depts == []
async def test_list_reflects_department_active_state(
self,
service: UserService,
department_service: DepartmentService,
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
await service.assign_department(fresh_db, user["id"], eng["id"])
# Disable the department.
await department_service.set_department_active(fresh_db, eng["id"], False)
depts = await service.list_user_departments(fresh_db, user["id"])
assert len(depts) == 1
assert depts[0]["is_active"] is False
# ---------------------------------------------------------------------------
# End-to-end happy path
# ---------------------------------------------------------------------------
class TestEndToEndHappyPath:
async def test_full_user_lifecycle(
self,
service: UserService,
department_service: DepartmentService,
session_service: SessionService,
fresh_db: Path,
):
"""Exercise the full user lifecycle in order:
create list get update reset_password assign_department
list_user_departments remove_department delete (soft).
"""
# 1. Create a department and a user assigned to it.
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db,
"alice",
"alice@example.com",
"InitialPassword123!",
department_ids=[eng["id"]],
)
assert user["username"] == "alice"
assert len(user["departments"]) == 1
# 2. list_users returns the new user.
users = await service.list_users(fresh_db)
assert len(users) == 1
assert users[0]["id"] == user["id"]
# 3. get_user returns the same user with departments.
fetched = await service.get_user(fresh_db, user["id"])
assert fetched is not None
assert fetched["username"] == "alice"
assert len(fetched["departments"]) == 1
# 4. update_user changes the role.
updated = await service.update_user(fresh_db, user["id"], role="admin")
assert updated["role"] == "admin"
# 5. reset_password updates the hash and revokes sessions.
await _create_session_for_user(fresh_db, user["id"])
assert await _count_active_sessions(fresh_db, user["id"]) == 1
reset = await service.reset_password(fresh_db, user["id"], "NewPassword456!")
assert reset is True
assert await _count_active_sessions(fresh_db, user["id"]) == 0
# 6. assign_department adds a second department.
hr = await _create_test_department(department_service, fresh_db, "HR")
await service.assign_department(fresh_db, user["id"], hr["id"])
# 7. list_user_departments returns both.
depts = await service.list_user_departments(fresh_db, user["id"])
assert len(depts) == 2
# 8. remove_department removes one.
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
assert removed is True
depts = await service.list_user_departments(fresh_db, user["id"])
assert len(depts) == 1
assert depts[0]["name"] == "HR"
# 9. delete_user is a soft delete.
deleted = await service.delete_user(fresh_db, user["id"])
assert deleted is True
row = await _fetch_user_row(fresh_db, user["id"])
assert row is not None # row still exists
assert bool(row["is_active"]) is False # but inactive
# ---------------------------------------------------------------------------
# Singleton helpers
# ---------------------------------------------------------------------------
class TestSingletonHelpers:
def test_get_user_service_returns_singleton(self):
from agentkit.server.admin.user_service import (
get_user_service,
set_user_service,
)
# Save the original singleton so we don't disturb other tests.
original = get_user_service()
try:
custom = UserService()
set_user_service(custom)
assert get_user_service() is custom
# Clearing falls back to a new lazy instance.
set_user_service(None)
new_one = get_user_service()
assert new_one is not custom
finally:
set_user_service(original)