From 81707d9c7c80a939e3fbc95664f7366898b604d4 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 22 May 2026 14:11:31 +0300 Subject: [PATCH] fix: clean merged panel identities --- backend/bot/app/web/webapp/account.py | 47 ++------- backend/bot/app/web/webapp/auth.py | 96 ++++++++++++++++++- tests/test_account_linking_panel.py | 133 ++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 40 deletions(-) create mode 100644 tests/test_account_linking_panel.py diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index 67259ec..aa92be2 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -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: diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index bceffc6..1f89045 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -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: diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py new file mode 100644 index 0000000..cb66e9b --- /dev/null +++ b/tests/test_account_linking_panel.py @@ -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")