From 6a239314dc9474933b21438a20addd52ef751dec Mon Sep 17 00:00:00 2001 From: jmoore-skild Date: Mon, 27 Jul 2026 14:43:21 -0400 Subject: [PATCH] feat(backup): restore selected categories from a Git backup commit (#2656) The Git backup feature was push-only: there was no equivalent of the local backup's Restore button, so recovering meant hand-downloading JSON files from the repository. This adds the read side. Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit that unchanged; GitLab overrides for its own REST shape, including tree pagination and subgroup path encoding. fetch_files is batched so the path -> blob SHA lookup happens once per restore rather than once per file, and uses the blobs API rather than contents because contents silently inlines only the first 1 MB. The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a preview and the restore that follows act on the same commit even if a scheduled backup lands in between. Categories are applied archives -> spools -> settings -> kprofiles: archives first because spool usage history references archive_id, K-profiles last because they leave the database and publish over MQTT. Restores never reuse the backup's primary keys. spool.id and print_archives.id are bare autoincrement columns, so ids from an old backup very likely belong to unrelated rows today; rows are matched on natural keys (tag_uid, then tray_uuid, then a descriptive composite for spools; content_hash or filename plus started_at for archives), inserted without an explicit id, and an old_id -> new_id map rewrites the foreign keys in spool usage history. created_at is carried across on insert so restoring the same backup twice matches instead of duplicating. Dangling printer/project links are cleared and reported rather than failing the row. Settings restore re-applies the collector's credential denylist on the read side, plus a pattern guard, because a backup taken before that denylist existed can still contain secrets. Restored archives are metadata-only: the 3MF and thumbnail bytes are not in a Git backup and print_archives.file_path is NOT NULL, so inserted rows get an empty path and the UI says so. Backup and restore take a mutex against each other; both write the same tables and talk to the same printers. Restores are logged as GitHubBackupLog rows with trigger="restore", which needs no migration and surfaces them in the existing History card. Cloud profiles are deliberately not a restore category. The collector never actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu Cloud API does not return - and the preset list it would write carries no setting payload. Filed separately. Permission github:restore already existed and is granted to Administrators, so no permission changes were needed. Tests: 125 new backend tests (provider reads across all four providers, the per-category appliers, the API endpoints) and 13 frontend tests. Full suites pass with no regressions; the 35 backend failures on Windows are byte-identical with and without this branch. --- backend/app/api/routes/github_backup.py | 70 +- backend/app/schemas/github_backup.py | 102 ++ backend/app/services/git_providers/base.py | 58 ++ backend/app/services/git_providers/github.py | 208 ++++ backend/app/services/git_providers/gitlab.py | 195 ++++ backend/app/services/github_backup.py | 29 + backend/app/services/github_restore.py | 923 +++++++++++++++++ .../integration/test_github_restore_api.py | 276 +++++ .../tests/unit/test_git_providers_restore.py | 431 ++++++++ backend/tests/unit/test_github_restore.py | 946 ++++++++++++++++++ .../components/GitHubRestoreModal.test.tsx | 367 +++++++ frontend/src/api/client.ts | 69 ++ .../src/components/GitHubBackupSettings.tsx | 17 + .../src/components/GitHubRestoreModal.tsx | 431 ++++++++ frontend/src/i18n/locales/de.ts | 24 + frontend/src/i18n/locales/en.ts | 24 + frontend/src/i18n/locales/es.ts | 24 + frontend/src/i18n/locales/fr.ts | 24 + frontend/src/i18n/locales/it.ts | 24 + frontend/src/i18n/locales/ja.ts | 24 + frontend/src/i18n/locales/ko.ts | 24 + frontend/src/i18n/locales/pt-BR.ts | 24 + frontend/src/i18n/locales/ru.ts | 24 + frontend/src/i18n/locales/tr.ts | 24 + frontend/src/i18n/locales/zh-CN.ts | 24 + frontend/src/i18n/locales/zh-TW.ts | 24 + 26 files changed, 4409 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/github_restore.py create mode 100644 backend/tests/integration/test_github_restore_api.py create mode 100644 backend/tests/unit/test_git_providers_restore.py create mode 100644 backend/tests/unit/test_github_restore.py create mode 100644 frontend/src/__tests__/components/GitHubRestoreModal.test.tsx create mode 100644 frontend/src/components/GitHubRestoreModal.tsx diff --git a/backend/app/api/routes/github_backup.py b/backend/app/api/routes/github_backup.py index d3165ba03..ed3d699da 100644 --- a/backend/app/api/routes/github_backup.py +++ b/backend/app/api/routes/github_backup.py @@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog from backend.app.models.user import User from backend.app.schemas.github_backup import ( + REF_PATTERN, CloudAccountCounts, GitHubBackupConfigCreate, GitHubBackupConfigResponse, @@ -19,10 +20,15 @@ from backend.app.schemas.github_backup import ( GitHubBackupLogResponse, GitHubBackupStatus, GitHubBackupTriggerResponse, + GitHubCommitListResponse, + GitHubRestorePreview, + GitHubRestoreRequest, + GitHubRestoreResponse, GitHubTestConnectionResponse, ProviderType, ) from backend.app.services.github_backup import github_backup_service +from backend.app.services.github_restore import github_restore_service logger = logging.getLogger(__name__) @@ -388,13 +394,75 @@ async def get_status( configured=True, enabled=config.enabled, is_running=github_backup_service.is_running, - progress=github_backup_service.progress, + restore_running=github_restore_service.is_running, + progress=github_backup_service.progress or github_restore_service.progress, last_backup_at=config.last_backup_at, last_backup_status=config.last_backup_status, next_scheduled_run=config.next_scheduled_run, ) +@router.get("/commits", response_model=GitHubCommitListResponse) +async def list_commits( + limit: int = Query(default=20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE), +): + """List recent backup commits so the user can pick one to restore from.""" + result = await db.execute(select(GitHubBackupConfig).limit(1)) + config = result.scalar_one_or_none() + + if not config: + raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.") + + commit_result = await github_restore_service.list_commits(config, limit=limit) + return GitHubCommitListResponse(**commit_result) + + +@router.get("/restore/preview", response_model=GitHubRestorePreview) +async def preview_restore( + ref: str = Query(default="HEAD", pattern=REF_PATTERN), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE), +): + """Report which categories a given backup commit contains.""" + result = await db.execute(select(GitHubBackupConfig).limit(1)) + config = result.scalar_one_or_none() + + if not config: + raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.") + + preview = await github_restore_service.preview(config, ref=ref) + return GitHubRestorePreview(**preview) + + +@router.post("/restore", response_model=GitHubRestoreResponse) +async def restore_backup( + request: GitHubRestoreRequest, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE), +): + """Restore selected categories from one backup commit. + + Note there is no private-repo gate here, unlike the config endpoints: that + check exists to stop credentials leaving the instance, and this path only + reads. A config can only be saved against a private repo anyway. + """ + result = await db.execute(select(GitHubBackupConfig).limit(1)) + config = result.scalar_one_or_none() + + if not config: + raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.") + + restore_result = await github_restore_service.run_restore( + config.id, + ref=request.ref, + categories=request.categories, + overwrite_existing=request.overwrite_existing, + ) + return GitHubRestoreResponse(**restore_result) + + @router.get("/logs", response_model=list[GitHubBackupLogResponse]) async def get_logs( limit: int = Query(default=50, ge=1, le=200), diff --git a/backend/app/schemas/github_backup.py b/backend/app/schemas/github_backup.py index 49d053623..7ee0cb2c5 100644 --- a/backend/app/schemas/github_backup.py +++ b/backend/app/schemas/github_backup.py @@ -176,6 +176,7 @@ class GitHubBackupStatus(BaseModel): configured: bool = Field(description="Whether backup is configured") enabled: bool = Field(description="Whether backup is enabled") is_running: bool = Field(description="Whether a backup is currently running") + restore_running: bool = Field(default=False, description="Whether a restore is currently running") progress: str | None = Field(default=None, description="Current backup progress message") last_backup_at: datetime | None last_backup_status: str | None @@ -204,3 +205,104 @@ class GitHubBackupTriggerResponse(BaseModel): log_id: int | None = None commit_sha: str | None = None files_changed: int = 0 + + +# --- Restore (issue #2656) -------------------------------------------------- + +# "HEAD" means "whatever the branch tip is right now"; the service resolves it +# to a concrete SHA before reading anything so preview and apply can't straddle +# two different commits. Anything else must look like a git object name. +REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$" + + +class RestoreCategory(StrEnum): + """Backup categories that can be restored. + + Cloud profiles are deliberately absent: the backup collector never actually + writes ``cloud_profiles/*.json`` (it reads a "setting" list key the Bambu + Cloud API does not return), and the preset list it would collect carries no + setting payload to restore from. Tracked separately from #2656. + """ + + KPROFILES = "kprofiles" + SETTINGS = "settings" + SPOOLS = "spools" + ARCHIVES = "archives" + + +class GitHubCommitInfo(BaseModel): + """One commit in the backup repository.""" + + sha: str + message: str + author: str + date: str + + +class GitHubCommitListResponse(BaseModel): + """Schema for the commit picker.""" + + success: bool + message: str + branch: str + commits: list[GitHubCommitInfo] = Field(default_factory=list) + + +class GitHubRestorePreviewCategory(BaseModel): + """What a single category looks like inside one backup commit.""" + + category: RestoreCategory + available: bool = Field(description="Whether this category is present in the commit") + item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable") + detail: str | None = Field(default=None, description="Why unavailable, or extra context") + + +class GitHubRestorePreview(BaseModel): + """Schema for inspecting a commit before restoring from it.""" + + success: bool + message: str + ref: str = Field(description="The concrete commit SHA that was inspected") + commit: GitHubCommitInfo | None = None + metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json") + categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list) + + +class GitHubRestoreRequest(BaseModel): + """Schema for triggering a restore.""" + + ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD") + categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore") + overwrite_existing: bool = Field( + default=False, + description="Update rows that already exist locally. When false, only missing rows are inserted.", + ) + + @model_validator(mode="after") + def deduplicate_categories(self) -> "GitHubRestoreRequest": + # Same category twice would double-count the result totals. + seen: list[RestoreCategory] = [] + for category in self.categories: + if category not in seen: + seen.append(category) + self.categories = seen + return self + + +class GitHubRestoreCategoryResult(BaseModel): + """Per-category outcome of a restore.""" + + restored: int = 0 + skipped: int = 0 + failed: int = 0 + notes: list[str] = Field(default_factory=list) + + +class GitHubRestoreResponse(BaseModel): + """Schema for the restore result.""" + + success: bool + message: str + log_id: int | None = None + ref: str | None = Field(default=None, description="The concrete commit SHA restored from") + results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict) diff --git a/backend/app/services/git_providers/base.py b/backend/app/services/git_providers/base.py index 2aea4371b..cb5f89744 100644 --- a/backend/app/services/git_providers/base.py +++ b/backend/app/services/git_providers/base.py @@ -76,3 +76,61 @@ class GitProviderBackend(ABC): client: httpx.AsyncClient, ) -> dict: """Push files to the repository. Returns status/message/commit_sha/files_changed.""" + + # --- Read side (restore, issue #2656) --------------------------------- + # The backup path only ever writes. Restore needs to walk history, list a + # snapshot and read individual blobs back, so these three mirror the + # ``{"success": bool, "message": str, ...}`` convention ``test_connection`` + # already uses rather than raising. + + @abstractmethod + async def list_commits( + self, + repo_url: str, + token: str, + branch: str, + client: httpx.AsyncClient, + limit: int = 20, + ) -> dict: + """List recent commits on ``branch``, newest first. + + Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``. + """ + + @abstractmethod + async def list_tree( + self, + repo_url: str, + token: str, + ref: str, + client: httpx.AsyncClient, + ) -> dict: + """List every blob path present at ``ref``. + + ``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA + via :meth:`list_commits` first, so the snapshot being previewed and the + one being restored are provably the same commit even if a scheduled + backup lands in between. + + Returns ``{"success", "message", "paths": [str]}``. + """ + + @abstractmethod + async def fetch_files( + self, + repo_url: str, + token: str, + ref: str, + paths: list[str], + client: httpx.AsyncClient, + ) -> dict: + """Read several files' decoded UTF-8 text at ``ref``. + + Batched rather than one-file-at-a-time so providers that need a tree + listing to map path -> blob SHA can do that lookup once for the whole + restore instead of per file. + + Returns ``{"success", "message", "files": {path: text}}``. Paths absent + from the commit are simply missing from ``files`` — that is not an error, + since which categories a given backup contains varies by config. + """ diff --git a/backend/app/services/git_providers/github.py b/backend/app/services/git_providers/github.py index c84620e65..dd0e99a82 100644 --- a/backend/app/services/git_providers/github.py +++ b/backend/app/services/git_providers/github.py @@ -115,6 +115,214 @@ class GitHubBackend(GitProviderBackend): "is_private": None, } + async def list_commits( + self, + repo_url: str, + token: str, + branch: str, + client: httpx.AsyncClient, + limit: int = 20, + ) -> dict: + """List recent commits on ``branch`` via the repo commits API.""" + try: + owner, repo = self.parse_repo_url(repo_url) + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + + # GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo + # do the reverse. Sending both lets GiteaBackend inherit this method + # unchanged instead of duplicating it for one query parameter. + response = await client.get( + f"{api_base}/repos/{owner}/{repo}/commits", + headers=headers, + params={"sha": branch, "per_page": limit, "limit": limit}, + ) + + if response.status_code == 404: + return { + "success": False, + "message": ( + f"Branch '{branch}' not found, or the repository has no commits yet. " + "Run a backup before restoring." + ), + "commits": [], + } + if response.status_code != 200: + msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}" + logger.warning("list_commits %s/%s: %s", owner, repo, msg) + return {"success": False, "message": msg, "commits": []} + + try: + data = response.json() + except ValueError: + return {"success": False, "message": "Non-JSON response listing commits", "commits": []} + if not isinstance(data, list): + return {"success": False, "message": "Unexpected shape listing commits", "commits": []} + + return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)} + + except Exception as e: + logger.exception("list_commits failed for %s branch=%s", repo_url, branch) + return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []} + + @staticmethod + def _parse_commit_entries(data: list, limit: int) -> list[dict]: + """Normalise GitHub/Gitea commit list entries to our flat shape.""" + commits = [] + for entry in data[:limit]: + if not isinstance(entry, dict): + continue + sha = entry.get("sha") + if not isinstance(sha, str) or not sha: + continue + commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {} + author = commit.get("author") if isinstance(commit.get("author"), dict) else {} + commits.append( + { + "sha": sha, + "message": commit.get("message") or "", + "author": author.get("name") or "", + "date": author.get("date") or "", + } + ) + return commits + + async def _blob_shas_at( + self, + client: httpx.AsyncClient, + headers: dict, + api_base: str, + owner: str, + repo: str, + ref: str, + ) -> tuple[dict[str, str] | None, str]: + """Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``. + + A commit SHA is a valid tree-ish for the trees API, so this resolves the + commit's tree in one request rather than commit -> tree -> list. + """ + response = await client.get( + f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1", + headers=headers, + ) + if response.status_code == 404: + return None, f"Commit or tree '{ref}' not found in the repository" + if response.status_code != 200: + return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}" + try: + data = response.json() + except ValueError: + return None, "Non-JSON response listing tree" + # Same limit the push path guards against: a truncated listing would make + # a restore silently skip categories that are actually in the backup. + if data.get("truncated"): + return None, ( + "Repository tree exceeds the API listing limit (truncated=true), so the backup " + "contents cannot be enumerated reliably. Rotate the backup repository." + ) + blobs: dict[str, str] = {} + for item in data.get("tree", []): + if not isinstance(item, dict) or item.get("type") != "blob": + continue + path, sha = item.get("path"), item.get("sha") + if isinstance(path, str) and isinstance(sha, str) and path and sha: + blobs[path] = sha + return blobs, "" + + async def list_tree( + self, + repo_url: str, + token: str, + ref: str, + client: httpx.AsyncClient, + ) -> dict: + """List blob paths present at ``ref`` via the Git Data trees API.""" + try: + owner, repo = self.parse_repo_url(repo_url) + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + + blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref) + if blobs is None: + logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error) + return {"success": False, "message": error, "paths": []} + + return {"success": True, "message": "OK", "paths": sorted(blobs)} + + except Exception as e: + logger.exception("list_tree failed for %s ref=%s", repo_url, ref) + return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": []} + + async def fetch_files( + self, + repo_url: str, + token: str, + ref: str, + paths: list[str], + client: httpx.AsyncClient, + ) -> dict: + """Read ``paths`` at ``ref`` via the Git Data blobs API. + + The blobs API is used rather than the contents API because contents + inlines only files up to 1 MB — an archive-heavy ``print_history.json`` + can exceed that, and it would come back with an empty body instead of an + error. + """ + try: + owner, repo = self.parse_repo_url(repo_url) + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + + blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref) + if blobs is None: + logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error) + return {"success": False, "message": error, "files": {}} + + files: dict[str, str] = {} + for path in paths: + sha = blobs.get(path) + if sha is None: + continue + response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers) + if response.status_code != 200: + msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}" + logger.warning("fetch_files %s/%s: %s", owner, repo, msg) + return {"success": False, "message": msg, "files": {}} + text, error = self._decode_blob(response, path) + if text is None: + logger.warning("fetch_files %s/%s: %s", owner, repo, error) + return {"success": False, "message": error, "files": {}} + files[path] = text + + return {"success": True, "message": "OK", "files": files} + + except Exception as e: + logger.exception("fetch_files failed for %s ref=%s", repo_url, ref) + return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}} + + def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]: + """Decode a blob API response body to text, or return an error message.""" + try: + data = response.json() + except ValueError: + return None, f"Non-JSON response reading {path}" + if not isinstance(data, dict): + return None, f"Unexpected shape reading {path}" + content = data.get("content") + if not isinstance(content, str): + return None, f"Missing content reading {path}" + encoding = data.get("encoding", "base64") + try: + if encoding == "base64": + # Both providers wrap base64 payloads at 60 chars; b64decode + # tolerates the newlines, but be explicit about it. + return base64.b64decode(content).decode("utf-8"), "" + if encoding in ("utf-8", "text", "plain"): + return content, "" + except (ValueError, UnicodeDecodeError) as e: + return None, f"Could not decode {path}: {type(e).__name__}" + return None, f"Unsupported blob encoding {encoding!r} reading {path}" + async def push_files( self, repo_url: str, diff --git a/backend/app/services/git_providers/gitlab.py b/backend/app/services/git_providers/gitlab.py index 6930c9b72..e4ee61eb7 100644 --- a/backend/app/services/git_providers/gitlab.py +++ b/backend/app/services/git_providers/gitlab.py @@ -115,6 +115,201 @@ class GitLabBackend(GitProviderBackend): "is_private": None, } + def _encoded_project(self, repo_url: str) -> str: + """Return the URL-encoded ``namespace/project`` path for /api/v4/projects/.""" + owner, repo = self.parse_repo_url(repo_url) + return urllib.parse.quote(f"{owner}/{repo}", safe="") + + async def list_commits( + self, + repo_url: str, + token: str, + branch: str, + client: httpx.AsyncClient, + limit: int = 20, + ) -> dict: + """List recent commits on ``branch`` via /repository/commits.""" + try: + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + encoded_path = self._encoded_project(repo_url) + + response = await client.get( + f"{api_base}/projects/{encoded_path}/repository/commits", + headers=headers, + params={"ref_name": branch, "per_page": limit}, + ) + + if response.status_code == 404: + return { + "success": False, + "message": ( + f"Branch '{branch}' not found, or the repository has no commits yet. " + "Run a backup before restoring." + ), + "commits": [], + } + if response.status_code != 200: + msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}" + logger.warning("list_commits %s: %s", repo_url, msg) + return {"success": False, "message": msg, "commits": []} + + try: + data = response.json() + except ValueError: + return {"success": False, "message": "Non-JSON response listing commits", "commits": []} + if not isinstance(data, list): + return {"success": False, "message": "Unexpected shape listing commits", "commits": []} + + commits = [] + for entry in data[:limit]: + if not isinstance(entry, dict): + continue + sha = entry.get("id") + if not isinstance(sha, str) or not sha: + continue + # GitLab flattens author/date onto the commit itself rather than + # nesting them under "commit" the way GitHub does. + commits.append( + { + "sha": sha, + "message": entry.get("message") or "", + "author": entry.get("author_name") or "", + "date": entry.get("committed_date") or entry.get("created_at") or "", + } + ) + + return {"success": True, "message": "OK", "commits": commits} + + except Exception as e: + logger.exception("list_commits failed for %s branch=%s", repo_url, branch) + return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []} + + async def list_tree( + self, + repo_url: str, + token: str, + ref: str, + client: httpx.AsyncClient, + ) -> dict: + """List blob paths at ``ref`` via /repository/tree, following pagination.""" + try: + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + encoded_path = self._encoded_project(repo_url) + + paths: list[str] = [] + page = 1 + # GitLab's tree endpoint paginates instead of exposing a "truncated" + # flag, so walk pages until one comes back short. The page cap stops + # a malformed X-Next-Page loop from spinning forever. + while page <= 50: + response = await client.get( + f"{api_base}/projects/{encoded_path}/repository/tree", + headers=headers, + params={"ref": ref, "recursive": "true", "per_page": 100, "page": page}, + ) + if response.status_code == 404: + return { + "success": False, + "message": f"Commit or tree '{ref}' not found in the repository", + "paths": [], + } + if response.status_code != 200: + msg = ( + f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}" + ) + logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg) + return {"success": False, "message": msg, "paths": []} + + try: + data = response.json() + except ValueError: + return {"success": False, "message": "Non-JSON response listing tree", "paths": []} + if not isinstance(data, list): + return {"success": False, "message": "Unexpected shape listing tree", "paths": []} + + for item in data: + if isinstance(item, dict) and item.get("type") == "blob": + path = item.get("path") + if isinstance(path, str) and path: + paths.append(path) + + if len(data) < 100: + break + page += 1 + + return {"success": True, "message": "OK", "paths": sorted(paths)} + + except Exception as e: + logger.exception("list_tree failed for %s ref=%s", repo_url, ref) + return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": []} + + async def fetch_files( + self, + repo_url: str, + token: str, + ref: str, + paths: list[str], + client: httpx.AsyncClient, + ) -> dict: + """Read ``paths`` at ``ref`` via /repository/files/{path}.""" + try: + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + encoded_path = self._encoded_project(repo_url) + + files: dict[str, str] = {} + for path in paths: + encoded_file = urllib.parse.quote(path, safe="") + response = await client.get( + f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}", + headers=headers, + params={"ref": ref}, + ) + # A path absent from this commit is expected — which categories a + # backup contains varies by config — so skip rather than fail. + if response.status_code == 404: + continue + if response.status_code != 200: + msg = ( + f"Failed to read {path} (HTTP {response.status_code}): " + f"{self._truncated_response_text(response)}" + ) + logger.warning("fetch_files %s: %s", repo_url, msg) + return {"success": False, "message": msg, "files": {}} + + try: + data = response.json() + except ValueError: + return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}} + if not isinstance(data, dict): + return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}} + + content = data.get("content") + if not isinstance(content, str): + return {"success": False, "message": f"Missing content reading {path}", "files": {}} + encoding = data.get("encoding", "base64") + try: + if encoding == "base64": + files[path] = base64.b64decode(content).decode("utf-8") + elif encoding in ("text", "utf-8", "plain"): + files[path] = content + else: + return { + "success": False, + "message": f"Unsupported encoding {encoding!r} reading {path}", + "files": {}, + } + except (ValueError, UnicodeDecodeError) as e: + return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}} + + return {"success": True, "message": "OK", "files": files} + + except Exception as e: + logger.exception("fetch_files failed for %s ref=%s", repo_url, ref) + return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}} + async def push_files( self, repo_url: str, diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index af0f2043e..26d8632ab 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -173,9 +173,32 @@ class GitHubBackupService: Returns: dict with success, message, log_id, commit_sha, files_changed """ + # Everything from here to `self._running_backup = True` must stay + # await-free. Both flags are plain bools and both callers are coroutines + # on one event loop, so with no suspension point in between the loop + # cannot run the restore service's mirror-image region (see + # github_restore.run_restore) in the gap — whichever gets here first sets + # its flag before the other can read it. Adding an `await` inside this + # block reintroduces the check-then-set race and lets a backup and a + # restore run at once. if self._running_backup: return {"success": False, "message": "A backup is already running", "log_id": None} + # Imported locally to avoid a module-level import cycle — the restore + # service imports this module's singleton to take the mirror-image lock. + # A restore rewrites the same tables this collector reads and publishes + # K-profiles to the same printers, so the two must not interleave. + # (A local `import` of an already-loaded module is not a suspension + # point, so it does not break the await-free rule above.) + from backend.app.services.github_restore import github_restore_service + + if github_restore_service.is_running: + return { + "success": False, + "message": "A restore is currently running. Wait for it to finish before backing up.", + "log_id": None, + } + self._running_backup = True log_id = None @@ -840,6 +863,12 @@ class GitHubBackupService: "energy_kwh": a.energy_kwh, "energy_cost": a.energy_cost, "created_at": str(a.created_at) if a.created_at else None, + # Soft-deleted archives are collected too — their row is kept on + # purpose so the stats endpoint keeps counting their filament and + # energy (see archive_service.soft_delete_archive). Recording + # deleted_at is what lets a restore put them back the way they + # were instead of resurrecting them as visible archives. + "deleted_at": str(a.deleted_at) if a.deleted_at else None, } archive_list.append(archive_data) diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py new file mode 100644 index 000000000..13b0dd405 --- /dev/null +++ b/backend/app/services/github_restore.py @@ -0,0 +1,923 @@ +"""Restore Bambuddy data from a Git provider backup (issue #2656). + +The backup side (``github_backup.py``) is push-only: it collects a handful of +JSON documents and commits them. This module is the read side — it walks the +backup repository's history, lets a caller inspect what a given commit contains, +and applies selected categories back into the local database (or, for +K-profiles, back onto the printers). + +Design notes worth knowing before editing: + +* **A restore never reuses the backup's primary keys.** ``spool.id`` and + ``print_archives.id`` are bare autoincrement columns, so the ids in a backup + taken weeks ago very likely belong to unrelated rows today. Rows are matched + on natural keys instead, inserted without an explicit id, and an + ``old_id -> new_id`` map is threaded through so foreign keys in dependent + tables (spool usage history) still line up. +* **Categories are applied archives -> spools -> settings -> kprofiles.** + Archives first because spool usage history references ``archive_id``; + K-profiles last because they leave the database and talk to hardware. +* **Cloud profiles are not restorable.** The backup collector never actually + writes ``cloud_profiles/*.json``, and the preset list it would write carries + no setting payload. Tracked separately from #2656. +""" + +import asyncio +import json +import logging +import re +from datetime import datetime, timezone + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.database import async_session +from backend.app.models.archive import PrintArchive +from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog +from backend.app.models.printer import Printer +from backend.app.models.project import Project +from backend.app.models.settings import Settings +from backend.app.models.spool import Spool +from backend.app.models.spool_usage_history import SpoolUsageHistory +from backend.app.schemas.github_backup import RestoreCategory +from backend.app.services.git_providers.factory import get_provider_backend +from backend.app.services.printer_manager import printer_manager + +logger = logging.getLogger(__name__) + +METADATA_PATH = "backup_metadata.json" +SETTINGS_PATH = "settings/app_settings.json" +SPOOLS_PATH = "spools/inventory.json" +SPOOL_USAGE_PATH = "spools/usage_history.json" +ARCHIVES_PATH = "archives/print_history.json" + +# kprofiles/{printer_serial}/{nozzle_diameter}.json +_KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$") + +# Settings keys the backup collector already refuses to write. Applied again on +# the read side because a backup taken before that denylist existed can still +# contain them, and a restore must not resurrect a stale credential. +_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"} + +# Belt-and-braces for the same reason: any key that looks like a secret is +# skipped even if it isn't in the explicit denylist above. +_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase") + +# Nozzle diameters the backup collector iterates. A path outside this set means +# the backup was written by a newer version, so accept it rather than dropping +# data, but keep the list for validation messages. +_KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"} + + +def _parse_dt(value) -> datetime | None: + """Best-effort parse of a datetime the backup wrote via ``str(...)``.""" + if not value or not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def _is_blocked_setting_key(key: str) -> bool: + lowered = key.lower() + return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS) + + +class _CategoryTally: + """Mutable accumulator matching ``GitHubRestoreCategoryResult``.""" + + def __init__(self) -> None: + self.restored = 0 + self.skipped = 0 + self.failed = 0 + self.notes: list[str] = [] + + def note(self, message: str) -> None: + # Notes are surfaced verbatim in the UI, so keep the list bounded rather + # than emitting one line per row for a large backup. + if message not in self.notes and len(self.notes) < 20: + self.notes.append(message) + + def as_dict(self) -> dict: + return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes} + + +class GitHubRestoreService: + """Reads a backup repository and applies selected categories locally.""" + + def __init__(self) -> None: + self._running_restore: bool = False + self._progress: str | None = None + self._http_client: httpx.AsyncClient | None = None + # Guards the check-then-set on ``_running_restore``. Without it two + # concurrent POSTs can both observe False before either sets it. + self._lock = asyncio.Lock() + + async def _get_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient(timeout=60.0) + return self._http_client + + @property + def is_running(self) -> bool: + return self._running_restore + + @property + def progress(self) -> str | None: + return self._progress + + # --- Repository reads -------------------------------------------------- + + async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict: + """List recent commits on the configured branch.""" + backend = get_provider_backend(config.provider) + client = await self._get_client() + result = await backend.list_commits( + repo_url=config.repository_url, + token=config.access_token, + branch=config.branch, + client=client, + limit=limit, + ) + result["branch"] = config.branch + return result + + async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str]: + """Turn ``HEAD`` into a concrete commit SHA. + + Done once up front so a preview and the restore that follows it act on + the same commit even if a scheduled backup lands in between. + """ + if ref and ref.upper() != "HEAD": + return ref, "" + result = await self.list_commits(config, limit=1) + if not result.get("success"): + return None, result.get("message") or "Could not read the backup repository" + commits = result.get("commits") or [] + if not commits: + return None, f"Branch '{config.branch}' has no commits to restore from" + return commits[0]["sha"], "" + + def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]: + """Return the paths in ``available`` that belong to ``category``.""" + if category == RestoreCategory.SETTINGS: + return [p for p in (SETTINGS_PATH,) if p in available] + if category == RestoreCategory.SPOOLS: + return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available] + if category == RestoreCategory.ARCHIVES: + return [p for p in (ARCHIVES_PATH,) if p in available] + if category == RestoreCategory.KPROFILES: + return sorted(p for p in available if _KPROFILE_PATH_RE.match(p)) + return [] + + @staticmethod + def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]: + """Parse each fetched file, collecting paths that failed to parse.""" + parsed: dict[str, object] = {} + bad: list[str] = [] + for path, text in raw.items(): + try: + parsed[path] = json.loads(text) + except (ValueError, TypeError): + bad.append(path) + return parsed, bad + + async def preview(self, config: GitHubBackupConfig, ref: str = "HEAD") -> dict: + """Report which categories a commit contains, and how much is in each.""" + resolved, error = await self._resolve_ref(config, ref) + if resolved is None: + return {"success": False, "message": error, "ref": ref, "categories": []} + + backend = get_provider_backend(config.provider) + client = await self._get_client() + + tree = await backend.list_tree( + repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client + ) + if not tree.get("success"): + return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved} + available: list[str] = tree.get("paths") or [] + + # One batched read covers metadata plus every category payload. + wanted = [METADATA_PATH] if METADATA_PATH in available else [] + for category in RestoreCategory: + wanted.extend(self._category_paths(category, available)) + + fetched = await backend.fetch_files( + repo_url=config.repository_url, token=config.access_token, ref=resolved, paths=wanted, client=client + ) + if not fetched.get("success"): + return { + "success": False, + "message": fetched.get("message") or "Could not read the commit contents", + "ref": resolved, + } + parsed, bad_paths = self._parse_json_files(fetched.get("files") or {}) + + metadata = parsed.get(METADATA_PATH) + metadata_version = metadata.get("version") if isinstance(metadata, dict) else None + + categories = [] + for category in RestoreCategory: + paths = self._category_paths(category, available) + if not paths: + categories.append( + { + "category": category, + "available": False, + "item_count": 0, + "detail": "Not present in this backup commit", + } + ) + continue + unreadable = [p for p in paths if p in bad_paths] + if unreadable: + categories.append( + { + "category": category, + "available": False, + "item_count": 0, + "detail": f"Unreadable JSON: {', '.join(unreadable)}", + } + ) + continue + count, detail = self._count_items(category, parsed) + categories.append({"category": category, "available": True, "item_count": count, "detail": detail}) + + commit_info = None + commits = (await self.list_commits(config, limit=20)).get("commits") or [] + for entry in commits: + if entry["sha"] == resolved: + commit_info = entry + break + + return { + "success": True, + "message": "OK", + "ref": resolved, + "commit": commit_info, + "metadata_version": metadata_version, + "categories": categories, + } + + @staticmethod + def _count_items(category: RestoreCategory, parsed: dict) -> tuple[int, str | None]: + """Count restorable items for ``category`` and describe any caveat.""" + if category == RestoreCategory.SETTINGS: + payload = parsed.get(SETTINGS_PATH) + values = payload.get("settings") if isinstance(payload, dict) else None + if not isinstance(values, dict): + return 0, "No settings in payload" + blocked = sum(1 for key in values if _is_blocked_setting_key(key)) + detail = f"{blocked} credential-like keys will be skipped" if blocked else None + return len(values) - blocked, detail + + if category == RestoreCategory.SPOOLS: + payload = parsed.get(SPOOLS_PATH) + spools = payload.get("spools") if isinstance(payload, dict) else None + usage_payload = parsed.get(SPOOL_USAGE_PATH) + usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None + count = len(spools) if isinstance(spools, list) else 0 + detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None + return count, detail + + if category == RestoreCategory.ARCHIVES: + payload = parsed.get(ARCHIVES_PATH) + archives = payload.get("archives") if isinstance(payload, dict) else None + count = len(archives) if isinstance(archives, list) else 0 + return count, "Metadata only — 3MF files and thumbnails are not in a Git backup" + + if category == RestoreCategory.KPROFILES: + total = 0 + serials = set() + for path, payload in parsed.items(): + match = _KPROFILE_PATH_RE.match(path) + if not match or not isinstance(payload, dict): + continue + serials.add(match.group(1)) + profiles = payload.get("profiles") + if isinstance(profiles, list): + total += len(profiles) + detail = f"across {len(serials)} printer(s)" if serials else None + return total, detail + + return 0, None + + # --- Restore ----------------------------------------------------------- + + async def run_restore( + self, + config_id: int, + ref: str, + categories: list[RestoreCategory], + overwrite_existing: bool = False, + ) -> dict: + """Apply selected categories from one backup commit.""" + # Import locally to avoid a module-level cycle: the backup service takes + # the mirror-image lock against us. + from backend.app.services.github_backup import github_backup_service + + # The lock serialises two concurrent restores; the backup side has no + # lock of its own, and relies on this region staying await-free after the + # acquisition. Both flags are plain bools on one event loop, so with no + # suspension point between the two reads and the write, the loop cannot + # slip github_backup.run_backup's mirror-image check in between. Adding an + # `await` below the acquisition and above `self._running_restore = True` + # would let a backup and a restore run at once. + async with self._lock: + if self._running_restore: + return {"success": False, "message": "A restore is already running", "results": {}} + if github_backup_service.is_running: + return { + "success": False, + "message": "A backup is currently running. Wait for it to finish before restoring.", + "results": {}, + } + self._running_restore = True + + log_id = None + try: + async with async_session() as db: + result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id)) + config = result.scalar_one_or_none() + if not config: + return {"success": False, "message": "Configuration not found", "results": {}} + + self._progress = "Resolving commit..." + resolved, error = await self._resolve_ref(config, ref) + if resolved is None: + return {"success": False, "message": error, "results": {}} + + log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved) + db.add(log) + await db.commit() + await db.refresh(log) + log_id = log.id + + try: + payload, error = await self._read_categories(config, resolved, categories) + if error: + raise RuntimeError(error) + + results = await self._apply(db, payload, categories, overwrite_existing) + await db.commit() + + total_restored = sum(tally.restored for tally in results.values()) + any_failed = any(tally.failed for tally in results.values()) + + log.status = "failed" if any_failed and total_restored == 0 else "success" + log.completed_at = datetime.now(timezone.utc) + log.files_changed = total_restored + if any_failed: + log.error_message = "Some items could not be restored — see the restore result for detail" + await db.commit() + + return { + "success": True, + "message": f"Restored {total_restored} item(s) from {resolved[:7]}", + "log_id": log_id, + "ref": resolved, + "results": {name: tally.as_dict() for name, tally in results.items()}, + } + + except Exception as e: + logger.exception("Restore failed for config %s ref %s", config_id, resolved) + await db.rollback() + log.status = "failed" + log.completed_at = datetime.now(timezone.utc) + log.error_message = str(e)[:1000] + await db.commit() + return {"success": False, "message": str(e), "log_id": log_id, "ref": resolved, "results": {}} + + finally: + self._running_restore = False + self._progress = None + + async def _read_categories( + self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory] + ) -> tuple[dict, str]: + """Fetch and parse just the files the requested categories need.""" + backend = get_provider_backend(config.provider) + client = await self._get_client() + + self._progress = "Listing backup contents..." + tree = await backend.list_tree( + repo_url=config.repository_url, token=config.access_token, ref=ref, client=client + ) + if not tree.get("success"): + return {}, tree.get("message") or "Could not list the commit" + available: list[str] = tree.get("paths") or [] + + wanted: list[str] = [] + for category in categories: + wanted.extend(self._category_paths(category, available)) + if not wanted: + return {}, "None of the selected categories are present in that commit" + + self._progress = "Downloading backup files..." + fetched = await backend.fetch_files( + repo_url=config.repository_url, token=config.access_token, ref=ref, paths=wanted, client=client + ) + if not fetched.get("success"): + return {}, fetched.get("message") or "Could not read the commit contents" + + parsed, bad = self._parse_json_files(fetched.get("files") or {}) + if bad: + return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}" + return parsed, "" + + async def _apply( + self, + db: AsyncSession, + payload: dict, + categories: list[RestoreCategory], + overwrite: bool, + ) -> dict[str, _CategoryTally]: + """Apply categories in dependency order and return per-category tallies.""" + results: dict[str, _CategoryTally] = {} + archive_id_map: dict[int, int] = {} + + # Archives first: spool usage history references archive_id. + if RestoreCategory.ARCHIVES in categories: + self._progress = "Restoring print archives..." + tally = _CategoryTally() + await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map) + results[RestoreCategory.ARCHIVES.value] = tally + + if RestoreCategory.SPOOLS in categories: + self._progress = "Restoring spool inventory..." + tally = _CategoryTally() + await self._restore_spools( + db, + payload.get(SPOOLS_PATH), + payload.get(SPOOL_USAGE_PATH), + overwrite, + tally, + archive_id_map, + ) + results[RestoreCategory.SPOOLS.value] = tally + + if RestoreCategory.SETTINGS in categories: + self._progress = "Restoring app settings..." + tally = _CategoryTally() + await self._restore_settings(db, payload.get(SETTINGS_PATH), overwrite, tally) + results[RestoreCategory.SETTINGS.value] = tally + + # Last, because it leaves the database and publishes over MQTT. + if RestoreCategory.KPROFILES in categories: + self._progress = "Sending K-profiles to printers..." + tally = _CategoryTally() + await self._restore_kprofiles(db, payload, tally) + results[RestoreCategory.KPROFILES.value] = tally + + return results + + # --- Per-category appliers -------------------------------------------- + + async def _restore_archives( + self, + db: AsyncSession, + payload, + overwrite: bool, + tally: _CategoryTally, + id_map: dict[int, int], + ) -> None: + archives = payload.get("archives") if isinstance(payload, dict) else None + if not isinstance(archives, list): + tally.note("No archive data in this backup") + return + + valid_printers = set((await db.execute(select(Printer.id))).scalars().all()) + valid_projects = set((await db.execute(select(Project.id))).scalars().all()) + + # Only metadata is backed up, never the 3MF/thumbnail bytes, and + # print_archives.file_path is NOT NULL — so inserted rows get an empty + # path and are history-only. Say so once rather than per row. + warned_files = False + + for entry in archives: + if not isinstance(entry, dict): + tally.failed += 1 + continue + + old_id = entry.get("id") if isinstance(entry.get("id"), int) else None + started_at = _parse_dt(entry.get("started_at")) + existing = await self._find_archive(db, entry, started_at) + + fields = { + "print_name": entry.get("print_name"), + "print_time_seconds": entry.get("print_time_seconds"), + "filament_used_grams": entry.get("filament_used_grams"), + "filament_type": entry.get("filament_type"), + "filament_color": entry.get("filament_color"), + "layer_height": entry.get("layer_height"), + "total_layers": entry.get("total_layers"), + "nozzle_diameter": entry.get("nozzle_diameter"), + "bed_temperature": entry.get("bed_temperature"), + "nozzle_temperature": entry.get("nozzle_temperature"), + "sliced_for_model": entry.get("sliced_for_model"), + "status": entry.get("status") or "completed", + "started_at": started_at, + "completed_at": _parse_dt(entry.get("completed_at")), + "makerworld_url": entry.get("makerworld_url"), + "designer": entry.get("designer"), + "external_url": entry.get("external_url"), + "is_favorite": bool(entry.get("is_favorite")), + "tags": entry.get("tags"), + "notes": entry.get("notes"), + "cost": entry.get("cost"), + "failure_reason": entry.get("failure_reason"), + "quantity": entry.get("quantity") or 1, + "energy_kwh": entry.get("energy_kwh"), + "energy_cost": entry.get("energy_cost"), + # A soft-deleted archive is still in the backup (its row is kept + # so stats keep counting it), so carry the flag across or the + # restore turns something the user deleted back into a visible + # archive. Backups written before this key existed have no + # deleted_at, and those rows can only come back live. + "deleted_at": _parse_dt(entry.get("deleted_at")), + } + + printer_id = entry.get("printer_id") + if printer_id is not None and printer_id not in valid_printers: + tally.note("Some archives referenced printers that no longer exist — link cleared") + printer_id = None + project_id = entry.get("project_id") + if project_id is not None and project_id not in valid_projects: + tally.note("Some archives referenced projects that no longer exist — link cleared") + project_id = None + fields["printer_id"] = printer_id + fields["project_id"] = project_id + + if existing is not None: + if old_id is not None: + id_map[old_id] = existing.id + if not overwrite: + tally.skipped += 1 + continue + # Overwrite means "make the local row match the backup", which + # includes un-deleting one the user deleted after the backup was + # taken. Legitimate, but not obvious from a restored/skipped + # count, so say it. + if existing.deleted_at is not None and fields["deleted_at"] is None: + tally.note("Archive(s) deleted since the backup are visible again — overwrite was on") + for key, value in fields.items(): + setattr(existing, key, value) + tally.restored += 1 + continue + + if not warned_files: + tally.note( + "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup" + ) + warned_files = True + + row = PrintArchive( + filename=entry.get("filename") or "restored-from-backup", + file_path="", + file_size=entry.get("file_size") or 0, + content_hash=entry.get("content_hash"), + **fields, + ) + created_at = _parse_dt(entry.get("created_at")) + if created_at is not None: + row.created_at = created_at + db.add(row) + await db.flush() + if old_id is not None: + id_map[old_id] = row.id + tally.restored += 1 + + async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None: + """Match a backed-up archive to a local row by natural key. + + ``started_at`` is nullable and genuinely NULL for a whole class of rows — + the re-slice path in ``library.py`` constructs ``PrintArchive`` without + one — so it cannot be *required* by the key. It narrows the match instead: + a backed-up row with no ``started_at`` matches a local row that has none + either. Requiring it meant those archives never matched, so each restore + re-inserted them as duplicates and overwrite mode could never update them. + + ``content_hash`` identifies the sliced file on its own, which is why it is + the branch allowed to run without a ``started_at``; ``filename`` is too + weak for that (re-slices share it) and still requires one. Two backed-up + rows sharing a hash *and* having no ``started_at`` are indistinguishable + in the backup, so they collapse onto one local row — better than + duplicating both on every restore. + + Soft-deleted rows are matched deliberately: there is no ``deleted_at`` + filter here because the row still exists, and matching it is what stops a + restore inserting a live duplicate of an archive the user has deleted. + """ + started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None) + + content_hash = entry.get("content_hash") + if content_hash: + result = await db.execute( + select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate) + ) + row = result.scalars().first() + if row is not None: + return row + + filename = entry.get("filename") + if filename and started_at: + result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate)) + return result.scalars().first() + return None + + async def _restore_spools( + self, + db: AsyncSession, + inventory, + usage_payload, + overwrite: bool, + tally: _CategoryTally, + archive_id_map: dict[int, int], + ) -> None: + spools = inventory.get("spools") if isinstance(inventory, dict) else None + if not isinstance(spools, list): + tally.note("No spool data in this backup") + return + + spool_id_map: dict[int, int] = {} + + for entry in spools: + if not isinstance(entry, dict): + tally.failed += 1 + continue + + old_id = entry.get("id") if isinstance(entry.get("id"), int) else None + existing = await self._find_spool(db, entry) + + fields = { + "material": entry.get("material") or "PLA", + "subtype": entry.get("subtype"), + "color_name": entry.get("color_name"), + "rgba": entry.get("rgba"), + "brand": entry.get("brand"), + "label_weight": entry.get("label_weight") or 1000, + "core_weight": entry.get("core_weight") or 250, + "weight_used": entry.get("weight_used") or 0, + "weight_locked": bool(entry.get("weight_locked")), + "slicer_filament": entry.get("slicer_filament"), + "slicer_filament_name": entry.get("slicer_filament_name"), + "nozzle_temp_min": entry.get("nozzle_temp_min"), + "nozzle_temp_max": entry.get("nozzle_temp_max"), + "note": entry.get("note"), + "cost_per_kg": entry.get("cost_per_kg"), + "tag_uid": entry.get("tag_uid"), + "tray_uuid": entry.get("tray_uuid"), + "data_origin": entry.get("data_origin"), + "tag_type": entry.get("tag_type"), + "archived_at": _parse_dt(entry.get("archived_at")), + } + + if existing is not None: + if old_id is not None: + spool_id_map[old_id] = existing.id + if not overwrite: + tally.skipped += 1 + continue + for key, value in fields.items(): + setattr(existing, key, value) + tally.restored += 1 + continue + + row = Spool(**fields) + # Carry the original created_at across. Without it the row would be + # stamped "now", and the composite fallback in _find_spool (which + # keys on created_at) would miss on a second restore and insert a + # duplicate instead of matching. + created_at = _parse_dt(entry.get("created_at")) + if created_at is not None: + row.created_at = created_at + db.add(row) + await db.flush() + if old_id is not None: + spool_id_map[old_id] = row.id + tally.restored += 1 + + await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map) + + async def _find_spool(self, db: AsyncSession, entry: dict) -> Spool | None: + """Match a backed-up spool to a local row. + + Physical identity first (an RFID/Bambu tag is the spool), then a + descriptive composite including ``created_at`` so two otherwise + identical spools added at different times stay distinct. + """ + tag_uid = entry.get("tag_uid") + if tag_uid: + result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid)) + row = result.scalars().first() + if row is not None: + return row + + tray_uuid = entry.get("tray_uuid") + if tray_uuid: + result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid)) + row = result.scalars().first() + if row is not None: + return row + + created_at = _parse_dt(entry.get("created_at")) + if created_at is None: + return None + result = await db.execute( + select(Spool).where( + Spool.created_at == created_at, + Spool.material == (entry.get("material") or "PLA"), + Spool.brand == entry.get("brand"), + Spool.subtype == entry.get("subtype"), + Spool.color_name == entry.get("color_name"), + ) + ) + return result.scalars().first() + + async def _restore_spool_usage( + self, + db: AsyncSession, + usage_payload, + tally: _CategoryTally, + spool_id_map: dict[int, int], + archive_id_map: dict[int, int], + ) -> None: + usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None + if not isinstance(usage, list) or not usage: + return + + valid_printers = set((await db.execute(select(Printer.id))).scalars().all()) + unresolved = 0 + + for entry in usage: + if not isinstance(entry, dict): + tally.failed += 1 + continue + + old_spool_id = entry.get("spool_id") + spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None + if spool_id is None: + # The parent spool never made it into the map: the backup's spool + # list didn't include it, or its entry carried no integer id. A + # spool that was merely *skipped* (matched locally, overwrite off) + # is mapped a few lines up in _restore_spools, so it never lands + # here — which is why the note below offers no remedy. + unresolved += 1 + tally.skipped += 1 + continue + + created_at = _parse_dt(entry.get("created_at")) + # Usage history has no natural key of its own, so dedupe on the + # tuple that makes a consumption event unique in practice. + existing = await db.execute( + select(SpoolUsageHistory).where( + SpoolUsageHistory.spool_id == spool_id, + SpoolUsageHistory.created_at == created_at, + SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0), + SpoolUsageHistory.print_name == entry.get("print_name"), + ) + ) + if existing.scalars().first() is not None: + tally.skipped += 1 + continue + + printer_id = entry.get("printer_id") + if printer_id is not None and printer_id not in valid_printers: + printer_id = None + + old_archive_id = entry.get("archive_id") + archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None + + row = SpoolUsageHistory( + spool_id=spool_id, + printer_id=printer_id, + print_name=entry.get("print_name"), + archive_id=archive_id, + weight_used=entry.get("weight_used") or 0, + percent_used=entry.get("percent_used") or 0, + status=entry.get("status") or "completed", + cost=entry.get("cost"), + ) + if created_at is not None: + row.created_at = created_at + db.add(row) + tally.restored += 1 + + if unresolved: + tally.note( + f"{unresolved} usage record(s) skipped — their spool is not in this backup's " + "spool list, so there is nothing to attach them to." + ) + + async def _restore_settings(self, db: AsyncSession, payload, overwrite: bool, tally: _CategoryTally) -> None: + values = payload.get("settings") if isinstance(payload, dict) else None + if not isinstance(values, dict): + tally.note("No settings data in this backup") + return + + blocked = 0 + for key, value in values.items(): + if not isinstance(key, str) or not key: + tally.failed += 1 + continue + if _is_blocked_setting_key(key): + blocked += 1 + tally.skipped += 1 + continue + if value is None: + tally.skipped += 1 + continue + + result = await db.execute(select(Settings).where(Settings.key == key)) + existing = result.scalar_one_or_none() + if existing is not None: + if not overwrite: + tally.skipped += 1 + continue + existing.value = str(value) + tally.restored += 1 + continue + + db.add(Settings(key=key, value=str(value))) + tally.restored += 1 + + if blocked: + tally.note(f"{blocked} credential-like key(s) skipped — re-enter secrets manually") + + async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None: + by_serial: dict[str, list[tuple[str, dict]]] = {} + for path, content in payload.items(): + match = _KPROFILE_PATH_RE.match(path) + if not match or not isinstance(content, dict): + continue + by_serial.setdefault(match.group(1), []).append((match.group(2), content)) + + if not by_serial: + tally.note("No K-profile data in this backup") + return + + result = await db.execute(select(Printer)) + printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number} + + # Overwrite is not offered for K-profiles: extrusion_cali_set replaces + # the profile occupying a slot, so writing is always an overwrite on the + # printer side. + tally.note("K-profiles always overwrite the matching slot on the printer") + tally.note("Profiles are published over MQTT without acknowledgement — verify on the printer") + + for serial, entries in sorted(by_serial.items()): + profile_total = sum(len(c.get("profiles") or []) for _, c in entries) + + printer = printers.get(serial) + if printer is None: + tally.skipped += profile_total + tally.note(f"No printer with serial {serial} — skipped") + continue + + client = printer_manager.get_client(printer.id) + if not client or not client.state.connected: + tally.skipped += profile_total + tally.note(f"{printer.name} ({serial}) is not connected — skipped") + continue + + for nozzle, content in sorted(entries): + profiles = content.get("profiles") + if not isinstance(profiles, list) or not profiles: + continue + if nozzle not in _KNOWN_NOZZLES: + tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is") + + profile_dicts = [ + { + "filament_id": p.get("filament_id", ""), + "name": p.get("name", ""), + "k_value": p.get("k_value", "0.020000"), + "nozzle_id": p.get("nozzle_id"), + "extruder_id": p.get("extruder_id", 0), + "setting_id": p.get("setting_id"), + "slot_id": p.get("slot_id", 0), + } + for p in profiles + if isinstance(p, dict) + ] + if not profile_dicts: + continue + + try: + sent = client.set_kprofiles_batch(profile_dicts, nozzle) + except Exception as e: + logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e) + sent = False + + if sent: + tally.restored += len(profile_dicts) + else: + tally.failed += len(profile_dicts) + tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})") + + +# Singleton instance +github_restore_service = GitHubRestoreService() diff --git a/backend/tests/integration/test_github_restore_api.py b/backend/tests/integration/test_github_restore_api.py new file mode 100644 index 000000000..9d72ec348 --- /dev/null +++ b/backend/tests/integration/test_github_restore_api.py @@ -0,0 +1,276 @@ +"""Integration tests for the Git backup restore API endpoints (#2656).""" + +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + + +@pytest.fixture(autouse=True) +def _mock_private_repo_check(): + """POST /config refuses to save unless the repo is confirmed private.""" + with patch( + "backend.app.services.github_backup.github_backup_service.test_connection", + new=AsyncMock( + return_value={ + "success": True, + "message": "Connection successful", + "repo_name": "test/repo", + "permissions": {"push": True}, + "is_private": True, + } + ), + ) as m: + yield m + + +async def _create_config(async_client: AsyncClient) -> dict: + response = await async_client.post( + "/api/v1/github-backup/config", + json={ + "repository_url": "https://github.com/test/repo", + "access_token": "ghp_testtoken123", + "branch": "main", + "backup_kprofiles": True, + "backup_spools": True, + "backup_archives": True, + "backup_settings": True, + "enabled": True, + }, + ) + assert response.status_code == 200 + return response.json() + + +class TestCommitsEndpoint: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_404_when_not_configured(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/github-backup/commits") + assert response.status_code == 404 + assert "Configure backup first" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_returns_commits_from_the_provider(self, async_client: AsyncClient): + await _create_config(async_client) + commits = [ + {"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"} + ] + with patch( + "backend.app.services.git_providers.github.GitHubBackend.list_commits", + new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}), + ): + response = await async_client.get("/api/v1/github-backup/commits") + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["branch"] == "main" + assert body["commits"][0]["sha"] == "aaa1111" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient): + await _create_config(async_client) + with patch( + "backend.app.services.git_providers.github.GitHubBackend.list_commits", + new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}), + ): + response = await async_client.get("/api/v1/github-backup/commits") + + assert response.status_code == 200 + assert response.json()["success"] is False + assert response.json()["commits"] == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_limit_is_bounded(self, async_client: AsyncClient): + await _create_config(async_client) + assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422 + assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422 + + +class TestPreviewEndpoint: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_404_when_not_configured(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/github-backup/restore/preview") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_reports_available_and_missing_categories(self, async_client: AsyncClient): + await _create_config(async_client) + preview = { + "success": True, + "message": "OK", + "ref": "aaa1111", + "commit": None, + "metadata_version": "1.0", + "categories": [ + {"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"}, + {"category": "settings", "available": True, "item_count": 12, "detail": None}, + {"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"}, + {"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"}, + ], + } + with patch( + "backend.app.services.github_restore.github_restore_service.preview", + new=AsyncMock(return_value=preview), + ): + response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111") + + assert response.status_code == 200 + body = response.json() + assert body["metadata_version"] == "1.0" + by_name = {c["category"]: c for c in body["categories"]} + assert by_name["kprofiles"]["available"] is False + assert by_name["spools"]["item_count"] == 4 + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"]) + async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref): + await _create_config(async_client) + response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}") + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_defaults_to_head(self, async_client: AsyncClient): + await _create_config(async_client) + mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []}) + with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock): + response = await async_client.get("/api/v1/github-backup/restore/preview") + + assert response.status_code == 200 + assert mock.await_args.kwargs["ref"] == "HEAD" + + +class TestRestoreEndpoint: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_404_when_not_configured(self, async_client: AsyncClient): + response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]}) + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_applies_selected_categories(self, async_client: AsyncClient): + await _create_config(async_client) + outcome = { + "success": True, + "message": "Restored 5 item(s) from aaa1111", + "log_id": 3, + "ref": "aaa1111", + "results": { + "spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []}, + "settings": {"restored": 1, "skipped": 2, "failed": 0, "notes": ["1 credential-like key(s) skipped"]}, + }, + } + with patch( + "backend.app.services.github_restore.github_restore_service.run_restore", + new=AsyncMock(return_value=outcome), + ) as mock: + response = await async_client.post( + "/api/v1/github-backup/restore", + json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["results"]["spools"]["restored"] == 4 + assert body["results"]["settings"]["notes"] == ["1 credential-like key(s) skipped"] + assert mock.await_args.kwargs["overwrite_existing"] is True + assert mock.await_args.kwargs["ref"] == "aaa1111" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_empty_category_list(self, async_client: AsyncClient): + await _create_config(async_client) + response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []}) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_unknown_category(self, async_client: AsyncClient): + await _create_config(async_client) + response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]}) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_malformed_ref(self, async_client: AsyncClient): + await _create_config(async_client) + response = await async_client.post( + "/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]} + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_defaults_overwrite_to_false(self, async_client: AsyncClient): + """The safe default: a restore only inserts what's missing.""" + await _create_config(async_client) + mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}}) + with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock): + response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]}) + + assert response.status_code == 200 + assert mock.await_args.kwargs["overwrite_existing"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient): + await _create_config(async_client) + with patch( + "backend.app.services.github_restore.github_restore_service.run_restore", + new=AsyncMock( + return_value={ + "success": False, + "message": "A backup is currently running. Wait for it to finish before restoring.", + "results": {}, + } + ), + ): + response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]}) + + assert response.status_code == 200 + assert response.json()["success"] is False + assert "backup is currently running" in response.json()["message"] + + +class TestStatusExposesRestoreState: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient): + await _create_config(async_client) + response = await async_client.get("/api/v1/github-backup/status") + assert response.status_code == 200 + assert response.json()["restore_running"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_running_is_reported(self, async_client: AsyncClient): + """The UI disables both action buttons off this flag.""" + await _create_config(async_client) + from backend.app.services.github_restore import github_restore_service + + github_restore_service._running_restore = True + github_restore_service._progress = "Restoring spool inventory..." + try: + response = await async_client.get("/api/v1/github-backup/status") + finally: + github_restore_service._running_restore = False + github_restore_service._progress = None + + assert response.json()["restore_running"] is True + assert response.json()["progress"] == "Restoring spool inventory..." + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/github-backup/status") + assert response.status_code == 200 + assert response.json()["restore_running"] is False diff --git a/backend/tests/unit/test_git_providers_restore.py b/backend/tests/unit/test_git_providers_restore.py new file mode 100644 index 000000000..109a1a4b6 --- /dev/null +++ b/backend/tests/unit/test_git_providers_restore.py @@ -0,0 +1,431 @@ +"""Unit tests for the git_providers read side used by restore (#2656). + +Covers list_commits / list_tree / fetch_files across all four providers, +including that Gitea and Forgejo inherit GitHub's Git Data API implementation +rather than needing their own. +""" + +import base64 +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from backend.app.services.git_providers.forgejo import ForgejoBackend +from backend.app.services.git_providers.gitea import GiteaBackend +from backend.app.services.git_providers.github import GitHubBackend +from backend.app.services.git_providers.gitlab import GitLabBackend + + +def _make_mock_response(status_code: int, body=None, text: str = ""): + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.json = MagicMock(return_value=body if body is not None else {}) + return resp + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode() + + +def _github_commit(sha: str, message: str = "Bambuddy backup", date: str = "2026-07-01T10:00:00Z"): + return {"sha": sha, "commit": {"message": message, "author": {"name": "Bambuddy", "date": date}}} + + +class TestGitHubListCommits: + def setup_method(self): + self.backend = GitHubBackend() + self.repo_url = "https://github.com/owner/repo" + self.token = "ghp_token" + + @pytest.mark.asyncio + async def test_returns_normalised_commits_newest_first(self): + client = AsyncMock() + client.get = AsyncMock( + return_value=_make_mock_response( + 200, + [ + _github_commit("aaa111", "Bambuddy backup - newest", "2026-07-02T10:00:00Z"), + _github_commit("bbb222", "Bambuddy backup - older", "2026-07-01T10:00:00Z"), + ], + ) + ) + + result = await self.backend.list_commits(self.repo_url, self.token, "main", client) + + assert result["success"] is True + assert [c["sha"] for c in result["commits"]] == ["aaa111", "bbb222"] + assert result["commits"][0]["message"] == "Bambuddy backup - newest" + assert result["commits"][0]["author"] == "Bambuddy" + assert result["commits"][0]["date"] == "2026-07-02T10:00:00Z" + + @pytest.mark.asyncio + async def test_sends_both_per_page_and_limit(self): + """GitHub honours per_page, Gitea honours limit — one call must carry both + so GiteaBackend can inherit this method unchanged.""" + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, [])) + + await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=7) + + params = client.get.await_args.kwargs["params"] + assert params["per_page"] == 7 + assert params["limit"] == 7 + assert params["sha"] == "main" + + @pytest.mark.asyncio + async def test_respects_limit_even_if_provider_overshoots(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit(f"sha{i}") for i in range(10)])) + + result = await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=3) + + assert len(result["commits"]) == 3 + + @pytest.mark.asyncio + async def test_404_explains_empty_repository(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(404, {})) + + result = await self.backend.list_commits(self.repo_url, self.token, "nope", client) + + assert result["success"] is False + assert "no commits yet" in result["message"] + assert result["commits"] == [] + + @pytest.mark.asyncio + async def test_skips_entries_without_a_sha(self): + client = AsyncMock() + client.get = AsyncMock( + return_value=_make_mock_response(200, [{"commit": {"message": "no sha"}}, _github_commit("good")]) + ) + + result = await self.backend.list_commits(self.repo_url, self.token, "main", client) + + assert [c["sha"] for c in result["commits"]] == ["good"] + + @pytest.mark.asyncio + async def test_non_list_body_is_an_error_not_a_crash(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"})) + + result = await self.backend.list_commits(self.repo_url, self.token, "main", client) + + assert result["success"] is False + assert "Unexpected shape" in result["message"] + + +class TestGitHubListTree: + def setup_method(self): + self.backend = GitHubBackend() + self.repo_url = "https://github.com/owner/repo" + self.token = "ghp_token" + + @pytest.mark.asyncio + async def test_returns_sorted_blob_paths_only(self): + client = AsyncMock() + client.get = AsyncMock( + return_value=_make_mock_response( + 200, + { + "tree": [ + {"type": "blob", "path": "spools/inventory.json", "sha": "s1"}, + {"type": "tree", "path": "spools", "sha": "d1"}, + {"type": "blob", "path": "backup_metadata.json", "sha": "m1"}, + ] + }, + ) + ) + + result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client) + + assert result["success"] is True + assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"] + + @pytest.mark.asyncio + async def test_truncated_tree_fails_loudly(self): + """A truncated listing would make restore silently miss categories.""" + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True})) + + result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client) + + assert result["success"] is False + assert "truncated" in result["message"] + + @pytest.mark.asyncio + async def test_404_names_the_missing_ref(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(404, {})) + + result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client) + + assert result["success"] is False + assert "deadbee" in result["message"] + + +class TestGitHubFetchFiles: + def setup_method(self): + self.backend = GitHubBackend() + self.repo_url = "https://github.com/owner/repo" + self.token = "ghp_token" + + @pytest.mark.asyncio + async def test_reads_requested_paths_via_blob_api(self): + tree = _make_mock_response( + 200, + { + "tree": [ + {"type": "blob", "path": "a.json", "sha": "sha-a"}, + {"type": "blob", "path": "b.json", "sha": "sha-b"}, + ] + }, + ) + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + tree, + _make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}), + ] + ) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client) + + assert result["success"] is True + assert result["files"] == {"a.json": '{"a": 1}'} + # One tree listing regardless of how many files are read. + assert client.get.await_count == 2 + + @pytest.mark.asyncio + async def test_lists_the_tree_once_for_many_files(self): + tree = _make_mock_response( + 200, + { + "tree": [ + {"type": "blob", "path": "a.json", "sha": "sha-a"}, + {"type": "blob", "path": "b.json", "sha": "sha-b"}, + ] + }, + ) + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + tree, + _make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}), + _make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}), + ] + ) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client) + + assert result["files"] == {"a.json": "1", "b.json": "2"} + assert client.get.await_count == 3 + + @pytest.mark.asyncio + async def test_missing_path_is_skipped_not_an_error(self): + """Which categories a backup contains varies by config, so an absent + path is expected rather than a failure.""" + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []})) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client) + + assert result["success"] is True + assert result["files"] == {} + + @pytest.mark.asyncio + async def test_blob_error_fails_the_whole_read(self): + tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]}) + client = AsyncMock() + client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")]) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client) + + assert result["success"] is False + assert "a.json" in result["message"] + assert result["files"] == {} + + @pytest.mark.asyncio + async def test_utf8_content_survives_round_trip(self): + payload = '{"color_name": "Jadeweiß", "note": "日本語"}' + tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]}) + client = AsyncMock() + client.get = AsyncMock( + side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})] + ) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client) + + assert result["files"]["a.json"] == payload + + @pytest.mark.asyncio + async def test_unsupported_encoding_is_reported(self): + tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]}) + client = AsyncMock() + client.get = AsyncMock( + side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})] + ) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client) + + assert result["success"] is False + assert "Unsupported blob encoding" in result["message"] + + +class TestGiteaAndForgejoInheritReads: + """Gitea overrides the *write* path only; reads come from GitHubBackend.""" + + @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend]) + def test_read_methods_are_not_overridden(self, backend_cls): + for method in ("list_commits", "list_tree", "fetch_files"): + assert getattr(backend_cls, method) is getattr(GitHubBackend, method) + + @pytest.mark.asyncio + async def test_gitea_list_commits_uses_its_own_api_base(self): + backend = GiteaBackend() + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")])) + + result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client) + + assert result["success"] is True + url = client.get.await_args.args[0] + assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits") + + @pytest.mark.asyncio + async def test_gitea_subpath_install_is_respected(self): + """Gitea/Forgejo behind a ROOT_URL sub-path (#2642).""" + backend = GiteaBackend() + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []})) + + await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client) + + url = client.get.await_args.args[0] + assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url + + +class TestGitLabReads: + def setup_method(self): + self.backend = GitLabBackend() + self.repo_url = "https://gitlab.com/owner/repo" + self.token = "glpat-test" + + @pytest.mark.asyncio + async def test_list_commits_reads_flattened_author_fields(self): + """GitLab puts message/author/date on the entry, not under 'commit'.""" + client = AsyncMock() + client.get = AsyncMock( + return_value=_make_mock_response( + 200, + [ + { + "id": "abc123", + "message": "Bambuddy backup", + "author_name": "Bambuddy", + "committed_date": "2026-07-02T10:00:00Z", + } + ], + ) + ) + + result = await self.backend.list_commits(self.repo_url, self.token, "main", client) + + assert result["success"] is True + assert result["commits"] == [ + { + "sha": "abc123", + "message": "Bambuddy backup", + "author": "Bambuddy", + "date": "2026-07-02T10:00:00Z", + } + ] + + @pytest.mark.asyncio + async def test_list_commits_uses_ref_name(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, [])) + + await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5) + + params = client.get.await_args.kwargs["params"] + assert params["ref_name"] == "bambuddy-backup" + assert params["per_page"] == 5 + + @pytest.mark.asyncio + async def test_subgroup_path_is_url_encoded(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, [])) + + await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client) + + url = client.get.await_args.args[0] + assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url + + @pytest.mark.asyncio + async def test_list_tree_returns_blob_paths(self): + client = AsyncMock() + client.get = AsyncMock( + return_value=_make_mock_response( + 200, + [ + {"type": "blob", "path": "spools/inventory.json"}, + {"type": "tree", "path": "spools"}, + ], + ) + ) + + result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client) + + assert result["success"] is True + assert result["paths"] == ["spools/inventory.json"] + + @pytest.mark.asyncio + async def test_list_tree_follows_pagination(self): + """GitLab paginates instead of exposing a truncated flag.""" + full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)] + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(200, full_page), + _make_mock_response(200, [{"type": "blob", "path": "last.json"}]), + ] + ) + + result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client) + + assert client.get.await_count == 2 + assert len(result["paths"]) == 101 + assert "last.json" in result["paths"] + + @pytest.mark.asyncio + async def test_fetch_files_decodes_base64(self): + client = AsyncMock() + client.get = AsyncMock( + return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"}) + ) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client) + + assert result["success"] is True + assert result["files"] == {"a.json": '{"k": 1}'} + + @pytest.mark.asyncio + async def test_fetch_files_encodes_nested_path(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"})) + + await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client) + + url = client.get.await_args.args[0] + assert "repository/files/spools%2Finventory.json" in url + + @pytest.mark.asyncio + async def test_fetch_files_skips_404(self): + client = AsyncMock() + client.get = AsyncMock(return_value=_make_mock_response(404, {})) + + result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client) + + assert result["success"] is True + assert result["files"] == {} diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py new file mode 100644 index 000000000..02ce3e0d4 --- /dev/null +++ b/backend/tests/unit/test_github_restore.py @@ -0,0 +1,946 @@ +"""Unit tests for the Git backup restore service (#2656). + +Focus is on the per-category appliers: natural-key matching, the deliberate +refusal to reuse the backup's primary keys, old_id -> new_id remapping for +dependent rows, overwrite-vs-skip, the settings credential blocklist, and the +K-profile paths that depend on live printers. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select + +from backend.app.models.archive import PrintArchive +from backend.app.models.settings import Settings +from backend.app.models.spool import Spool +from backend.app.models.spool_usage_history import SpoolUsageHistory +from backend.app.schemas.github_backup import GitHubRestoreRequest, RestoreCategory +from backend.app.services.github_restore import ( + ARCHIVES_PATH, + SETTINGS_PATH, + SPOOL_USAGE_PATH, + SPOOLS_PATH, + GitHubRestoreService, + _CategoryTally, + _is_blocked_setting_key, + _parse_dt, +) + + +def _service() -> GitHubRestoreService: + return GitHubRestoreService() + + +class TestParseDt: + def test_parses_str_datetime_the_backup_writes(self): + assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456) + + def test_parses_iso_with_t_separator(self): + assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5) + + @pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}]) + def test_returns_none_for_junk(self, value): + assert _parse_dt(value) is None + + +class TestSettingKeyBlocklist: + @pytest.mark.parametrize( + "key", + [ + "bambu_cloud_token", + "auth_secret_key", + "ha_token", + "prometheus_token", + "printer_access_code", + "smtp_password", + "some_api_key", + "ftp_passphrase", + "MQTT_SECRET", + ], + ) + def test_credential_like_keys_are_blocked(self, key): + assert _is_blocked_setting_key(key) is True + + @pytest.mark.parametrize( + "key", + ["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"], + ) + def test_ordinary_keys_are_allowed(self, key): + assert _is_blocked_setting_key(key) is False + + +class TestCategoryTally: + def test_notes_are_deduplicated(self): + tally = _CategoryTally() + tally.note("same") + tally.note("same") + assert tally.notes == ["same"] + + def test_notes_are_bounded(self): + tally = _CategoryTally() + for i in range(50): + tally.note(f"note {i}") + assert len(tally.notes) == 20 + + +class TestRestoreRequestSchema: + def test_rejects_empty_category_list(self): + with pytest.raises(ValueError): + GitHubRestoreRequest(categories=[]) + + def test_deduplicates_categories(self): + request = GitHubRestoreRequest( + categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS] + ) + assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS] + + def test_defaults_to_head(self): + assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD" + + @pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40]) + def test_accepts_valid_refs(self, ref): + assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref + + @pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"]) + def test_rejects_refs_that_are_not_object_names(self, ref): + with pytest.raises(ValueError): + GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]) + + +class TestRestoreSettings: + @pytest.mark.asyncio + async def test_inserts_missing_keys(self, db_session): + tally = _CategoryTally() + payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}} + + await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally) + await db_session.commit() + + rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()} + assert rows == {"currency": "EUR", "theme": "dark"} + assert tally.restored == 2 + + @pytest.mark.asyncio + async def test_skips_existing_key_when_overwrite_off(self, db_session): + db_session.add(Settings(key="currency", value="USD")) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally) + await db_session.commit() + + row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one() + assert row.value == "USD" + assert tally.skipped == 1 + assert tally.restored == 0 + + @pytest.mark.asyncio + async def test_overwrites_existing_key_when_enabled(self, db_session): + db_session.add(Settings(key="currency", value="USD")) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally) + await db_session.commit() + + row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one() + assert row.value == "EUR" + assert tally.restored == 1 + + @pytest.mark.asyncio + async def test_credential_keys_are_never_restored(self, db_session): + """A backup predating the collector's denylist can still contain secrets.""" + tally = _CategoryTally() + payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}} + + await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally) + await db_session.commit() + + keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()} + assert keys == {"currency"} + assert tally.skipped == 2 + assert any("credential-like" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_missing_payload_is_noted_not_fatal(self, db_session): + tally = _CategoryTally() + await _service()._restore_settings(db_session, None, overwrite=True, tally=tally) + assert tally.restored == 0 + assert tally.notes + + +class TestRestoreSpools: + def _spool_entry(self, **overrides): + entry = { + "id": 41, + "material": "PLA", + "subtype": "Basic", + "color_name": "Jade White", + "brand": "Bambu Lab", + "tag_uid": "AABBCCDD", + "created_at": "2026-01-05 12:00:00", + "weight_used": 120.5, + } + entry.update(overrides) + return entry + + @pytest.mark.asyncio + async def test_inserts_without_reusing_backup_id(self, db_session): + """The backup's spool.id belongs to an unrelated row today.""" + db_session.add(Spool(material="PETG")) # occupies id 1 + await db_session.commit() + + tally = _CategoryTally() + payload = {"spools": [self._spool_entry(id=1)]} + + await _service()._restore_spools(db_session, payload, None, False, tally, {}) + await db_session.commit() + + spools = (await db_session.execute(select(Spool))).scalars().all() + assert len(spools) == 2 + restored = next(s for s in spools if s.tag_uid == "AABBCCDD") + assert restored.id != 1 + assert restored.material == "PLA" + + @pytest.mark.asyncio + async def test_matches_existing_spool_by_tag_uid(self, db_session): + db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old")) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {}) + await db_session.commit() + + assert len((await db_session.execute(select(Spool))).scalars().all()) == 1 + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_matches_existing_spool_by_tray_uuid(self, db_session): + db_session.add(Spool(material="PLA", tray_uuid="1234" * 8)) + await db_session.commit() + tally = _CategoryTally() + entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8) + + await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {}) + await db_session.commit() + + assert len((await db_session.execute(select(Spool))).scalars().all()) == 1 + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_matches_tagless_spool_by_descriptive_composite(self, db_session): + """Manually added spools have no tag, so fall back to created_at + description.""" + db_session.add( + Spool( + material="PLA", + subtype="Basic", + color_name="Jade White", + brand="Bambu Lab", + created_at=datetime(2026, 1, 5, 12, 0, 0), + ) + ) + await db_session.commit() + tally = _CategoryTally() + + entry = self._spool_entry(tag_uid=None) + await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {}) + await db_session.commit() + + assert len((await db_session.execute(select(Spool))).scalars().all()) == 1 + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_overwrite_updates_matched_spool(self, db_session): + db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0)) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(Spool))).scalar_one() + assert row.color_name == "Jade White" + assert row.weight_used == 120.5 + assert tally.restored == 1 + + @pytest.mark.asyncio + async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session): + """Second restore of the same backup must match, not duplicate.""" + service = _service() + payload = {"spools": [self._spool_entry(tag_uid=None)]} + + await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {}) + await db_session.commit() + await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {}) + await db_session.commit() + + spools = (await db_session.execute(select(Spool))).scalars().all() + assert len(spools) == 1 + assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0) + + @pytest.mark.asyncio + async def test_usage_history_spool_id_is_remapped(self, db_session): + """Usage rows must point at the new local spool id, not the backup's.""" + tally = _CategoryTally() + inventory = {"spools": [self._spool_entry(id=41)]} + usage = { + "usage_history": [ + { + "id": 900, + "spool_id": 41, + "printer_id": None, + "print_name": "benchy.3mf", + "archive_id": None, + "weight_used": 12.0, + "percent_used": 5, + "status": "completed", + "created_at": "2026-02-01 09:00:00", + } + ] + } + + await _service()._restore_spools(db_session, inventory, usage, False, tally, {}) + await db_session.commit() + + spool = (await db_session.execute(select(Spool))).scalar_one() + row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one() + assert row.spool_id == spool.id + assert row.print_name == "benchy.3mf" + + @pytest.mark.asyncio + async def test_usage_history_archive_id_is_remapped(self, db_session): + tally = _CategoryTally() + inventory = {"spools": [self._spool_entry(id=41)]} + usage = { + "usage_history": [ + { + "spool_id": 41, + "archive_id": 77, + "weight_used": 1.0, + "created_at": "2026-02-01 09:00:00", + } + ] + } + archive = PrintArchive(filename="a.3mf", file_path="", file_size=1) + db_session.add(archive) + await db_session.flush() + + await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id}) + await db_session.commit() + + row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one() + assert row.archive_id == archive.id + + @pytest.mark.asyncio + async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session): + tally = _CategoryTally() + usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]} + + await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {}) + await db_session.commit() + + assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None + assert tally.skipped == 1 + assert any("their spool is not in this backup's spool list" in note for note in tally.notes) + # No remedy is offered, because none exists: overwrite does not change + # which spools land in the map (a skipped spool is mapped anyway), and + # usage history is always restored alongside the spools category. + assert not any("overwrite" in note.lower() for note in tally.notes) + + @pytest.mark.asyncio + async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session): + """A skipped spool is still mapped, so its usage rows are not "unresolved". + + This is why the note above offers no remedy: turning overwrite on would + not rescue anything, and saying so misdescribed which records are lost. + """ + db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old")) + await db_session.commit() + tally = _CategoryTally() + inventory = {"spools": [self._spool_entry(id=41)]} + usage = { + "usage_history": [ + {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"} + ] + } + + await _service()._restore_spools(db_session, inventory, usage, False, tally, {}) + await db_session.commit() + + spool = (await db_session.execute(select(Spool))).scalar_one() + row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one() + assert row.spool_id == spool.id + assert not any("spool list" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session): + service = _service() + inventory = {"spools": [self._spool_entry(id=41)]} + usage = { + "usage_history": [ + {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"} + ] + } + + await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {}) + await db_session.commit() + await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {}) + await db_session.commit() + + rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all() + assert len(rows) == 1 + + @pytest.mark.asyncio + async def test_dangling_printer_id_is_cleared(self, db_session): + tally = _CategoryTally() + inventory = {"spools": [self._spool_entry(id=41)]} + usage = { + "usage_history": [ + {"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"} + ] + } + + await _service()._restore_spools(db_session, inventory, usage, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one() + assert row.printer_id is None + + +class TestRestoreArchives: + def _archive_entry(self, **overrides): + entry = { + "id": 77, + "filename": "benchy.3mf", + "file_size": 2048, + "content_hash": "abc123", + "print_name": "Benchy", + "status": "completed", + "started_at": "2026-03-01 10:00:00", + "completed_at": "2026-03-01 11:00:00", + "created_at": "2026-03-01 10:00:00", + "quantity": 1, + "is_favorite": False, + } + entry.update(overrides) + return entry + + @pytest.mark.asyncio + async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session): + """print_archives.file_path is NOT NULL but is not in the backup.""" + tally = _CategoryTally() + id_map: dict[int, int] = {} + + await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.file_path == "" + assert row.filename == "benchy.3mf" + assert row.id != 77 + assert id_map == {77: row.id} + assert any("metadata only" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_matches_existing_archive_by_hash_and_start(self, db_session): + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="/data/benchy.3mf", + file_size=2048, + content_hash="abc123", + started_at=datetime(2026, 3, 1, 10, 0, 0), + ) + ) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {}) + await db_session.commit() + + rows = (await db_session.execute(select(PrintArchive))).scalars().all() + assert len(rows) == 1 + assert rows[0].file_path == "/data/benchy.3mf" + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_falls_back_to_filename_and_start_without_hash(self, db_session): + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="/data/benchy.3mf", + file_size=2048, + started_at=datetime(2026, 3, 1, 10, 0, 0), + ) + ) + await db_session.commit() + tally = _CategoryTally() + + entry = self._archive_entry(content_hash=None) + await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {}) + await db_session.commit() + + assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1 + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_matches_archive_with_no_started_at_by_hash(self, db_session): + """started_at is NULL for re-sliced archives, so it cannot be required. + + Gating both match branches on it meant these rows never matched: every + restore re-inserted them and overwrite mode could never update them. + """ + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="/data/benchy.3mf", + file_size=2048, + content_hash="abc123", + started_at=None, + ) + ) + await db_session.commit() + tally = _CategoryTally() + + entry = self._archive_entry(started_at=None) + await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {}) + await db_session.commit() + + assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1 + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_started_at_still_discriminates_when_present(self, db_session): + """A NULL-tolerant match must not collapse rows that do differ.""" + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="/data/benchy.3mf", + file_size=2048, + content_hash="abc123", + started_at=datetime(2026, 3, 1, 10, 0, 0), + ) + ) + await db_session.commit() + tally = _CategoryTally() + + # Same file, no start time recorded — a different row, not that one. + entry = self._archive_entry(started_at=None) + await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {}) + await db_session.commit() + + assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2 + assert tally.restored == 1 + + @pytest.mark.asyncio + async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session): + """A backup keeps soft-deleted rows, so the flag has to survive. + + Their row is retained on purpose (stats keep counting the filament and + energy), so without carrying deleted_at a restore turns an archive the + user deleted back into a visible one. + """ + tally = _CategoryTally() + entry = self._archive_entry(deleted_at="2026-03-02 08:00:00") + + await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0) + assert tally.restored == 1 + + @pytest.mark.asyncio + async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session): + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="", + file_size=2048, + content_hash="abc123", + started_at=datetime(2026, 3, 1, 10, 0, 0), + deleted_at=datetime(2026, 3, 5, 9, 0, 0), + ) + ) + await db_session.commit() + tally = _CategoryTally() + + # The backup predates the deletion, so its copy is live. + await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0) + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session): + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="", + file_size=2048, + content_hash="abc123", + started_at=datetime(2026, 3, 1, 10, 0, 0), + deleted_at=datetime(2026, 3, 5, 9, 0, 0), + ) + ) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, True, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.deleted_at is None + assert tally.restored == 1 + assert any("visible again" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session): + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="/data/benchy.3mf", + file_size=2048, + content_hash="abc123", + started_at=datetime(2026, 3, 1, 10, 0, 0), + notes="old", + ) + ) + await db_session.commit() + tally = _CategoryTally() + + entry = self._archive_entry(notes="restored note") + await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.notes == "restored note" + # The 3MF on disk must not be orphaned by a metadata restore. + assert row.file_path == "/data/benchy.3mf" + assert tally.restored == 1 + + @pytest.mark.asyncio + async def test_dangling_printer_and_project_links_are_cleared(self, db_session): + tally = _CategoryTally() + entry = self._archive_entry(printer_id=4242, project_id=4343) + + await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.printer_id is None + assert row.project_id is None + assert any("no longer exist" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_valid_printer_link_is_preserved(self, db_session, printer_factory): + printer = await printer_factory() + tally = _CategoryTally() + entry = self._archive_entry(printer_id=printer.id) + + await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.printer_id == printer.id + + @pytest.mark.asyncio + async def test_non_dict_entry_counts_as_failed(self, db_session): + tally = _CategoryTally() + await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {}) + assert tally.failed == 1 + + +class TestRestoreKprofiles: + def _payload(self, serial="00M09A123456789", nozzle="0.4"): + return { + f"kprofiles/{serial}/{nozzle}.json": { + "version": "1.0", + "printer_serial": serial, + "nozzle_diameter": nozzle, + "profiles": [ + { + "slot_id": 0, + "name": "Bambu PLA", + "k_value": "0.020000", + "filament_id": "GFA00", + "nozzle_id": "HS00-0.4", + "extruder_id": 0, + "setting_id": "PFUS123", + } + ], + } + } + + @pytest.mark.asyncio + async def test_sends_batch_to_connected_printer(self, db_session, printer_factory): + printer = await printer_factory(serial_number="00M09A123456789") + client = MagicMock() + client.state.connected = True + client.set_kprofiles_batch = MagicMock(return_value=True) + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=client) + await _service()._restore_kprofiles(db_session, self._payload(), tally) + + client.set_kprofiles_batch.assert_called_once() + profiles, nozzle = client.set_kprofiles_batch.call_args.args + assert nozzle == "0.4" + assert profiles[0]["name"] == "Bambu PLA" + assert profiles[0]["filament_id"] == "GFA00" + assert tally.restored == 1 + assert manager.get_client.call_args.args == (printer.id,) + + @pytest.mark.asyncio + async def test_always_warns_that_mqtt_is_unacknowledged(self, db_session, printer_factory): + await printer_factory(serial_number="00M09A123456789") + client = MagicMock() + client.state.connected = True + client.set_kprofiles_batch = MagicMock(return_value=True) + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=client) + await _service()._restore_kprofiles(db_session, self._payload(), tally) + + assert any("without acknowledgement" in note for note in tally.notes) + assert any("always overwrite" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_unknown_serial_is_skipped_with_reason(self, db_session): + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager"): + await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally) + + assert tally.restored == 0 + assert tally.skipped == 1 + assert any("No printer with serial NOSUCH" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory): + await printer_factory(serial_number="00M09A123456789", name="Shelf Printer") + client = MagicMock() + client.state.connected = False + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=client) + await _service()._restore_kprofiles(db_session, self._payload(), tally) + + assert tally.skipped == 1 + assert tally.failed == 0 + assert any("not connected" in note for note in tally.notes) + + @pytest.mark.asyncio + async def test_no_client_at_all_is_skipped(self, db_session, printer_factory): + await printer_factory(serial_number="00M09A123456789") + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=None) + await _service()._restore_kprofiles(db_session, self._payload(), tally) + + assert tally.skipped == 1 + + @pytest.mark.asyncio + async def test_publish_failure_counts_as_failed(self, db_session, printer_factory): + await printer_factory(serial_number="00M09A123456789") + client = MagicMock() + client.state.connected = True + client.set_kprofiles_batch = MagicMock(return_value=False) + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=client) + await _service()._restore_kprofiles(db_session, self._payload(), tally) + + assert tally.failed == 1 + assert tally.restored == 0 + + @pytest.mark.asyncio + async def test_publish_exception_is_contained(self, db_session, printer_factory): + await printer_factory(serial_number="00M09A123456789") + client = MagicMock() + client.state.connected = True + client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down")) + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=client) + await _service()._restore_kprofiles(db_session, self._payload(), tally) + + assert tally.failed == 1 + + @pytest.mark.asyncio + async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory): + await printer_factory(serial_number="00M09A123456789") + payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")} + client = MagicMock() + client.state.connected = True + client.set_kprofiles_batch = MagicMock(return_value=True) + tally = _CategoryTally() + + with patch("backend.app.services.github_restore.printer_manager") as manager: + manager.get_client = MagicMock(return_value=client) + await _service()._restore_kprofiles(db_session, payload, tally) + + assert client.set_kprofiles_batch.call_count == 2 + assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"} + assert tally.restored == 2 + + @pytest.mark.asyncio + async def test_empty_payload_is_noted(self, db_session): + tally = _CategoryTally() + await _service()._restore_kprofiles(db_session, {}, tally) + assert any("No K-profile data" in note for note in tally.notes) + + +class TestSoftDeletedArchiveRoundTrip: + """The two halves of the soft-delete fix only work together. + + The collector keeps soft-deleted rows on purpose (their stats still count), + so if it doesn't write ``deleted_at`` there is nothing for the restore to + carry across and a deleted archive comes back visible. Covered end to end + because each half looks harmless on its own. + """ + + @pytest.mark.asyncio + async def test_deleted_at_survives_collect_then_restore(self, db_session): + from backend.app.services.github_backup import github_backup_service + + deleted_at = datetime(2026, 3, 5, 9, 0, 0) + db_session.add( + PrintArchive( + filename="trashed.3mf", + file_path="", + file_size=1024, + content_hash="hash-trashed", + started_at=datetime(2026, 3, 1, 10, 0, 0), + deleted_at=deleted_at, + ) + ) + await db_session.commit() + + files: dict = {} + await github_backup_service._collect_archives(db_session, files) + payload = files[ARCHIVES_PATH] + assert payload["archives"][0]["deleted_at"] == str(deleted_at) + + # Restore that payload into an instance where the row is gone entirely. + await db_session.execute(PrintArchive.__table__.delete()) + await db_session.commit() + + tally = _CategoryTally() + await _service()._restore_archives(db_session, payload, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.deleted_at == deleted_at, "a deleted archive must not come back visible" + + +class TestCategoryPathMapping: + def setup_method(self): + self.service = _service() + self.available = [ + "backup_metadata.json", + SETTINGS_PATH, + SPOOLS_PATH, + SPOOL_USAGE_PATH, + ARCHIVES_PATH, + "kprofiles/SERIAL1/0.4.json", + "kprofiles/SERIAL1/0.8.json", + "cloud_profiles/filament.json", + ] + + def test_spools_includes_usage_history(self): + paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available) + assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH] + + def test_kprofiles_globs_all_serials_and_nozzles(self): + paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available) + assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"] + + def test_absent_paths_are_omitted(self): + paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"]) + assert paths == [] + + def test_cloud_profiles_are_not_a_restore_category(self): + assert "cloud_profiles" not in {c.value for c in RestoreCategory} + + +class TestMutex: + @pytest.mark.asyncio + async def test_restore_refuses_while_a_backup_is_running(self): + service = _service() + with patch("backend.app.services.github_backup.github_backup_service") as backup: + backup.is_running = True + result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS]) + + assert result["success"] is False + assert "backup is currently running" in result["message"] + + @pytest.mark.asyncio + async def test_restore_refuses_while_another_restore_is_running(self): + service = _service() + service._running_restore = True + + result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS]) + + assert result["success"] is False + assert "restore is already running" in result["message"] + + @pytest.mark.asyncio + async def test_backup_refuses_while_a_restore_is_running(self): + from backend.app.services.github_backup import GitHubBackupService + + backup_service = GitHubBackupService() + with patch("backend.app.services.github_restore.github_restore_service") as restore: + restore.is_running = True + result = await backup_service.run_backup(1, trigger="manual") + + assert result["success"] is False + assert "restore is currently running" in result["message"] + + +class TestResolveRef: + @pytest.mark.asyncio + async def test_concrete_sha_passes_through_without_an_api_call(self): + service = _service() + service.list_commits = AsyncMock() + config = MagicMock(branch="main") + + resolved, error = await service._resolve_ref(config, "abc1234") + + assert resolved == "abc1234" + assert error == "" + service.list_commits.assert_not_awaited() + + @pytest.mark.asyncio + async def test_head_resolves_to_the_tip_sha(self): + service = _service() + service.list_commits = AsyncMock( + return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]} + ) + config = MagicMock(branch="main") + + resolved, error = await service._resolve_ref(config, "HEAD") + + assert resolved == "tipsha1" + assert error == "" + + @pytest.mark.asyncio + async def test_empty_history_is_an_error(self): + service = _service() + service.list_commits = AsyncMock(return_value={"success": True, "commits": []}) + config = MagicMock(branch="main") + + resolved, error = await service._resolve_ref(config, "HEAD") + + assert resolved is None + assert "no commits" in error diff --git a/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx b/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx new file mode 100644 index 000000000..1602b734c --- /dev/null +++ b/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx @@ -0,0 +1,367 @@ +/** + * Tests for the Restore from Git Backup modal (#2656). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { GitHubRestoreModal } from '../../components/GitHubRestoreModal'; + +const mockCommits = { + success: true, + message: 'OK', + branch: 'main', + commits: [ + { + sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0', + message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC', + author: 'Bambuddy', + date: '2026-07-02T10:00:00Z', + }, + { + sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111', + message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC', + author: 'Bambuddy', + date: '2026-07-01T10:00:00Z', + }, + ], +}; + +const mockPreview = { + success: true, + message: 'OK', + ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0', + commit: mockCommits.commits[0], + metadata_version: '1.0', + categories: [ + { category: 'archives', available: true, item_count: 30, detail: 'Metadata only' }, + { category: 'spools', available: true, item_count: 4, detail: null }, + { category: 'settings', available: true, item_count: 12, detail: null }, + { category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' }, + ], +}; + +type JsonBody = Record; + +function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) { + server.use( + http.get('/api/v1/github-backup/commits', () => + HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody)) + ), + http.get('/api/v1/github-backup/restore/preview', () => + HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody)) + ), + ); +} + +describe('GitHubRestoreModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEndpoints(); + }); + + it('renders the title and commit picker', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument(); + }); + expect(screen.getByLabelText('Backup commit')).toBeInTheDocument(); + }); + + it('defaults to the latest commit and lists recent commits', async () => { + render(); + + const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement; + expect(select.value).toBe('HEAD'); + await waitFor(() => { + expect(screen.getByText(/Latest backup/)).toBeInTheDocument(); + }); + // Commits are labelled by short SHA. + await waitFor(() => { + expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument(); + }); + }); + + it('shows item counts for categories present in the commit', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('30 in backup')).toBeInTheDocument(); + }); + expect(screen.getByText('4 in backup')).toBeInTheDocument(); + expect(screen.getByText('12 in backup')).toBeInTheDocument(); + }); + + it('disables a category that is absent from the commit', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[]; + // Four categories in fixed order: archives, spools, settings, kprofiles. + expect(checkboxes).toHaveLength(4); + expect(checkboxes[3].disabled).toBe(true); + expect(checkboxes[0].disabled).toBe(false); + }); + + it('keeps Restore disabled until a category is selected', async () => { + render(); + + const restoreButton = await screen.findByRole('button', { name: /Restore$/ }); + expect(restoreButton).toBeDisabled(); + + // Wait for the preview to populate the category list before selecting. + const checkboxes = await waitFor(() => { + const found = screen.getAllByRole('checkbox') as HTMLInputElement[]; + expect(found).toHaveLength(4); + return found; + }); + await userEvent.click(checkboxes[1]); + + await waitFor(() => expect(restoreButton).not.toBeDisabled()); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + }); + + it('requires confirmation before sending the restore', async () => { + let restoreCalls = 0; + server.use( + http.post('/api/v1/github-backup/restore', async () => { + restoreCalls += 1; + return HttpResponse.json({ + success: true, + message: 'Restored 4 item(s) from aaa1111', + log_id: 3, + ref: mockPreview.ref, + results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } }, + }); + }) + ); + render(); + + const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]); + await userEvent.click(checkboxes[1]); + await userEvent.click(screen.getByRole('button', { name: /Restore$/ })); + + // Confirm dialog appears; nothing sent yet. + await waitFor(() => { + expect(screen.getByText('Restore from backup?')).toBeInTheDocument(); + }); + expect(restoreCalls).toBe(0); + }); + + it('sends the selected categories and shows per-category results', async () => { + let body: Record | null = null; + server.use( + http.post('/api/v1/github-backup/restore', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ + success: true, + message: 'Restored 4 item(s) from aaa1111', + log_id: 3, + ref: mockPreview.ref, + results: { + spools: { restored: 4, skipped: 1, failed: 0, notes: ['1 usage record(s) skipped'] }, + }, + }); + }) + ); + render(); + + const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]); + await userEvent.click(checkboxes[1]); + await userEvent.click(screen.getByRole('button', { name: /Restore$/ })); + await waitFor(() => screen.getByText('Restore from backup?')); + + const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ }); + await userEvent.click(confirmButtons[confirmButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument(); + }); + // The commit posted is the sha the preview resolved to, not the symbolic + // 'HEAD' the picker defaults to: re-resolving server-side would restore a + // backup that landed after the preview the user actually approved. + expect(body).toMatchObject({ + categories: ['spools'], + overwrite_existing: false, + ref: mockPreview.ref, + }); + expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument(); + expect(screen.getByText('1 usage record(s) skipped')).toBeInTheDocument(); + }); + + it('sends overwrite_existing when the toggle is on', async () => { + let body: Record | null = null; + server.use( + http.post('/api/v1/github-backup/restore', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} }); + }) + ); + render(); + + const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]); + await userEvent.click(checkboxes[1]); + await userEvent.click(screen.getByRole('switch')); + await userEvent.click(screen.getByRole('button', { name: /Restore$/ })); + await waitFor(() => screen.getByText('Restore from backup?')); + const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ }); + await userEvent.click(confirmButtons[confirmButtons.length - 1]); + + await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true })); + }); + + it('warns more strongly when overwrite is enabled', async () => { + render(); + + const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]); + await userEvent.click(checkboxes[1]); + await userEvent.click(screen.getByRole('switch')); + await userEvent.click(screen.getByRole('button', { name: /Restore$/ })); + + await waitFor(() => { + expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument(); + }); + }); + + it('surfaces a preview failure instead of an empty category list', async () => { + mockEndpoints({ + preview: { + success: false, + message: 'Commit or tree deadbee not found in the repository', + ref: 'deadbee', + categories: [], + }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument(); + }); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + }); + + it('surfaces a commit listing failure', async () => { + mockEndpoints({ + commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText('Invalid access token')).toBeInTheDocument(); + }); + }); + + // A refused restore answers 200 with `success: false`, and two of the five + // refusals are ordinary conditions rather than errors — a restore already + // running, and a backup mid-flight. Rendering the result panel for those put a + // green tick and "reload so the restored data appears" above a message saying + // nothing had been restored, i.e. a failure that read as a success. + it('reports a backend refusal such as the backup/restore mutex', async () => { + server.use( + http.post('/api/v1/github-backup/restore', () => + HttpResponse.json({ + success: false, + message: 'A backup is currently running. Wait for it to finish before restoring.', + results: {}, + }) + ) + ); + render(); + + const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]); + await userEvent.click(checkboxes[1]); + await userEvent.click(screen.getByRole('button', { name: /Restore$/ })); + await waitFor(() => screen.getByText('Restore from backup?')); + const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ }); + await userEvent.click(confirmButtons[confirmButtons.length - 1]); + + await waitFor(() => { + expect( + screen.getByText('A backup is currently running. Wait for it to finish before restoring.') + ).toBeInTheDocument(); + }); + + // Not the success panel: no reload hint, no "Reload now", and the form is + // still there so the user can retry once the backup finishes. + expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument(); + expect(screen.getByLabelText('Backup commit')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled(); + }); + + it('does not refresh the data caches when a restore was refused', async () => { + server.use( + http.post('/api/v1/github-backup/restore', () => + HttpResponse.json({ success: false, message: 'A restore is already running', results: {} }) + ) + ); + const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries'); + render(); + + const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]); + await userEvent.click(checkboxes[1]); + await userEvent.click(screen.getByRole('button', { name: /Restore$/ })); + await waitFor(() => screen.getByText('Restore from backup?')); + const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ }); + await userEvent.click(confirmButtons[confirmButtons.length - 1]); + await waitFor(() => screen.getByText('A restore is already running')); + + const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey)); + // Nothing was written, so nothing to re-read... + expect(keys).not.toContain(JSON.stringify(['spools'])); + expect(keys).not.toContain(JSON.stringify(['archives'])); + // ...but a failure past the commit resolve writes a "failed" log row, so the + // history is refreshed whatever the outcome. + expect(keys).toContain(JSON.stringify(['github-backup-logs'])); + invalidate.mockRestore(); + }); + + // A provider-side failure answers 200 with `success: false`; a rejected + // *request* throws in `request()`, leaving `data` undefined. Reading the + // message off `data` alone meant the second kind rendered an empty modal — + // picker holding only "Latest", every category greyed out, no explanation. + it('explains a rejected preview request instead of greying out every category', async () => { + server.use( + http.get('/api/v1/github-backup/restore/preview', () => + HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 }) + ) + ); + render(); + + await waitFor(() => { + expect(screen.getByText('Not authenticated')).toBeInTheDocument(); + }); + // The category list is replaced by the error, not rendered disabled. + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + }); + + it('explains a rejected commit-list request', async () => { + server.use( + http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 })) + ); + render(); + + // No detail in the body, so the generic string carries the message. + await waitFor(() => { + expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument(); + }); + }); + + it('closes via the close button', async () => { + const onClose = vi.fn(); + render(); + + await waitFor(() => screen.getByText('Restore from Git Backup')); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0bdf7b3b9..9289fc0d3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2820,12 +2820,68 @@ export interface GitHubBackupStatus { configured: boolean; enabled: boolean; is_running: boolean; + restore_running: boolean; progress: string | null; last_backup_at: string | null; last_backup_status: string | null; next_scheduled_run: string | null; } +// Restore from a Git backup (#2656). Cloud profiles are absent deliberately — +// the backup collector never writes them, so there is nothing to restore. +export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives'; + +export interface GitHubCommitInfo { + sha: string; + message: string; + author: string; + date: string; +} + +export interface GitHubCommitListResponse { + success: boolean; + message: string; + branch: string; + commits: GitHubCommitInfo[]; +} + +export interface GitHubRestorePreviewCategory { + category: RestoreCategory; + available: boolean; + item_count: number; + detail: string | null; +} + +export interface GitHubRestorePreview { + success: boolean; + message: string; + ref: string; + commit: GitHubCommitInfo | null; + metadata_version: string | null; + categories: GitHubRestorePreviewCategory[]; +} + +export interface GitHubRestoreRequest { + ref?: string; + categories: RestoreCategory[]; + overwrite_existing?: boolean; +} + +export interface GitHubRestoreCategoryResult { + restored: number; + skipped: number; + failed: number; + notes: string[]; +} + +export interface GitHubRestoreResponse { + success: boolean; + message: string; + log_id: number | null; + ref: string | null; + results: Record; +} + export interface LocalBackupStatus { enabled: boolean; schedule: string; @@ -6684,6 +6740,19 @@ export const api = { clearGitHubBackupLogs: (keepLast: number = 10) => request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }), + // Restore from a Git backup (#2656) + getGitHubBackupCommits: (limit: number = 20) => + request(`/github-backup/commits?limit=${limit}`), + + getGitHubRestorePreview: (ref: string = 'HEAD') => + request(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`), + + restoreFromGitHub: (payload: GitHubRestoreRequest) => + request('/github-backup/restore', { + method: 'POST', + body: JSON.stringify(payload), + }), + // Scheduled local backups getLocalBackupStatus: () => request('/local-backup/status'), diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index f13a255fd..cf49c91b4 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -40,6 +40,7 @@ import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { ConfirmModal } from './ConfirmModal'; +import { GitHubRestoreModal } from './GitHubRestoreModal'; import { useToast } from '../contexts/ToastContext'; import { formatRelativeTime, parseUTCDate } from '../utils/date'; @@ -158,6 +159,9 @@ export function GitHubBackupSettings() { const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null); const fileInputRef = useRef(null); + // Restore from the Git backup repository (#2656) + const [showGitRestore, setShowGitRestore] = useState(false); + // Scheduled local backup state const [deleteConfirmFile, setDeleteConfirmFile] = useState(null); const [restoreConfirmFile, setRestoreConfirmFile] = useState(null); @@ -951,6 +955,16 @@ export function GitHubBackupSettings() { {testLoading ? : } {t('backup.test')} + {/* Restore from the backup repo (#2656) */} + )} @@ -1445,6 +1459,9 @@ export function GitHubBackupSettings() { + {/* Restore from the Git backup repository (#2656) */} + {showGitRestore && setShowGitRestore(false)} />} + {/* Delete Backup Confirmation Modal */} {deleteConfirmFile && ( void; +} + +interface CategoryMeta { + id: RestoreCategory; + labelKey: string; + icon: React.ReactNode; +} + +// Order mirrors the order the backend applies them in. Labels reuse the keys +// the backup checkbox group already ships in all locales. +const CATEGORIES: CategoryMeta[] = [ + { id: 'archives', labelKey: 'backup.printArchives', icon: }, + { id: 'spools', labelKey: 'backup.spoolInventory', icon: }, + { id: 'settings', labelKey: 'backup.appSettings', icon: }, + { id: 'kprofiles', labelKey: 'backup.kProfiles', icon: }, +]; + +const CATEGORY_LABEL_KEYS: Record = Object.fromEntries( + CATEGORIES.map((c) => [c.id, c.labelKey]) +); + +const LATEST = 'HEAD'; + +export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const [selectedRef, setSelectedRef] = useState(LATEST); + const [selected, setSelected] = useState>({}); + const [overwriteExisting, setOverwriteExisting] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [result, setResult] = useState(null); + + const commitsQuery = useQuery({ + queryKey: ['github-backup-commits'], + queryFn: () => api.getGitHubBackupCommits(20), + }); + + const previewQuery = useQuery({ + queryKey: ['github-restore-preview', selectedRef], + queryFn: () => api.getGitHubRestorePreview(selectedRef), + }); + + // Restore the exact commit the preview described, not the ref that was asked + // for. They differ for the default "Latest backup" selection, which posts the + // symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing + // between preview and restore would silently restore a different commit than + // the one whose contents the user just approved. + const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef; + + const restoreMutation = useMutation({ + mutationFn: () => + api.restoreFromGitHub({ + ref: resolvedRef, + categories: CATEGORIES.filter((c) => selected[c.id]).map((c) => c.id), + overwrite_existing: overwriteExisting, + }), + onSuccess: (data) => { + setShowConfirm(false); + // The endpoint answers 200 for a refused or failed restore too, with + // `success: false` and an empty `results` — and two of those are ordinary + // conditions, not errors: another restore already running, and a backup + // being mid-flight. Rendering the result panel for them showed a green + // tick, no tally at all and a "reload so the restored data appears" hint + // above a message saying nothing had been restored. Only a real success + // gets the panel; a failure keeps the form and shows the red block below. + if (data.success) { + setResult(data); + // A restore rewrites rows these caches hold. + queryClient.invalidateQueries({ queryKey: ['spools'] }); + queryClient.invalidateQueries({ queryKey: ['archives'] }); + queryClient.invalidateQueries({ queryKey: ['settings'] }); + } + // A failure that got as far as resolving the commit still writes a log row + // (status "failed"), so refresh the history and status either way. + queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] }); + queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); + }, + onError: () => setShowConfirm(false), + }); + + const isRestoring = restoreMutation.isPending; + + // Close on Escape, except while a restore is in flight. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isRestoring && !showConfirm) onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, isRestoring, showConfirm]); + + // Interrupting a restore mid-flight can leave a partly-applied category. + useEffect(() => { + if (!isRestoring) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isRestoring]); + + const availability = useMemo(() => { + const map: Record = {}; + previewQuery.data?.categories?.forEach((c) => { + map[c.category] = { available: c.available, itemCount: c.item_count, detail: c.detail }; + }); + return map; + }, [previewQuery.data]); + + // Selecting a category that isn't in the newly-picked commit would send a + // request the backend rejects, so drop those whenever the preview changes. + useEffect(() => { + if (!previewQuery.data) return; + setSelected((prev) => { + const next: Record = {}; + CATEGORIES.forEach((c) => { + next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available); + }); + return next; + }); + }, [previewQuery.data, availability]); + + const selectedCount = CATEGORIES.filter((c) => selected[c.id]).length; + const commits = commitsQuery.data?.commits ?? []; + + const formatCommitLabel = (sha: string, message: string, date: string) => { + const firstLine = (message || '').split('\n')[0]; + const when = date ? new Date(date).toLocaleString() : ''; + return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`; + }; + + // Two ways these can fail, and both have to reach the user. A provider-side + // failure (bad token, repo unreachable) answers 200 with `success: false` and + // a message. A rejected *request* — a 401/403 once the session expires with + // the modal open, a 500, the network dropping — throws in `request()`, so + // `data` is undefined: reading the message off `data` alone left the picker + // holding only "Latest" and every category greyed out by an empty availability + // map, with nothing on screen saying why. + const queryError = (query: { isError: boolean; error: unknown }) => + query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null; + + const previewError = + queryError(previewQuery) ?? + (previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null); + const commitsError = + queryError(commitsQuery) ?? + (commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null); + + return ( + <> +
+ e.stopPropagation()}> + + {/* Header */} +
+
+
+ +
+
+

{t('backup.restoreFromGit.title')}

+

{t('backup.restoreFromGit.subtitle')}

+
+
+ +
+ + {result ? ( + /* Result summary */ +
+
+ + {result.message} +
+ {Object.entries(result.results).map(([name, tally]) => ( +
+
+ + {CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name} + + + {t('backup.restoreFromGit.tally', { + restored: tally.restored, + skipped: tally.skipped, + failed: tally.failed, + })} + +
+ {tally.notes.length > 0 && ( +
    + {tally.notes.map((note) => ( +
  • + + {note} +
  • + ))} +
+ )} +
+ ))} +
+

+ {t('backup.restoreFromGit.reloadHint')} +

+
+
+ ) : ( +
+ {/* A restore that was refused or failed comes back here rather + than to the result panel, so keep these above the fold. */} + {restoreMutation.isError && ( +
+

+ {(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')} +

+
+ )} + {restoreMutation.data && !restoreMutation.data.success && ( +
+

{restoreMutation.data.message}

+
+ )} + + {/* Commit picker */} +
+ + + {commitsError &&

{commitsError}

} +
+ + {/* Category selection */} +
+

{t('backup.restoreFromGit.categoriesLabel')}

+ {previewQuery.isLoading ? ( +
+ + {t('backup.restoreFromGit.inspecting')} +
+ ) : previewError ? ( +
+

{previewError}

+
+ ) : ( +
+ {CATEGORIES.map((category) => { + const info = availability[category.id]; + const isAvailable = Boolean(info?.available); + const isChecked = Boolean(selected[category.id]) && isAvailable; + return ( + + ); + })} +
+ )} +
+ + {/* Overwrite toggle */} +
+
+
+

{t('backup.restoreFromGit.overwriteLabel')}

+

+ {overwriteExisting + ? t('backup.restoreFromGit.overwriteOn') + : t('backup.restoreFromGit.overwriteOff')} +

+
+ +
+
+ +
+ )} + + {/* Footer */} +
+ {result ? ( + <> + +
+ + +
+ + ) : ( + <> + + {t('backup.restoreFromGit.selectedCount', { count: selectedCount })} + +
+ + +
+ + )} +
+
+
+
+ + {showConfirm && ( + restoreMutation.mutate()} + onCancel={() => setShowConfirm(false)} + /> + )} + + ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 2adb916b7..a7dc0fc2e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -4873,6 +4873,30 @@ export default { clearedLogs: '{{count}} Protokolle gelöscht', failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Wiederherstellen', + title: 'Aus Git-Backup wiederherstellen', + subtitle: 'Commit auswählen und festlegen, was wiederhergestellt wird', + commitLabel: 'Backup-Commit', + latestCommit: 'Neuestes Backup (Branch-Spitze)', + categoriesLabel: 'Was wiederherstellen', + inspecting: 'Backup-Inhalt wird gelesen...', + itemCount: '{{count}} im Backup', + overwriteLabel: 'Vorhandene Einträge überschreiben', + overwriteOn: 'Vorhandene Einträge werden aus dem Backup aktualisiert.', + overwriteOff: 'Es werden nur fehlende Einträge ergänzt; vorhandene bleiben unverändert.', + selectedCount: '{{count}} ausgewählt', + restoring: 'Wird wiederhergestellt...', + confirmTitle: 'Aus Backup wiederherstellen?', + confirmMessage: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt. Fehlende Einträge werden ergänzt, vorhandene bleiben unverändert.', + confirmMessageOverwrite: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt und lokal vorhandene Einträge überschrieben. Dies kann nicht rückgängig gemacht werden.', + tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen', + reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.', + failed: 'Wiederherstellung fehlgeschlagen.', + loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.', + }, + // History history: 'Verlauf', clear: 'Löschen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 111494fe9..b5d4bcef4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4916,6 +4916,30 @@ export default { clearedLogs: 'Cleared {{count}} logs', failedToClearLogs: 'Failed to clear logs: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Restore', + title: 'Restore from Git Backup', + subtitle: 'Pick a commit and choose what to restore', + commitLabel: 'Backup commit', + latestCommit: 'Latest backup (branch tip)', + categoriesLabel: 'What to restore', + inspecting: 'Reading backup contents...', + itemCount: '{{count}} in backup', + overwriteLabel: 'Overwrite existing entries', + overwriteOn: 'Existing entries will be updated from the backup.', + overwriteOff: 'Only missing entries are added; existing ones are left untouched.', + selectedCount: '{{count}} selected', + restoring: 'Restoring...', + confirmTitle: 'Restore from backup?', + confirmMessage: 'The selected categories will be restored from this commit. Missing entries are added; existing entries stay as they are.', + confirmMessageOverwrite: 'The selected categories will be restored from this commit, overwriting entries that already exist locally. This cannot be undone.', + tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed', + reloadHint: 'Reload Bambuddy so the restored data appears everywhere.', + failed: 'Restore failed.', + loadFailed: 'Could not read the backup repository.', + }, + // History history: 'History', clear: 'Clear', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 79b856d56..01c587951 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -4881,6 +4881,30 @@ export default { clearedLogs: 'Se borraron {{count}} registros', failedToClearLogs: 'Error al borrar los registros: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Restaurar', + title: 'Restaurar desde copia de Git', + subtitle: 'Elige un commit y qué se debe restaurar', + commitLabel: 'Commit de la copia', + latestCommit: 'Última copia (punta de la rama)', + categoriesLabel: 'Qué restaurar', + inspecting: 'Leyendo el contenido de la copia...', + itemCount: '{{count}} en la copia', + overwriteLabel: 'Sobrescribir entradas existentes', + overwriteOn: 'Las entradas existentes se actualizarán desde la copia.', + overwriteOff: 'Solo se añaden las entradas que falten; las existentes no se modifican.', + selectedCount: '{{count}} seleccionados', + restoring: 'Restaurando...', + confirmTitle: '¿Restaurar desde la copia?', + confirmMessage: 'Las categorías seleccionadas se restaurarán desde este commit. Se añaden las entradas que falten y las existentes se mantienen igual.', + confirmMessageOverwrite: 'Las categorías seleccionadas se restaurarán desde este commit y se sobrescribirán las entradas que ya existan localmente. Esto no se puede deshacer.', + tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos', + reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.', + failed: 'La restauración ha fallado.', + loadFailed: 'No se pudo leer el repositorio de copias de seguridad.', + }, + // History history: 'Historial', clear: 'Borrar', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 8dcbe8348..4f1c36358 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -4862,6 +4862,30 @@ export default { clearedLogs: '{{count}} journaux supprimés', failedToClearLogs: 'Échec de la suppression des journaux : {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Restaurer', + title: 'Restaurer depuis la sauvegarde Git', + subtitle: 'Choisissez un commit et ce qui doit être restauré', + commitLabel: 'Commit de sauvegarde', + latestCommit: 'Dernière sauvegarde (tête de branche)', + categoriesLabel: 'Éléments à restaurer', + inspecting: 'Lecture du contenu de la sauvegarde...', + itemCount: '{{count}} dans la sauvegarde', + overwriteLabel: 'Écraser les entrées existantes', + overwriteOn: 'Les entrées existantes seront mises à jour depuis la sauvegarde.', + overwriteOff: 'Seules les entrées manquantes sont ajoutées ; les existantes ne sont pas modifiées.', + selectedCount: '{{count}} sélectionné(s)', + restoring: 'Restauration...', + confirmTitle: 'Restaurer depuis la sauvegarde ?', + confirmMessage: 'Les catégories sélectionnées seront restaurées depuis ce commit. Les entrées manquantes sont ajoutées, les existantes restent inchangées.', + confirmMessageOverwrite: 'Les catégories sélectionnées seront restaurées depuis ce commit et les entrées déjà présentes localement seront écrasées. Cette action est irréversible.', + tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec', + reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.', + failed: 'Échec de la restauration.', + loadFailed: 'Impossible de lire le dépôt de sauvegarde.', + }, + // History history: 'Historique', clear: 'Effacer', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 3ac04cec5..0e465dcb7 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -4861,6 +4861,30 @@ export default { clearedLogs: '{{count}} log eliminati', failedToClearLogs: 'Eliminazione log fallita: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Ripristina', + title: 'Ripristina dal backup Git', + subtitle: 'Scegli un commit e cosa ripristinare', + commitLabel: 'Commit del backup', + latestCommit: 'Ultimo backup (punta del branch)', + categoriesLabel: 'Cosa ripristinare', + inspecting: 'Lettura del contenuto del backup...', + itemCount: '{{count}} nel backup', + overwriteLabel: 'Sovrascrivi le voci esistenti', + overwriteOn: 'Le voci esistenti verranno aggiornate dal backup.', + overwriteOff: 'Vengono aggiunte solo le voci mancanti; quelle esistenti restano invariate.', + selectedCount: '{{count}} selezionati', + restoring: 'Ripristino in corso...', + confirmTitle: 'Ripristinare dal backup?', + confirmMessage: 'Le categorie selezionate verranno ripristinate da questo commit. Le voci mancanti vengono aggiunte, quelle esistenti restano invariate.', + confirmMessageOverwrite: 'Le categorie selezionate verranno ripristinate da questo commit sovrascrivendo le voci già presenti in locale. Operazione non annullabile.', + tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti', + reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.', + failed: 'Ripristino non riuscito.', + loadFailed: 'Impossibile leggere il repository di backup.', + }, + // History history: 'Cronologia', clear: 'Cancella', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index b866be8d6..69e33d784 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -4873,6 +4873,30 @@ export default { clearedLogs: '{{count}}件のログを削除しました', failedToClearLogs: 'ログの削除に失敗しました: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: '復元', + title: 'Git バックアップから復元', + subtitle: 'コミットと復元する項目を選択します', + commitLabel: 'バックアップのコミット', + latestCommit: '最新のバックアップ (ブランチ先端)', + categoriesLabel: '復元する項目', + inspecting: 'バックアップの内容を読み込んでいます...', + itemCount: 'バックアップ内に {{count}} 件', + overwriteLabel: '既存のエントリを上書きする', + overwriteOn: '既存のエントリはバックアップの内容で更新されます。', + overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。', + selectedCount: '{{count}} 件選択中', + restoring: '復元しています...', + confirmTitle: 'バックアップから復元しますか?', + confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。', + confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。', + tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件', + reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。', + failed: '復元に失敗しました。', + loadFailed: 'バックアップリポジトリを読み取れませんでした。', + }, + // History history: '履歴', clear: 'クリア', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 360804a58..437b7eba8 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -4637,6 +4637,30 @@ export default { backupFailed2: '백업 실패: {{message}}', clearedLogs: '{{count}}개 로그 초기화됨', failedToClearLogs: '로그 초기화 실패: {{message}}', + + // Restore from Git backup (#2656) + restoreFromGit: { + button: '복원', + title: 'Git 백업에서 복원', + subtitle: '커밋과 복원할 항목을 선택하세요', + commitLabel: '백업 커밋', + latestCommit: '최신 백업 (브랜치 최신 커밋)', + categoriesLabel: '복원할 항목', + inspecting: '백업 내용을 읽고 있습니다...', + itemCount: '백업에 {{count}}개', + overwriteLabel: '기존 항목 덮어쓰기', + overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.', + overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.', + selectedCount: '{{count}}개 선택됨', + restoring: '복원 중...', + confirmTitle: '백업에서 복원하시겠습니까?', + confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.', + confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.', + tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개', + reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.', + failed: '복원에 실패했습니다.', + loadFailed: '백업 저장소를 읽을 수 없습니다.', + }, history: '기록', clear: '초기화', date: '날짜', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index ae747b928..4751195c7 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -4861,6 +4861,30 @@ export default { clearedLogs: '{{count}} logs removidos', failedToClearLogs: 'Falha ao limpar logs: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Restaurar', + title: 'Restaurar do backup Git', + subtitle: 'Escolha um commit e o que deve ser restaurado', + commitLabel: 'Commit do backup', + latestCommit: 'Backup mais recente (ponta do branch)', + categoriesLabel: 'O que restaurar', + inspecting: 'Lendo o conteúdo do backup...', + itemCount: '{{count}} no backup', + overwriteLabel: 'Sobrescrever entradas existentes', + overwriteOn: 'As entradas existentes serão atualizadas a partir do backup.', + overwriteOff: 'Apenas as entradas ausentes são adicionadas; as existentes permanecem intactas.', + selectedCount: '{{count}} selecionados', + restoring: 'Restaurando...', + confirmTitle: 'Restaurar do backup?', + confirmMessage: 'As categorias selecionadas serão restauradas deste commit. As entradas ausentes são adicionadas e as existentes permanecem como estão.', + confirmMessageOverwrite: 'As categorias selecionadas serão restauradas deste commit, sobrescrevendo as entradas que já existem localmente. Não é possível desfazer.', + tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha', + reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.', + failed: 'Falha na restauração.', + loadFailed: 'Não foi possível ler o repositório de backup.', + }, + // History history: 'Histórico', clear: 'Limpar', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index a294e2487..b0e6d0d4f 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -4629,6 +4629,30 @@ export default { backupFailed2: "Ошибка резервного копирования: {{message}}", clearedLogs: "Очищено записей журнала: {{count}}", failedToClearLogs: "Не удалось очистить журнал: {{message}}", + + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Восстановить', + title: 'Восстановление из резервной копии Git', + subtitle: 'Выберите коммит и данные для восстановления', + commitLabel: 'Коммит резервной копии', + latestCommit: 'Последняя резервная копия (вершина ветки)', + categoriesLabel: 'Что восстановить', + inspecting: 'Чтение содержимого резервной копии...', + itemCount: '{{count}} в резервной копии', + overwriteLabel: 'Перезаписывать существующие записи', + overwriteOn: 'Существующие записи будут обновлены из резервной копии.', + overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.', + selectedCount: 'Выбрано: {{count}}', + restoring: 'Восстановление...', + confirmTitle: 'Восстановить из резервной копии?', + confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.', + confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.', + tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}', + reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.', + failed: 'Не удалось выполнить восстановление.', + loadFailed: 'Не удалось прочитать репозиторий резервных копий.', + }, history: "История", clear: "Очистить", date: "Дата", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index b8f9a0f09..47d1e81ff 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -4851,6 +4851,30 @@ export default { clearedLogs: '{{count}} günlük temizlendi', failedToClearLogs: 'Günlükler temizlenemedi: {{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: 'Geri Yükle', + title: 'Git yedeğinden geri yükle', + subtitle: 'Bir commit ve geri yüklenecek verileri seçin', + commitLabel: 'Yedek commit\'i', + latestCommit: 'En son yedek (dal ucu)', + categoriesLabel: 'Neler geri yüklenecek', + inspecting: 'Yedek içeriği okunuyor...', + itemCount: 'yedekte {{count}} kayıt', + overwriteLabel: 'Mevcut kayıtların üzerine yaz', + overwriteOn: 'Mevcut kayıtlar yedekten güncellenecek.', + overwriteOff: 'Yalnızca eksik kayıtlar eklenir; mevcut olanlara dokunulmaz.', + selectedCount: '{{count}} seçildi', + restoring: 'Geri yükleniyor...', + confirmTitle: 'Yedekten geri yüklensin mi?', + confirmMessage: 'Seçilen kategoriler bu commit\'ten geri yüklenecek. Eksik kayıtlar eklenir, mevcut kayıtlar olduğu gibi kalır.', + confirmMessageOverwrite: 'Seçilen kategoriler bu commit\'ten geri yüklenecek ve yerelde bulunan kayıtların üzerine yazılacak. Bu işlem geri alınamaz.', + tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız', + reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.', + failed: 'Geri yükleme başarısız oldu.', + loadFailed: 'Yedek deposu okunamadı.', + }, + history: 'Geçmiş', clear: 'Temizle', date: 'Tarih', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 0cb51efd9..8fefc26c1 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4861,6 +4861,30 @@ export default { clearedLogs: '已清除 {{count}} 条日志', failedToClearLogs: '清除日志失败:{{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: '恢复', + title: '从 Git 备份恢复', + subtitle: '选择提交以及要恢复的内容', + commitLabel: '备份提交', + latestCommit: '最新备份(分支最新提交)', + categoriesLabel: '恢复内容', + inspecting: '正在读取备份内容...', + itemCount: '备份中有 {{count}} 项', + overwriteLabel: '覆盖已有条目', + overwriteOn: '已有条目将根据备份内容更新。', + overwriteOff: '仅添加缺失的条目,已有条目保持不变。', + selectedCount: '已选择 {{count}} 项', + restoring: '正在恢复...', + confirmTitle: '要从备份恢复吗?', + confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。', + confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。', + tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项', + reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。', + failed: '恢复失败。', + loadFailed: '无法读取备份仓库。', + }, + // History history: '历史记录', clear: '清除', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 5eb2b89f7..6ecc3a15b 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -4861,6 +4861,30 @@ export default { clearedLogs: '已清除 {{count}} 條日誌', failedToClearLogs: '清除日誌失敗:{{message}}', + // Restore from Git backup (#2656) + restoreFromGit: { + button: '還原', + title: '從 Git 備份還原', + subtitle: '選擇提交以及要還原的項目', + commitLabel: '備份提交', + latestCommit: '最新備份(分支最新提交)', + categoriesLabel: '還原項目', + inspecting: '正在讀取備份內容...', + itemCount: '備份中有 {{count}} 筆', + overwriteLabel: '覆寫既有項目', + overwriteOn: '既有項目將依備份內容更新。', + overwriteOff: '僅新增缺少的項目,既有項目保持不變。', + selectedCount: '已選擇 {{count}} 筆', + restoring: '正在還原...', + confirmTitle: '要從備份還原嗎?', + confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。', + confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。', + tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆', + reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。', + failed: '還原失敗。', + loadFailed: '無法讀取備份儲存庫。', + }, + // History history: '歷史紀錄', clear: '清除',