feat: add remnawave panel dry-run mode

This commit is contained in:
3252a8
2026-06-01 10:32:38 +03:00
parent c2344824dc
commit 391487811b
6 changed files with 713 additions and 1 deletions
+18
View File
@@ -22,6 +22,7 @@ from unittest.mock import MagicMock
from bot.app.factories.build_services import build_core_services
from bot.payment_providers.yookassa import YooKassaService
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
@@ -125,6 +126,23 @@ class BuildServicesWiringTests(unittest.TestCase):
self.assertIsInstance(panel_webhook, PanelWebhookService)
self.assertIs(getattr(panel_webhook, "subscription_service", None), subscription)
def test_development_runtime_wires_panel_dry_run_service(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(tmpdir, APP_RUNTIME_MODE="development")
services = build_core_services(
settings=settings,
bot=MagicMock(),
async_session_factory=MagicMock(),
i18n=MagicMock(),
bot_username_for_default_return="testbot",
)
self.assertIsInstance(services["panel_service"], PanelDryRunApiService)
self.assertIs(
services["subscription_service"].panel_service,
services["panel_service"],
)
def test_factory_returns_every_documented_service(self):
"""Guards against silently dropping a service from the bundle. The
web layer reads these keys off ``request.app`` directly — a missing
+100
View File
@@ -0,0 +1,100 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
def _settings(**overrides):
values = {
"PANEL_API_URL": "https://panel.example.test/api",
"PANEL_API_KEY": "panel-key",
"PANEL_DRY_RUN_VALIDATE_REMOTE": False,
"PANEL_DRY_RUN_SYNTHETIC_CREATE": True,
"PANEL_USER_CACHE_TTL_SECONDS": 0,
"PANEL_DEVICES_CACHE_TTL_SECONDS": 0,
"PANEL_ALL_USERS_CACHE_TTL_SECONDS": 0,
"PANEL_ALL_USERS_PAGE_SIZE": 1000,
"REDIS_KEY_PREFIX": "tests",
"USER_HWID_DEVICE_LIMIT": None,
}
values.update(overrides)
return SimpleNamespace(**values)
class PanelDryRunApiServiceTests(unittest.IsolatedAsyncioTestCase):
async def test_update_user_details_returns_synthetic_success_without_http_write(self):
service = PanelDryRunApiService(_settings())
service._request_once = AsyncMock()
result = await service.update_user_details_on_panel(
"user-uuid",
{"trafficLimitBytes": 1024, "trafficLimitStrategy": "NO_RESET"},
)
self.assertEqual(result["uuid"], "user-uuid")
self.assertTrue(result["dryRun"])
self.assertEqual(result["trafficLimitBytes"], 1024)
service._request_once.assert_not_awaited()
async def test_update_user_details_rejects_invalid_payload(self):
service = PanelDryRunApiService(_settings())
service._request_once = AsyncMock()
result = await service.update_user_details_on_panel(
"user-uuid",
{"trafficLimitBytes": -1, "trafficLimitStrategy": "NO_RESET"},
)
self.assertIsNone(result)
service._request_once.assert_not_awaited()
async def test_remote_validation_blocks_missing_panel_user(self):
service = PanelDryRunApiService(_settings(PANEL_DRY_RUN_VALIDATE_REMOTE=True))
service._request_once = AsyncMock(
return_value={"error": True, "status_code": 404, "details": {"errorCode": "A062"}}
)
result = await service.update_user_details_on_panel(
"missing-user",
{"trafficLimitBytes": 1024, "trafficLimitStrategy": "NO_RESET"},
)
self.assertIsNone(result)
service._request_once.assert_awaited_once()
async def test_create_panel_user_returns_synthetic_user(self):
service = PanelDryRunApiService(_settings())
service._request_once = AsyncMock()
result = await service.create_panel_user(
username_on_panel="tg_42",
telegram_id=42,
default_traffic_limit_bytes=2048,
default_traffic_limit_strategy="NO_RESET",
)
panel_user = result["response"]
self.assertTrue(panel_user["dryRun"])
self.assertEqual(panel_user["username"], "tg_42")
self.assertIn("uuid", panel_user)
self.assertTrue(
panel_user["subscriptionUrl"].startswith("https://panel.example.test/api/sub/")
)
self.assertTrue(panel_user["subscriptionUrl"].endswith(panel_user["shortUuid"]))
service._request_once.assert_not_awaited()
async def test_happ_encrypt_post_stays_live(self):
service = PanelDryRunApiService(_settings())
service._request_once = AsyncMock(
return_value={"response": {"encryptedLink": "happ://crypt4/test"}}
)
result = await service.encrypt_happ_link("https://panel.example.test/sub/abc")
self.assertEqual(result, "happ://crypt4/test")
service._request_once.assert_awaited_once()
if __name__ == "__main__": # pragma: no cover
unittest.main()
+45
View File
@@ -42,6 +42,51 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.WEBAPP_TITLE, "/minishop")
def test_panel_write_mode_defaults_to_live_in_production(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
self.assertEqual(settings.APP_RUNTIME_MODE, "production")
self.assertEqual(settings.PANEL_WRITE_MODE, "auto")
self.assertFalse(settings.panel_dry_run_enabled)
def test_development_runtime_enables_panel_dry_run_in_auto_mode(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
APP_RUNTIME_MODE="development",
)
self.assertTrue(settings.panel_dry_run_enabled)
def test_panel_write_mode_live_overrides_development_runtime(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
APP_RUNTIME_MODE="development",
PANEL_WRITE_MODE="live",
)
self.assertFalse(settings.panel_dry_run_enabled)
def test_panel_write_mode_rejects_unknown_value(self):
with self.assertRaises(ValidationError):
Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
PANEL_WRITE_MODE="danger",
)
def test_legacy_subscription_prices_have_defaults(self):
settings = Settings(
_env_file=None,