test_github_restore.py 82 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946
  1. """Unit tests for the Git backup restore service (#2656).
  2. Focus is on the per-category appliers: natural-key matching, the deliberate
  3. refusal to reuse the backup's primary keys, old_id -> new_id remapping for
  4. dependent rows, overwrite-vs-skip, the settings credential blocklist, and the
  5. K-profile paths that depend on live printers.
  6. """
  7. from datetime import datetime
  8. from types import SimpleNamespace
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from sqlalchemy import select
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.settings import Settings
  14. from backend.app.models.spool import Spool
  15. from backend.app.models.spool_usage_history import SpoolUsageHistory
  16. from backend.app.models.user import User
  17. from backend.app.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
  18. from backend.app.services.github_restore import (
  19. _COMPANION_CREDENTIAL_ENV,
  20. _COMPANION_CREDENTIALS,
  21. ARCHIVES_PATH,
  22. SETTINGS_PATH,
  23. SPOOL_USAGE_PATH,
  24. SPOOLS_PATH,
  25. GitHubRestoreService,
  26. _CategoryTally,
  27. _is_blocked_setting_key,
  28. _is_protected_setting_key,
  29. _is_usable_credential,
  30. _parse_dt,
  31. _setting_value_is_true,
  32. _SettingsPlan,
  33. )
  34. def _service() -> GitHubRestoreService:
  35. return GitHubRestoreService()
  36. def _messages(tally: _CategoryTally) -> list[str]:
  37. """The English rendering of each note.
  38. Notes are ``{code, params, message}`` since they became translatable
  39. (#2656); asserting on the message keeps these tests readable while
  40. ``_codes`` covers the half a client actually keys on.
  41. """
  42. return [note["message"] for note in tally.notes]
  43. def _codes(tally: _CategoryTally) -> list[str]:
  44. return [note["code"] for note in tally.notes]
  45. class TestParseDt:
  46. def test_parses_str_datetime_the_backup_writes(self):
  47. assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
  48. def test_parses_iso_with_t_separator(self):
  49. assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5)
  50. @pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}])
  51. def test_returns_none_for_junk(self, value):
  52. assert _parse_dt(value) is None
  53. class TestSettingKeyBlocklist:
  54. @pytest.mark.parametrize(
  55. "key",
  56. [
  57. "bambu_cloud_token",
  58. "auth_secret_key",
  59. "ha_token",
  60. "prometheus_token",
  61. "printer_access_code",
  62. "smtp_password",
  63. "some_api_key",
  64. "ftp_passphrase",
  65. "MQTT_SECRET",
  66. ],
  67. )
  68. def test_credential_like_keys_are_blocked(self, key):
  69. assert _is_blocked_setting_key(key) is True
  70. @pytest.mark.parametrize(
  71. "key",
  72. ["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"],
  73. )
  74. def test_ordinary_keys_are_allowed(self, key):
  75. assert _is_blocked_setting_key(key) is False
  76. @pytest.mark.parametrize(
  77. "key",
  78. ["auth_enabled", "advanced_auth_enabled", "local_login_enabled", "setup_completed"],
  79. )
  80. def test_auth_policy_keys_are_protected(self, key):
  81. # Not credential-shaped, so the secret hints never catch them.
  82. assert _is_blocked_setting_key(key) is False
  83. assert _is_protected_setting_key(key) is True
  84. @pytest.mark.parametrize("key", ["currency", "ldap_enabled", "auth_secret_key"])
  85. def test_protected_set_is_only_the_auth_policy_keys(self, key):
  86. assert _is_protected_setting_key(key) is False
  87. def test_ha_token_from_env_is_deliberately_not_carved_out(self):
  88. """Recorded so the review's question about it is not re-litigated.
  89. ``ha_token_from_env`` looks like a false positive for the ``token`` hint,
  90. but it is only ever constructed in the settings GET response
  91. (``get_homeassistant_settings``). It is absent from ``AppSettingsUpdate``
  92. and so is never a ``Settings`` row — it cannot reach a backup, which
  93. makes an allowlist entry for it dead code.
  94. Carving it out would also be a live hole rather than a tidy-up: an
  95. attacker-authored ``settings/app_settings.json`` could then get a
  96. ``*token*``-named row written simply by choosing that name. This
  97. blocklist's whole job is belt-and-braces, so a name-shaped exception to
  98. it is exactly the wrong shape of fix.
  99. """
  100. assert _is_blocked_setting_key("ha_token_from_env") is True
  101. class TestCategoryTally:
  102. def test_a_note_carries_code_params_and_english(self):
  103. tally = _CategoryTally()
  104. tally.note("noData", "No data of this kind in this backup")
  105. tally.note("spoolUsageUnresolved", "2 usage record(s) skipped", count=2)
  106. assert tally.notes == [
  107. {"code": "noData", "params": {}, "message": "No data of this kind in this backup"},
  108. {"code": "spoolUsageUnresolved", "params": {"count": 2}, "message": "2 usage record(s) skipped"},
  109. ]
  110. def test_notes_are_deduplicated(self):
  111. tally = _CategoryTally()
  112. tally.note("noData", "same")
  113. tally.note("noData", "same")
  114. assert len(tally.notes) == 1
  115. def test_the_same_code_with_different_params_is_kept(self):
  116. """Two printers can both be offline, and the user needs both names."""
  117. tally = _CategoryTally()
  118. tally.note("kprofilesPrinterOffline", "A is not connected", printer="A")
  119. tally.note("kprofilesPrinterOffline", "B is not connected", printer="B")
  120. assert len(tally.notes) == 2
  121. def test_notes_are_bounded(self):
  122. tally = _CategoryTally()
  123. for i in range(50):
  124. tally.note("noData", f"note {i}", index=i)
  125. assert len(tally.notes) == 20
  126. class TestRestoreRequestSchema:
  127. def test_rejects_empty_category_list(self):
  128. with pytest.raises(ValueError):
  129. GitHubRestoreRequest(categories=[])
  130. def test_deduplicates_categories(self):
  131. request = GitHubRestoreRequest(
  132. categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
  133. )
  134. assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
  135. def test_defaults_to_head(self):
  136. assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD"
  137. @pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40])
  138. def test_accepts_valid_refs(self, ref):
  139. assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref
  140. @pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"])
  141. def test_rejects_refs_that_are_not_object_names(self, ref):
  142. with pytest.raises(ValueError):
  143. GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS])
  144. class TestRestoreSettings:
  145. @pytest.mark.asyncio
  146. async def test_inserts_missing_keys(self, db_session):
  147. tally = _CategoryTally()
  148. payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
  149. await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
  150. await db_session.commit()
  151. rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
  152. assert rows == {"currency": "EUR", "theme": "dark"}
  153. assert tally.restored == 2
  154. @pytest.mark.asyncio
  155. async def test_skips_existing_key_when_overwrite_off(self, db_session):
  156. db_session.add(Settings(key="currency", value="USD"))
  157. await db_session.commit()
  158. tally = _CategoryTally()
  159. await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally)
  160. await db_session.commit()
  161. row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
  162. assert row.value == "USD"
  163. assert tally.skipped == 1
  164. assert tally.restored == 0
  165. @pytest.mark.asyncio
  166. async def test_overwrites_existing_key_when_enabled(self, db_session):
  167. db_session.add(Settings(key="currency", value="USD"))
  168. await db_session.commit()
  169. tally = _CategoryTally()
  170. await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally)
  171. await db_session.commit()
  172. row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
  173. assert row.value == "EUR"
  174. assert tally.restored == 1
  175. @pytest.mark.asyncio
  176. async def test_credential_keys_are_never_restored(self, db_session):
  177. """A backup predating the collector's denylist can still contain secrets."""
  178. tally = _CategoryTally()
  179. payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}}
  180. await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
  181. await db_session.commit()
  182. keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()}
  183. assert keys == {"currency"}
  184. # Refusals are notes, not tally rows: the preview never counted these
  185. # keys, so counting them here would put the total above what the user
  186. # was shown before they pressed Restore.
  187. assert tally.skipped == 0
  188. assert any("credential-like" in note for note in _messages(tally))
  189. @pytest.mark.asyncio
  190. async def test_auth_settings_are_never_restored(self, db_session):
  191. """Restoring auth_enabled=false would disable auth behind the cache's back."""
  192. db_session.add(Settings(key="auth_enabled", value="true"))
  193. db_session.add(Settings(key="local_login_enabled", value="true"))
  194. await db_session.commit()
  195. tally = _CategoryTally()
  196. payload = {
  197. "settings": {
  198. "currency": "EUR",
  199. "auth_enabled": "false",
  200. "advanced_auth_enabled": "false",
  201. "local_login_enabled": "false",
  202. "setup_completed": "false",
  203. }
  204. }
  205. await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
  206. await db_session.commit()
  207. rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
  208. assert rows["auth_enabled"] == "true"
  209. assert rows["local_login_enabled"] == "true"
  210. assert "advanced_auth_enabled" not in rows
  211. assert "setup_completed" not in rows
  212. assert rows["currency"] == "EUR"
  213. assert tally.restored == 1
  214. # As above: refused keys are outside the preview's count, so outside the
  215. # tally too.
  216. assert tally.skipped == 0
  217. assert any("authentication setting" in note for note in _messages(tally))
  218. @pytest.mark.asyncio
  219. async def test_missing_payload_is_noted_not_fatal(self, db_session):
  220. tally = _CategoryTally()
  221. await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
  222. assert tally.restored == 0
  223. assert _codes(tally) == ["noData"]
  224. class TestSettingValueIsTrue:
  225. """Only the spellings a reader actually treats as "on" count as on."""
  226. @pytest.mark.parametrize("value", ["true", "TRUE", " True ", True])
  227. def test_on(self, value):
  228. assert _setting_value_is_true(value) is True
  229. @pytest.mark.parametrize("value", ["false", "1", "on", "yes", "", None, False, 0])
  230. def test_off(self, value):
  231. # "1"/"on"/"yes" are deliberately off: no reader in the codebase treats
  232. # them as on, so restoring one cannot switch anything on either.
  233. assert _setting_value_is_true(value) is False
  234. class TestUsableCredential:
  235. @pytest.mark.parametrize("value", ["s3cret", " x "])
  236. def test_present_values_are_usable(self, value):
  237. assert _is_usable_credential(value) is True
  238. @pytest.mark.parametrize("value", [None, "", " "])
  239. def test_absent_or_blank_is_not(self, value):
  240. # A present-but-blank prometheus_token row is exactly the `if token:`
  241. # hole in the metrics route, so it must not count as protection.
  242. assert _is_usable_credential(value) is False
  243. class TestCompanionCredentials:
  244. """Toggles whose safety depends on a credential the restore refuses to write.
  245. ``prometheus_enabled`` is the sharp one. ``/api/v1/metrics`` is a public
  246. route whose only gate is a non-empty ``prometheus_token``, so restoring the
  247. toggle onto an instance that has no token row publishes the entire metrics
  248. body to anyone who can reach the port — and with overwrite *off*, since the
  249. row is missing rather than present. The other four break an integration
  250. rather than open one, but they are the same shape.
  251. """
  252. async def _restore(self, db, tally=None, overwrite=False, **settings) -> _CategoryTally:
  253. tally = tally or _CategoryTally()
  254. await _service()._restore_settings(db, {"settings": settings}, overwrite=overwrite, tally=tally)
  255. await db.commit()
  256. return tally
  257. async def _rows(self, db) -> dict:
  258. return {s.key: s.value for s in (await db.execute(select(Settings))).scalars().all()}
  259. # --- The refusal itself ------------------------------------------------
  260. @pytest.mark.asyncio
  261. async def test_prometheus_toggle_is_refused_when_its_token_was_skipped(self, db_session):
  262. """The headline case: overwrite off, empty database, endpoint stays shut."""
  263. tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true", prometheus_token="s3cret")
  264. rows = await self._rows(db_session)
  265. assert rows == {"currency": "EUR"}
  266. assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally))
  267. @pytest.mark.asyncio
  268. @pytest.mark.parametrize("toggle,credential", sorted(_COMPANION_CREDENTIALS.items()))
  269. async def test_every_pair_refuses_its_toggle(self, db_session, toggle, credential, monkeypatch):
  270. monkeypatch.delenv("HA_TOKEN", raising=False)
  271. await self._restore(db_session, **{toggle: "true", credential: "s3cret"})
  272. assert toggle not in await self._rows(db_session)
  273. @pytest.mark.asyncio
  274. async def test_ha_toggle_is_refused_when_the_environment_has_no_token(self, db_session, monkeypatch):
  275. monkeypatch.delenv("HA_TOKEN", raising=False)
  276. await self._restore(db_session, ha_enabled="true", ha_token="s3cret", ha_url="http://ha.local")
  277. rows = await self._rows(db_session)
  278. assert "ha_enabled" not in rows
  279. assert rows["ha_url"] == "http://ha.local"
  280. @pytest.mark.asyncio
  281. async def test_a_blank_local_credential_row_is_not_usable(self, db_session):
  282. db_session.add(Settings(key="prometheus_token", value=""))
  283. await db_session.commit()
  284. await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
  285. assert "prometheus_enabled" not in await self._rows(db_session)
  286. @pytest.mark.asyncio
  287. @pytest.mark.parametrize("value", ["TRUE", " True ", True])
  288. async def test_true_is_refused_however_it_is_spelled(self, db_session, value):
  289. await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
  290. assert "prometheus_enabled" not in await self._rows(db_session)
  291. # --- Ruling 3: the tally counts what the preview counted ---------------
  292. @pytest.mark.asyncio
  293. async def test_refusals_are_not_counted_in_the_tally(self, db_session):
  294. tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true", prometheus_token="s3cret")
  295. assert (tally.restored, tally.skipped, tally.failed) == (1, 0, 0)
  296. @pytest.mark.asyncio
  297. async def test_tally_total_equals_the_preview_item_count(self, db_session):
  298. """The ruling, encoded: the user is shown a number, and it has to hold.
  299. Off by three before this change — the two name-based refusals and the
  300. companion one were all counted as ``skipped`` despite never being in the
  301. preview's count.
  302. """
  303. db_session.add(Settings(key="theme", value="light"))
  304. await db_session.commit()
  305. values = {
  306. "currency": "EUR", # inserted -> restored
  307. "theme": "dark", # exists, overwrite off -> skipped
  308. "low_stock_threshold": None, # no value -> skipped
  309. "": "junk", # unusable key -> failed
  310. "bambu_cloud_token": "x", # blocked -> refused
  311. "auth_enabled": "false", # protected -> refused
  312. "prometheus_enabled": "true", # companion -> refused
  313. "prometheus_token": "s3cret", # blocked -> refused
  314. }
  315. item_count, _ = await _service()._count_items(
  316. db_session, RestoreCategory.SETTINGS, {SETTINGS_PATH: {"settings": values}}
  317. )
  318. tally = _CategoryTally()
  319. await _service()._restore_settings(db_session, {"settings": values}, overwrite=False, tally=tally)
  320. await db_session.commit()
  321. assert tally.restored + tally.skipped + tally.failed == item_count
  322. assert (tally.restored, tally.skipped, tally.failed) == (1, 2, 1)
  323. @pytest.mark.asyncio
  324. async def test_preview_count_drops_by_one_when_the_local_credential_is_missing(self, db_session):
  325. parsed = {
  326. SETTINGS_PATH: {"settings": {"currency": "EUR", "prometheus_enabled": "true", "prometheus_token": "s3cret"}}
  327. }
  328. refused_count, refused_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  329. db_session.add(Settings(key="prometheus_token", value="already-set"))
  330. await db_session.commit()
  331. allowed_count, allowed_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  332. assert refused_count == allowed_count - 1
  333. assert refused_detail.code == "settingsCompanionWillSkip"
  334. assert refused_detail.params == {"count": 1, "companion": 1}
  335. # Nothing is being left off now, so the wording drops back to the plain
  336. # credential caveat.
  337. assert allowed_detail.code == "settingsCredentialsWillSkip"
  338. # --- Controls: over-refusal is the real risk here ----------------------
  339. @pytest.mark.asyncio
  340. async def test_a_usable_local_credential_lets_the_toggle_through(self, db_session):
  341. db_session.add(Settings(key="prometheus_token", value="already-set"))
  342. await db_session.commit()
  343. tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
  344. assert (await self._rows(db_session))["prometheus_enabled"] == "true"
  345. assert not any("switched off" in note for note in _messages(tally))
  346. @pytest.mark.asyncio
  347. async def test_an_anonymous_broker_is_not_a_false_positive(self, db_session):
  348. """mqtt_relay passes an empty password straight through — a real config."""
  349. tally = await self._restore(db_session, mqtt_enabled="true", mqtt_broker="10.0.0.5")
  350. assert (await self._rows(db_session))["mqtt_enabled"] == "true"
  351. assert not any("switched off" in note for note in _messages(tally))
  352. @pytest.mark.asyncio
  353. async def test_an_anonymous_ldap_bind_is_not_a_false_positive(self, db_session):
  354. """Same for a backup that carries the key with a blank value."""
  355. tally = await self._restore(db_session, ldap_enabled="true", ldap_bind_password=" ")
  356. assert (await self._rows(db_session))["ldap_enabled"] == "true"
  357. assert not any("switched off" in note for note in _messages(tally))
  358. @pytest.mark.asyncio
  359. async def test_turning_a_toggle_off_is_always_written(self, db_session):
  360. await self._restore(db_session, prometheus_enabled="false", prometheus_token="s3cret")
  361. assert (await self._rows(db_session))["prometheus_enabled"] == "false"
  362. @pytest.mark.asyncio
  363. @pytest.mark.parametrize("value", ["1", "on", "yes"])
  364. async def test_spellings_no_reader_treats_as_on_are_written(self, db_session, value):
  365. await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
  366. assert (await self._rows(db_session))["prometheus_enabled"] == value
  367. @pytest.mark.asyncio
  368. async def test_ha_token_in_the_environment_counts_as_usable(self, db_session, monkeypatch):
  369. monkeypatch.setenv("HA_TOKEN", "from-env")
  370. await self._restore(db_session, ha_enabled="true", ha_token="s3cret")
  371. assert (await self._rows(db_session))["ha_enabled"] == "true"
  372. @pytest.mark.asyncio
  373. async def test_a_toggle_already_on_locally_is_written(self, db_session):
  374. """The exposure pre-dates the restore, so "left switched off" would be a lie."""
  375. db_session.add(Settings(key="prometheus_enabled", value="true"))
  376. await db_session.commit()
  377. tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true", prometheus_token="s3cret")
  378. assert (await self._rows(db_session))["prometheus_enabled"] == "true"
  379. assert not any("switched off" in note for note in _messages(tally))
  380. # --- The map itself ----------------------------------------------------
  381. def test_every_companion_credential_is_blocked_and_no_toggle_is(self):
  382. """Guards the rule against a future edit to _SECRET_KEY_HINTS.
  383. If a credential stopped being blocked, its toggle would travel with it
  384. and the refusal would be pointless; if a toggle started being blocked,
  385. the pair would never be reached at all.
  386. """
  387. for toggle, credential in _COMPANION_CREDENTIALS.items():
  388. assert _is_blocked_setting_key(credential) is True, credential
  389. assert _is_blocked_setting_key(toggle) is False, toggle
  390. assert _is_protected_setting_key(toggle) is False, toggle
  391. def test_every_environment_override_names_a_companion_credential(self):
  392. assert set(_COMPANION_CREDENTIAL_ENV) <= set(_COMPANION_CREDENTIALS.values())
  393. @pytest.mark.asyncio
  394. async def test_plan_leaves_unusable_key_names_in_no_bucket(self, db_session):
  395. """They are the restore's ``failed``, not a refusal."""
  396. plan = await _service()._plan_settings(db_session, {"": "x", 7: "y", "currency": "EUR"})
  397. assert plan == _SettingsPlan()
  398. class TestSpoolTagOverwrite:
  399. """Overwrite must not write the backup's *other* tag key onto a matched spool.
  400. ``tag_uid`` and ``tray_uuid`` are both in the overwrite ``setattr`` loop, and
  401. neither column has a unique constraint, so writing one onto a spool matched
  402. by the other silently creates a duplicate tag rather than erroring. After
  403. that ``_find_spool``'s ``.first()`` is non-deterministic and an AMS tag
  404. lookup resolves to an arbitrary one of the two. The same loop can also clear
  405. a tag the user has scanned since the backup was taken.
  406. """
  407. def _entry(self, **overrides):
  408. entry = {
  409. "id": 41,
  410. "material": "PLA",
  411. "brand": "Bambu Lab",
  412. "created_at": "2026-01-05 12:00:00",
  413. "tag_uid": "TAG-A",
  414. "tray_uuid": None,
  415. }
  416. entry.update(overrides)
  417. return entry
  418. async def _restore(self, db, entry, tally=None):
  419. tally = tally or _CategoryTally()
  420. await _service()._restore_spools(db, {"spools": [entry]}, None, True, tally, {})
  421. await db.commit()
  422. return tally
  423. @pytest.mark.asyncio
  424. async def test_an_empty_incoming_tag_does_not_clear_a_scanned_one(self, db_session):
  425. """The backup predates the scan, so the local tag is the newer fact."""
  426. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-LIVE"))
  427. await db_session.commit()
  428. tally = await self._restore(db_session, self._entry(tray_uuid=None))
  429. row = (await db_session.execute(select(Spool))).scalar_one()
  430. assert row.tray_uuid == "TRAY-LIVE"
  431. assert any(note["code"] == "spoolTagKept" for note in tally.notes)
  432. @pytest.mark.asyncio
  433. async def test_a_tag_another_spool_already_holds_is_not_written(self, db_session):
  434. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A"))
  435. db_session.add(Spool(material="PETG", brand="Other", tray_uuid="TRAY-B"))
  436. await db_session.commit()
  437. tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-B"))
  438. holders = (await db_session.execute(select(Spool).where(Spool.tray_uuid == "TRAY-B"))).scalars().all()
  439. assert len(holders) == 1, "a duplicate tray_uuid makes AMS lookups non-deterministic"
  440. assert holders[0].material == "PETG"
  441. assert any(note["code"] == "spoolTagKept" for note in tally.notes)
  442. @pytest.mark.asyncio
  443. async def test_the_note_counts_every_column_it_kept(self, db_session):
  444. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-LIVE"))
  445. db_session.add(Spool(material="PETG", brand="Other", tag_uid="TAG-CLASH"))
  446. await db_session.commit()
  447. # Matched on tray_uuid, so the guard judges tag_uid: it clashes.
  448. tally = await self._restore(db_session, self._entry(tag_uid="TAG-CLASH", tray_uuid="TRAY-LIVE"))
  449. row = (await db_session.execute(select(Spool).where(Spool.tray_uuid == "TRAY-LIVE"))).scalar_one()
  450. assert row.tag_uid == "TAG-A"
  451. note = next(n for n in tally.notes if n["code"] == "spoolTagKept")
  452. assert note["params"] == {"count": 1}
  453. # --- Controls ----------------------------------------------------------
  454. @pytest.mark.asyncio
  455. async def test_a_free_tag_is_still_written(self, db_session):
  456. """The point of overwrite: a spool that gained a tray_uuid gets it."""
  457. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A"))
  458. await db_session.commit()
  459. tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-NEW"))
  460. row = (await db_session.execute(select(Spool))).scalar_one()
  461. assert row.tray_uuid == "TRAY-NEW"
  462. assert not any(note["code"] == "spoolTagKept" for note in tally.notes)
  463. @pytest.mark.asyncio
  464. async def test_an_unchanged_tag_is_not_reported_as_kept(self, db_session):
  465. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-A"))
  466. await db_session.commit()
  467. tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-A"))
  468. assert not any(note["code"] == "spoolTagKept" for note in tally.notes)
  469. @pytest.mark.asyncio
  470. async def test_a_new_spool_keeps_both_tags_from_the_backup(self, db_session):
  471. """The guard is an overwrite-only concern; an insert is unaffected."""
  472. await self._restore(db_session, self._entry(tag_uid="TAG-NEW", tray_uuid="TRAY-NEW"))
  473. row = (await db_session.execute(select(Spool))).scalar_one()
  474. assert (row.tag_uid, row.tray_uuid) == ("TAG-NEW", "TRAY-NEW")
  475. @pytest.mark.asyncio
  476. async def test_find_spool_reports_which_key_matched(self, db_session):
  477. db_session.add(Spool(material="PLA", tag_uid="TAG-A"))
  478. db_session.add(Spool(material="PETG", tray_uuid="TRAY-B"))
  479. await db_session.commit()
  480. service = _service()
  481. assert (await service._find_spool(db_session, {"tag_uid": "TAG-A"}))[1] == "tag_uid"
  482. assert (await service._find_spool(db_session, {"tray_uuid": "TRAY-B"}))[1] == "tray_uuid"
  483. assert await service._find_spool(db_session, {"tag_uid": "NOPE"}) == (None, None)
  484. class TestRestoreSpools:
  485. def _spool_entry(self, **overrides):
  486. entry = {
  487. "id": 41,
  488. "material": "PLA",
  489. "subtype": "Basic",
  490. "color_name": "Jade White",
  491. "brand": "Bambu Lab",
  492. "tag_uid": "AABBCCDD",
  493. "created_at": "2026-01-05 12:00:00",
  494. "weight_used": 120.5,
  495. }
  496. entry.update(overrides)
  497. return entry
  498. @pytest.mark.asyncio
  499. async def test_inserts_without_reusing_backup_id(self, db_session):
  500. """The backup's spool.id belongs to an unrelated row today."""
  501. db_session.add(Spool(material="PETG")) # occupies id 1
  502. await db_session.commit()
  503. tally = _CategoryTally()
  504. payload = {"spools": [self._spool_entry(id=1)]}
  505. await _service()._restore_spools(db_session, payload, None, False, tally, {})
  506. await db_session.commit()
  507. spools = (await db_session.execute(select(Spool))).scalars().all()
  508. assert len(spools) == 2
  509. restored = next(s for s in spools if s.tag_uid == "AABBCCDD")
  510. assert restored.id != 1
  511. assert restored.material == "PLA"
  512. @pytest.mark.asyncio
  513. async def test_matches_existing_spool_by_tag_uid(self, db_session):
  514. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
  515. await db_session.commit()
  516. tally = _CategoryTally()
  517. await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {})
  518. await db_session.commit()
  519. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  520. assert tally.skipped == 1
  521. @pytest.mark.asyncio
  522. async def test_matches_existing_spool_by_tray_uuid(self, db_session):
  523. db_session.add(Spool(material="PLA", tray_uuid="1234" * 8))
  524. await db_session.commit()
  525. tally = _CategoryTally()
  526. entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8)
  527. await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
  528. await db_session.commit()
  529. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  530. assert tally.skipped == 1
  531. @pytest.mark.asyncio
  532. async def test_matches_tagless_spool_by_descriptive_composite(self, db_session):
  533. """Manually added spools have no tag, so fall back to created_at + description."""
  534. db_session.add(
  535. Spool(
  536. material="PLA",
  537. subtype="Basic",
  538. color_name="Jade White",
  539. brand="Bambu Lab",
  540. created_at=datetime(2026, 1, 5, 12, 0, 0),
  541. )
  542. )
  543. await db_session.commit()
  544. tally = _CategoryTally()
  545. entry = self._spool_entry(tag_uid=None)
  546. await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
  547. await db_session.commit()
  548. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  549. assert tally.skipped == 1
  550. @pytest.mark.asyncio
  551. async def test_overwrite_updates_matched_spool(self, db_session):
  552. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0))
  553. await db_session.commit()
  554. tally = _CategoryTally()
  555. await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
  556. await db_session.commit()
  557. row = (await db_session.execute(select(Spool))).scalar_one()
  558. assert row.color_name == "Jade White"
  559. assert row.weight_used == 120.5
  560. assert tally.restored == 1
  561. @pytest.mark.asyncio
  562. async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session):
  563. """Second restore of the same backup must match, not duplicate."""
  564. service = _service()
  565. payload = {"spools": [self._spool_entry(tag_uid=None)]}
  566. await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
  567. await db_session.commit()
  568. await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
  569. await db_session.commit()
  570. spools = (await db_session.execute(select(Spool))).scalars().all()
  571. assert len(spools) == 1
  572. assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
  573. @pytest.mark.asyncio
  574. async def test_usage_history_spool_id_is_remapped(self, db_session):
  575. """Usage rows must point at the new local spool id, not the backup's."""
  576. tally = _CategoryTally()
  577. inventory = {"spools": [self._spool_entry(id=41)]}
  578. usage = {
  579. "usage_history": [
  580. {
  581. "id": 900,
  582. "spool_id": 41,
  583. "printer_id": None,
  584. "print_name": "benchy.3mf",
  585. "archive_id": None,
  586. "weight_used": 12.0,
  587. "percent_used": 5,
  588. "status": "completed",
  589. "created_at": "2026-02-01 09:00:00",
  590. }
  591. ]
  592. }
  593. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  594. await db_session.commit()
  595. spool = (await db_session.execute(select(Spool))).scalar_one()
  596. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  597. assert row.spool_id == spool.id
  598. assert row.print_name == "benchy.3mf"
  599. @pytest.mark.asyncio
  600. async def test_usage_history_archive_id_is_remapped(self, db_session):
  601. tally = _CategoryTally()
  602. inventory = {"spools": [self._spool_entry(id=41)]}
  603. usage = {
  604. "usage_history": [
  605. {
  606. "spool_id": 41,
  607. "archive_id": 77,
  608. "weight_used": 1.0,
  609. "created_at": "2026-02-01 09:00:00",
  610. }
  611. ]
  612. }
  613. archive = PrintArchive(filename="a.3mf", file_path="", file_size=1)
  614. db_session.add(archive)
  615. await db_session.flush()
  616. await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id})
  617. await db_session.commit()
  618. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  619. assert row.archive_id == archive.id
  620. @pytest.mark.asyncio
  621. async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
  622. tally = _CategoryTally()
  623. usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]}
  624. await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
  625. await db_session.commit()
  626. assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
  627. assert tally.skipped == 1
  628. assert any("their spool is not in this backup's spool list" in note for note in _messages(tally))
  629. # No remedy is offered, because none exists: overwrite does not change
  630. # which spools land in the map (a skipped spool is mapped anyway), and
  631. # usage history is always restored alongside the spools category.
  632. assert not any("overwrite" in note.lower() for note in _messages(tally))
  633. @pytest.mark.asyncio
  634. async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
  635. """A skipped spool is still mapped, so its usage rows are not "unresolved".
  636. This is why the note above offers no remedy: turning overwrite on would
  637. not rescue anything, and saying so misdescribed which records are lost.
  638. """
  639. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
  640. await db_session.commit()
  641. tally = _CategoryTally()
  642. inventory = {"spools": [self._spool_entry(id=41)]}
  643. usage = {
  644. "usage_history": [
  645. {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
  646. ]
  647. }
  648. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  649. await db_session.commit()
  650. spool = (await db_session.execute(select(Spool))).scalar_one()
  651. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  652. assert row.spool_id == spool.id
  653. assert not any("spool list" in note for note in _messages(tally))
  654. @pytest.mark.asyncio
  655. async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
  656. service = _service()
  657. inventory = {"spools": [self._spool_entry(id=41)]}
  658. usage = {
  659. "usage_history": [
  660. {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
  661. ]
  662. }
  663. await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  664. await db_session.commit()
  665. await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  666. await db_session.commit()
  667. rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
  668. assert len(rows) == 1
  669. @pytest.mark.asyncio
  670. async def test_dropped_archive_link_is_explained(self, db_session):
  671. """Spools without archives nulls every usage -> archive link, silently."""
  672. tally = _CategoryTally()
  673. inventory = {"spools": [self._spool_entry(id=41)]}
  674. usage = {
  675. "usage_history": [
  676. {"spool_id": 41, "archive_id": 7, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"},
  677. {"spool_id": 41, "archive_id": 8, "weight_used": 2.0, "created_at": "2026-02-01 10:00:00"},
  678. {"spool_id": 41, "weight_used": 3.0, "created_at": "2026-02-01 11:00:00"},
  679. ]
  680. }
  681. # Empty archive_id_map: the archives category wasn't selected, so its
  682. # payload was never fetched and there is nothing to match against.
  683. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  684. await db_session.commit()
  685. rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
  686. assert len(rows) == 3
  687. assert all(row.archive_id is None for row in rows)
  688. # Only the two that had a link to lose are counted.
  689. assert any("2 usage record(s) restored without their print-history link" in n for n in _messages(tally))
  690. assert any("select Print archives alongside" in n for n in _messages(tally))
  691. @pytest.mark.asyncio
  692. async def test_no_note_when_every_archive_link_resolves(self, db_session):
  693. tally = _CategoryTally()
  694. inventory = {"spools": [self._spool_entry(id=41)]}
  695. usage = {
  696. "usage_history": [
  697. {"spool_id": 41, "archive_id": 7, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
  698. ]
  699. }
  700. archive = PrintArchive(filename="linked.3mf", file_path="", file_size=1)
  701. db_session.add(archive)
  702. await db_session.flush()
  703. await _service()._restore_spools(db_session, inventory, usage, False, tally, {7: archive.id})
  704. await db_session.commit()
  705. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  706. assert row.archive_id == archive.id
  707. assert not any("print-history link" in note for note in _messages(tally))
  708. @pytest.mark.asyncio
  709. async def test_dangling_printer_id_is_cleared(self, db_session):
  710. tally = _CategoryTally()
  711. inventory = {"spools": [self._spool_entry(id=41)]}
  712. usage = {
  713. "usage_history": [
  714. {"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
  715. ]
  716. }
  717. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  718. await db_session.commit()
  719. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  720. assert row.printer_id is None
  721. class TestRestoreArchives:
  722. def _archive_entry(self, **overrides):
  723. entry = {
  724. "id": 77,
  725. "filename": "benchy.3mf",
  726. "file_size": 2048,
  727. "content_hash": "abc123",
  728. "print_name": "Benchy",
  729. "status": "completed",
  730. "started_at": "2026-03-01 10:00:00",
  731. "completed_at": "2026-03-01 11:00:00",
  732. "created_at": "2026-03-01 10:00:00",
  733. "quantity": 1,
  734. "is_favorite": False,
  735. }
  736. entry.update(overrides)
  737. return entry
  738. @pytest.mark.asyncio
  739. async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
  740. """print_archives.file_path is NOT NULL but is not in the backup."""
  741. tally = _CategoryTally()
  742. id_map: dict[int, int] = {}
  743. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map)
  744. await db_session.commit()
  745. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  746. assert row.file_path == ""
  747. assert row.filename == "benchy.3mf"
  748. assert row.id != 77
  749. assert id_map == {77: row.id}
  750. assert any("metadata only" in note for note in _messages(tally))
  751. @pytest.mark.asyncio
  752. async def test_matches_existing_archive_by_hash_and_start(self, db_session):
  753. db_session.add(
  754. PrintArchive(
  755. filename="benchy.3mf",
  756. file_path="/data/benchy.3mf",
  757. file_size=2048,
  758. content_hash="abc123",
  759. started_at=datetime(2026, 3, 1, 10, 0, 0),
  760. )
  761. )
  762. await db_session.commit()
  763. tally = _CategoryTally()
  764. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
  765. await db_session.commit()
  766. rows = (await db_session.execute(select(PrintArchive))).scalars().all()
  767. assert len(rows) == 1
  768. assert rows[0].file_path == "/data/benchy.3mf"
  769. assert tally.skipped == 1
  770. @pytest.mark.asyncio
  771. async def test_falls_back_to_filename_and_start_without_hash(self, db_session):
  772. db_session.add(
  773. PrintArchive(
  774. filename="benchy.3mf",
  775. file_path="/data/benchy.3mf",
  776. file_size=2048,
  777. started_at=datetime(2026, 3, 1, 10, 0, 0),
  778. )
  779. )
  780. await db_session.commit()
  781. tally = _CategoryTally()
  782. entry = self._archive_entry(content_hash=None)
  783. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  784. await db_session.commit()
  785. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
  786. assert tally.skipped == 1
  787. @pytest.mark.asyncio
  788. async def test_matches_archive_with_no_started_at_by_hash(self, db_session):
  789. """started_at is NULL for re-sliced archives, so it cannot be required.
  790. Gating both match branches on it meant these rows never matched: every
  791. restore re-inserted them and overwrite mode could never update them.
  792. """
  793. db_session.add(
  794. PrintArchive(
  795. filename="benchy.3mf",
  796. file_path="/data/benchy.3mf",
  797. file_size=2048,
  798. content_hash="abc123",
  799. started_at=None,
  800. )
  801. )
  802. await db_session.commit()
  803. tally = _CategoryTally()
  804. entry = self._archive_entry(started_at=None)
  805. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  806. await db_session.commit()
  807. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
  808. assert tally.skipped == 1
  809. @pytest.mark.asyncio
  810. async def test_started_at_still_discriminates_when_present(self, db_session):
  811. """A NULL-tolerant match must not collapse rows that do differ."""
  812. db_session.add(
  813. PrintArchive(
  814. filename="benchy.3mf",
  815. file_path="/data/benchy.3mf",
  816. file_size=2048,
  817. content_hash="abc123",
  818. started_at=datetime(2026, 3, 1, 10, 0, 0),
  819. )
  820. )
  821. await db_session.commit()
  822. tally = _CategoryTally()
  823. # Same file, no start time recorded — a different row, not that one.
  824. entry = self._archive_entry(started_at=None)
  825. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  826. await db_session.commit()
  827. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2
  828. assert tally.restored == 1
  829. @pytest.mark.asyncio
  830. async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session):
  831. """A backup keeps soft-deleted rows, so the flag has to survive.
  832. Their row is retained on purpose (stats keep counting the filament and
  833. energy), so without carrying deleted_at a restore turns an archive the
  834. user deleted back into a visible one.
  835. """
  836. tally = _CategoryTally()
  837. entry = self._archive_entry(deleted_at="2026-03-02 08:00:00")
  838. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  839. await db_session.commit()
  840. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  841. assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0)
  842. assert tally.restored == 1
  843. @pytest.mark.asyncio
  844. async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session):
  845. db_session.add(
  846. PrintArchive(
  847. filename="benchy.3mf",
  848. file_path="",
  849. file_size=2048,
  850. content_hash="abc123",
  851. started_at=datetime(2026, 3, 1, 10, 0, 0),
  852. deleted_at=datetime(2026, 3, 5, 9, 0, 0),
  853. )
  854. )
  855. await db_session.commit()
  856. tally = _CategoryTally()
  857. # The backup predates the deletion, so its copy is live.
  858. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
  859. await db_session.commit()
  860. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  861. assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0)
  862. assert tally.skipped == 1
  863. @pytest.mark.asyncio
  864. async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session):
  865. db_session.add(
  866. PrintArchive(
  867. filename="benchy.3mf",
  868. file_path="",
  869. file_size=2048,
  870. content_hash="abc123",
  871. started_at=datetime(2026, 3, 1, 10, 0, 0),
  872. deleted_at=datetime(2026, 3, 5, 9, 0, 0),
  873. )
  874. )
  875. await db_session.commit()
  876. tally = _CategoryTally()
  877. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, True, tally, {})
  878. await db_session.commit()
  879. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  880. assert row.deleted_at is None
  881. assert tally.restored == 1
  882. assert any("visible again" in note for note in _messages(tally))
  883. @pytest.mark.asyncio
  884. async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
  885. db_session.add(
  886. PrintArchive(
  887. filename="benchy.3mf",
  888. file_path="/data/benchy.3mf",
  889. file_size=2048,
  890. content_hash="abc123",
  891. started_at=datetime(2026, 3, 1, 10, 0, 0),
  892. notes="old",
  893. )
  894. )
  895. await db_session.commit()
  896. tally = _CategoryTally()
  897. entry = self._archive_entry(notes="restored note")
  898. await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
  899. await db_session.commit()
  900. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  901. assert row.notes == "restored note"
  902. # The 3MF on disk must not be orphaned by a metadata restore.
  903. assert row.file_path == "/data/benchy.3mf"
  904. assert tally.restored == 1
  905. @pytest.mark.asyncio
  906. async def test_dangling_printer_and_project_links_are_cleared(self, db_session):
  907. tally = _CategoryTally()
  908. entry = self._archive_entry(printer_id=4242, project_id=4343)
  909. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  910. await db_session.commit()
  911. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  912. assert row.printer_id is None
  913. assert row.project_id is None
  914. assert any("no longer exist" in note for note in _messages(tally))
  915. @pytest.mark.asyncio
  916. async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
  917. printer = await printer_factory()
  918. tally = _CategoryTally()
  919. entry = self._archive_entry(printer_id=printer.id)
  920. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  921. await db_session.commit()
  922. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  923. assert row.printer_id == printer.id
  924. @pytest.mark.asyncio
  925. async def test_non_dict_entry_counts_as_failed(self, db_session):
  926. tally = _CategoryTally()
  927. await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {})
  928. assert tally.failed == 1
  929. class TestRestoreKprofiles:
  930. @staticmethod
  931. def _live(slot_id, filament_id="GFA00", name="Bambu PLA", setting_id="PFUS123"):
  932. """One profile as the printer currently reports it."""
  933. return SimpleNamespace(slot_id=slot_id, filament_id=filament_id, name=name, setting_id=setting_id)
  934. def _client(self, live=None, sent=True):
  935. client = MagicMock()
  936. client.state.connected = True
  937. client.set_kprofiles_batch = MagicMock(return_value=sent)
  938. client.get_kprofiles = AsyncMock(return_value=list(live or []))
  939. return client
  940. def _payload(self, serial="00M09A123456789", nozzle="0.4"):
  941. return {
  942. f"kprofiles/{serial}/{nozzle}.json": {
  943. "version": "1.0",
  944. "printer_serial": serial,
  945. "nozzle_diameter": nozzle,
  946. "profiles": [
  947. {
  948. "slot_id": 0,
  949. "name": "Bambu PLA",
  950. "k_value": "0.020000",
  951. "filament_id": "GFA00",
  952. "nozzle_id": "HS00-0.4",
  953. "extruder_id": 0,
  954. "setting_id": "PFUS123",
  955. }
  956. ],
  957. }
  958. }
  959. @pytest.mark.asyncio
  960. async def test_sends_batch_to_connected_printer(self, db_session, printer_factory):
  961. printer = await printer_factory(serial_number="00M09A123456789")
  962. client = MagicMock()
  963. client.state.connected = True
  964. client.set_kprofiles_batch = MagicMock(return_value=True)
  965. tally = _CategoryTally()
  966. with patch("backend.app.services.github_restore.printer_manager") as manager:
  967. manager.get_client = MagicMock(return_value=client)
  968. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  969. client.set_kprofiles_batch.assert_called_once()
  970. profiles, nozzle = client.set_kprofiles_batch.call_args.args
  971. assert nozzle == "0.4"
  972. assert profiles[0]["name"] == "Bambu PLA"
  973. assert profiles[0]["filament_id"] == "GFA00"
  974. assert tally.restored == 1
  975. assert manager.get_client.call_args.args == (printer.id,)
  976. @pytest.mark.asyncio
  977. async def test_always_warns_to_verify_on_the_printer(self, db_session, printer_factory):
  978. await printer_factory(serial_number="00M09A123456789")
  979. client = self._client()
  980. tally = _CategoryTally()
  981. with patch("backend.app.services.github_restore.printer_manager") as manager:
  982. manager.get_client = MagicMock(return_value=client)
  983. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  984. # The printer does answer extrusion_cali_set, but it reports "fail" on
  985. # writes that land, so the note must not promise either way.
  986. assert any("verify the profiles on the printer" in note for note in _messages(tally))
  987. assert not any("without acknowledgement" in note for note in _messages(tally))
  988. assert any("always overwrite" in note for note in _messages(tally))
  989. # --- cali_idx is resolved live, never taken from the backup -------------
  990. #
  991. # Regression cover for the silent no-op found testing on an X1E: the backup
  992. # stored cali_idx 8151, a Bambuddy edit re-keyed the profile to 4606, and
  993. # the restore aimed extrusion_cali_set at 8151. The printer dropped it and
  994. # the tally still said "1 restored".
  995. @pytest.mark.asyncio
  996. async def test_uses_the_live_cali_idx_not_the_backed_up_slot(self, db_session, printer_factory):
  997. await printer_factory(serial_number="00M09A123456789")
  998. payload = self._payload()
  999. payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]["slot_id"] = 8151
  1000. client = self._client(live=[self._live(slot_id=4606)])
  1001. tally = _CategoryTally()
  1002. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1003. manager.get_client = MagicMock(return_value=client)
  1004. await _service()._restore_kprofiles(db_session, payload, tally)
  1005. client.get_kprofiles.assert_awaited_once_with(nozzle_diameter="0.4")
  1006. profiles, _ = client.set_kprofiles_batch.call_args.args
  1007. assert profiles[0]["cali_idx"] == 4606, "must address the slot that exists now"
  1008. assert profiles[0]["cali_idx"] != 8151, "must not reuse the backup's cali_idx"
  1009. assert tally.restored == 1
  1010. @pytest.mark.asyncio
  1011. async def test_matches_on_name_when_setting_id_was_regenerated(self, db_session, printer_factory):
  1012. # A delete-then-add edit mints a fresh setting_id, so the name carries
  1013. # the match instead.
  1014. await printer_factory(serial_number="00M09A123456789")
  1015. client = self._client(live=[self._live(slot_id=4606, setting_id="PF9999999999")])
  1016. tally = _CategoryTally()
  1017. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1018. manager.get_client = MagicMock(return_value=client)
  1019. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1020. profiles, _ = client.set_kprofiles_batch.call_args.args
  1021. assert profiles[0]["cali_idx"] == 4606
  1022. # The live setting_id wins: it is what the printer associates with the slot.
  1023. assert profiles[0]["setting_id"] == "PF9999999999"
  1024. @pytest.mark.asyncio
  1025. async def test_unmatched_profile_is_added_rather_than_aimed_at_a_dead_slot(self, db_session, printer_factory):
  1026. await printer_factory(serial_number="00M09A123456789")
  1027. client = self._client(live=[]) # printer has nothing for this nozzle
  1028. tally = _CategoryTally()
  1029. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1030. manager.get_client = MagicMock(return_value=client)
  1031. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1032. profiles, _ = client.set_kprofiles_batch.call_args.args
  1033. assert profiles[0]["cali_idx"] == -1, "-1 tells the printer to add a new profile"
  1034. assert profiles[0]["setting_id"] == "PFUS123", "falls back to the backed-up preset"
  1035. assert any("added as new profiles" in note for note in _messages(tally))
  1036. @pytest.mark.asyncio
  1037. async def test_different_filament_is_not_treated_as_a_match(self, db_session, printer_factory):
  1038. # Same slot, different filament — matching on slot alone would clobber
  1039. # an unrelated profile.
  1040. await printer_factory(serial_number="00M09A123456789")
  1041. client = self._client(live=[self._live(slot_id=4606, filament_id="GFB99", name="Bambu PLA")])
  1042. tally = _CategoryTally()
  1043. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1044. manager.get_client = MagicMock(return_value=client)
  1045. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1046. profiles, _ = client.set_kprofiles_batch.call_args.args
  1047. assert profiles[0]["cali_idx"] == -1
  1048. @pytest.mark.asyncio
  1049. async def test_unreadable_live_index_degrades_to_adding(self, db_session, printer_factory):
  1050. # A failed read must not abort the restore.
  1051. await printer_factory(serial_number="00M09A123456789")
  1052. client = self._client()
  1053. client.get_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt timeout"))
  1054. tally = _CategoryTally()
  1055. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1056. manager.get_client = MagicMock(return_value=client)
  1057. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1058. profiles, _ = client.set_kprofiles_batch.call_args.args
  1059. assert profiles[0]["cali_idx"] == -1
  1060. assert tally.restored == 1
  1061. @pytest.mark.asyncio
  1062. async def test_sole_profile_for_a_filament_matches_without_setting_id_or_name(self, db_session, printer_factory):
  1063. await printer_factory(serial_number="00M09A123456789")
  1064. payload = self._payload()
  1065. entry = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]
  1066. entry["setting_id"] = None
  1067. entry["name"] = ""
  1068. client = self._client(live=[self._live(slot_id=4606, setting_id="PFOTHER", name="Renamed")])
  1069. tally = _CategoryTally()
  1070. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1071. manager.get_client = MagicMock(return_value=client)
  1072. await _service()._restore_kprofiles(db_session, payload, tally)
  1073. profiles, _ = client.set_kprofiles_batch.call_args.args
  1074. assert profiles[0]["cali_idx"] == 4606
  1075. @pytest.mark.asyncio
  1076. async def test_ambiguous_filament_without_discriminator_is_added_not_guessed(self, db_session, printer_factory):
  1077. await printer_factory(serial_number="00M09A123456789")
  1078. payload = self._payload()
  1079. entry = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]
  1080. entry["setting_id"] = None
  1081. entry["name"] = ""
  1082. client = self._client(live=[self._live(slot_id=1, setting_id="A"), self._live(slot_id=2, setting_id="B")])
  1083. tally = _CategoryTally()
  1084. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1085. manager.get_client = MagicMock(return_value=client)
  1086. await _service()._restore_kprofiles(db_session, payload, tally)
  1087. profiles, _ = client.set_kprofiles_batch.call_args.args
  1088. assert profiles[0]["cali_idx"] == -1, "two candidates and nothing to tell them apart"
  1089. @pytest.mark.asyncio
  1090. async def test_unknown_serial_is_skipped_with_reason(self, db_session):
  1091. tally = _CategoryTally()
  1092. with patch("backend.app.services.github_restore.printer_manager"):
  1093. await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally)
  1094. assert tally.restored == 0
  1095. assert tally.skipped == 1
  1096. assert any("No printer with serial NOSUCH" in note for note in _messages(tally))
  1097. @pytest.mark.asyncio
  1098. async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
  1099. await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
  1100. client = MagicMock()
  1101. client.state.connected = False
  1102. tally = _CategoryTally()
  1103. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1104. manager.get_client = MagicMock(return_value=client)
  1105. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1106. assert tally.skipped == 1
  1107. assert tally.failed == 0
  1108. assert any("not connected" in note for note in _messages(tally))
  1109. @pytest.mark.asyncio
  1110. async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
  1111. await printer_factory(serial_number="00M09A123456789")
  1112. tally = _CategoryTally()
  1113. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1114. manager.get_client = MagicMock(return_value=None)
  1115. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1116. assert tally.skipped == 1
  1117. @pytest.mark.asyncio
  1118. async def test_publish_failure_counts_as_failed(self, db_session, printer_factory):
  1119. await printer_factory(serial_number="00M09A123456789")
  1120. client = MagicMock()
  1121. client.state.connected = True
  1122. client.set_kprofiles_batch = MagicMock(return_value=False)
  1123. tally = _CategoryTally()
  1124. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1125. manager.get_client = MagicMock(return_value=client)
  1126. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1127. assert tally.failed == 1
  1128. assert tally.restored == 0
  1129. @pytest.mark.asyncio
  1130. async def test_publish_exception_is_contained(self, db_session, printer_factory):
  1131. await printer_factory(serial_number="00M09A123456789")
  1132. client = MagicMock()
  1133. client.state.connected = True
  1134. client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down"))
  1135. tally = _CategoryTally()
  1136. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1137. manager.get_client = MagicMock(return_value=client)
  1138. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1139. assert tally.failed == 1
  1140. @pytest.mark.asyncio
  1141. async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory):
  1142. await printer_factory(serial_number="00M09A123456789")
  1143. payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
  1144. client = MagicMock()
  1145. client.state.connected = True
  1146. client.set_kprofiles_batch = MagicMock(return_value=True)
  1147. tally = _CategoryTally()
  1148. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1149. manager.get_client = MagicMock(return_value=client)
  1150. await _service()._restore_kprofiles(db_session, payload, tally)
  1151. assert client.set_kprofiles_batch.call_count == 2
  1152. assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"}
  1153. assert tally.restored == 2
  1154. @pytest.mark.asyncio
  1155. async def test_empty_payload_is_noted(self, db_session):
  1156. tally = _CategoryTally()
  1157. await _service()._restore_kprofiles(db_session, {}, tally)
  1158. assert _codes(tally) == ["noData"]
  1159. class TestSoftDeletedArchiveRoundTrip:
  1160. """The two halves of the soft-delete fix only work together.
  1161. The collector keeps soft-deleted rows on purpose (their stats still count),
  1162. so if it doesn't write ``deleted_at`` there is nothing for the restore to
  1163. carry across and a deleted archive comes back visible. Covered end to end
  1164. because each half looks harmless on its own.
  1165. """
  1166. @pytest.mark.asyncio
  1167. async def test_deleted_at_survives_collect_then_restore(self, db_session):
  1168. from backend.app.services.github_backup import github_backup_service
  1169. deleted_at = datetime(2026, 3, 5, 9, 0, 0)
  1170. db_session.add(
  1171. PrintArchive(
  1172. filename="trashed.3mf",
  1173. file_path="",
  1174. file_size=1024,
  1175. content_hash="hash-trashed",
  1176. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1177. deleted_at=deleted_at,
  1178. )
  1179. )
  1180. await db_session.commit()
  1181. files: dict = {}
  1182. await github_backup_service._collect_archives(db_session, files)
  1183. payload = files[ARCHIVES_PATH]
  1184. assert payload["archives"][0]["deleted_at"] == str(deleted_at)
  1185. # Restore that payload into an instance where the row is gone entirely.
  1186. await db_session.execute(PrintArchive.__table__.delete())
  1187. await db_session.commit()
  1188. tally = _CategoryTally()
  1189. await _service()._restore_archives(db_session, payload, False, tally, {})
  1190. await db_session.commit()
  1191. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1192. assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
  1193. class TestRestoredArchiveOwnership:
  1194. """A restored archive without an owner is invisible to the person who owns it.
  1195. ``created_by_id`` is not attribution, it is the column the access check runs
  1196. on: ``_ensure_archive_visible`` fails closed on NULL (404 for any caller
  1197. without ``archives:read_all``) and the list paths filter
  1198. ``created_by_id == user.id``. So on a multi-user instance the tally reported
  1199. archives restored while their owner could neither list nor open them.
  1200. """
  1201. def _entry(self, **overrides):
  1202. entry = {
  1203. "id": 77,
  1204. "filename": "benchy.3mf",
  1205. "file_size": 2048,
  1206. "content_hash": "abc123",
  1207. "started_at": "2026-03-01 10:00:00",
  1208. "created_at": "2026-03-01 10:00:00",
  1209. }
  1210. entry.update(overrides)
  1211. return entry
  1212. async def _user(self, db, username="alice"):
  1213. user = User(username=username, role="operator")
  1214. db.add(user)
  1215. await db.flush()
  1216. return user
  1217. @pytest.mark.asyncio
  1218. async def test_owner_is_carried_across(self, db_session):
  1219. user = await self._user(db_session)
  1220. tally = _CategoryTally()
  1221. await _service()._restore_archives(
  1222. db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
  1223. )
  1224. await db_session.commit()
  1225. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1226. assert row.created_by_id == user.id
  1227. assert not any("owner cleared" in note for note in _messages(tally))
  1228. @pytest.mark.asyncio
  1229. async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session):
  1230. """The archive is still worth having; an admin can reassign it."""
  1231. tally = _CategoryTally()
  1232. await _service()._restore_archives(
  1233. db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
  1234. )
  1235. await db_session.commit()
  1236. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1237. assert row.created_by_id is None
  1238. assert tally.restored == 1 and tally.failed == 0
  1239. assert any("owner cleared" in note and "archives:read_all" in note for note in _messages(tally))
  1240. @pytest.mark.asyncio
  1241. async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session):
  1242. tally = _CategoryTally()
  1243. archives = [
  1244. self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_id=4242),
  1245. self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_id=4243),
  1246. ]
  1247. await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
  1248. await db_session.commit()
  1249. assert sum(1 for note in _messages(tally) if "owner cleared" in note) == 1
  1250. @pytest.mark.asyncio
  1251. async def test_a_backup_without_the_key_still_restores(self, db_session):
  1252. """Backups taken before the collector recorded it just can't know the owner."""
  1253. tally = _CategoryTally()
  1254. await _service()._restore_archives(db_session, {"archives": [self._entry()]}, False, tally, {})
  1255. await db_session.commit()
  1256. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1257. assert row.created_by_id is None
  1258. assert not any("owner cleared" in note for note in _messages(tally))
  1259. @pytest.mark.asyncio
  1260. async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):
  1261. alice = await self._user(db_session, "alice")
  1262. bob = await self._user(db_session, "bob")
  1263. db_session.add(
  1264. PrintArchive(
  1265. filename="benchy.3mf",
  1266. file_path="/data/benchy.3mf",
  1267. file_size=2048,
  1268. content_hash="abc123",
  1269. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1270. created_by_id=bob.id,
  1271. )
  1272. )
  1273. await db_session.commit()
  1274. await _service()._restore_archives(
  1275. db_session, {"archives": [self._entry(created_by_id=alice.id)]}, True, _CategoryTally(), {}
  1276. )
  1277. await db_session.commit()
  1278. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1279. assert row.created_by_id == alice.id
  1280. @pytest.mark.asyncio
  1281. async def test_owner_survives_collect_then_restore(self, db_session):
  1282. """Both halves, because each looks harmless alone.
  1283. The collector never wrote the key, so there was nothing for the restore
  1284. to carry across even once it wanted to.
  1285. """
  1286. from backend.app.services.github_backup import github_backup_service
  1287. user = await self._user(db_session)
  1288. db_session.add(
  1289. PrintArchive(
  1290. filename="owned.3mf",
  1291. file_path="",
  1292. file_size=1024,
  1293. content_hash="hash-owned",
  1294. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1295. created_by_id=user.id,
  1296. )
  1297. )
  1298. await db_session.commit()
  1299. files: dict = {}
  1300. await github_backup_service._collect_archives(db_session, files)
  1301. payload = files[ARCHIVES_PATH]
  1302. assert payload["archives"][0]["created_by_id"] == user.id
  1303. await db_session.execute(PrintArchive.__table__.delete())
  1304. await db_session.commit()
  1305. await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
  1306. await db_session.commit()
  1307. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1308. assert row.created_by_id == user.id, "a restored archive its owner cannot see is not restored"
  1309. class TestCategoryPathMapping:
  1310. def setup_method(self):
  1311. self.service = _service()
  1312. self.available = [
  1313. "backup_metadata.json",
  1314. SETTINGS_PATH,
  1315. SPOOLS_PATH,
  1316. SPOOL_USAGE_PATH,
  1317. ARCHIVES_PATH,
  1318. "kprofiles/SERIAL1/0.4.json",
  1319. "kprofiles/SERIAL1/0.8.json",
  1320. "cloud_profiles/filament.json",
  1321. ]
  1322. def test_spools_includes_usage_history(self):
  1323. paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available)
  1324. assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH]
  1325. def test_kprofiles_globs_all_serials_and_nozzles(self):
  1326. paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available)
  1327. assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"]
  1328. def test_absent_paths_are_omitted(self):
  1329. paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"])
  1330. assert paths == []
  1331. def test_cloud_profiles_are_not_a_restore_category(self):
  1332. assert "cloud_profiles" not in {c.value for c in RestoreCategory}
  1333. class TestMutex:
  1334. @pytest.mark.asyncio
  1335. async def test_restore_refuses_while_a_backup_is_running(self):
  1336. service = _service()
  1337. with patch("backend.app.services.github_backup.github_backup_service") as backup:
  1338. backup.is_running = True
  1339. result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
  1340. assert result["success"] is False
  1341. assert "backup is currently running" in result["message"]
  1342. @pytest.mark.asyncio
  1343. async def test_restore_refuses_while_another_restore_is_running(self):
  1344. service = _service()
  1345. service._running_restore = True
  1346. result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
  1347. assert result["success"] is False
  1348. assert "restore is already running" in result["message"]
  1349. @pytest.mark.asyncio
  1350. async def test_backup_refuses_while_a_restore_is_running(self):
  1351. from backend.app.services.github_backup import GitHubBackupService
  1352. backup_service = GitHubBackupService()
  1353. with patch("backend.app.services.github_restore.github_restore_service") as restore:
  1354. restore.is_running = True
  1355. result = await backup_service.run_backup(1, trigger="manual")
  1356. assert result["success"] is False
  1357. assert "restore is currently running" in result["message"]
  1358. class TestMqttRelayReconfigure:
  1359. """Restoring mqtt_* rows has to reach the live relay, not just the table."""
  1360. @pytest.mark.asyncio
  1361. async def test_reconfigures_from_the_committed_rows(self, db_session):
  1362. db_session.add(Settings(key="mqtt_enabled", value="true"))
  1363. db_session.add(Settings(key="mqtt_broker", value="restored.local"))
  1364. db_session.add(Settings(key="mqtt_port", value="8883"))
  1365. db_session.add(Settings(key="mqtt_use_tls", value="true"))
  1366. # Never restorable (credential blocklist), so it comes from the row that
  1367. # was already there.
  1368. db_session.add(Settings(key="mqtt_password", value="kept"))
  1369. await db_session.commit()
  1370. tally = _CategoryTally()
  1371. relay = MagicMock()
  1372. relay.configure = AsyncMock(return_value=True)
  1373. with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  1374. await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_broker"}, tally)
  1375. relay.configure.assert_awaited_once()
  1376. sent = relay.configure.await_args.args[0]
  1377. assert sent["mqtt_enabled"] is True
  1378. assert sent["mqtt_broker"] == "restored.local"
  1379. assert sent["mqtt_port"] == 8883
  1380. assert sent["mqtt_use_tls"] is True
  1381. assert sent["mqtt_password"] == "kept"
  1382. assert sent["mqtt_topic_prefix"] == "bambuddy"
  1383. assert tally.notes == []
  1384. @pytest.mark.asyncio
  1385. async def test_no_reconnect_when_no_mqtt_key_was_written(self, db_session):
  1386. """configure() tears the connection down, so don't call it for a theme change."""
  1387. tally = _CategoryTally()
  1388. relay = MagicMock()
  1389. relay.configure = AsyncMock()
  1390. with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  1391. await _service()._reconfigure_mqtt_relay(db_session, {"currency", "theme"}, tally)
  1392. relay.configure.assert_not_awaited()
  1393. @pytest.mark.asyncio
  1394. async def test_broker_failure_is_noted_not_fatal(self, db_session):
  1395. tally = _CategoryTally()
  1396. relay = MagicMock()
  1397. relay.configure = AsyncMock(side_effect=OSError("no route to broker"))
  1398. with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  1399. await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_enabled"}, tally)
  1400. assert any("restart Bambuddy" in note for note in _messages(tally))
  1401. @pytest.mark.asyncio
  1402. async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):
  1403. db_session.add(Settings(key="mqtt_broker", value="old.local"))
  1404. await db_session.commit()
  1405. written: set[str] = set()
  1406. payload = {
  1407. "settings": {
  1408. "mqtt_broker": "new.local",
  1409. "currency": "EUR",
  1410. "mqtt_password": "leaked",
  1411. "auth_enabled": "false",
  1412. }
  1413. }
  1414. await _service()._restore_settings(
  1415. db_session, payload, overwrite=True, tally=_CategoryTally(), keys_written=written
  1416. )
  1417. # Skipped keys are not "written", or a blocked mqtt_password would
  1418. # trigger a pointless reconnect.
  1419. assert written == {"mqtt_broker", "currency"}
  1420. @pytest.mark.asyncio
  1421. async def test_keys_skipped_for_overwrite_off_are_not_reported(self, db_session):
  1422. db_session.add(Settings(key="mqtt_broker", value="old.local"))
  1423. await db_session.commit()
  1424. written: set[str] = set()
  1425. await _service()._restore_settings(
  1426. db_session,
  1427. {"settings": {"mqtt_broker": "new.local"}},
  1428. overwrite=False,
  1429. tally=_CategoryTally(),
  1430. keys_written=written,
  1431. )
  1432. assert written == set()
  1433. @pytest.mark.asyncio
  1434. async def test_a_refused_mqtt_enabled_is_not_reported_as_written(self, db_session):
  1435. """So the relay reconfigures from the *local* mqtt_enabled, not the backup's.
  1436. The companion rule refuses ``mqtt_enabled`` when the backup's password
  1437. cannot come across and there is none stored locally. It must not then
  1438. appear in ``keys_written``, or _reconfigure_mqtt_relay would be asked to
  1439. bring up a broker connection the restore deliberately declined to enable.
  1440. """
  1441. written: set[str] = set()
  1442. await _service()._restore_settings(
  1443. db_session,
  1444. {"settings": {"mqtt_enabled": "true", "mqtt_password": "refused", "mqtt_broker": "new.local"}},
  1445. overwrite=True,
  1446. tally=_CategoryTally(),
  1447. keys_written=written,
  1448. )
  1449. assert written == {"mqtt_broker"}
  1450. class TestApplyOrdering:
  1451. """_apply must not hold SQLite's write transaction across the MQTT phase."""
  1452. def _recording_service(self, calls: list[str]):
  1453. service = _service()
  1454. # Sync side effects on purpose: an AsyncMock returns a coroutine its
  1455. # side_effect hands back rather than awaiting it, so an async recorder
  1456. # would never run.
  1457. service._restore_archives = AsyncMock(side_effect=lambda *a, **k: calls.append("archives"))
  1458. service._restore_spools = AsyncMock(side_effect=lambda *a, **k: calls.append("spools"))
  1459. service._restore_settings = AsyncMock(side_effect=lambda *a, **k: calls.append("settings"))
  1460. service._restore_kprofiles = AsyncMock(side_effect=lambda *a, **k: calls.append("kprofiles"))
  1461. return service
  1462. @pytest.mark.asyncio
  1463. async def test_commits_database_categories_before_talking_to_printers(self):
  1464. """get_kprofiles is 3 x 5 s per printer/nozzle; busy_timeout is 15 s."""
  1465. calls: list[str] = []
  1466. service = self._recording_service(calls)
  1467. db = MagicMock()
  1468. db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
  1469. await service._apply(
  1470. db,
  1471. {},
  1472. [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS, RestoreCategory.KPROFILES],
  1473. False,
  1474. )
  1475. assert calls == ["archives", "spools", "commit", "kprofiles"]
  1476. @pytest.mark.asyncio
  1477. async def test_does_not_split_the_transaction_without_kprofiles(self):
  1478. """A database-only restore stays one transaction, committed by run_restore."""
  1479. calls: list[str] = []
  1480. service = self._recording_service(calls)
  1481. db = MagicMock()
  1482. db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
  1483. await service._apply(db, {}, [RestoreCategory.ARCHIVES, RestoreCategory.SETTINGS], False)
  1484. assert calls == ["archives", "settings"]
  1485. assert db.commit.await_count == 0
  1486. class TestResolveRef:
  1487. @pytest.mark.asyncio
  1488. async def test_concrete_sha_passes_through_without_an_api_call(self):
  1489. service = _service()
  1490. service.list_commits = AsyncMock()
  1491. config = MagicMock(branch="main")
  1492. resolved, error, commit = await service._resolve_ref(config, "abc1234")
  1493. assert resolved == "abc1234"
  1494. assert error == ""
  1495. # Nothing was fetched, so there is no entry to describe it with.
  1496. assert commit is None
  1497. service.list_commits.assert_not_awaited()
  1498. @pytest.mark.asyncio
  1499. async def test_head_resolves_to_the_tip_sha(self):
  1500. service = _service()
  1501. service.list_commits = AsyncMock(
  1502. return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]}
  1503. )
  1504. config = MagicMock(branch="main")
  1505. resolved, error, commit = await service._resolve_ref(config, "HEAD")
  1506. assert resolved == "tipsha1"
  1507. assert error == ""
  1508. # Handed back so preview does not list commits a second time just to
  1509. # describe the one it already fetched.
  1510. assert commit == {"sha": "tipsha1"}
  1511. @pytest.mark.asyncio
  1512. async def test_empty_history_is_an_error(self):
  1513. service = _service()
  1514. service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
  1515. config = MagicMock(branch="main")
  1516. resolved, error, commit = await service._resolve_ref(config, "HEAD")
  1517. assert resolved is None
  1518. assert "no commits" in error
  1519. assert commit is None
  1520. class TestDescribeCommit:
  1521. """A preview that says `commit: null` gives the user no idea what they picked."""
  1522. def _config(self):
  1523. return MagicMock(branch="main", provider="github", repository_url="https://github.com/o/r", access_token="t")
  1524. def _entry(self, sha: str):
  1525. return {"sha": sha, "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-01T10:00:00Z"}
  1526. @pytest.mark.asyncio
  1527. async def test_an_abbreviated_ref_matches_a_full_sha_in_the_window(self):
  1528. """REF_PATTERN accepts 7 characters; providers return 40.
  1529. The old exact `==` therefore never matched an abbreviated ref, even when
  1530. the commit was right there in the top 20.
  1531. """
  1532. service = _service()
  1533. full = "abc1234" + "0" * 33
  1534. service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry(full)]})
  1535. found = await service._describe_commit(self._config(), "abc1234")
  1536. assert found is not None
  1537. assert found["sha"] == full
  1538. @pytest.mark.asyncio
  1539. async def test_a_full_sha_matches_an_abbreviated_entry(self):
  1540. service = _service()
  1541. service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry("abc1234")]})
  1542. found = await service._describe_commit(self._config(), "abc1234" + "0" * 33)
  1543. assert found is not None
  1544. @pytest.mark.asyncio
  1545. async def test_a_commit_outside_the_window_is_fetched_directly(self):
  1546. service = _service()
  1547. service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry("f" * 40)]})
  1548. backend = MagicMock()
  1549. backend.get_commit = AsyncMock(return_value={"success": True, "commit": self._entry("old" + "0" * 37)})
  1550. with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
  1551. found = await service._describe_commit(self._config(), "old" + "0" * 37)
  1552. assert found["sha"] == "old" + "0" * 37
  1553. backend.get_commit.assert_awaited_once()
  1554. @pytest.mark.asyncio
  1555. async def test_a_direct_lookup_failure_is_not_fatal(self):
  1556. """It is a subject line: render the preview without it."""
  1557. service = _service()
  1558. service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
  1559. backend = MagicMock()
  1560. backend.get_commit = AsyncMock(return_value={"success": False, "message": "boom", "commit": None})
  1561. with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
  1562. assert await service._describe_commit(self._config(), "a" * 40) is None
  1563. @pytest.mark.asyncio
  1564. async def test_the_window_scan_is_not_run_twice(self):
  1565. """_resolve_ref already listed commits for HEAD; preview reuses that."""
  1566. service = _service()
  1567. tip = self._entry("t" * 40)
  1568. service.list_commits = AsyncMock(return_value={"success": True, "commits": [tip]})
  1569. resolved, _, commit = await service._resolve_ref(self._config(), "HEAD")
  1570. assert resolved == "t" * 40
  1571. assert commit == tip
  1572. assert service.list_commits.await_count == 1