feat: add backups feature
This commit is contained in:
@@ -2,7 +2,9 @@ import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from bot.app.web.admin_settings_manifest import manifest_payload
|
||||
import pytest
|
||||
|
||||
from bot.app.web.admin_settings_manifest import coerce_value, get_field_by_key, manifest_payload
|
||||
from bot.middlewares.i18n import resolve_locale_key
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -33,6 +35,15 @@ SUBSCRIPTION_GUIDE_SETTINGS = (
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON",
|
||||
)
|
||||
|
||||
BACKUP_SETTINGS = (
|
||||
"BACKUP_ENABLED",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
)
|
||||
|
||||
ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
|
||||
"admin_tariffs_trial_title",
|
||||
"admin_tariffs_trial_subtitle",
|
||||
@@ -171,6 +182,31 @@ def test_subscription_guide_settings_i18n_keys_exist():
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_backup_settings_i18n_keys_exist():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
assert manifest["BACKUP_ENABLED"]["section"] == "backups"
|
||||
assert manifest["BACKUP_ENABLED"]["section_order"] == 9
|
||||
assert manifest["BACKUP_INTERVAL_SECONDS"]["min"] == 60
|
||||
assert manifest["BACKUP_INTERVAL_SECONDS"]["optional"] is False
|
||||
assert manifest["BACKUP_LOCAL_RETENTION"]["min"] == 1
|
||||
assert manifest["BACKUP_LOCAL_RETENTION"]["optional"] is False
|
||||
|
||||
for language in ("ru", "en"):
|
||||
messages = _locale(language)
|
||||
|
||||
assert "admin_settings_section_backups" in messages
|
||||
for setting_key in BACKUP_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
assert field["i18n_label_key"] in messages
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_backup_required_numeric_settings_reject_empty_values():
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "")
|
||||
|
||||
|
||||
def test_payment_provider_settings_include_webhook_metadata():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
@@ -190,9 +226,7 @@ def test_payment_provider_admin_only_toggles_are_mutually_exclusive():
|
||||
manifest["PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"]
|
||||
== "PLATEGA_CRYPTO_ENABLED"
|
||||
)
|
||||
assert (
|
||||
manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED"
|
||||
)
|
||||
assert manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED"
|
||||
|
||||
|
||||
def test_legacy_tariff_settings_are_separated_from_payment_settings():
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.services.backup_archive import (
|
||||
attach_archive_integrity,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
from bot.services.backup_restore_service import (
|
||||
BackupArchiveError,
|
||||
BackupRestoreService,
|
||||
)
|
||||
from bot.services.backup_worker import BACKUP_FILENAME_PREFIX
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
|
||||
values = {
|
||||
"BOT_TOKEN": "token",
|
||||
"POSTGRES_USER": "app_user",
|
||||
"POSTGRES_PASSWORD": "app_password",
|
||||
"POSTGRES_DB": "shop",
|
||||
"BACKUP_DIR": str(tmp_path / "backups"),
|
||||
"BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir),
|
||||
"_env_file": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def _write_backup_archive(
|
||||
settings: Settings,
|
||||
path: Path,
|
||||
*,
|
||||
include_db=True,
|
||||
include_compose=True,
|
||||
unsafe=False,
|
||||
) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=path.parent) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
if include_db:
|
||||
dump_dir = staging_dir / "database"
|
||||
dump_dir.mkdir(parents=True)
|
||||
(dump_dir / "shop.dump").write_bytes(b"fake dump")
|
||||
if include_compose:
|
||||
compose_dir = staging_dir / "compose"
|
||||
compose_dir.mkdir(parents=True)
|
||||
(compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
|
||||
(compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8")
|
||||
manifest = {
|
||||
"app": "remnawave-minishop",
|
||||
"format_version": 1,
|
||||
"type": "test",
|
||||
"created_at": "2026-05-27T09:00:00+00:00",
|
||||
"postgres": {"database": "shop", "included": include_db},
|
||||
"compose": {"included": include_compose, "files_count": 2 if include_compose else 0},
|
||||
"warnings": [],
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
settings=settings,
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
write_zip_from_directory(staging_dir, path)
|
||||
if unsafe:
|
||||
# Add a malicious member after signing; validation must reject before restore.
|
||||
with zipfile.ZipFile(path, "a") as archive:
|
||||
archive.writestr("compose/../evil.txt", "nope")
|
||||
|
||||
|
||||
def test_backup_restore_service_lists_archives_with_contents(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||
_write_backup_archive(settings, archive_path)
|
||||
|
||||
archives = BackupRestoreService(settings).list_archives()
|
||||
|
||||
assert [item.name for item in archives] == [archive_path.name]
|
||||
assert archives[0].has_database is True
|
||||
assert archives[0].has_compose is True
|
||||
assert archives[0].database_name == "shop"
|
||||
assert archives[0].compose_files_count == 2
|
||||
|
||||
|
||||
def test_backup_restore_service_rejects_path_traversal_archive_name(tmp_path):
|
||||
settings = _settings(tmp_path, tmp_path / "compose")
|
||||
service = BackupRestoreService(settings)
|
||||
|
||||
with pytest.raises(BackupArchiveError):
|
||||
service.archive_path_for_name("../backup.zip")
|
||||
|
||||
|
||||
def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
(compose_dir / "docker-compose.yml").write_text("old: true\n", encoding="utf-8")
|
||||
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||
_write_backup_archive(settings, archive_path, include_db=False)
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
result = service.restore_archive_sync(
|
||||
archive_path.name,
|
||||
restore_database=False,
|
||||
restore_compose=True,
|
||||
)
|
||||
|
||||
assert result.database_restored is False
|
||||
assert result.compose_files_restored == 2
|
||||
assert (compose_dir / "docker-compose.yml").read_text(encoding="utf-8") == "services: {}\n"
|
||||
assert (compose_dir / ".env").read_text(encoding="utf-8") == "POSTGRES_PASSWORD=secret\n"
|
||||
assert result.compose_pre_restore_archive
|
||||
assert Path(result.compose_pre_restore_archive).is_file()
|
||||
snapshot = service.inspect_archive(Path(result.compose_pre_restore_archive))
|
||||
assert snapshot.has_compose is True
|
||||
assert snapshot.compose_files_count == 1
|
||||
|
||||
|
||||
def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||
_write_backup_archive(settings, archive_path, include_db=False, unsafe=True)
|
||||
|
||||
with pytest.raises(BackupArchiveError):
|
||||
BackupRestoreService(settings).restore_archive_sync(
|
||||
archive_path.name,
|
||||
restore_database=False,
|
||||
restore_compose=True,
|
||||
)
|
||||
|
||||
assert not (tmp_path / "evil.txt").exists()
|
||||
|
||||
|
||||
def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||
_write_backup_archive(settings, archive_path, include_compose=False)
|
||||
service = BackupRestoreService(settings)
|
||||
restored_payloads = []
|
||||
|
||||
def fake_pg_restore(dump_path: Path) -> None:
|
||||
restored_payloads.append(dump_path.read_bytes())
|
||||
|
||||
service._run_pg_restore = fake_pg_restore
|
||||
|
||||
result = asyncio.run(
|
||||
service.restore_archive(
|
||||
archive_path.name,
|
||||
restore_database=True,
|
||||
restore_compose=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.database_restored is True
|
||||
assert restored_payloads == [b"fake dump"]
|
||||
|
||||
|
||||
def test_backup_restore_service_validates_uploaded_zip(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
temp_path = tmp_path / "not-a-backup.zip"
|
||||
temp_path.write_text("not zip", encoding="utf-8")
|
||||
|
||||
with pytest.raises(BackupArchiveError):
|
||||
BackupRestoreService(settings).import_uploaded_archive(temp_path, "backup.zip")
|
||||
|
||||
|
||||
def test_backup_restore_service_rejects_tampered_archive(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||
_write_backup_archive(settings, archive_path, include_compose=False)
|
||||
|
||||
tampered_path = archive_path.with_name("tampered.zip")
|
||||
with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target:
|
||||
for member in source.infolist():
|
||||
payload = source.read(member.filename)
|
||||
if member.filename == "database/shop.dump":
|
||||
payload = b"not the signed dump"
|
||||
target.writestr(member, payload)
|
||||
|
||||
with pytest.raises(BackupArchiveError):
|
||||
BackupRestoreService(settings).import_uploaded_archive(tampered_path, "tampered.zip")
|
||||
@@ -0,0 +1,168 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from bot.services.backup_worker import BACKUP_FILENAME_PREFIX, BackupWorker
|
||||
from bot.services.settings_override_service import refresh_overrides_from_db
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self):
|
||||
self.send_document = AsyncMock()
|
||||
self.send_message = AsyncMock()
|
||||
|
||||
|
||||
class _FakePgDumpBackupWorker(BackupWorker):
|
||||
def _run_pg_dump(self, dump_path: Path) -> None:
|
||||
dump_path.write_bytes(b"fake custom pg dump")
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeSessionFactory:
|
||||
def __call__(self):
|
||||
return _FakeSession()
|
||||
|
||||
|
||||
def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
|
||||
values = {
|
||||
"BOT_TOKEN": "token",
|
||||
"POSTGRES_USER": "app_user",
|
||||
"POSTGRES_PASSWORD": "app_password",
|
||||
"POSTGRES_DB": "shop",
|
||||
"BACKUP_DIR": str(tmp_path / "backups"),
|
||||
"BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir),
|
||||
"BACKUP_CHAT_ID": 123,
|
||||
"BACKUP_LOCAL_RETENTION": 1,
|
||||
"_env_file": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
(compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
|
||||
(compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8")
|
||||
(compose_dir / "Caddyfile").write_text("example.com\n", encoding="utf-8")
|
||||
(compose_dir / "node_modules").mkdir()
|
||||
(compose_dir / "node_modules" / "ignored.txt").write_text("ignored", encoding="utf-8")
|
||||
|
||||
settings = _settings(tmp_path, compose_dir)
|
||||
backup_dir = Path(settings.BACKUP_DIR)
|
||||
backup_dir.mkdir(parents=True)
|
||||
old_archive = backup_dir / f"{BACKUP_FILENAME_PREFIX}old.zip"
|
||||
old_archive.write_text("old", encoding="utf-8")
|
||||
os.utime(old_archive, (1, 1))
|
||||
|
||||
bot = _FakeBot()
|
||||
worker = _FakePgDumpBackupWorker(settings, bot)
|
||||
|
||||
result = asyncio.run(worker.create_and_send_backup())
|
||||
|
||||
assert result.archive_path.is_file()
|
||||
assert result.db_dump_included is True
|
||||
assert result.compose_files_count == 3
|
||||
assert not old_archive.exists()
|
||||
bot.send_document.assert_awaited_once()
|
||||
send_kwargs = bot.send_document.await_args.kwargs
|
||||
assert send_kwargs["chat_id"] == 123
|
||||
assert "Database dump: yes" in send_kwargs["caption"]
|
||||
|
||||
with zipfile.ZipFile(result.archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
||||
|
||||
assert "database/shop.dump" in names
|
||||
assert "compose/docker-compose.yml" in names
|
||||
assert "compose/.env" in names
|
||||
assert "compose/Caddyfile" in names
|
||||
assert all("node_modules" not in name for name in names)
|
||||
assert manifest["postgres"]["database"] == "shop"
|
||||
assert manifest["compose"]["files_count"] == 3
|
||||
|
||||
|
||||
def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
settings = _settings(
|
||||
tmp_path,
|
||||
compose_dir,
|
||||
BACKUP_CHAT_ID="",
|
||||
BACKUP_THREAD_ID="",
|
||||
LOG_CHAT_ID=-100123,
|
||||
LOG_THREAD_ID=77,
|
||||
BACKUP_POSTGRES_DUMP_ENABLED=False,
|
||||
BACKUP_COMPOSE_ENABLED=False,
|
||||
)
|
||||
bot = _FakeBot()
|
||||
worker = _FakePgDumpBackupWorker(settings, bot)
|
||||
|
||||
result = asyncio.run(worker.create_and_send_backup())
|
||||
|
||||
assert result.db_dump_included is False
|
||||
bot.send_document.assert_awaited_once()
|
||||
send_kwargs = bot.send_document.await_args.kwargs
|
||||
assert send_kwargs["chat_id"] == -100123
|
||||
assert send_kwargs["message_thread_id"] == 77
|
||||
|
||||
|
||||
def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):
|
||||
missing_compose_dir = tmp_path / "missing-compose"
|
||||
settings = _settings(
|
||||
tmp_path,
|
||||
missing_compose_dir,
|
||||
BACKUP_POSTGRES_DUMP_ENABLED=True,
|
||||
BACKUP_COMPOSE_ENABLED=True,
|
||||
)
|
||||
bot = _FakeBot()
|
||||
worker = _FakePgDumpBackupWorker(settings, bot)
|
||||
|
||||
result = asyncio.run(worker.create_and_send_backup())
|
||||
|
||||
assert result.archive_path.is_file()
|
||||
assert result.db_dump_included is True
|
||||
assert result.compose_files_count == 0
|
||||
assert any("Compose source directory is unavailable" in item for item in result.warnings)
|
||||
with zipfile.ZipFile(result.archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "database/shop.dump" in names
|
||||
|
||||
|
||||
def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch):
|
||||
from bot.services import settings_override_service
|
||||
|
||||
settings = SimpleNamespace(BACKUP_ENABLED=True)
|
||||
monkeypatch.setattr(
|
||||
settings_override_service.app_settings_dal,
|
||||
"get_all_overrides",
|
||||
AsyncMock(return_value={}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings_override_service,
|
||||
"Settings",
|
||||
lambda: SimpleNamespace(BACKUP_ENABLED=False),
|
||||
)
|
||||
|
||||
applied = asyncio.run(
|
||||
refresh_overrides_from_db(
|
||||
settings,
|
||||
_FakeSessionFactory(),
|
||||
keys={"BACKUP_ENABLED"},
|
||||
)
|
||||
)
|
||||
|
||||
assert applied == 0
|
||||
assert settings.BACKUP_ENABLED is False
|
||||
@@ -15,8 +15,7 @@ def test_backend_startup_does_not_run_panel_sync_inline():
|
||||
forbidden_imports = [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ImportFrom)
|
||||
and node.module == "bot.handlers.admin.sync_admin"
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "bot.handlers.admin.sync_admin"
|
||||
]
|
||||
forbidden_calls = [
|
||||
node
|
||||
@@ -30,6 +29,22 @@ def test_backend_startup_does_not_run_panel_sync_inline():
|
||||
assert forbidden_calls == []
|
||||
|
||||
|
||||
def test_worker_starts_backup_task_without_enabled_guard():
|
||||
source = Path("backend/main_worker.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
guarded_backup_tasks = [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.If)
|
||||
and isinstance(node.test, ast.Attribute)
|
||||
and node.test.attr == "BACKUP_ENABLED"
|
||||
]
|
||||
|
||||
assert "BackupWorker" in source
|
||||
assert guarded_backup_tasks == []
|
||||
|
||||
|
||||
def test_telegram_startup_network_error_retries_until_success_without_traceback(caplog):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -190,6 +190,28 @@ class SettingsTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
|
||||
|
||||
def test_backup_defaults_are_safe_and_blank_targets_use_log_fallback(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
BACKUP_CHAT_ID="",
|
||||
BACKUP_THREAD_ID="",
|
||||
)
|
||||
|
||||
self.assertFalse(settings.BACKUP_ENABLED)
|
||||
self.assertEqual(settings.BACKUP_INTERVAL_SECONDS, 3600)
|
||||
self.assertEqual(settings.BACKUP_DIR, "data/backups")
|
||||
self.assertEqual(settings.BACKUP_LOCAL_RETENTION, 100)
|
||||
self.assertIsNone(settings.BACKUP_CHAT_ID)
|
||||
self.assertIsNone(settings.BACKUP_THREAD_ID)
|
||||
self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source")
|
||||
self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR)
|
||||
self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore")
|
||||
self.assertTrue(settings.BACKUP_ARCHIVE_SIGNATURE_REQUIRED)
|
||||
self.assertIsNone(settings.BACKUP_ARCHIVE_SIGNATURE_SECRET)
|
||||
|
||||
def test_subscription_purchase_description_is_localized_and_toggleable(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
|
||||
@@ -186,6 +186,9 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
||||
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
|
||||
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route",
|
||||
("GET", "/api/admin/backups"): "admin_backups_list_route",
|
||||
("POST", "/api/admin/backups/upload"): "admin_backups_upload_route",
|
||||
("POST", "/api/admin/backups/restore"): "admin_backups_restore_route",
|
||||
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
|
||||
}
|
||||
|
||||
@@ -219,6 +222,15 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(match_info.handler.__name__, "index_route")
|
||||
|
||||
def test_admin_backups_page_route_is_registered(self):
|
||||
app = web.Application()
|
||||
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||
|
||||
request = make_mocked_request("GET", "/admin/backups", app=app)
|
||||
match_info = asyncio.run(app.router.resolve(request))
|
||||
|
||||
self.assertEqual(match_info.handler.__name__, "index_route")
|
||||
|
||||
def test_webapp_favicon_asset_route_is_registered(self):
|
||||
app = web.Application()
|
||||
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||
|
||||
Reference in New Issue
Block a user