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:
parent
54be47d9ba
commit
6dca9ba4f2
|
|
@ -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
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue