From 31eb5c06ad1acc03d9411ed5383eee53d95f52cf Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 23 May 2026 22:27:51 +0300 Subject: [PATCH] fix: repair linked panel email during sync --- backend/bot/app/web/webapp/auth.py | 10 +- backend/bot/handlers/admin/sync_admin.py | 142 +++++++++++++++++++++-- tests/test_account_linking_panel.py | 43 +++++++ tests/test_admin_sync_performance.py | 105 +++++++++++++++++ 4 files changed, 291 insertions(+), 9 deletions(-) diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index 87d64a7..086cc2e 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -1058,11 +1058,19 @@ async def _sync_panel_identity_for_user( payload["status"] = "ACTIVE" try: - await subscription_service.panel_service.update_user_details_on_panel( + updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel( user.panel_user_uuid, payload, log_response=False, ) + if not updated_panel_user or ( + isinstance(updated_panel_user, dict) and updated_panel_user.get("error") + ): + logger.warning( + "Panel identity update returned no success payload for user %s", + user.user_id, + ) + return False return True except Exception as exc: logger.warning( diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py index 453d206..e802b54 100644 --- a/backend/bot/handlers/admin/sync_admin.py +++ b/backend/bot/handlers/admin/sync_admin.py @@ -66,6 +66,8 @@ def _panel_identity_matches_user( panel_user: dict[str, Any], user: User, desired_description: str, + *, + missing_identity_fields_match: bool = True, ) -> bool: if desired_description and not _description_matches( panel_user.get("description"), @@ -73,17 +75,65 @@ def _panel_identity_matches_user( ): return False - if user.email and _normalize_panel_email(panel_user.get("email")) != user.email.strip().lower(): - return False + if user.email: + if "email" not in panel_user: + return missing_identity_fields_match + panel_email = _normalize_panel_email(panel_user.get("email")) + if panel_email != user.email.strip().lower(): + return False - if user.telegram_id and _coerce_panel_telegram_id(panel_user.get("telegramId")) != int( - user.telegram_id - ): - return False + if user.telegram_id: + if "telegramId" not in panel_user: + return missing_identity_fields_match + panel_telegram_id = _coerce_panel_telegram_id(panel_user.get("telegramId")) + if panel_telegram_id != int(user.telegram_id): + return False return True +def _panel_identity_needs_full_fetch(panel_user: dict[str, Any], user: User) -> bool: + if user.email: + if "email" not in panel_user: + return True + if not _normalize_panel_email(panel_user.get("email")): + return True + if user.telegram_id: + if "telegramId" not in panel_user: + return True + if _coerce_panel_telegram_id(panel_user.get("telegramId")) is None: + return True + return False + + +async def _panel_identity_view_for_comparison( + panel_service: PanelApiService, + panel_uuid: str, + panel_user: dict[str, Any], + user: User, +) -> tuple[dict[str, Any], bool]: + """Return the most reliable panel user view available for identity comparison. + + Remnawave list responses may omit identity fields. When that happens, fetch + the concrete user by UUID before deciding whether the panel really needs a + repair PATCH. + """ + + if not _panel_identity_needs_full_fetch(panel_user, user): + return panel_user, True + try: + full_panel_user = await panel_service.get_user_by_uuid(panel_uuid) + except Exception: + logging.exception( + "Sync: failed to fetch full panel user %s for identity comparison", + panel_uuid, + ) + return panel_user, True + if not full_panel_user: + return panel_user, True + return full_panel_user, False + + def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]: payload: dict[str, Any] = {"description": description_text} if user.email: @@ -802,6 +852,72 @@ async def _perform_sync_impl( and str(linked_uuid) in panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()) ) + linked_uuid_present_on_panel = bool( + linked_uuid and str(linked_uuid) in panel_users_by_uuid + ) + panel_uuid_owner = users_by_panel_uuid.get(panel_uuid) + if ( + panel_uuid_owner + and panel_uuid_owner.user_id != existing_user.user_id + and not linked_uuid_still_present + ): + if linked_uuid_present_on_panel: + msg = ( + f"Panel UUID {panel_uuid} for user {actual_user_id} is already " + f"linked to local user {panel_uuid_owner.user_id}, while current " + f"local panel UUID {linked_uuid} still exists on panel." + ) + sync_errors.append(msg) + logging.warning("Sync: %s", msg) + continue + + previous_panel_uuid = existing_user.panel_user_uuid + previous_owner_user_id = panel_uuid_owner.user_id + previous_owner_email = panel_uuid_owner.email + previous_owner_telegram_id = panel_uuid_owner.telegram_id + ( + existing_user, + can_merge_panel_uuid_owner, + ) = await _merge_local_duplicate_panel_user_if_needed( + session, + existing_user=existing_user, + duplicate_panel_uuid=panel_uuid, + ) + if not can_merge_panel_uuid_owner: + logging.warning( + "Sync: panel UUID %s is already linked to local user %s; " + "skipping reassignment to user %s because local merge failed.", + panel_uuid, + previous_owner_user_id, + actual_user_id, + ) + continue + + existing_user.panel_user_uuid = panel_uuid + actual_user_id = existing_user.user_id + user_was_updated = True + users_uuid_updated += 1 + if previous_panel_uuid: + users_by_panel_uuid.pop(str(previous_panel_uuid), None) + users_by_panel_uuid[panel_uuid] = existing_user + users_by_user_id.pop(int(previous_owner_user_id), None) + users_by_user_id[int(existing_user.user_id)] = existing_user + if previous_owner_telegram_id is not None: + users_by_telegram_id.pop(int(previous_owner_telegram_id), None) + if existing_user.telegram_id is not None: + users_by_telegram_id[int(existing_user.telegram_id)] = existing_user + if previous_owner_email: + users_by_email.pop(previous_owner_email.strip().lower(), None) + if existing_user.email: + users_by_email[existing_user.email.strip().lower()] = existing_user + logging.info( + "Sync: merged local user %s owning panel UUID %s into user %s " + "and reassigned stale local panel UUID %s.", + previous_owner_user_id, + panel_uuid, + actual_user_id, + previous_panel_uuid, + ) if linked_uuid_still_present: is_duplicate_panel_identity = True ( @@ -859,7 +975,7 @@ async def _perform_sync_impl( panel_uuid, ) continue - else: + elif existing_user.panel_user_uuid != panel_uuid: existing_user.panel_user_uuid = panel_uuid user_was_updated = True users_uuid_updated += 1 @@ -911,10 +1027,20 @@ async def _perform_sync_impl( ) # Update description only when it differs from the current one on panel desired_description = description_text.strip() - if desired_description and not _panel_identity_matches_user( + ( + panel_user_for_identity, + missing_identity_fields_match, + ) = await _panel_identity_view_for_comparison( + panel_service, + panel_uuid, panel_user_dict, existing_user, + ) + if desired_description and not _panel_identity_matches_user( + panel_user_for_identity, + existing_user, desired_description, + missing_identity_fields_match=missing_identity_fields_match, ): await panel_service.update_user_details_on_panel( panel_uuid, diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py index 46139fa..e5ed2df 100644 --- a/tests/test_account_linking_panel.py +++ b/tests/test_account_linking_panel.py @@ -9,6 +9,7 @@ from bot.app.web.webapp import account as account_routes from bot.app.web.webapp.auth import ( _link_telegram_to_user, _sync_merged_panel_identity_for_user, + _sync_panel_identity_for_user, ) @@ -30,6 +31,48 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): async def __aexit__(self, exc_type, exc, tb): return None + async def test_panel_identity_sync_reports_failed_update_response(self): + user = SimpleNamespace( + user_id=42, + panel_user_uuid="panel-42", + email="linked@example.com", + telegram_id=42, + username="alice", + first_name=None, + last_name=None, + ) + panel_service = SimpleNamespace(update_user_details_on_panel=AsyncMock(return_value=None)) + request = SimpleNamespace( + app={"subscription_service": SimpleNamespace(panel_service=panel_service)} + ) + + result = await _sync_panel_identity_for_user(request, user) + + self.assertFalse(result) + panel_service.update_user_details_on_panel.assert_awaited_once() + + async def test_panel_identity_sync_reports_successful_update_response(self): + user = SimpleNamespace( + user_id=42, + panel_user_uuid="panel-42", + email="linked@example.com", + telegram_id=42, + username="alice", + first_name=None, + last_name=None, + ) + panel_service = SimpleNamespace( + update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-42"}) + ) + request = SimpleNamespace( + app={"subscription_service": SimpleNamespace(panel_service=panel_service)} + ) + + result = await _sync_panel_identity_for_user(request, user) + + self.assertTrue(result) + panel_service.update_user_details_on_panel.assert_awaited_once() + async def test_merged_panel_identity_deletes_source_before_updating_target(self): calls = [] diff --git a/tests/test_admin_sync_performance.py b/tests/test_admin_sync_performance.py index ce9e156..d592da8 100644 --- a/tests/test_admin_sync_performance.py +++ b/tests/test_admin_sync_performance.py @@ -7,6 +7,9 @@ from bot.handlers.admin.sync_admin import ( _absorb_duplicate_panel_identity, _coerce_panel_telegram_id, _description_matches, + _panel_identity_matches_user, + _panel_identity_needs_full_fetch, + _panel_identity_view_for_comparison, _should_update_lifetime_used_traffic, _subscription_update_delta, ) @@ -36,6 +39,108 @@ def test_panel_telegram_id_is_coerced_to_int(): assert _coerce_panel_telegram_id("") is None +def test_panel_identity_match_treats_missing_list_email_as_unknown(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + ) + panel_user = { + "description": "linked@example.com\nalice", + "telegramId": 42, + } + + assert _panel_identity_matches_user( + panel_user, + user, + "linked@example.com\nalice", + ) + + +def test_panel_identity_match_treats_missing_full_email_as_mismatch(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + ) + panel_user = { + "description": "linked@example.com\nalice", + "telegramId": 42, + } + + assert not _panel_identity_matches_user( + panel_user, + user, + "linked@example.com\nalice", + missing_identity_fields_match=False, + ) + + +def test_panel_identity_match_rejects_different_returned_email(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + ) + panel_user = { + "description": "linked@example.com\nalice", + "email": "other@example.com", + "telegramId": 42, + } + + assert not _panel_identity_matches_user( + panel_user, + user, + "linked@example.com\nalice", + ) + + +def test_panel_identity_needs_full_fetch_for_missing_or_blank_identity_fields(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + ) + + assert _panel_identity_needs_full_fetch({"telegramId": 42}, user) + assert _panel_identity_needs_full_fetch({"email": "", "telegramId": 42}, user) + assert _panel_identity_needs_full_fetch({"email": "linked@example.com"}, user) + assert not _panel_identity_needs_full_fetch( + {"email": "linked@example.com", "telegramId": "42"}, + user, + ) + + +def test_panel_identity_view_fetches_full_user_when_list_email_missing(): + panel_service = SimpleNamespace( + get_user_by_uuid=AsyncMock( + return_value={ + "uuid": "panel-1", + "description": "linked@example.com\nalice", + "email": "linked@example.com", + "telegramId": 42, + } + ) + ) + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + ) + + panel_user, missing_fields_match = asyncio.run( + _panel_identity_view_for_comparison( + panel_service, + "panel-1", + { + "uuid": "panel-1", + "description": "linked@example.com\nalice", + "telegramId": 42, + }, + user, + ) + ) + + assert panel_user["email"] == "linked@example.com" + assert not missing_fields_match + panel_service.get_user_by_uuid.assert_awaited_once_with("panel-1") + + def test_subscription_update_delta_skips_unchanged_fields(): end_date = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc) subscription = Subscription(