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