Files
bambuddy/backend/tests/unit/test_git_providers_restore.py
T
jmoore-skild 6a239314dc 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.
2026-08-04 08:22:57 -04:00

432 lines
16 KiB
Python

"""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"] == {}