feat: add backup recovery, setup wizard and user-view guards
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s

This commit is contained in:
2026-09-18 17:23:03 +12:00
parent a6a4a9aa24
commit fd6671cf7e
44 changed files with 4650 additions and 114 deletions
+1
View File
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
+4
View File
@@ -929,6 +929,10 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str
def ensure_admin_user() -> None:
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
return
# Environment credentials bootstrap only the first administrator. In
# particular, do not inject a destination host's account into a restored DB.
if has_admin_user():
return
existing = get_user_by_username(settings.admin_username)
if existing:
return
+62 -6
View File
@@ -6,6 +6,8 @@ import uuid
from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
@@ -33,6 +35,10 @@ from .routers.insights import router as insights_router
from .routers.identities import router as identities_router
from .routers.recaps import router as recaps_router
from .routers.newsletters import router as newsletters_router
from .routers.backups import router as backups_router
from .routers.setup import router as setup_router
from .services.backups import apply_pending_restore
from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.email_recaps import run_email_recap_loop
@@ -52,10 +58,12 @@ from .logging_config import (
)
from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
from .request_limits import InstallationBodyLimitMiddleware
from .secret_storage import validate_secret_storage_configuration
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
_background_started = False
app = FastAPI(
title=settings.app_name,
@@ -71,6 +79,23 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(InstallationBodyLimitMiddleware)
@app.exception_handler(RequestValidationError)
async def installation_validation_error(request: Request, exc: RequestValidationError):
if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
# Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
# includes rejected raw input. Never echo tokens/passwords/passphrases.
return JSONResponse(
status_code=422,
content={"detail": [
{key: error[key] for key in ("type", "loc", "msg") if key in error}
for error in exc.errors()
]},
headers={"Cache-Control": "no-store"},
)
return await request_validation_exception_handler(request, exc)
@app.middleware("http")
@@ -221,9 +246,9 @@ def _log_security_configuration_warnings() -> None:
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
if admin_password == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
@@ -244,8 +269,11 @@ def _enforce_secure_startup_configuration() -> None:
_enforce_secret_configuration()
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
if is_setup_required() and setup_token_configured():
return
raise RuntimeError(
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
"First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
"or a secure ADMIN_PASSWORD, until an admin account exists."
)
@@ -264,6 +292,9 @@ async def startup() -> None:
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
_enforce_secret_configuration()
# Restore offline, before any schema migration, database reader or worker.
apply_pending_restore()
initialize_setup_state()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
@@ -286,9 +317,22 @@ async def startup() -> None:
runtime.log_background_sync_level,
runtime.requests_data_source,
)
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
logger.info("Background imports and automation paused for initial setup")
app.state.on_setup_complete = _start_background_tasks
await _start_background_tasks()
logger.info("startup complete")
async def _start_background_tasks() -> None:
global _background_started
if _background_started:
return
if is_setup_required():
logger.info("Background imports and automation paused until setup is complete")
return
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
logger.info("Background imports and automation disabled by configuration")
return
_background_started = True
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("request-local-stages", run_local_request_stage_loop)
@@ -298,7 +342,17 @@ async def startup() -> None:
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
_launch_background_task("email-recaps", run_email_recap_loop)
_launch_background_task("newsletters", run_newsletter_loop)
logger.info("startup complete")
@app.on_event("shutdown")
async def shutdown() -> None:
global _background_started
for task in _background_tasks:
task.cancel()
if _background_tasks:
await asyncio.gather(*_background_tasks, return_exceptions=True)
_background_tasks.clear()
_background_started = False
app.include_router(requests_router)
@@ -317,3 +371,5 @@ app.include_router(insights_router)
app.include_router(identities_router)
app.include_router(recaps_router)
app.include_router(newsletters_router)
app.include_router(backups_router)
app.include_router(setup_router)
+50
View File
@@ -0,0 +1,50 @@
"""Bound security-sensitive request bodies before JSON/multipart parsing."""
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart
# envelope; count streamed chunks as well as checking the untrusted header.
RESTORE_BODY_LIMIT = 34 * 1024 * 1024
BOOTSTRAP_BODY_LIMIT = 16 * 1024
class InstallationBodyLimitMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope.get("method") != "POST":
await self.app(scope, receive, send)
return
path = scope.get("path", "").rstrip("/")
limit = {
"/admin/backups/restore": RESTORE_BODY_LIMIT,
"/admin/backups/export": BOOTSTRAP_BODY_LIMIT,
"/setup/bootstrap": BOOTSTRAP_BODY_LIMIT,
}.get(path)
if limit is None:
await self.app(scope, receive, send)
return
headers = dict(scope.get("headers", []))
try:
length = int(headers.get(b"content-length", b"0"))
except ValueError:
length = -1
if length < 0 or length > limit:
await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send)
return
received = 0
async def bounded_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > limit:
raise HTTPException(status_code=413, detail="Request body is too large.")
return message
await self.app(scope, bounded_receive, send)
+85
View File
@@ -0,0 +1,85 @@
"""Administrator-only encrypted backup downloads and staged restores."""
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from starlette.concurrency import run_in_threadpool
from ..auth import require_admin
from ..db import get_rate_limit_status, record_rate_limit_event
from ..services import backups
def _no_store(response: Response) -> None:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
router = APIRouter(
prefix="/admin/backups", tags=["backups"],
dependencies=[Depends(require_admin), Depends(_no_store)],
)
class ExportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
passphrase: SecretStr = Field(min_length=12, max_length=1024)
include_cache: bool = False
def _rate_limit(user: dict) -> None:
key = str(user["username"])
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
if exceeded:
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
record_rate_limit_event("backups", key)
@router.get("")
def status() -> dict:
return backups.backup_status()
@router.post("/export")
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
_rate_limit(user)
try:
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
except backups.BackupError as exc:
raise HTTPException(400, str(exc)) from exc
return Response(content, media_type="application/octet-stream", headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store", "Pragma": "no-cache",
})
@router.post("/restore", status_code=202)
async def restore(
file: UploadFile = File(...),
passphrase: str = Form(..., min_length=12, max_length=1024),
confirmation: Literal["RESTORE"] = Form(...),
user: dict = Depends(require_admin),
) -> dict:
_rate_limit(user)
try:
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
except backups.BackupError as exc:
raise HTTPException(400, str(exc)) from exc
finally:
await file.close()
return {
"status": "staged", "restart_required": True, "backup": metadata,
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
}
@router.delete("/restore")
def cancel() -> dict:
try:
backups.cancel_restore()
except backups.BackupError as exc:
raise HTTPException(409, str(exc)) from exc
return {"status": "cancelled"}
+80
View File
@@ -0,0 +1,80 @@
"""Initial install bootstrap and authenticated setup wizard endpoints."""
from inspect import isawaitable
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import Field, SecretStr
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
from ..auth import _extract_client_ip, require_admin
from ..services import setup as setup_service
router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
class BootstrapRequest(StrictRequest):
setup_token: SecretStr = Field(min_length=1, max_length=1024)
username: str = Field(min_length=1, max_length=100)
password: SecretStr = Field(min_length=1, max_length=1024)
class SetupProgress(StrictRequest):
step: setup_service.SetupStep
@router.get("/status")
def public_status(response: Response) -> dict:
response.headers["Cache-Control"] = "no-store"
return setup_service.get_public_setup_status()
@router.post("/bootstrap", status_code=201)
def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
status = setup_service.get_public_setup_status()
if not status["needs_admin"]:
raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Too many setup attempts. Try again later.",
headers={"Retry-After": str(retry_after)},
)
try:
setup_service.bootstrap_administrator(
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value()
)
except setup_service.InvalidSetupTokenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except setup_service.SetupUnavailableError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"status": "created", "username": payload.username.strip()}
@router.get("/state", dependencies=[Depends(require_admin)])
def get_state() -> dict:
return setup_service.get_setup_state()
@router.put("/state", dependencies=[Depends(require_admin)])
def update_state(payload: SetupProgress) -> dict:
return setup_service.update_setup_step(payload.step)
@router.post("/complete", dependencies=[Depends(require_admin)])
async def finish_setup(request: Request) -> dict:
try:
state = setup_service.complete_setup()
except setup_service.SetupUnavailableError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Startup owns worker lifecycle. Its callback must be idempotent so retries
# after a network interruption cannot start duplicate import/automation jobs.
callback = getattr(request.app.state, "on_setup_complete", None)
if callback is not None:
result = callback()
if isawaitable(result):
await result
return state
+1 -1
View File
@@ -15,7 +15,7 @@ SENSITIVE_SETTING_KEYS = frozenset(
"magent_notify_telegram_bot_token", "magent_notify_push_token",
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key",
"prowlarr_api_key", "qbittorrent_password",
"prowlarr_api_key", "qbittorrent_password", "discord_webhook_url",
}
)
+631
View File
@@ -0,0 +1,631 @@
"""Encrypted, portable backups and restart-only SQLite restores.
Restore is deliberately a two-step operation: the authenticated request validates
and stages it, then a single backend process applies it before opening the DB.
A durable journal and a private rollback copy protect interrupted installations.
"""
from __future__ import annotations
from contextlib import closing, contextmanager
from datetime import datetime, timezone
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import shutil
import sqlite3
import stat
import tempfile
import threading
import time
from typing import Any, BinaryIO, Iterator
import uuid
import zipfile
import zlib
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from pydantic import TypeAdapter
from ..config import Settings, settings
from ..db import _db_path
from ..schema_migrations import MIGRATIONS
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
FORMAT_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
MAX_ENTRIES = 20_000
MAGIC = b"MAGENT-BACKUP\x00\x01"
_LOCK = threading.Lock()
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
# Host identity, process controls and local file locations belong to the target.
_LOCAL_FIELDS = {
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
}
class BackupError(ValueError):
"""A safe-to-display backup validation or state error."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _assets_root() -> Path:
# Matches the image and branding routers, independently of SQLITE_PATH.
return Path.cwd() / "data"
def _control_root() -> Path:
return Path(_db_path()).absolute().parent / "backups"
def _private_dir(path: Path) -> None:
if path.is_symlink():
raise BackupError("Backup directories must not be symbolic links")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
path.chmod(0o700)
def _write_private(path: Path, content: bytes) -> None:
with path.open("xb") as handle:
path.chmod(0o600)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
def _write_json(path: Path, data: dict) -> None:
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
try:
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
os.replace(temporary, path)
_sync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def _sync_directory(path: Path) -> None:
if os.name != "nt":
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sync_tree(path: Path) -> None:
for parent, _directories, files in os.walk(path, topdown=False):
for filename in files:
with (Path(parent) / filename).open("r+b") as handle:
os.fsync(handle.fileno())
_sync_directory(Path(parent))
@contextmanager
def _exclusive_operation() -> Iterator[None]:
if not _LOCK.acquire(blocking=False):
raise BackupError("Another backup or restore operation is in progress")
handle = None
locked = False
try:
root = _control_root()
_private_dir(root)
handle = (root / "operation.lock").open("a+b")
os.chmod(handle.name, 0o600)
# OS locks are released even if a process crashes; support the dev host too.
if os.name == "nt":
import msvcrt
handle.seek(0)
if not handle.read(1):
handle.write(b"0")
handle.flush()
handle.seek(0)
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
else:
import fcntl
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
locked = True
yield
finally:
if handle is not None:
if locked:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
_LOCK.release()
def validate_passphrase(passphrase: str) -> None:
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
def _key(passphrase: str, salt: bytes) -> bytes:
validate_passphrase(passphrase)
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
def _encrypt(content: bytes, passphrase: str) -> bytes:
salt, nonce = os.urandom(16), os.urandom(12)
header = MAGIC + salt + nonce
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
def _decrypt(content: bytes, passphrase: str) -> bytes:
header_size = len(MAGIC) + 28
if len(content) > MAX_UPLOAD_BYTES:
raise BackupError("Backup exceeds the 32 MiB upload limit")
if len(content) < header_size + 16 or not content.startswith(MAGIC):
raise BackupError("This is not a supported encrypted Magent backup")
salt = content[len(MAGIC):len(MAGIC) + 16]
nonce = content[len(MAGIC) + 16:header_size]
try:
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
except InvalidTag as exc:
raise BackupError("Incorrect passphrase or damaged backup") from exc
def _database_copy(source: Path, destination: Path) -> None:
if not source.is_file() or source.is_symlink():
raise BackupError("The configured database is unavailable or is a symbolic link")
deadline = time.monotonic() + 60
def progress(_status: int, _remaining: int, _total: int) -> None:
if time.monotonic() > deadline:
raise BackupError("Database is too busy to back up; try again shortly")
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
with closing(sqlite3.connect(destination)) as dst:
destination.chmod(0o600)
src.backup(dst, pages=256, progress=progress, sleep=0.05)
dst.execute("PRAGMA journal_mode=DELETE")
def _portable_database(path: Path) -> None:
"""Materialize env-backed settings and remove source-specific encryption."""
with closing(sqlite3.connect(path)) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
# init_db recreates application-owned triggers after restoration; never
# distribute executable schema objects in a data backup.
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
quoted = str(trigger).replace('"', '""')
conn.execute(f'DROP TRIGGER "{quoted}"')
overrides = dict(conn.execute("SELECT key, value FROM settings"))
for key, default in settings.model_dump().items():
if key in _LOCAL_FIELDS:
continue
value = overrides.get(key)
value = default if value is None else decrypt_setting_value(key, value)
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(key, "" if value is None else str(value), _now()),
)
for key in _LOCAL_FIELDS:
conn.execute("DELETE FROM settings WHERE key=?", (key,))
# Future secret keys may not yet be exposed through Settings.
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
conn.commit()
conn.execute("VACUUM")
def _asset_allowed(name: str, include_cache: bool) -> bool:
parts = PurePosixPath(name).parts
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
return True
return bool(
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
and parts[4] not in {".", ".."}
)
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
root = _assets_root()
for directory in ("branding", "artwork") if include_cache else ("branding",):
base = root / directory
if not base.exists():
continue
if base.is_symlink() or root.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
for parent, directories, files in os.walk(base, followlinks=False):
if any((Path(parent) / name).is_symlink() for name in directories + files):
raise BackupError("Symbolic links are not supported in backup assets")
for filename in files:
path = Path(parent) / filename
archive_name = "files/" + path.relative_to(root).as_posix()
if _asset_allowed(archive_name, include_cache):
yield path, archive_name
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
validate_passphrase(passphrase)
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
directory = Path(temporary)
directory.chmod(0o700)
database = directory / "database.sqlite3"
_database_copy(Path(_db_path()).absolute(), database)
_portable_database(database)
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
archive_path = directory / "payload.zip"
manifest = {
"format_version": FORMAT_VERSION, "created_at": _now(),
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
"files": {},
}
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive_path.chmod(0o600)
total = 0
for path, name in files:
digest = hashlib.sha256()
size = 0
with path.open("rb") as source, archive.open(name, "w") as destination:
while chunk := source.read(1024 * 1024):
total += len(chunk)
size += len(chunk)
if total > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
digest.update(chunk)
destination.write(chunk)
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
archive.writestr("manifest.json", json.dumps(manifest))
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return encrypted, f"magent-backup-{stamp}.magent-backup"
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
try:
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
conn.execute("PRAGMA trusted_schema=OFF")
deadline = time.monotonic() + 30
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
raise BackupError("Backup database failed its integrity check")
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
if len(schema) > 500 or any(
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
for kind, _name, sql in schema
):
raise BackupError("Backup contains an unsupported database schema")
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise BackupError("Backup database contains broken references")
required = {
"settings": {"key", "value", "updated_at"},
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
"signup_invites": {"id", "code", "enabled"},
"requests_cache": {"request_id", "payload_json"},
"schema_migrations": {"version", "name", "applied_at"},
"password_reset_tokens": {"id", "token_hash"},
}
for table, fields in required.items():
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup does not contain a compatible Magent database")
optional = {
"installation_setup": {"id", "completed", "step", "completed_at"},
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
}
table_names = {name for kind, name, _sql in schema if kind == "table"}
for table, fields in optional.items():
if table in table_names:
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup setup state has an incompatible schema")
# An admin can stage a restore only after target initialization. Its
# schema is a trusted reference for *all* runtime columns, including
# versioned migrations that init_db will not rerun on a restored DB.
target = Path(_db_path()).absolute()
if target.is_file() and target != path:
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
continue
quoted = str(table).replace('"', '""')
expected = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
}
actual = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
}
if expected != actual:
raise BackupError("Backup is missing database columns required by this installation")
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
if versions != {migration.version for migration in MIGRATIONS}:
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
if not conn.execute(
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
).fetchone():
raise BackupError("Backup must contain an active administrator account")
values = dict(conn.execute("SELECT key,value FROM settings"))
if _LOCAL_FIELDS.intersection(values):
raise BackupError("Backup contains host-specific configuration")
# Pydantic checks the types of portable settings without reading env values.
for key, value in values.items():
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
value = decrypt_setting_value(key, value)
if key in Settings.model_fields and value not in {None, ""}:
field = Settings.model_fields[key]
TypeAdapter(field.rebuild_annotation()).validate_python(value)
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup database or configuration is invalid") from exc
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ENTRIES:
raise BackupError("Backup contains too many files")
names = [entry.filename for entry in entries]
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
raise BackupError("Backup manifest is missing or contains duplicate files")
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
for entry in entries:
parts = PurePosixPath(entry.filename).parts
mode = entry.external_attr >> 16
if (
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
or str(PurePosixPath(entry.filename)) != entry.filename
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
):
raise BackupError("Backup contains an unsafe archive entry")
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
raise BackupError("Backup manifest is too large")
manifest = json.loads(archive.read("manifest.json"))
if (
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
or not isinstance(manifest.get("include_cache"), bool)
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
or not isinstance(manifest.get("files"), dict)
or set(manifest["files"]) != set(names) - {"manifest.json"}
):
raise BackupError("Backup manifest is invalid or unsupported")
extracted_bytes = 0
for entry in entries:
name = entry.filename
if name == "manifest.json":
continue
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
raise BackupError("Backup contains an unsupported file")
expected = manifest["files"][name]
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
raise BackupError("Backup file does not match its manifest")
target = directory.joinpath(*PurePosixPath(name).parts)
_private_dir(target.parent)
digest = hashlib.sha256()
with archive.open(entry) as source, target.open("xb") as destination:
target.chmod(0o600)
while chunk := source.read(1024 * 1024):
extracted_bytes += len(chunk)
if extracted_bytes > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
digest.update(chunk)
destination.write(chunk)
destination.flush()
os.fsync(destination.fileno())
if digest.hexdigest() != expected.get("sha256"):
raise BackupError("Backup file failed its checksum")
_validate_database(directory / "database.sqlite3")
return manifest
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup archive is invalid or damaged") from exc
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
validate_passphrase(passphrase)
with _exclusive_operation():
root = _control_root()
pending = root / "pending"
if pending.exists():
raise BackupError("A restore is already staged; cancel it before uploading another")
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
stage = Path(temporary)
stage.chmod(0o700)
manifest = _extract_archive(payload, stage)
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
if value and str(value).startswith("enc:v1:"):
raise BackupError("Backup settings are not portable")
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
# Do not revive reset links or existing browser sessions. Invites remain intact.
conn.execute("DELETE FROM password_reset_tokens")
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
if not manifest["include_cache"]:
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
conn.commit()
# Remove plaintext secret remnants from replaced/free SQLite pages.
conn.execute("VACUUM")
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
metadata["staged_at"] = _now()
_write_json(stage / "metadata.json", metadata)
# Stage survives reboot; it contains only secrets encrypted for this installation.
os.replace(stage, pending)
_sync_directory(root)
return metadata
def backup_status() -> dict[str, Any]:
root = _control_root()
pending_path = root / "pending" / "metadata.json"
last_path = root / "last-restore.json"
return {
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
"max_expanded_bytes": MAX_EXPANDED_BYTES,
"include_cache_default": False,
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
}
def cancel_restore() -> None:
with _exclusive_operation():
pending = _control_root() / "pending"
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
if pending.exists():
shutil.rmtree(pending)
def _replace_file(source: Path, target: Path) -> None:
_private_dir(target.parent)
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
try:
shutil.copyfile(source, temporary)
temporary.chmod(0o600)
with temporary.open("r+b") as handle:
os.fsync(handle.fileno())
os.replace(temporary, target)
_sync_directory(target.parent)
finally:
temporary.unlink(missing_ok=True)
def _replace_assets(source: Path, target: Path) -> None:
if target.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
if target.exists():
shutil.rmtree(target)
if source.exists():
shutil.copytree(source, target, copy_function=shutil.copyfile)
for parent, _directories, files in os.walk(target):
Path(parent).chmod(0o700)
for filename in files:
(Path(parent) / filename).chmod(0o600)
_sync_tree(target)
if target.parent.exists():
_sync_directory(target.parent)
def _recover(journal: dict, root: Path) -> None:
rollback_name = journal.get("rollback_directory", "")
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
raise BackupError("Restore recovery journal is invalid")
rollback = root / rollback_name
database = Path(_db_path()).absolute()
if journal["had_database"]:
_replace_file(rollback / "database.sqlite3", database)
else:
database.unlink(missing_ok=True)
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
for name in journal["asset_roots"]:
if name not in {"branding", "artwork"}:
raise BackupError("Restore recovery journal is invalid")
_replace_assets(rollback / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
"message": "An interrupted or failed restore was rolled back automatically.",
})
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
pending = root / "pending"
if pending.exists():
shutil.rmtree(pending)
(root / "restore-journal.json").unlink()
_sync_directory(root)
def apply_pending_restore() -> bool:
"""Call once before init_db, with no other backend processes using the DB."""
with _exclusive_operation():
root = _control_root()
journal_path = root / "restore-journal.json"
if journal_path.exists():
journal = json.loads(journal_path.read_text())
if journal.get("phase") in {"complete", "rolled_back"}:
if (root / "pending").exists():
shutil.rmtree(root / "pending")
journal_path.unlink()
_sync_directory(root)
return journal["phase"] == "complete"
_recover(journal, root)
return False
pending = root / "pending"
if not pending.exists():
return False
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
metadata = json.loads((pending / "metadata.json").read_text())
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
database = Path(_db_path()).absolute()
rollback = root / ("rollback-" + uuid.uuid4().hex)
_private_dir(rollback)
# Ensure all disk-space/permission failures in backup happen before replacement.
if database.exists():
_database_copy(database, rollback / "database.sqlite3")
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
# Reject links anywhere before copying or deleting the controlled asset trees.
list(_asset_files(metadata["include_cache"]))
for name in names:
source = _assets_root() / name
if source.exists():
shutil.copytree(source, rollback / "files" / name)
_sync_tree(rollback)
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
_write_json(journal_path, journal)
try:
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
_replace_file(pending / "database.sqlite3", database)
for name in names:
_replace_assets(pending / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
"backup_created_at": metadata["created_at"],
})
_write_json(journal_path, {**journal, "phase": "complete"})
except Exception:
_recover(journal, root)
raise
shutil.rmtree(pending)
journal_path.unlink()
_sync_directory(root)
return True
+197
View File
@@ -0,0 +1,197 @@
"""Persistent, operator-authorized first-install setup.
Initialize the marker before the main schema: an existing users table identifies
an upgraded installation, while a new database must finish the setup wizard.
The marker and first administrator are protected by SQLite write transactions.
"""
from datetime import datetime, timezone
import hmac
from math import ceil
from time import time
from typing import Literal
from .. import db
from ..config import settings
from ..security import hash_password, validate_password_policy
SetupStep = Literal["administrator", "apps", "preferences", "review"]
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
BOOTSTRAP_IP_ATTEMPTS = 5
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
class SetupUnavailableError(ValueError):
"""Setup has finished, or another administrator already exists."""
class InvalidSetupTokenError(ValueError):
"""The operator's setup token was absent or did not match."""
def initialize_setup_state() -> None:
"""Run once before init_db; subsequent calls preserve progress."""
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
existing_install = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
).fetchone() is not None
conn.execute(
"""CREATE TABLE IF NOT EXISTS installation_setup (
id INTEGER PRIMARY KEY CHECK (id = 1),
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
step TEXT NOT NULL,
completed_at TEXT
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
scope TEXT NOT NULL,
key_hash TEXT NOT NULL,
occurred_at REAL NOT NULL
)"""
)
conn.execute(
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
VALUES (1, ?, ?, ?)""",
(
int(existing_install),
"review" if existing_install else "administrator",
datetime.now(timezone.utc).isoformat() if existing_install else None,
),
)
def get_setup_state() -> dict:
with db._connect() as conn:
# Old databases and isolated callers without startup initialization are
# already installed. A missing marker must never open public bootstrap.
table = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
).fetchone()
row = conn.execute(
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
).fetchone() if table else None
if row is None:
return {"completed": True, "step": "review", "completed_at": None}
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
def is_setup_required() -> bool:
return not get_setup_state()["completed"]
def get_public_setup_status() -> dict:
required = is_setup_required()
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
def setup_token_configured() -> bool:
"""Reject missing values and obvious examples, without claiming to measure entropy."""
token = str(getattr(settings, "setup_token", "") or "").strip()
placeholder = token.casefold().replace("_", "-")
return (
len(token) >= 32
and len(set(token)) > 1
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
)
def consume_bootstrap_attempt(client_ip: str) -> int | None:
"""Atomically reserve one attempt; return Retry-After when limited.
The IP is keyed using the existing HMAC helper, never stored in clear text.
A shared cap limits distributed attempts and expensive password hashing.
"""
now = time()
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
limits = (
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
)
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
(cutoff,),
)
retry_after = 0
for scope, key, maximum in limits:
count, oldest = conn.execute(
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
(scope, key, cutoff),
).fetchone()
if count >= maximum:
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
if retry_after:
return retry_after
conn.executemany(
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
[(scope, key, now) for scope, key, _ in limits],
)
return None
def bootstrap_administrator(setup_token: str, username: str, password: str) -> None:
"""Claim fresh setup exactly once using the deployment's setup token."""
expected = str(getattr(settings, "setup_token", "") or "")
if not setup_token_configured() or not hmac.compare_digest(
setup_token.encode("utf-8"), expected.encode("utf-8")
):
raise InvalidSetupTokenError("Invalid setup token.")
username = username.strip()
if not username or len(username) > 100 or any(
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
):
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
if len(password) > 1024:
raise ValueError("Password must contain no more than 1024 characters.")
password = validate_password_policy(password)
if not is_setup_required() or db.has_admin_user():
raise SetupUnavailableError("Initial administrator setup is no longer available.")
password_hash = hash_password(password)
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
if setup is None or setup[0] or admin:
raise SetupUnavailableError("Initial administrator setup is no longer available.")
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
raise SetupUnavailableError("That username already exists.")
conn.execute(
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
VALUES (?, ?, 'admin', 'local', ?)""",
(username, password_hash, datetime.now(timezone.utc).isoformat()),
)
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
def update_setup_step(step: SetupStep) -> dict:
if step not in SETUP_STEPS:
raise ValueError("Invalid setup step.")
if not is_setup_required():
return get_setup_state()
with db._connect() as conn:
conn.execute(
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
)
return get_setup_state()
def complete_setup() -> dict:
if not is_setup_required():
return get_setup_state()
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
raise SetupUnavailableError("Create an administrator before completing setup.")
conn.execute(
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
WHERE id = 1 AND completed = 0""",
(datetime.now(timezone.utc).isoformat(),),
)
return get_setup_state()
+310
View File
@@ -0,0 +1,310 @@
from contextlib import closing
import io
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import zipfile
from cryptography.fernet import Fernet
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.config import settings
from backend.app.routers import backups as backup_router
from backend.app.services import backups
PASSPHRASE = "test backup passphrase with spaces"
class BackupTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.database = self.root / "magent.db"
for key, value in {
"sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
"settings_encryption_key": Fernet.generate_key().decode(),
"jwt_secret": "source-installation-signing-secret-for-backup-tests",
"admin_username": "backup-admin", "admin_password": "a secure initial password",
"jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
"discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
}.items():
context = patch.object(settings, key, value)
context.start()
self.addCleanup(context.stop)
context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
context.start()
self.addCleanup(context.stop)
db.init_db()
db.set_setting("sonarr_api_key", "database-integration-secret")
db.set_setting("site_login_message", "Restored configuration")
db.set_setting("installation_setup", "complete")
with closing(sqlite3.connect(self.database)) as conn, conn:
conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
conn.execute(
"INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
)
self.assets = self.root / "assets"
(self.assets / "branding").mkdir(parents=True)
(self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
(self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
def export(self, include_cache=True):
content, filename = backups.create_backup(PASSPHRASE, include_cache)
self.assertTrue(filename.endswith(".magent-backup"))
return content
def rewrite_archive(self, content, change):
decrypted = backups._decrypt(content, PASSPHRASE)
with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
change(files)
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
for name, value in files.items():
archive.writestr(name, value)
return backups._encrypt(output.getvalue(), PASSPHRASE)
def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
content = self.export()
self.assertNotIn(b"database-integration-secret", content)
self.assertNotIn(b"environment-integration-secret", content)
original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
db.set_setting("site_login_message", "Live data before restart")
settings.settings_encryption_key = Fernet.generate_key().decode()
settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
# Simulate a different host with different env-backed integration settings.
settings.jellyfin_api_key = "destination-env-value"
metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(metadata["include_cache"])
self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
self.assertIsNotNone(backups.backup_status()["pending_restore"])
staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
self.assertNotIn(b"database-integration-secret", staged_bytes)
self.assertNotIn(b"environment-integration-secret", staged_bytes)
self.assertNotIn(b"legacy-private-token", staged_bytes)
(self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
self.assertTrue(backups.apply_pending_restore())
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
self.assertEqual(db.get_setting("installation_setup"), "complete")
self.assertIsNone(db.get_setting("setup_token"))
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
with closing(sqlite3.connect(self.database)) as conn, conn:
self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
status = backups.backup_status()
self.assertIsNone(status["pending_restore"])
self.assertEqual(status["last_restore"]["status"], "restored")
self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
self.assertFalse(backups.apply_pending_restore())
def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
with closing(sqlite3.connect(self.database)) as writer:
writer.execute("PRAGMA journal_mode=WAL")
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
writer.commit()
self.assertTrue(Path(str(self.database) + "-wal").exists())
content = self.export()
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(backups.apply_pending_restore())
with closing(sqlite3.connect(self.database)) as restored:
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
def test_process_interruption_is_recovered_on_next_startup(self):
class ProcessStopped(BaseException):
pass
content = self.export()
db.set_setting("site_login_message", "Value before interrupted restart")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
with self.assertRaises(ProcessStopped):
backups.apply_pending_restore()
self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
self.assertFalse(backups.apply_pending_restore())
self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
self.assertIsNone(backups.backup_status()["pending_restore"])
def test_crash_after_rollback_does_not_reapply_pending_restore(self):
class ProcessStopped(BaseException):
pass
content = self.export()
db.set_setting("site_login_message", "Value to retain")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
replace_assets = backups._replace_assets
remove_tree = backups.shutil.rmtree
calls = 0
def fail_first_copy(source, target):
nonlocal calls
calls += 1
if calls == 1:
raise OSError("failed apply")
return replace_assets(source, target)
def interrupt_cleanup(path, *args, **kwargs):
if Path(path).name == "pending":
raise ProcessStopped()
return remove_tree(path, *args, **kwargs)
with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
with self.assertRaises(ProcessStopped):
backups.apply_pending_restore()
journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
self.assertEqual(journal["phase"], "rolled_back")
self.assertFalse(backups.apply_pending_restore())
self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
self.assertIsNone(backups.backup_status()["pending_restore"])
def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
directory = self.root / "schema-test"
directory.mkdir()
backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
source = directory / "database.sqlite3"
with closing(sqlite3.connect(source)) as conn, conn:
conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
backups._validate_database(source)
def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
content = self.export()
db.set_setting("site_login_message", "Current data")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
settings.settings_encryption_key = Fernet.generate_key().decode()
with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
backups.apply_pending_restore()
self.assertEqual(db.get_setting("site_login_message"), "Current data")
self.assertIsNotNone(backups.backup_status()["pending_restore"])
def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
self.assertIn("database.sqlite3", archive.namelist())
self.assertIn("files/branding/logo.png", archive.namelist())
self.assertFalse(any("artwork" in name for name in archive.namelist()))
def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
content = self.export()
for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
with self.subTest(password=password):
with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
backups.stage_restore(io.BytesIO(bad_content), password)
self.assertIsNone(backups.backup_status()["pending_restore"])
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
content = self.export()
for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
with self.subTest(name=name):
malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
with self.assertRaises(backups.BackupError):
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
with self.assertRaises(backups.BackupError):
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
self.assertFalse((self.root / "outside.txt").exists())
def test_size_limit_and_unsupported_schema_rejected(self):
content = self.export()
with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
with self.assertRaisesRegex(backups.BackupError, "upload limit"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with closing(sqlite3.connect(self.database)) as conn, conn:
conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
# Validate the original fixture to avoid executing the malicious trigger in export.
with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
backups._validate_database(self.database)
def test_unsupported_compression_is_rejected_before_expansion(self):
content = self.export()
rewritten = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
for entry in original.infolist():
target.writestr(entry.filename, original.read(entry))
with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
def test_cancel_is_idempotent_and_does_not_change_database(self):
content = self.export()
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
with self.assertRaisesRegex(backups.BackupError, "already staged"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
backups.cancel_restore()
backups.cancel_restore()
self.assertIsNone(backups.backup_status()["pending_restore"])
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
content = self.export()
db.set_setting("site_login_message", "Keep this current value")
(self.assets / "branding" / "logo.png").write_bytes(b"current logo")
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
original = backups._replace_assets
calls = 0
def fail_once(source, target):
nonlocal calls
calls += 1
if calls == 1:
raise OSError("simulated interrupted copy")
return original(source, target)
with patch.object(backups, "_replace_assets", side_effect=fail_once):
with self.assertRaisesRegex(OSError, "interrupted copy"):
backups.apply_pending_restore()
self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
self.assertFalse(backups.apply_pending_restore())
def test_api_requires_admin_and_restore_confirmation(self):
app = FastAPI()
app.include_router(backup_router.router)
with TestClient(app) as client:
self.assertEqual(client.get("/admin/backups").status_code, 401)
app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
self.assertEqual(client.get("/admin/backups").status_code, 403)
self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
status = client.get("/admin/backups")
self.assertEqual(status.status_code, 200)
self.assertEqual(status.headers["cache-control"], "no-store")
self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
self.assertEqual(rejected.status_code, 422)
restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
self.assertEqual(restored.status_code, 202)
self.assertTrue(restored.json()["restart_required"])
self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
"""Real application HTTP checks for installation, cookies, and backup controls.
All persistence and artwork paths are isolated in temporary directories; workers,
logging file handlers, and the metrics listener are disabled for these tests.
"""
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from backend.app import db, main
from backend.app.config import settings
from backend.app.services import backups
OPERATOR_TOKEN = "installation-http-operator-token-test-123456789"
OWNER_PASSWORD = "installation-http-owner-password-123456789"
BACKUP_PASSPHRASE = "installation-http-backup-passphrase-123456789"
class InstallationHttpTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
for key, value in {
"sqlite_path": str(self.root / "magent.db"),
"sqlite_journal_mode": "DELETE",
"jwt_secret": "installation-http-test-jwt-secret-1234567890",
"settings_encryption_key": None,
"admin_username": "unused-environment-admin",
"admin_password": "",
"setup_token": OPERATOR_TOKEN,
"auth_cookie_secure": True,
"auth_cookie_domain": None,
"auth_cookie_samesite": "strict",
}.items():
context = patch.object(settings, key, value)
context.start()
self.addCleanup(context.stop)
for context in (
patch.object(main, "configure_logging"),
patch.object(main, "start_metrics"),
patch.object(main, "_background_tasks", []),
patch.object(main, "_background_started", False),
patch.object(backups, "_assets_root", return_value=self.root / "assets"),
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}),
):
context.start()
self.addCleanup(context.stop)
self.origin = str(settings.cors_allow_origin).rstrip("/")
self.client = self.enterContext(TestClient(main.app, base_url="https://magent.test"))
self.client.headers["Origin"] = self.origin
def create_owner(self):
response = self.client.post("/setup/bootstrap", json={
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
})
self.assertEqual(response.status_code, 201, response.text)
return response
def sign_in(self):
response = self.client.post("/auth/login", data={"username": "owner", "password": OWNER_PASSWORD})
self.assertEqual(response.status_code, 200, response.text)
self.assertIn(settings.auth_cookie_name, self.client.cookies)
auth_cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "="))
self.assertIn("HttpOnly", auth_cookie)
self.assertIn("Secure", auth_cookie)
self.assertIn("SameSite=strict", auth_cookie)
self.assertNotIn("Authorization", self.client.headers)
def test_fresh_setup_cookie_settings_completion_and_backup_round_trip(self):
status = self.client.get("/setup/status")
self.assertEqual(status.json(), {"setup_required": True, "needs_admin": True})
self.assertEqual(status.headers["cache-control"], "no-store")
self.assertIn("default-src 'none'", status.headers["content-security-policy"])
self.assertEqual(self.client.get("/setup/state").status_code, 401)
self.assertEqual(self.client.get("/admin/backups").status_code, 401)
self.create_owner()
self.sign_in()
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
response = self.client.put("/admin/settings", json={
"jellyfin_base_url": "http://jellyfin.test:8096",
"jellyfin_api_key": "test-integration-key-for-setup",
"site_login_message": "Welcome to this installation",
})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["updated"], 3)
values = {row["key"]: row for row in self.client.get("/admin/settings").json()["settings"]}
self.assertEqual(values["jellyfin_base_url"]["value"], "http://jellyfin.test:8096")
self.assertIsNone(values["jellyfin_api_key"]["value"])
self.assertTrue(values["jellyfin_api_key"]["isSet"])
response = self.client.put("/setup/state", json={"step": "review"})
self.assertEqual(response.status_code, 200, response.text)
completed = self.client.post("/setup/complete")
self.assertEqual(completed.status_code, 200, completed.text)
self.assertTrue(completed.json()["completed"])
self.assertEqual(self.client.get("/setup/status").json(), {"setup_required": False, "needs_admin": False})
self.assertEqual(main._background_tasks, [])
exported = self.client.post("/admin/backups/export", json={
"passphrase": BACKUP_PASSPHRASE, "include_cache": False,
})
self.assertEqual(exported.status_code, 200, exported.text[:100])
self.assertTrue(exported.content.startswith(backups.MAGIC))
self.assertEqual(exported.headers["cache-control"], "no-store")
self.assertNotIn(b"test-integration-key-for-setup", exported.content)
restored = self.client.post("/admin/backups/restore", files={
"file": ("restore.magent-backup", io.BytesIO(exported.content), "application/octet-stream"),
}, data={"passphrase": BACKUP_PASSPHRASE, "confirmation": "RESTORE"})
self.assertEqual(restored.status_code, 202, restored.text)
self.assertTrue(restored.json()["restart_required"])
self.assertEqual(db.get_setting("site_login_message"), "Welcome to this installation")
self.assertIsNotNone(self.client.get("/admin/backups").json()["pending_restore"])
cancelled = self.client.delete("/admin/backups/restore")
self.assertEqual(cancelled.status_code, 200, cancelled.text)
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
def test_cross_origin_bootstrap_and_authenticated_changes_are_rejected(self):
response = self.client.post("/setup/bootstrap", headers={"Origin": "https://unrelated.invalid"}, json={
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
})
self.assertEqual(response.status_code, 403)
self.assertFalse(db.has_admin_user())
self.create_owner()
self.sign_in()
response = self.client.put("/setup/state", headers={"Origin": "https://unrelated.invalid"}, json={"step": "review"})
self.assertEqual(response.status_code, 403)
response = self.client.post("/admin/backups/export", headers={"Origin": "https://unrelated.invalid"}, json={"passphrase": BACKUP_PASSPHRASE})
self.assertEqual(response.status_code, 403)
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
def test_setup_validation_errors_do_not_echo_password_or_token(self):
secret_password = "private-password-marker-" + "p" * 1024
secret_token = "private-token-marker-" + "t" * 1024
for payload, secret in (
({"setup_token": OPERATOR_TOKEN, "username": "owner", "password": secret_password}, secret_password),
({"setup_token": secret_token, "username": "owner", "password": OWNER_PASSWORD}, secret_token),
({"setup_token": OPERATOR_TOKEN, "password": OWNER_PASSWORD}, OWNER_PASSWORD),
):
with self.subTest(secret=secret[:22]):
response = self.client.post("/setup/bootstrap", json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertNotIn(secret, response.text)
self.assertNotIn(OPERATOR_TOKEN, response.text)
for error in response.json()["detail"]:
self.assertNotIn("input", error)
def test_backup_validation_errors_do_not_echo_passphrases(self):
self.create_owner()
self.sign_in()
passphrase = "private-backup-passphrase-marker-" + "p" * 1024
response = self.client.post("/admin/backups/export", json={"passphrase": passphrase})
self.assertEqual(response.status_code, 422)
self.assertNotIn(passphrase, response.text)
response = self.client.post("/admin/backups/restore", files={"file": ("archive", b"data")}, data={
"passphrase": passphrase, "confirmation": "RESTORE",
})
self.assertEqual(response.status_code, 422)
self.assertNotIn(passphrase, response.text)
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
def test_real_middleware_rejects_oversized_bootstrap_before_creation(self):
response = self.client.post("/setup/bootstrap", content=b"x" * (17 * 1024), headers={"Content-Type": "application/json"})
self.assertEqual(response.status_code, 413, response.text)
self.assertFalse(db.has_admin_user())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,162 @@
import asyncio
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import httpx
from fastapi import FastAPI, File, Request, UploadFile
from backend.app import db, main
from backend.app.config import settings
from backend.app.request_limits import InstallationBodyLimitMiddleware
from backend.app.services import setup
class InstallationLifecycleTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temporary.cleanup)
patches = [
patch.object(settings, "sqlite_path", str(Path(temporary.name) / "magent.db")),
patch.object(settings, "jwt_secret", "installation-lifecycle-secret-1234567890"),
patch.object(settings, "settings_encryption_key", None),
patch.object(settings, "admin_password", ""),
patch.object(settings, "setup_token", "operator-setup-token-at-least-32-characters"),
patch.object(main, "_background_started", False),
patch.object(main, "_background_tasks", []),
patch.object(main, "start_metrics"),
patch.object(main, "configure_logging"),
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "true"}),
]
for item in patches:
item.start()
self.addCleanup(item.stop)
async def test_fresh_start_waits_for_admin_and_completion_then_starts_workers_once(self):
with patch.object(main, "_launch_background_task") as launch:
await main.startup()
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": True})
launch.assert_not_called()
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
await main._start_background_tasks()
launch.assert_not_called()
setup.complete_setup()
await main.app.state.on_setup_complete()
await main.app.state.on_setup_complete()
self.assertEqual(launch.call_count, 9)
async def test_upgraded_install_starts_normally_without_setup_token(self):
db.init_db()
db.create_user("owner", "existing-password-12345", role="admin")
settings.setup_token = ""
with patch.object(main, "_launch_background_task") as launch:
await main.startup()
self.assertFalse(setup.is_setup_required())
self.assertEqual(launch.call_count, 9)
async def test_disabled_workers_stay_disabled_after_setup(self):
setup.initialize_setup_state()
db.init_db()
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
setup.complete_setup()
with patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), patch.object(main, "_launch_background_task") as launch:
await main._start_background_tasks()
launch.assert_not_called()
async def test_bad_secret_stops_before_restore_or_database_initialization(self):
settings.jwt_secret = "short"
with patch.object(main, "apply_pending_restore") as restore, patch.object(main, "init_db") as initialize:
with self.assertRaisesRegex(RuntimeError, "JWT_SECRET"):
await main.startup()
restore.assert_not_called()
initialize.assert_not_called()
async def test_restore_failure_stops_before_initialization_and_workers(self):
with patch.object(main, "apply_pending_restore", side_effect=RuntimeError("restore failed")), patch.object(main, "init_db") as initialize, patch.object(main, "_launch_background_task") as launch:
with self.assertRaisesRegex(RuntimeError, "restore failed"):
await main.startup()
initialize.assert_not_called()
launch.assert_not_called()
async def test_startup_order_is_restore_then_setup_marker_then_schema(self):
calls = Mock()
calls.attach_mock(Mock(wraps=main.apply_pending_restore), "restore")
calls.attach_mock(Mock(wraps=main.initialize_setup_state), "setup")
calls.attach_mock(Mock(wraps=main.init_db), "schema")
with patch.object(main, "apply_pending_restore", calls.restore), patch.object(main, "initialize_setup_state", calls.setup), patch.object(main, "init_db", calls.schema):
await main.startup()
self.assertEqual([call[0] for call in calls.mock_calls], ["restore", "setup", "schema"])
def test_missing_token_does_not_allow_fresh_bootstrap(self):
setup.initialize_setup_state()
db.init_db()
settings.setup_token = ""
with self.assertRaisesRegex(RuntimeError, "SETUP_TOKEN"):
main._enforce_secure_startup_configuration()
def test_destination_environment_does_not_add_an_admin_to_restored_accounts(self):
db.init_db()
db.create_user("restored-owner", "existing-password-12345", role="admin")
with patch.object(settings, "admin_username", "host-bootstrap"), patch.object(settings, "admin_password", "new-host-password-12345"):
db.init_db()
self.assertIsNone(db.get_user_by_username("host-bootstrap"))
async def test_shutdown_cancels_workers_and_allows_next_start(self):
task = asyncio.create_task(asyncio.Event().wait())
main._background_tasks.append(task)
main._background_started = True
await main.shutdown()
self.assertTrue(task.cancelled())
self.assertEqual(main._background_tasks, [])
self.assertFalse(main._background_started)
class InstallationRequestLimitsTests(unittest.IsolatedAsyncioTestCase):
async def test_rejects_oversized_declared_body_before_parser(self):
app = FastAPI()
app.add_middleware(InstallationBodyLimitMiddleware)
@app.post("/setup/bootstrap")
async def bootstrap(request: Request):
self.fail("Body must be rejected before the endpoint")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/setup/bootstrap", content=b"{}", headers={"Content-Length": "999999"})
self.assertEqual(response.status_code, 413)
async def test_counts_chunks_with_missing_or_forged_content_length(self):
app = FastAPI()
app.add_middleware(InstallationBodyLimitMiddleware)
@app.post("/setup/bootstrap")
async def bootstrap(request: Request):
return await request.json()
async def chunks():
yield b'{"token":"'
yield b"a" * 17000
yield b'"}'
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
for headers in ({}, {"Content-Length": "1"}):
response = await client.post("/setup/bootstrap", content=chunks(), headers=headers)
self.assertEqual(response.status_code, 413)
async def test_multipart_stream_limit_is_413_not_parser_500(self):
app = FastAPI()
app.add_middleware(InstallationBodyLimitMiddleware)
@app.post("/admin/backups/restore")
async def restore(file: UploadFile = File(...)):
return {"size": file.size}
async def chunks():
yield b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="backup"\r\n\r\n'
yield b"a" * 2048
yield b"\r\n--boundary--\r\n"
with patch("backend.app.request_limits.RESTORE_BODY_LIMIT", 1024):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/admin/backups/restore", content=chunks(), headers={"Content-Type": "multipart/form-data; boundary=boundary"})
self.assertEqual(response.status_code, 413)
+267
View File
@@ -0,0 +1,267 @@
from concurrent.futures import ThreadPoolExecutor
import os
import tempfile
from threading import Barrier
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.config import settings
from backend.app.routers import setup as setup_router
from backend.app.security import create_access_token
from backend.app.services import setup
SETUP_TOKEN = "operator-setup-token-for-tests-only-1234567890"
ADMIN_PASSWORD = "A-long-admin-password!123"
class SetupTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(self.temp.cleanup)
for field, value in {
"sqlite_path": os.path.join(self.temp.name, "test.db"),
"sqlite_journal_mode": "DELETE",
"admin_username": "environment-admin",
"admin_password": "",
"jwt_secret": "setup-test-jwt-secret-only-1234567890",
"settings_encryption_key": "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=",
}.items():
context = patch.object(settings, field, value)
context.start()
self.addCleanup(context.stop)
context = patch.object(setup, "settings", SimpleNamespace(setup_token=SETUP_TOKEN))
context.start()
self.addCleanup(context.stop)
setup.initialize_setup_state()
db.init_db()
self.app = FastAPI()
self.app.include_router(setup_router.router)
self.client = TestClient(self.app)
self.addCleanup(self.client.close)
def bootstrap(self, **changes):
return self.client.post("/setup/bootstrap", json={
"setup_token": SETUP_TOKEN,
"username": "first-admin",
"password": ADMIN_PASSWORD,
**changes,
})
def admin_headers(self):
return {"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}"}
def test_fresh_install_requires_setup_and_exposes_no_configuration(self):
response = self.client.get("/setup/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"setup_required": True, "needs_admin": True})
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertEqual(self.client.get("/setup/state").status_code, 401)
def test_existing_install_migrates_as_completed_without_reopening_bootstrap(self):
with db._connect() as conn:
conn.execute("DROP TABLE installation_setup")
setup.initialize_setup_state()
self.assertEqual(setup.get_public_setup_status(), {"setup_required": False, "needs_admin": False})
self.assertIsNotNone(setup.get_setup_state()["completed_at"])
self.assertEqual(self.bootstrap().status_code, 409)
def test_missing_marker_fails_closed(self):
with db._connect() as conn:
conn.execute("DROP TABLE installation_setup")
self.assertFalse(setup.is_setup_required())
self.assertEqual(self.bootstrap().status_code, 409)
def test_marker_survives_restart_before_schema_initialization(self):
new_path = os.path.join(self.temp.name, "interrupted.db")
with patch.object(settings, "sqlite_path", new_path):
setup.initialize_setup_state()
setup.initialize_setup_state()
db.init_db()
self.assertTrue(setup.is_setup_required())
def test_empty_precreated_database_is_a_fresh_install(self):
new_path = os.path.join(self.temp.name, "empty.db")
with open(new_path, "wb"):
pass
with patch.object(settings, "sqlite_path", new_path):
setup.initialize_setup_state()
db.init_db()
self.assertTrue(setup.is_setup_required())
def test_environment_admin_uses_wizard_without_public_bootstrap(self):
with patch.object(settings, "admin_password", ADMIN_PASSWORD):
db.ensure_admin_user()
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": False})
self.assertEqual(self.bootstrap().status_code, 409)
def test_valid_token_creates_local_admin_once_and_uses_password_hash(self):
response = self.bootstrap()
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(response.json(), {"status": "created", "username": "first-admin"})
user = db.verify_user_password("first-admin", ADMIN_PASSWORD)
self.assertIsNotNone(user)
self.assertEqual(user["role"], "admin")
self.assertEqual(user["auth_provider"], "local")
self.assertNotEqual(user["password_hash"], ADMIN_PASSWORD)
self.assertEqual(setup.get_setup_state()["step"], "apps")
self.assertEqual(self.bootstrap(username="second-admin").status_code, 409)
self.assertEqual(len(db.get_all_users()), 1)
def test_invalid_and_missing_operator_tokens_never_create_admin(self):
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
with patch.object(setup.settings, "setup_token", ""):
self.assertEqual(self.bootstrap().status_code, 403)
with patch.object(setup.settings, "setup_token", "too-short"):
self.assertEqual(self.bootstrap(setup_token="too-short").status_code, 403)
self.assertFalse(db.has_admin_user())
def test_non_ascii_token_fails_cleanly(self):
self.assertEqual(self.bootstrap(setup_token="invalid-\N{SNOWMAN}").status_code, 403)
self.assertFalse(db.has_admin_user())
def test_example_and_repeated_character_setup_tokens_are_rejected(self):
for token in (
"replace-with-a-separate-random-setup-token",
"CHANGE_ME_before_starting_this_installation",
"your-setup-token-goes-here-at-least-32-characters",
"a" * 64,
"0" * 64,
" " * 64,
):
with self.subTest(token=token), patch.object(setup.settings, "setup_token", token):
self.assertFalse(setup.setup_token_configured())
with self.assertRaises(setup.InvalidSetupTokenError):
setup.bootstrap_administrator(token, "owner", ADMIN_PASSWORD)
self.assertFalse(db.has_admin_user())
self.assertTrue(setup.setup_token_configured())
def test_password_policy_and_username_validation(self):
for username in (" ", "admin user", "admin\x7f", "admin\nname"):
with self.subTest(username=repr(username)):
self.assertEqual(self.bootstrap(username=username).status_code, 400)
self.assertEqual(self.bootstrap(password="short").status_code, 400)
self.assertFalse(db.has_admin_user())
def test_oversized_fields_and_unexpected_privileges_are_rejected(self):
self.assertEqual(self.bootstrap(password="x" * 1025).status_code, 422)
self.assertEqual(self.bootstrap(username="x" * 101).status_code, 422)
self.assertEqual(self.bootstrap(role="admin").status_code, 422)
self.assertFalse(db.has_admin_user())
def test_existing_normalized_username_is_not_replaced(self):
db.create_user("Taken", ADMIN_PASSWORD)
self.assertEqual(self.bootstrap(username="taken").status_code, 409)
self.assertFalse(db.has_admin_user())
self.assertEqual(len(db.get_all_users()), 1)
def test_bootstrap_attempts_are_persistently_limited(self):
for _ in range(setup.BOOTSTRAP_IP_ATTEMPTS):
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
setup.initialize_setup_state()
response = self.bootstrap()
self.assertEqual(response.status_code, 429)
self.assertGreater(int(response.headers["retry-after"]), 0)
self.assertFalse(db.has_admin_user())
with db._connect() as conn:
keys = [row[0] for row in conn.execute("SELECT key_hash FROM installation_setup_attempts")]
self.assertNotIn("testclient", keys)
def test_rate_limit_global_cap_and_expiry(self):
with patch.object(setup, "time", return_value=1000):
for number in range(setup.BOOTSTRAP_GLOBAL_ATTEMPTS):
self.assertIsNone(setup.consume_bootstrap_attempt(f"192.0.2.{number}"))
self.assertEqual(setup.consume_bootstrap_attempt("198.51.100.1"), 900)
with patch.object(setup, "time", return_value=1901):
self.assertIsNone(setup.consume_bootstrap_attempt("198.51.100.1"))
def test_concurrent_attempts_cannot_bypass_rate_limit(self):
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(lambda _: setup.consume_bootstrap_attempt("192.0.2.1"), range(12)))
self.assertEqual(results.count(None), setup.BOOTSTRAP_IP_ATTEMPTS)
def test_concurrent_bootstraps_create_only_one_admin(self):
barrier = Barrier(4)
def synchronized_hash(_):
barrier.wait(timeout=10)
return "test-only-precomputed-hash"
def create(number):
try:
setup.bootstrap_administrator(SETUP_TOKEN, f"admin-{number}", ADMIN_PASSWORD)
return True
except setup.SetupUnavailableError:
return False
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(create, range(4)))
self.assertEqual(results.count(True), 1)
self.assertEqual(len(db.get_all_users()), 1)
def test_state_mutations_require_admin_and_progress_resumes(self):
self.assertEqual(self.bootstrap().status_code, 201)
db.create_user("viewer", ADMIN_PASSWORD)
user_headers = {"Authorization": f"Bearer {create_access_token('viewer', 'user')}"}
for path, method, kwargs in (
("/setup/state", "get", {}),
("/setup/state", "put", {"json": {"step": "review"}}),
("/setup/complete", "post", {}),
):
with self.subTest(path=path, method=method):
call = getattr(self.client, method)
self.assertEqual(call(path, **kwargs).status_code, 401)
self.assertEqual(call(path, headers=user_headers, **kwargs).status_code, 403)
response = self.client.put("/setup/state", json={"step": "preferences"}, headers=self.admin_headers())
self.assertEqual(response.status_code, 200)
setup.initialize_setup_state()
db.init_db()
self.assertEqual(setup.get_setup_state()["step"], "preferences")
self.assertTrue(setup.is_setup_required())
self.assertEqual(self.client.put(
"/setup/state", json={"step": "invalid"}, headers=self.admin_headers()
).status_code, 422)
def test_completion_invokes_worker_callback_and_cannot_reopen_bootstrap(self):
self.assertEqual(self.bootstrap().status_code, 201)
callback = AsyncMock()
self.app.state.on_setup_complete = callback
response = self.client.post("/setup/complete", headers=self.admin_headers())
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(response.json()["completed"])
self.assertIsNotNone(response.json()["completed_at"])
callback.assert_awaited_once()
self.assertFalse(setup.is_setup_required())
# A retry can restart an idempotent callback if the first response was
# interrupted, while keeping the original completion timestamp.
retry = self.client.post("/setup/complete", headers=self.admin_headers())
self.assertEqual(retry.json(), response.json())
self.assertEqual(callback.await_count, 2)
self.client.put("/setup/state", json={"step": "administrator"}, headers=self.admin_headers())
with db._connect() as conn:
conn.execute("DELETE FROM users")
self.assertEqual(self.bootstrap().status_code, 409)
self.assertEqual(setup.get_setup_state()["step"], "review")
def test_completion_requires_an_administrator(self):
with self.assertRaises(setup.SetupUnavailableError):
setup.complete_setup()
self.assertTrue(setup.is_setup_required())
def test_sync_callback_is_supported(self):
self.assertEqual(self.bootstrap().status_code, 201)
called = []
self.app.state.on_setup_complete = lambda: called.append(True)
response = self.client.post("/setup/complete", headers=self.admin_headers())
self.assertEqual(response.status_code, 200)
self.assertEqual(called, [True])
if __name__ == "__main__":
unittest.main()