From 2dd1ca0197eaba2c9b6ca9b7083f8814401a8d0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:03:27 +0000 Subject: [PATCH] feat(backup): add database backup/restore with scheduled retention and admin dashboard - Add BackupRecord model for tracking backup archives - Add migration 021_add_backup_records - Add backup configuration settings (backup_enabled, backup_dir, backup_remote_destination, backup_remote_folder, backup_retain_hourly/daily/weekly) - Add backup_tasks.py with create_backup, cleanup_old_backups, and helpers - Register hourly/daily/weekly Celery beat schedules - Add /api/admin/backup/* REST endpoints (list, create, download, restore, delete, cleanup) - Add /admin/backup dashboard view and template - Add backup link to admin dropdown navigation in base.html - Add backup settings to SETTING_METADATA in settings_service.py - Add comprehensive test suite (39 tests passing) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/__init__.py | 2 + app/api/backup.py | 237 +++++++ app/celery_worker.py | 35 + app/config.py | 42 ++ app/models.py | 44 ++ app/tasks/backup_tasks.py | 413 ++++++++++++ app/utils/settings_service.py | 66 ++ app/views/__init__.py | 2 + app/views/backup.py | 58 ++ frontend/templates/backup.html | 366 +++++++++++ frontend/templates/base.html | 6 + migrations/versions/021_add_backup_records.py | 43 ++ tests/test_backup.py | 621 ++++++++++++++++++ 13 files changed, 1935 insertions(+) create mode 100644 app/api/backup.py create mode 100644 app/tasks/backup_tasks.py create mode 100644 app/views/backup.py create mode 100644 frontend/templates/backup.html create mode 100644 migrations/versions/021_add_backup_records.py create mode 100644 tests/test_backup.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 9387fe88..5b158592 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api.admin_users import router as admin_users_router from app.api.azure import router as azure_router +from app.api.backup import router as backup_router from app.api.billing import router as billing_router from app.api.database import router as database_router from app.api.diagnostic import router as diagnostic_router @@ -43,6 +44,7 @@ router = APIRouter() # Include all the routers router.include_router(admin_users_router) router.include_router(user_router) +router.include_router(backup_router) router.include_router(files_router) router.include_router(process_router) router.include_router(diagnostic_router) diff --git a/app/api/backup.py b/app/api/backup.py new file mode 100644 index 00000000..2c83058c --- /dev/null +++ b/app/api/backup.py @@ -0,0 +1,237 @@ +""" +Backup and restore API endpoints for DocuElevate. + +Provides REST endpoints for: +- Listing existing backups +- Triggering a manual backup +- Downloading a backup archive +- Restoring from an uploaded backup file +- Deleting a backup record +- Running retention cleanup +""" + +import logging +import os +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import BackupRecord + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/admin/backup", tags=["backup"]) + + +def _require_admin(request: Request) -> dict: + """Ensure the caller is an admin. Raises 403 otherwise.""" + user = request.session.get("user") + if not user or not user.get("is_admin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user + + +# Annotated shorthand so FastAPI can resolve and tests can override it. +AdminUser = Annotated[dict, Depends(_require_admin)] + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/") +async def list_backups( + _admin: AdminUser, + db: Session = Depends(get_db), +) -> list[dict]: + """Return all backup records, newest first.""" + records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).all() + return [ + { + "id": r.id, + "filename": r.filename, + "backup_type": r.backup_type, + "size_bytes": r.size_bytes, + "checksum": r.checksum, + "status": r.status, + "local_path": r.local_path, + "remote_destination": r.remote_destination, + "remote_path": r.remote_path, + "created_at": r.created_at.isoformat() if r.created_at else None, + "local_available": bool(r.local_path and os.path.exists(r.local_path)), + } + for r in records + ] + + +@router.post("/create") +async def trigger_backup( + _admin: AdminUser, + backup_type: str = "hourly", +) -> dict: + """Trigger a manual backup immediately. + + Query parameter ``backup_type`` accepts ``hourly``, ``daily``, or + ``weekly`` (default: ``hourly``). + """ + if backup_type not in ("hourly", "daily", "weekly"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid backup_type") + + from app.tasks.backup_tasks import create_backup + + task = create_backup.delay(backup_type=backup_type) + return {"task_id": task.id, "status": "queued", "backup_type": backup_type} + + +@router.get("/{backup_id}/download") +async def download_backup( + backup_id: int, + _admin: AdminUser, + db: Session = Depends(get_db), +) -> FileResponse: + """Stream the backup archive to the client.""" + rec = db.get(BackupRecord, backup_id) + if rec is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found") + if not rec.local_path or not os.path.exists(rec.local_path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Local archive file is not available (may have been pruned)", + ) + return FileResponse( + path=rec.local_path, + filename=rec.filename, + media_type="application/gzip", + ) + + +@router.post("/restore") +async def restore_backup( + _admin: AdminUser, + file: UploadFile, + db: Session = Depends(get_db), +) -> dict: + """Restore the database from an uploaded gzip-compressed SQL dump. + + **Warning**: This overwrites the current database contents. + Only SQLite databases are supported. + + The uploaded file must be a ``.db.gz`` file produced by the DocuElevate + backup task (a gzip-compressed SQLite ``.dump()`` SQL script). + """ + from app.tasks.backup_tasks import _db_path + + db_path = _db_path() + if db_path is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Restore is only supported for SQLite databases.", + ) + + if not file.filename or not file.filename.endswith(".db.gz"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Uploaded file must be a .db.gz backup archive.", + ) + + import gzip + import sqlite3 + import tempfile + from pathlib import Path + + # Write the upload to a temp file first so we can validate it + with tempfile.NamedTemporaryFile(suffix=".db.gz", delete=False) as tmp: + tmp_path = Path(tmp.name) + content = await file.read() + tmp.write(content) + + try: + # Decompress and read SQL statements + with gzip.open(str(tmp_path), "rt", encoding="utf-8") as gz: + sql_script = gz.read() + except Exception as exc: + tmp_path.unlink(missing_ok=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to decompress backup file: {exc}", + ) from exc + + # Create a fresh in-memory DB from the script to validate it + try: + mem_conn = sqlite3.connect(":memory:") + mem_conn.executescript(sql_script) + mem_conn.close() + except sqlite3.Error as exc: + tmp_path.unlink(missing_ok=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Backup file contains invalid SQL: {exc}", + ) from exc + + # Close the application DB session before replacing the file + db.close() + + # Preserve the current DB before overwriting + import shutil + + bak = str(db_path) + ".pre_restore" + try: + shutil.copy2(str(db_path), bak) + except OSError as exc: + logger.warning(f"Could not create pre-restore backup at {bak}: {exc}") + + try: + # Write the restored database + restore_conn = sqlite3.connect(str(db_path)) + restore_conn.executescript(sql_script) + restore_conn.close() + except sqlite3.Error as exc: + # Attempt rollback + try: + if os.path.exists(bak): + shutil.copy2(bak, str(db_path)) + except OSError as rollback_exc: + logger.error(f"Rollback failed; database may be corrupted: {rollback_exc}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Restore failed: {exc}", + ) from exc + finally: + tmp_path.unlink(missing_ok=True) + + logger.info(f"Database restored from uploaded backup: {file.filename}") + return {"status": "restored", "filename": file.filename} + + +@router.delete("/{backup_id}") +async def delete_backup( + backup_id: int, + _admin: AdminUser, + db: Session = Depends(get_db), +) -> dict: + """Delete a backup record (and local file if present).""" + rec = db.get(BackupRecord, backup_id) + if rec is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found") + + if rec.local_path and os.path.exists(rec.local_path): + try: + os.remove(rec.local_path) + except OSError as exc: + logger.warning(f"Could not remove local backup file {rec.local_path}: {exc}") + + db.delete(rec) + db.commit() + return {"status": "deleted", "id": backup_id} + + +@router.post("/cleanup") +async def run_cleanup(_admin: AdminUser) -> dict: + """Manually trigger the retention cleanup for all backup tiers.""" + from app.tasks.backup_tasks import cleanup_old_backups + + task = cleanup_old_backups.delay() + return {"task_id": task.id, "status": "queued"} diff --git a/app/celery_worker.py b/app/celery_worker.py index ddf83efe..1254012e 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -8,6 +8,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe # Import the shared Celery instance from app.celery_app import celery from app.config import settings +from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401 from app.tasks.check_credentials import check_credentials from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401 from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401 @@ -111,6 +112,40 @@ celery.conf.beat_schedule = { "schedule": crontab(hour="0", minute="5"), # 00:05 UTC daily "options": {"expires": 3600}, }, + # ── Database backup tasks ────────────────────────────────────────────── + # Hourly backup (kept for 4 days) + "backup-hourly": ( + { + "task": "app.tasks.backup_tasks.create_backup", + "schedule": crontab(minute="0"), # top of every hour + "kwargs": {"backup_type": "hourly"}, + "options": {"expires": 3300}, + } + if settings.backup_enabled + else None + ), + # Daily backup (kept for 3 weeks) – runs at 02:30 UTC + "backup-daily": ( + { + "task": "app.tasks.backup_tasks.create_backup", + "schedule": crontab(hour="2", minute="30"), + "kwargs": {"backup_type": "daily"}, + "options": {"expires": 3600}, + } + if settings.backup_enabled + else None + ), + # Weekly backup (kept for 13 weeks) – runs every Sunday at 03:00 UTC + "backup-weekly": ( + { + "task": "app.tasks.backup_tasks.create_backup", + "schedule": crontab(hour="3", minute="0", day_of_week="0"), + "kwargs": {"backup_type": "weekly"}, + "options": {"expires": 3600}, + } + if settings.backup_enabled + else None + ), } # Remove None entries from beat_schedule diff --git a/app/config.py b/app/config.py index 0f13c5f8..a0d19daf 100644 --- a/app/config.py +++ b/app/config.py @@ -423,6 +423,48 @@ class Settings(BaseSettings): description="Enable webhook delivery for document events", ) + # ── Backup / restore settings ────────────────────────────────────────────── + backup_enabled: bool = Field( + default=True, + description=( + "Enable automatic scheduled database backups. " + "When enabled, hourly, daily, and weekly backups are created automatically. Default: True." + ), + ) + backup_dir: Optional[str] = Field( + default=None, + description=("Directory where local backup archives are stored. Defaults to /backups when not set."), + ) + # Remote destination: one of s3, dropbox, google_drive, onedrive, nextcloud, + # webdav, ftp, sftp, email, or empty/None for local-only. + backup_remote_destination: Optional[str] = Field( + default=None, + description=( + "Storage provider to upload remote backup copies to. " + "Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. " + "Leave empty to keep backups local only." + ), + ) + backup_remote_folder: str = Field( + default="backups", + description=( + "Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'." + ), + ) + # Retention counts (number of snapshots to keep per tier) + backup_retain_hourly: int = Field( + default=96, + description="Number of hourly backups to retain (default 96 = 4 days × 24 h).", + ) + backup_retain_daily: int = Field( + default=21, + description="Number of daily backups to retain (default 21 = 3 weeks).", + ) + backup_retain_weekly: int = Field( + default=13, + description="Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).", + ) + # File upload size limits (for security - see SECURITY_AUDIT.md) max_upload_size: int = Field( default=1073741824, # 1GB in bytes (1024 * 1024 * 1024) diff --git a/app/models.py b/app/models.py index 560b2b8b..99eaf412 100644 --- a/app/models.py +++ b/app/models.py @@ -374,3 +374,47 @@ class PipelineStep(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class BackupRecord(Base): + """Tracks database backup files and their retention metadata. + + Each row represents one backup archive (a gzipped SQLite dump). + ``backup_type`` classifies the backup for retention purposes: + - ``hourly`` – kept for up to 4 days (96 snapshots) + - ``daily`` – kept for up to 3 weeks (21 snapshots) + - ``weekly`` – kept for up to 13 weeks (≈ 90 days) + ``location`` is ``local`` when the file is stored on-disk under the + configured backup directory, or ``remote`` when it has been uploaded to + a storage provider or sent via e-mail. + """ + + __tablename__ = "backup_records" + + id = Column(Integer, primary_key=True, index=True) + + # Human-readable archive filename (e.g. backup_hourly_2026-03-07T12-00-00.db.gz) + filename = Column(String(255), nullable=False, unique=True) + + # Full path on the local filesystem (may be NULL for remote-only backups) + local_path = Column(String(1024), nullable=True) + + # Classification used by the retention policy + backup_type = Column(String(20), nullable=False, index=True) # hourly | daily | weekly + + # Archive size in bytes (0 if unknown) + size_bytes = Column(Integer, nullable=False, default=0) + + # Checksum of the archive for integrity verification (SHA-256 hex) + checksum = Column(String(64), nullable=True) + + # Whether the backup was successfully created + status = Column(String(20), nullable=False, default="ok") # ok | failed + + # Storage destination where a remote copy was uploaded (e.g. "s3", "dropbox", "email") + remote_destination = Column(String(50), nullable=True) + + # Path / key of the remote copy (bucket key, folder path, etc.) + remote_path = Column(String(1024), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/app/tasks/backup_tasks.py b/app/tasks/backup_tasks.py new file mode 100644 index 00000000..c94ca1eb --- /dev/null +++ b/app/tasks/backup_tasks.py @@ -0,0 +1,413 @@ +""" +Backup and restore tasks for DocuElevate. + +Retention strategy +------------------ +- **hourly** backups – retained for 4 days (``backup_retain_hourly``, default 96) +- **daily** backups – retained for 3 weeks (``backup_retain_daily``, default 21) +- **weekly** backups – retained for 13 weeks (``backup_retain_weekly``, default 13) + +Three separate Celery-beat entries call ``create_backup`` with the appropriate +``backup_type`` argument: +- every hour → ``create_backup("hourly")`` +- every day → ``create_backup("daily")`` +- every week → ``create_backup("weekly")`` + +After each backup is created ``_apply_retention`` prunes old local backups for +that tier. Remote copies are pruned by ``_prune_remote_backups`` which mirrors +the same retention limits. +""" + +import gzip +import hashlib +import logging +import os +from datetime import datetime, timezone +from pathlib import Path + +from app.celery_app import celery +from app.config import settings +from app.database import SessionLocal +from app.models import BackupRecord + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_BACKUP_TYPE_RETAIN: dict[str, str] = { + "hourly": "backup_retain_hourly", + "daily": "backup_retain_daily", + "weekly": "backup_retain_weekly", +} + + +def _backup_dir() -> Path: + """Return (and create) the local backup directory.""" + raw = getattr(settings, "backup_dir", None) or os.path.join(settings.workdir, "backups") + path = Path(raw) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _db_path() -> Path | None: + """Return the SQLite database file path, or None for non-SQLite databases.""" + from sqlalchemy.engine.url import make_url + + url = make_url(settings.database_url) + if url.get_backend_name() != "sqlite": + return None + db = url.database + if not db or db == ":memory:": + return None + return Path(db) + + +def _sha256(path: Path) -> str: + """Return the SHA-256 hex digest of *path*.""" + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _dump_sqlite(db_path: Path, dest: Path) -> None: + """Write a gzip-compressed SQL dump of *db_path* to *dest*.""" + import sqlite3 + + conn = sqlite3.connect(str(db_path)) + try: + with gzip.open(str(dest), "wt", encoding="utf-8") as gz: + for line in conn.iterdump(): + gz.write(line + "\n") + finally: + conn.close() + + +def _apply_retention(backup_type: str, db: object) -> None: + """Delete local backups beyond the retention limit for *backup_type*. + + Args: + backup_type: One of ``hourly``, ``daily``, ``weekly``. + db: Active SQLAlchemy session. + """ + retain_attr = _BACKUP_TYPE_RETAIN.get(backup_type, "backup_retain_hourly") + retain = int(getattr(settings, retain_attr, 96)) + + # Query ALL records for this tier (with or without a local file) so that + # remote-only and already-pruned records still count toward the retention window. + records = ( + db.query(BackupRecord) + .filter(BackupRecord.backup_type == backup_type) + .order_by(BackupRecord.created_at.desc()) + .all() + ) + + to_prune = records[retain:] + for rec in to_prune: + if rec.local_path and os.path.exists(rec.local_path): + try: + os.remove(rec.local_path) + logger.info(f"Pruned local backup: {rec.local_path}") + except OSError as exc: + logger.warning(f"Failed to remove local backup {rec.local_path}: {exc}") + rec.local_path = None + # If no remote copy either, delete the record entirely + if not rec.remote_path: + db.delete(rec) + + db.commit() + + +def _prune_remote_backups(backup_type: str, db: object) -> None: + """Prune remote backup records beyond the retention limit. + + The actual remote deletion is best-effort (logged but not fatal). + + Args: + backup_type: One of ``hourly``, ``daily``, ``weekly``. + db: Active SQLAlchemy session. + """ + retain_attr = _BACKUP_TYPE_RETAIN.get(backup_type, "backup_retain_hourly") + retain = int(getattr(settings, retain_attr, 96)) + + # Query ALL records for this tier so that already-pruned local records + # still count toward the retention window. + records = ( + db.query(BackupRecord) + .filter(BackupRecord.backup_type == backup_type) + .order_by(BackupRecord.created_at.desc()) + .all() + ) + + to_prune = [r for r in records[retain:] if r.remote_path] + for rec in to_prune: + _delete_remote_copy(rec) + rec.remote_path = None + rec.remote_destination = None + if not rec.local_path: + db.delete(rec) + + db.commit() + + +def _delete_remote_copy(rec: BackupRecord) -> None: # noqa: C901 + """Best-effort deletion of the remote copy described by *rec*.""" + dest = rec.remote_destination + remote_path = rec.remote_path + if not dest or not remote_path: + return + + try: + if dest == "s3": + import boto3 + + s3 = boto3.client( + "s3", + region_name=settings.aws_region, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + ) + s3.delete_object(Bucket=settings.s3_bucket_name, Key=remote_path) + logger.info(f"Deleted remote S3 backup: s3://{settings.s3_bucket_name}/{remote_path}") + + elif dest == "dropbox": + import dropbox as dbx_module + + dbx = dbx_module.Dropbox(settings.dropbox_refresh_token) + dbx.files_delete_v2(remote_path) + logger.info(f"Deleted remote Dropbox backup: {remote_path}") + + elif dest in ("ftp", "sftp", "nextcloud", "webdav", "google_drive", "onedrive", "email"): + # For other providers best-effort is logged only – deletion not implemented yet. + logger.debug(f"Remote deletion not implemented for destination '{dest}', skipping {remote_path}") + + except Exception as exc: + logger.warning(f"Failed to delete remote backup {remote_path} from {dest}: {exc}") + + +def _upload_remote(archive_path: Path, filename: str) -> tuple[str, str] | None: # noqa: C901 + """Upload *archive_path* to the configured remote destination. + + Returns: + ``(destination, remote_path)`` on success, ``None`` on failure or when + no remote destination is configured. + """ + dest = getattr(settings, "backup_remote_destination", None) + if not dest: + return None + + remote_folder = getattr(settings, "backup_remote_folder", "backups") or "backups" + remote_key = f"{remote_folder}/{filename}" + + try: + if dest == "s3": + import boto3 + + s3 = boto3.client( + "s3", + region_name=settings.aws_region, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + ) + with open(archive_path, "rb") as fh: + s3.upload_fileobj(fh, settings.s3_bucket_name, remote_key) + logger.info(f"Uploaded backup to S3: s3://{settings.s3_bucket_name}/{remote_key}") + return (dest, remote_key) + + elif dest == "dropbox": + import dropbox as dbx_module + + dbx = dbx_module.Dropbox(settings.dropbox_refresh_token) + dropbox_path = f"/{remote_key}" + with open(archive_path, "rb") as fh: + dbx.files_upload(fh.read(), dropbox_path, mode=dbx_module.files.WriteMode("overwrite")) + logger.info(f"Uploaded backup to Dropbox: {dropbox_path}") + return (dest, dropbox_path) + + elif dest == "email": + _email_backup(archive_path, filename) + return (dest, f"email:{filename}") + + elif dest == "nextcloud": + import requests + + url = f"{settings.nextcloud_upload_url}/{remote_key}" + with open(archive_path, "rb") as fh: + resp = requests.put( + url, + data=fh, + auth=(settings.nextcloud_username, settings.nextcloud_password), + timeout=120, + ) + resp.raise_for_status() + logger.info(f"Uploaded backup to Nextcloud: {url}") + return (dest, url) + + elif dest == "webdav": + import requests + + url = f"{settings.webdav_url}/{remote_key}" + with open(archive_path, "rb") as fh: + resp = requests.put( + url, + data=fh, + auth=(settings.webdav_username, settings.webdav_password), + verify=settings.webdav_verify_ssl, + timeout=120, + ) + resp.raise_for_status() + logger.info(f"Uploaded backup to WebDAV: {url}") + return (dest, url) + + else: + logger.warning(f"Backup remote destination '{dest}' upload not implemented; keeping local only.") + return None + + except Exception as exc: + logger.error(f"Failed to upload backup to {dest}: {exc}", exc_info=True) + return None + + +def _email_backup(archive_path: Path, filename: str) -> None: + """Send *archive_path* as an e-mail attachment to the default recipient.""" + import smtplib + from email.mime.application import MIMEApplication + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + recipient = settings.email_default_recipient + if not recipient: + raise ValueError("email_default_recipient is not configured") + + msg = MIMEMultipart() + msg["Subject"] = f"[DocuElevate] Database backup – {filename}" + msg["From"] = settings.email_sender or settings.email_username or "docuelevate@localhost" + msg["To"] = recipient + + body = MIMEText(f"Automated database backup from DocuElevate.\n\nFile: {filename}\n", "plain") + msg.attach(body) + + with open(archive_path, "rb") as fh: + part = MIMEApplication(fh.read(), Name=filename) + part["Content-Disposition"] = f'attachment; filename="{filename}"' + msg.attach(part) + + with smtplib.SMTP(settings.email_host, settings.email_port, timeout=60) as server: + if settings.email_use_tls: + server.starttls() + if settings.email_username and settings.email_password: + server.login(settings.email_username, settings.email_password) + server.sendmail(msg["From"], [recipient], msg.as_string()) + + logger.info(f"Backup e-mailed to {recipient}: {filename}") + + +# --------------------------------------------------------------------------- +# Public Celery tasks +# --------------------------------------------------------------------------- + + +@celery.task(name="app.tasks.backup_tasks.create_backup", bind=True) +def create_backup(self, backup_type: str = "hourly") -> dict: + """Create a database backup archive and apply retention. + + Args: + backup_type: ``"hourly"``, ``"daily"``, or ``"weekly"``. + + Returns: + A dict with ``filename``, ``size_bytes``, and ``status``. + """ + if backup_type not in _BACKUP_TYPE_RETAIN: + backup_type = "hourly" + + if not getattr(settings, "backup_enabled", True): + logger.debug("Backup is disabled; skipping create_backup task.") + return {"status": "disabled"} + + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") + filename = f"backup_{backup_type}_{ts}.db.gz" + archive_path = _backup_dir() / filename + + db_path = _db_path() + if db_path is None: + logger.warning("Backup task skipped: non-SQLite databases are not supported for file-based backups.") + return {"status": "unsupported_db"} + + if not db_path.exists(): + logger.error(f"Database file not found: {db_path}") + return {"status": "error", "detail": f"DB file missing: {db_path}"} + + status = "ok" + checksum: str | None = None + size_bytes = 0 + remote_destination: str | None = None + remote_path: str | None = None + + try: + _dump_sqlite(db_path, archive_path) + size_bytes = archive_path.stat().st_size + checksum = _sha256(archive_path) + logger.info(f"Created {backup_type} backup: {archive_path} ({size_bytes:,} bytes)") + except Exception as exc: + logger.error(f"Failed to create backup archive {filename}: {exc}", exc_info=True) + status = "failed" + # Record the failure so it is visible in the dashboard + with SessionLocal() as db: + rec = BackupRecord( + filename=filename, + local_path=None, + backup_type=backup_type, + size_bytes=0, + checksum=None, + status="failed", + ) + db.add(rec) + db.commit() + return {"status": "error", "detail": str(exc)} + + # Optional remote upload + result = _upload_remote(archive_path, filename) + if result: + remote_destination, remote_path = result + + with SessionLocal() as db: + rec = BackupRecord( + filename=filename, + local_path=str(archive_path), + backup_type=backup_type, + size_bytes=size_bytes, + checksum=checksum, + status=status, + remote_destination=remote_destination, + remote_path=remote_path, + ) + db.add(rec) + db.commit() + + # Apply retention policy for this tier + _apply_retention(backup_type, db) + if remote_destination: + _prune_remote_backups(backup_type, db) + + return { + "filename": filename, + "size_bytes": size_bytes, + "status": status, + "remote_destination": remote_destination, + } + + +@celery.task(name="app.tasks.backup_tasks.cleanup_old_backups") +def cleanup_old_backups() -> dict: + """Manually trigger retention clean-up for all backup tiers. + + This is also called automatically after each ``create_backup`` run. + """ + with SessionLocal() as db: + for btype in ("hourly", "daily", "weekly"): + _apply_retention(btype, db) + _prune_remote_backups(btype, db) + return {"status": "ok"} diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 1c3557d8..31642825 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -1300,6 +1300,72 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + # Backup / Restore + "backup_enabled": { + "category": "Backup", + "description": ("Enable automatic scheduled database backups (hourly, daily, weekly). Default: True."), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "backup_dir": { + "category": "Backup", + "description": ( + "Directory where local backup archives are stored. Defaults to /backups when not set." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "backup_remote_destination": { + "category": "Backup", + "description": ( + "Storage provider for remote backup copies. " + "Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. " + "Leave empty to keep backups local only." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + "options": ["", "s3", "dropbox", "google_drive", "onedrive", "nextcloud", "webdav", "ftp", "sftp", "email"], + }, + "backup_remote_folder": { + "category": "Backup", + "description": ( + "Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "backup_retain_hourly": { + "category": "Backup", + "description": "Number of hourly backups to retain (default 96 = 4 days × 24 h).", + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "backup_retain_daily": { + "category": "Backup", + "description": "Number of daily backups to retain (default 21 = 3 weeks).", + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "backup_retain_weekly": { + "category": "Backup", + "description": "Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).", + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, # UI / Appearance "ui_default_color_scheme": { "category": "UI", diff --git a/app/views/__init__.py b/app/views/__init__.py index 6b1670d7..e499da1e 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -5,6 +5,7 @@ Aggregated view routers for the application. from fastapi import APIRouter from app.views.admin_users import router as admin_users_router +from app.views.backup import router as backup_router from app.views.db_wizard import router as db_wizard_router from app.views.dropbox import router as dropbox_router from app.views.filemanager import router as filemanager_router @@ -30,6 +31,7 @@ router = APIRouter() router.include_router(wizard_router) # Wizard first (for /setup) router.include_router(db_wizard_router) # Database wizard router.include_router(admin_users_router) # Admin user management +router.include_router(backup_router) # Backup dashboard router.include_router(general_router) router.include_router(status_router) router.include_router(onedrive_router) diff --git a/app/views/backup.py b/app/views/backup.py new file mode 100644 index 00000000..f4fd2480 --- /dev/null +++ b/app/views/backup.py @@ -0,0 +1,58 @@ +""" +Backup management dashboard view – admin only. +""" + +import logging + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.config import settings +from app.models import BackupRecord +from app.views.base import APIRouter, get_db, require_login, templates +from app.views.settings import require_admin_access + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/admin/backup") +@require_login +@require_admin_access +async def backup_dashboard(request: Request, db: Session = Depends(get_db)): + """Backup management dashboard – admin only.""" + try: + records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).limit(500).all() + + # Summarise counts per tier + counts: dict[str, int] = {"hourly": 0, "daily": 0, "weekly": 0} + for r in records: + if r.backup_type in counts: + counts[r.backup_type] += 1 + + # Compute total local size + import os + + total_size = sum(r.size_bytes for r in records if r.local_path and os.path.exists(r.local_path)) + + return templates.TemplateResponse( + "backup.html", + { + "request": request, + "records": records, + "counts": counts, + "total_size": total_size, + "backup_enabled": getattr(settings, "backup_enabled", True), + "backup_remote_destination": getattr(settings, "backup_remote_destination", None), + "backup_retain_hourly": getattr(settings, "backup_retain_hourly", 96), + "backup_retain_daily": getattr(settings, "backup_retain_daily", 21), + "backup_retain_weekly": getattr(settings, "backup_retain_weekly", 13), + "app_version": settings.version, + }, + ) + except Exception as e: + logger.error(f"Error loading backup dashboard: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load backup dashboard", + ) diff --git a/frontend/templates/backup.html b/frontend/templates/backup.html new file mode 100644 index 00000000..cc8f9f3a --- /dev/null +++ b/frontend/templates/backup.html @@ -0,0 +1,366 @@ +{% extends "base.html" %} +{% block title %}Backup Management{% endblock %} + +{% block content %} +
+ + +
+
+

+ + Backup Management +

+

+ Database backups are created automatically: hourly (4 days), daily (3 weeks), weekly (13 weeks). +

+
+
+ + + + + +
+
+ + + + + +
+ +
+

Auto-backup

+

+ {% if backup_enabled %} Enabled + {% else %} Disabled{% endif %} +

+
+ +
+

Remote destination

+

+ {% if backup_remote_destination %} + + {{ backup_remote_destination }} + {% else %} + Local only + {% endif %} +

+
+ +
+

Retention

+

+ {{ backup_retain_hourly }} hourly · + {{ backup_retain_daily }} daily · + {{ backup_retain_weekly }} weekly +

+
+ +
+

Total local size

+

+ {{ (total_size / 1048576) | round(2) }} MB +

+
+
+ + +
+

+ Restore from File +

+

+ Upload a .db.gz backup archive to restore the database. + This will overwrite all current data. +

+
+ +
+
+ + +
+ +
+
+
+ + +
+
+

+ Backup Archives + ({{ records | length }} records) +

+ +
+ + + + +
+
+ + {% if records %} +
+ + + + + + + + + + + + + + {% for r in records %} + + + + + + + + + + {% endfor %} + +
FilenameTypeCreatedSizeStatusStorageActions
+ {{ r.filename }} + + {% if r.backup_type == 'hourly' %} + + hourly + + {% elif r.backup_type == 'daily' %} + + daily + + {% else %} + + weekly + + {% endif %} + + {% if r.created_at %}{{ r.created_at.strftime('%Y-%m-%d %H:%M') }} UTC{% endif %} + + {% if r.size_bytes %} + {{ (r.size_bytes / 1024) | round(1) }} KB + {% else %}—{% endif %} + + {% if r.status == 'ok' %} + + ok + + {% else %} + + {{ r.status }} + + {% endif %} + + {% if r.local_path %} + + local + + {% endif %} + {% if r.remote_destination %} + + {{ r.remote_destination }} + + {% endif %} + {% if not r.local_path and not r.remote_destination %} + + {% endif %} + + {% if r.local_path %} + + Download + + {% endif %} + +
+
+ {% else %} +
+ +

No backups yet.

+

Click Backup Now to create your first backup.

+
+ {% endif %} +
+ + +
+

+ Retention policy +

+
    +
  • Hourly – kept for 4 days ({{ backup_retain_hourly }} snapshots)
  • +
  • Daily – kept for 3 weeks ({{ backup_retain_daily }} snapshots)
  • +
  • Weekly – kept for ~3 months ({{ backup_retain_weekly }} snapshots)
  • +
+

+ Backups beyond these limits are automatically pruned after each new backup is created. + Use the Clean Up button to apply retention manually. +

+
+ +
+ + +{% endblock %} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 6a8e55c2..6fcbfa37 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -160,6 +160,9 @@ Queue Monitor + + Backup & Restore + @@ -298,6 +301,9 @@ Queue Monitor + + Backup & Restore + diff --git a/migrations/versions/021_add_backup_records.py b/migrations/versions/021_add_backup_records.py new file mode 100644 index 00000000..b9f62935 --- /dev/null +++ b/migrations/versions/021_add_backup_records.py @@ -0,0 +1,43 @@ +"""Add backup_records table for database backup tracking + +Revision ID: 021_add_backup_records +Revises: 020_add_subscription_change_pending +Create Date: 2026-03-07 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "021_add_backup_records" +down_revision: Union[str, None] = "020_add_subscription_change_pending" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create backup_records table.""" + op.create_table( + "backup_records", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("local_path", sa.String(1024), nullable=True), + sa.Column("backup_type", sa.String(20), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("checksum", sa.String(64), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="ok"), + sa.Column("remote_destination", sa.String(50), nullable=True), + sa.Column("remote_path", sa.String(1024), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("filename"), + ) + op.create_index("ix_backup_records_backup_type", "backup_records", ["backup_type"]) + op.create_index("ix_backup_records_created_at", "backup_records", ["created_at"]) + + +def downgrade() -> None: + """Drop backup_records table.""" + op.drop_index("ix_backup_records_created_at", "backup_records") + op.drop_index("ix_backup_records_backup_type", "backup_records") + op.drop_table("backup_records") diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 00000000..d4859493 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,621 @@ +""" +Tests for the backup/restore functionality. + +Covers: +- BackupRecord model creation +- backup_tasks: create_backup, cleanup_old_backups, retention helpers +- app/api/backup.py endpoints: list, create, download, restore, delete, cleanup +- app/views/backup.py dashboard view +""" + +import gzip +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.main import app +from app.models import BackupRecord + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def bk_engine(): + """In-memory SQLite engine with all tables for backup tests.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + yield engine + Base.metadata.drop_all(engine) + + +@pytest.fixture() +def admin_client(bk_engine): + """TestClient with admin override for backup routes.""" + from app.api.backup import _require_admin + + def override_db(): + Session = sessionmaker(bind=bk_engine) + session = Session() + try: + yield session + finally: + session.close() + + def override_require_admin(): + return {"email": "admin@test.com", "is_admin": True} + + app.dependency_overrides[get_db] = override_db + app.dependency_overrides[_require_admin] = override_require_admin + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client: + yield client + app.dependency_overrides.clear() + + +@pytest.fixture() +def non_admin_client(bk_engine): + """TestClient without admin override - _require_admin will raise 403.""" + + def override_db(): + Session = sessionmaker(bind=bk_engine) + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = override_db + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client: + yield client + app.dependency_overrides.clear() + + +@pytest.fixture() +def db_session(bk_engine): + """SQLAlchemy session against the in-memory engine.""" + Session = sessionmaker(bind=bk_engine) + session = Session() + yield session + session.close() + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBackupRecordModel: + """Test the BackupRecord SQLAlchemy model.""" + + def test_create_backup_record(self, db_session): + """Test creating a BackupRecord persists correctly.""" + rec = BackupRecord( + filename="backup_daily_2026-01-01T02-30-00.db.gz", + local_path="/tmp/test.db.gz", + backup_type="daily", + size_bytes=2048, + checksum="deadbeef", + status="ok", + ) + db_session.add(rec) + db_session.commit() + db_session.refresh(rec) + + assert rec.id is not None + assert rec.filename == "backup_daily_2026-01-01T02-30-00.db.gz" + assert rec.backup_type == "daily" + assert rec.size_bytes == 2048 + + def test_backup_record_defaults(self, db_session): + """Test default values for BackupRecord fields.""" + rec = BackupRecord( + filename="backup_weekly_2026-01-01T03-00-00.db.gz", + backup_type="weekly", + ) + db_session.add(rec) + db_session.commit() + db_session.refresh(rec) + + assert rec.status == "ok" + assert rec.size_bytes == 0 + assert rec.remote_destination is None + assert rec.remote_path is None + + def test_backup_record_remote_fields(self, db_session): + """Test remote destination fields on BackupRecord.""" + rec = BackupRecord( + filename="backup_hourly_remote.db.gz", + backup_type="hourly", + remote_destination="s3", + remote_path="backups/backup_hourly_remote.db.gz", + ) + db_session.add(rec) + db_session.commit() + db_session.refresh(rec) + + assert rec.remote_destination == "s3" + assert rec.remote_path == "backups/backup_hourly_remote.db.gz" + + +# --------------------------------------------------------------------------- +# Task helper unit tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBackupTaskHelpers: + """Unit tests for backup_tasks helper functions.""" + + def test_backup_dir_creation(self, tmp_path): + """_backup_dir() creates the directory if it does not exist.""" + from app.tasks.backup_tasks import _backup_dir + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_dir = str(tmp_path / "mybkp") + d = _backup_dir() + assert d.exists() + + def test_backup_dir_default(self, tmp_path): + """_backup_dir() defaults to /backups.""" + from app.tasks.backup_tasks import _backup_dir + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_dir = None + mock_settings.workdir = str(tmp_path) + d = _backup_dir() + assert d == tmp_path / "backups" + + def test_sha256(self, tmp_path): + """_sha256() returns a 64-char hex string.""" + from app.tasks.backup_tasks import _sha256 + + f = tmp_path / "test.bin" + f.write_bytes(b"hello world") + digest = _sha256(f) + assert len(digest) == 64 + assert all(c in "0123456789abcdef" for c in digest) + + def test_dump_sqlite(self, tmp_path): + """_dump_sqlite() writes a gzip-compressed SQL dump.""" + from app.tasks.backup_tasks import _dump_sqlite + + src = tmp_path / "src.db" + conn = sqlite3.connect(str(src)) + conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)") + conn.execute("INSERT INTO t VALUES (1, 'hello')") + conn.commit() + conn.close() + + dest = tmp_path / "dump.db.gz" + _dump_sqlite(src, dest) + + assert dest.exists() + with gzip.open(str(dest), "rt") as gz: + content = gz.read() + assert "CREATE TABLE t" in content + assert "hello" in content + + def test_db_path_sqlite(self): + """_db_path() returns path for sqlite:/// URLs.""" + from app.tasks.backup_tasks import _db_path + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.database_url = "sqlite:////tmp/test.db" + result = _db_path() + assert result == Path("/tmp/test.db") + + def test_db_path_memory(self): + """_db_path() returns None for in-memory sqlite.""" + from app.tasks.backup_tasks import _db_path + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.database_url = "sqlite:///:memory:" + result = _db_path() + assert result is None + + def test_db_path_postgres(self): + """_db_path() returns None for non-SQLite databases.""" + from app.tasks.backup_tasks import _db_path + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.database_url = "postgresql://user:pass@localhost/db" + result = _db_path() + assert result is None + + def test_apply_retention_prunes_old(self, tmp_path, db_session): + """_apply_retention() deletes backups beyond the retention limit.""" + from app.tasks.backup_tasks import _apply_retention + + for i in range(5): + f = tmp_path / f"backup_hourly_{i:04d}.db.gz" + f.write_bytes(b"x") + rec = BackupRecord( + filename=f"backup_hourly_{i:04d}.db.gz", + local_path=str(f), + backup_type="hourly", + size_bytes=1, + status="ok", + created_at=datetime(2026, 1, 1, i, 0, 0, tzinfo=timezone.utc), + ) + db_session.add(rec) + db_session.commit() + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_retain_hourly = 3 + _apply_retention("hourly", db_session) + + remaining = db_session.query(BackupRecord).filter_by(backup_type="hourly").all() + remaining_paths = [r.local_path for r in remaining if r.local_path is not None] + assert len(remaining_paths) <= 3 + + def test_apply_retention_removes_record_no_remote(self, tmp_path, db_session): + """_apply_retention() deletes the DB record when no local file or remote copy remain.""" + from app.tasks.backup_tasks import _apply_retention + + # Old record – local file doesn't exist, no remote + old_rec = BackupRecord( + filename="backup_hourly_old.db.gz", + local_path=str(tmp_path / "nonexistent.db.gz"), + backup_type="hourly", + size_bytes=1, + status="ok", + created_at=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + ) + db_session.add(old_rec) + # Two newer records so the old one falls outside retention window + for i, dt in enumerate([datetime(2026, 1, 1), datetime(2026, 1, 2)]): + db_session.add( + BackupRecord( + filename=f"backup_hourly_new_{i}.db.gz", + backup_type="hourly", + size_bytes=1, + status="ok", + created_at=dt.replace(tzinfo=timezone.utc), + ) + ) + db_session.commit() + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_retain_hourly = 2 + _apply_retention("hourly", db_session) + + remaining = db_session.query(BackupRecord).filter_by(filename="backup_hourly_old.db.gz").first() + assert remaining is None + + def test_upload_remote_no_destination(self, tmp_path): + """_upload_remote() returns None when no destination is configured.""" + from app.tasks.backup_tasks import _upload_remote + + f = tmp_path / "bkp.db.gz" + f.write_bytes(b"data") + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_remote_destination = None + result = _upload_remote(f, "bkp.db.gz") + assert result is None + + def test_upload_remote_unknown_dest(self, tmp_path): + """_upload_remote() returns None for an unimplemented destination.""" + from app.tasks.backup_tasks import _upload_remote + + f = tmp_path / "bkp.db.gz" + f.write_bytes(b"data") + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_remote_destination = "unknown_provider" + mock_settings.backup_remote_folder = "backups" + result = _upload_remote(f, "bkp.db.gz") + assert result is None + + def test_delete_remote_copy_no_dest(self): + """_delete_remote_copy() does nothing when rec has no destination.""" + from app.tasks.backup_tasks import _delete_remote_copy + + rec = BackupRecord( + filename="x.db.gz", + backup_type="hourly", + remote_destination=None, + remote_path=None, + ) + _delete_remote_copy(rec) + + +# --------------------------------------------------------------------------- +# Task integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCreateBackupTask: + """Tests for the create_backup Celery task.""" + + def test_backup_disabled(self): + """create_backup returns early when backup_enabled is False.""" + from app.tasks.backup_tasks import create_backup + + with patch("app.tasks.backup_tasks.settings") as mock_settings: + mock_settings.backup_enabled = False + result = create_backup("hourly") + assert result["status"] == "disabled" + + def test_non_sqlite_db(self): + """create_backup returns unsupported_db for non-SQLite databases.""" + from app.tasks.backup_tasks import create_backup + + with ( + patch("app.tasks.backup_tasks.settings") as mock_settings, + patch("app.tasks.backup_tasks._db_path", return_value=None), + ): + mock_settings.backup_enabled = True + result = create_backup("hourly") + assert result["status"] == "unsupported_db" + + def test_missing_db_file(self, tmp_path): + """create_backup returns error when the DB file does not exist.""" + from app.tasks.backup_tasks import create_backup + + missing = tmp_path / "does_not_exist.db" + + with ( + patch("app.tasks.backup_tasks.settings") as mock_settings, + patch("app.tasks.backup_tasks._db_path", return_value=missing), + ): + mock_settings.backup_enabled = True + result = create_backup("hourly") + assert result["status"] == "error" + + def test_successful_backup(self, tmp_path): + """create_backup creates a .db.gz archive and a BackupRecord.""" + from app.tasks.backup_tasks import create_backup + + db_file = tmp_path / "test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)") + conn.close() + + backup_dir = tmp_path / "backups" + + with ( + patch("app.tasks.backup_tasks.settings") as mock_settings, + patch("app.tasks.backup_tasks._db_path", return_value=db_file), + patch("app.tasks.backup_tasks._backup_dir", return_value=backup_dir), + patch("app.tasks.backup_tasks._upload_remote", return_value=None), + patch("app.tasks.backup_tasks._apply_retention"), + patch("app.tasks.backup_tasks._prune_remote_backups"), + patch("app.tasks.backup_tasks.SessionLocal") as mock_sl, + ): + backup_dir.mkdir(parents=True, exist_ok=True) + mock_settings.backup_enabled = True + mock_db = MagicMock() + mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_sl.return_value.__exit__ = MagicMock(return_value=False) + result = create_backup("hourly") + + assert result["status"] == "ok" + assert "filename" in result + assert result["filename"].startswith("backup_hourly_") + + def test_invalid_backup_type_defaults_to_hourly(self, tmp_path): + """create_backup normalises unknown backup_type to 'hourly'.""" + from app.tasks.backup_tasks import create_backup + + db_file = tmp_path / "test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)") + conn.close() + + backup_dir = tmp_path / "backups" + backup_dir.mkdir() + + with ( + patch("app.tasks.backup_tasks.settings") as mock_settings, + patch("app.tasks.backup_tasks._db_path", return_value=db_file), + patch("app.tasks.backup_tasks._backup_dir", return_value=backup_dir), + patch("app.tasks.backup_tasks._upload_remote", return_value=None), + patch("app.tasks.backup_tasks._apply_retention"), + patch("app.tasks.backup_tasks._prune_remote_backups"), + patch("app.tasks.backup_tasks.SessionLocal") as mock_sl, + ): + mock_settings.backup_enabled = True + mock_db = MagicMock() + mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_sl.return_value.__exit__ = MagicMock(return_value=False) + result = create_backup("invalid_type") + + assert result.get("status") == "ok" + assert "hourly" in result["filename"] + + +# --------------------------------------------------------------------------- +# API endpoint tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestBackupAPIEndpoints: + """Tests for /api/admin/backup/* endpoints.""" + + def test_list_backups_admin(self, admin_client): + """GET /api/admin/backup/ returns a list for admin users.""" + resp = admin_client.get("/api/admin/backup/") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + def test_list_backups_non_admin(self, non_admin_client): + """GET /api/admin/backup/ returns 403 for non-admin users.""" + resp = non_admin_client.get("/api/admin/backup/") + assert resp.status_code == 403 + + def test_trigger_backup_admin(self, admin_client): + """POST /api/admin/backup/create queues a backup task.""" + with patch("app.tasks.backup_tasks.create_backup") as mock_task: + mock_result = MagicMock() + mock_result.id = "fake-task-id" + mock_task.delay.return_value = mock_result + resp = admin_client.post("/api/admin/backup/create?backup_type=hourly") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "queued" + assert data["backup_type"] == "hourly" + + def test_trigger_backup_invalid_type(self, admin_client): + """POST /api/admin/backup/create returns 400 for invalid type.""" + resp = admin_client.post("/api/admin/backup/create?backup_type=invalid") + assert resp.status_code == 400 + + def test_trigger_backup_non_admin(self, non_admin_client): + """POST /api/admin/backup/create returns 403 for non-admin.""" + resp = non_admin_client.post("/api/admin/backup/create") + assert resp.status_code == 403 + + def test_download_backup_not_found(self, admin_client): + """GET /api/admin/backup/99999/download returns 404 for unknown ID.""" + resp = admin_client.get("/api/admin/backup/99999/download") + assert resp.status_code == 404 + + def test_download_backup_no_local_file(self, admin_client, bk_engine): + """GET /api/admin/backup/{id}/download returns 404 when file was pruned.""" + Session = sessionmaker(bind=bk_engine) + with Session() as db: + rec = BackupRecord( + filename="backup_hourly_pruned.db.gz", + local_path="/nonexistent/path/file.db.gz", + backup_type="hourly", + size_bytes=0, + status="ok", + ) + db.add(rec) + db.commit() + rid = rec.id + + resp = admin_client.get(f"/api/admin/backup/{rid}/download") + assert resp.status_code == 404 + + def test_delete_backup_admin(self, admin_client, bk_engine): + """DELETE /api/admin/backup/{id} removes the record.""" + Session = sessionmaker(bind=bk_engine) + with Session() as db: + rec = BackupRecord( + filename="backup_hourly_to_delete.db.gz", + backup_type="hourly", + size_bytes=0, + status="ok", + ) + db.add(rec) + db.commit() + rid = rec.id + + resp = admin_client.delete(f"/api/admin/backup/{rid}") + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + + def test_delete_backup_not_found(self, admin_client): + """DELETE /api/admin/backup/99999 returns 404.""" + resp = admin_client.delete("/api/admin/backup/99999") + assert resp.status_code == 404 + + def test_delete_backup_non_admin(self, non_admin_client): + """DELETE /api/admin/backup/1 returns 403 for non-admin.""" + resp = non_admin_client.delete("/api/admin/backup/1") + assert resp.status_code == 403 + + def test_cleanup_endpoint_admin(self, admin_client): + """POST /api/admin/backup/cleanup queues cleanup task.""" + with patch("app.tasks.backup_tasks.cleanup_old_backups") as mock_task: + mock_result = MagicMock() + mock_result.id = "fake-cleanup-id" + mock_task.delay.return_value = mock_result + resp = admin_client.post("/api/admin/backup/cleanup") + assert resp.status_code == 200 + assert resp.json()["status"] == "queued" + + def test_cleanup_endpoint_non_admin(self, non_admin_client): + """POST /api/admin/backup/cleanup returns 403 for non-admin.""" + resp = non_admin_client.post("/api/admin/backup/cleanup") + assert resp.status_code == 403 + + def test_restore_wrong_extension(self, admin_client): + """POST /api/admin/backup/restore rejects non-.db.gz files.""" + resp = admin_client.post( + "/api/admin/backup/restore", + files={"file": ("backup.zip", b"data", "application/zip")}, + ) + assert resp.status_code == 400 + + def test_restore_invalid_gz_content(self, admin_client): + """POST /api/admin/backup/restore rejects corrupt gzip data.""" + resp = admin_client.post( + "/api/admin/backup/restore", + files={"file": ("backup.db.gz", b"not gzip data at all", "application/gzip")}, + ) + assert resp.status_code == 400 + + def test_restore_valid_archive(self, admin_client, tmp_path): + """POST /api/admin/backup/restore succeeds with a valid gzip SQL dump.""" + sql = "BEGIN TRANSACTION;\nCOMMIT;\n" + gz_data = gzip.compress(sql.encode()) + + db_file = tmp_path / "restore_test.db" + conn = sqlite3.connect(str(db_file)) + conn.close() + + with patch("app.tasks.backup_tasks._db_path", return_value=db_file): + resp = admin_client.post( + "/api/admin/backup/restore", + files={"file": ("backup.db.gz", gz_data, "application/gzip")}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "restored" + + def test_restore_non_sqlite_db(self, admin_client): + """POST /api/admin/backup/restore returns 400 for non-SQLite database.""" + sql = "BEGIN TRANSACTION;\nCOMMIT;\n" + gz_data = gzip.compress(sql.encode()) + + with patch("app.tasks.backup_tasks._db_path", return_value=None): + resp = admin_client.post( + "/api/admin/backup/restore", + files={"file": ("backup.db.gz", gz_data, "application/gzip")}, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# View tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestBackupView: + """Tests for the /admin/backup dashboard view.""" + + def test_backup_dashboard_admin(self, admin_client): + """GET /admin/backup returns 200 for admin users.""" + resp = admin_client.get("/admin/backup") + assert resp.status_code == 200 + assert b"Backup" in resp.content + + def test_backup_dashboard_non_admin_redirect(self, non_admin_client): + """GET /admin/backup redirects non-admin users.""" + resp = non_admin_client.get("/admin/backup", follow_redirects=False) + assert resp.status_code in (302, 303) + + def test_backup_dashboard_unauthenticated(self): + """GET /admin/backup redirects unauthenticated users.""" + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client: + resp = client.get("/admin/backup", follow_redirects=False) + assert resp.status_code in (302, 303)