fix: repair linked panel email during sync
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user