807 lines
30 KiB
Python
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)
|