bambuddy/backend/tests/unit/test_github_backup_cloud_profiles.py
maziggy 455a9e4ba7 fix(backup): collect cloud profiles from every connected account (#2717)
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.

The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.

It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.

Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.

Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:

    cloud_profiles/bambu/user-3/{filament,printer,process}.json
    cloud_profiles/orca/user-3/{filament,printer,process}.json

Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.

Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.

Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.

The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.

The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.

One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.

Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
2026-07-31 16:59:33 +02:00

509 lines
20 KiB
Python

"""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)]