| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- """Cloud-profile collection for Git backup (#2717).
- The collector used to read a ``setting`` key the Bambu Cloud API never returns,
- so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
- it was. It also asked for the auth-disabled credential store unconditionally,
- which meant it saw no accounts at all once auth was on. These tests pin the
- response shape it actually has to parse, the account enumeration, and the
- metadata now telling the truth.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- from backend.app.services.github_backup import GitHubBackupService
- # The real listing body: keyed by preset type, each holding private/public
- # lists. There is no top-level "setting" array, and the entries carry no "type"
- # of their own — the type is the outer key, and Bambu calls process "print".
- BAMBU_LISTING = {
- "filament": {
- "private": [
- {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
- ],
- "public": [
- {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
- ],
- },
- "printer": {
- "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
- "public": [],
- },
- "print": {
- "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
- "public": [],
- },
- }
- def _detail(setting_id: str, name: str, base: str) -> dict:
- return {
- "setting_id": setting_id,
- "name": name,
- "type": "filament",
- "version": "1.0",
- "base_id": base,
- "filament_id": "P1234",
- "setting": {"filament_flow_ratio": ["0.98"]},
- }
- def _bambu_cloud(listing=None, detail_side_effect=None):
- cloud = MagicMock()
- cloud.is_authenticated = True
- cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
- cloud.get_setting_detail = AsyncMock(
- side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
- )
- cloud.close = AsyncMock()
- return cloud
- def _orca_service(profiles):
- svc = MagicMock()
- svc.list_profiles = AsyncMock(return_value=profiles)
- svc.close = AsyncMock()
- return svc
- @pytest.fixture
- def service():
- return GitHubBackupService()
- class TestCloudAccountEnumeration:
- """Which accounts a backup collects from."""
- @pytest.mark.asyncio
- async def test_auth_disabled_uses_the_global_store(self, service, db_session):
- """With auth off there is no User row at all — credentials live in the
- Settings table and the account is keyed ``global``."""
- with (
- patch(
- "backend.app.api.routes.cloud.get_stored_token",
- new_callable=AsyncMock,
- return_value=("bambu-token", "a@b.c", "global"),
- ),
- patch(
- "backend.app.api.routes.orca_cloud._load_credentials",
- new_callable=AsyncMock,
- return_value=MagicMock(token=None),
- ),
- ):
- bambu, orca = await service.cloud_accounts(db_session)
- assert bambu == [("global", None)]
- assert orca == []
- @pytest.mark.asyncio
- async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
- """The bug that made this invisible: with auth on, tokens live on User
- rows, and the collector only ever looked at the global store. Each cloud
- is enumerated separately so a user connected to one shows up only there.
- """
- both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
- bambu_only = User(username="bambu-only", cloud_token="t2")
- orca_only = User(username="orca-only", orca_cloud_token="o2")
- neither = User(username="neither")
- db_session.add_all([both, bambu_only, orca_only, neither])
- await db_session.commit()
- with (
- patch(
- "backend.app.api.routes.cloud.get_stored_token",
- new_callable=AsyncMock,
- return_value=(None, None, "global"),
- ),
- patch(
- "backend.app.api.routes.orca_cloud._load_credentials",
- new_callable=AsyncMock,
- return_value=MagicMock(token=None),
- ),
- ):
- bambu, orca = await service.cloud_accounts(db_session)
- assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
- assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
- @pytest.mark.asyncio
- async def test_global_and_per_user_accounts_coexist(self, service, db_session):
- """A Settings row survives someone enabling auth later. Dropping it
- would silently stop backing up that account's presets."""
- db_session.add(User(username="u", cloud_token="t1"))
- await db_session.commit()
- with (
- patch(
- "backend.app.api.routes.cloud.get_stored_token",
- new_callable=AsyncMock,
- return_value=("legacy-global", None, "global"),
- ),
- patch(
- "backend.app.api.routes.orca_cloud._load_credentials",
- new_callable=AsyncMock,
- return_value=MagicMock(token=None),
- ),
- ):
- bambu, _orca = await service.cloud_accounts(db_session)
- assert "global" in [key for key, _ in bambu]
- assert len(bambu) == 2
- class TestBambuCollection:
- @pytest.mark.asyncio
- async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
- """The whole bug in one assertion: presets come out of
- ``data[type]["private"]``, not a flat ``setting`` list, and ``print``
- maps to ``process``."""
- files: dict = {}
- with patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- return_value=_bambu_cloud(),
- ):
- counts = await service._collect_bambu_profiles(db_session, files, "global", None)
- assert counts == {"filament": 1, "printer": 1, "process": 1}
- assert set(files) == {
- "cloud_profiles/bambu/global/filament.json",
- "cloud_profiles/bambu/global/printer.json",
- "cloud_profiles/bambu/global/process.json",
- }
- @pytest.mark.asyncio
- async def test_public_presets_are_not_backed_up(self, service, db_session):
- """Bambu's bundled catalogue is identical for everyone, re-downloadable,
- and not recreatable under your account — backing it up would churn the
- repository on every run for no recovery value."""
- files: dict = {}
- with patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- return_value=_bambu_cloud(),
- ):
- await service._collect_bambu_profiles(db_session, files, "global", None)
- filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
- assert [p["setting_id"] for p in filament] == ["PFUS1"]
- @pytest.mark.asyncio
- async def test_stores_the_payload_a_restore_needs(self, service, db_session):
- """The listing is metadata only. Without ``base_id`` and ``setting``
- the backup is a list of names — ``create_setting`` cannot rebuild from
- it."""
- files: dict = {}
- with patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- return_value=_bambu_cloud(),
- ):
- await service._collect_bambu_profiles(db_session, files, "global", None)
- preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
- assert preset["base_id"] == "GFSA00"
- assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
- assert preset["type"] == "filament"
- @pytest.mark.asyncio
- async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
- """Backup repositories can be public, and ``user_id`` adds nothing to a
- rebuild."""
- files: dict = {}
- with patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- return_value=_bambu_cloud(),
- ):
- await service._collect_bambu_profiles(db_session, files, "global", None)
- for payload in files.values():
- for preset in payload["profiles"]:
- assert "user_id" not in preset
- @pytest.mark.asyncio
- async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
- """And it is counted, not swallowed — a partial backup that looks
- complete is how #2717 stayed invisible."""
- def detail(setting_id):
- if setting_id == "PFUS1":
- raise RuntimeError("boom")
- return _detail(setting_id, "ok", "GFSA00")
- files: dict = {}
- with patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- return_value=_bambu_cloud(detail_side_effect=detail),
- ):
- counts = await service._collect_bambu_profiles(db_session, files, "global", None)
- assert "cloud_profiles/bambu/global/filament.json" not in files
- assert counts["printer"] == 1
- assert counts["process"] == 1
- assert counts["failed"] == 1
- @pytest.mark.asyncio
- async def test_unauthenticated_account_writes_nothing(self, service, db_session):
- files: dict = {}
- with patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- return_value=None,
- ):
- counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
- assert counts == {}
- assert files == {}
- class TestOrcaCollection:
- @pytest.mark.asyncio
- async def test_groups_by_content_type_including_aliases(self, service, db_session):
- """Orca carries the type at ``content.type`` and uses BambuStudio-style
- aliases — ``machine`` is a printer, ``process`` and ``print`` are both
- process. Same map the Orca tab groups by."""
- profiles = [
- {"id": 1, "name": "f", "content": {"type": "filament"}},
- {"id": 2, "name": "m", "content": {"type": "machine"}},
- {"id": 3, "name": "p", "content": {"type": "print"}},
- {"id": 4, "name": "p2", "content": {"type": "process"}},
- ]
- files: dict = {}
- with patch(
- "backend.app.api.routes.orca_cloud._build_authenticated_service",
- new_callable=AsyncMock,
- return_value=_orca_service(profiles),
- ):
- counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
- assert counts == {"filament": 1, "printer": 1, "process": 2}
- assert "cloud_profiles/orca/user-3/printer.json" in files
- @pytest.mark.asyncio
- async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
- """The sync-pull listing already carries each profile's content, so
- unlike Bambu there is no per-profile round trip."""
- svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
- files: dict = {}
- with patch(
- "backend.app.api.routes.orca_cloud._build_authenticated_service",
- new_callable=AsyncMock,
- return_value=svc,
- ):
- await service._collect_orca_profiles(db_session, files, "global", None)
- stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
- assert stored["content"] == {"type": "filament", "flow": 0.98}
- assert svc.list_profiles.await_count == 1
- @pytest.mark.asyncio
- async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
- """The Orca *route* drops profiles whose type it can't render, which is
- right for a list and wrong for a backup: silently omitting a profile
- because Orca added a type is the same class of bug as #2717."""
- profiles = [
- {"id": 1, "name": "f", "content": {"type": "filament"}},
- {"id": 2, "name": "x", "content": {"type": "something_new"}},
- {"id": 3, "name": "y", "content": {}},
- ]
- files: dict = {}
- with patch(
- "backend.app.api.routes.orca_cloud._build_authenticated_service",
- new_callable=AsyncMock,
- return_value=_orca_service(profiles),
- ):
- counts = await service._collect_orca_profiles(db_session, files, "global", None)
- assert counts["other"] == 2
- assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
- @pytest.mark.asyncio
- async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
- """An unexpected failure building the Orca client must not abort the
- rest of the backup — the other accounts and the other cloud still have
- profiles worth collecting."""
- files: dict = {}
- with patch(
- "backend.app.api.routes.orca_cloud._build_authenticated_service",
- new_callable=AsyncMock,
- side_effect=RuntimeError("session expired"),
- ):
- counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
- assert counts == {}
- assert files == {}
- @pytest.mark.asyncio
- async def test_the_backup_never_disconnects_an_account(self, service, db_session):
- """A backup is an observer. It must not change anyone's sign-in state
- on a schedule — least of all on Orca's composite rejection reason,
- which cannot tell a real revocation from a lost refresh-rotation race.
- The Profiles route clears the dead pairing instead, with the user
- present to act on it.
- """
- from fastapi import HTTPException
- build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
- files: dict = {}
- with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
- counts = await service._collect_orca_profiles(db_session, files, "global", None)
- assert counts == {}
- assert build.await_args.kwargs["clear_on_auth_failure"] is False
- @pytest.mark.asyncio
- async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
- """Not clearing means the warning recurs every run, so the one line the
- operator sees has to say how to stop it."""
- from fastapi import HTTPException
- files: dict = {}
- with (
- caplog.at_level("WARNING"),
- patch(
- "backend.app.api.routes.orca_cloud._build_authenticated_service",
- new_callable=AsyncMock,
- side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
- ),
- ):
- counts = await service._collect_orca_profiles(db_session, files, "global", None)
- assert counts == {}
- assert "paired again" in caplog.text
- assert "Later runs will skip it too" in caplog.text
- @pytest.mark.asyncio
- async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
- """502 is very likely gone by the next run, so it must not carry the
- "go and re-pair" advice a rejected session does."""
- from fastapi import HTTPException
- files: dict = {}
- with (
- caplog.at_level("WARNING"),
- patch(
- "backend.app.api.routes.orca_cloud._build_authenticated_service",
- new_callable=AsyncMock,
- side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
- ),
- ):
- counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
- assert counts == {}
- assert "unreachable" in caplog.text
- assert "paired again" not in caplog.text
- class TestCollectorAndMetadata:
- @pytest.mark.asyncio
- async def test_no_connected_account_collects_nothing(self, service, db_session):
- files: dict = {}
- with (
- patch(
- "backend.app.api.routes.cloud.get_stored_token",
- new_callable=AsyncMock,
- return_value=(None, None, "global"),
- ),
- patch(
- "backend.app.api.routes.orca_cloud._load_credentials",
- new_callable=AsyncMock,
- return_value=MagicMock(token=None),
- ),
- ):
- summary = await service._collect_cloud_profiles(db_session, files)
- assert summary == {"bambu": {}, "orca": {}}
- assert files == {}
- @pytest.mark.asyncio
- async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
- a = User(username="a", cloud_token="t1")
- b = User(username="b", cloud_token="t2")
- db_session.add_all([a, b])
- await db_session.commit()
- def build(db, user=None):
- if user is not None and user.username == "a":
- raise RuntimeError("cloud down for this account")
- return _bambu_cloud()
- files: dict = {}
- with (
- patch(
- "backend.app.api.routes.cloud.get_stored_token",
- new_callable=AsyncMock,
- return_value=(None, None, "global"),
- ),
- patch(
- "backend.app.api.routes.orca_cloud._load_credentials",
- new_callable=AsyncMock,
- return_value=MagicMock(token=None),
- ),
- patch(
- "backend.app.api.routes.cloud.build_authenticated_cloud",
- new_callable=AsyncMock,
- side_effect=build,
- ),
- ):
- summary = await service._collect_cloud_profiles(db_session, files)
- assert f"user-{a.id}" not in summary["bambu"]
- assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
- @pytest.mark.asyncio
- async def test_metadata_reports_collection_not_configuration(self, service, db_session):
- """``contents.cloud_profiles`` said ``true`` on every backup, including
- the ones that wrote nothing. A restore has to be able to trust it."""
- config = MagicMock(
- backup_kprofiles=False,
- backup_cloud_profiles=True,
- backup_settings=False,
- backup_spools=False,
- backup_archives=False,
- )
- with patch.object(
- service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
- ):
- files = await service._collect_backup_data(db_session, config)
- assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
- assert "cloud_profiles" not in files["backup_metadata.json"]
- @pytest.mark.asyncio
- async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
- config = MagicMock(
- backup_kprofiles=False,
- backup_cloud_profiles=True,
- backup_settings=False,
- backup_spools=False,
- backup_archives=False,
- )
- summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
- with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
- files = await service._collect_backup_data(db_session, config)
- assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
- assert files["backup_metadata.json"]["cloud_profiles"] == summary
- class TestSettingsFallbackIsStillHonoured:
- @pytest.mark.asyncio
- async def test_global_orca_row_is_discovered(self, service, db_session):
- """Orca's auth-disabled fallback lives in the same Settings table as
- Bambu's; both stores are read on every run."""
- db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
- await db_session.commit()
- with patch(
- "backend.app.api.routes.cloud.get_stored_token",
- new_callable=AsyncMock,
- return_value=(None, None, "global"),
- ):
- _bambu, orca = await service.cloud_accounts(db_session)
- assert orca == [("global", None)]
|