diff --git a/backend/bot/app/web/admin_api_impl/settings.py b/backend/bot/app/web/admin_api_impl/settings.py index 91366b4..2763791 100644 --- a/backend/bot/app/web/admin_api_impl/settings.py +++ b/backend/bot/app/web/admin_api_impl/settings.py @@ -19,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response: overrides_by_key = {entry["key"]: entry for entry in overrides} fields = manifest_payload() + webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/") sections: Dict[str, Dict[str, Any]] = {} for field in fields: key = field["key"] @@ -53,6 +54,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response: response_field["read_error"] = read_error if is_secret: response_field["has_value"] = bool(value) + webhook_path = str(response_field.get("webhook_path") or "").strip() + if webhook_path: + if not webhook_path.startswith("/"): + webhook_path = f"/{webhook_path}" + response_field["webhook_path"] = webhook_path + response_field["webhook_base_url_configured"] = bool(webhook_base_url) + if webhook_base_url: + response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}" sections[section_id]["fields"].append(response_field) ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 2bee5b2..1ede0fa 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -562,7 +562,11 @@ def manifest_payload() -> List[dict]: same value so existing UIs that only read ``placeholder`` also show the hint inside the empty input. """ - from bot.payment_providers import find_manifest_owner, manifest_field_default + from bot.payment_providers import ( + find_manifest_owner, + manifest_field_default, + provider_webhook_metadata, + ) sections_order = { "general": 1, @@ -587,10 +591,12 @@ def manifest_payload() -> List[dict]: ) default_value: Optional[str] = None + webhook_metadata: Optional[dict] = None owner = find_manifest_owner(field.key) if owner is not None: spec, manifest_field = owner default_value = manifest_field_default(spec, manifest_field) + webhook_metadata = provider_webhook_metadata(spec) placeholder = field.placeholder if not placeholder and default_value: @@ -617,6 +623,8 @@ def manifest_payload() -> List[dict]: } if default_value is not None: item["default"] = default_value + if webhook_metadata: + item.update(webhook_metadata) if field.choices: item["choices"] = [ { diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index 67259ec..d6ed3ce 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -2,7 +2,11 @@ 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, + _notify_account_merged, + _sync_merged_panel_identity_for_user, +) from .common import _invalidate_webapp_user_caches @@ -109,7 +113,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 +127,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: @@ -178,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response: return _json_error(500, "link_failed", "Link failed") await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) + if merge_notice: + await _notify_account_merged( + request, + settings, + merge_notice=merge_notice, + email=final_email, + telegram_id=final_telegram_id, + username=final_username, + first_name=final_first_name, + ) if should_notify_email_linked: try: from bot.services.notification_service import NotificationService @@ -345,28 +345,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: @@ -401,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: return _json_error(500, "link_failed", "Link failed") await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) + if merge_notice: + await _notify_account_merged( + request, + settings, + merge_notice=merge_notice, + email=final_email, + telegram_id=final_telegram_id, + username=final_username, + first_name=final_first_name, + ) if should_notify_telegram_linked and final_telegram_id: try: from bot.services.notification_service import NotificationService diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index 6cbb4a3..f7c0391 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -834,6 +834,45 @@ def _run_git_command(*args: str) -> str: return result.stdout.strip() +def _normalize_version_branch(raw_branch: str) -> str: + branch = str(raw_branch or "").strip() + for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"): + if branch.startswith(prefix): + branch = branch[len(prefix) :] + break + if branch == "HEAD": + return "" + return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48] + + +def _resolve_version_branch() -> str: + for env_name in ( + "REMNAWAVE_MINISHOP_BRANCH", + "GIT_BRANCH", + "BRANCH_NAME", + "GITHUB_REF_NAME", + "CI_COMMIT_REF_NAME", + ): + branch = _normalize_version_branch(os.getenv(env_name, "")) + if branch: + return branch + return _normalize_version_branch( + _run_git_command("branch", "--show-current") + or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD") + ) + + +def _format_app_version(tag: str, sha: str, branch: str) -> str: + branch_suffix = "" if not branch or branch == "main" else f"-{branch}" + if tag and sha: + return f"{tag}{branch_suffix}+g{sha}" + if sha: + return f"dev{branch_suffix}+g{sha}" + if tag: + return f"{tag}{branch_suffix}" + return f"dev{branch_suffix}+unknown" + + def _resolve_app_version() -> str: global _APP_VERSION_CACHE if _APP_VERSION_CACHE: @@ -855,21 +894,8 @@ def _resolve_app_version() -> str: tag = _run_git_command("describe", "--tags", "--abbrev=0") sha = _run_git_command("rev-parse", "--short", "HEAD") - dirty = bool(_run_git_command("status", "--porcelain")) - - if tag and sha: - commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count") - if commits_since_tag and commits_since_tag != "0": - version = f"{tag}+{commits_since_tag}.g{sha}" - else: - version = tag - elif sha: - version = f"dev+g{sha}" - else: - version = "dev+unknown" - - if dirty: - version = f"{version}-dirty" + branch = _resolve_version_branch() + version = _format_app_version(tag, sha, branch) _APP_VERSION_CACHE = version return version @@ -1372,10 +1398,10 @@ def _resolve_webapp_js_asset_name() -> str: def _resolve_webapp_admin_js_asset_name() -> str: - return _resolve_hashed_js_asset_name( - kind="admin-js", - base_name="subscription_webapp_admin", - ) + # The admin bundle is lazy-loaded from the already running Mini App. In + # deployments where nginx serves static files in front of aiohttp, stale + # hashed admin filenames can 404 even though the runtime build asset exists. + return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js") def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str: @@ -1405,10 +1431,9 @@ def _resolve_webapp_css_asset_name() -> str: def _resolve_webapp_admin_css_asset_name() -> str: - return _resolve_hashed_css_asset_name( - kind="admin-css", - base_name="subscription_webapp_admin", - ) + # Keep the lazy-loaded admin stylesheet on the stable build filename for + # the same reason as the JS bundle above. + return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css") def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str: diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index bceffc6..87d64a7 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,34 @@ 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, + ) + await _notify_account_merged( + request, + settings, + merge_notice=link_merge_notice, + email=linked_user_for_panel.email, + telegram_id=_telegram_id_for_user(linked_user_for_panel), + username=linked_user_for_panel.username, + first_name=linked_user_for_panel.first_name, + ) + 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 +1022,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, @@ -995,7 +1051,11 @@ async def _sync_panel_identity_for_user( if user.email: payload["email"] = user.email if expire_at is not None: + if expire_at.tzinfo is None: + expire_at = expire_at.replace(tzinfo=timezone.utc) payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") + if expire_at > datetime.now(timezone.utc): + payload["status"] = "ACTIVE" try: await subscription_service.panel_service.update_user_details_on_panel( @@ -1013,6 +1073,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, *, @@ -1050,6 +1157,42 @@ async def _build_account_merge_notice( } +async def _notify_account_merged( + request: web.Request, + settings: Settings, + *, + merge_notice: Optional[Dict[str, Any]], + email: Optional[str], + telegram_id: Optional[int], + username: Optional[str], + first_name: Optional[str], +) -> None: + if not merge_notice: + return + try: + from bot.services.notification_service import NotificationService + + bot: Bot = request.app["bot"] + notification_service = NotificationService( + bot, + settings, + request.app.get("i18n"), + ) + await notification_service.notify_account_merged( + primary_user_id=int(merge_notice.get("primary_user_id") or 0), + removed_user_id=int(merge_notice.get("removed_user_id") or 0), + email=email, + telegram_id=telegram_id, + username=username, + first_name=first_name, + final_end_date_text=str(merge_notice.get("final_end_date_text") or ""), + primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"), + removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"), + ) + except Exception: + logger.exception("Failed to send account merged notification") + + def _apply_telegram_profile_to_user( user: User, telegram_user: Dict[str, Any], @@ -1102,7 +1245,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 +1276,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/backend/bot/app/web/webapp/cache_helpers.py b/backend/bot/app/web/webapp/cache_helpers.py index 97fc40e..7f06157 100644 --- a/backend/bot/app/web/webapp/cache_helpers.py +++ b/backend/bot/app/web/webapp/cache_helpers.py @@ -75,15 +75,25 @@ def invalidate_local_webapp_user_payload( def invalidate_all_local_webapp_user_payloads( settings: Settings, + namespace: Optional[str] = None, *, - include_devices: bool = False, + include_devices: Optional[bool] = None, ) -> None: - namespaces = set(_payload_namespaces(include_devices)) + if include_devices is not None: + namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices)) + elif namespace is not None: + namespaces = {namespace} + else: + namespaces = None + for (settings_id, cache_namespace, _ttl), cache in tuple( _WEBAPP_USER_PAYLOAD_CACHES.items() ): - if settings_id == id(settings) and cache_namespace in namespaces: - cache.invalidate() + if settings_id != id(settings): + continue + if namespaces is not None and cache_namespace not in namespaces: + continue + cache.invalidate() async def invalidate_webapp_user_caches( @@ -117,10 +127,18 @@ async def invalidate_all_webapp_user_payloads( *, include_devices: bool = False, ) -> None: - invalidate_all_local_webapp_user_payloads(settings, include_devices=include_devices) for namespace in _payload_namespaces(include_devices): + invalidate_all_local_webapp_user_payloads(settings, namespace=namespace) try: pattern = redis_key(settings, "cache", "webapp", namespace, "*") await cache_delete_pattern(settings, pattern) except Exception: continue + + +async def invalidate_all_webapp_user_caches( + settings: Settings, + *, + include_devices: bool = False, +) -> None: + await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices) diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py index fa74835..453d206 100644 --- a/backend/bot/handlers/admin/sync_admin.py +++ b/backend/bot/handlers/admin/sync_admin.py @@ -1,6 +1,6 @@ import asyncio import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Optional, Union from aiogram import Bot, Router, types @@ -42,8 +42,55 @@ def _normalize_description(value: Optional[str]) -> str: return "\n".join((value or "").split()).strip() +def _repair_cp1251_mojibake(value: str) -> str: + try: + return value.encode("latin1").decode("cp1251") + except (UnicodeEncodeError, UnicodeDecodeError): + return value + + +def _description_variants(value: Optional[str]) -> set[str]: + normalized = _normalize_description(value) + variants = {normalized} + repaired = _normalize_description(_repair_cp1251_mojibake(normalized)) + if repaired: + variants.add(repaired) + return variants + + def _description_matches(current: Optional[str], desired: str) -> bool: - return _normalize_description(current) == _normalize_description(desired) + return bool(_description_variants(current) & _description_variants(desired)) + + +def _panel_identity_matches_user( + panel_user: dict[str, Any], + user: User, + desired_description: str, +) -> bool: + if desired_description and not _description_matches( + panel_user.get("description"), + desired_description, + ): + return False + + if user.email and _normalize_panel_email(panel_user.get("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 + + return True + + +def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]: + payload: dict[str, Any] = {"description": description_text} + if user.email: + payload["email"] = user.email + if user.telegram_id: + payload["telegramId"] = user.telegram_id + return payload def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool: @@ -61,6 +108,21 @@ def _as_utc(value: datetime) -> datetime: return value.astimezone(timezone.utc) +def _panel_expire_at(panel_user: dict[str, Any]) -> Optional[datetime]: + raw_value = panel_user.get("expireAt") + if not raw_value: + return None + try: + return datetime.fromisoformat(str(raw_value).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + + +def _panel_subscription_uuid(panel_user: dict[str, Any]) -> Optional[str]: + value = panel_user.get("subscriptionUuid") or panel_user.get("shortUuid") + return str(value) if value else None + + def _should_update_lifetime_used_traffic( existing_user, lifetime_used: int, @@ -303,6 +365,211 @@ async def _bind_panel_email_to_user( return existing_user, True +async def _merge_local_duplicate_panel_user_if_needed( + session: AsyncSession, + *, + existing_user, + duplicate_panel_uuid: str, +): + duplicate_local_user = await user_dal.get_user_by_panel_uuid(session, duplicate_panel_uuid) + if not duplicate_local_user or duplicate_local_user.user_id == existing_user.user_id: + return existing_user, True + + try: + merged_user = await user_dal.merge_users( + session, + source_user_id=duplicate_local_user.user_id, + target_user_id=existing_user.user_id, + ) + logging.info( + "Sync: merged local duplicate user %s into %s for duplicate panel UUID %s.", + duplicate_local_user.user_id, + merged_user.user_id, + duplicate_panel_uuid, + ) + return merged_user, True + except Exception as exc: + logging.warning( + "Sync: could not merge local duplicate user %s into %s for panel UUID %s: %s", + duplicate_local_user.user_id, + existing_user.user_id, + duplicate_panel_uuid, + exc, + ) + return existing_user, False + + +def _panel_identity_payload_with_expiry( + user, + *, + expire_at: datetime, +) -> dict[str, Any]: + description_text = "\n".join( + line + for line in [ + user.email or "", + user.username or "", + user.first_name or "", + user.last_name or "", + ] + if line + ) + payload = _panel_identity_update_payload(user, description_text) + payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") + if expire_at > datetime.now(timezone.utc): + payload["status"] = "ACTIVE" + return payload + + +async def _absorb_duplicate_panel_identity( + session: AsyncSession, + *, + panel_service: PanelApiService, + existing_user, + keep_panel_uuid: str, + keep_panel_user: Optional[dict[str, Any]], + duplicate_panel_user: dict[str, Any], + settings: Settings, + subscriptions_by_panel_uuid: dict[str, Subscription], + active_subscriptions_by_user_panel: dict[tuple[int, str], Subscription], +) -> dict[str, int | bool]: + duplicate_panel_uuid = str(duplicate_panel_user.get("uuid") or "") + if not duplicate_panel_uuid: + return {"resolved": False, "subscriptions_created": 0, "subscriptions_updated": 0} + + subscriptions_created = 0 + subscriptions_updated = 0 + now = datetime.now(timezone.utc) + duplicate_expire_at = _panel_expire_at(duplicate_panel_user) + duplicate_status = str(duplicate_panel_user.get("status") or "").upper() + duplicate_is_active = bool( + duplicate_expire_at and duplicate_status == "ACTIVE" and duplicate_expire_at > now + ) + + keep_subscription_uuid = _panel_subscription_uuid(keep_panel_user or {}) + target_sub = ( + subscriptions_by_panel_uuid.get(keep_subscription_uuid) if keep_subscription_uuid else None + ) + if not target_sub: + target_sub = active_subscriptions_by_user_panel.get( + (int(existing_user.user_id), keep_panel_uuid) + ) + + final_end_date: Optional[datetime] = None + if duplicate_is_active and duplicate_expire_at: + source_remaining = max(timedelta(0), duplicate_expire_at - now) + if target_sub: + target_end = _as_utc(target_sub.end_date) + base_end = target_end if target_end > now else now + final_end_date = base_end + source_remaining + update_payload: dict[str, Any] = { + "user_id": int(existing_user.user_id), + "panel_user_uuid": keep_panel_uuid, + "end_date": final_end_date, + "is_active": True, + "status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE", + } + if keep_subscription_uuid: + update_payload["panel_subscription_uuid"] = keep_subscription_uuid + update_delta = _subscription_update_delta(target_sub, update_payload) + if update_delta: + await subscription_dal.update_subscription( + session, + target_sub.subscription_id, + update_delta, + ) + for key, value in update_delta.items(): + setattr(target_sub, key, value) + subscriptions_updated += 1 + elif keep_subscription_uuid: + final_end_date = now + (duplicate_expire_at - now) + created_sub = await subscription_dal.upsert_subscription( + session, + { + "user_id": int(existing_user.user_id), + "panel_user_uuid": keep_panel_uuid, + "panel_subscription_uuid": keep_subscription_uuid, + "start_date": None, + "end_date": final_end_date, + "duration_months": None, + "is_active": True, + "status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE", + "traffic_limit_bytes": getattr(settings, "user_traffic_limit_bytes", 0), + "auto_renew_enabled": False, + }, + ) + subscriptions_by_panel_uuid[keep_subscription_uuid] = created_sub + active_subscriptions_by_user_panel[ + (int(created_sub.user_id), created_sub.panel_user_uuid) + ] = created_sub + subscriptions_created += 1 + + duplicate_subscription_uuid = _panel_subscription_uuid(duplicate_panel_user) + duplicate_sub = ( + subscriptions_by_panel_uuid.get(duplicate_subscription_uuid) + if duplicate_subscription_uuid + else None + ) + if duplicate_sub and duplicate_sub is not target_sub: + await subscription_dal.update_subscription( + session, + duplicate_sub.subscription_id, + { + "user_id": int(existing_user.user_id), + "is_active": False, + "skip_notifications": True, + "status_from_panel": "MERGED_PANEL_DUPLICATE", + }, + ) + duplicate_sub.user_id = int(existing_user.user_id) + duplicate_sub.is_active = False + duplicate_sub.skip_notifications = True + duplicate_sub.status_from_panel = "MERGED_PANEL_DUPLICATE" + subscriptions_updated += 1 + elif not duplicate_sub: + await session.execute( + update(Subscription) + .where(Subscription.panel_user_uuid == duplicate_panel_uuid) + .values( + user_id=int(existing_user.user_id), + is_active=False, + skip_notifications=True, + status_from_panel="MERGED_PANEL_DUPLICATE", + ) + ) + + if final_end_date: + await panel_service.update_user_details_on_panel( + keep_panel_uuid, + _panel_identity_payload_with_expiry(existing_user, expire_at=final_end_date), + log_response=False, + ) + + deleted = await panel_service.delete_user_from_panel( + duplicate_panel_uuid, + log_response=False, + ) + if deleted: + logging.info( + "Sync: absorbed duplicate panel UUID %s into kept panel UUID %s for user %s.", + duplicate_panel_uuid, + keep_panel_uuid, + existing_user.user_id, + ) + else: + logging.warning( + "Sync: failed to delete duplicate panel UUID %s after absorbing it into %s.", + duplicate_panel_uuid, + keep_panel_uuid, + ) + + return { + "resolved": bool(deleted), + "subscriptions_created": subscriptions_created, + "subscriptions_updated": subscriptions_updated, + } + + async def perform_sync( panel_service: PanelApiService, session: AsyncSession, @@ -383,6 +650,11 @@ async def _perform_sync_impl( subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"] active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"] panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"] + panel_users_by_uuid = { + str(panel_user["uuid"]): panel_user + for panel_user in panel_users_data + if panel_user.get("uuid") + } for panel_user_dict in panel_users_data: try: @@ -532,12 +804,61 @@ async def _perform_sync_impl( ) if linked_uuid_still_present: is_duplicate_panel_identity = True + ( + existing_user, + can_absorb_duplicate_panel_user, + ) = await _merge_local_duplicate_panel_user_if_needed( + session, + existing_user=existing_user, + duplicate_panel_uuid=panel_uuid, + ) + if not can_absorb_duplicate_panel_user: + logging.warning( + "Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s because local duplicate merge failed.", # noqa: E501 + telegram_id_from_panel, + linked_uuid, + panel_uuid, + ) + continue + actual_user_id = existing_user.user_id + users_by_panel_uuid[linked_uuid] = existing_user + if existing_user.telegram_id is not None: + users_by_telegram_id[int(existing_user.telegram_id)] = existing_user + users_by_user_id[int(existing_user.user_id)] = existing_user + if existing_user.email: + users_by_email[existing_user.email.strip().lower()] = existing_user + merge_result = await _absorb_duplicate_panel_identity( + session, + panel_service=panel_service, + existing_user=existing_user, + keep_panel_uuid=str(linked_uuid), + keep_panel_user=panel_users_by_uuid.get(str(linked_uuid)), + duplicate_panel_user=panel_user_dict, + settings=settings, + subscriptions_by_panel_uuid=subscriptions_by_panel_uuid, + active_subscriptions_by_user_panel=( + active_subscriptions_by_user_panel + ), + ) + subscriptions_created += int(merge_result["subscriptions_created"]) + subscriptions_updated += int(merge_result["subscriptions_updated"]) + subscriptions_synced_count += int( + merge_result["subscriptions_created"] + ) + int(merge_result["subscriptions_updated"]) + if merge_result["resolved"]: + users_updated += 1 + users_uuid_updated += 1 + panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard( + str(panel_uuid) + ) + users_by_panel_uuid.pop(str(panel_uuid), None) logging.warning( - "Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s.", # noqa: E501 + "Sync: duplicate panel users share telegramId %s; kept local panel UUID %s and processed duplicate panel UUID %s.", # noqa: E501 telegram_id_from_panel, linked_uuid, panel_uuid, ) + continue else: existing_user.panel_user_uuid = panel_uuid user_was_updated = True @@ -589,28 +910,15 @@ async def _perform_sync_impl( if line ) # Update description only when it differs from the current one on panel - current_panel_description = ( - panel_user_dict.get("description") or "" - ).strip() desired_description = description_text.strip() - if desired_description and not _description_matches( - current_panel_description, desired_description + if desired_description and not _panel_identity_matches_user( + panel_user_dict, + existing_user, + desired_description, ): await panel_service.update_user_details_on_panel( panel_uuid, - { - "description": description_text, - **( - {"email": existing_user.email} - if existing_user.email - else {} - ), - **( - {"telegramId": existing_user.telegram_id} - if existing_user.telegram_id - else {} - ), - }, + _panel_identity_update_payload(existing_user, description_text), ) except Exception as e_desc: logging.warning( diff --git a/backend/bot/payment_providers/__init__.py b/backend/bot/payment_providers/__init__.py index 041440e..5c39619 100644 --- a/backend/bot/payment_providers/__init__.py +++ b/backend/bot/payment_providers/__init__.py @@ -25,6 +25,7 @@ from .registry import ( provider_emoji_map, provider_label_map, provider_telegram_button_text, + provider_webhook_metadata, resolve_provider_presentation, ) @@ -53,5 +54,6 @@ __all__ = [ "provider_telegram_button_text", "provider_emoji_map", "provider_label_map", + "provider_webhook_metadata", "resolve_provider_presentation", ] diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 0ae2b86..194aa62 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -333,6 +333,46 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide return None +def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]: + if spec.webhook_path and spec.webhook_route: + return spec + if not spec.service_key: + return None + for candidate in PAYMENT_PROVIDER_SPECS: + if ( + candidate.service_key == spec.service_key + and candidate.webhook_path + and candidate.webhook_route + ): + return candidate + return None + + +def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]: + """Return admin-manifest webhook metadata for a provider SPEC. + + Some visible payment buttons share one backing service and webhook route + (for example Platega SBP and Platega Crypto), so presentation-only specs + inherit the route from their service sibling. + """ + webhook_spec = _webhook_spec_for(spec) + if webhook_spec is None or not webhook_spec.webhook_path: + return None + try: + path = str(webhook_spec.webhook_path(None) or "").strip() + except Exception: + return None + if not path: + return None + return { + "provider_id": spec.id, + "provider_label": spec.label, + "webhook_provider_id": webhook_spec.id, + "webhook_path": path, + "webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url), + } + + def manifest_field_default( spec: PaymentProviderSpec, manifest_field: ProviderManifestField, diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py index 9a01a40..c713978 100644 --- a/backend/bot/services/notification_service.py +++ b/backend/bot/services/notification_service.py @@ -690,6 +690,51 @@ class NotificationService: profile_keyboard = self._build_profile_keyboard(_, telegram_id) await self._send_to_log_channel(message, reply_markup=profile_keyboard) + async def notify_account_merged( + self, + *, + primary_user_id: int, + removed_user_id: int, + email: Optional[str], + telegram_id: Optional[int], + username: Optional[str] = None, + first_name: Optional[str] = None, + final_end_date_text: Optional[str] = None, + primary_panel_user_uuid: Optional[str] = None, + removed_panel_user_uuid: Optional[str] = None, + ): + """Send notification when duplicate email/Telegram accounts are merged.""" + if not self.settings.LOG_NEW_USERS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + display_user_id = int(telegram_id or primary_user_id) + user_display = self._format_user_display( + user_id=display_user_id, + username=username, + first_name=first_name, + ) + + message = _( + "log_account_merged", + primary_user_id=primary_user_id, + removed_user_id=removed_user_id, + telegram_id=telegram_id or "", + user_display=user_display, + email=hd.quote(email or ""), + final_end_date=hd.quote(final_end_date_text or ""), + primary_panel_user_uuid=hd.quote(primary_panel_user_uuid or ""), + removed_panel_user_uuid=hd.quote(removed_panel_user_uuid or ""), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) + + profile_keyboard = ( + self._build_profile_keyboard(_, int(telegram_id)) if telegram_id else None + ) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) + def _format_traffic_gb_admin(self, traffic_gb: float) -> str: value = float(traffic_gb) if value.is_integer(): diff --git a/backend/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py index 999472d..a6d2ed9 100644 --- a/backend/bot/services/tariff_worker.py +++ b/backend/bot/services/tariff_worker.py @@ -155,6 +155,13 @@ class TariffTrafficWorker: cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid)) if cached_panel_user is not None: return cached_panel_user + return await self._repair_missing_panel_user_for_subscription( + session, + sub, + panel_users_by_uuid=panel_users_by_uuid, + semaphore=semaphore, + confirmed_missing=True, + ) async with semaphore: try: @@ -167,12 +174,22 @@ class TariffTrafficWorker: sub.panel_user_uuid, ) return {} - return data or {} + if data: + return data + return await self._repair_missing_panel_user_for_subscription( + session, + sub, + panel_users_by_uuid=None, + semaphore=semaphore, + confirmed_missing=False, + ) for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE): chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE] panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk)) for sub, panel_data in zip(chunk, panel_payloads): + if not panel_data: + continue try: tariff = self.settings.tariffs_config.require(sub.tariff_key) except Exception: @@ -255,6 +272,69 @@ class TariffTrafficWorker: ) return by_uuid + async def _repair_missing_panel_user_for_subscription( + self, + session: AsyncSession, + sub: Subscription, + *, + panel_users_by_uuid: Optional[dict[str, dict]], + semaphore: asyncio.Semaphore, + confirmed_missing: bool, + ) -> dict: + current_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip() + try: + user_id = int(sub.user_id) + except (TypeError, ValueError): + user_id = 0 + db_user = await user_dal.get_user_by_id(session, user_id) if user_id else None + canonical_uuid = str(getattr(db_user, "panel_user_uuid", "") or "").strip() + + if canonical_uuid and canonical_uuid != current_uuid: + panel_user = None + if panel_users_by_uuid is not None: + panel_user = panel_users_by_uuid.get(canonical_uuid) + else: + async with semaphore: + try: + panel_user = await self.panel_service.get_user_by_uuid( + canonical_uuid, + log_response=False, + ) + except Exception: + logging.exception( + "TariffTrafficWorker: failed to fetch canonical panel user %s", + canonical_uuid, + ) + panel_user = None + if panel_user: + logging.warning( + "TariffTrafficWorker: repaired subscription %s panel UUID %s -> %s", + sub.subscription_id, + current_uuid, + canonical_uuid, + ) + sub.panel_user_uuid = canonical_uuid + return panel_user + + if confirmed_missing: + sub.is_active = False + sub.skip_notifications = True + sub.status_from_panel = "PANEL_USER_NOT_FOUND" + logging.warning( + "TariffTrafficWorker: deactivated subscription %s because panel user %s " + "is missing", + sub.subscription_id, + current_uuid, + ) + else: + logging.warning( + "TariffTrafficWorker: skipping subscription %s because panel user %s " + "could not be fetched", + sub.subscription_id, + current_uuid, + ) + return {} + async def _ensure_period_reset_strategy( self, sub: Subscription, diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index b2868f4..e3151ac 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,32 +1,45 @@ -# Resolve the application version from .git at build time and emit a single -# tiny ``.build-version`` file. The .git tree is consumed in this throwaway -# stage and never copied into the runtime image — only the resulting one-line -# version string ships. This matches the runtime fallback chain in -# ``_resolve_app_version`` (REMNAWAVE_MINISHOP_VERSION env > .build-version -# file > live git > "dev+unknown") so the admin sidebar always shows a tag / -# sha even though the runtime images have no git tooling and no .git tree. +# Resolve the application version from .git at build time and emit a tiny +# .build-version file. The .git tree is consumed in this throwaway stage and +# never copied into the runtime image; only the tag + commit version string +# ships. Non-main builds include the branch name so they are visibly distinct +# from release builds. This matches the runtime fallback chain in _resolve_app_version +# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git > +# "dev+unknown") so the admin sidebar always shows a tag / sha even though the +# runtime images have no git tooling and no .git tree. FROM alpine:3.20 AS version-builder RUN apk add --no-cache git WORKDIR /repo +ARG REMNAWAVE_MINISHOP_BRANCH="" +ARG GIT_BRANCH="" +ARG BRANCH_NAME="" +ARG GITHUB_REF_NAME="" +ARG CI_COMMIT_REF_NAME="" COPY .git ./.git RUN set -eu; \ git config --global --add safe.directory /repo; \ tag=$(git describe --tags --abbrev=0 2>/dev/null || true); \ sha=$(git rev-parse --short HEAD 2>/dev/null || true); \ - dirty=$(git status --porcelain 2>/dev/null | head -c1 || true); \ + branch="${REMNAWAVE_MINISHOP_BRANCH:-${GIT_BRANCH:-${BRANCH_NAME:-${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME:-}}}}}"; \ + if [ -z "$branch" ]; then branch=$(git branch --show-current 2>/dev/null || true); fi; \ + if [ -z "$branch" ]; then branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true); fi; \ + case "$branch" in \ + refs/heads/*) branch="${branch#refs/heads/}" ;; \ + refs/remotes/origin/*) branch="${branch#refs/remotes/origin/}" ;; \ + origin/*) branch="${branch#origin/}" ;; \ + HEAD) branch="" ;; \ + esac; \ + branch_slug=$(printf '%s' "$branch" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//' | cut -c1-48); \ + branch_suffix=""; \ + if [ -n "$branch_slug" ] && [ "$branch_slug" != "main" ]; then branch_suffix="-$branch_slug"; fi; \ if [ -n "$tag" ] && [ -n "$sha" ]; then \ - commits_since_tag=$(git rev-list "$tag..HEAD" --count 2>/dev/null || true); \ - if [ -n "$commits_since_tag" ] && [ "$commits_since_tag" != "0" ]; then \ - version="${tag}+${commits_since_tag}.g${sha}"; \ - else \ - version="$tag"; \ - fi; \ + version="${tag}${branch_suffix}+g${sha}"; \ elif [ -n "$sha" ]; then \ - version="dev+g${sha}"; \ + version="dev${branch_suffix}+g${sha}"; \ + elif [ -n "$tag" ]; then \ + version="${tag}${branch_suffix}"; \ else \ - version="dev+unknown"; \ + version="dev${branch_suffix}+unknown"; \ fi; \ - if [ -n "$dirty" ]; then version="${version}-dirty"; fi; \ printf '%s' "$version" > /build-version; \ printf '%s' "${tag:-unknown}" > /build-tag; \ printf '%s' "${sha:-unknown}" > /build-commit diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a29e538..289e641 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -200,7 +200,7 @@ showLogin, telegramSdk, getTg: () => tg, - telegramOAuthClientId, + telegramOAuthClientId: () => telegramOAuthClientId, currentLang: () => currentLang, normalizeLangCode, updateLocalData: (updatedLanguage) => { @@ -405,6 +405,7 @@ $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); $: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); $: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData); + $: telegramMiniAppContext = hasTelegramLaunchParams(); $: telegramLoginUnavailable = !telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"; $: telegramLoginChecking = @@ -660,7 +661,10 @@ link.rel = "stylesheet"; link.href = href; link.onload = () => resolve(); - link.onerror = () => reject(new Error(`stylesheet_load_failed:${href}`)); + link.onerror = () => { + link.remove(); + reject(new Error(`stylesheet_load_failed:${href}`)); + }; document.head.appendChild(link); }); } @@ -673,11 +677,34 @@ script.src = src; script.async = true; script.onload = () => resolve(); - script.onerror = () => reject(new Error(`script_load_failed:${src}`)); + script.onerror = () => { + script.remove(); + reject(new Error(`script_load_failed:${src}`)); + }; document.head.appendChild(script); }); } + async function appendStylesheetWithFallback(id, href, fallbackName) { + const fallbackHref = resolveWebappAssetPath("", fallbackName); + try { + await appendStylesheetOnce(id, href); + } catch (error) { + if (!fallbackHref || href === fallbackHref) throw error; + await appendStylesheetOnce(id, fallbackHref); + } + } + + async function appendScriptWithFallback(id, src, fallbackName) { + const fallbackSrc = resolveWebappAssetPath("", fallbackName); + try { + await appendScriptOnce(id, src); + } catch (error) { + if (!fallbackSrc || src === fallbackSrc) throw error; + await appendScriptOnce(id, fallbackSrc); + } + } + function readAdminBundleApi() { const bundle = window.SubscriptionWebAppAdmin; return bundle?.mount ? bundle : null; @@ -697,8 +724,16 @@ adminBundlePromise = (async () => { const cssHref = resolveWebappAssetPath(CFG.adminCssAsset, "subscription_webapp_admin.css"); const jsSrc = resolveWebappAssetPath(CFG.adminJsAsset, "subscription_webapp_admin.js"); - await appendStylesheetOnce("subscription-webapp-admin-css", cssHref); - await appendScriptOnce("subscription-webapp-admin-js", jsSrc); + await appendStylesheetWithFallback( + "subscription-webapp-admin-css", + cssHref, + "subscription_webapp_admin.css", + ); + await appendScriptWithFallback( + "subscription-webapp-admin-js", + jsSrc, + "subscription_webapp_admin.js", + ); const loaded = readAdminBundleApi(); if (!loaded) throw new Error("admin_bundle_missing_mount"); adminBundleApi = loaded; @@ -1513,7 +1548,9 @@ {user} {userAgreementUrl} {userLanguage} - linkTelegramAccount={accountStore.linkTelegramAccount} + showLogout={!telegramMiniAppContext} + linkTelegramAccount={() => + accountStore.linkTelegramAccount(() => telegramMiniAppInitData)} logout={accountStore.logout} {openAdminPanel} {openExternalLink} diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 64eda7f..aec1f18 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -1,5 +1,14 @@ +{#snippet renderWebhookHint(webhook)} + {@const displayValue = webhook.url || webhook.path} +
+
+ {at("settings_provider_webhook_url", {}, "Webhook URL")} + + {webhook.url + ? at( + "settings_provider_webhook_url_hint", + {}, + "Use this URL in the provider webhook settings." + ) + : at( + "settings_provider_webhook_base_missing", + { path: webhook.path }, + `Set WEBHOOK_BASE_URL to show the full URL for ${webhook.path}.` + )} + +
+
+ {displayValue} + copyWebhookUrl(webhook)} + > + {#if copiedWebhookKey === webhook.key} + + {at("copied", {}, "Copied")} + {:else} + + {at("copy", {}, "Copy")} + {/if} + +
+
+{/snippet} + {#snippet renderField(field)} {@const revealed = isSecretRevealed(field.key)}
@@ -467,6 +580,9 @@ {@const labelGroups = groups.filter((g) => g.label)}
{#if rootGroup} + {#if rootGroup.webhook} + {@render renderWebhookHint(rootGroup.webhook)} + {/if} {#each rootGroup.fields as field} {@render renderField(field)} {/each} @@ -510,6 +626,9 @@
+ {#if group.webhook} + {@render renderWebhookHint(group.webhook)} + {/if} {#each group.fields as field} {@render renderField(field)} {/each} diff --git a/frontend/src/lib/webapp/stores/accountStore.js b/frontend/src/lib/webapp/stores/accountStore.js index 34a59eb..421d5bb 100644 --- a/frontend/src/lib/webapp/stores/accountStore.js +++ b/frontend/src/lib/webapp/stores/accountStore.js @@ -145,6 +145,12 @@ export function createAccountStore({ clearPasswordCooldownTimer(); } + function getTelegramOAuthClientId() { + const value = + typeof telegramOAuthClientId === "function" ? telegramOAuthClientId() : telegramOAuthClientId; + return Number(value || 0); + } + function closeSetPasswordDialog() { state.update((s) => ({ ...s, @@ -318,19 +324,21 @@ export function createAccountStore({ } } - async function linkTelegramAccount(getTelegramMiniAppInitData) { + async function linkTelegramAccount(getTelegramMiniAppInitData = () => "") { const s = get(state); if (s.linkTelegramBusy) return; + const readTelegramMiniAppInitData = + typeof getTelegramMiniAppInitData === "function" ? getTelegramMiniAppInitData : () => ""; const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams(); if (isTelegramMiniAppAttempt) { await telegramSdk.ensureForAction(); } - const initData = getTelegramMiniAppInitData(); + const initData = readTelegramMiniAppInitData(); if (initData) { await linkTelegramAccountWithPayload({ init_data: initData }); return; } - if (!telegramOAuthClientId) { + if (!getTelegramOAuthClientId()) { showToast(t("wa_auth_telegram_not_configured")); return; } @@ -362,6 +370,7 @@ export function createAccountStore({ } async function logout() { + if (telegramSdk.hasLaunchParams()) return; markManualLogout(); clearToken(); try { diff --git a/frontend/src/lib/webapp/webappBoot.js b/frontend/src/lib/webapp/webappBoot.js index feea227..33e9b23 100644 --- a/frontend/src/lib/webapp/webappBoot.js +++ b/frontend/src/lib/webapp/webappBoot.js @@ -61,11 +61,6 @@ export async function runWebappBoot({ ); } - if (isManuallyLoggedOut()) { - showLogin(); - return; - } - const widgetAuthData = readTelegramLoginWidgetAuthData(); if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return; @@ -78,6 +73,11 @@ export async function runWebappBoot({ } } + if (isManuallyLoggedOut()) { + showLogin(); + return; + } + if (getToken() || getCsrfToken()) { try { await loadData(); diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 4ba895e..c92a658 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -1889,6 +1889,65 @@ align-items: center; } +.admin-webhook-hint { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 16px; + align-items: center; + min-width: 0; + padding: 14px 18px; + border-bottom: 1px solid var(--admin-border); + background: color-mix(in srgb, var(--info) 7%, transparent); +} + +.admin-webhook-hint-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-webhook-hint-meta strong { + color: var(--admin-text); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-webhook-hint-meta small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.admin-webhook-value { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.admin-webhook-value code { + flex: 1 1 auto; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2)); + color: var(--admin-text); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-webhook-copy { + flex: 0 0 auto; +} + .admin-setting:last-child { border-bottom: 0; } @@ -2625,6 +2684,26 @@ padding: 14px 14px; } + .admin-webhook-hint { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + padding: 14px; + } + + .admin-webhook-value { + align-items: stretch; + flex-direction: column; + } + + .admin-webhook-value code { + white-space: normal; + overflow-wrap: anywhere; + } + + .admin-webhook-copy { + align-self: flex-start; + } + .admin-card-head { padding: 12px 14px; } @@ -2704,38 +2783,6 @@ } } -.settings-admin-block { - display: grid; - gap: 10px; - margin: 6px 0 0; -} - -.settings-row.settings-row-admin { - border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - border-radius: var(--radius); - padding: 10px 12px; - width: 100%; - cursor: pointer; - transition: - background 0.12s ease, - border-color 0.12s ease; -} - -.settings-row.settings-row-admin:hover { - background: color-mix(in srgb, #f59e0b 18%, transparent); - border-color: color-mix(in srgb, #f59e0b 58%, transparent); -} - -.settings-row.settings-row-admin > svg:first-child { - color: #fbbf24; - opacity: 1; -} - -.settings-row.settings-row-admin strong { - color: var(--text); -} - /* ============================================================ shadcn-svelte primitives: Tabs / Select / Switch / Label bits-ui exposes data-state attributes; styling here matches diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 1d8f1e9..f917cdc 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -1287,6 +1287,11 @@ a { gap: 8px; } +.settings-admin-block { + display: grid; + gap: 8px; +} + .settings-divider { height: 1px; background: var(--border); @@ -1329,6 +1334,56 @@ a { font-size: 12px; } +.settings-row.settings-row-admin { + min-height: 54px; + border-color: var(--warning-border); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--warning) 13%, var(--panel)), + color-mix(in srgb, var(--warning) 7%, var(--panel-2)) + ); + cursor: pointer; + transition: + transform 0.14s ease, + border-color 0.14s ease, + background 0.14s ease, + box-shadow 0.14s ease; +} + +.settings-row.settings-row-admin:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--warning) 52%, var(--border)); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--warning) 16%, var(--panel)), + color-mix(in srgb, var(--warning) 9%, var(--panel-2)) + ); +} + +.settings-row.settings-row-admin:active:not(:disabled) { + transform: translateY(1px); +} + +.settings-row.settings-row-admin:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--warning) 58%, var(--border)); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--warning) 24%, transparent), + inset 0 1px 0 var(--inset-highlight); +} + +.settings-row.settings-row-admin > svg:first-child { + color: var(--warning-text); + opacity: 1; +} + +.settings-row.settings-row-admin > svg:last-child { + color: color-mix(in srgb, var(--warning) 70%, var(--muted)); +} + +.settings-row.settings-row-admin strong { + color: var(--warning-text); +} + .settings-row-linked { grid-template-columns: 28px minmax(0, 1fr); border-color: var(--success-border); diff --git a/frontend/src/webapp/screens/SettingsScreen.svelte b/frontend/src/webapp/screens/SettingsScreen.svelte index e056f63..68b70f5 100644 --- a/frontend/src/webapp/screens/SettingsScreen.svelte +++ b/frontend/src/webapp/screens/SettingsScreen.svelte @@ -34,6 +34,7 @@ export let user = {}; export let userAgreementUrl = ""; export let userLanguage = ""; + export let showLogout = true; export let linkTelegramAccount = () => {}; export let logout = () => {}; @@ -187,10 +188,12 @@ {/if} - + {#if showLogout} + + {/if}
diff --git a/locales/en.json b/locales/en.json index 16674ce..48c154c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -363,6 +363,7 @@ "log_new_email_user_registration": "📧 New User (email)\n\n🆔 ID: {user_id}\n📧 Email: {email}{referral_text}\n📅 Time: {timestamp}", "log_account_email_linked": "📧 Email linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}", "log_account_telegram_linked": "📨 Telegram linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}", + "log_account_merged": "🔗 Accounts merged\n\n🆔 Kept user ID: {primary_user_id}\n🗑 Removed user ID: {removed_user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n⏰ New end date: {final_end_date}\n📋 Kept panel UUID: {primary_panel_user_uuid}\n📋 Removed panel UUID: {removed_panel_user_uuid}\n🕐 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_received_traffic": "{provider_emoji} Payment Received (traffic top-up)\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_traffic_purchase_line": "{gb} GB · {kind}", @@ -956,6 +957,11 @@ "admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_wata": "Wata", "admin_settings_subsection_heleket": "Heleket", + "admin_settings_provider_webhook_url": "Webhook URL", + "admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.", + "admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.", + "admin_copy": "Copy", + "admin_copied": "Copied", "admin_settings_validation_errors": "Errors: {errors}", "admin_settings_save_error": "Error: {error}", "admin_sync_started": "Synchronization started", diff --git a/locales/ru.json b/locales/ru.json index 2a480dc..f77c31f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -363,6 +363,7 @@ "log_new_email_user_registration": "📧 Новый пользователь (email)\n\n🆔 ID: {user_id}\n📧 Email: {email}{referral_text}\n📅 Время: {timestamp}", "log_account_email_linked": "📧 Привязана почта\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}", "log_account_telegram_linked": "📨 Привязан Telegram\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}", + "log_account_merged": "🔗 Аккаунты объединены\n\n🆔 Оставлен ID: {primary_user_id}\n🗑 Удалён ID: {removed_user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n⏰ Новая дата окончания: {final_end_date}\n📋 UUID оставленного в панели: {primary_panel_user_uuid}\n📋 UUID удалённого в панели: {removed_panel_user_uuid}\n🕐 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_received_traffic": "{provider_emoji} Получен платеж (докупка трафика)\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_traffic_purchase_line": "{gb} ГБ · {kind}", @@ -956,6 +957,11 @@ "admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_wata": "Wata", "admin_settings_subsection_heleket": "Heleket", + "admin_settings_provider_webhook_url": "Webhook URL", + "admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.", + "admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.", + "admin_copy": "Копировать", + "admin_copied": "Скопировано", "admin_settings_validation_errors": "Ошибки: {errors}", "admin_settings_save_error": "Ошибка: {error}", "admin_sync_started": "Синхронизация запущена", diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py new file mode 100644 index 0000000..46139fa --- /dev/null +++ b/tests/test_account_linking_panel.py @@ -0,0 +1,343 @@ +import json +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web import subscription_webapp # noqa: F401 +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, +) + + +class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): + class _AsyncSessionFactory: + def __init__(self): + self.session = SimpleNamespace( + commit=AsyncMock(), + rollback=AsyncMock(), + flush=AsyncMock(), + ) + + def __call__(self): + return self + + async def __aenter__(self): + return self.session + + async def __aexit__(self, exc_type, exc, tb): + return None + + 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_merged_panel_identity_reactivates_expired_target_with_transferred_time(self): + expire_at = datetime.now(timezone.utc) + timedelta(days=30) + panel_service = SimpleNamespace( + delete_user_from_panel=AsyncMock(return_value=True), + update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-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-email", + final_panel_uuid="panel-target", + expire_at=expire_at, + ) + + self.assertTrue(result) + panel_service.delete_user_from_panel.assert_awaited_once_with( + "panel-email", + log_response=False, + ) + _, payload = panel_service.update_user_details_on_panel.await_args.args[:2] + expected_expire_at = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") + self.assertEqual(payload["expireAt"], expected_expire_at) + self.assertEqual(payload["status"], "ACTIVE") + + 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") + + async def test_email_only_session_can_link_existing_telegram_only_account(self): + email_user = SimpleNamespace( + user_id=-100, + email="linked@example.com", + email_verified_at=object(), + panel_user_uuid="panel-email", + telegram_id=None, + username=None, + first_name=None, + last_name=None, + language_code="ru", + telegram_photo_url=None, + is_banned=False, + ) + telegram_user_record = SimpleNamespace( + user_id=42, + email=None, + email_verified_at=None, + panel_user_uuid="panel-telegram", + telegram_id=42, + username="old", + first_name=None, + last_name=None, + language_code="ru", + telegram_photo_url=None, + is_banned=False, + ) + merged_user = SimpleNamespace( + user_id=42, + email="linked@example.com", + email_verified_at=object(), + panel_user_uuid="panel-telegram", + telegram_id=42, + username="old", + first_name=None, + last_name=None, + language_code="ru", + telegram_photo_url=None, + is_banned=False, + ) + panel_calls = [] + + async def delete_source(*args, **kwargs): + panel_calls.append("delete") + return True + + async def update_target(*args, **kwargs): + panel_calls.append("update") + return {"uuid": "panel-telegram"} + + panel_service = SimpleNamespace( + delete_user_from_panel=AsyncMock(side_effect=delete_source), + update_user_details_on_panel=AsyncMock(side_effect=update_target), + ) + settings = SimpleNamespace( + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + REDIS_URL=None, + REDIS_KEY_PREFIX="test", + DEFAULT_LANGUAGE="ru", + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": self._AsyncSessionFactory(), + "subscription_service": SimpleNamespace(panel_service=panel_service), + "email_auth_service": None, + "i18n": None, + "bot": SimpleNamespace(), + }, + json=AsyncMock(return_value={"init_data": "telegram-init-data"}), + ) + telegram_auth_payload = { + "id": 42, + "username": "alice", + "first_name": "Alice", + "last_name": "", + "language_code": "ru", + } + notification_service = SimpleNamespace( + notify_account_telegram_linked=AsyncMock(), + notify_account_merged=AsyncMock(), + ) + + with ( + patch.object(account_routes, "_require_user_id", return_value=-100), + patch.object( + account_routes, + "_validate_telegram_auth_payload", + AsyncMock(return_value=telegram_auth_payload), + ), + patch.object( + account_routes.user_dal, + "get_user_by_id", + AsyncMock(return_value=email_user), + ), + patch.object( + account_routes.user_dal, + "get_user_by_telegram_id", + AsyncMock(return_value=telegram_user_record), + ), + patch.object( + account_routes.user_dal, + "merge_users", + AsyncMock(return_value=merged_user), + ) as merge_users, + patch.object( + account_routes.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=None), + ), + patch( + "bot.services.notification_service.NotificationService", + return_value=notification_service, + ), + ): + response = await account_routes.account_telegram_link_route(request) + + self.assertEqual(response.status, 200) + payload = json.loads(response.text) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["user_id"], 42) + self.assertEqual(payload["telegram_id"], 42) + self.assertEqual(payload["account_merge"]["removed_user_id"], -100) + self.assertEqual(payload["account_merge"]["primary_user_id"], 42) + merge_users.assert_awaited_once_with( + request.app["async_session_factory"].session, + source_user_id=-100, + target_user_id=42, + ) + self.assertEqual(panel_calls, ["delete", "update"]) + panel_service.delete_user_from_panel.assert_awaited_once_with( + "panel-email", + log_response=False, + ) + update_uuid, update_payload = panel_service.update_user_details_on_panel.await_args.args[:2] + self.assertEqual(update_uuid, "panel-telegram") + self.assertEqual(update_payload["email"], "linked@example.com") + self.assertEqual(update_payload["telegramId"], 42) + notification_service.notify_account_merged.assert_awaited_once_with( + primary_user_id=42, + removed_user_id=-100, + email="linked@example.com", + telegram_id=42, + username="alice", + first_name="Alice", + final_end_date_text="", + primary_panel_user_uuid="panel-telegram", + removed_panel_user_uuid="panel-email", + ) + self.assertIn("rw_webapp_session", response.cookies) diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 8633a66..39d55e0 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -93,3 +93,13 @@ def test_subscription_guide_settings_i18n_keys_exist(): field = manifest[setting_key] assert field["i18n_label_key"] in messages assert field["i18n_description_key"] in messages + + +def test_payment_provider_settings_include_webhook_metadata(): + manifest = _manifest_by_key() + + assert manifest["FREEKASSA_ENABLED"]["webhook_path"] == "/webhook/freekassa" + assert manifest["FREEKASSA_ENABLED"]["provider_id"] == "freekassa" + assert manifest["PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU"]["webhook_path"] == "/webhook/platega" + assert manifest["YOOKASSA_SHOP_ID"]["webhook_requires_base_url"] is True + assert "webhook_path" not in manifest["PAYMENT_STARS_WEBAPP_LABEL_RU"] diff --git a/tests/test_admin_sync_performance.py b/tests/test_admin_sync_performance.py index 68a7ecd..ce9e156 100644 --- a/tests/test_admin_sync_performance.py +++ b/tests/test_admin_sync_performance.py @@ -1,7 +1,10 @@ +import asyncio from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from unittest.mock import AsyncMock, patch from bot.handlers.admin.sync_admin import ( + _absorb_duplicate_panel_identity, _coerce_panel_telegram_id, _description_matches, _should_update_lifetime_used_traffic, @@ -14,6 +17,20 @@ def test_description_match_ignores_whitespace_shape(): assert _description_matches("email@example.com username", "email@example.com\nusername") +def test_description_match_accepts_cp1251_mojibake_from_panel(): + desired = "user@example.com\nalice\nАлексей\nЧерников" + panel_value = "user@example.com\nalice\nÀëåêñåé\n×åðíèêîâ" + + assert _description_matches(panel_value, desired) + + +def test_description_match_rejects_different_identity_after_mojibake_repair(): + desired = "user@example.com\nalice\nАлексей" + panel_value = "other@example.com\nalice\nÀëåêñåé" + + assert not _description_matches(panel_value, desired) + + def test_panel_telegram_id_is_coerced_to_int(): assert _coerce_panel_telegram_id("12345") == 12345 assert _coerce_panel_telegram_id("") is None @@ -122,3 +139,98 @@ def test_lifetime_traffic_update_allows_large_delta_and_skips_duplicate_panel_id settings=settings, is_duplicate_panel_identity=True, ) + + +def test_absorb_duplicate_panel_identity_extends_kept_user_and_deletes_duplicate(): + now = datetime.now(timezone.utc) + target_sub = SimpleNamespace( + subscription_id=10, + user_id=42, + panel_user_uuid="panel-keep", + panel_subscription_uuid="sub-keep", + end_date=now - timedelta(days=2), + is_active=False, + status_from_panel="EXPIRED", + ) + duplicate_sub = SimpleNamespace( + subscription_id=11, + user_id=42, + panel_user_uuid="panel-duplicate", + panel_subscription_uuid="sub-duplicate", + end_date=now + timedelta(days=30), + is_active=True, + skip_notifications=False, + status_from_panel="ACTIVE", + ) + panel_service = SimpleNamespace( + update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-keep"}), + delete_user_from_panel=AsyncMock(return_value=True), + ) + session = SimpleNamespace(execute=AsyncMock()) + settings = SimpleNamespace(user_traffic_limit_bytes=0) + user = SimpleNamespace( + user_id=42, + panel_user_uuid="panel-keep", + telegram_id=969808056, + email="paid@example.com", + username="alice", + first_name="Alice", + last_name=None, + ) + + async def update_subscription(_session, subscription_id, update_data): + sub = target_sub if subscription_id == target_sub.subscription_id else duplicate_sub + for key, value in update_data.items(): + setattr(sub, key, value) + return sub + + with patch( + "bot.handlers.admin.sync_admin.subscription_dal.update_subscription", + AsyncMock(side_effect=update_subscription), + ): + result = asyncio.run( + _absorb_duplicate_panel_identity( + session, + panel_service=panel_service, + existing_user=user, + keep_panel_uuid="panel-keep", + keep_panel_user={ + "uuid": "panel-keep", + "subscriptionUuid": "sub-keep", + "status": "EXPIRED", + "expireAt": (now - timedelta(days=2)).isoformat(), + }, + duplicate_panel_user={ + "uuid": "panel-duplicate", + "subscriptionUuid": "sub-duplicate", + "telegramId": 969808056, + "status": "ACTIVE", + "expireAt": (now + timedelta(days=30)).isoformat(), + }, + settings=settings, + subscriptions_by_panel_uuid={ + "sub-keep": target_sub, + "sub-duplicate": duplicate_sub, + }, + active_subscriptions_by_user_panel={}, + ) + ) + + assert result["resolved"] + assert result["subscriptions_updated"] == 2 + assert target_sub.is_active + assert target_sub.status_from_panel == "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE" + assert target_sub.panel_user_uuid == "panel-keep" + assert target_sub.end_date > now + timedelta(days=29) + assert not duplicate_sub.is_active + assert duplicate_sub.skip_notifications + assert duplicate_sub.status_from_panel == "MERGED_PANEL_DUPLICATE" + panel_service.update_user_details_on_panel.assert_awaited_once() + update_uuid, update_payload = panel_service.update_user_details_on_panel.await_args.args[:2] + assert update_uuid == "panel-keep" + assert update_payload["status"] == "ACTIVE" + assert update_payload["telegramId"] == 969808056 + panel_service.delete_user_from_panel.assert_awaited_once_with( + "panel-duplicate", + log_response=False, + ) diff --git a/tests/test_app_version_resolution.py b/tests/test_app_version_resolution.py index 098792e..defb55d 100644 --- a/tests/test_app_version_resolution.py +++ b/tests/test_app_version_resolution.py @@ -1,20 +1,21 @@ -"""Tests for ``_resolve_app_version`` — the source of the admin sidebar footer. +"""Tests for ``_resolve_app_version``: the admin sidebar footer source. The admin sidebar shows ``{appVersion}`` next to a "remnawave-minishop" GitHub -link. That string is rendered by -``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through the -following precedence chain: +link. Release builds from ``main`` should render the latest reachable tag and +commit sha, without a dirty suffix. Builds from other branches include the +branch name. That string is rendered by +``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through this +precedence chain: - 1. ``REMNAWAVE_MINISHOP_VERSION`` env var — manual override; - 2. ``$APP_ROOT/.build-version`` file — baked at Docker build time by the - ``version-builder`` stage in ``deploy/docker/Dockerfile`` (consumes .git - in a throwaway stage and ships only this one tiny file); - 3. live ``git describe`` / ``rev-parse`` — works in local dev where .git - is present; + 1. ``REMNAWAVE_MINISHOP_VERSION`` env var: manual override; + 2. ``$APP_ROOT/.build-version`` file: baked at Docker build time by the + ``version-builder`` stage in ``deploy/docker/Dockerfile``; + 3. live ``git describe`` / ``rev-parse``: local dev fallback where .git is + present; 4. ``"dev+unknown"`` as the ultimate fallback. -Before the build-time bake, the admin footer in production silently fell back -to step 4 because the Docker image carries no .git tree and no git binary. +The Docker image carries no git binary and no .git tree at runtime, so the +baked ``.build-version`` file is the production source for the sidebar. """ import importlib @@ -28,11 +29,20 @@ from unittest.mock import patch import bot.app.web.subscription_webapp # noqa: F401 from bot.app.web.webapp import assets as assets_module +_VERSION_ENV_NAMES = ( + "REMNAWAVE_MINISHOP_VERSION", + "REMNAWAVE_MINISHOP_BRANCH", + "GIT_BRANCH", + "BRANCH_NAME", + "GITHUB_REF_NAME", + "CI_COMMIT_REF_NAME", +) + def _reset_cache() -> None: # The resolver memoizes the first result in a module-level global. assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined] - # Some callers reach through the facade re-export — clear that too. + # Some callers reach through the facade re-export; clear that too. runtime = importlib.import_module("bot.app.web.webapp._runtime") runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined] @@ -41,6 +51,13 @@ def _resolve(): return assets_module._resolve_app_version() +def _clean_version_env() -> dict: + env = dict(os.environ) + for name in _VERSION_ENV_NAMES: + env.pop(name, None) + return env + + class EnvOverrideTests(unittest.TestCase): def setUp(self) -> None: _reset_cache() @@ -62,12 +79,13 @@ class EnvOverrideTests(unittest.TestCase): self.assertEqual(called["git"], 0) def test_blank_env_var_falls_through(self): - env = {"REMNAWAVE_MINISHOP_VERSION": " "} + env = _clean_version_env() + env["REMNAWAVE_MINISHOP_VERSION"] = " " with ( - patch.dict(os.environ, env), + patch.dict(os.environ, env, clear=True), patch.object(assets_module, "_run_git_command", lambda *a: ""), ): - # No env, no file, no git → fallback string. + # No env, no file, no git: fallback string. self.assertEqual(_resolve(), "dev+unknown") @@ -80,24 +98,22 @@ class BuildVersionFileTests(unittest.TestCase): def test_reads_baked_version_file(self): with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / ".build-version").write_text("v3.4.5+12.gabcdef1", encoding="utf-8") - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + (Path(tmp) / ".build-version").write_text("v3.4.5+gabcdef1", encoding="utf-8") + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), - # ensure live git doesn't accidentally win if file read fails: + # Ensure live git does not accidentally win if file read fails. patch.object(assets_module, "_run_git_command", lambda *a: ""), ): - self.assertEqual(_resolve(), "v3.4.5+12.gabcdef1") + self.assertEqual(_resolve(), "v3.4.5+gabcdef1") def test_strips_trailing_whitespace_and_newlines_in_file(self): - # Shell ``printf '%s'`` writes no newline, but earlier helpers used - # ``echo`` which appends one. Both must produce the same result. + # Shell ``printf '%s'`` writes no newline, but older helpers may have + # used ``echo``. Both must produce the same result. with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / ".build-version").write_text("v1.2.3\n\n", encoding="utf-8") - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), @@ -108,14 +124,13 @@ class BuildVersionFileTests(unittest.TestCase): def test_empty_file_falls_through_to_git_then_unknown(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / ".build-version").write_text("", encoding="utf-8") - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), patch.object(assets_module, "_run_git_command", lambda *a: ""), ): - # No env, empty file, no live git → ultimate fallback. + # No env, empty file, no live git: ultimate fallback. self.assertEqual(_resolve(), "dev+unknown") @@ -126,56 +141,83 @@ class LiveGitFallbackTests(unittest.TestCase): _reset_cache() self.addCleanup(_reset_cache) - def _run_with_git(self, replies: dict, *, dirty: bool = False) -> str: - # Map (subcommand, *args) tuples to canned stdout values. - def fake_git(*args): - return replies.get(args, "") - + def _run_with_git(self, replies: dict) -> str: with tempfile.TemporaryDirectory() as tmp: # ``.build-version`` deliberately absent so we fall through. - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() base = { ("describe", "--tags", "--abbrev=0"): replies.get("tag", ""), ("rev-parse", "--short", "HEAD"): replies.get("sha", ""), - ("status", "--porcelain"): "M file\n" if dirty else "", - ("rev-list", f"{replies.get('tag', '')}..HEAD", "--count"): replies.get( - "commits_since_tag", "0" - ), + ("branch", "--show-current"): replies.get("branch", ""), } - def real_fake_git(*args): + def fake_git(*args): return base.get(args, "") with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), - patch.object(assets_module, "_run_git_command", real_fake_git), + patch.object(assets_module, "_run_git_command", fake_git), ): return _resolve() - def test_tag_with_zero_commits_since_returns_bare_tag(self): - result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}) - self.assertEqual(result, "v2.0.0") + def test_tag_with_sha_returns_tag_plus_sha(self): + result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1"}) + self.assertEqual(result, "v2.0.0+gabcdef1") - def test_tag_plus_distance_plus_sha_format(self): + def test_main_branch_does_not_add_branch_suffix(self): + result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "branch": "main"}) + self.assertEqual(result, "v2.0.0+gabcdef1") + + def test_non_main_branch_adds_branch_suffix(self): + result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "branch": "dev"}) + self.assertEqual(result, "v2.0.0-dev+gabcdef1") + + def test_branch_name_is_sanitized_for_version(self): + result = self._run_with_git( + {"tag": "v2.0.0", "sha": "abcdef1", "branch": "feature/cool build"} + ) + self.assertEqual(result, "v2.0.0-feature-cool-build+gabcdef1") + + def test_commit_distance_is_not_included(self): result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"}) - self.assertEqual(result, "v2.0.0+7.gabcdef1") + self.assertEqual(result, "v2.0.0+gabcdef1") def test_sha_only_when_no_tag(self): result = self._run_with_git({"sha": "abcdef1"}) self.assertEqual(result, "dev+gabcdef1") + def test_tag_only_when_no_sha(self): + result = self._run_with_git({"tag": "v2.0.0"}) + self.assertEqual(result, "v2.0.0") + def test_neither_tag_nor_sha_is_unknown(self): result = self._run_with_git({}) self.assertEqual(result, "dev+unknown") - def test_dirty_suffix_is_appended(self): - result = self._run_with_git( - {"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}, - dirty=True, - ) - self.assertEqual(result, "v2.0.0-dirty") + def test_dirty_suffix_is_not_appended_or_queried(self): + calls = [] + + def fake_git(*args): + calls.append(args) + replies = { + ("describe", "--tags", "--abbrev=0"): "v2.0.0", + ("rev-parse", "--short", "HEAD"): "abcdef1", + ("status", "--porcelain"): "M file\n", + } + return replies.get(args, "") + + with tempfile.TemporaryDirectory() as tmp: + env = _clean_version_env() + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + patch.object(assets_module, "_run_git_command", fake_git), + ): + result = _resolve() + + self.assertEqual(result, "v2.0.0+gabcdef1") + self.assertNotIn(("status", "--porcelain"), calls) class CacheBehaviourTests(unittest.TestCase): @@ -191,8 +233,7 @@ class CacheBehaviourTests(unittest.TestCase): return "abcdef1" if args == ("rev-parse", "--short", "HEAD") else "" with tempfile.TemporaryDirectory() as tmp: - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), diff --git a/tests/test_security.py b/tests/test_security.py index 2b15d38..61cfb6c 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -722,6 +722,61 @@ class AdminSettingsSecurityTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(secret_field["has_value"]) self.assertNotIn("super-secret", response.text) + async def test_admin_settings_exposes_payment_webhook_urls(self): + class AsyncSessionFactory: + def __call__(self): + return self + + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + SHOP_NAME="Visible shop", + WEBHOOK_BASE_URL="https://web.tnnl.cc/", + ) + request = SimpleNamespace( + app={"settings": settings, "async_session_factory": AsyncSessionFactory()}, + headers={}, + cookies={}, + admin_telegram_id=1, + ) + request.get = lambda key, default=None: getattr(request, key, default) + + with ( + patch.object(admin_settings_routes, "_require_admin_user_id", return_value=1), + patch.object( + admin_api.app_settings_dal, + "get_overrides_with_meta", + AsyncMock(return_value=[]), + ), + ): + response = await admin_api.admin_settings_get_route(request) + + payload = json.loads(response.text) + fields = { + field["key"]: field + for section in payload["sections"] + for field in section["fields"] + } + + self.assertEqual( + fields["FREEKASSA_ENABLED"]["webhook_url"], + "https://web.tnnl.cc/webhook/freekassa", + ) + self.assertTrue(fields["FREEKASSA_ENABLED"]["webhook_base_url_configured"]) + self.assertEqual( + fields["PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU"]["webhook_url"], + "https://web.tnnl.cc/webhook/platega", + ) + self.assertNotIn("webhook_url", fields["PAYMENT_STARS_WEBAPP_LABEL_RU"]) + class DatabaseLoggingSecurityTests(unittest.TestCase): def test_database_url_redaction_hides_password(self): diff --git a/tests/test_support_notifications.py b/tests/test_support_notifications.py index 35d94eb..d73e4c1 100644 --- a/tests/test_support_notifications.py +++ b/tests/test_support_notifications.py @@ -364,3 +364,51 @@ def test_support_user_reply_can_send_email_without_telegram_channels(): assert channels == [] assert emails[0]["ticket_id"] == 7 + + +def test_account_merge_notification_goes_to_log_channel(): + messages = [] + + class I18n: + def gettext(self, _language, key, **kwargs): + if key == "log_open_profile_link": + return "Open profile" + assert key == "log_account_merged" + return ( + f"merged primary={kwargs['primary_user_id']} " + f"removed={kwargs['removed_user_id']} " + f"email={kwargs['email']} end={kwargs['final_end_date']}" + ) + + service = NotificationService( + bot=SimpleNamespace(), + settings=_settings(LOG_CHAT_ID=-100123, DEFAULT_LANGUAGE="en"), + i18n=I18n(), + ) + + async def send_to_log_channel(message, thread_id=None, reply_markup=None): + messages.append((message, thread_id, reply_markup)) + + service._send_to_log_channel = send_to_log_channel + + asyncio.run( + service.notify_account_merged( + primary_user_id=42, + removed_user_id=-100, + email="paid@example.com", + telegram_id=100200300, + username="alice", + first_name="Alice", + final_end_date_text="2026-06-21 10:00", + primary_panel_user_uuid="panel-telegram", + removed_panel_user_uuid="panel-email", + ) + ) + + assert len(messages) == 1 + message, thread_id, reply_markup = messages[0] + assert "primary=42" in message + assert "removed=-100" in message + assert "paid@example.com" in message + assert thread_id is None + assert reply_markup.inline_keyboard[0][0].url == "tg://user?id=100200300" diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py index ab65078..8e2245a 100644 --- a/tests/test_tariff_worker.py +++ b/tests/test_tariff_worker.py @@ -1,3 +1,4 @@ +import asyncio import json import tempfile import unittest @@ -617,3 +618,107 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(result) panel_service.get_all_panel_users.assert_not_awaited() + + async def test_missing_panel_subscription_repairs_to_user_panel_uuid(self): + panel_service = AsyncMock(spec=PanelApiService) + worker = TariffTrafficWorker( + settings=SimpleNamespace(), + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=SimpleNamespace(), + ) + sub = SimpleNamespace( + subscription_id=10, + user_id=123, + panel_user_uuid="old-panel", + is_active=True, + status_from_panel="ACTIVE", + skip_notifications=False, + ) + panel_user = {"uuid": "new-panel", "username": "tg_123"} + + with patch( + "bot.services.tariff_worker.user_dal.get_user_by_id", + new=AsyncMock(return_value=SimpleNamespace(panel_user_uuid="new-panel")), + ): + result = await worker._repair_missing_panel_user_for_subscription( + AsyncMock(), + sub, + panel_users_by_uuid={"new-panel": panel_user}, + semaphore=asyncio.Semaphore(1), + confirmed_missing=True, + ) + + self.assertEqual(result, panel_user) + self.assertEqual(sub.panel_user_uuid, "new-panel") + self.assertTrue(sub.is_active) + panel_service.get_user_by_uuid.assert_not_awaited() + + async def test_missing_panel_subscription_deactivates_when_bulk_prefetch_confirms_absent(self): + panel_service = AsyncMock(spec=PanelApiService) + worker = TariffTrafficWorker( + settings=SimpleNamespace(), + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=SimpleNamespace(), + ) + sub = SimpleNamespace( + subscription_id=11, + user_id=123, + panel_user_uuid="missing-panel", + is_active=True, + status_from_panel="ACTIVE", + skip_notifications=False, + ) + + with patch( + "bot.services.tariff_worker.user_dal.get_user_by_id", + new=AsyncMock(return_value=SimpleNamespace(panel_user_uuid="missing-panel")), + ): + result = await worker._repair_missing_panel_user_for_subscription( + AsyncMock(), + sub, + panel_users_by_uuid={}, + semaphore=asyncio.Semaphore(1), + confirmed_missing=True, + ) + + self.assertEqual(result, {}) + self.assertFalse(sub.is_active) + self.assertTrue(sub.skip_notifications) + self.assertEqual(sub.status_from_panel, "PANEL_USER_NOT_FOUND") + + async def test_missing_panel_subscription_only_skips_when_absence_is_not_confirmed(self): + panel_service = AsyncMock(spec=PanelApiService) + panel_service.get_user_by_uuid = AsyncMock(return_value=None) + worker = TariffTrafficWorker( + settings=SimpleNamespace(), + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=SimpleNamespace(), + ) + sub = SimpleNamespace( + subscription_id=12, + user_id=123, + panel_user_uuid="missing-panel", + is_active=True, + status_from_panel="ACTIVE", + skip_notifications=False, + ) + + with patch( + "bot.services.tariff_worker.user_dal.get_user_by_id", + new=AsyncMock(return_value=SimpleNamespace(panel_user_uuid="missing-panel")), + ): + result = await worker._repair_missing_panel_user_for_subscription( + AsyncMock(), + sub, + panel_users_by_uuid=None, + semaphore=asyncio.Semaphore(1), + confirmed_missing=False, + ) + + self.assertEqual(result, {}) + self.assertTrue(sub.is_active) + self.assertFalse(sub.skip_notifications) + self.assertEqual(sub.status_from_panel, "ACTIVE") diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 7f62f4d..3ff2223 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -1,5 +1,5 @@ import unittest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -131,3 +131,109 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): self.assertIn("user_payment_methods", delete_tables) self.assertIn("promo_code_activations", delete_tables) session.delete.assert_awaited_once_with(source) + + async def test_merge_users_moves_active_email_subscription_onto_expired_telegram_account(self): + before = datetime.now(timezone.utc) + source = SimpleNamespace( + user_id=-100, + email="paid@example.com", + telegram_id=None, + panel_user_uuid="panel-email", + email_verified_at=before, + username=None, + first_name=None, + last_name=None, + language_code="ru", + telegram_photo_url=None, + channel_subscription_verified=False, + channel_subscription_checked_at=None, + channel_subscription_verified_for=None, + lifetime_used_traffic_bytes=0, + referred_by_id=None, + referral_code=None, + ) + target = SimpleNamespace( + user_id=42, + email=None, + telegram_id=42, + panel_user_uuid="panel-telegram", + email_verified_at=None, + username="old", + first_name=None, + last_name=None, + language_code="ru", + telegram_photo_url=None, + channel_subscription_verified=False, + channel_subscription_checked_at=None, + channel_subscription_verified_for=None, + lifetime_used_traffic_bytes=0, + referred_by_id=None, + referral_code=None, + ) + source_active_sub = SimpleNamespace( + end_date=before + timedelta(days=30), + is_active=True, + skip_notifications=False, + last_notification_sent=before, + status_from_panel="ACTIVE", + panel_user_uuid="panel-email", + ) + expired_target_sub = SimpleNamespace( + end_date=before - timedelta(days=3), + is_active=False, + skip_notifications=False, + last_notification_sent=before, + status_from_panel="EXPIRED", + panel_user_uuid="panel-telegram", + ) + session = SimpleNamespace( + execute=AsyncMock(side_effect=lambda stmt: FakeResult()), + delete=AsyncMock(), + flush=AsyncMock(), + refresh=AsyncMock(), + ) + + async def fake_get_user_by_id(_session, user_id): + if user_id == source.user_id: + return source + if user_id == target.user_id: + return target + return None + + async def fake_get_active_subscription(_session, user_id, panel_user_uuid=None): + if user_id == source.user_id and panel_user_uuid == source.panel_user_uuid: + return source_active_sub + return None + + async def fake_get_latest_subscription(_session, user_id, panel_user_uuid=None, **_kwargs): + if user_id == target.user_id and panel_user_uuid == target.panel_user_uuid: + return expired_target_sub + return None + + with ( + patch("db.dal.user_dal.get_user_by_id", side_effect=fake_get_user_by_id), + patch( + "db.dal.user_dal._get_active_subscription_for_user", + side_effect=fake_get_active_subscription, + ), + patch( + "db.dal.user_dal._get_latest_subscription_for_user", + side_effect=fake_get_latest_subscription, + ), + ): + merged = await user_dal.merge_users( + session, + source_user_id=source.user_id, + target_user_id=target.user_id, + ) + + self.assertIs(merged, target) + self.assertEqual(target.email, "paid@example.com") + self.assertTrue(expired_target_sub.is_active) + self.assertEqual(expired_target_sub.status_from_panel, "ACTIVE_EXTENDED_BY_MERGE") + self.assertIsNone(expired_target_sub.last_notification_sent) + self.assertGreater(expired_target_sub.end_date, before + timedelta(days=29)) + self.assertLess(expired_target_sub.end_date, before + timedelta(days=31)) + self.assertFalse(source_active_sub.is_active) + self.assertTrue(source_active_sub.skip_notifications) + self.assertEqual(source_active_sub.status_from_panel, "MERGED_INTO_ACCOUNT") diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 83ab5b9..4879a11 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -15,6 +15,7 @@ from PIL import Image from bot.app.web import subscription_webapp from bot.app.web.admin_api_impl import themes as admin_themes from bot.app.web.webapp import assets as webapp_assets +from bot.app.web.webapp import cache_helpers from config.settings import Settings from config.webapp_themes_config import WebappThemesConfig, builtin_webapp_themes_config @@ -449,6 +450,58 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): [{"id": "yookassa", "name": "Bank card", "icon": "WalletCards"}], ) + def test_serialize_payment_methods_includes_wata_from_provider_config(self): + from bot.payment_providers import build_provider_configs, get_provider_bundle + + build_provider_configs(force=True) + bundle = get_provider_bundle("wata_service") + self.assertIsNotNone(bundle) + bundle.config.ENABLED = True + bundle.config.API_TOKEN = "wata-token" + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + PAYMENT_METHODS_ORDER="wata", + STARS_ENABLED=False, + ) + app = {"wata_service": SimpleNamespace(configured=True)} + + methods = subscription_webapp._serialize_payment_methods(settings, app, "en") + + self.assertEqual(methods, [{"id": "wata", "name": "Wata", "icon": "WalletCards"}]) + + async def test_invalidate_all_webapp_user_caches_clears_cached_me_payload(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + REDIS_URL=None, + ) + calls = 0 + + async def loader(): + nonlocal calls + calls += 1 + return {"payment_methods": [{"id": f"method-{calls}"}]} + + first = await cache_helpers.webapp_cached_user_payload(settings, "me", 42, 60, loader) + second = await cache_helpers.webapp_cached_user_payload(settings, "me", 42, 60, loader) + + self.assertEqual(first, {"payment_methods": [{"id": "method-1"}]}) + self.assertEqual(second, first) + self.assertEqual(calls, 1) + + await cache_helpers.invalidate_all_webapp_user_caches(settings) + third = await cache_helpers.webapp_cached_user_payload(settings, "me", 42, 60, loader) + + self.assertEqual(third, {"payment_methods": [{"id": "method-2"}]}) + self.assertEqual(calls, 2) + def test_serialize_plans_includes_stars_only_subscription_options(self): settings = Settings( _env_file=None, @@ -488,7 +541,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "subscription_webapp.min.22222222.js", ) - def test_resolve_webapp_admin_asset_names_prefer_latest_minified_builds(self): + def test_resolve_webapp_admin_asset_names_use_stable_runtime_builds(self): with tempfile.TemporaryDirectory() as tmpdir: asset_dir = Path(tmpdir) (asset_dir / "subscription_webapp_admin.js").write_text( @@ -513,11 +566,11 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): with patch.object(webapp_assets, "ASSET_DIR", asset_dir): self.assertEqual( subscription_webapp._resolve_webapp_admin_js_asset_name(), - "subscription_webapp_admin.min.22222222.js", + "subscription_webapp_admin.js", ) self.assertEqual( subscription_webapp._resolve_webapp_admin_css_asset_name(), - "subscription_webapp_admin.22222222.css", + "subscription_webapp_admin.css", ) async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self): diff --git a/tests/test_webapp_telegram_logout.py b/tests/test_webapp_telegram_logout.py new file mode 100644 index 0000000..eb5a262 --- /dev/null +++ b/tests/test_webapp_telegram_logout.py @@ -0,0 +1,35 @@ +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _read(path: str) -> str: + return (REPO_ROOT / path).read_text(encoding="utf-8") + + +def test_telegram_init_data_login_runs_before_manual_logout_gate(): + source = _read("frontend/src/lib/webapp/webappBoot.js") + + init_data_pos = source.index("const initData = getInitDataForBoot();") + manual_logout_pos = source.index("if (isManuallyLoggedOut())") + + assert init_data_pos < manual_logout_pos + + +def test_logout_button_is_controlled_by_telegram_context(): + app_source = _read("frontend/src/App.svelte") + settings_source = _read("frontend/src/webapp/screens/SettingsScreen.svelte") + + assert "$: telegramMiniAppContext = hasTelegramLaunchParams();" in app_source + assert "showLogout={!telegramMiniAppContext}" in app_source + assert "export let showLogout = true;" in settings_source + assert "{#if showLogout}" in settings_source + + +def test_logout_handler_is_noop_inside_telegram_mini_app(): + source = _read("frontend/src/lib/webapp/stores/accountStore.js") + + guard_pos = source.index("if (telegramSdk.hasLaunchParams()) return;") + mark_logout_pos = source.index("markManualLogout();") + + assert guard_pos < mark_logout_pos