fix: clean merged panel identities
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
from .auth import _hash_email_password
|
||||
from .auth import _hash_email_password, _sync_merged_panel_identity_for_user
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
current_user.email = email
|
||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
if not merge_notice:
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
await session.commit()
|
||||
final_user_id = int(current_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(current_user)
|
||||
@@ -122,28 +123,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -345,28 +331,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
|
||||
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
redirect_path = "/settings" if purpose == "link" else "/"
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
final_user_id: Optional[int] = None
|
||||
source_user_id_for_cache: Optional[int] = None
|
||||
linked_user_for_panel: Optional[User] = None
|
||||
link_source_panel_uuid: Optional[str] = None
|
||||
link_final_panel_uuid: Optional[str] = None
|
||||
link_merge_notice: Optional[Dict[str, Any]] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if purpose == "link":
|
||||
current_user_id = int(state.get("user_id") or 0)
|
||||
source_user_id_for_cache = current_user_id
|
||||
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
|
||||
link_source_panel_uuid = (
|
||||
current_user_before_link.panel_user_uuid if current_user_before_link else None
|
||||
)
|
||||
db_user = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
telegram_user=telegram_user,
|
||||
settings=settings,
|
||||
)
|
||||
if int(db_user.user_id) != current_user_id:
|
||||
link_final_panel_uuid = db_user.panel_user_uuid
|
||||
link_merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=db_user,
|
||||
source_user_id=current_user_id,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
linked_user_for_panel = db_user
|
||||
else:
|
||||
db_user = await _ensure_user_from_telegram(
|
||||
session,
|
||||
@@ -388,6 +408,25 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
raise redirect(redirect_path, "failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
|
||||
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
|
||||
await _invalidate_webapp_user_caches(
|
||||
settings,
|
||||
source_user_id_for_cache,
|
||||
final_user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
|
||||
if purpose == "link" and link_merge_notice and linked_user_for_panel:
|
||||
merge_end_date_raw = link_merge_notice.get("final_end_date")
|
||||
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
linked_user_for_panel,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
final_panel_uuid=link_final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -974,6 +1013,14 @@ def _panel_description_for_user(user: User) -> str:
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
return None
|
||||
value = str(raw_value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
async def _sync_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
@@ -1013,6 +1060,53 @@ async def _sync_panel_identity_for_user(
|
||||
return False
|
||||
|
||||
|
||||
async def _delete_merged_source_panel_user(
|
||||
request: web.Request,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
) -> bool:
|
||||
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
|
||||
return True
|
||||
|
||||
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_merged_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
expire_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
|
||||
# before patching the surviving one so merged accounts can accept both IDs.
|
||||
await _delete_merged_source_panel_user(
|
||||
request,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
|
||||
)
|
||||
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
|
||||
|
||||
|
||||
async def _build_account_merge_notice(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -1102,7 +1196,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
@@ -1134,7 +1227,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bot.app.web.webapp.auth import (
|
||||
_link_telegram_to_user,
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
|
||||
|
||||
class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_merged_panel_identity_deletes_source_before_updating_target(self):
|
||||
calls = []
|
||||
|
||||
async def delete_source(*args, **kwargs):
|
||||
calls.append("delete")
|
||||
return True
|
||||
|
||||
async def update_target(*args, **kwargs):
|
||||
calls.append("update")
|
||||
return {"uuid": "panel-target"}
|
||||
|
||||
panel_service = SimpleNamespace(
|
||||
delete_user_from_panel=AsyncMock(side_effect=delete_source),
|
||||
update_user_details_on_panel=AsyncMock(side_effect=update_target),
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
|
||||
)
|
||||
user = SimpleNamespace(
|
||||
user_id=42,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
email="linked@example.com",
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
last_name=None,
|
||||
)
|
||||
|
||||
result = await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
user,
|
||||
source_panel_uuid="panel-source",
|
||||
final_panel_uuid="panel-target",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(calls, ["delete", "update"])
|
||||
panel_service.delete_user_from_panel.assert_awaited_once_with(
|
||||
"panel-source",
|
||||
log_response=False,
|
||||
)
|
||||
panel_service.update_user_details_on_panel.assert_awaited_once()
|
||||
update_uuid, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
||||
self.assertEqual(update_uuid, "panel-target")
|
||||
self.assertEqual(payload["email"], "linked@example.com")
|
||||
self.assertEqual(payload["telegramId"], 42)
|
||||
|
||||
async def test_telegram_merge_defers_panel_sync_until_source_cleanup(self):
|
||||
current_user = SimpleNamespace(
|
||||
user_id=-100,
|
||||
email="linked@example.com",
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-source",
|
||||
telegram_id=None,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
)
|
||||
existing_telegram_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email=None,
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
)
|
||||
merged_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email="linked@example.com",
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
)
|
||||
panel_service = SimpleNamespace(update_user_details_on_panel=AsyncMock())
|
||||
request = SimpleNamespace(
|
||||
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
|
||||
)
|
||||
session = SimpleNamespace(flush=AsyncMock())
|
||||
telegram_user = {
|
||||
"id": 42,
|
||||
"username": "alice",
|
||||
"first_name": "Alice",
|
||||
"last_name": "",
|
||||
"language_code": "ru",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bot.app.web.webapp.auth.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=current_user),
|
||||
),
|
||||
patch(
|
||||
"bot.app.web.webapp.auth.user_dal.get_user_by_telegram_id",
|
||||
AsyncMock(return_value=existing_telegram_user),
|
||||
),
|
||||
patch(
|
||||
"bot.app.web.webapp.auth.user_dal.merge_users",
|
||||
AsyncMock(return_value=merged_user),
|
||||
),
|
||||
):
|
||||
result = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
current_user_id=-100,
|
||||
telegram_user=telegram_user,
|
||||
settings=SimpleNamespace(DEFAULT_LANGUAGE="ru"),
|
||||
)
|
||||
|
||||
self.assertIs(result, merged_user)
|
||||
panel_service.update_user_details_on_panel.assert_not_awaited()
|
||||
self.assertEqual(merged_user.username, "alice")
|
||||
Reference in New Issue
Block a user