fischer-agentkit/tests/unit/admin/test_user_service.py

807 lines
30 KiB
Python

"""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)