[OND211-2329]: Updated remove member from team API and tests.

This commit is contained in:
Hetavi Shah 2025-11-20 11:31:51 +05:30
parent 6c68429333
commit d5dcb4408e
3 changed files with 139 additions and 176 deletions

View file

@ -1019,13 +1019,13 @@ def add_users(tenant_id: str) -> Response:
)
@manager.route('/<tenant_id>/users/remove', methods=['POST']) # noqa: F821
@manager.route('/<tenant_id>/user/remove', methods=['POST']) # noqa: F821
@login_required
@validate_request("user_ids")
def remove_users(tenant_id: str) -> Response:
@validate_request("user_id")
def remove_user(tenant_id: str) -> Response:
"""
Remove one or more users from a team. Only OWNER or ADMIN can remove users.
Owners cannot be removed. Supports both single user and bulk operations.
Remove a user from a team. Only OWNER or ADMIN can remove users.
Owners cannot be removed.
---
tags:
@ -1044,28 +1044,26 @@ def remove_users(tenant_id: str) -> Response:
schema:
type: object
required:
- user_ids
- user_id
properties:
user_ids:
type: array
description: List of user IDs to remove
items:
type: string
user_id:
type: string
description: User ID to remove
responses:
200:
description: Users removed successfully
description: User removed successfully
schema:
type: object
properties:
data:
type: object
properties:
removed:
type: array
description: Successfully removed user IDs
failed:
type: array
description: Users that failed to be removed with error messages
user_id:
type: string
description: Removed user ID
email:
type: string
description: Removed user email
message:
type: string
400:
@ -1084,103 +1082,74 @@ def remove_users(tenant_id: str) -> Response:
)
req: Dict[str, Any] = request.json if request.json is not None else {}
user_ids: List[str] = req.get("user_ids", [])
user_id: Optional[str] = req.get("user_id")
if not isinstance(user_ids, list) or len(user_ids) == 0:
if not user_id or not isinstance(user_id, str):
return get_json_result(
data=False,
message="'user_ids' must be a non-empty array.",
message="'user_id' must be a non-empty string.",
code=RetCode.ARGUMENT_ERROR
)
removed_users: List[Dict[str, str]] = []
failed_users: List[Dict[str, str]] = []
# Get all admins/owners for validation (check if removing would leave team without admin/owner)
all_user_tenants: List[Any] = UserTenantService.query(tenant_id=tenant_id)
admin_owner_ids: Set[str] = {
ut.user_id for ut in all_user_tenants
if ut.role in [UserTenantRole.OWNER, UserTenantRole.ADMIN] and ut.status == StatusEnum.VALID.value
}
for user_id in user_ids:
if not isinstance(user_id, str):
failed_users.append({
"user_id": str(user_id),
"error": "Invalid user ID format."
})
continue
try:
# Check if user exists in the team
user_tenant = UserTenantService.filter_by_tenant_and_user_id(tenant_id, user_id)
if not user_tenant:
return get_json_result(
data=False,
message="User is not a member of this team.",
code=RetCode.DATA_ERROR
)
try:
# Check if user exists in the team
user_tenant = UserTenantService.filter_by_tenant_and_user_id(tenant_id, user_id)
if not user_tenant:
failed_users.append({
"user_id": user_id,
"error": "User is not a member of this team."
})
continue
# Prevent removing the owner
if user_tenant.role == UserTenantRole.OWNER:
failed_users.append({
"user_id": user_id,
"error": "Cannot remove the team owner."
})
continue
# Prevent removing yourself if you're the only admin
if user_id == current_user.id and user_tenant.role == UserTenantRole.ADMIN:
remaining_admins: Set[str] = admin_owner_ids - {user_id}
if len(remaining_admins) == 0:
failed_users.append({
"user_id": user_id,
"error": "Cannot remove yourself. At least one owner or admin must remain in the team."
})
continue
# Remove user from team
UserTenantService.filter_delete([
UserTenant.tenant_id == tenant_id,
UserTenant.user_id == user_id
])
# Get user info for response
user: Optional[Any] = UserService.filter_by_id(user_id)
user_email: str = user.email if user else "Unknown"
removed_users.append({
# Prevent removing the owner
if user_tenant.role == UserTenantRole.OWNER:
return get_json_result(
data=False,
message="Cannot remove the team owner.",
code=RetCode.DATA_ERROR
)
# Get all admins/owners for validation (check if removing would leave team without admin/owner)
all_user_tenants: List[Any] = UserTenantService.query(tenant_id=tenant_id)
admin_owner_ids: Set[str] = {
ut.user_id for ut in all_user_tenants
if ut.role in [UserTenantRole.OWNER, UserTenantRole.ADMIN] and ut.status == StatusEnum.VALID.value
}
# Prevent removing yourself if you're the only admin
if user_id == current_user.id and user_tenant.role == UserTenantRole.ADMIN:
remaining_admins: Set[str] = admin_owner_ids - {user_id}
if len(remaining_admins) == 0:
return get_json_result(
data=False,
message="Cannot remove yourself. At least one owner or admin must remain in the team.",
code=RetCode.DATA_ERROR
)
# Remove user from team
UserTenantService.filter_delete([
UserTenant.tenant_id == tenant_id,
UserTenant.user_id == user_id
])
# Get user info for response
user: Optional[Any] = UserService.filter_by_id(user_id)
user_email: str = user.email if user else "Unknown"
return get_json_result(
data={
"user_id": user_id,
"email": user_email
})
except Exception as e:
logging.exception(f"Error removing user {user_id}: {e}")
failed_users.append({
"user_id": user_id,
"error": f"Failed to remove user: {str(e)}"
})
result: Dict[str, List[Dict[str, str]]] = {
"removed": removed_users,
"failed": failed_users
}
if failed_users and not removed_users:
return get_json_result(
data=result,
message=f"Failed to remove all users. {len(failed_users)} error(s).",
code=RetCode.DATA_ERROR
},
message="User removed successfully."
)
elif failed_users:
except Exception as e:
logging.exception(f"Error removing user {user_id}: {e}")
return get_json_result(
data=result,
message=f"Removed {len(removed_users)} user(s). {len(failed_users)} user(s) failed."
)
else:
return get_json_result(
data=result,
message=f"Successfully removed {len(removed_users)} user(s)."
data=False,
message=f"Failed to remove user: {str(e)}",
code=RetCode.EXCEPTION_ERROR
)

View file

@ -450,19 +450,19 @@ def add_users_to_team(
return res.json()
def remove_users_from_team(
def remove_user_from_team(
auth: Union[AuthBase, str, None],
tenant_id: str,
payload: Optional[Dict[str, Any]] = None,
*,
headers: Dict[str, str] = HEADERS,
) -> Dict[str, Any]:
"""Remove users from a team (tenant).
"""Remove a user from a team (tenant).
Args:
auth: Authentication object (AuthBase subclass), token string, or None.
tenant_id: The tenant/team ID to remove users from.
payload: Optional JSON payload containing user_ids list.
tenant_id: The tenant/team ID to remove user from.
payload: Optional JSON payload containing user_id string.
headers: Optional HTTP headers. Defaults to HEADERS.
Returns:
@ -471,7 +471,7 @@ def remove_users_from_team(
Raises:
requests.RequestException: If the HTTP request fails.
"""
url: str = f"{HOST_ADDRESS}{TEAM_API_URL}/{tenant_id}/users/remove"
url: str = f"{HOST_ADDRESS}{TEAM_API_URL}/{tenant_id}/user/remove"
res: requests.Response = requests.post(
url=url, headers=headers, auth=auth, json=payload
)

View file

@ -27,7 +27,7 @@ from common import (
create_user,
encrypt_password,
login_as_user,
remove_users_from_team,
remove_user_from_team,
)
from configs import INVALID_API_TOKEN
from libs.auth import RAGFlowWebApiAuth
@ -83,16 +83,16 @@ class TestAuthorization:
add_users_to_team(web_api_auth, tenant_id, add_payload)
# Try to remove user with invalid auth
remove_payload: dict[str, list[str]] = {"user_ids": [user_id]}
res: dict[str, Any] = remove_users_from_team(invalid_auth, tenant_id, remove_payload)
remove_payload: dict[str, str] = {"user_id": user_id}
res: dict[str, Any] = remove_user_from_team(invalid_auth, tenant_id, remove_payload)
assert res["code"] == expected_code, res
if expected_message:
assert expected_message in res["message"]
@pytest.mark.p1
class TestRemoveUsers:
"""Comprehensive tests for removing users from a team."""
class TestRemoveUser:
"""Comprehensive tests for removing a user from a team."""
@pytest.fixture
def test_team(self, web_api_auth: RAGFlowWebApiAuth) -> dict[str, Any]:
@ -151,36 +151,36 @@ class TestRemoveUsers:
tenant_id: str = team_with_users["team"]["id"]
user_id: str = team_with_users["users"][0]["id"]
remove_payload: dict[str, list[str]] = {"user_ids": [user_id]}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
remove_payload: dict[str, str] = {"user_id": user_id}
res: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload)
assert res["code"] == 0, res
assert "data" in res
assert "removed" in res["data"]
assert len(res["data"]["removed"]) == 1
assert res["data"]["removed"][0]["user_id"] == user_id
assert "failed" in res["data"]
assert len(res["data"]["failed"]) == 0
assert res["data"]["user_id"] == user_id
assert "email" in res["data"]
@pytest.mark.p1
def test_remove_multiple_users(
self, web_api_auth: RAGFlowWebApiAuth, team_with_users: dict[str, Any]
) -> None:
"""Test removing multiple users in bulk."""
"""Test removing multiple users one by one."""
if len(team_with_users["users"]) < 2:
pytest.skip("Need at least 2 users in team")
tenant_id: str = team_with_users["team"]["id"]
user_ids: list[str] = [user["id"] for user in team_with_users["users"][:2]]
remove_payload: dict[str, list[str]] = {"user_ids": user_ids}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
# Remove first user
remove_payload1: dict[str, str] = {"user_id": user_ids[0]}
res1: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload1)
assert res1["code"] == 0, res1
assert res1["data"]["user_id"] == user_ids[0]
assert res["code"] == 0, res
assert len(res["data"]["removed"]) == 2
assert len(res["data"]["failed"]) == 0
removed_ids = {user["user_id"] for user in res["data"]["removed"]}
assert removed_ids == set(user_ids)
# Remove second user
remove_payload2: dict[str, str] = {"user_id": user_ids[1]}
res2: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload2)
assert res2["code"] == 0, res2
assert res2["data"]["user_id"] == user_ids[1]
@pytest.mark.p1
def test_remove_user_not_in_team(
@ -194,14 +194,12 @@ class TestRemoveUsers:
# Use a user that was not added to the team
user_id: str = test_users[3]["id"]
remove_payload: dict[str, list[str]] = {"user_ids": [user_id]}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
remove_payload: dict[str, str] = {"user_id": user_id}
res: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload)
# API returns error code when all removals fail
# API returns error code when removal fails
assert res["code"] == 102 # DATA_ERROR
assert len(res["data"]["removed"]) == 0
assert len(res["data"]["failed"]) == 1
assert "not a member" in res["data"]["failed"][0]["error"].lower()
assert "not a member" in res["message"].lower()
@pytest.mark.p1
def test_remove_owner(
@ -211,20 +209,18 @@ class TestRemoveUsers:
tenant_id: str = test_team["id"]
owner_id: str = test_team["owner_id"]
remove_payload: dict[str, list[str]] = {"user_ids": [owner_id]}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
remove_payload: dict[str, str] = {"user_id": owner_id}
res: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload)
# API returns error code when all removals fail
# API returns error code when removal fails
assert res["code"] == 102 # DATA_ERROR
assert len(res["data"]["removed"]) == 0
assert len(res["data"]["failed"]) == 1
assert "owner" in res["data"]["failed"][0]["error"].lower()
assert "owner" in res["message"].lower()
@pytest.mark.p1
def test_remove_users_partial_success(
self, web_api_auth: RAGFlowWebApiAuth, team_with_users: dict[str, Any], test_users: list[dict[str, Any]]
) -> None:
"""Test removing users where some succeed and some fail."""
"""Test removing users one by one where some succeed and some fail."""
if not team_with_users["users"] or len(test_users) < 4:
pytest.skip("Need users in team and at least 4 test users")
@ -233,52 +229,52 @@ class TestRemoveUsers:
valid_user_id: str = team_with_users["users"][0]["id"]
invalid_user_id: str = test_users[3]["id"] # Not in team
remove_payload: dict[str, list[str]] = {"user_ids": [valid_user_id, invalid_user_id]}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
# First remove valid user - should succeed
remove_payload1: dict[str, str] = {"user_id": valid_user_id}
res1: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload1)
assert res1["code"] == 0, res1
assert res1["data"]["user_id"] == valid_user_id
assert res["code"] == 0, res
assert len(res["data"]["removed"]) == 1
assert len(res["data"]["failed"]) == 1
assert res["data"]["removed"][0]["user_id"] == valid_user_id
assert "not a member" in res["data"]["failed"][0]["error"].lower()
# Then try to remove invalid user - should fail
remove_payload2: dict[str, str] = {"user_id": invalid_user_id}
res2: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload2)
assert res2["code"] == 102 # DATA_ERROR
assert "not a member" in res2["message"].lower()
@pytest.mark.p1
def test_remove_users_empty_list(
def test_remove_users_empty_user_id(
self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any]
) -> None:
"""Test removing users with empty list."""
"""Test removing user with empty user_id."""
tenant_id: str = test_team["id"]
remove_payload: dict[str, list[str]] = {"user_ids": []}
remove_payload: dict[str, str] = {"user_id": ""}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
res: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload)
assert res["code"] == 101 # ARGUMENT_ERROR
assert "non-empty" in res["message"].lower() or "empty" in res["message"].lower()
@pytest.mark.p1
def test_remove_users_missing_user_ids_field(
def test_remove_users_missing_user_id_field(
self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any]
) -> None:
"""Test removing users without 'user_ids' field."""
"""Test removing user without 'user_id' field."""
tenant_id: str = test_team["id"]
remove_payload: dict[str, Any] = {}
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
res: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload)
assert res["code"] == 101 # ARGUMENT_ERROR
@pytest.mark.p1
def test_remove_users_invalid_user_id_format(
self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any]
) -> None:
"""Test removing users with invalid user ID format."""
"""Test removing user with invalid user ID format."""
tenant_id: str = test_team["id"]
remove_payload: dict[str, list[Any]] = {"user_ids": [12345]} # Not a string
remove_payload: dict[str, Any] = {"user_id": 12345} # Not a string
res: dict[str, Any] = remove_users_from_team(web_api_auth, tenant_id, remove_payload)
# API returns error code when all removals fail
assert res["code"] == 102 # DATA_ERROR
assert len(res["data"]["removed"]) == 0
assert len(res["data"]["failed"]) == 1
assert "invalid" in res["data"]["failed"][0]["error"].lower()
res: dict[str, Any] = remove_user_from_team(web_api_auth, tenant_id, remove_payload)
# API returns error code when removal fails
assert res["code"] == 101 # ARGUMENT_ERROR (validation should catch non-string)
@pytest.mark.p1
def test_remove_users_not_owner_or_admin(
@ -305,8 +301,8 @@ class TestRemoveUsers:
# Try to remove the other user (normal user should not be able to)
other_user_id: str = test_users[1]["id"]
remove_payload: dict[str, list[str]] = {"user_ids": [other_user_id]}
res: dict[str, Any] = remove_users_from_team(normal_user_auth, tenant_id, remove_payload)
remove_payload: dict[str, str] = {"user_id": other_user_id}
res: dict[str, Any] = remove_user_from_team(normal_user_auth, tenant_id, remove_payload)
assert res["code"] == 108 # PERMISSION_ERROR
assert "owner" in res["message"].lower() or "admin" in res["message"].lower()
@ -346,15 +342,13 @@ class TestRemoveUsers:
admin_user_id: str = test_users[0]["id"]
# Try to remove the admin (should fail - last admin cannot remove themselves)
remove_payload: dict[str, list[str]] = {"user_ids": [admin_user_id]}
res: dict[str, Any] = remove_users_from_team(admin_auth, tenant_id, remove_payload)
# API may return error code when all removals fail, or permission error if role not updated
remove_payload: dict[str, str] = {"user_id": admin_user_id}
res: dict[str, Any] = remove_user_from_team(admin_auth, tenant_id, remove_payload)
# API may return error code when removal fails, or permission error if role not updated
assert res["code"] in [102, 108] # DATA_ERROR or PERMISSION_ERROR
if res["code"] == 102:
# If we get DATA_ERROR, check the failed entry
assert len(res["data"]["removed"]) == 0
assert len(res["data"]["failed"]) == 1
assert "cannot remove yourself" in res["data"]["failed"][0]["error"].lower() or "at least one" in res["data"]["failed"][0]["error"].lower()
# If we get DATA_ERROR, check the message
assert "cannot remove yourself" in res["message"].lower() or "at least one" in res["message"].lower()
else:
# If we get PERMISSION_ERROR, the user might not have admin role yet
assert "owner" in res["message"].lower() or "admin" in res["message"].lower()
@ -363,14 +357,14 @@ class TestRemoveUsers:
def test_remove_users_invalid_tenant_id(
self, web_api_auth: RAGFlowWebApiAuth, test_users: list[dict[str, Any]]
) -> None:
"""Test removing users from a non-existent team."""
"""Test removing user from a non-existent team."""
if not test_users:
pytest.skip("No test users created")
invalid_tenant_id: str = f"invalid_{uuid.uuid4().hex[:8]}"
remove_payload: dict[str, list[str]] = {"user_ids": [test_users[0]["id"]]}
remove_payload: dict[str, str] = {"user_id": test_users[0]["id"]}
res: dict[str, Any] = remove_users_from_team(web_api_auth, invalid_tenant_id, remove_payload)
res: dict[str, Any] = remove_user_from_team(web_api_auth, invalid_tenant_id, remove_payload)
assert res["code"] != 0
assert "not found" in res["message"].lower() or res["code"] in [100, 102, 108]