fix(admin): delete Remnawave user with bot account
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
_serialize_subscription,
|
||||
_serialize_user,
|
||||
)
|
||||
|
||||
import hashlib
|
||||
from html import escape as html_escape
|
||||
@@ -861,18 +872,62 @@ async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_id,
|
||||
user=user,
|
||||
)
|
||||
if panel_user_uuids and panel_service is None:
|
||||
await session.rollback()
|
||||
return _error(503, "panel_service_unavailable")
|
||||
|
||||
for panel_uuid in panel_user_uuids:
|
||||
try:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Admin webapp failed to delete panel user %s for user %s: %s",
|
||||
panel_uuid,
|
||||
target_id,
|
||||
exc,
|
||||
)
|
||||
await session.rollback()
|
||||
return _error(502, "panel_delete_failed", str(exc))
|
||||
|
||||
if not panel_deleted:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
502,
|
||||
"panel_delete_failed",
|
||||
f"Failed to delete panel user {panel_uuid}",
|
||||
)
|
||||
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log(
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"user_id": actor_id if actor_id != target_id else None,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"content": (
|
||||
f"Deleted user_id={target_id}; "
|
||||
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1264,8 +1264,13 @@ async def process_delete_user_confirmation_handler(
|
||||
return
|
||||
|
||||
try:
|
||||
if user_model.panel_user_uuid:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid)
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_user_id,
|
||||
user=user_model,
|
||||
)
|
||||
for panel_uuid in panel_user_uuids:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(panel_uuid)
|
||||
if not panel_deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
|
||||
@@ -12,10 +12,17 @@ from sqlalchemy.orm import aliased
|
||||
|
||||
from ..models import (
|
||||
AdAttribution,
|
||||
EmailVerificationCode,
|
||||
HwidDevicePurchase,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
Subscription,
|
||||
SupportTicket,
|
||||
SupportTicketMessage,
|
||||
TariffChange,
|
||||
TrafficTopup,
|
||||
TrafficWarning,
|
||||
User,
|
||||
UserBilling,
|
||||
UserPaymentMethod,
|
||||
@@ -583,6 +590,40 @@ async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_panel_user_uuids_for_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
) -> List[str]:
|
||||
"""Return every Remnawave user UUID linked to a bot user.
|
||||
|
||||
The canonical UUID normally lives on ``users.panel_user_uuid``, but older
|
||||
or partially-synced records can still have UUIDs only on subscription rows.
|
||||
"""
|
||||
|
||||
if user is None:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
|
||||
panel_uuids: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add_uuid(value: Any) -> None:
|
||||
panel_uuid = str(value or "").strip()
|
||||
if panel_uuid and panel_uuid not in seen:
|
||||
seen.add(panel_uuid)
|
||||
panel_uuids.append(panel_uuid)
|
||||
|
||||
add_uuid(getattr(user, "panel_user_uuid", None))
|
||||
|
||||
stmt = select(Subscription.panel_user_uuid).where(Subscription.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
for panel_uuid in result.scalars().all():
|
||||
add_uuid(panel_uuid)
|
||||
|
||||
return panel_uuids
|
||||
|
||||
|
||||
async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
"""Get comprehensive user statistics including active users, trial users, etc."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -706,19 +747,69 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||
)
|
||||
|
||||
# Clean up dependent tables that do not cascade automatically
|
||||
subscription_ids = select(Subscription.subscription_id).where(Subscription.user_id == user_id)
|
||||
payment_ids = select(Payment.payment_id).where(Payment.user_id == user_id)
|
||||
support_ticket_ids = select(SupportTicket.ticket_id).where(SupportTicket.user_id == user_id)
|
||||
|
||||
# Clean up dependent tables that do not cascade automatically.
|
||||
await session.execute(
|
||||
delete(TrafficTopup).where(
|
||||
or_(
|
||||
TrafficTopup.subscription_id.in_(subscription_ids),
|
||||
TrafficTopup.payment_id.in_(payment_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(HwidDevicePurchase).where(
|
||||
or_(
|
||||
HwidDevicePurchase.subscription_id.in_(subscription_ids),
|
||||
HwidDevicePurchase.payment_id.in_(payment_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(TariffChange).where(
|
||||
or_(
|
||||
TariffChange.subscription_id.in_(subscription_ids),
|
||||
TariffChange.payment_id.in_(payment_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
|
||||
)
|
||||
await session.execute(
|
||||
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
|
||||
)
|
||||
await session.execute(
|
||||
update(SupportTicketMessage)
|
||||
.where(SupportTicketMessage.author_user_id == user_id)
|
||||
.values(author_user_id=None)
|
||||
)
|
||||
await session.execute(delete(SupportTicket).where(SupportTicket.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(EmailVerificationCode).where(EmailVerificationCode.target_user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
await session.execute(delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(
|
||||
or_(
|
||||
PromoCodeActivation.user_id == user_id,
|
||||
PromoCodeActivation.payment_id.in_(payment_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id))
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
|
||||
await session.delete(user)
|
||||
await session.flush()
|
||||
|
||||
@@ -1028,7 +1028,7 @@
|
||||
description={at(
|
||||
"user_delete_confirm_subtitle",
|
||||
{},
|
||||
"Действие необратимо. Удалятся все платежи, подписки и логи."
|
||||
"Действие необратимо. Удалятся записи в БД бота и пользователь в Remnawave Panel."
|
||||
)}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={() => usersStore.updateState({ userDeleteOpen: false })}
|
||||
|
||||
+2
-2
@@ -317,7 +317,7 @@
|
||||
"admin_traffic_grant_regular_done": "✅ +{gb} GB of regular traffic granted to user {user_id}",
|
||||
"admin_traffic_grant_premium_done": "✅ +{gb} GB of premium traffic granted to user {user_id}",
|
||||
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} from the bot and the panel.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||
@@ -1235,7 +1235,7 @@
|
||||
"admin_user_ban_confirm_title": "Ban user?",
|
||||
"admin_user_ban_confirm_subtitle": "{name} will no longer be able to interact with the bot. This can be undone later.",
|
||||
"admin_user_delete_confirm_title": "Delete user?",
|
||||
"admin_user_delete_confirm_subtitle": "This action is irreversible. All payments, subscriptions, and logs will be deleted.",
|
||||
"admin_user_delete_confirm_subtitle": "This action is irreversible. Bot database records and the Remnawave Panel user will be deleted.",
|
||||
"admin_user_btn_confirm_delete": "Confirm Delete",
|
||||
"admin_btn_add_premium_squad": "Add premium squad",
|
||||
"admin_tariff_col_volume_gb": "Volume, GB",
|
||||
|
||||
+2
-2
@@ -317,7 +317,7 @@
|
||||
"admin_traffic_grant_regular_done": "✅ +{gb} ГБ обычного трафика выдано пользователю {user_id}",
|
||||
"admin_traffic_grant_premium_done": "✅ +{gb} ГБ премиум-трафика выдано пользователю {user_id}",
|
||||
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id} из бота и панели.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||
@@ -1235,7 +1235,7 @@
|
||||
"admin_user_ban_confirm_title": "Заблокировать пользователя?",
|
||||
"admin_user_ban_confirm_subtitle": "{name} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.",
|
||||
"admin_user_delete_confirm_title": "Удалить пользователя?",
|
||||
"admin_user_delete_confirm_subtitle": "Действие необратимо. Удалятся все платежи, подписки и логи.",
|
||||
"admin_user_delete_confirm_subtitle": "Действие необратимо. Удалятся записи в БД бота и пользователь в Remnawave Panel.",
|
||||
"admin_user_btn_confirm_delete": "Подтвердить удаление",
|
||||
"admin_btn_add_premium_squad": "Добавить premium-сквад",
|
||||
"admin_tariff_col_volume_gb": "Объём, GB",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bot.app.web.admin_api_impl import users as admin_users
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
|
||||
async def rollback(self):
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
class AdminUserDeleteRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _request(self, session: FakeSession, app_overrides=None):
|
||||
app = {
|
||||
"settings": SimpleNamespace(),
|
||||
"async_session_factory": lambda: session,
|
||||
}
|
||||
app.update(app_overrides or {})
|
||||
return SimpleNamespace(
|
||||
app=app,
|
||||
match_info={"user_id": "42"},
|
||||
)
|
||||
|
||||
async def test_deletes_panel_users_before_bot_db_user(self):
|
||||
session = FakeSession()
|
||||
calls = []
|
||||
|
||||
async def delete_panel_user(panel_uuid, log_response=False):
|
||||
calls.append(("panel", panel_uuid, log_response))
|
||||
return True
|
||||
|
||||
async def delete_db_user(_session, user_id):
|
||||
calls.append(("db", user_id))
|
||||
return True
|
||||
|
||||
panel_service = SimpleNamespace(
|
||||
delete_user_from_panel=AsyncMock(side_effect=delete_panel_user),
|
||||
)
|
||||
request = self._request(session, {"panel_service": panel_service})
|
||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
||||
|
||||
with (
|
||||
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||
patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)),
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"get_panel_user_uuids_for_user",
|
||||
AsyncMock(return_value=["panel-main", "panel-sub"]),
|
||||
) as collect_uuids,
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"delete_user_and_relations",
|
||||
AsyncMock(side_effect=delete_db_user),
|
||||
),
|
||||
patch.object(
|
||||
admin_users.message_log_dal,
|
||||
"create_message_log_no_commit",
|
||||
AsyncMock(),
|
||||
) as log_mock,
|
||||
patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
|
||||
):
|
||||
response = await admin_users.admin_user_delete_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(json.loads(response.text)["ok"], True)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("panel", "panel-main", False),
|
||||
("panel", "panel-sub", False),
|
||||
("db", 42),
|
||||
],
|
||||
)
|
||||
collect_uuids.assert_awaited_once_with(session, 42, user=user)
|
||||
log_payload = log_mock.await_args.args[1]
|
||||
self.assertEqual(log_payload["event_type"], "admin_delete_user_webapp")
|
||||
self.assertIn("panel-main,panel-sub", log_payload["content"])
|
||||
self.assertTrue(session.committed)
|
||||
self.assertFalse(session.rolled_back)
|
||||
|
||||
async def test_aborts_when_panel_service_is_unavailable_for_panel_user(self):
|
||||
session = FakeSession()
|
||||
request = self._request(session)
|
||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
||||
|
||||
with (
|
||||
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||
patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)),
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"get_panel_user_uuids_for_user",
|
||||
AsyncMock(return_value=["panel-main"]),
|
||||
),
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"delete_user_and_relations",
|
||||
AsyncMock(),
|
||||
) as delete_db,
|
||||
):
|
||||
response = await admin_users.admin_user_delete_route(request)
|
||||
|
||||
self.assertEqual(response.status, 503)
|
||||
self.assertEqual(json.loads(response.text)["error"], "panel_service_unavailable")
|
||||
delete_db.assert_not_awaited()
|
||||
self.assertTrue(session.rolled_back)
|
||||
self.assertFalse(session.committed)
|
||||
|
||||
async def test_aborts_when_panel_delete_fails(self):
|
||||
session = FakeSession()
|
||||
panel_service = SimpleNamespace(
|
||||
delete_user_from_panel=AsyncMock(return_value=False),
|
||||
)
|
||||
request = self._request(session, {"panel_service": panel_service})
|
||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
||||
|
||||
with (
|
||||
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||
patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)),
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"get_panel_user_uuids_for_user",
|
||||
AsyncMock(return_value=["panel-main"]),
|
||||
),
|
||||
patch.object(
|
||||
admin_users.user_dal,
|
||||
"delete_user_and_relations",
|
||||
AsyncMock(),
|
||||
) as delete_db,
|
||||
):
|
||||
response = await admin_users.admin_user_delete_route(request)
|
||||
|
||||
self.assertEqual(response.status, 502)
|
||||
self.assertEqual(json.loads(response.text)["error"], "panel_delete_failed")
|
||||
panel_service.delete_user_from_panel.assert_awaited_once_with(
|
||||
"panel-main",
|
||||
log_response=False,
|
||||
)
|
||||
delete_db.assert_not_awaited()
|
||||
self.assertTrue(session.rolled_back)
|
||||
self.assertFalse(session.committed)
|
||||
@@ -29,6 +29,73 @@ class FakeResult:
|
||||
|
||||
|
||||
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(
|
||||
return_value=FakeResult(["panel-main", "panel-sub", "panel-sub", ""])
|
||||
),
|
||||
)
|
||||
|
||||
result = await user_dal.get_panel_user_uuids_for_user(session, 42, user=user)
|
||||
|
||||
self.assertEqual(result, ["panel-main", "panel-sub"])
|
||||
stmt = session.execute.await_args.args[0]
|
||||
sql = str(
|
||||
stmt.compile(
|
||||
dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
self.assertIn("subscriptions", sql)
|
||||
self.assertIn("42", sql)
|
||||
|
||||
async def test_delete_user_and_relations_cleans_dependent_tables_before_parents(self):
|
||||
user = SimpleNamespace(user_id=42)
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=lambda stmt: FakeResult()),
|
||||
delete=AsyncMock(),
|
||||
flush=AsyncMock(),
|
||||
)
|
||||
|
||||
with patch("db.dal.user_dal.get_user_by_id", AsyncMock(return_value=user)):
|
||||
deleted = await user_dal.delete_user_and_relations(session, 42)
|
||||
|
||||
self.assertTrue(deleted)
|
||||
|
||||
delete_tables = []
|
||||
update_tables = []
|
||||
for call in session.execute.await_args_list:
|
||||
stmt = call.args[0]
|
||||
if isinstance(stmt, Delete):
|
||||
delete_tables.append(stmt.table.name)
|
||||
elif isinstance(stmt, Update):
|
||||
update_tables.append(stmt.table.name)
|
||||
|
||||
self.assertLess(delete_tables.index("traffic_topups"), delete_tables.index("payments"))
|
||||
self.assertLess(delete_tables.index("traffic_topups"), delete_tables.index("subscriptions"))
|
||||
self.assertLess(
|
||||
delete_tables.index("hwid_device_purchases"),
|
||||
delete_tables.index("payments"),
|
||||
)
|
||||
self.assertLess(delete_tables.index("tariff_changes"), delete_tables.index("payments"))
|
||||
self.assertLess(
|
||||
delete_tables.index("traffic_warnings"),
|
||||
delete_tables.index("subscriptions"),
|
||||
)
|
||||
self.assertLess(
|
||||
delete_tables.index("promo_code_activations"),
|
||||
delete_tables.index("payments"),
|
||||
)
|
||||
self.assertLess(
|
||||
delete_tables.index("support_ticket_messages"),
|
||||
delete_tables.index("support_tickets"),
|
||||
)
|
||||
self.assertIn("support_ticket_messages", update_tables)
|
||||
self.assertIn("email_verification_codes", delete_tables)
|
||||
session.delete.assert_awaited_once_with(user)
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
async def test_get_user_ids_without_active_subscription_uses_left_join_null_check(self):
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=FakeResult([2, 3])),
|
||||
|
||||
Reference in New Issue
Block a user