fix: clean merged panel identities

This commit is contained in:
3252a8
2026-05-22 14:11:31 +03:00
parent 8a38524774
commit 81707d9c7c
3 changed files with 236 additions and 40 deletions
+9 -38
View File
@@ -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:
+94 -2
View File
@@ -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:
+133
View File
@@ -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")