test_github_backup_cloud_profiles.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. """Cloud-profile collection for Git backup (#2717).
  2. The collector used to read a ``setting`` key the Bambu Cloud API never returns,
  3. so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
  4. it was. It also asked for the auth-disabled credential store unconditionally,
  5. which meant it saw no accounts at all once auth was on. These tests pin the
  6. response shape it actually has to parse, the account enumeration, and the
  7. metadata now telling the truth.
  8. """
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.models.settings import Settings
  12. from backend.app.models.user import User
  13. from backend.app.services.github_backup import GitHubBackupService
  14. # The real listing body: keyed by preset type, each holding private/public
  15. # lists. There is no top-level "setting" array, and the entries carry no "type"
  16. # of their own — the type is the outer key, and Bambu calls process "print".
  17. BAMBU_LISTING = {
  18. "filament": {
  19. "private": [
  20. {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
  21. ],
  22. "public": [
  23. {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
  24. ],
  25. },
  26. "printer": {
  27. "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
  28. "public": [],
  29. },
  30. "print": {
  31. "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
  32. "public": [],
  33. },
  34. }
  35. def _detail(setting_id: str, name: str, base: str) -> dict:
  36. return {
  37. "setting_id": setting_id,
  38. "name": name,
  39. "type": "filament",
  40. "version": "1.0",
  41. "base_id": base,
  42. "filament_id": "P1234",
  43. "setting": {"filament_flow_ratio": ["0.98"]},
  44. }
  45. def _bambu_cloud(listing=None, detail_side_effect=None):
  46. cloud = MagicMock()
  47. cloud.is_authenticated = True
  48. cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
  49. cloud.get_setting_detail = AsyncMock(
  50. side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
  51. )
  52. cloud.close = AsyncMock()
  53. return cloud
  54. def _orca_service(profiles):
  55. svc = MagicMock()
  56. svc.list_profiles = AsyncMock(return_value=profiles)
  57. svc.close = AsyncMock()
  58. return svc
  59. @pytest.fixture
  60. def service():
  61. return GitHubBackupService()
  62. class TestCloudAccountEnumeration:
  63. """Which accounts a backup collects from."""
  64. @pytest.mark.asyncio
  65. async def test_auth_disabled_uses_the_global_store(self, service, db_session):
  66. """With auth off there is no User row at all — credentials live in the
  67. Settings table and the account is keyed ``global``."""
  68. with (
  69. patch(
  70. "backend.app.api.routes.cloud.get_stored_token",
  71. new_callable=AsyncMock,
  72. return_value=("bambu-token", "a@b.c", "global"),
  73. ),
  74. patch(
  75. "backend.app.api.routes.orca_cloud._load_credentials",
  76. new_callable=AsyncMock,
  77. return_value=MagicMock(token=None),
  78. ),
  79. ):
  80. bambu, orca = await service.cloud_accounts(db_session)
  81. assert bambu == [("global", None)]
  82. assert orca == []
  83. @pytest.mark.asyncio
  84. async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
  85. """The bug that made this invisible: with auth on, tokens live on User
  86. rows, and the collector only ever looked at the global store. Each cloud
  87. is enumerated separately so a user connected to one shows up only there.
  88. """
  89. both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
  90. bambu_only = User(username="bambu-only", cloud_token="t2")
  91. orca_only = User(username="orca-only", orca_cloud_token="o2")
  92. neither = User(username="neither")
  93. db_session.add_all([both, bambu_only, orca_only, neither])
  94. await db_session.commit()
  95. with (
  96. patch(
  97. "backend.app.api.routes.cloud.get_stored_token",
  98. new_callable=AsyncMock,
  99. return_value=(None, None, "global"),
  100. ),
  101. patch(
  102. "backend.app.api.routes.orca_cloud._load_credentials",
  103. new_callable=AsyncMock,
  104. return_value=MagicMock(token=None),
  105. ),
  106. ):
  107. bambu, orca = await service.cloud_accounts(db_session)
  108. assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
  109. assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
  110. @pytest.mark.asyncio
  111. async def test_global_and_per_user_accounts_coexist(self, service, db_session):
  112. """A Settings row survives someone enabling auth later. Dropping it
  113. would silently stop backing up that account's presets."""
  114. db_session.add(User(username="u", cloud_token="t1"))
  115. await db_session.commit()
  116. with (
  117. patch(
  118. "backend.app.api.routes.cloud.get_stored_token",
  119. new_callable=AsyncMock,
  120. return_value=("legacy-global", None, "global"),
  121. ),
  122. patch(
  123. "backend.app.api.routes.orca_cloud._load_credentials",
  124. new_callable=AsyncMock,
  125. return_value=MagicMock(token=None),
  126. ),
  127. ):
  128. bambu, _orca = await service.cloud_accounts(db_session)
  129. assert "global" in [key for key, _ in bambu]
  130. assert len(bambu) == 2
  131. class TestBambuCollection:
  132. @pytest.mark.asyncio
  133. async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
  134. """The whole bug in one assertion: presets come out of
  135. ``data[type]["private"]``, not a flat ``setting`` list, and ``print``
  136. maps to ``process``."""
  137. files: dict = {}
  138. with patch(
  139. "backend.app.api.routes.cloud.build_authenticated_cloud",
  140. new_callable=AsyncMock,
  141. return_value=_bambu_cloud(),
  142. ):
  143. counts = await service._collect_bambu_profiles(db_session, files, "global", None)
  144. assert counts == {"filament": 1, "printer": 1, "process": 1}
  145. assert set(files) == {
  146. "cloud_profiles/bambu/global/filament.json",
  147. "cloud_profiles/bambu/global/printer.json",
  148. "cloud_profiles/bambu/global/process.json",
  149. }
  150. @pytest.mark.asyncio
  151. async def test_public_presets_are_not_backed_up(self, service, db_session):
  152. """Bambu's bundled catalogue is identical for everyone, re-downloadable,
  153. and not recreatable under your account — backing it up would churn the
  154. repository on every run for no recovery value."""
  155. files: dict = {}
  156. with patch(
  157. "backend.app.api.routes.cloud.build_authenticated_cloud",
  158. new_callable=AsyncMock,
  159. return_value=_bambu_cloud(),
  160. ):
  161. await service._collect_bambu_profiles(db_session, files, "global", None)
  162. filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
  163. assert [p["setting_id"] for p in filament] == ["PFUS1"]
  164. @pytest.mark.asyncio
  165. async def test_stores_the_payload_a_restore_needs(self, service, db_session):
  166. """The listing is metadata only. Without ``base_id`` and ``setting``
  167. the backup is a list of names — ``create_setting`` cannot rebuild from
  168. it."""
  169. files: dict = {}
  170. with patch(
  171. "backend.app.api.routes.cloud.build_authenticated_cloud",
  172. new_callable=AsyncMock,
  173. return_value=_bambu_cloud(),
  174. ):
  175. await service._collect_bambu_profiles(db_session, files, "global", None)
  176. preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
  177. assert preset["base_id"] == "GFSA00"
  178. assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
  179. assert preset["type"] == "filament"
  180. @pytest.mark.asyncio
  181. async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
  182. """Backup repositories can be public, and ``user_id`` adds nothing to a
  183. rebuild."""
  184. files: dict = {}
  185. with patch(
  186. "backend.app.api.routes.cloud.build_authenticated_cloud",
  187. new_callable=AsyncMock,
  188. return_value=_bambu_cloud(),
  189. ):
  190. await service._collect_bambu_profiles(db_session, files, "global", None)
  191. for payload in files.values():
  192. for preset in payload["profiles"]:
  193. assert "user_id" not in preset
  194. @pytest.mark.asyncio
  195. async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
  196. """And it is counted, not swallowed — a partial backup that looks
  197. complete is how #2717 stayed invisible."""
  198. def detail(setting_id):
  199. if setting_id == "PFUS1":
  200. raise RuntimeError("boom")
  201. return _detail(setting_id, "ok", "GFSA00")
  202. files: dict = {}
  203. with patch(
  204. "backend.app.api.routes.cloud.build_authenticated_cloud",
  205. new_callable=AsyncMock,
  206. return_value=_bambu_cloud(detail_side_effect=detail),
  207. ):
  208. counts = await service._collect_bambu_profiles(db_session, files, "global", None)
  209. assert "cloud_profiles/bambu/global/filament.json" not in files
  210. assert counts["printer"] == 1
  211. assert counts["process"] == 1
  212. assert counts["failed"] == 1
  213. @pytest.mark.asyncio
  214. async def test_unauthenticated_account_writes_nothing(self, service, db_session):
  215. files: dict = {}
  216. with patch(
  217. "backend.app.api.routes.cloud.build_authenticated_cloud",
  218. new_callable=AsyncMock,
  219. return_value=None,
  220. ):
  221. counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
  222. assert counts == {}
  223. assert files == {}
  224. class TestOrcaCollection:
  225. @pytest.mark.asyncio
  226. async def test_groups_by_content_type_including_aliases(self, service, db_session):
  227. """Orca carries the type at ``content.type`` and uses BambuStudio-style
  228. aliases — ``machine`` is a printer, ``process`` and ``print`` are both
  229. process. Same map the Orca tab groups by."""
  230. profiles = [
  231. {"id": 1, "name": "f", "content": {"type": "filament"}},
  232. {"id": 2, "name": "m", "content": {"type": "machine"}},
  233. {"id": 3, "name": "p", "content": {"type": "print"}},
  234. {"id": 4, "name": "p2", "content": {"type": "process"}},
  235. ]
  236. files: dict = {}
  237. with patch(
  238. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  239. new_callable=AsyncMock,
  240. return_value=_orca_service(profiles),
  241. ):
  242. counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
  243. assert counts == {"filament": 1, "printer": 1, "process": 2}
  244. assert "cloud_profiles/orca/user-3/printer.json" in files
  245. @pytest.mark.asyncio
  246. async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
  247. """The sync-pull listing already carries each profile's content, so
  248. unlike Bambu there is no per-profile round trip."""
  249. svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
  250. files: dict = {}
  251. with patch(
  252. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  253. new_callable=AsyncMock,
  254. return_value=svc,
  255. ):
  256. await service._collect_orca_profiles(db_session, files, "global", None)
  257. stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
  258. assert stored["content"] == {"type": "filament", "flow": 0.98}
  259. assert svc.list_profiles.await_count == 1
  260. @pytest.mark.asyncio
  261. async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
  262. """The Orca *route* drops profiles whose type it can't render, which is
  263. right for a list and wrong for a backup: silently omitting a profile
  264. because Orca added a type is the same class of bug as #2717."""
  265. profiles = [
  266. {"id": 1, "name": "f", "content": {"type": "filament"}},
  267. {"id": 2, "name": "x", "content": {"type": "something_new"}},
  268. {"id": 3, "name": "y", "content": {}},
  269. ]
  270. files: dict = {}
  271. with patch(
  272. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  273. new_callable=AsyncMock,
  274. return_value=_orca_service(profiles),
  275. ):
  276. counts = await service._collect_orca_profiles(db_session, files, "global", None)
  277. assert counts["other"] == 2
  278. assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
  279. @pytest.mark.asyncio
  280. async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
  281. """An unexpected failure building the Orca client must not abort the
  282. rest of the backup — the other accounts and the other cloud still have
  283. profiles worth collecting."""
  284. files: dict = {}
  285. with patch(
  286. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  287. new_callable=AsyncMock,
  288. side_effect=RuntimeError("session expired"),
  289. ):
  290. counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
  291. assert counts == {}
  292. assert files == {}
  293. @pytest.mark.asyncio
  294. async def test_the_backup_never_disconnects_an_account(self, service, db_session):
  295. """A backup is an observer. It must not change anyone's sign-in state
  296. on a schedule — least of all on Orca's composite rejection reason,
  297. which cannot tell a real revocation from a lost refresh-rotation race.
  298. The Profiles route clears the dead pairing instead, with the user
  299. present to act on it.
  300. """
  301. from fastapi import HTTPException
  302. build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
  303. files: dict = {}
  304. with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
  305. counts = await service._collect_orca_profiles(db_session, files, "global", None)
  306. assert counts == {}
  307. assert build.await_args.kwargs["clear_on_auth_failure"] is False
  308. @pytest.mark.asyncio
  309. async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
  310. """Not clearing means the warning recurs every run, so the one line the
  311. operator sees has to say how to stop it."""
  312. from fastapi import HTTPException
  313. files: dict = {}
  314. with (
  315. caplog.at_level("WARNING"),
  316. patch(
  317. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  318. new_callable=AsyncMock,
  319. side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
  320. ),
  321. ):
  322. counts = await service._collect_orca_profiles(db_session, files, "global", None)
  323. assert counts == {}
  324. assert "paired again" in caplog.text
  325. assert "Later runs will skip it too" in caplog.text
  326. @pytest.mark.asyncio
  327. async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
  328. """502 is very likely gone by the next run, so it must not carry the
  329. "go and re-pair" advice a rejected session does."""
  330. from fastapi import HTTPException
  331. files: dict = {}
  332. with (
  333. caplog.at_level("WARNING"),
  334. patch(
  335. "backend.app.api.routes.orca_cloud._build_authenticated_service",
  336. new_callable=AsyncMock,
  337. side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
  338. ),
  339. ):
  340. counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
  341. assert counts == {}
  342. assert "unreachable" in caplog.text
  343. assert "paired again" not in caplog.text
  344. class TestCollectorAndMetadata:
  345. @pytest.mark.asyncio
  346. async def test_no_connected_account_collects_nothing(self, service, db_session):
  347. files: dict = {}
  348. with (
  349. patch(
  350. "backend.app.api.routes.cloud.get_stored_token",
  351. new_callable=AsyncMock,
  352. return_value=(None, None, "global"),
  353. ),
  354. patch(
  355. "backend.app.api.routes.orca_cloud._load_credentials",
  356. new_callable=AsyncMock,
  357. return_value=MagicMock(token=None),
  358. ),
  359. ):
  360. summary = await service._collect_cloud_profiles(db_session, files)
  361. assert summary == {"bambu": {}, "orca": {}}
  362. assert files == {}
  363. @pytest.mark.asyncio
  364. async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
  365. a = User(username="a", cloud_token="t1")
  366. b = User(username="b", cloud_token="t2")
  367. db_session.add_all([a, b])
  368. await db_session.commit()
  369. def build(db, user=None):
  370. if user is not None and user.username == "a":
  371. raise RuntimeError("cloud down for this account")
  372. return _bambu_cloud()
  373. files: dict = {}
  374. with (
  375. patch(
  376. "backend.app.api.routes.cloud.get_stored_token",
  377. new_callable=AsyncMock,
  378. return_value=(None, None, "global"),
  379. ),
  380. patch(
  381. "backend.app.api.routes.orca_cloud._load_credentials",
  382. new_callable=AsyncMock,
  383. return_value=MagicMock(token=None),
  384. ),
  385. patch(
  386. "backend.app.api.routes.cloud.build_authenticated_cloud",
  387. new_callable=AsyncMock,
  388. side_effect=build,
  389. ),
  390. ):
  391. summary = await service._collect_cloud_profiles(db_session, files)
  392. assert f"user-{a.id}" not in summary["bambu"]
  393. assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
  394. @pytest.mark.asyncio
  395. async def test_metadata_reports_collection_not_configuration(self, service, db_session):
  396. """``contents.cloud_profiles`` said ``true`` on every backup, including
  397. the ones that wrote nothing. A restore has to be able to trust it."""
  398. config = MagicMock(
  399. backup_kprofiles=False,
  400. backup_cloud_profiles=True,
  401. backup_settings=False,
  402. backup_spools=False,
  403. backup_archives=False,
  404. )
  405. with patch.object(
  406. service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
  407. ):
  408. files = await service._collect_backup_data(db_session, config)
  409. assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
  410. assert "cloud_profiles" not in files["backup_metadata.json"]
  411. @pytest.mark.asyncio
  412. async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
  413. config = MagicMock(
  414. backup_kprofiles=False,
  415. backup_cloud_profiles=True,
  416. backup_settings=False,
  417. backup_spools=False,
  418. backup_archives=False,
  419. )
  420. summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
  421. with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
  422. files = await service._collect_backup_data(db_session, config)
  423. assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
  424. assert files["backup_metadata.json"]["cloud_profiles"] == summary
  425. class TestSettingsFallbackIsStillHonoured:
  426. @pytest.mark.asyncio
  427. async def test_global_orca_row_is_discovered(self, service, db_session):
  428. """Orca's auth-disabled fallback lives in the same Settings table as
  429. Bambu's; both stores are read on every run."""
  430. db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
  431. await db_session.commit()
  432. with patch(
  433. "backend.app.api.routes.cloud.get_stored_token",
  434. new_callable=AsyncMock,
  435. return_value=(None, None, "global"),
  436. ):
  437. _bambu, orca = await service.cloud_accounts(db_session)
  438. assert orca == [("global", None)]