chore: tune backup function and related docs
This commit is contained in:
@@ -70,16 +70,3 @@ FRONTEND_PORT=8082
|
||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||
# Keep loopback for local proxy; add your proxy network if needed.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# Periodic backups from the worker container. Disabled by default.
|
||||
# The archive contains a pg_dump custom-format dump and a snapshot of the
|
||||
# compose directory mounted read-only into /app/compose-source.
|
||||
BACKUP_ENABLED=False
|
||||
BACKUP_CHAT_ID=
|
||||
BACKUP_INTERVAL_SECONDS=3600
|
||||
BACKUP_LOCAL_RETENTION=100
|
||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
||||
BACKUP_COMPOSE_ENABLED=True
|
||||
COMPOSE_BACKUP_SOURCE=.
|
||||
COMPOSE_RESTORE_MODE=rw
|
||||
|
||||
@@ -1,53 +1,15 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
BACKUP_APP_ID = "remnawave-minishop"
|
||||
BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-"
|
||||
BACKUP_FORMAT_VERSION = 1
|
||||
BACKUP_MANIFEST_NAME = "manifest.json"
|
||||
|
||||
|
||||
def backup_signature_secret(settings: Settings) -> str:
|
||||
configured = str(getattr(settings, "BACKUP_ARCHIVE_SIGNATURE_SECRET", "") or "").strip()
|
||||
return configured or settings.BOT_TOKEN
|
||||
|
||||
|
||||
def canonical_manifest_payload(manifest: dict[str, Any]) -> bytes:
|
||||
payload = json.loads(json.dumps(manifest, ensure_ascii=False))
|
||||
archive = payload.get("archive")
|
||||
if isinstance(archive, dict):
|
||||
archive.pop("signature", None)
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sign_manifest(manifest: dict[str, Any], settings: Settings) -> str:
|
||||
return hmac.new(
|
||||
backup_signature_secret(settings).encode("utf-8"),
|
||||
canonical_manifest_payload(manifest),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def verify_manifest_signature(manifest: dict[str, Any], settings: Settings) -> bool:
|
||||
archive = manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
|
||||
signature = str(archive.get("signature") or "")
|
||||
if not signature:
|
||||
return False
|
||||
expected = sign_manifest(manifest, settings)
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
@@ -79,14 +41,12 @@ def attach_archive_integrity(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
file_records: list[dict[str, Any]],
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
manifest["app"] = BACKUP_APP_ID
|
||||
manifest["format_version"] = BACKUP_FORMAT_VERSION
|
||||
manifest["archive"] = {
|
||||
"files": file_records,
|
||||
}
|
||||
manifest["archive"]["signature"] = sign_manifest(manifest, settings)
|
||||
|
||||
|
||||
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
|
||||
|
||||
@@ -21,7 +21,6 @@ from bot.services.backup_archive import (
|
||||
BACKUP_MANIFEST_NAME,
|
||||
attach_archive_integrity,
|
||||
build_file_records,
|
||||
verify_manifest_signature,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
@@ -153,16 +152,6 @@ class BackupRestoreService:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
signature_valid = self._archive_signature_valid(manifest)
|
||||
signature_required = getattr(
|
||||
self.settings,
|
||||
"BACKUP_ARCHIVE_SIGNATURE_REQUIRED",
|
||||
True,
|
||||
)
|
||||
if signature_required and not signature_valid:
|
||||
raise BackupArchiveError("Archive manifest signature is not valid")
|
||||
if not signature_valid:
|
||||
warnings.append("manifest signature is not valid")
|
||||
has_database = self._find_database_dump_member(archive) is not None
|
||||
compose_members = self._compose_file_members(archive)
|
||||
|
||||
@@ -401,7 +390,6 @@ class BackupRestoreService:
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
settings=self.settings,
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
@@ -551,18 +539,12 @@ class BackupRestoreService:
|
||||
raise BackupArchiveError("Archive manifest format is not supported")
|
||||
return manifest
|
||||
|
||||
def _archive_signature_valid(self, manifest: dict[str, Any]) -> bool:
|
||||
return verify_manifest_signature(manifest, self.settings)
|
||||
|
||||
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
if getattr(self.settings, "BACKUP_ARCHIVE_SIGNATURE_REQUIRED", True):
|
||||
if not self._archive_signature_valid(manifest):
|
||||
raise BackupArchiveError("Archive manifest signature is not valid")
|
||||
self._validate_archive_integrity(archive, manifest)
|
||||
|
||||
def _validate_archive_integrity(
|
||||
|
||||
@@ -179,7 +179,6 @@ class BackupWorker:
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
settings=self.settings,
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
|
||||
|
||||
@@ -135,8 +135,6 @@ class Settings(BaseSettings):
|
||||
BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||
BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore")
|
||||
BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED: bool = Field(default=True)
|
||||
BACKUP_ARCHIVE_SIGNATURE_SECRET: Optional[str] = Field(default=None)
|
||||
BACKUP_COMPOSE_ENABLED: bool = Field(default=True)
|
||||
BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source")
|
||||
BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None)
|
||||
@@ -1005,7 +1003,6 @@ class Settings(BaseSettings):
|
||||
"SMTP_FROM_EMAIL",
|
||||
"SMTP_FROM_NAME",
|
||||
"SMTP_FALLBACK_PORTS",
|
||||
"BACKUP_ARCHIVE_SIGNATURE_SECRET",
|
||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||
"BACKUP_COMPOSE_RESTORE_DIR",
|
||||
mode="before",
|
||||
|
||||
@@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
# Periodic backups from the worker container. Disabled by default.
|
||||
BACKUP_ENABLED=False
|
||||
BACKUP_CHAT_ID=
|
||||
BACKUP_INTERVAL_SECONDS=3600
|
||||
BACKUP_LOCAL_RETENTION=100
|
||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
||||
BACKUP_COMPOSE_ENABLED=True
|
||||
COMPOSE_BACKUP_SOURCE=.
|
||||
COMPOSE_RESTORE_MODE=rw
|
||||
|
||||
@@ -39,14 +39,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
# Periodic backups from the worker container. Disabled by default.
|
||||
BACKUP_ENABLED=False
|
||||
BACKUP_CHAT_ID=
|
||||
BACKUP_INTERVAL_SECONDS=3600
|
||||
BACKUP_LOCAL_RETENTION=100
|
||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
||||
BACKUP_COMPOSE_ENABLED=True
|
||||
COMPOSE_BACKUP_SOURCE=.
|
||||
COMPOSE_RESTORE_MODE=rw
|
||||
|
||||
@@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
# Periodic backups from the worker container. Disabled by default.
|
||||
BACKUP_ENABLED=False
|
||||
BACKUP_CHAT_ID=
|
||||
BACKUP_INTERVAL_SECONDS=3600
|
||||
BACKUP_LOCAL_RETENTION=100
|
||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
||||
BACKUP_COMPOSE_ENABLED=True
|
||||
COMPOSE_BACKUP_SOURCE=.
|
||||
COMPOSE_RESTORE_MODE=rw
|
||||
|
||||
@@ -39,14 +39,3 @@ PANEL_API_KEY=change_me
|
||||
PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# Periodic backups from the worker container. Disabled by default.
|
||||
BACKUP_ENABLED=False
|
||||
BACKUP_CHAT_ID=
|
||||
BACKUP_INTERVAL_SECONDS=3600
|
||||
BACKUP_LOCAL_RETENTION=100
|
||||
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
|
||||
BACKUP_ARCHIVE_SIGNATURE_SECRET=
|
||||
BACKUP_COMPOSE_ENABLED=True
|
||||
COMPOSE_BACKUP_SOURCE=.
|
||||
COMPOSE_RESTORE_MODE=rw
|
||||
|
||||
@@ -84,8 +84,6 @@
|
||||
| `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. |
|
||||
| `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. |
|
||||
| `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. |
|
||||
| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись `manifest.json` при upload/restore. По умолчанию `True`. |
|
||||
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи backup-архивов. Если пусто, используется `BOT_TOKEN`. |
|
||||
| `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. |
|
||||
| `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. |
|
||||
| `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. |
|
||||
|
||||
+24
-13
@@ -24,23 +24,26 @@ remnawave-minishop-backup-20260527-120000+0300.zip
|
||||
|
||||
Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**.
|
||||
|
||||
Минимальный `.env`:
|
||||
Минимальный `.env`, если `LOG_CHAT_ID` уже задан и подходит для бэкапов:
|
||||
|
||||
```env
|
||||
BACKUP_ENABLED=True
|
||||
BACKUP_CHAT_ID=-1001234567890
|
||||
BACKUP_INTERVAL_SECONDS=3600
|
||||
BACKUP_LOCAL_RETENTION=100
|
||||
BACKUP_COMPOSE_ENABLED=True
|
||||
COMPOSE_BACKUP_SOURCE=.
|
||||
COMPOSE_RESTORE_MODE=rw
|
||||
```
|
||||
|
||||
`BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа: 12:00, 13:00 и т.д. Значение по умолчанию для локального хранения - 100 последних ZIP-архивов.
|
||||
Если бэкапы нужно отправлять в отдельный чат или topic/thread, добавьте только нужные переменные:
|
||||
|
||||
```env
|
||||
BACKUP_CHAT_ID=-1001234567890
|
||||
BACKUP_THREAD_ID=123
|
||||
```
|
||||
|
||||
Остальные backup-переменные обычно не нужны в `.env`: `BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа 12:00, 13:00 и т.д.; `BACKUP_LOCAL_RETENTION=100` хранит 100 последних ZIP-архивов; `BACKUP_COMPOSE_ENABLED=True`, `COMPOSE_BACKUP_SOURCE=.` и `COMPOSE_RESTORE_MODE=rw` уже совпадают со стандартным compose-сценарием.
|
||||
|
||||
`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`.
|
||||
|
||||
Каждый архив подписывается HMAC-подписью в `manifest.json` и содержит SHA-256 каждого файла. По умолчанию restore принимает только архивы с валидной подписью этого инстанса. Если нужен отдельный стабильный ключ подписи, задайте `BACKUP_ARCHIVE_SIGNATURE_SECRET`; если ключ пустой, используется `BOT_TOKEN`.
|
||||
Каждый архив содержит `manifest.json` с SHA-256 и размером каждого файла. Это позволяет проверить, что архив не поврежден и его содержимое не отличается от manifest.
|
||||
|
||||
Архив не привязан к текущему инстансу, `BOT_TOKEN` или серверу. Его можно загрузить и восстановить на другом сервере, если формат архива поддерживается и проверки целостности проходят.
|
||||
|
||||
## Mount compose-папки
|
||||
|
||||
@@ -91,7 +94,6 @@ Backend валидирует архив до восстановления:
|
||||
|
||||
- файл должен быть валидным ZIP;
|
||||
- `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата;
|
||||
- HMAC-подпись manifest должна быть валидной, если `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True`;
|
||||
- SHA-256 и размер каждого файла должны совпадать с manifest;
|
||||
- выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется;
|
||||
- пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли;
|
||||
@@ -101,7 +103,18 @@ Backend валидирует архив до восстановления:
|
||||
- compose restore стартует только если целевая папка существует и доступна на запись;
|
||||
- backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно.
|
||||
|
||||
Это защищает от случайной загрузки мусорного файла, zip-slip-архивов, поврежденных ZIP и структурно похожих архивов, которые не были созданы этим инстансом. Если вы сознательно восстанавливаете старый неподписанный архив, временно выставьте `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=False`, восстановите архив и верните проверку обратно.
|
||||
Это защищает от случайной загрузки мусорного файла, zip-slip-архивов и поврежденных ZIP. Проверка специально не привязана к секретам инстанса, чтобы архивы можно было использовать для переноса между серверами. Это не проверка доверенного источника: не восстанавливайте архивы, происхождение которых вы не контролируете.
|
||||
|
||||
## Перенос на другой сервер
|
||||
|
||||
Для переноса БД между инстансами:
|
||||
|
||||
1. Создайте backup на старом сервере или возьмите ZIP из Telegram.
|
||||
2. На новом сервере загрузите архив в **Система -> Бэкапы**.
|
||||
3. Выберите `БД`; `compose-папку` включайте только если хотите перенести `.env`, `docker-compose.yml` и proxy-конфиги.
|
||||
4. Запустите restore и после восстановления выполните миграции/healthcheck.
|
||||
|
||||
Если переносите compose-папку, проверьте домены, токены, `WEBHOOK_BASE_URL`, `SUBSCRIPTION_MINI_APP_URL`, bind-порты и volume/mount пути: на новом сервере они могут отличаться.
|
||||
|
||||
## Ручное восстановление БД
|
||||
|
||||
@@ -134,8 +147,6 @@ docker compose logs -f backend worker
|
||||
| `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. |
|
||||
| `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. |
|
||||
| `BACKUP_DIR` | Каталог ZIP-архивов. |
|
||||
| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись manifest при upload/restore. |
|
||||
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи архивов; если пустой, используется `BOT_TOKEN`. |
|
||||
| `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. |
|
||||
| `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. |
|
||||
| `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. |
|
||||
|
||||
@@ -34,7 +34,6 @@ def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
|
||||
|
||||
|
||||
def _write_backup_archive(
|
||||
settings: Settings,
|
||||
path: Path,
|
||||
*,
|
||||
include_db=True,
|
||||
@@ -65,7 +64,6 @@ def _write_backup_archive(
|
||||
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)
|
||||
@@ -80,7 +78,7 @@ def test_backup_restore_service_lists_archives_with_contents(tmp_path):
|
||||
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)
|
||||
_write_backup_archive(archive_path)
|
||||
|
||||
archives = BackupRestoreService(settings).list_archives()
|
||||
|
||||
@@ -106,7 +104,7 @@ def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path)
|
||||
|
||||
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)
|
||||
_write_backup_archive(archive_path, include_db=False)
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
result = service.restore_archive_sync(
|
||||
@@ -131,7 +129,7 @@ def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path):
|
||||
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)
|
||||
_write_backup_archive(archive_path, include_db=False, unsafe=True)
|
||||
|
||||
with pytest.raises(BackupArchiveError):
|
||||
BackupRestoreService(settings).restore_archive_sync(
|
||||
@@ -148,7 +146,7 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
|
||||
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)
|
||||
_write_backup_archive(archive_path, include_compose=False)
|
||||
service = BackupRestoreService(settings)
|
||||
restored_payloads = []
|
||||
|
||||
@@ -169,6 +167,33 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
|
||||
assert restored_payloads == [b"fake dump"]
|
||||
|
||||
|
||||
def test_backup_restore_service_accepts_archive_from_another_instance(tmp_path):
|
||||
compose_dir = tmp_path / "compose"
|
||||
compose_dir.mkdir()
|
||||
target_settings = _settings(tmp_path, compose_dir, BOT_TOKEN="target-token")
|
||||
archive_path = Path(target_settings.BACKUP_DIR) / (
|
||||
f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
|
||||
)
|
||||
_write_backup_archive(archive_path, include_compose=False)
|
||||
|
||||
service = BackupRestoreService(target_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 = service.restore_archive_sync(
|
||||
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()
|
||||
@@ -185,7 +210,7 @@ def test_backup_restore_service_rejects_tampered_archive(tmp_path):
|
||||
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)
|
||||
_write_backup_archive(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:
|
||||
|
||||
@@ -209,8 +209,6 @@ class SettingsTests(unittest.TestCase):
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user