Merge origin/dev into feature/telegram-flood-hardening

This commit is contained in:
3252a8
2026-06-10 16:49:53 +03:00
139 changed files with 6227 additions and 535 deletions
+361
View File
@@ -0,0 +1,361 @@
import json
import tempfile
import time
import unittest
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.services import config_health_service as health
def _settings(**overrides):
base = {
"BACKUP_DIR": "data/backups",
"TARIFFS_CONFIG_PATH": "data/tariffs.json",
"SUBSCRIPTION_MINI_APP_URL": "https://shop.example.com/app",
"REDIS_URL": "redis://redis:6379/0",
"SMTP_USERNAME": None,
"SMTP_PASSWORD": None,
"SMTP_FROM_EMAIL": None,
"email_auth_configured": False,
"WEBHOOK_BASE_URL": "https://shop.example.com",
"telegram_webhook_path": "/tg/webhook",
"PANEL_API_URL": "https://panel.example.com/api",
"PANEL_API_KEY": "panel-key",
"trusted_proxies": ["127.0.0.1", "172.16.0.0/12"],
}
base.update(overrides)
return SimpleNamespace(**base)
def _alert_ids(alerts):
return [alert.id for alert in alerts]
class DataDirAlertsTests(unittest.TestCase):
def test_missing_data_dir_reported_as_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
alerts = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
self.assertEqual(_alert_ids(alerts), ["data_dir_missing"])
self.assertEqual(alerts[0].severity, "error")
self.assertIn("backups", alerts[0].sections)
def test_writable_data_dir_produces_no_alerts(self):
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "data").mkdir()
alerts = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
self.assertEqual(alerts, [])
def test_unwritable_data_dir_reported(self):
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "data").mkdir()
with patch.object(health, "_dir_is_writable", return_value=False):
alerts = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
self.assertIn("data_dir_not_writable", _alert_ids(alerts))
class ConfigFileAlertsTests(unittest.TestCase):
def test_invalid_tariffs_config_reported(self):
with tempfile.TemporaryDirectory() as tmpdir:
tariffs_path = Path(tmpdir) / "tariffs.json"
tariffs_path.write_text("{not json", encoding="utf-8")
settings = _settings(TARIFFS_CONFIG_PATH=str(tariffs_path))
with patch.object(health, "APP_ROOT", Path(tmpdir)):
alerts = health.config_file_alerts(settings)
self.assertIn("tariffs_config_invalid", _alert_ids(alerts))
def test_invalid_locale_overrides_reported(self):
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir) / "data"
data_dir.mkdir()
(data_dir / "locales-overrides.json").write_text("{oops", encoding="utf-8")
settings = _settings(TARIFFS_CONFIG_PATH=str(Path(tmpdir) / "absent.json"))
with patch.object(health, "APP_ROOT", Path(tmpdir)):
alerts = health.config_file_alerts(settings)
self.assertIn("locale_overrides_invalid", _alert_ids(alerts))
def test_valid_files_produce_no_alerts(self):
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir) / "data"
data_dir.mkdir()
(data_dir / "locales-overrides.json").write_text("{}", encoding="utf-8")
settings = _settings(TARIFFS_CONFIG_PATH=str(Path(tmpdir) / "absent.json"))
with patch.object(health, "APP_ROOT", Path(tmpdir)):
alerts = health.config_file_alerts(settings)
self.assertNotIn("tariffs_config_invalid", _alert_ids(alerts))
self.assertNotIn("locale_overrides_invalid", _alert_ids(alerts))
class PaymentProviderAlertsTests(unittest.TestCase):
@staticmethod
def _spec(
spec_id,
*,
enabled=True,
configured=True,
webhook_requires_base_url=False,
service_key=None,
):
return SimpleNamespace(
id=spec_id,
label=spec_id.title(),
service_key=service_key or f"{spec_id}_service",
webhook_requires_base_url=webhook_requires_base_url,
is_effectively_enabled=lambda settings: enabled,
is_service_configured=lambda app: configured,
)
def test_enabled_but_unconfigured_provider_reported(self):
specs = [self._spec("wata", configured=False)]
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
alerts = health.payment_provider_alerts(_settings(), app={})
self.assertEqual(_alert_ids(alerts), ["provider_not_configured:wata"])
self.assertEqual(alerts[0].message_key, "provider_not_configured")
self.assertEqual(alerts[0].params["provider"], "Wata")
def test_webhook_provider_without_base_url_reported(self):
specs = [self._spec("yookassa", webhook_requires_base_url=True)]
settings = _settings(WEBHOOK_BASE_URL=None)
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
alerts = health.payment_provider_alerts(settings, app={})
self.assertIn("provider_webhook_needs_base_url:yookassa", _alert_ids(alerts))
def test_no_enabled_providers_reported_as_warning(self):
specs = [self._spec("wata", enabled=False)]
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
alerts = health.payment_provider_alerts(_settings(), app={})
self.assertEqual(_alert_ids(alerts), ["no_payment_methods"])
self.assertEqual(alerts[0].severity, "warning")
def test_configured_enabled_provider_produces_no_alerts(self):
specs = [self._spec("wata")]
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
alerts = health.payment_provider_alerts(_settings(), app={})
self.assertEqual(alerts, [])
def test_shared_service_reported_once(self):
specs = [
self._spec("platega", configured=False, service_key="platega_service"),
self._spec("platega_crypto", configured=False, service_key="platega_service"),
]
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
alerts = health.payment_provider_alerts(_settings(), app={})
self.assertEqual(_alert_ids(alerts), ["provider_not_configured:platega"])
class SettingsAlertsTests(unittest.TestCase):
def test_clean_settings_produce_no_alerts(self):
self.assertEqual(health.settings_alerts(_settings()), [])
def test_missing_mini_app_url_reported(self):
alerts = health.settings_alerts(_settings(SUBSCRIPTION_MINI_APP_URL=None))
self.assertIn("mini_app_url_missing", _alert_ids(alerts))
def test_http_mini_app_url_reported_as_error(self):
alerts = health.settings_alerts(
_settings(SUBSCRIPTION_MINI_APP_URL="http://shop.example.com")
)
ids = _alert_ids(alerts)
self.assertIn("mini_app_url_not_https", ids)
self.assertEqual(alerts[ids.index("mini_app_url_not_https")].severity, "error")
def test_missing_redis_reported(self):
alerts = health.settings_alerts(_settings(REDIS_URL=None))
self.assertIn("redis_not_configured", _alert_ids(alerts))
def test_partial_smtp_reported(self):
alerts = health.settings_alerts(_settings(SMTP_USERNAME="mailer"))
self.assertIn("smtp_incomplete", _alert_ids(alerts))
def test_complete_smtp_not_reported(self):
alerts = health.settings_alerts(
_settings(SMTP_USERNAME="mailer", email_auth_configured=True)
)
self.assertNotIn("smtp_incomplete", _alert_ids(alerts))
class ProxyAlertsTests(unittest.TestCase):
def test_untrusted_proxy_reported(self):
request = SimpleNamespace(
remote="203.0.113.50",
headers={"X-Forwarded-For": "198.51.100.7"},
)
alerts = health.proxy_alerts(request, _settings())
self.assertEqual(_alert_ids(alerts), ["proxy_not_trusted"])
def test_trusted_proxy_not_reported(self):
request = SimpleNamespace(
remote="172.18.0.5",
headers={"X-Forwarded-For": "198.51.100.7"},
)
self.assertEqual(health.proxy_alerts(request, _settings()), [])
def test_direct_request_not_reported(self):
request = SimpleNamespace(remote="203.0.113.50", headers={})
self.assertEqual(health.proxy_alerts(request, _settings()), [])
class TelegramAlertsTests(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _webhook_info(**overrides):
base = {
"url": "https://shop.example.com/tg/webhook",
"last_error_date": None,
"last_error_message": None,
"pending_update_count": 0,
}
base.update(overrides)
return SimpleNamespace(**base)
async def test_healthy_webhook_produces_no_alerts(self):
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=self._webhook_info()))
self.assertEqual(await health.telegram_alerts(bot, _settings()), [])
async def test_missing_webhook_reported_as_error(self):
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=self._webhook_info(url="")))
alerts = await health.telegram_alerts(bot, _settings())
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_missing"])
self.assertEqual(alerts[0].severity, "error")
async def test_webhook_mismatch_reported(self):
bot = SimpleNamespace(
get_webhook_info=AsyncMock(
return_value=self._webhook_info(url="https://other.example.com/tg/webhook")
)
)
alerts = await health.telegram_alerts(bot, _settings())
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_mismatch"])
async def test_recent_delivery_error_with_pending_update_reported(self):
info = self._webhook_info(
last_error_date=datetime.now(timezone.utc),
last_error_message="SSL error",
pending_update_count=1,
)
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
alerts = await health.telegram_alerts(bot, _settings())
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_error"])
self.assertEqual(alerts[0].params["error"], "SSL error")
async def test_recent_delivery_error_without_pending_update_not_reported(self):
info = self._webhook_info(
last_error_date=datetime.now(timezone.utc),
last_error_message="Connection refused",
pending_update_count=0,
)
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
self.assertEqual(await health.telegram_alerts(bot, _settings()), [])
async def test_stale_delivery_error_not_reported(self):
info = self._webhook_info(last_error_date=time.time() - 7200)
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
self.assertEqual(await health.telegram_alerts(bot, _settings()), [])
async def test_pending_updates_reported(self):
info = self._webhook_info(pending_update_count=500)
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
alerts = await health.telegram_alerts(bot, _settings())
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_pending"])
async def test_unauthorized_token_reported_as_error(self):
class TelegramUnauthorizedError(Exception):
pass
bot = SimpleNamespace(
get_webhook_info=AsyncMock(side_effect=TelegramUnauthorizedError("401"))
)
alerts = await health.telegram_alerts(bot, _settings())
self.assertEqual(_alert_ids(alerts), ["bot_token_invalid"])
async def test_generic_api_error_reported_as_warning(self):
bot = SimpleNamespace(get_webhook_info=AsyncMock(side_effect=OSError("boom")))
alerts = await health.telegram_alerts(bot, _settings())
self.assertEqual(_alert_ids(alerts), ["telegram_api_error"])
self.assertEqual(alerts[0].severity, "warning")
class PanelAlertsTests(unittest.IsolatedAsyncioTestCase):
async def test_unconfigured_panel_reported(self):
settings = _settings(PANEL_API_URL=None, PANEL_API_KEY=None)
alerts = await health.panel_alerts(None, settings)
self.assertEqual(_alert_ids(alerts), ["panel_api_not_configured"])
async def test_unreachable_panel_reported(self):
panel_service = SimpleNamespace(get_system_stats=AsyncMock(return_value=None))
alerts = await health.panel_alerts(panel_service, _settings())
self.assertEqual(_alert_ids(alerts), ["panel_api_unreachable"])
async def test_healthy_panel_produces_no_alerts(self):
panel_service = SimpleNamespace(get_system_stats=AsyncMock(return_value={"cpu": 1}))
self.assertEqual(await health.panel_alerts(panel_service, _settings()), [])
class CollectAlertsTests(unittest.IsolatedAsyncioTestCase):
async def test_collect_sorts_errors_first_and_serializes(self):
settings = _settings()
request = SimpleNamespace(app={"settings": settings}, headers={}, remote="127.0.0.1")
warning = health.ConfigAlert(id="warn_alert", severity="warning", sections=("settings",))
error = health.ConfigAlert(id="error_alert", severity="error", sections=("backups",))
with (
patch.object(health, "local_alerts", return_value=[warning, error]),
patch.object(health, "network_alerts", AsyncMock(return_value=[])),
):
payload = await health.collect_config_alerts(request)
self.assertEqual([item["id"] for item in payload], ["error_alert", "warn_alert"])
self.assertEqual(payload[0]["message_key"], "error_alert")
self.assertEqual(payload[0]["sections"], ["backups"])
async def test_network_alerts_cached_between_calls(self):
settings = _settings()
app = {"settings": settings, "bot": None, "panel_service": None}
health._network_cache.clear()
with patch.object(health, "panel_alerts", AsyncMock(return_value=[])) as panel_mock:
await health.network_alerts(app, settings)
await health.network_alerts(app, settings)
self.assertEqual(panel_mock.await_count, 1)
await health.network_alerts(app, settings, refresh=True)
self.assertEqual(panel_mock.await_count, 2)
health._network_cache.clear()
class HealthLocaleKeysTests(unittest.TestCase):
def test_every_message_key_has_locale_entries(self):
root = Path(__file__).resolve().parents[1]
for language in ("ru", "en"):
messages = json.loads(
(root / "locales" / f"{language}.json").read_text(encoding="utf-8")
)
for suffix in ("title", "refresh", *health.ALL_MESSAGE_KEYS):
self.assertIn(
f"admin_health_{suffix}",
messages,
f"locales/{language}.json is missing admin_health_{suffix}",
)
def test_alert_ids_used_by_checks_are_known_message_keys(self):
known = set(health.ALL_MESSAGE_KEYS)
with tempfile.TemporaryDirectory() as tmpdir:
local = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
local += health.settings_alerts(_settings(SUBSCRIPTION_MINI_APP_URL=None, REDIS_URL=None))
for alert in local:
self.assertIn(alert.message_key or alert.id, known)
if __name__ == "__main__":
unittest.main()
+240
View File
@@ -0,0 +1,240 @@
import json
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.app.web.admin_api_impl import broadcast as broadcast_module
from bot.app.web.admin_api_impl import common as common_module
from bot.app.web.admin_api_impl import users as users_module
class FakeResult:
def __init__(self, rows=None):
self._rows = rows or []
def all(self):
return self._rows
def scalars(self):
return self
class FakeSession:
def __init__(self):
self.execute = AsyncMock(side_effect=[FakeResult([]), FakeResult([]), FakeResult([])])
self.committed = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
self.committed = True
async def rollback(self):
pass
def _active_subscription(panel_user_uuid="panel-from-sub"):
return SimpleNamespace(
subscription_id=10,
panel_user_uuid=panel_user_uuid,
panel_subscription_uuid=None,
start_date=datetime(2026, 6, 1, tzinfo=timezone.utc),
end_date=datetime(2026, 7, 1, tzinfo=timezone.utc),
duration_months=1,
is_active=True,
status_from_panel="ACTIVE",
traffic_limit_bytes=100,
traffic_used_bytes=0,
tier_baseline_bytes=0,
topup_balance_bytes=0,
premium_used_bytes=0,
premium_baseline_bytes=0,
premium_topup_balance_bytes=0,
premium_topup_used_bytes=0,
premium_bonus_bytes=0,
regular_bonus_bytes=0,
regular_unlimited_override=False,
premium_unlimited_override=False,
premium_is_limited=False,
tariff_key="standard",
auto_renew_enabled=True,
provider="yookassa",
is_throttled=False,
)
class AdminPanelActivityTests(unittest.IsolatedAsyncioTestCase):
def test_panel_activity_detects_connected_and_never_connected_users(self):
self.assertEqual(
common_module._panel_user_connection_activity(
{"userTraffic": {"onlineAt": "2026-06-05T12:00:00Z"}}
),
{
"status": "connected",
"last_connected_at": "2026-06-05T12:00:00+00:00",
},
)
self.assertEqual(
common_module._panel_user_connection_activity(
{
"userTraffic": {
"onlineAt": None,
"firstConnectedAt": None,
"lastConnectedNodeUuid": None,
"lifetimeUsedTrafficBytes": 0,
},
}
),
{"status": "never", "last_connected_at": None},
)
self.assertEqual(
common_module._panel_user_connection_activity(
{"userTraffic": {"lifetimeUsedTrafficBytes": 1024}}
),
{"status": "connected", "last_connected_at": None},
)
async def test_active_never_connected_audience_uses_panel_status(self):
session = SimpleNamespace(
execute=AsyncMock(
return_value=FakeResult(
[
(1, "never-panel"),
(2, "connected-panel"),
(3, "missing-panel"),
(4, "also-never-panel"),
(4, "also-connected-panel"),
]
)
)
)
async def get_user_by_uuid(panel_uuid):
return {
"never-panel": {
"userTraffic": {
"onlineAt": None,
"firstConnectedAt": None,
"lastConnectedNodeUuid": None,
},
},
"connected-panel": {"userTraffic": {"onlineAt": "2026-06-05T12:00:00Z"}},
"also-never-panel": {
"userTraffic": {
"onlineAt": None,
"firstConnectedAt": None,
"lastConnectedNodeUuid": None,
},
},
"also-connected-panel": {
"userTraffic": {"lifetimeUsedTrafficBytes": 1},
},
}.get(panel_uuid)
panel_service = SimpleNamespace(get_user_by_uuid=AsyncMock(side_effect=get_user_by_uuid))
result = await broadcast_module._user_ids_with_active_subscription_never_connected(
session,
panel_service,
)
self.assertEqual(result, [1])
self.assertEqual(
[call.args[0] for call in panel_service.get_user_by_uuid.await_args_list],
[
"never-panel",
"connected-panel",
"missing-panel",
"also-never-panel",
"also-connected-panel",
],
)
async def test_user_detail_includes_last_vpn_connection_from_panel(self):
session = FakeSession()
user = SimpleNamespace(
user_id=42,
telegram_id=42,
telegram_photo_url=None,
username="alice",
first_name="Alice",
last_name=None,
email=None,
language_code="ru",
is_banned=False,
registration_date=datetime(2026, 6, 1, tzinfo=timezone.utc),
panel_user_uuid=None,
referral_code=None,
referred_by_id=None,
trial_eligibility_reset_at=None,
)
active_sub = _active_subscription("panel-from-sub")
panel_service = SimpleNamespace(
get_user_by_uuid=AsyncMock(
return_value={
"subscriptionUrl": "https://panel.example/sub/short",
"userTraffic": {"onlineAt": "2026-06-05T12:00:00Z"},
}
)
)
request = SimpleNamespace(
app={
"settings": SimpleNamespace(SUBSCRIPTION_MINI_APP_URL=None),
"async_session_factory": lambda: session,
"subscription_service": SimpleNamespace(panel_service=panel_service),
},
match_info={"user_id": "42"},
)
with (
patch.object(users_module, "_require_admin_user_id", return_value=100),
patch.object(users_module.user_dal, "get_user_by_id", AsyncMock(return_value=user)),
patch.object(
users_module.subscription_dal,
"get_active_subscription_by_user_id",
AsyncMock(return_value=active_sub),
),
patch.object(
users_module.payment_dal,
"get_user_total_paid",
AsyncMock(return_value=0),
),
patch.object(
users_module.message_log_dal,
"count_user_message_logs",
AsyncMock(return_value=0),
),
patch.object(
users_module.user_dal,
"get_referrer_for_user",
AsyncMock(return_value=None),
),
patch.object(
users_module.user_dal,
"count_users_referred_by",
AsyncMock(return_value=0),
),
patch.object(users_module, "_bulk_user_avatar_keys", AsyncMock(return_value={})),
patch.object(
users_module.user_dal,
"ensure_referral_code",
AsyncMock(return_value="REF"),
),
):
response = await users_module.admin_user_detail_route(request)
payload = json.loads(response.text)
self.assertEqual(response.status, 200)
self.assertEqual(payload["subscription_url"], "https://panel.example/sub/short")
self.assertEqual(payload["vpn_connection_status"], "connected")
self.assertEqual(payload["last_vpn_connected_at"], "2026-06-05T12:00:00+00:00")
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-from-sub")
if __name__ == "__main__":
unittest.main()
@@ -24,6 +24,7 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"PAYMENT_REQUEST_TIMEOUT_SECONDS",
)
SUBSCRIPTION_GUIDE_SETTINGS = (
@@ -192,6 +193,11 @@ def test_support_settings_i18n_keys_exist_in_admin_locales():
def test_subscription_purchase_description_settings_i18n_keys_exist():
manifest = _manifest_by_key()
timeout_field = manifest["PAYMENT_REQUEST_TIMEOUT_SECONDS"]
assert timeout_field["type"] == "float"
assert timeout_field["optional"] is False
assert timeout_field["min"] == 1
for language in ("ru", "en"):
messages = _locale(language)
for setting_key in SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS:
+54
View File
@@ -28,9 +28,11 @@ from unittest.mock import patch
# Importing the webapp facade populates the runtime helpers we need.
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import assets as assets_module
from bot.utils import app_version as app_version_module
_VERSION_ENV_NAMES = (
"REMNAWAVE_MINISHOP_VERSION",
"REMNAWAVE_MINISHOP_BUILD_PROVENANCE",
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
@@ -42,6 +44,8 @@ _VERSION_ENV_NAMES = (
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]
app_version_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
app_version_module._APP_BUILD_PROVENANCE_CACHE = None # type: ignore[attr-defined]
# 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]
@@ -251,5 +255,55 @@ class CacheBehaviourTests(unittest.TestCase):
self.assertEqual(first_calls, second_calls)
class BuildProvenanceTests(unittest.TestCase):
def setUp(self) -> None:
_reset_cache()
self.addCleanup(_reset_cache)
def test_env_var_short_circuits_build_file(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".build-provenance").write_text("custom", encoding="utf-8")
env = _clean_version_env()
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "official"
with (
patch.dict(os.environ, env, clear=True),
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
):
self.assertEqual(app_version_module.resolve_build_provenance(), "official")
self.assertFalse(app_version_module.resolve_image_modified())
def test_reads_baked_build_provenance_file(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".build-provenance").write_text("custom\n", encoding="utf-8")
env = _clean_version_env()
with (
patch.dict(os.environ, env, clear=True),
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
):
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
self.assertTrue(app_version_module.resolve_image_modified())
def test_missing_marker_defaults_to_custom(self):
with tempfile.TemporaryDirectory() as tmp:
env = _clean_version_env()
with (
patch.dict(os.environ, env, clear=True),
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
):
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
self.assertTrue(app_version_module.resolve_image_modified())
def test_legacy_boolean_aliases_are_normalized(self):
env = _clean_version_env()
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "true"
with patch.dict(os.environ, env, clear=True):
self.assertEqual(app_version_module.resolve_build_provenance(), "official")
_reset_cache()
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "fork"
with patch.dict(os.environ, env, clear=True):
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
if __name__ == "__main__": # pragma: no cover
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
"""Guards the Docker packaging of the Mini App's hashed web assets.
The backend renders the Mini App shell and rewrites the stylesheet/script tags
to content-hashed asset names (``subscription_webapp.<hash>.css``). Those hashed
files are gitignored build artifacts, so the backend image only sees them if the
Dockerfile copies them in from the ``frontend-builder`` stage. Without that copy
the asset resolver falls back to the bare ``/subscription_webapp.css`` URL, which
never changes between deploys and is served ``no-store`` -- iOS WebViews cache it
aggressively and render a stale, broken-looking Mini App.
These checks fail loudly if a future Dockerfile refactor drops the copy or moves
the stages so the copy can no longer resolve.
"""
import re
import unittest
from pathlib import Path
DOCKERFILE_PATH = Path(__file__).resolve().parents[1] / "deploy" / "docker" / "Dockerfile"
class DockerWebappAssetTests(unittest.TestCase):
def setUp(self) -> None:
self.dockerfile = DOCKERFILE_PATH.read_text(encoding="utf-8")
def test_backend_stage_copies_built_webapp_assets(self) -> None:
self.assertRegex(
self.dockerfile,
r"COPY\s+--from=frontend-builder\s+\S*backend/bot/app/web/templates/"
r"\s+\S*backend/bot/app/web/templates/",
"backend image must copy the freshly built (hashed) webapp assets so the "
"shell emits immutable, cache-busting asset URLs",
)
def test_frontend_builder_is_defined_before_backend_stage(self) -> None:
builder_idx = self.dockerfile.find("AS frontend-builder")
backend_idx = self.dockerfile.find("AS backend")
self.assertNotEqual(builder_idx, -1, "frontend-builder stage is missing")
self.assertNotEqual(backend_idx, -1, "backend stage is missing")
self.assertLess(
builder_idx,
backend_idx,
"frontend-builder must be defined before the backend stage that copies from it",
)
def test_frontend_builder_builds_the_webapp_assets(self) -> None:
self.assertIn("npm run build:webapp", self.dockerfile)
def test_worker_stage_does_not_copy_webapp_assets(self) -> None:
# The worker runs background jobs and never serves the web shell, so it
# should stay lean and not depend on the frontend build.
worker_match = re.search(
r"FROM\s+python-base\s+AS\s+worker(?P<body>.*?)(?:\nFROM\s|\Z)",
self.dockerfile,
flags=re.DOTALL,
)
self.assertIsNotNone(worker_match, "worker stage is missing")
self.assertNotIn("frontend-builder", worker_match.group("body"))
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -2,9 +2,12 @@ import json
import re
import subprocess
import sys
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from PIL import Image
from bot.middlewares.i18n import JsonI18n
from bot.services import email_templates as email_templates_module
from bot.services.email_templates import (
@@ -318,6 +321,39 @@ def test_uploaded_webapp_logo_is_embedded_inline(tmp_path, monkeypatch):
assert inline_logo.data == logo_body
def test_uploaded_webp_logo_is_embedded_as_transparent_png(tmp_path, monkeypatch):
uploads_dir = tmp_path / "uploads"
uploads_dir.mkdir()
filename = "logo-2222222222222222.webp"
source = Image.new("RGBA", (3, 3), (0, 0, 0, 0))
source.putpixel((1, 1), (255, 0, 0, 255))
raw = BytesIO()
source.save(raw, format="WEBP", lossless=True)
(uploads_dir / filename).write_bytes(raw.getvalue())
monkeypatch.setattr(email_templates_module, "_WEBAPP_UPLOADED_LOGO_DIR", uploads_dir)
settings = _settings()
settings.WEBAPP_LOGO_URL = f"/webapp-uploaded-logo/{filename}"
content = render_login_code(
settings,
code="123456",
language_code="en",
purpose="login",
i18n=_i18n("en"),
)
assert 'src="cid:webapp-logo"' in content.html
assert len(content.inline_images) == 1
inline_logo = content.inline_images[0]
assert inline_logo.content_type == "image/png"
with Image.open(BytesIO(inline_logo.data)) as converted:
assert converted.mode == "RGBA"
assert converted.getpixel((0, 0))[3] == 0
assert converted.getpixel((1, 1))[3] == 255
def test_public_https_webapp_logo_remains_external():
settings = _settings()
settings.WEBAPP_LOGO_URL = "https://cdn.example.com/logo.png"
+23
View File
@@ -45,6 +45,29 @@ def test_worker_starts_backup_task_without_enabled_guard():
assert guarded_backup_tasks == []
def test_telegram_webhook_configuration_is_deferred_until_site_start():
main_source = Path("backend/bot/main_bot.py").read_text(encoding="utf-8")
web_source = Path("backend/bot/app/web/web_server.py").read_text(encoding="utf-8")
tree = ast.parse(main_source)
startup_node = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.AsyncFunctionDef) and node.name == "on_startup_configured"
)
startup_set_webhook_calls = [
node
for node in ast.walk(startup_node)
if isinstance(node, ast.Attribute) and node.attr == "set_webhook"
]
assert startup_set_webhook_calls == []
assert "after_webhooks_started=_after_webhooks_started" in main_source
assert web_source.index("await site.start()") < web_source.index(
"await after_webhooks_started()"
)
def test_telegram_startup_network_error_retries_until_success_without_traceback(caplog):
calls = []
+55
View File
@@ -0,0 +1,55 @@
import unittest
from types import SimpleNamespace
from bot.payment_providers.shared.http_client import (
HttpClientMixin,
_should_retry_transport_error,
)
class _DummyHttpClient(HttpClientMixin):
def __init__(self, total_timeout=20):
self._init_http_client(total_timeout=total_timeout)
class PaymentHttpClientTests(unittest.IsolatedAsyncioTestCase):
async def test_http_client_tracks_sent_headers_for_safe_retries(self):
client = _DummyHttpClient()
try:
session = await client._get_session()
self.assertFalse(session.connector.force_close)
self.assertTrue(session.trace_configs)
finally:
await client.close()
async def test_http_client_retries_only_before_headers_are_sent(self):
self.assertTrue(_should_retry_transport_error(TimeoutError(), {"headers_sent": False}))
self.assertFalse(_should_retry_transport_error(TimeoutError(), {"headers_sent": True}))
async def test_http_client_applies_runtime_timeout_changes(self):
settings = SimpleNamespace(PAYMENT_REQUEST_TIMEOUT_SECONDS=20)
client = _DummyHttpClient(total_timeout=lambda: settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
try:
first = await client._get_session()
self.assertEqual(first.timeout.total, 20)
self.assertIs(await client._get_session(), first)
settings.PAYMENT_REQUEST_TIMEOUT_SECONDS = 5
second = await client._get_session()
self.assertIsNot(second, first)
self.assertEqual(second.timeout.total, 5)
# The replaced session must stay usable for in-flight requests;
# it is closed later, and close() always sweeps it up.
self.assertFalse(first.closed)
finally:
await client.close()
self.assertTrue(first.closed)
self.assertTrue(second.closed)
async def test_http_client_falls_back_to_default_timeout_on_bad_source(self):
client = _DummyHttpClient(total_timeout=lambda: None)
try:
session = await client._get_session()
self.assertEqual(session.timeout.total, 20.0)
finally:
await client.close()
+80
View File
@@ -0,0 +1,80 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch
from bot.services.promo_code_service import PromoCodeService
class PromoCodeServiceTests(IsolatedAsyncioTestCase):
async def test_apply_promo_passes_default_tariff_for_new_bonus_subscription(self):
end_date = datetime(2026, 1, 8, tzinfo=timezone.utc)
settings = SimpleNamespace(
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED=False,
BRUTE_FORCE_LOCK_SECONDS=60,
BRUTE_FORCE_MAX_FAILURES=5,
BRUTE_FORCE_WINDOW_SECONDS=300,
tariffs_config=SimpleNamespace(default_tariff="standard"),
)
subscription_service = SimpleNamespace(
extend_active_subscription_days=AsyncMock(return_value=end_date)
)
i18n = SimpleNamespace(gettext=lambda lang, key, **kw: key)
service = PromoCodeService(settings, subscription_service, AsyncMock(), i18n)
session = AsyncMock()
promo = SimpleNamespace(
promo_code_id=5,
code="HELLO",
bonus_days=7,
)
with (
patch(
"bot.services.promo_code_service.security_dal.check_throttle",
AsyncMock(return_value=SimpleNamespace(locked=False, retry_after=None)),
),
patch(
"bot.services.promo_code_service.promo_code_dal.get_active_promo_code_by_code_str",
AsyncMock(return_value=promo),
),
patch(
"bot.services.promo_code_service.promo_code_dal.get_user_activation_for_promo",
AsyncMock(return_value=None),
),
patch(
"bot.services.promo_code_service.promo_code_dal.record_promo_activation",
AsyncMock(return_value=True),
),
patch(
"bot.services.promo_code_service.promo_code_dal.increment_promo_code_usage",
AsyncMock(return_value=True),
),
patch(
"bot.services.promo_code_service.security_dal.clear_throttle_state",
AsyncMock(),
),
patch(
"bot.services.promo_code_service.NotificationService",
return_value=SimpleNamespace(notify_promo_activation=AsyncMock()),
),
patch(
"bot.services.promo_code_service.user_dal.get_user_by_id",
AsyncMock(return_value=None),
),
):
success, result = await service.apply_promo_code(
session=session,
user_id=42,
code_input="hello",
user_lang="en",
)
self.assertTrue(success)
self.assertEqual(result, end_date)
subscription_service.extend_active_subscription_days.assert_awaited_once_with(
session=session,
user_id=42,
bonus_days=7,
reason="promo code HELLO",
tariff_key="standard",
)
+106 -1
View File
@@ -12,6 +12,7 @@ from aiohttp import web
from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import settings as admin_settings_routes
from bot.app.web.web_server import TrustedProxyAccessLogger
from bot.app.web.webapp import account as account_routes
from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce,
@@ -37,17 +38,96 @@ from db.database_setup import redacted_database_url
class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
async def test_request_client_ip_uses_last_forwarded_for_value_for_trusted_proxy(self):
async def test_request_client_ip_uses_rightmost_untrusted_forwarded_ip(self):
request = SimpleNamespace(
remote="127.0.0.1",
headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"},
)
self.assertEqual(
request_client_ip(
request,
trusted_proxies=["127.0.0.1", "198.51.100.0/24"],
),
"203.0.113.10",
)
async def test_request_client_ip_ignores_spoofed_forwarded_prefix(self):
request = SimpleNamespace(
remote="127.0.0.1",
headers={"X-Forwarded-For": "198.51.100.200, 203.0.113.10"},
)
self.assertEqual(
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
"203.0.113.10",
)
async def test_request_client_ip_ignores_forwarded_for_from_untrusted_remote(self):
request = SimpleNamespace(
remote="203.0.113.50",
headers={"X-Forwarded-For": "198.51.100.200, 192.0.2.10"},
)
self.assertEqual(
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
"203.0.113.50",
)
async def test_request_client_ip_uses_leftmost_forwarded_ip_when_all_hops_are_trusted(self):
request = SimpleNamespace(
remote="127.0.0.1",
headers={"X-Forwarded-For": "172.18.0.4, 172.18.0.5"},
)
self.assertEqual(
request_client_ip(request, trusted_proxies=["127.0.0.1", "172.16.0.0/12"]),
"172.18.0.4",
)
async def test_request_client_ip_keeps_last_forwarded_ip_without_remote(self):
request = SimpleNamespace(
remote=None,
headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"},
)
self.assertEqual(
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
"198.51.100.7",
)
async def test_request_client_ip_skips_trusted_forwarded_proxy_chain(self):
request = SimpleNamespace(
remote="172.19.0.6",
headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"},
)
self.assertEqual(
request_client_ip(request, trusted_proxies=["172.19.0.0/16"]),
"203.0.113.10",
)
async def test_access_logger_uses_forwarded_ip_only_for_trusted_proxy(self):
trusted_request = SimpleNamespace(
remote="172.19.0.6",
headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"},
app={"settings": SimpleNamespace(trusted_proxies=["172.19.0.0/16"])},
)
untrusted_request = SimpleNamespace(
remote="172.19.0.6",
headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"},
app={"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"])},
)
self.assertEqual(
TrustedProxyAccessLogger._format_a(trusted_request, object(), 0),
"203.0.113.10",
)
self.assertEqual(
TrustedProxyAccessLogger._format_a(untrusted_request, object(), 0),
"172.19.0.6",
)
async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self):
request = SimpleNamespace(
app={
@@ -70,6 +150,30 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status, 403)
request.json.assert_not_awaited()
async def test_account_email_routes_reject_when_email_auth_disabled(self):
settings = SimpleNamespace(email_auth_configured=False)
handlers = [
account_routes.account_email_request_route,
account_routes.account_email_verify_route,
account_routes.account_password_request_route,
account_routes.account_password_confirm_route,
]
with patch.object(account_routes, "_require_user_id", return_value=42):
for handler in handlers:
request = SimpleNamespace(
app={"settings": settings},
json=AsyncMock(side_effect=AssertionError("request.json() must not be called")),
headers={},
cookies={},
)
response = await handler(request)
self.assertEqual(response.status, 503, handler.__name__)
self.assertIn("email_auth_not_configured", response.text)
request.json.assert_not_awaited()
class FreeKassaServiceTests(unittest.TestCase):
def _make_service(self) -> FreeKassaService:
@@ -77,6 +181,7 @@ class FreeKassaServiceTests(unittest.TestCase):
settings = SimpleNamespace(
DEFAULT_CURRENCY_SYMBOL="RUB",
PAYMENT_REQUEST_TIMEOUT_SECONDS=15,
trusted_proxies=["127.0.0.1"],
)
config = FreeKassaConfig(
+20
View File
@@ -42,6 +42,26 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.WEBAPP_TITLE, "/minishop")
def test_trusted_proxies_default_includes_private_proxy_ranges(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
self.assertEqual(
settings.trusted_proxies,
[
"127.0.0.1",
"::1",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
],
)
def test_panel_write_mode_defaults_to_live_in_production(self):
settings = Settings(
_env_file=None,
@@ -0,0 +1,83 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, Mock, patch
from bot.handlers.user.start import start_command_handler
class StartReferralWelcomeBonusTests(IsolatedAsyncioTestCase):
async def test_start_referral_welcome_bonus_passes_default_tariff(self):
end_date = datetime(2026, 1, 9, tzinfo=timezone.utc)
settings = SimpleNamespace(
DEFAULT_LANGUAGE="en",
ADMIN_IDS=[],
DISABLE_WELCOME_MESSAGE=False,
REFERRAL_WELCOME_BONUS_DAYS=3,
tariffs_config=SimpleNamespace(default_tariff="standard"),
)
i18n = SimpleNamespace(gettext=lambda lang, key, **kw: key)
subscription_service = SimpleNamespace(
extend_active_subscription_days=AsyncMock(return_value=end_date)
)
session = AsyncMock()
message = SimpleNamespace(
from_user=SimpleNamespace(
id=42,
username="alice",
first_name="Alice",
last_name="Example",
full_name="Alice Example",
),
bot=AsyncMock(),
answer=AsyncMock(),
)
state = SimpleNamespace(clear=AsyncMock())
ref_match = Mock()
ref_match.group.return_value = "ABC123"
created_user = SimpleNamespace(user_id=42, referred_by_id=7)
with (
patch(
"bot.handlers.user.start._resolve_referrer_from_start_ref",
AsyncMock(return_value=7),
),
patch(
"bot.handlers.user.start.user_dal.get_user_by_id",
AsyncMock(return_value=None),
),
patch(
"bot.handlers.user.start.user_dal.create_user",
AsyncMock(return_value=(created_user, True)),
),
patch(
"bot.handlers.user.start.ensure_required_channel_subscription",
AsyncMock(return_value=True),
),
patch(
"bot.handlers.user.start.send_main_menu",
AsyncMock(),
),
patch(
"bot.services.notification_service.NotificationService",
return_value=SimpleNamespace(notify_new_user_registration=AsyncMock()),
),
):
await start_command_handler(
message=message,
state=state,
settings=settings,
i18n_data={"current_language": "en", "i18n_instance": i18n},
subscription_service=subscription_service,
referral_service=AsyncMock(),
session=session,
ref_match=ref_match,
)
subscription_service.extend_active_subscription_days.assert_awaited_once_with(
session,
42,
3,
reason="referral_welcome_bonus",
tariff_key="standard",
)
@@ -532,6 +532,72 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
async def test_promo_bonus_without_active_subscription_uses_default_tariff_squads(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(
_tariffs_config_payload(),
tmpdir,
USER_TRAFFIC_LIMIT_GB=999,
USER_EXTERNAL_SQUAD_UUID="external-squad",
)
service = _make_service(settings)
service._get_or_create_panel_user_link_details = AsyncMock(
return_value=("panel-user", "short-uuid", "short", False)
)
service.panel_service.update_user_details_on_panel = AsyncMock(
return_value={"ok": True}
)
updated_sub = SimpleNamespace(
subscription_id=10,
end_date=datetime.now(timezone.utc) + timedelta(days=7),
traffic_limit_bytes=100 * GIB,
tariff_key="standard",
hwid_device_limit=3,
)
with (
patch(
"bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
AsyncMock(return_value=SimpleNamespace(user_id=42)),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=None),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.deactivate_other_active_subscriptions",
AsyncMock(),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.upsert_subscription",
AsyncMock(return_value=updated_sub),
) as upsert_subscription,
):
await service.extend_active_subscription_days(
session=AsyncMock(),
user_id=42,
bonus_days=7,
reason="promo code HELLO",
tariff_key="standard",
)
sub_payload = upsert_subscription.await_args.args[1]
self.assertEqual(sub_payload["tariff_key"], "standard")
self.assertEqual(sub_payload["traffic_limit_bytes"], 100 * GIB)
self.assertEqual(sub_payload["tier_baseline_bytes"], 100 * GIB)
self.assertEqual(sub_payload["premium_baseline_bytes"], 25 * GIB)
self.assertEqual(sub_payload["hwid_device_limit"], 3)
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
self.assertEqual(panel_payload["trafficLimitBytes"], 100 * GIB)
self.assertEqual(panel_payload["trafficLimitStrategy"], "MONTH")
self.assertEqual(panel_payload["hwidDeviceLimit"], 3)
self.assertEqual(
panel_payload["activeInternalSquads"],
["main-squad", "shared-squad", "premium-squad"],
)
self.assertEqual(panel_payload["externalSquadUuid"], "external-squad")
async def test_referral_extension_preserves_existing_tariff_limit(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(
+131
View File
@@ -27,6 +27,10 @@ def _i18n():
return JsonI18n(str(REPO_ROOT / "locales"), default="ru")
def _keyboard_buttons(markup):
return [button for row in markup.inline_keyboard for button in row]
def test_support_ticket_closed_email_uses_user_language():
content = render_support_ticket_closed_user(
_settings(DEFAULT_LANGUAGE="en"),
@@ -106,6 +110,25 @@ def test_admin_support_keyboard_uses_consistent_admin_links():
assert user_card_button.web_app.url == "https://app.example.com/app/admin/users/100200300"
def test_admin_support_keyboard_can_use_group_safe_urls():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com/app"),
)
ticket = SimpleNamespace(ticket_id=42)
user = SimpleNamespace(user_id=100200300)
keyboard = service._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
ticket_button = keyboard.inline_keyboard[0][0]
user_card_button = keyboard.inline_keyboard[1][1]
assert ticket_button.web_app is None
assert ticket_button.url == "https://app.example.com/app/admin/support/42"
assert keyboard.inline_keyboard[1][0].url == "tg://user?id=100200300"
assert user_card_button.web_app is None
assert user_card_button.url == "https://app.example.com/app/admin/users/100200300"
def test_admin_support_keyboard_falls_back_to_startapp_url():
service = NotificationService(
bot=SimpleNamespace(),
@@ -364,6 +387,114 @@ def test_disabled_admin_support_email_keeps_telegram_and_log_notifications():
assert emails == []
def test_support_topic_suppresses_admin_dm_and_uses_url_buttons():
channels = []
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(
LOG_CHAT_ID=-1003918000002,
LOG_SUPPORT_THREAD_ID=77,
SUBSCRIPTION_MINI_APP_URL="https://app.example.com",
),
)
async def send_to_admins(message, reply_markup=None):
channels.append(("admins", None, bool(message), reply_markup))
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
channels.append(("log", thread_id, bool(message), reply_markup))
service._send_to_admins = send_to_admins
service._send_to_log_channel = send_to_log_channel
ticket = SimpleNamespace(
ticket_id=7,
priority="normal",
category="technical",
subject="Connection issue",
)
user = SimpleNamespace(
user_id=100200300,
username="user",
first_name="User",
last_name=None,
email="user@example.com",
)
asyncio.run(
service.notify_new_support_ticket(
ticket,
user,
"Cannot connect",
{"tariff": "Standard", "end_date": "2026-06-01"},
)
)
assert [item[0] for item in channels] == ["log"]
assert channels[0][1] == 77
markup = channels[0][3]
buttons = _keyboard_buttons(markup)
assert all(button.web_app is None for button in buttons)
assert buttons[0].url == "https://app.example.com/admin/support/7"
assert buttons[2].url == "https://app.example.com/admin/users/100200300"
def test_support_user_reply_topic_suppresses_admin_dm_and_uses_url_buttons():
channels = []
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(
LOG_CHAT_ID=-1003918000002,
LOG_SUPPORT_THREAD_ID=77,
SUBSCRIPTION_MINI_APP_URL="https://app.example.com",
),
)
async def send_to_admins(message, reply_markup=None):
channels.append(("admins", None, bool(message), reply_markup))
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
channels.append(("log", thread_id, bool(message), reply_markup))
service._send_to_admins = send_to_admins
service._send_to_log_channel = send_to_log_channel
ticket = SimpleNamespace(
ticket_id=7,
priority="normal",
category="technical",
subject="Connection issue",
)
message = SimpleNamespace(body="Still cannot connect")
user = SimpleNamespace(
user_id=100200300,
username="user",
first_name="User",
last_name=None,
email="user@example.com",
)
asyncio.run(
service.notify_support_user_reply(
ticket,
message,
user,
{},
unread_count=3,
send_telegram=True,
send_email=False,
)
)
assert [item[0] for item in channels] == ["log"]
assert channels[0][1] == 77
buttons = _keyboard_buttons(channels[0][3])
assert all(button.web_app is None for button in buttons)
assert buttons[0].url == "https://app.example.com/admin/support/7"
def test_support_user_reply_can_send_email_without_telegram_channels():
emails = []
channels = []
+33
View File
@@ -51,6 +51,8 @@ def test_build_payload_shape(settings):
for key in (
"app_version",
"app_version_tag",
"build_provenance",
"image_modified",
"os",
"arch",
"python_version",
@@ -63,13 +65,44 @@ def test_build_payload_shape(settings):
assert key in props, f"missing property: {key}"
assert isinstance(props["payment_providers"], list)
assert props["build_provenance"] in {"official", "custom", "unknown"}
assert isinstance(props["image_modified"], bool)
# No DB session -> user count degrades to the smallest bucket.
assert props["users_bucket"] == "0"
# Person properties mirror the event properties so PostHog breakdowns work.
assert props["$set"]["app_version"] == props["app_version"]
assert props["$set"]["build_provenance"] == props["build_provenance"]
assert props["$set"]["image_modified"] == props["image_modified"]
assert props["$lib"] == "remnawave-minishop"
def test_payload_marks_official_images_not_modified(settings, monkeypatch):
monkeypatch.setattr(
"bot.services.telemetry_worker.resolve_build_provenance",
lambda: "official",
)
monkeypatch.setattr("bot.services.telemetry_worker.resolve_image_modified", lambda: False)
worker = TelemetryWorker(settings, None)
payload = asyncio.run(worker._build_payload(None, "install-123"))
props = payload["properties"]
assert props["build_provenance"] == "official"
assert props["image_modified"] is False
def test_payload_marks_custom_images_modified(settings, monkeypatch):
monkeypatch.setattr("bot.services.telemetry_worker.resolve_build_provenance", lambda: "custom")
monkeypatch.setattr("bot.services.telemetry_worker.resolve_image_modified", lambda: True)
worker = TelemetryWorker(settings, None)
payload = asyncio.run(worker._build_payload(None, "install-123"))
props = payload["properties"]
assert props["build_provenance"] == "custom"
assert props["image_modified"] is True
def test_payload_contains_no_secrets_or_pii(settings):
worker = TelemetryWorker(settings, None)
payload = asyncio.run(worker._build_payload(None, "install-123"))
+5 -2
View File
@@ -23,6 +23,7 @@ from bot.keyboards.inline.user_keyboards import (
get_yk_autopay_choice_keyboard,
payment_methods_back_callback,
payment_options_back_callback,
tariff_purchase_back_callback,
)
from bot.middlewares.i18n import LOCALE_KEY_ALIASES
from config.tariffs_config import TariffsConfig
@@ -332,14 +333,16 @@ class UserBotMenuTests(unittest.TestCase):
"en",
self.i18n,
settings,
back_callback="main_action:bot_subscribe",
back_callback=tariff_purchase_back_callback("bot"),
callback_context="bot",
)
self.assertIn("tariff:select:basic:bot", self._callback_data(catalog))
self.assertIn("main_action:bot_interface", self._callback_data(catalog))
self.assertIn("tariff:period:basic:1:bot", self._callback_data(periods))
self.assertIn("main_action:bot_subscribe", self._callback_data(periods))
self.assertIn("main_action:bot_interface", self._callback_data(periods))
self.assertEqual(tariff_purchase_back_callback("bot"), "main_action:bot_interface")
self.assertEqual(tariff_purchase_back_callback(None), "main_action:subscribe")
self.assertEqual(
payment_options_back_callback("subscription@basic|bot"),
"tariff:select:basic:bot",
+40
View File
@@ -60,6 +60,7 @@ def _service(session, **config_overrides):
settings = SimpleNamespace(
DEFAULT_CURRENCY_SYMBOL="RUB",
DEFAULT_LANGUAGE="ru",
PAYMENT_REQUEST_TIMEOUT_SECONDS=15,
traffic_sale_mode=False,
trusted_proxies=[],
)
@@ -354,6 +355,45 @@ def test_try_reuse_pending_link_returns_url_for_opened_link():
assert url == "https://wata.pro/p/link-id"
def test_try_reuse_pending_link_returns_none_for_other_link_id():
service = _service(_FakeSession())
payment = _payment(provider_payment_id="link-id")
async def fake_get_payment_link(payment_link_id):
assert payment_link_id == "link-id"
return True, {
"id": "other-link-id",
"status": "Opened",
"url": "https://wata.pro/p/other-link-id",
"expirationDateTime": "2099-01-01T00:00:00Z",
}
service.get_payment_link = fake_get_payment_link
url = asyncio.run(service.try_reuse_pending_link(payment))
assert url is None
def test_try_reuse_pending_link_returns_none_for_other_order_id():
service = _service(_FakeSession())
payment = _payment(provider_payment_id="link-id")
async def fake_get_payment_link(payment_link_id):
assert payment_link_id == "link-id"
return True, {
"id": "link-id",
"orderId": "999",
"status": "Opened",
"url": "https://wata.pro/p/link-id",
"expirationDateTime": "2099-01-01T00:00:00Z",
}
service.get_payment_link = fake_get_payment_link
url = asyncio.run(service.try_reuse_pending_link(payment))
assert url is None
def test_try_reuse_pending_link_returns_none_for_closed_link():
service = _service(_FakeSession())
payment = _payment(provider_payment_id="link-id")
+99 -13
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from aiohttp import web
from PIL import Image, ImageOps
from bot.app.web import subscription_webapp
@@ -330,8 +331,19 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("https://fonts.googleapis.com", html)
self.assertNotIn('id="logo-preload"', html)
self.assertNotIn('href=""', html)
self.assertNotIn("<title>/minishop</title>", html)
self.assertIn("<title>Subscription</title>", html)
self.assertLess(html.index("/subscription_webapp.css"), html.index("WEBAPP_JS_SCRIPT"))
def test_mobile_bottom_nav_many_items_uses_compact_phone_layout(self):
css_path = Path(__file__).resolve().parents[1] / "frontend/src/styles/webapp.css"
css = css_path.read_text(encoding="utf-8")
self.assertIn("@media (max-width: 460px)", css)
self.assertIn(".bottom-nav.bottom-nav-many", css)
self.assertIn(".bottom-nav.bottom-nav-many .bottom-nav-label", css)
self.assertIn("display: none;", css)
def test_https_webapp_logo_uses_same_origin_proxy(self):
settings = SimpleNamespace(WEBAPP_LOGO_URL="https://cdn.example.com/logo.png")
@@ -476,6 +488,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status, 200)
self.assertEqual(response.content_type, "image/png")
self.assertEqual(response.body, b"touch-icon")
self.assertEqual(response.headers["Cache-Control"], "no-cache")
async def test_current_favicon_alias_serves_default_icon_when_unconfigured(self):
settings = SimpleNamespace(
@@ -492,6 +505,23 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status, 200)
self.assertEqual(response.content_type, "image/png")
self.assertGreater(len(response.body), 0)
self.assertEqual(response.headers["Cache-Control"], "no-cache")
async def test_current_favicon_alias_redirect_is_not_cached(self):
settings = SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_LOGO_URL="",
WEBAPP_FAVICON_USE_CUSTOM=True,
WEBAPP_FAVICON_URL="/uploaded-icon.png",
WEBAPP_LOGO_FAVICON_URL="",
)
request = SimpleNamespace(app={"settings": settings}, path="/icon-192.png")
with self.assertRaises(web.HTTPFound) as exc:
await webapp_assets.webapp_current_favicon_route(request)
self.assertEqual(exc.exception.location, "/uploaded-icon.png")
self.assertEqual(exc.exception.headers["Cache-Control"], "no-cache")
async def test_default_logo_route_serves_bundled_logo(self):
settings = SimpleNamespace(WEBAPP_ENABLED=True)
@@ -549,6 +579,18 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('href="/apple-touch-icon.png"', template)
self.assertIn('href="/favicon.ico"', template)
def test_frontend_runtime_fallback_title_is_not_minishop_path(self):
app_source = Path("frontend/src/App.svelte").read_text(encoding="utf-8")
browser_source = Path("frontend/src/lib/webapp/browser.js").read_text(encoding="utf-8")
preview_source = Path("frontend/src/PreviewBoard.svelte").read_text(encoding="utf-8")
admin_source = Path("frontend/src/admin/AdminPanel.svelte").read_text(encoding="utf-8")
self.assertNotIn('title: "/minishop"', app_source)
self.assertNotIn('CFG.title || "/minishop"', app_source)
self.assertNotIn('brand.title || "/minishop"', browser_source)
self.assertNotIn('config.title || "/minishop"', preview_source)
self.assertNotIn('brandTitle = "/minishop"', admin_source)
def test_frontend_nginx_proxies_root_icon_aliases(self):
nginx_conf = Path("deploy/docker/frontend/nginx.conf").read_text(encoding="utf-8")
@@ -557,6 +599,19 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("/webapp-default-logo.webp", nginx_conf)
self.assertIn("proxy_pass http://backend:8081;", nginx_conf)
def test_frontend_nginx_proxies_shell_routes_for_dynamic_head(self):
nginx_conf = Path("deploy/docker/frontend/nginx.conf").read_text(encoding="utf-8")
marker = 'location ~ "^/(?:$|login/password$|home$|install$|trial$|s/[a-f0-9]{32}$'
self.assertIn(marker, nginx_conf)
start = nginx_conf.index(marker)
shell_block = nginx_conf[start : nginx_conf.index("\n\n", start)]
self.assertIn("proxy_pass http://backend:8081;", shell_block)
self.assertIn("proxy_set_header Host $host;", shell_block)
self.assertIn("devices$", shell_block)
self.assertIn("admin(?:/.*)?$", shell_block)
def test_home_logo_scale_rules_beat_late_loaded_admin_brand_styles(self):
css = Path("frontend/src/styles/webapp.css").read_text(encoding="utf-8")
base_css = Path("frontend/src/styles/base.css").read_text(encoding="utf-8")
@@ -592,10 +647,8 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
root = Path(tmpdir)
uploads = root / "uploads"
favicons = root / "favicons"
emoji = root / "emoji"
uploads.mkdir()
favicons.mkdir()
emoji.mkdir()
(uploads / "logo-1111111111111111.png").write_bytes(b"keep")
(uploads / "logo-2222222222222222.png").write_bytes(b"remove")
(favicons / "aaaaaaaaaaaaaaaa").mkdir()
@@ -604,10 +657,6 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
(favicons / "aaaaaaaaaaaaaaaa" / "icon-180.png").write_bytes(b"keep")
(favicons / "bbbbbbbbbbbbbbbb" / "icon-180.png").write_bytes(b"keep")
(favicons / "cccccccccccccccc" / "icon-180.png").write_bytes(b"remove")
# Emoji logos were removed; any leftover emoji cache files are purged.
(emoji / "1f929.512.gif").write_bytes(b"remove")
(emoji / "1f929.512.webp").write_bytes(b"remove")
(emoji / "1f525.512.gif").write_bytes(b"remove")
settings = SimpleNamespace(
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-1111111111111111.png",
WEBAPP_FAVICON_URL="/webapp-favicon/aaaaaaaaaaaaaaaa/icon-180.png",
@@ -617,7 +666,6 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
with (
patch.object(admin_themes, "WEBAPP_UPLOADED_LOGO_DIR", uploads),
patch.object(admin_themes, "WEBAPP_FAVICON_DIR", favicons),
patch.object(admin_themes, "WEBAPP_EMOJI_CACHE_DIR", emoji),
):
admin_themes.prune_unused_appearance_assets(settings)
@@ -626,9 +674,6 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue((favicons / "aaaaaaaaaaaaaaaa").exists())
self.assertTrue((favicons / "bbbbbbbbbbbbbbbb").exists())
self.assertFalse((favicons / "cccccccccccccccc").exists())
self.assertFalse((emoji / "1f929.512.gif").exists())
self.assertFalse((emoji / "1f929.512.webp").exists())
self.assertFalse((emoji / "1f525.512.gif").exists())
async def test_persist_appearance_upload_writes_overrides_and_clears_caches(self):
settings = SimpleNamespace()
@@ -935,7 +980,26 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"subscription_webapp.min.22222222.js",
)
def test_resolve_webapp_admin_asset_names_use_stable_runtime_builds(self):
def test_resolve_webapp_asset_names_version_stable_fallbacks(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
(asset_dir / "subscription_webapp.js").write_text(
"console.log('fallback');", encoding="utf-8"
)
(asset_dir / "subscription_webapp.css").write_text(".app{color:red}", encoding="utf-8")
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
webapp_assets._ASSET_NAME_CACHE.clear()
self.assertRegex(
subscription_webapp._resolve_webapp_js_asset_name(),
r"^subscription_webapp\.js\?v=[0-9a-f]{8}$",
)
self.assertRegex(
subscription_webapp._resolve_webapp_css_asset_name(),
r"^subscription_webapp\.css\?v=[0-9a-f]{8}$",
)
def test_resolve_webapp_admin_asset_names_prefer_latest_hashed_builds(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
(asset_dir / "subscription_webapp_admin.js").write_text(
@@ -958,13 +1022,35 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
os.utime(new_css, (2, 2))
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
webapp_assets._ASSET_NAME_CACHE.clear()
self.assertEqual(
subscription_webapp._resolve_webapp_admin_js_asset_name(),
"subscription_webapp_admin.js",
"subscription_webapp_admin.min.22222222.js",
)
self.assertEqual(
subscription_webapp._resolve_webapp_admin_css_asset_name(),
"subscription_webapp_admin.css",
"subscription_webapp_admin.22222222.css",
)
def test_resolve_webapp_admin_asset_names_fall_back_to_runtime_builds(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
(asset_dir / "subscription_webapp_admin.js").write_text(
"console.log('admin fallback');", encoding="utf-8"
)
(asset_dir / "subscription_webapp_admin.css").write_text(
".admin{color:red}", encoding="utf-8"
)
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
webapp_assets._ASSET_NAME_CACHE.clear()
self.assertRegex(
subscription_webapp._resolve_webapp_admin_js_asset_name(),
r"^subscription_webapp_admin\.js\?v=[0-9a-f]{8}$",
)
self.assertRegex(
subscription_webapp._resolve_webapp_admin_css_asset_name(),
r"^subscription_webapp_admin\.css\?v=[0-9a-f]{8}$",
)
async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self):
+64
View File
@@ -0,0 +1,64 @@
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
def test_devices_labels_use_subscription_limit_before_devices_payload():
node = shutil.which("node")
if not node:
pytest.skip("node is required to exercise the webapp label helpers")
script = textwrap.dedent(
"""
const mod = await import("./frontend/src/lib/webapp/devicesLabels.js");
const t = (key, params = {}, fallback = "") => {
if (key === "wa_devices_count") return `${params.current}/${params.max}`;
if (key === "wa_devices_unlimited") return "Unlimited";
return fallback || key;
};
const result = {
missingLimit: mod.devicesLimitLabel(null, t),
fallbackLimit: mod.devicesLimitLabel(null, t, 5),
fallbackCount: mod.devicesCountLabel({ current_devices: 2 }, t, 5),
fallbackPercent: mod.devicesPercent({ current_devices: 2 }, 5),
unlimitedLimit: mod.devicesLimitLabel({ max_devices: 0 }, t),
unlimitedPercent: mod.devicesPercent({ current_devices: 2, max_devices: 0 }),
};
console.log(JSON.stringify(result));
"""
)
completed = subprocess.run(
[node, "--input-type=module", "--eval", script],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
)
payload = json.loads(completed.stdout)
assert payload == {
"missingLimit": "...",
"fallbackLimit": "5",
"fallbackCount": "2/5",
"fallbackPercent": 40,
"unlimitedLimit": "Unlimited",
"unlimitedPercent": 100,
}
def test_devices_screen_passes_subscription_limit_as_initial_fallback():
source = (REPO_ROOT / "frontend/src/webapp/screens/DevicesScreen.svelte").read_text(
encoding="utf-8"
)
assert "effectiveMaxDevices = devicesData?.max_devices ?? subscription?.max_devices" in source
assert "devicesCountLabel(devicesData, t, effectiveMaxDevices)" in source
assert "devicesPercent(devicesData, effectiveMaxDevices)" in source
assert "devicesLimitLabel(devicesData, t, effectiveMaxDevices)" in source
+652 -2
View File
@@ -1,11 +1,19 @@
import json
import time
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import billing as billing_module
from bot.payment_providers.base import WebAppPaymentContext
from bot.payment_providers.yookassa import create_webapp_payment
from bot.payment_providers.base import PaymentProviderSpec, WebAppPaymentContext
from bot.payment_providers.freekassa import FreeKassaService
from bot.payment_providers.heleket import HeleketService
from bot.payment_providers.paykilla import PaykillaService
from bot.payment_providers.platega import PlategaService
from bot.payment_providers.severpay import SeverPayService
from bot.payment_providers.shared import reusable_webapp_payment_response
from bot.payment_providers.yookassa import create_webapp_payment, reuse_webapp_payment
class _SessionFactory:
@@ -20,6 +28,648 @@ class _SessionFactory:
class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
async def test_heleket_reuses_unexpired_check_payment(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="invoice-77",
provider_payment_url=None,
)
service = object.__new__(HeleketService)
service.get_payment_info = AsyncMock(
return_value=(
True,
{
"uuid": "invoice-77",
"order_id": "77",
"amount": "299.00",
"currency": "RUB",
"payment_status": "check",
"is_final": False,
"expired_at": int(time.time()) + 900,
"url": "https://heleket.example/pay/77",
},
)
)
url = await service.try_reuse_pending_payment(payment)
self.assertEqual(url, "https://heleket.example/pay/77")
service.get_payment_info.assert_awaited_once_with("invoice-77")
async def test_heleket_reuses_by_order_id_when_provider_amount_includes_fee(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="invoice-77",
provider_payment_url=None,
)
service = object.__new__(HeleketService)
service.get_payment_info = AsyncMock(
return_value=(
True,
{
"uuid": "invoice-77",
"order_id": "77",
"amount": "314.00",
"currency": "RUB",
"payment_status": "check",
"is_final": False,
"expired_at": int(time.time()) + 900,
"url": "https://heleket.example/pay/77",
},
)
)
url = await service.try_reuse_pending_payment(payment)
self.assertEqual(url, "https://heleket.example/pay/77")
async def test_heleket_does_not_reuse_processing_payment(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="invoice-77",
provider_payment_url="https://heleket.example/pay/77",
)
service = object.__new__(HeleketService)
service.get_payment_info = AsyncMock(
return_value=(
True,
{
"uuid": "invoice-77",
"order_id": "77",
"amount": "299.00",
"currency": "RUB",
"payment_status": "process",
"is_final": False,
"expired_at": int(time.time()) + 900,
},
)
)
self.assertIsNone(await service.try_reuse_pending_payment(payment))
async def test_severpay_reuses_new_payment(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="12345",
provider_payment_url="https://severpay.example/pay/77",
)
service = object.__new__(SeverPayService)
service.get_payment = AsyncMock(
return_value=(
True,
{
"id": 12345,
"uid": "payment-uid-77",
"order_id": "77",
"amount": 299.0,
"currency": "RUB",
"status": "new",
},
)
)
url = await service.try_reuse_pending_payment(payment)
self.assertEqual(url, "https://severpay.example/pay/77")
service.get_payment.assert_awaited_once_with("12345")
async def test_severpay_reuses_by_order_id_when_provider_amount_includes_fee(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="12345",
provider_payment_url="https://severpay.example/pay/77",
)
service = object.__new__(SeverPayService)
service.get_payment = AsyncMock(
return_value=(
True,
{
"id": 12345,
"uid": "payment-uid-77",
"order_id": "77",
"amount": 314.0,
"currency": "RUB",
"status": "new",
},
)
)
url = await service.try_reuse_pending_payment(payment)
self.assertEqual(url, "https://severpay.example/pay/77")
async def test_severpay_does_not_reuse_failed_payment(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="12345",
provider_payment_url="https://severpay.example/pay/77",
)
service = object.__new__(SeverPayService)
service.get_payment = AsyncMock(
return_value=(
True,
{
"id": 12345,
"order_id": "77",
"amount": 299.0,
"currency": "RUB",
"status": "fail",
},
)
)
self.assertIsNone(await service.try_reuse_pending_payment(payment))
async def test_platega_reuses_matching_pending_transaction(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="transaction-77",
provider_payment_url="https://platega.example/pay/77",
)
service = object.__new__(PlategaService)
service.get_transaction = AsyncMock(
return_value=(
True,
{
"id": "transaction-77",
"status": "PENDING",
"paymentDetails": {"amount": 299.0, "currency": "RUB"},
"payload": json.dumps(
{
"payment_db_id": 77,
"user_id": 1001,
"sale_mode": "subscription@standard",
"platega_variant": "sbp",
}
),
},
)
)
url = await service.try_reuse_pending_transaction(
payment,
user_id=1001,
sale_mode="subscription@standard",
variant="sbp",
)
self.assertEqual(url, "https://platega.example/pay/77")
service.get_transaction.assert_awaited_once_with("transaction-77")
async def test_platega_reuses_by_payload_when_provider_amount_includes_fee(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="transaction-77",
provider_payment_url="https://platega.example/pay/77",
)
service = object.__new__(PlategaService)
service.get_transaction = AsyncMock(
return_value=(
True,
{
"id": "transaction-77",
"status": "PENDING",
"paymentDetails": {"amount": 314.0, "currency": "RUB"},
"payload": json.dumps(
{
"payment_db_id": 77,
"user_id": 1001,
"sale_mode": "subscription@standard",
"platega_variant": "sbp",
}
),
},
)
)
url = await service.try_reuse_pending_transaction(
payment,
user_id=1001,
sale_mode="subscription@standard",
variant="sbp",
)
self.assertEqual(url, "https://platega.example/pay/77")
async def test_platega_does_not_reuse_other_variant(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="transaction-77",
provider_payment_url="https://platega.example/pay/77",
)
service = object.__new__(PlategaService)
service.get_transaction = AsyncMock(
return_value=(
True,
{
"id": "transaction-77",
"status": "PENDING",
"paymentDetails": {"amount": 299.0, "currency": "RUB"},
"payload": json.dumps(
{
"payment_db_id": 77,
"user_id": 1001,
"sale_mode": "subscription@standard",
"platega_variant": "crypto",
}
),
},
)
)
self.assertIsNone(
await service.try_reuse_pending_transaction(
payment,
user_id=1001,
sale_mode="subscription@standard",
variant="sbp",
)
)
async def test_platega_reuses_tariff_upgrade_pending_transaction(self):
payment = SimpleNamespace(
payment_id=35,
amount=150.0,
currency="RUB",
provider_payment_id="transaction-35",
provider_payment_url="https://platega.example/pay/35",
)
service = object.__new__(PlategaService)
service.get_transaction = AsyncMock(
return_value=(
True,
{
"id": "transaction-35",
"status": "PENDING",
"paymentDetails": {"amount": 150.0, "currency": "RUB"},
"payload": json.dumps(
{
"payment_db_id": 35,
"user_id": 734546943,
"months": 1,
"sale_mode": "tariff_upgrade@main",
"traffic_gb": None,
"hwid_devices": None,
"source": "webapp",
"platega_variant": "sbp",
}
),
},
)
)
url = await service.try_reuse_pending_transaction(
payment,
user_id=734546943,
sale_mode="tariff_upgrade@main",
variant="sbp",
)
self.assertEqual(url, "https://platega.example/pay/35")
async def test_render_link_or_fail_skips_id_without_payment_url(self):
from bot.payment_providers.shared.callbacks import render_link_or_fail
payment = SimpleNamespace(payment_id=77, status="pending_platega")
session = AsyncMock()
callback = SimpleNamespace(
message=SimpleNamespace(edit_text=AsyncMock()),
answer=AsyncMock(),
)
with (
patch(
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
AsyncMock(return_value=True),
) as store_id,
patch(
"bot.payment_providers.shared.callbacks.render_payment_link",
AsyncMock(),
) as render_link,
patch(
"bot.payment_providers.shared.callbacks.safe_mark_failed_creation",
AsyncMock(),
) as mark_failed,
patch(
"bot.payment_providers.shared.callbacks.notify_payment_gateway_failure",
AsyncMock(),
),
):
await render_link_or_fail(
callback,
translator=lambda key, **kwargs: key,
current_lang="ru",
i18n=None,
parts=SimpleNamespace(),
session=session,
payment=payment,
api_success=True,
payment_url=None,
provider_payment_id="transaction-77",
log_prefix="Platega",
)
store_id.assert_not_awaited()
render_link.assert_not_awaited()
mark_failed.assert_awaited_once()
async def test_freekassa_reuses_matching_new_order(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="order-hash-77",
)
service = object.__new__(FreeKassaService)
service.config = SimpleNamespace(PAYMENT_URL="https://freekassa.example/")
service.get_orders = AsyncMock(
return_value=(
True,
{
"type": "success",
"orders": [
{
"merchant_order_id": "77",
"fk_order_id": 12345,
"amount": 299.0,
"currency": "RUB",
"status": 0,
}
],
},
)
)
url = await service.try_reuse_pending_order(payment)
self.assertEqual(url, "https://freekassa.example/form/12345/order-hash-77")
service.get_orders.assert_awaited_once_with(payment_id=77, order_status=0)
async def test_freekassa_reuses_by_order_id_when_provider_amount_includes_fee(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="order-hash-77",
)
service = object.__new__(FreeKassaService)
service.config = SimpleNamespace(PAYMENT_URL="https://freekassa.example/")
service.get_orders = AsyncMock(
return_value=(
True,
{
"orders": [
{
"merchant_order_id": "77",
"fk_order_id": 12345,
"amount": 199.0,
"currency": "RUB",
"status": 0,
}
]
},
)
)
url = await service.try_reuse_pending_order(payment)
self.assertEqual(url, "https://freekassa.example/form/12345/order-hash-77")
async def test_reusable_payment_response_returns_existing_payment(self):
payment = SimpleNamespace(payment_id=77)
resolver = AsyncMock(return_value="https://provider.example/pay/77")
spec = PaymentProviderSpec(
id="provider",
provider_key="provider",
label="Provider",
pending_status="pending_provider",
enabled=lambda _config: True,
reuse_webapp_payment=resolver,
)
ctx = WebAppPaymentContext(
request=SimpleNamespace(app={}),
session=AsyncMock(),
user_id=1001,
method="provider",
months=3,
price=299.0,
stars_price=None,
description="Subscription",
sale_mode="subscription@standard",
currency="RUB",
)
with patch.object(
billing_module.payment_dal,
"find_recent_pending_provider_payment",
AsyncMock(return_value=payment),
) as find_pending:
response = await reusable_webapp_payment_response(ctx, spec)
self.assertIsNotNone(response)
self.assertEqual(response.status, 200)
self.assertIn(b'"payment_id": 77', response.body)
resolver.assert_awaited_once_with(ctx, payment)
self.assertEqual(find_pending.await_args.kwargs["amount"], 299.0)
self.assertEqual(find_pending.await_args.kwargs["currency"], "RUB")
self.assertEqual(find_pending.await_args.kwargs["sale_mode"], "subscription@standard")
self.assertEqual(find_pending.await_args.kwargs["months"], 3)
self.assertEqual(find_pending.await_args.kwargs["tariff_key"], "standard")
async def test_yookassa_reuses_only_matching_pending_invoice(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
yookassa_payment_id="yk_77",
provider_payment_id=None,
)
service = SimpleNamespace(
configured=True,
get_payment_info=AsyncMock(
return_value={
"id": "yk_77",
"status": "pending",
"paid": False,
"amount_value": 299.0,
"amount_currency": "RUB",
"metadata": {
"user_id": "1001",
"payment_db_id": "77",
"sale_mode": "subscription@standard",
},
"confirmation_url": "https://yookassa.example/pay/77",
}
),
)
ctx = WebAppPaymentContext(
request=SimpleNamespace(app={"yookassa_service": service}),
session=AsyncMock(),
user_id=1001,
method="yookassa",
months=3,
price=299.0,
stars_price=None,
description="Subscription",
sale_mode="subscription@standard",
currency="RUB",
)
url = await reuse_webapp_payment(ctx, payment)
self.assertEqual(url, "https://yookassa.example/pay/77")
service.get_payment_info.assert_awaited_once_with("yk_77")
async def test_yookassa_reuses_by_metadata_when_provider_amount_includes_fee(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
yookassa_payment_id="yk_77",
provider_payment_id=None,
)
service = SimpleNamespace(
configured=True,
get_payment_info=AsyncMock(
return_value={
"id": "yk_77",
"status": "pending",
"paid": False,
"amount_value": 314.0,
"amount_currency": "RUB",
"metadata": {
"user_id": "1001",
"payment_db_id": "77",
"sale_mode": "subscription@standard",
},
"confirmation_url": "https://yookassa.example/pay/77",
}
),
)
ctx = WebAppPaymentContext(
request=SimpleNamespace(app={"yookassa_service": service}),
session=AsyncMock(),
user_id=1001,
method="yookassa",
months=3,
price=299.0,
stars_price=None,
description="Subscription",
sale_mode="subscription@standard",
currency="RUB",
)
url = await reuse_webapp_payment(ctx, payment)
self.assertEqual(url, "https://yookassa.example/pay/77")
async def test_yookassa_does_not_reuse_invoice_with_other_sale_mode(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
yookassa_payment_id="yk_77",
provider_payment_id=None,
)
service = SimpleNamespace(
configured=True,
get_payment_info=AsyncMock(
return_value={
"status": "pending",
"paid": False,
"amount_value": 299.0,
"amount_currency": "RUB",
"metadata": {
"user_id": "1001",
"payment_db_id": "77",
"sale_mode": "traffic@standard",
},
"confirmation_url": "https://yookassa.example/pay/77",
}
),
)
ctx = WebAppPaymentContext(
request=SimpleNamespace(app={"yookassa_service": service}),
session=AsyncMock(),
user_id=1001,
method="yookassa",
months=3,
price=299.0,
stars_price=None,
description="Subscription",
sale_mode="subscription@standard",
currency="RUB",
)
self.assertIsNone(await reuse_webapp_payment(ctx, payment))
async def test_paykilla_reuses_processing_invoice_by_client_order_id(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="pk_77",
)
service = object.__new__(PaykillaService)
service.config = SimpleNamespace(WIDGET_URL="https://gopay.paykilla.com")
service.get_invoice_details = AsyncMock(
return_value=(
True,
{
"id": "pk_77",
"clientOrderId": "77",
"status": "PROCESSING",
"totalPrice": "314.00",
"currency": "USD",
},
)
)
url = await service.try_reuse_pending_invoice(payment)
self.assertEqual(url, "https://gopay.paykilla.com/pk_77")
service.get_invoice_details.assert_awaited_once_with("pk_77")
async def test_paykilla_does_not_reuse_invoice_with_other_client_order_id(self):
payment = SimpleNamespace(
payment_id=77,
amount=299.0,
currency="RUB",
provider_payment_id="pk_77",
)
service = object.__new__(PaykillaService)
service.config = SimpleNamespace(WIDGET_URL="https://gopay.paykilla.com")
service.get_invoice_details = AsyncMock(
return_value=(
True,
{
"id": "pk_77",
"clientOrderId": "88",
"status": "PROCESSING",
},
)
)
self.assertIsNone(await service.try_reuse_pending_invoice(payment))
async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self):
payment = SimpleNamespace(
payment_id=42,
@@ -45,6 +45,7 @@ class WebAppReferralWelcomeBonusTests(IsolatedAsyncioTestCase):
REFERRAL_WELCOME_BONUS_DAYS=3,
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED=True,
DISPOSABLE_EMAIL_DOMAINS="mailinator.com",
tariffs_config=SimpleNamespace(default_tariff="standard"),
)
user = SimpleNamespace(
user_id=42,
@@ -75,4 +76,5 @@ class WebAppReferralWelcomeBonusTests(IsolatedAsyncioTestCase):
42,
3,
reason="referral_welcome_bonus",
tariff_key="standard",
)
+8 -5
View File
@@ -34,14 +34,14 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertEqual(win95.tokens.style_preset, "win95")
self.assertFalse(win95.use_primary_accent)
self.assertTrue(win95.use_in_admin)
self.assertEqual(win95.assets_version, 13)
self.assertEqual(cfg.theme_by_key("light").assets_version, 5)
self.assertEqual(win95.assets_version, 14)
self.assertEqual(cfg.theme_by_key("light").assets_version, 6)
ascii_theme = cfg.theme_by_key("ascii")
self.assertIsNotNone(ascii_theme)
self.assertEqual(ascii_theme.css_file, "style.css")
self.assertFalse(ascii_theme.use_primary_accent)
self.assertTrue(ascii_theme.use_in_admin)
self.assertEqual(ascii_theme.assets_version, 6)
self.assertEqual(ascii_theme.assets_version, 7)
def test_env_override_default_theme(self):
cfg = builtin_webapp_themes_config("#00fe7a")
@@ -430,7 +430,7 @@ class WebappThemesConfigTests(unittest.TestCase):
descriptor["assets_version"],
cfg.theme_by_key("windows95").assets_version,
)
self.assertEqual(descriptor["assets_version"], 13)
self.assertEqual(descriptor["assets_version"], 14)
self.assertIn("lucide-house", css)
self.assertIn("lucide-earth", css)
self.assertIn("lucide-circle-check", css)
@@ -444,6 +444,7 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertIn("New webapp surfaces: support, purchase info, password login", css)
self.assertIn("Install guide theme surfaces", css)
self.assertIn("Admin controls: range sliders and sortable rows", css)
self.assertIn("Admin health config alerts", css)
self.assertIn(
(
".theme-key-windows95 .support-list-card {\n"
@@ -486,6 +487,7 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertIn(".theme-key-light.app-shell", css)
self.assertIn("Install guide theme surfaces", css)
self.assertIn("Admin controls: range sliders and sortable rows", css)
self.assertIn("Admin health config alerts", css)
def test_resolved_refreshes_stale_builtin_ascii_assets(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -518,7 +520,8 @@ class WebappThemesConfigTests(unittest.TestCase):
descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8"))
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
self.assertEqual(descriptor["assets_version"], cfg.theme_by_key("ascii").assets_version)
self.assertEqual(descriptor["assets_version"], 6)
self.assertEqual(descriptor["assets_version"], 7)
self.assertIn("Console-style tables", css)
self.assertIn("Install guide theme surfaces", css)
self.assertIn("Admin controls: range sliders and sortable rows", css)
self.assertIn("Admin health config alerts", css)