diff --git a/backend/bot/app/web/admin_api_impl/settings.py b/backend/bot/app/web/admin_api_impl/settings.py index 5f13ee4..dbb2aa2 100644 --- a/backend/bot/app/web/admin_api_impl/settings.py +++ b/backend/bot/app/web/admin_api_impl/settings.py @@ -13,6 +13,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"] @@ -34,6 +35,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response: } 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"]) @@ -70,6 +79,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} + try: + from bot.app.web.webapp.cache_helpers import invalidate_all_webapp_user_caches + + await invalidate_all_webapp_user_caches(settings, include_devices=True) + except Exception: + logger.exception("Failed to invalidate WebApp user payload caches after settings update") if ( "WEBAPP_LOGO_URL" in updates or "WEBAPP_LOGO_URL" in deletes diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 447c9fd..0130564 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -507,7 +507,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, @@ -531,10 +535,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: @@ -561,6 +567,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 65ef57d..b6ca590 100644 --- a/backend/bot/app/web/webapp/cache_helpers.py +++ b/backend/bot/app/web/webapp/cache_helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Awaitable, Callable, Optional -from bot.infra.redis import cache_delete, redis_key +from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key from bot.utils.ttl_cache import AsyncTTLCache from config.settings import Settings @@ -55,6 +55,20 @@ def invalidate_local_webapp_user_payload( cache.invalidate(key) +def invalidate_all_local_webapp_user_payloads( + settings: Settings, + namespace: Optional[str] = None, +) -> None: + for (settings_id, cache_namespace, _ttl), cache in tuple( + _WEBAPP_USER_PAYLOAD_CACHES.items() + ): + if settings_id != id(settings): + continue + if namespace is not None and cache_namespace != namespace: + continue + cache.invalidate() + + async def invalidate_webapp_user_caches( settings: Settings, *user_ids: Optional[int], @@ -79,3 +93,23 @@ async def invalidate_webapp_user_caches( invalidate_local_webapp_user_payload(settings, "devices", user_id) if keys: await cache_delete(settings, *keys) + + +async def invalidate_all_webapp_user_caches( + settings: Settings, + *, + include_devices: bool = False, +) -> None: + namespaces = ["me"] + if include_devices: + namespaces.append("devices") + + for namespace in namespaces: + invalidate_all_local_webapp_user_payloads(settings, namespace) + try: + await cache_delete_pattern( + settings, + redis_key(settings, "cache", "webapp", namespace, "*"), + ) + except Exception: + continue diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py index fa74835..c56b507 100644 --- a/backend/bot/handlers/admin/sync_admin.py +++ b/backend/bot/handlers/admin/sync_admin.py @@ -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: @@ -589,28 +636,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 fe4f70a..f83567d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -194,7 +194,7 @@ showLogin, telegramSdk, getTg: () => tg, - telegramOAuthClientId, + telegramOAuthClientId: () => telegramOAuthClientId, currentLang: () => currentLang, normalizeLangCode, updateLocalData: (updatedLanguage) => { @@ -397,6 +397,7 @@ $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); $: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); $: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData); + $: telegramMiniAppContext = hasTelegramLaunchParams(); $: telegramLoginUnavailable = !telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"; $: telegramLoginChecking = @@ -634,7 +635,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); }); } @@ -647,11 +651,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; @@ -671,8 +698,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; @@ -1407,7 +1442,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 1c01d90..da0b650 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -1,5 +1,5 @@ +{#snippet renderWebhookHint(webhook)} + {@const displayValue = webhook.url || webhook.path} +
{displayValue}
+ {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 bf7efc2..9411693 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 546afad..cfb00e8 100644
--- a/tests/test_admin_settings_manifest_i18n.py
+++ b/tests/test_admin_settings_manifest_i18n.py
@@ -68,3 +68,13 @@ def test_subscription_purchase_description_settings_i18n_keys_exist():
assert field["section"] == "pricing"
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..94183ca 100644
--- a/tests/test_admin_sync_performance.py
+++ b/tests/test_admin_sync_performance.py
@@ -14,6 +14,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
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