test_github_restore.py 144 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336
  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, timedelta
  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. _COMPANION_EXPOSURE_TOGGLES,
  22. ARCHIVES_PATH,
  23. SETTINGS_PATH,
  24. SPOOL_USAGE_PATH,
  25. SPOOLS_PATH,
  26. GitHubRestoreService,
  27. _CategoryTally,
  28. _is_blocked_setting_key,
  29. _is_protected_setting_key,
  30. _is_usable_credential,
  31. _parse_dt,
  32. _setting_value_is_true,
  33. _SettingsPlan,
  34. )
  35. def _service() -> GitHubRestoreService:
  36. return GitHubRestoreService()
  37. def _messages(tally: _CategoryTally) -> list[str]:
  38. """The English rendering of each note.
  39. Notes are ``{code, params, message}`` since they became translatable
  40. (#2656); asserting on the message keeps these tests readable while
  41. ``_codes`` covers the half a client actually keys on.
  42. """
  43. return [note["message"] for note in tally.notes]
  44. def _codes(tally: _CategoryTally) -> list[str]:
  45. return [note["code"] for note in tally.notes]
  46. class TestParseDt:
  47. def test_parses_str_datetime_the_backup_writes(self):
  48. assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
  49. def test_parses_iso_with_t_separator(self):
  50. assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5)
  51. @pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}])
  52. def test_returns_none_for_junk(self, value):
  53. assert _parse_dt(value) is None
  54. def test_an_offset_is_normalised_to_naive_utc(self):
  55. """Every DateTime column here is naive UTC; an aware value cannot be
  56. written to one without silently shifting the wall clock, nor compared
  57. against one without raising."""
  58. assert _parse_dt("2026-07-27T08:02:05+02:00") == datetime(2026, 7, 27, 6, 2, 5)
  59. assert _parse_dt("2026-07-27T06:02:05+00:00").tzinfo is None
  60. class TestSettingKeyBlocklist:
  61. @pytest.mark.parametrize(
  62. "key",
  63. [
  64. "bambu_cloud_token",
  65. "auth_secret_key",
  66. "ha_token",
  67. "prometheus_token",
  68. "printer_access_code",
  69. "smtp_password",
  70. "some_api_key",
  71. "ftp_passphrase",
  72. "MQTT_SECRET",
  73. ],
  74. )
  75. def test_credential_like_keys_are_blocked(self, key):
  76. assert _is_blocked_setting_key(key) is True
  77. @pytest.mark.parametrize(
  78. "key",
  79. ["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"],
  80. )
  81. def test_ordinary_keys_are_allowed(self, key):
  82. assert _is_blocked_setting_key(key) is False
  83. @pytest.mark.parametrize(
  84. "key",
  85. ["auth_enabled", "advanced_auth_enabled", "local_login_enabled", "setup_completed"],
  86. )
  87. def test_auth_policy_keys_are_protected(self, key):
  88. # Not credential-shaped, so the secret hints never catch them.
  89. assert _is_blocked_setting_key(key) is False
  90. assert _is_protected_setting_key(key) is True
  91. @pytest.mark.parametrize("key", ["currency", "auth_secret_key", "mqtt_enabled", "prometheus_enabled"])
  92. def test_protected_set_does_not_swallow_ordinary_or_credential_keys(self, key):
  93. assert _is_protected_setting_key(key) is False
  94. @pytest.mark.parametrize(
  95. "key",
  96. [
  97. "ldap_enabled",
  98. "ldap_server_url",
  99. "ldap_search_base",
  100. "ldap_user_filter",
  101. "ldap_security",
  102. "ldap_group_mapping",
  103. "ldap_auto_provision",
  104. "ldap_ca_cert_path",
  105. "ldap_default_group",
  106. "ldap_bind_dn",
  107. "LDAP_ENABLED",
  108. "ldap_something_added_later",
  109. ],
  110. )
  111. def test_the_whole_ldap_family_is_protected(self, key):
  112. """Together these name *which directory decides who you are*.
  113. ``auth.py`` reads them live from this table on every login, so a restore
  114. that writes them substitutes the authentication source: point
  115. ``ldap_server_url`` at another directory, set ``ldap_auto_provision``,
  116. and ``ldap_default_group`` decides what the account it creates gets.
  117. The companion rule did not cover this and could not: it pairs
  118. ``ldap_enabled`` with ``ldap_bind_password`` and asks whether the
  119. integration will *work*, and an anonymous bind works — so a payload that
  120. simply omitted the password had its toggle written. Refused by prefix so
  121. a key added to the LDAP schema later is refused by default, and matched
  122. case-insensitively because the key comes from the backup's JSON rather
  123. than from our own writer.
  124. """
  125. assert _is_protected_setting_key(key) is True
  126. def test_ldap_enabled_is_not_also_a_companion_toggle(self):
  127. """It was, and the pair is what let the family through.
  128. Kept as a test rather than a comment because re-adding it would read as
  129. tightening the rule while actually being dead code —
  130. ``_is_protected_setting_key`` runs first in ``_plan_settings``.
  131. """
  132. assert "ldap_enabled" not in _COMPANION_CREDENTIALS
  133. def test_ha_token_from_env_is_deliberately_not_carved_out(self):
  134. """Recorded so the review's question about it is not re-litigated.
  135. ``ha_token_from_env`` looks like a false positive for the ``token`` hint,
  136. but it is only ever constructed in the settings GET response
  137. (``get_homeassistant_settings``). It is absent from ``AppSettingsUpdate``
  138. and so is never a ``Settings`` row — it cannot reach a backup, which
  139. makes an allowlist entry for it dead code.
  140. Carving it out would also be a live hole rather than a tidy-up: an
  141. attacker-authored ``settings/app_settings.json`` could then get a
  142. ``*token*``-named row written simply by choosing that name. This
  143. The hints are the primary refusal for every credential the collector
  144. does not filter, so a name-shaped exception to them is exactly the wrong
  145. shape of fix.
  146. """
  147. assert _is_blocked_setting_key("ha_token_from_env") is True
  148. class TestCategoryTally:
  149. def test_a_note_carries_code_params_and_english(self):
  150. tally = _CategoryTally()
  151. tally.note("noData", "No data of this kind in this backup")
  152. tally.note("spoolUsageUnresolved", "2 usage record(s) skipped", count=2)
  153. assert tally.notes == [
  154. {"code": "noData", "params": {}, "message": "No data of this kind in this backup"},
  155. {"code": "spoolUsageUnresolved", "params": {"count": 2}, "message": "2 usage record(s) skipped"},
  156. ]
  157. def test_notes_are_deduplicated(self):
  158. tally = _CategoryTally()
  159. tally.note("noData", "same")
  160. tally.note("noData", "same")
  161. assert len(tally.notes) == 1
  162. def test_the_same_code_with_different_params_is_kept(self):
  163. """Two printers can both be offline, and the user needs both names."""
  164. tally = _CategoryTally()
  165. tally.note("kprofilesPrinterOffline", "A is not connected", printer="A")
  166. tally.note("kprofilesPrinterOffline", "B is not connected", printer="B")
  167. assert len(tally.notes) == 2
  168. def test_notes_are_bounded(self):
  169. tally = _CategoryTally()
  170. for i in range(50):
  171. tally.note("noData", f"note {i}", index=i)
  172. assert len(tally.notes) == 20
  173. class TestRestoreRequestSchema:
  174. def test_rejects_empty_category_list(self):
  175. with pytest.raises(ValueError):
  176. GitHubRestoreRequest(categories=[])
  177. def test_deduplicates_categories(self):
  178. request = GitHubRestoreRequest(
  179. categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
  180. )
  181. assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
  182. def test_defaults_to_head(self):
  183. assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD"
  184. @pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40])
  185. def test_accepts_valid_refs(self, ref):
  186. assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref
  187. @pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"])
  188. def test_rejects_refs_that_are_not_object_names(self, ref):
  189. with pytest.raises(ValueError):
  190. GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS])
  191. class TestRestoreSettings:
  192. @pytest.mark.asyncio
  193. async def test_inserts_missing_keys(self, db_session):
  194. tally = _CategoryTally()
  195. payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
  196. await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
  197. await db_session.commit()
  198. rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
  199. assert rows == {"currency": "EUR", "theme": "dark"}
  200. assert tally.restored == 2
  201. @pytest.mark.asyncio
  202. async def test_skips_existing_key_when_overwrite_off(self, db_session):
  203. db_session.add(Settings(key="currency", value="USD"))
  204. await db_session.commit()
  205. tally = _CategoryTally()
  206. await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally)
  207. await db_session.commit()
  208. row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
  209. assert row.value == "USD"
  210. assert tally.skipped == 1
  211. assert tally.restored == 0
  212. @pytest.mark.asyncio
  213. async def test_overwrites_existing_key_when_enabled(self, db_session):
  214. db_session.add(Settings(key="currency", value="USD"))
  215. await db_session.commit()
  216. tally = _CategoryTally()
  217. await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally)
  218. await db_session.commit()
  219. row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
  220. assert row.value == "EUR"
  221. assert tally.restored == 1
  222. @pytest.mark.asyncio
  223. async def test_credential_keys_are_never_restored(self, db_session):
  224. """A backup predating the collector's denylist can still contain secrets."""
  225. tally = _CategoryTally()
  226. payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}}
  227. await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
  228. await db_session.commit()
  229. keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()}
  230. assert keys == {"currency"}
  231. # Refusals are notes, not tally rows: the preview never counted these
  232. # keys, so counting them here would put the total above what the user
  233. # was shown before they pressed Restore.
  234. assert tally.skipped == 0
  235. assert any("credential-like" in note for note in _messages(tally))
  236. @pytest.mark.asyncio
  237. async def test_auth_settings_are_never_restored(self, db_session):
  238. """Restoring auth_enabled=false would disable auth behind the cache's back."""
  239. db_session.add(Settings(key="auth_enabled", value="true"))
  240. db_session.add(Settings(key="local_login_enabled", value="true"))
  241. await db_session.commit()
  242. tally = _CategoryTally()
  243. payload = {
  244. "settings": {
  245. "currency": "EUR",
  246. "auth_enabled": "false",
  247. "advanced_auth_enabled": "false",
  248. "local_login_enabled": "false",
  249. "setup_completed": "false",
  250. }
  251. }
  252. await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
  253. await db_session.commit()
  254. rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
  255. assert rows["auth_enabled"] == "true"
  256. assert rows["local_login_enabled"] == "true"
  257. assert "advanced_auth_enabled" not in rows
  258. assert "setup_completed" not in rows
  259. assert rows["currency"] == "EUR"
  260. assert tally.restored == 1
  261. # As above: refused keys are outside the preview's count, so outside the
  262. # tally too.
  263. assert tally.skipped == 0
  264. assert any("authentication setting" in note for note in _messages(tally))
  265. @pytest.mark.asyncio
  266. async def test_missing_payload_is_noted_not_fatal(self, db_session):
  267. tally = _CategoryTally()
  268. await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
  269. assert tally.restored == 0
  270. assert _codes(tally) == ["noData"]
  271. class TestSettingValueIsTrue:
  272. """Only the spellings a reader actually treats as "on" count as on."""
  273. @pytest.mark.parametrize("value", ["true", "TRUE", " True ", True])
  274. def test_on(self, value):
  275. assert _setting_value_is_true(value) is True
  276. @pytest.mark.parametrize("value", ["false", "1", "on", "yes", "", None, False, 0])
  277. def test_off(self, value):
  278. # "1"/"on"/"yes" are deliberately off: no reader in the codebase treats
  279. # them as on, so restoring one cannot switch anything on either.
  280. assert _setting_value_is_true(value) is False
  281. class TestUsableCredential:
  282. @pytest.mark.parametrize("value", ["s3cret", " x "])
  283. def test_present_values_are_usable(self, value):
  284. assert _is_usable_credential(value) is True
  285. @pytest.mark.parametrize("value", [None, "", " "])
  286. def test_absent_or_blank_is_not(self, value):
  287. # A present-but-blank prometheus_token row is exactly the `if token:`
  288. # hole in the metrics route, so it must not count as protection.
  289. assert _is_usable_credential(value) is False
  290. class TestCompanionCredentials:
  291. """Toggles whose safety depends on a credential the restore refuses to write.
  292. ``prometheus_enabled`` is the sharp one. ``/api/v1/metrics`` is a public
  293. route whose only gate is a non-empty ``prometheus_token``, so restoring the
  294. toggle onto an instance that has no token row publishes the entire metrics
  295. body to anyone who can reach the port — and with overwrite *off*, since the
  296. row is missing rather than present. The other four break an integration
  297. rather than open one, but they are the same shape.
  298. """
  299. async def _restore(self, db, tally=None, overwrite=False, **settings) -> _CategoryTally:
  300. tally = tally or _CategoryTally()
  301. await _service()._restore_settings(db, {"settings": settings}, overwrite=overwrite, tally=tally)
  302. await db.commit()
  303. return tally
  304. async def _rows(self, db) -> dict:
  305. return {s.key: s.value for s in (await db.execute(select(Settings))).scalars().all()}
  306. # --- The refusal itself ------------------------------------------------
  307. @pytest.mark.asyncio
  308. async def test_prometheus_toggle_is_refused_when_its_token_was_skipped(self, db_session):
  309. """The headline case: overwrite off, empty database, endpoint stays shut."""
  310. tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true", prometheus_token="s3cret")
  311. rows = await self._rows(db_session)
  312. assert rows == {"currency": "EUR"}
  313. assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally))
  314. @pytest.mark.asyncio
  315. @pytest.mark.parametrize("toggle,credential", sorted(_COMPANION_CREDENTIALS.items()))
  316. async def test_every_pair_refuses_its_toggle(self, db_session, toggle, credential, monkeypatch):
  317. monkeypatch.delenv("HA_TOKEN", raising=False)
  318. await self._restore(db_session, **{toggle: "true", credential: "s3cret"})
  319. assert toggle not in await self._rows(db_session)
  320. @pytest.mark.asyncio
  321. async def test_an_authored_ldap_payload_cannot_substitute_the_directory(self, db_session):
  322. """The attack the companion rule could not see, refused end to end.
  323. Anyone who can write to the backup repository can author this file, and
  324. the shape that beat the old rule is the natural one for an attacker:
  325. *omit* ``ldap_bind_password``. They own the directory being pointed at,
  326. so they need no bind credential from us — and an anonymous bind is a
  327. working config, which is exactly what the availability rule was built to
  328. allow through.
  329. Left unrefused, the next login against a fresh username binds to
  330. ``ldap_server_url``, ``ldap_auto_provision`` creates the local account,
  331. and ``ldap_default_group`` decides it is an Administrator. Overwrite-off
  332. is enough on an instance that never configured LDAP: there are no rows
  333. to skip.
  334. """
  335. tally = await self._restore(
  336. db_session,
  337. currency="EUR",
  338. ldap_enabled="true",
  339. ldap_server_url="ldaps://evil.example.com:636",
  340. ldap_security="ldaps",
  341. ldap_search_base="dc=evil,dc=com",
  342. ldap_user_filter="(uid={username})",
  343. ldap_auto_provision="true",
  344. ldap_default_group="Administrators",
  345. )
  346. rows = await self._rows(db_session)
  347. assert rows == {"currency": "EUR"}, "not one LDAP row may land"
  348. assert any("authentication" in note.lower() for note in _messages(tally))
  349. @pytest.mark.asyncio
  350. async def test_ha_toggle_is_refused_when_the_environment_has_no_token(self, db_session, monkeypatch):
  351. monkeypatch.delenv("HA_TOKEN", raising=False)
  352. await self._restore(db_session, ha_enabled="true", ha_token="s3cret", ha_url="http://ha.local")
  353. rows = await self._rows(db_session)
  354. assert "ha_enabled" not in rows
  355. assert rows["ha_url"] == "http://ha.local"
  356. @pytest.mark.asyncio
  357. async def test_a_blank_local_credential_row_is_not_usable(self, db_session):
  358. db_session.add(Settings(key="prometheus_token", value=""))
  359. await db_session.commit()
  360. await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
  361. assert "prometheus_enabled" not in await self._rows(db_session)
  362. @pytest.mark.asyncio
  363. @pytest.mark.parametrize("value", ["TRUE", " True ", True])
  364. async def test_true_is_refused_however_it_is_spelled(self, db_session, value):
  365. await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
  366. assert "prometheus_enabled" not in await self._rows(db_session)
  367. # --- Ruling 3: the tally counts what the preview counted ---------------
  368. @pytest.mark.asyncio
  369. async def test_refusals_are_not_counted_in_the_tally(self, db_session):
  370. tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true", prometheus_token="s3cret")
  371. assert (tally.restored, tally.skipped, tally.failed) == (1, 0, 0)
  372. @pytest.mark.asyncio
  373. async def test_tally_total_equals_the_preview_item_count(self, db_session):
  374. """The ruling, encoded: the user is shown a number, and it has to hold.
  375. Off by three before this change — the two name-based refusals and the
  376. companion one were all counted as ``skipped`` despite never being in the
  377. preview's count.
  378. """
  379. db_session.add(Settings(key="theme", value="light"))
  380. await db_session.commit()
  381. values = {
  382. "currency": "EUR", # inserted -> restored
  383. "theme": "dark", # exists, overwrite off -> skipped
  384. "low_stock_threshold": None, # no value -> skipped
  385. "": "junk", # unusable key -> failed
  386. "bambu_cloud_token": "x", # blocked -> refused
  387. "auth_enabled": "false", # protected -> refused
  388. "prometheus_enabled": "true", # companion -> refused
  389. "prometheus_token": "s3cret", # blocked -> refused
  390. }
  391. item_count, _ = await _service()._count_items(
  392. db_session, RestoreCategory.SETTINGS, {SETTINGS_PATH: {"settings": values}}
  393. )
  394. tally = _CategoryTally()
  395. await _service()._restore_settings(db_session, {"settings": values}, overwrite=False, tally=tally)
  396. await db_session.commit()
  397. assert tally.restored + tally.skipped + tally.failed == item_count
  398. assert (tally.restored, tally.skipped, tally.failed) == (1, 2, 1)
  399. @pytest.mark.asyncio
  400. async def test_the_spools_tally_holds_the_same_invariant(self, db_session):
  401. """Spools broke it the other way: the tally counted more than the preview.
  402. ``_restore_spool_usage`` increments this category's tally, but the
  403. preview counted only the spools and mentioned the usage records in the
  404. detail — so a backup with any usage history reported a total larger than
  405. the number the user was shown.
  406. """
  407. spools = {
  408. "spools": [
  409. {"id": 1, "material": "PLA", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
  410. {"id": 2, "material": "PETG", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
  411. ]
  412. }
  413. usage = {
  414. "usage_history": [
  415. {"id": 9, "spool_id": 1, "grams_used": 12.5, "created_at": "2026-01-06 09:00:00"},
  416. {"id": 10, "spool_id": 2, "grams_used": 4.0, "created_at": "2026-01-06 10:00:00"},
  417. {"id": 11, "spool_id": 404, "grams_used": 1.0, "created_at": "2026-01-06 11:00:00"},
  418. ]
  419. }
  420. item_count, _ = await _service()._count_items(
  421. db_session, RestoreCategory.SPOOLS, {SPOOLS_PATH: spools, SPOOL_USAGE_PATH: usage}
  422. )
  423. tally = _CategoryTally()
  424. await _service()._restore_spools(db_session, spools, usage, False, tally, {})
  425. await db_session.commit()
  426. assert item_count == 5, "two spools plus three usage records, all of which the tally counts"
  427. assert tally.restored + tally.skipped + tally.failed == item_count
  428. @pytest.mark.asyncio
  429. async def test_preview_count_drops_by_one_when_the_local_credential_is_missing(self, db_session):
  430. parsed = {
  431. SETTINGS_PATH: {"settings": {"currency": "EUR", "prometheus_enabled": "true", "prometheus_token": "s3cret"}}
  432. }
  433. refused_count, refused_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  434. db_session.add(Settings(key="prometheus_token", value="already-set"))
  435. await db_session.commit()
  436. allowed_count, allowed_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  437. assert refused_count == allowed_count - 1
  438. assert refused_detail.code == "settingsCompanionWillSkip"
  439. assert refused_detail.params == {"count": 1, "companion": 1}
  440. # Nothing is being left off now, so the wording drops back to the plain
  441. # credential caveat.
  442. assert allowed_detail.code == "settingsCredentialsWillSkip"
  443. # --- The exposure class: a blank backup credential is the hole ----------
  444. #
  445. # The rule's second condition — "the backup carried a usable credential" —
  446. # is what stops it refusing an anonymous MQTT broker. It does not transfer to
  447. # Prometheus: a backup taken on an instance that enabled Prometheus without a
  448. # token (the field is optional and defaults to "") carries the toggle and no
  449. # usable token, and writing it opens /api/v1/metrics just as wide. That is
  450. # the *more* likely source of the exposure, not the less.
  451. @pytest.mark.asyncio
  452. async def test_prometheus_is_refused_when_the_backup_has_no_token_key_at_all(self, db_session):
  453. tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true")
  454. rows = await self._rows(db_session)
  455. assert rows == {"currency": "EUR"}
  456. assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally))
  457. @pytest.mark.asyncio
  458. @pytest.mark.parametrize("token", ["", " "])
  459. async def test_prometheus_is_refused_when_the_backup_token_is_blank(self, db_session, token):
  460. tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token=token)
  461. assert "prometheus_enabled" not in await self._rows(db_session)
  462. assert "settingsCompanionSkipped" in _codes(tally)
  463. @pytest.mark.asyncio
  464. async def test_the_preview_says_so_with_no_credential_key_to_skip(self, db_session):
  465. """The wording has to survive ``blocked`` being empty.
  466. The shared caveat counts credential-like keys *and* switches; on this
  467. payload there are no credential-like keys, so "0 credential-like key(s)
  468. will be skipped" would be noise.
  469. """
  470. parsed = {SETTINGS_PATH: {"settings": {"currency": "EUR", "prometheus_enabled": "true"}}}
  471. count, detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  472. assert count == 1
  473. assert detail.code == "settingsCompanionOnlyWillSkip"
  474. assert detail.params == {"companion": 1}
  475. @pytest.mark.asyncio
  476. async def test_the_availability_class_keeps_the_backup_credential_condition(self, db_session):
  477. """The other half of the same change: only Prometheus loses condition 2.
  478. Absent is treated like blank here — an anonymous broker is a working
  479. config, so refusing it would be a false positive.
  480. LDAP used to be in this list and is not any more: the same reasoning that
  481. makes an anonymous bind legitimate is what let an authored payload point
  482. the instance at another directory, so the family is refused outright
  483. rather than judged on availability. See
  484. ``test_the_whole_ldap_family_is_protected``.
  485. """
  486. await self._restore(db_session, mqtt_enabled="true", virtual_printer_enabled="true")
  487. rows = await self._rows(db_session)
  488. assert rows["mqtt_enabled"] == "true"
  489. assert rows["virtual_printer_enabled"] == "true"
  490. def test_every_exposure_toggle_is_a_companion_toggle(self):
  491. assert _COMPANION_EXPOSURE_TOGGLES.issubset(_COMPANION_CREDENTIALS)
  492. # --- Controls: over-refusal is the real risk here ----------------------
  493. @pytest.mark.asyncio
  494. async def test_a_usable_local_credential_lets_the_toggle_through(self, db_session):
  495. db_session.add(Settings(key="prometheus_token", value="already-set"))
  496. await db_session.commit()
  497. tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
  498. assert (await self._rows(db_session))["prometheus_enabled"] == "true"
  499. assert not any("switched off" in note for note in _messages(tally))
  500. @pytest.mark.asyncio
  501. async def test_the_exposure_route_still_stands_down_for_a_local_token(self, db_session):
  502. """Skipping condition 2 must not skip the local-state pass with it."""
  503. db_session.add(Settings(key="prometheus_token", value="already-set"))
  504. await db_session.commit()
  505. tally = await self._restore(db_session, prometheus_enabled="true")
  506. assert (await self._rows(db_session))["prometheus_enabled"] == "true"
  507. assert not any("switched off" in note for note in _messages(tally))
  508. @pytest.mark.asyncio
  509. async def test_the_exposure_route_still_stands_down_when_already_on(self, db_session):
  510. """The exposure pre-dates this restore either way — see ruling 3."""
  511. db_session.add(Settings(key="prometheus_enabled", value="true"))
  512. await db_session.commit()
  513. tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true")
  514. assert (await self._rows(db_session))["prometheus_enabled"] == "true"
  515. assert not any("switched off" in note for note in _messages(tally))
  516. @pytest.mark.asyncio
  517. async def test_an_anonymous_broker_is_not_a_false_positive(self, db_session):
  518. """mqtt_relay passes an empty password straight through — a real config."""
  519. tally = await self._restore(db_session, mqtt_enabled="true", mqtt_broker="10.0.0.5")
  520. assert (await self._rows(db_session))["mqtt_enabled"] == "true"
  521. assert not any("switched off" in note for note in _messages(tally))
  522. @pytest.mark.asyncio
  523. async def test_a_blank_ldap_bind_password_no_longer_lets_the_toggle_through(self, db_session):
  524. """The inverted control, and the reason the LDAP pair had to go.
  525. A blank bind password used to read as "anonymous bind, a working config,
  526. do not over-refuse". It reads the same way to an attacker authoring the
  527. file, who wants no bind credential precisely because the directory is
  528. theirs — so the availability question cannot be asked about an
  529. authentication source at all.
  530. """
  531. await self._restore(db_session, ldap_enabled="true", ldap_bind_password=" ")
  532. assert "ldap_enabled" not in await self._rows(db_session)
  533. @pytest.mark.asyncio
  534. async def test_turning_a_toggle_off_is_always_written(self, db_session):
  535. await self._restore(db_session, prometheus_enabled="false", prometheus_token="s3cret")
  536. assert (await self._rows(db_session))["prometheus_enabled"] == "false"
  537. @pytest.mark.asyncio
  538. @pytest.mark.parametrize("value", ["1", "on", "yes"])
  539. async def test_spellings_no_reader_treats_as_on_are_written(self, db_session, value):
  540. await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
  541. assert (await self._rows(db_session))["prometheus_enabled"] == value
  542. @pytest.mark.asyncio
  543. async def test_ha_token_in_the_environment_counts_as_usable(self, db_session, monkeypatch):
  544. monkeypatch.setenv("HA_TOKEN", "from-env")
  545. await self._restore(db_session, ha_enabled="true", ha_token="s3cret")
  546. assert (await self._rows(db_session))["ha_enabled"] == "true"
  547. @pytest.mark.asyncio
  548. async def test_a_toggle_already_on_locally_is_written(self, db_session):
  549. """The exposure pre-dates the restore, so "left switched off" would be a lie."""
  550. db_session.add(Settings(key="prometheus_enabled", value="true"))
  551. await db_session.commit()
  552. tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true", prometheus_token="s3cret")
  553. assert (await self._rows(db_session))["prometheus_enabled"] == "true"
  554. assert not any("switched off" in note for note in _messages(tally))
  555. # --- The map itself ----------------------------------------------------
  556. def test_every_companion_credential_is_blocked_and_no_toggle_is(self):
  557. """Guards the rule against a future edit to _SECRET_KEY_HINTS.
  558. If a credential stopped being blocked, its toggle would travel with it
  559. and the refusal would be pointless; if a toggle started being blocked,
  560. the pair would never be reached at all.
  561. """
  562. for toggle, credential in _COMPANION_CREDENTIALS.items():
  563. assert _is_blocked_setting_key(credential) is True, credential
  564. assert _is_blocked_setting_key(toggle) is False, toggle
  565. assert _is_protected_setting_key(toggle) is False, toggle
  566. def test_every_environment_override_names_a_companion_credential(self):
  567. assert set(_COMPANION_CREDENTIAL_ENV) <= set(_COMPANION_CREDENTIALS.values())
  568. @pytest.mark.asyncio
  569. async def test_plan_leaves_unusable_key_names_in_no_bucket(self, db_session):
  570. """They are the restore's ``failed``, not a refusal."""
  571. plan = await _service()._plan_settings(db_session, {"": "x", 7: "y", "currency": "EUR"})
  572. assert plan == _SettingsPlan()
  573. class TestSpoolTagOverwrite:
  574. """Overwrite must not write the backup's *other* tag key onto a matched spool.
  575. ``tag_uid`` and ``tray_uuid`` are both in the overwrite ``setattr`` loop, and
  576. neither column has a unique constraint, so writing one onto a spool matched
  577. by the other silently creates a duplicate tag rather than erroring. After
  578. that ``_find_spool``'s ``.first()`` is non-deterministic and an AMS tag
  579. lookup resolves to an arbitrary one of the two. The same loop can also clear
  580. a tag the user has scanned since the backup was taken.
  581. """
  582. def _entry(self, **overrides):
  583. entry = {
  584. "id": 41,
  585. "material": "PLA",
  586. "brand": "Bambu Lab",
  587. "created_at": "2026-01-05 12:00:00",
  588. "tag_uid": "TAG-A",
  589. "tray_uuid": None,
  590. }
  591. entry.update(overrides)
  592. return entry
  593. async def _restore(self, db, entry, tally=None):
  594. tally = tally or _CategoryTally()
  595. await _service()._restore_spools(db, {"spools": [entry]}, None, True, tally, {})
  596. await db.commit()
  597. return tally
  598. @pytest.mark.asyncio
  599. async def test_an_empty_incoming_tag_does_not_clear_a_scanned_one(self, db_session):
  600. """The backup predates the scan, so the local tag is the newer fact."""
  601. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-LIVE"))
  602. await db_session.commit()
  603. tally = await self._restore(db_session, self._entry(tray_uuid=None))
  604. row = (await db_session.execute(select(Spool))).scalar_one()
  605. assert row.tray_uuid == "TRAY-LIVE"
  606. assert any(note["code"] == "spoolTagKept" for note in tally.notes)
  607. @pytest.mark.asyncio
  608. async def test_a_tag_another_spool_already_holds_is_not_written(self, db_session):
  609. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A"))
  610. db_session.add(Spool(material="PETG", brand="Other", tray_uuid="TRAY-B"))
  611. await db_session.commit()
  612. tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-B"))
  613. holders = (await db_session.execute(select(Spool).where(Spool.tray_uuid == "TRAY-B"))).scalars().all()
  614. assert len(holders) == 1, "a duplicate tray_uuid makes AMS lookups non-deterministic"
  615. assert holders[0].material == "PETG"
  616. assert any(note["code"] == "spoolTagKept" for note in tally.notes)
  617. @pytest.mark.asyncio
  618. async def test_the_note_counts_every_column_it_kept(self, db_session):
  619. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-LIVE"))
  620. db_session.add(Spool(material="PETG", brand="Other", tag_uid="TAG-CLASH"))
  621. await db_session.commit()
  622. # Matched on tray_uuid, so the guard judges tag_uid: it clashes.
  623. tally = await self._restore(db_session, self._entry(tag_uid="TAG-CLASH", tray_uuid="TRAY-LIVE"))
  624. row = (await db_session.execute(select(Spool).where(Spool.tray_uuid == "TRAY-LIVE"))).scalar_one()
  625. assert row.tag_uid == "TAG-A"
  626. note = next(n for n in tally.notes if n["code"] == "spoolTagKept")
  627. assert note["params"] == {"count": 1}
  628. # --- Controls ----------------------------------------------------------
  629. @pytest.mark.asyncio
  630. async def test_a_free_tag_is_still_written(self, db_session):
  631. """The point of overwrite: a spool that gained a tray_uuid gets it."""
  632. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A"))
  633. await db_session.commit()
  634. tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-NEW"))
  635. row = (await db_session.execute(select(Spool))).scalar_one()
  636. assert row.tray_uuid == "TRAY-NEW"
  637. assert not any(note["code"] == "spoolTagKept" for note in tally.notes)
  638. @pytest.mark.asyncio
  639. async def test_an_unchanged_tag_is_not_reported_as_kept(self, db_session):
  640. db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-A"))
  641. await db_session.commit()
  642. tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-A"))
  643. assert not any(note["code"] == "spoolTagKept" for note in tally.notes)
  644. @pytest.mark.asyncio
  645. async def test_a_new_spool_keeps_both_tags_from_the_backup(self, db_session):
  646. """The guard is an overwrite-only concern; an insert is unaffected."""
  647. await self._restore(db_session, self._entry(tag_uid="TAG-NEW", tray_uuid="TRAY-NEW"))
  648. row = (await db_session.execute(select(Spool))).scalar_one()
  649. assert (row.tag_uid, row.tray_uuid) == ("TAG-NEW", "TRAY-NEW")
  650. @pytest.mark.asyncio
  651. async def test_find_spool_reports_which_key_matched(self, db_session):
  652. db_session.add(Spool(material="PLA", tag_uid="TAG-A"))
  653. db_session.add(Spool(material="PETG", tray_uuid="TRAY-B"))
  654. await db_session.commit()
  655. service = _service()
  656. assert (await service._find_spool(db_session, {"tag_uid": "TAG-A"}))[1] == "tag_uid"
  657. assert (await service._find_spool(db_session, {"tray_uuid": "TRAY-B"}))[1] == "tray_uuid"
  658. assert await service._find_spool(db_session, {"tag_uid": "NOPE"}) == (None, None)
  659. class TestRestoreSpools:
  660. def _spool_entry(self, **overrides):
  661. entry = {
  662. "id": 41,
  663. "material": "PLA",
  664. "subtype": "Basic",
  665. "color_name": "Jade White",
  666. "brand": "Bambu Lab",
  667. "tag_uid": "AABBCCDD",
  668. "created_at": "2026-01-05 12:00:00",
  669. "weight_used": 120.5,
  670. }
  671. entry.update(overrides)
  672. return entry
  673. @pytest.mark.asyncio
  674. async def test_inserts_without_reusing_backup_id(self, db_session):
  675. """The backup's spool.id belongs to an unrelated row today."""
  676. db_session.add(Spool(material="PETG")) # occupies id 1
  677. await db_session.commit()
  678. tally = _CategoryTally()
  679. payload = {"spools": [self._spool_entry(id=1)]}
  680. await _service()._restore_spools(db_session, payload, None, False, tally, {})
  681. await db_session.commit()
  682. spools = (await db_session.execute(select(Spool))).scalars().all()
  683. assert len(spools) == 2
  684. restored = next(s for s in spools if s.tag_uid == "AABBCCDD")
  685. assert restored.id != 1
  686. assert restored.material == "PLA"
  687. @pytest.mark.asyncio
  688. async def test_matches_existing_spool_by_tag_uid(self, db_session):
  689. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
  690. await db_session.commit()
  691. tally = _CategoryTally()
  692. await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {})
  693. await db_session.commit()
  694. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  695. assert tally.skipped == 1
  696. @pytest.mark.asyncio
  697. async def test_matches_existing_spool_by_tray_uuid(self, db_session):
  698. db_session.add(Spool(material="PLA", tray_uuid="1234" * 8))
  699. await db_session.commit()
  700. tally = _CategoryTally()
  701. entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8)
  702. await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
  703. await db_session.commit()
  704. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  705. assert tally.skipped == 1
  706. @pytest.mark.asyncio
  707. async def test_matches_tagless_spool_by_descriptive_composite(self, db_session):
  708. """Manually added spools have no tag, so fall back to created_at + description."""
  709. db_session.add(
  710. Spool(
  711. material="PLA",
  712. subtype="Basic",
  713. color_name="Jade White",
  714. brand="Bambu Lab",
  715. created_at=datetime(2026, 1, 5, 12, 0, 0),
  716. )
  717. )
  718. await db_session.commit()
  719. tally = _CategoryTally()
  720. entry = self._spool_entry(tag_uid=None)
  721. await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
  722. await db_session.commit()
  723. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  724. assert tally.skipped == 1
  725. @pytest.mark.asyncio
  726. async def test_overwrite_updates_matched_spool(self, db_session):
  727. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0))
  728. await db_session.commit()
  729. tally = _CategoryTally()
  730. await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
  731. await db_session.commit()
  732. row = (await db_session.execute(select(Spool))).scalar_one()
  733. assert row.color_name == "Jade White"
  734. assert row.weight_used == 120.5
  735. assert tally.restored == 1
  736. @pytest.mark.asyncio
  737. async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session):
  738. """Second restore of the same backup must match, not duplicate."""
  739. service = _service()
  740. payload = {"spools": [self._spool_entry(tag_uid=None)]}
  741. await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
  742. await db_session.commit()
  743. await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
  744. await db_session.commit()
  745. spools = (await db_session.execute(select(Spool))).scalars().all()
  746. assert len(spools) == 1
  747. assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
  748. @pytest.mark.asyncio
  749. async def test_usage_history_spool_id_is_remapped(self, db_session):
  750. """Usage rows must point at the new local spool id, not the backup's."""
  751. tally = _CategoryTally()
  752. inventory = {"spools": [self._spool_entry(id=41)]}
  753. usage = {
  754. "usage_history": [
  755. {
  756. "id": 900,
  757. "spool_id": 41,
  758. "printer_id": None,
  759. "print_name": "benchy.3mf",
  760. "archive_id": None,
  761. "weight_used": 12.0,
  762. "percent_used": 5,
  763. "status": "completed",
  764. "created_at": "2026-02-01 09:00:00",
  765. }
  766. ]
  767. }
  768. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  769. await db_session.commit()
  770. spool = (await db_session.execute(select(Spool))).scalar_one()
  771. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  772. assert row.spool_id == spool.id
  773. assert row.print_name == "benchy.3mf"
  774. @pytest.mark.asyncio
  775. async def test_usage_history_archive_id_is_remapped(self, db_session):
  776. tally = _CategoryTally()
  777. inventory = {"spools": [self._spool_entry(id=41)]}
  778. usage = {
  779. "usage_history": [
  780. {
  781. "spool_id": 41,
  782. "archive_id": 77,
  783. "weight_used": 1.0,
  784. "created_at": "2026-02-01 09:00:00",
  785. }
  786. ]
  787. }
  788. archive = PrintArchive(filename="a.3mf", file_path="", file_size=1)
  789. db_session.add(archive)
  790. await db_session.flush()
  791. await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id})
  792. await db_session.commit()
  793. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  794. assert row.archive_id == archive.id
  795. @pytest.mark.asyncio
  796. async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
  797. tally = _CategoryTally()
  798. usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]}
  799. await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
  800. await db_session.commit()
  801. assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
  802. assert tally.skipped == 1
  803. assert any("their spool is not in this backup's spool list" in note for note in _messages(tally))
  804. # No remedy is offered, because none exists: overwrite does not change
  805. # which spools land in the map (a skipped spool is mapped anyway), and
  806. # usage history is always restored alongside the spools category.
  807. assert not any("overwrite" in note.lower() for note in _messages(tally))
  808. @pytest.mark.asyncio
  809. async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
  810. """A skipped spool is still mapped, so its usage rows are not "unresolved".
  811. This is why the note above offers no remedy: turning overwrite on would
  812. not rescue anything, and saying so misdescribed which records are lost.
  813. """
  814. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
  815. await db_session.commit()
  816. tally = _CategoryTally()
  817. inventory = {"spools": [self._spool_entry(id=41)]}
  818. usage = {
  819. "usage_history": [
  820. {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
  821. ]
  822. }
  823. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  824. await db_session.commit()
  825. spool = (await db_session.execute(select(Spool))).scalar_one()
  826. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  827. assert row.spool_id == spool.id
  828. assert not any("spool list" in note for note in _messages(tally))
  829. @pytest.mark.asyncio
  830. async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
  831. service = _service()
  832. inventory = {"spools": [self._spool_entry(id=41)]}
  833. usage = {
  834. "usage_history": [
  835. {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
  836. ]
  837. }
  838. await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  839. await db_session.commit()
  840. await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  841. await db_session.commit()
  842. rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
  843. assert len(rows) == 1
  844. @pytest.mark.asyncio
  845. async def test_dropped_archive_link_is_explained(self, db_session):
  846. """Spools without archives nulls every usage -> archive link, silently."""
  847. tally = _CategoryTally()
  848. inventory = {"spools": [self._spool_entry(id=41)]}
  849. usage = {
  850. "usage_history": [
  851. {"spool_id": 41, "archive_id": 7, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"},
  852. {"spool_id": 41, "archive_id": 8, "weight_used": 2.0, "created_at": "2026-02-01 10:00:00"},
  853. {"spool_id": 41, "weight_used": 3.0, "created_at": "2026-02-01 11:00:00"},
  854. ]
  855. }
  856. # Empty archive_id_map: the archives category wasn't selected, so its
  857. # payload was never fetched and there is nothing to match against.
  858. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  859. await db_session.commit()
  860. rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
  861. assert len(rows) == 3
  862. assert all(row.archive_id is None for row in rows)
  863. # Only the two that had a link to lose are counted.
  864. assert any("2 usage record(s) restored without their print-history link" in n for n in _messages(tally))
  865. assert any("select Print archives alongside" in n for n in _messages(tally))
  866. @pytest.mark.asyncio
  867. async def test_no_note_when_every_archive_link_resolves(self, db_session):
  868. tally = _CategoryTally()
  869. inventory = {"spools": [self._spool_entry(id=41)]}
  870. usage = {
  871. "usage_history": [
  872. {"spool_id": 41, "archive_id": 7, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
  873. ]
  874. }
  875. archive = PrintArchive(filename="linked.3mf", file_path="", file_size=1)
  876. db_session.add(archive)
  877. await db_session.flush()
  878. await _service()._restore_spools(db_session, inventory, usage, False, tally, {7: archive.id})
  879. await db_session.commit()
  880. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  881. assert row.archive_id == archive.id
  882. assert not any("print-history link" in note for note in _messages(tally))
  883. @pytest.mark.asyncio
  884. async def test_dangling_printer_id_is_cleared(self, db_session):
  885. tally = _CategoryTally()
  886. inventory = {"spools": [self._spool_entry(id=41)]}
  887. usage = {
  888. "usage_history": [
  889. {"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
  890. ]
  891. }
  892. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  893. await db_session.commit()
  894. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  895. assert row.printer_id is None
  896. class TestServerDefaultCreatedAtDedupe:
  897. """Dedupe against rows whose ``created_at`` came from the server default.
  898. Every test above seeds its "existing" row through the restore itself, which
  899. binds ``created_at`` explicitly — so both sides end up in SQLAlchemy's
  900. microsecond format and a SQL ``==`` matches. Rows the *application* created
  901. do not: SQLite fills ``server_default=func.now()`` from
  902. ``CURRENT_TIMESTAMP``, which has second precision, and the two strings
  903. never compare equal. That is the ordinary case — a user's own spools and
  904. their print history — and it duplicated the lot on every restore.
  905. """
  906. @staticmethod
  907. async def _native_spool(db_session, **kwargs):
  908. """A spool created the way the app creates one: no explicit created_at."""
  909. spool = Spool(material="PLA", brand="Bambu Lab", subtype="Basic", color_name="Jade White", **kwargs)
  910. db_session.add(spool)
  911. await db_session.commit()
  912. await db_session.refresh(spool)
  913. return spool
  914. def _entry_for(self, spool, **overrides):
  915. """The backup entry the collector writes for ``spool``."""
  916. entry = {
  917. "id": 41,
  918. "material": spool.material,
  919. "brand": spool.brand,
  920. "subtype": spool.subtype,
  921. "color_name": spool.color_name,
  922. "created_at": str(spool.created_at),
  923. }
  924. entry.update(overrides)
  925. return entry
  926. @pytest.mark.asyncio
  927. async def test_find_spool_matches_on_the_composite_fallback(self, db_session):
  928. spool = await self._native_spool(db_session)
  929. found, matched_on = await _service()._find_spool(db_session, self._entry_for(spool))
  930. assert found is not None and found.id == spool.id
  931. assert matched_on is None # the composite, not a tag column
  932. @pytest.mark.asyncio
  933. async def test_a_tagless_spool_is_not_duplicated(self, db_session):
  934. spool = await self._native_spool(db_session)
  935. payload = {"spools": [self._entry_for(spool)]}
  936. tally = _CategoryTally()
  937. await _service()._restore_spools(db_session, payload, None, False, tally, {})
  938. await db_session.commit()
  939. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  940. assert tally.skipped == 1
  941. @pytest.mark.asyncio
  942. async def test_overwrite_updates_the_original_instead_of_inserting(self, db_session):
  943. spool = await self._native_spool(db_session)
  944. payload = {"spools": [self._entry_for(spool, weight_used=250.0)]}
  945. await _service()._restore_spools(db_session, payload, None, True, _CategoryTally(), {})
  946. await db_session.commit()
  947. row = (await db_session.execute(select(Spool))).scalar_one()
  948. assert row.id == spool.id
  949. assert row.weight_used == 250.0
  950. @pytest.mark.asyncio
  951. async def test_a_second_spool_added_later_stays_distinct(self, db_session):
  952. """The composite is only unique because of created_at, so the Python
  953. comparison has to stay exact — not a same-day tolerance."""
  954. spool = await self._native_spool(db_session)
  955. twin = Spool(material=spool.material, brand=spool.brand, subtype=spool.subtype, color_name=spool.color_name)
  956. twin.created_at = spool.created_at + timedelta(hours=1)
  957. db_session.add(twin)
  958. await db_session.commit()
  959. found, _ = await _service()._find_spool(db_session, self._entry_for(spool))
  960. assert found.id == spool.id
  961. @pytest.mark.asyncio
  962. async def test_existing_usage_history_is_not_re_inserted(self, db_session):
  963. spool = await self._native_spool(db_session, tag_uid="AABBCCDD")
  964. usage_row = SpoolUsageHistory(spool_id=spool.id, print_name="b.3mf", weight_used=5.0)
  965. db_session.add(usage_row)
  966. await db_session.commit()
  967. await db_session.refresh(usage_row)
  968. tally = _CategoryTally()
  969. inventory = {"spools": [self._entry_for(spool, tag_uid="AABBCCDD")]}
  970. usage = {
  971. "usage_history": [
  972. {
  973. "spool_id": 41,
  974. "print_name": "b.3mf",
  975. "weight_used": 5.0,
  976. "created_at": str(usage_row.created_at),
  977. }
  978. ]
  979. }
  980. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  981. await db_session.commit()
  982. rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
  983. assert len(rows) == 1
  984. assert tally.skipped == 2 # the spool and its one usage row
  985. @pytest.mark.asyncio
  986. async def test_a_genuinely_new_usage_row_still_lands(self, db_session):
  987. """Dedupe by timestamp must not swallow a repeat of the same print."""
  988. spool = await self._native_spool(db_session, tag_uid="AABBCCDD")
  989. usage_row = SpoolUsageHistory(spool_id=spool.id, print_name="b.3mf", weight_used=5.0)
  990. db_session.add(usage_row)
  991. await db_session.commit()
  992. await db_session.refresh(usage_row)
  993. inventory = {"spools": [self._entry_for(spool, tag_uid="AABBCCDD")]}
  994. usage = {
  995. "usage_history": [
  996. {
  997. "spool_id": 41,
  998. "print_name": "b.3mf",
  999. "weight_used": 5.0,
  1000. "created_at": str(usage_row.created_at + timedelta(days=1)),
  1001. }
  1002. ]
  1003. }
  1004. await _service()._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  1005. await db_session.commit()
  1006. assert len((await db_session.execute(select(SpoolUsageHistory))).scalars().all()) == 2
  1007. class TestRestoreArchives:
  1008. def _archive_entry(self, **overrides):
  1009. entry = {
  1010. "id": 77,
  1011. "filename": "benchy.3mf",
  1012. "file_size": 2048,
  1013. "content_hash": "abc123",
  1014. "print_name": "Benchy",
  1015. "status": "completed",
  1016. "started_at": "2026-03-01 10:00:00",
  1017. "completed_at": "2026-03-01 11:00:00",
  1018. "created_at": "2026-03-01 10:00:00",
  1019. "quantity": 1,
  1020. "is_favorite": False,
  1021. }
  1022. entry.update(overrides)
  1023. return entry
  1024. @pytest.mark.asyncio
  1025. async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
  1026. """print_archives.file_path is NOT NULL but is not in the backup."""
  1027. tally = _CategoryTally()
  1028. id_map: dict[int, int] = {}
  1029. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map)
  1030. await db_session.commit()
  1031. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1032. assert row.file_path == ""
  1033. assert row.filename == "benchy.3mf"
  1034. assert row.id != 77
  1035. assert id_map == {77: row.id}
  1036. assert any("metadata only" in note for note in _messages(tally))
  1037. @pytest.mark.asyncio
  1038. async def test_matches_existing_archive_by_hash_and_start(self, db_session):
  1039. db_session.add(
  1040. PrintArchive(
  1041. filename="benchy.3mf",
  1042. file_path="/data/benchy.3mf",
  1043. file_size=2048,
  1044. content_hash="abc123",
  1045. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1046. )
  1047. )
  1048. await db_session.commit()
  1049. tally = _CategoryTally()
  1050. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
  1051. await db_session.commit()
  1052. rows = (await db_session.execute(select(PrintArchive))).scalars().all()
  1053. assert len(rows) == 1
  1054. assert rows[0].file_path == "/data/benchy.3mf"
  1055. assert tally.skipped == 1
  1056. @pytest.mark.asyncio
  1057. async def test_falls_back_to_filename_and_start_without_hash(self, db_session):
  1058. db_session.add(
  1059. PrintArchive(
  1060. filename="benchy.3mf",
  1061. file_path="/data/benchy.3mf",
  1062. file_size=2048,
  1063. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1064. )
  1065. )
  1066. await db_session.commit()
  1067. tally = _CategoryTally()
  1068. entry = self._archive_entry(content_hash=None)
  1069. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  1070. await db_session.commit()
  1071. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
  1072. assert tally.skipped == 1
  1073. @pytest.mark.asyncio
  1074. async def test_matches_archive_with_no_started_at_by_hash(self, db_session):
  1075. """started_at is NULL for re-sliced archives, so it cannot be required.
  1076. Gating both match branches on it meant these rows never matched: every
  1077. restore re-inserted them and overwrite mode could never update them.
  1078. """
  1079. db_session.add(
  1080. PrintArchive(
  1081. filename="benchy.3mf",
  1082. file_path="/data/benchy.3mf",
  1083. file_size=2048,
  1084. content_hash="abc123",
  1085. started_at=None,
  1086. )
  1087. )
  1088. await db_session.commit()
  1089. tally = _CategoryTally()
  1090. entry = self._archive_entry(started_at=None)
  1091. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  1092. await db_session.commit()
  1093. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
  1094. assert tally.skipped == 1
  1095. @pytest.mark.asyncio
  1096. async def test_started_at_still_discriminates_when_present(self, db_session):
  1097. """A NULL-tolerant match must not collapse rows that do differ."""
  1098. db_session.add(
  1099. PrintArchive(
  1100. filename="benchy.3mf",
  1101. file_path="/data/benchy.3mf",
  1102. file_size=2048,
  1103. content_hash="abc123",
  1104. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1105. )
  1106. )
  1107. await db_session.commit()
  1108. tally = _CategoryTally()
  1109. # Same file, no start time recorded — a different row, not that one.
  1110. entry = self._archive_entry(started_at=None)
  1111. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  1112. await db_session.commit()
  1113. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2
  1114. assert tally.restored == 1
  1115. @pytest.mark.asyncio
  1116. async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session):
  1117. """A backup keeps soft-deleted rows, so the flag has to survive.
  1118. Their row is retained on purpose (stats keep counting the filament and
  1119. energy), so without carrying deleted_at a restore turns an archive the
  1120. user deleted back into a visible one.
  1121. """
  1122. tally = _CategoryTally()
  1123. entry = self._archive_entry(deleted_at="2026-03-02 08:00:00")
  1124. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  1125. await db_session.commit()
  1126. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1127. assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0)
  1128. assert tally.restored == 1
  1129. @pytest.mark.asyncio
  1130. async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session):
  1131. db_session.add(
  1132. PrintArchive(
  1133. filename="benchy.3mf",
  1134. file_path="",
  1135. file_size=2048,
  1136. content_hash="abc123",
  1137. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1138. deleted_at=datetime(2026, 3, 5, 9, 0, 0),
  1139. )
  1140. )
  1141. await db_session.commit()
  1142. tally = _CategoryTally()
  1143. # The backup predates the deletion, so its copy is live.
  1144. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
  1145. await db_session.commit()
  1146. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1147. assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0)
  1148. assert tally.skipped == 1
  1149. @pytest.mark.asyncio
  1150. async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session):
  1151. """The entry has to *say* the archive was live — absent no longer means null.
  1152. A commit taken before the collector wrote ``deleted_at`` carries no
  1153. opinion about it, and overwrite now leaves the column alone in that
  1154. case; see ``TestRestoredArchiveOwnership``.
  1155. """
  1156. db_session.add(
  1157. PrintArchive(
  1158. filename="benchy.3mf",
  1159. file_path="",
  1160. file_size=2048,
  1161. content_hash="abc123",
  1162. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1163. deleted_at=datetime(2026, 3, 5, 9, 0, 0),
  1164. )
  1165. )
  1166. await db_session.commit()
  1167. tally = _CategoryTally()
  1168. await _service()._restore_archives(
  1169. db_session, {"archives": [self._archive_entry(deleted_at=None)]}, True, tally, {}
  1170. )
  1171. await db_session.commit()
  1172. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1173. assert row.deleted_at is None
  1174. assert tally.restored == 1
  1175. assert any("visible again" in note for note in _messages(tally))
  1176. @pytest.mark.asyncio
  1177. async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
  1178. db_session.add(
  1179. PrintArchive(
  1180. filename="benchy.3mf",
  1181. file_path="/data/benchy.3mf",
  1182. file_size=2048,
  1183. content_hash="abc123",
  1184. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1185. notes="old",
  1186. )
  1187. )
  1188. await db_session.commit()
  1189. tally = _CategoryTally()
  1190. entry = self._archive_entry(notes="restored note")
  1191. await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
  1192. await db_session.commit()
  1193. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1194. assert row.notes == "restored note"
  1195. # The 3MF on disk must not be orphaned by a metadata restore.
  1196. assert row.file_path == "/data/benchy.3mf"
  1197. assert tally.restored == 1
  1198. @pytest.mark.asyncio
  1199. async def test_dangling_printer_and_project_links_are_cleared(self, db_session):
  1200. tally = _CategoryTally()
  1201. entry = self._archive_entry(printer_id=4242, project_id=4343)
  1202. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  1203. await db_session.commit()
  1204. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1205. assert row.printer_id is None
  1206. assert row.project_id is None
  1207. assert any("no longer exist" in note for note in _messages(tally))
  1208. @pytest.mark.asyncio
  1209. async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
  1210. printer = await printer_factory()
  1211. tally = _CategoryTally()
  1212. entry = self._archive_entry(printer_id=printer.id)
  1213. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  1214. await db_session.commit()
  1215. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1216. assert row.printer_id == printer.id
  1217. @pytest.mark.asyncio
  1218. async def test_non_dict_entry_counts_as_failed(self, db_session):
  1219. tally = _CategoryTally()
  1220. await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {})
  1221. assert tally.failed == 1
  1222. class TestRestoreKprofiles:
  1223. @staticmethod
  1224. def _live(
  1225. slot_id,
  1226. filament_id="GFA00",
  1227. name="Bambu PLA",
  1228. setting_id="PFUS123",
  1229. extruder_id=0,
  1230. nozzle_id="HS00-0.4",
  1231. ):
  1232. """One profile as the printer currently reports it.
  1233. ``extruder_id`` and ``nozzle_id`` mirror ``KProfile`` (bambu_mqtt.py),
  1234. which has carried both all along; single-nozzle printers report
  1235. extruder 0. Both are non-default fields there, so a live profile always
  1236. has them — the double must too, or it licenses code that would break on
  1237. the real object.
  1238. """
  1239. return SimpleNamespace(
  1240. slot_id=slot_id,
  1241. filament_id=filament_id,
  1242. name=name,
  1243. setting_id=setting_id,
  1244. extruder_id=extruder_id,
  1245. nozzle_id=nozzle_id,
  1246. )
  1247. def _client(self, live=None, sent="7", ack=(True, "")):
  1248. """A connected printer client.
  1249. ``set_kprofiles_batch`` returns the sequence_id it published under, not
  1250. a success flag (#2718), and the verdict arrives separately from
  1251. ``await_cali_ack`` as ``(ok, detail)``.
  1252. """
  1253. client = MagicMock()
  1254. client.state.connected = True
  1255. client.set_kprofiles_batch = MagicMock(return_value=sent)
  1256. client.await_cali_ack = AsyncMock(return_value=ack)
  1257. client.get_kprofiles = AsyncMock(return_value=list(live or []))
  1258. return client
  1259. def _payload(self, serial="00M09A123456789", nozzle="0.4"):
  1260. return {
  1261. f"kprofiles/{serial}/{nozzle}.json": {
  1262. "version": "1.0",
  1263. "printer_serial": serial,
  1264. "nozzle_diameter": nozzle,
  1265. "profiles": [
  1266. {
  1267. "slot_id": 0,
  1268. "name": "Bambu PLA",
  1269. "k_value": "0.020000",
  1270. "filament_id": "GFA00",
  1271. "nozzle_id": "HS00-0.4",
  1272. "extruder_id": 0,
  1273. "setting_id": "PFUS123",
  1274. }
  1275. ],
  1276. }
  1277. }
  1278. @pytest.mark.asyncio
  1279. async def test_sends_batch_to_connected_printer(self, db_session, printer_factory):
  1280. printer = await printer_factory(serial_number="00M09A123456789")
  1281. client = self._client()
  1282. tally = _CategoryTally()
  1283. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1284. manager.get_client = MagicMock(return_value=client)
  1285. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1286. client.set_kprofiles_batch.assert_called_once()
  1287. profiles, nozzle = client.set_kprofiles_batch.call_args.args
  1288. assert nozzle == "0.4"
  1289. assert profiles[0]["name"] == "Bambu PLA"
  1290. assert profiles[0]["filament_id"] == "GFA00"
  1291. assert tally.restored == 1
  1292. assert manager.get_client.call_args.args == (printer.id,)
  1293. @pytest.mark.asyncio
  1294. async def test_always_warns_to_verify_on_the_printer(self, db_session, printer_factory):
  1295. await printer_factory(serial_number="00M09A123456789")
  1296. client = self._client()
  1297. tally = _CategoryTally()
  1298. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1299. manager.get_client = MagicMock(return_value=client)
  1300. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1301. # A refusal is now read and counted failed, so the caveat is narrowed to
  1302. # what is genuinely left uncertain: a printer that never answers.
  1303. assert any("verify the profiles on the printer" in note for note in _messages(tally))
  1304. assert any("does not answer still counts as restored" in note for note in _messages(tally))
  1305. assert not any("without acknowledgement" in note for note in _messages(tally))
  1306. assert any("always overwrite" in note for note in _messages(tally))
  1307. # --- cali_idx is resolved live, never taken from the backup -------------
  1308. #
  1309. # Regression cover for the silent no-op found testing on an X1E: the backup
  1310. # stored cali_idx 8151, a Bambuddy edit re-keyed the profile to 4606, and
  1311. # the restore aimed extrusion_cali_set at 8151. The printer dropped it and
  1312. # the tally still said "1 restored".
  1313. @pytest.mark.asyncio
  1314. async def test_uses_the_live_cali_idx_not_the_backed_up_slot(self, db_session, printer_factory):
  1315. await printer_factory(serial_number="00M09A123456789")
  1316. payload = self._payload()
  1317. payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]["slot_id"] = 8151
  1318. client = self._client(live=[self._live(slot_id=4606)])
  1319. tally = _CategoryTally()
  1320. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1321. manager.get_client = MagicMock(return_value=client)
  1322. await _service()._restore_kprofiles(db_session, payload, tally)
  1323. client.get_kprofiles.assert_awaited_once_with(nozzle_diameter="0.4")
  1324. profiles, _ = client.set_kprofiles_batch.call_args.args
  1325. assert profiles[0]["cali_idx"] == 4606, "must address the slot that exists now"
  1326. assert profiles[0]["cali_idx"] != 8151, "must not reuse the backup's cali_idx"
  1327. assert tally.restored == 1
  1328. @pytest.mark.asyncio
  1329. async def test_matches_on_name_when_setting_id_was_regenerated(self, db_session, printer_factory):
  1330. # A delete-then-add edit mints a fresh setting_id, so the name carries
  1331. # the match instead.
  1332. await printer_factory(serial_number="00M09A123456789")
  1333. client = self._client(live=[self._live(slot_id=4606, setting_id="PF9999999999")])
  1334. tally = _CategoryTally()
  1335. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1336. manager.get_client = MagicMock(return_value=client)
  1337. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1338. profiles, _ = client.set_kprofiles_batch.call_args.args
  1339. assert profiles[0]["cali_idx"] == 4606
  1340. # The live setting_id wins: it is what the printer associates with the slot.
  1341. assert profiles[0]["setting_id"] == "PF9999999999"
  1342. @pytest.mark.asyncio
  1343. async def test_unmatched_profile_is_added_rather_than_aimed_at_a_dead_slot(self, db_session, printer_factory):
  1344. await printer_factory(serial_number="00M09A123456789")
  1345. client = self._client(live=[]) # printer has nothing for this nozzle
  1346. tally = _CategoryTally()
  1347. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1348. manager.get_client = MagicMock(return_value=client)
  1349. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1350. profiles, _ = client.set_kprofiles_batch.call_args.args
  1351. assert profiles[0]["cali_idx"] == -1, "-1 tells the printer to add a new profile"
  1352. assert profiles[0]["setting_id"] == "PFUS123", "falls back to the backed-up preset"
  1353. assert any("added as new profiles" in note for note in _messages(tally))
  1354. @pytest.mark.asyncio
  1355. async def test_different_filament_is_not_treated_as_a_match(self, db_session, printer_factory):
  1356. # Same slot, different filament — matching on slot alone would clobber
  1357. # an unrelated profile.
  1358. await printer_factory(serial_number="00M09A123456789")
  1359. client = self._client(live=[self._live(slot_id=4606, filament_id="GFB99", name="Bambu PLA")])
  1360. tally = _CategoryTally()
  1361. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1362. manager.get_client = MagicMock(return_value=client)
  1363. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1364. profiles, _ = client.set_kprofiles_batch.call_args.args
  1365. assert profiles[0]["cali_idx"] == -1
  1366. @pytest.mark.asyncio
  1367. async def test_unreadable_live_index_degrades_to_adding(self, db_session, printer_factory):
  1368. # A failed read must not abort the restore.
  1369. await printer_factory(serial_number="00M09A123456789")
  1370. client = self._client()
  1371. client.get_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt timeout"))
  1372. tally = _CategoryTally()
  1373. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1374. manager.get_client = MagicMock(return_value=client)
  1375. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1376. profiles, _ = client.set_kprofiles_batch.call_args.args
  1377. assert profiles[0]["cali_idx"] == -1
  1378. assert tally.restored == 1
  1379. @pytest.mark.asyncio
  1380. async def test_sole_profile_for_a_filament_matches_without_setting_id_or_name(self, db_session, printer_factory):
  1381. await printer_factory(serial_number="00M09A123456789")
  1382. payload = self._payload()
  1383. entry = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]
  1384. entry["setting_id"] = None
  1385. entry["name"] = ""
  1386. client = self._client(live=[self._live(slot_id=4606, setting_id="PFOTHER", name="Renamed")])
  1387. tally = _CategoryTally()
  1388. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1389. manager.get_client = MagicMock(return_value=client)
  1390. await _service()._restore_kprofiles(db_session, payload, tally)
  1391. profiles, _ = client.set_kprofiles_batch.call_args.args
  1392. assert profiles[0]["cali_idx"] == 4606
  1393. @pytest.mark.asyncio
  1394. async def test_ambiguous_filament_without_discriminator_is_added_not_guessed(self, db_session, printer_factory):
  1395. await printer_factory(serial_number="00M09A123456789")
  1396. payload = self._payload()
  1397. entry = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]
  1398. entry["setting_id"] = None
  1399. entry["name"] = ""
  1400. client = self._client(live=[self._live(slot_id=1, setting_id="A"), self._live(slot_id=2, setting_id="B")])
  1401. tally = _CategoryTally()
  1402. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1403. manager.get_client = MagicMock(return_value=client)
  1404. await _service()._restore_kprofiles(db_session, payload, tally)
  1405. profiles, _ = client.set_kprofiles_batch.call_args.args
  1406. assert profiles[0]["cali_idx"] == -1, "two candidates and nothing to tell them apart"
  1407. @pytest.mark.asyncio
  1408. async def test_two_entries_cannot_claim_the_same_live_slot(self, db_session, printer_factory):
  1409. """One live profile cannot stand in for two backed-up ones (#2656).
  1410. Both entries fell through to the single-candidate arm, both took
  1411. cali_idx 4606, both went into the batch — so the second overwrote the
  1412. first on the printer while the tally counted two restored. Reachable
  1413. whenever the user has deleted one of a pair since the backup, because
  1414. the delete-then-add re-key is what strips the setting_id match.
  1415. """
  1416. await printer_factory(serial_number="00M09A123456789")
  1417. payload = self._payload()
  1418. entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
  1419. entries[0].update(setting_id="PFGONE1", name="PLA Basic")
  1420. entries.append({**entries[0], "setting_id": "PFGONE2", "name": "PLA Matte"})
  1421. client = self._client(live=[self._live(slot_id=4606, setting_id="PFUS123", name="Bambu PLA")])
  1422. tally = _CategoryTally()
  1423. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1424. manager.get_client = MagicMock(return_value=client)
  1425. await _service()._restore_kprofiles(db_session, payload, tally)
  1426. profiles, _ = client.set_kprofiles_batch.call_args.args
  1427. assert [p["cali_idx"] for p in profiles] == [4606, -1], "the displaced entry has to be added, not aliased"
  1428. assert sum(1 for p in profiles if p["cali_idx"] == 4606) == 1
  1429. assert any("added as new profiles" in note for note in _messages(tally))
  1430. @pytest.mark.asyncio
  1431. async def test_the_displaced_entry_does_not_inherit_the_claimed_setting_id(self, db_session, printer_factory):
  1432. """An add-as-new keeps its own preset, or it lands on top of the match anyway.
  1433. cali_idx -1 is only safe if the rest of the payload doesn't point at the
  1434. profile the first entry just claimed — the generated-setting_id fallback
  1435. reads setting_id when cali_idx is -1.
  1436. """
  1437. await printer_factory(serial_number="00M09A123456789")
  1438. payload = self._payload()
  1439. entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
  1440. entries[0].update(setting_id="PFGONE1", name="PLA Basic")
  1441. entries.append({**entries[0], "setting_id": "PFGONE2", "name": "PLA Matte"})
  1442. client = self._client(live=[self._live(slot_id=4606, setting_id="PFUS123", name="Bambu PLA")])
  1443. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1444. manager.get_client = MagicMock(return_value=client)
  1445. await _service()._restore_kprofiles(db_session, payload, _CategoryTally())
  1446. profiles, _ = client.set_kprofiles_batch.call_args.args
  1447. assert profiles[0]["setting_id"] == "PFUS123", "the match prefers the live preset"
  1448. assert profiles[1]["setting_id"] == "PFGONE2", "the displaced entry keeps its own"
  1449. @pytest.mark.asyncio
  1450. async def test_two_entries_matching_two_live_profiles_keep_their_own_slots(self, db_session, printer_factory):
  1451. """Control: the guard must not displace a legitimate second match."""
  1452. await printer_factory(serial_number="00M09A123456789")
  1453. payload = self._payload()
  1454. entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
  1455. entries.append({**entries[0], "setting_id": "PFUS456", "name": "Bambu PETG"})
  1456. client = self._client(
  1457. live=[
  1458. self._live(slot_id=4606, setting_id="PFUS123", name="Bambu PLA"),
  1459. self._live(slot_id=4607, setting_id="PFUS456", name="Bambu PETG"),
  1460. ]
  1461. )
  1462. tally = _CategoryTally()
  1463. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1464. manager.get_client = MagicMock(return_value=client)
  1465. await _service()._restore_kprofiles(db_session, payload, tally)
  1466. profiles, _ = client.set_kprofiles_batch.call_args.args
  1467. assert [p["cali_idx"] for p in profiles] == [4606, 4607]
  1468. assert not any("added as new profiles" in note for note in _messages(tally))
  1469. @pytest.mark.asyncio
  1470. async def test_a_claimed_slot_does_not_make_an_ambiguous_pair_matchable(self, db_session, printer_factory):
  1471. """Two live profiles for one filament stay ambiguous after one is taken.
  1472. The single-candidate fallback is judged against every candidate, not the
  1473. unclaimed ones — otherwise claiming the first would leave exactly one
  1474. "available" and turn a guess the code deliberately refuses into a match.
  1475. """
  1476. await printer_factory(serial_number="00M09A123456789")
  1477. payload = self._payload()
  1478. entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
  1479. entries[0].update(setting_id="PFUS123", name="Bambu PLA")
  1480. entries.append({**entries[0], "setting_id": None, "name": ""})
  1481. client = self._client(
  1482. live=[
  1483. self._live(slot_id=1, setting_id="PFUS123", name="Bambu PLA"),
  1484. self._live(slot_id=2, setting_id="PFOTHER", name="Renamed"),
  1485. ]
  1486. )
  1487. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1488. manager.get_client = MagicMock(return_value=client)
  1489. await _service()._restore_kprofiles(db_session, payload, _CategoryTally())
  1490. profiles, _ = client.set_kprofiles_batch.call_args.args
  1491. assert [p["cali_idx"] for p in profiles] == [1, -1]
  1492. # --- the match is scoped to the extruder it was calibrated on -----------
  1493. #
  1494. # get_kprofiles reads per nozzle *diameter*, so on a dual-nozzle printer
  1495. # both extruders come back in one list. Scoping candidates on filament_id
  1496. # alone let one extruder's calibration be written over the other's.
  1497. @pytest.mark.asyncio
  1498. async def test_each_extruders_profile_lands_on_its_own_extruder(self, db_session, printer_factory):
  1499. """The same preset calibrated on both extruders of an H2D.
  1500. Both live profiles share a filament_id *and* a setting_id, so the
  1501. setting_id arm matched whichever the printer happened to list first —
  1502. and with an entry per extruder the two swapped slots, each overwriting
  1503. the other's calibration while the tally counted both restored.
  1504. """
  1505. await printer_factory(serial_number="00M09A123456789")
  1506. payload = self._payload()
  1507. entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
  1508. entries.append({**entries[0], "extruder_id": 1, "nozzle_id": "HS00-0.4-R"})
  1509. client = self._client(
  1510. live=[
  1511. # Right extruder first, which is what made the bug bite.
  1512. self._live(slot_id=1001, extruder_id=1),
  1513. self._live(slot_id=1000, extruder_id=0),
  1514. ]
  1515. )
  1516. tally = _CategoryTally()
  1517. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1518. manager.get_client = MagicMock(return_value=client)
  1519. await _service()._restore_kprofiles(db_session, payload, tally)
  1520. profiles, _ = client.set_kprofiles_batch.call_args.args
  1521. assert [(p["extruder_id"], p["cali_idx"]) for p in profiles] == [(0, 1000), (1, 1001)]
  1522. assert tally.restored == 2
  1523. assert not any("added as new profiles" in note for note in _messages(tally))
  1524. @pytest.mark.asyncio
  1525. async def test_the_other_extruders_profile_is_not_a_candidate(self, db_session, printer_factory):
  1526. """One backed-up entry, and the only live profile is the other extruder's.
  1527. Adding as new is the right answer: extruder 0's calibration is not a
  1528. stand-in for extruder 1's, however well the filament and preset line up.
  1529. """
  1530. await printer_factory(serial_number="00M09A123456789")
  1531. client = self._client(live=[self._live(slot_id=1001, extruder_id=1)])
  1532. tally = _CategoryTally()
  1533. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1534. manager.get_client = MagicMock(return_value=client)
  1535. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1536. profiles, _ = client.set_kprofiles_batch.call_args.args
  1537. assert profiles[0]["cali_idx"] == -1
  1538. assert any("added as new profiles" in note for note in _messages(tally))
  1539. @pytest.mark.asyncio
  1540. async def test_an_entry_without_an_extruder_id_still_matches(self, db_session, printer_factory):
  1541. """Control: a pre-#2656 backup carries no extruder_id.
  1542. A missing key must leave the match exactly as it was, not turn every
  1543. entry into an add.
  1544. """
  1545. await printer_factory(serial_number="00M09A123456789")
  1546. payload = self._payload()
  1547. payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0].pop("extruder_id")
  1548. client = self._client(live=[self._live(slot_id=4606)])
  1549. tally = _CategoryTally()
  1550. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1551. manager.get_client = MagicMock(return_value=client)
  1552. await _service()._restore_kprofiles(db_session, payload, tally)
  1553. profiles, _ = client.set_kprofiles_batch.call_args.args
  1554. assert profiles[0]["cali_idx"] == 4606
  1555. assert tally.restored == 1
  1556. @pytest.mark.asyncio
  1557. async def test_a_live_index_that_reports_no_extruder_still_matches(self, db_session, printer_factory):
  1558. """Control: the same, for a printer whose profiles carry no extruder_id."""
  1559. await printer_factory(serial_number="00M09A123456789")
  1560. live = SimpleNamespace(slot_id=4606, filament_id="GFA00", name="Bambu PLA", setting_id="PFUS123")
  1561. client = self._client(live=[live])
  1562. tally = _CategoryTally()
  1563. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1564. manager.get_client = MagicMock(return_value=client)
  1565. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1566. profiles, _ = client.set_kprofiles_batch.call_args.args
  1567. assert profiles[0]["cali_idx"] == 4606
  1568. assert tally.restored == 1
  1569. @pytest.mark.asyncio
  1570. async def test_a_backup_without_a_nozzle_id_omits_the_key(self, db_session, printer_factory):
  1571. """``set_kprofiles_batch`` defaults it, and only an absent key lets it.
  1572. The default is ``p.get("nozzle_id", f"HS00-{diameter}")``, which a key
  1573. present-and-None defeats — the batch would publish a null nozzle_id to
  1574. the printer. Printers that omit the field (#1748) are the reason the
  1575. default exists, so it has to be reachable.
  1576. """
  1577. await printer_factory(serial_number="00M09A123456789")
  1578. payload = self._payload()
  1579. payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0].pop("nozzle_id")
  1580. # No live match either, so neither source can supply one.
  1581. client = self._client(live=[])
  1582. tally = _CategoryTally()
  1583. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1584. manager.get_client = MagicMock(return_value=client)
  1585. await _service()._restore_kprofiles(db_session, payload, tally)
  1586. profiles, _ = client.set_kprofiles_batch.call_args.args
  1587. assert "nozzle_id" not in profiles[0]
  1588. @pytest.mark.asyncio
  1589. async def test_the_backups_nozzle_id_is_used_when_nothing_is_live(self, db_session, printer_factory):
  1590. await printer_factory(serial_number="00M09A123456789")
  1591. client = self._client(live=[])
  1592. tally = _CategoryTally()
  1593. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1594. manager.get_client = MagicMock(return_value=client)
  1595. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1596. profiles, _ = client.set_kprofiles_batch.call_args.args
  1597. assert profiles[0]["nozzle_id"] == "HS00-0.4"
  1598. @pytest.mark.asyncio
  1599. async def test_the_live_nozzle_id_beats_the_backups(self, db_session, printer_factory):
  1600. """The nozzle may have been swapped since the backup; we write to the
  1601. one that is fitted now, exactly as with setting_id."""
  1602. await printer_factory(serial_number="00M09A123456789")
  1603. client = self._client(live=[self._live(slot_id=4606, nozzle_id="SS00-0.4")])
  1604. tally = _CategoryTally()
  1605. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1606. manager.get_client = MagicMock(return_value=client)
  1607. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1608. profiles, _ = client.set_kprofiles_batch.call_args.args
  1609. assert profiles[0]["nozzle_id"] == "SS00-0.4"
  1610. @pytest.mark.asyncio
  1611. async def test_a_live_profile_without_a_nozzle_id_falls_back_to_the_backup(self, db_session, printer_factory):
  1612. """Same defensive read as extruder_id: not every live profile carries
  1613. every field."""
  1614. await printer_factory(serial_number="00M09A123456789")
  1615. live = SimpleNamespace(slot_id=4606, filament_id="GFA00", name="Bambu PLA", setting_id="PFUS123")
  1616. client = self._client(live=[live])
  1617. tally = _CategoryTally()
  1618. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1619. manager.get_client = MagicMock(return_value=client)
  1620. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1621. profiles, _ = client.set_kprofiles_batch.call_args.args
  1622. assert profiles[0]["nozzle_id"] == "HS00-0.4"
  1623. @pytest.mark.asyncio
  1624. async def test_unknown_serial_is_skipped_with_reason(self, db_session):
  1625. tally = _CategoryTally()
  1626. with patch("backend.app.services.github_restore.printer_manager"):
  1627. await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally)
  1628. assert tally.restored == 0
  1629. assert tally.skipped == 1
  1630. assert any("No printer with serial NOSUCH" in note for note in _messages(tally))
  1631. @pytest.mark.asyncio
  1632. async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
  1633. await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
  1634. client = MagicMock()
  1635. client.state.connected = False
  1636. tally = _CategoryTally()
  1637. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1638. manager.get_client = MagicMock(return_value=client)
  1639. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1640. assert tally.skipped == 1
  1641. assert tally.failed == 0
  1642. assert any("not connected" in note for note in _messages(tally))
  1643. @pytest.mark.asyncio
  1644. async def test_a_non_dict_profile_is_counted_failed_not_dropped(self, db_session, printer_factory):
  1645. """The online path was the one place an entry left the tally entirely.
  1646. ``_kprofile_profile_count`` counts it, so the offline and
  1647. printer-missing paths already count the same entry skipped and the
  1648. failure path counts it outstanding — only the connected path skipped it
  1649. silently, so restored + skipped + failed came up short of the number the
  1650. preview showed.
  1651. """
  1652. await printer_factory(serial_number="00M09A123456789")
  1653. payload = self._payload()
  1654. path = next(iter(payload))
  1655. payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
  1656. tally = _CategoryTally()
  1657. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1658. manager.get_client = MagicMock(return_value=self._client())
  1659. await _service()._restore_kprofiles(db_session, payload, tally)
  1660. assert tally.failed == 1
  1661. assert tally.restored + tally.skipped + tally.failed == 2
  1662. @pytest.mark.asyncio
  1663. async def test_the_offline_path_counts_the_same_entry(self, db_session, printer_factory):
  1664. """Control for the above: the two paths have to agree on the total."""
  1665. await printer_factory(serial_number="00M09A123456789")
  1666. payload = self._payload()
  1667. path = next(iter(payload))
  1668. payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
  1669. client = MagicMock()
  1670. client.state.connected = False
  1671. tally = _CategoryTally()
  1672. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1673. manager.get_client = MagicMock(return_value=client)
  1674. await _service()._restore_kprofiles(db_session, payload, tally)
  1675. assert tally.restored + tally.skipped + tally.failed == 2
  1676. @pytest.mark.asyncio
  1677. async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
  1678. await printer_factory(serial_number="00M09A123456789")
  1679. tally = _CategoryTally()
  1680. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1681. manager.get_client = MagicMock(return_value=None)
  1682. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1683. assert tally.skipped == 1
  1684. @pytest.mark.asyncio
  1685. async def test_publish_failure_counts_as_failed(self, db_session, printer_factory):
  1686. # None is what set_kprofiles_batch returns when it could not publish —
  1687. # a disconnected client. There is no ack to wait for in that case.
  1688. await printer_factory(serial_number="00M09A123456789")
  1689. client = self._client(sent=None)
  1690. tally = _CategoryTally()
  1691. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1692. manager.get_client = MagicMock(return_value=client)
  1693. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1694. assert tally.failed == 1
  1695. assert tally.restored == 0
  1696. assert "kprofilesSendFailed" in _codes(tally)
  1697. assert "kprofilesRefused" not in _codes(tally), "nothing was sent, so the printer refused nothing"
  1698. client.await_cali_ack.assert_not_awaited()
  1699. @pytest.mark.asyncio
  1700. async def test_publish_exception_is_contained(self, db_session, printer_factory):
  1701. await printer_factory(serial_number="00M09A123456789")
  1702. client = MagicMock()
  1703. client.state.connected = True
  1704. client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down"))
  1705. tally = _CategoryTally()
  1706. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1707. manager.get_client = MagicMock(return_value=client)
  1708. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1709. assert tally.failed == 1
  1710. # --- the printer's verdict decides the tally, not the publish ------------
  1711. #
  1712. # #2718 changed set_kprofiles_batch from returning a bool to returning the
  1713. # sequence_id it published under. A sequence_id string is truthy, so a
  1714. # restore that branches on the return value alone reports every refused
  1715. # write as saved — the defect that fix closed in every other caller.
  1716. @pytest.mark.asyncio
  1717. async def test_awaits_the_ack_for_the_sequence_id_it_was_given(self, db_session, printer_factory):
  1718. await printer_factory(serial_number="00M09A123456789")
  1719. client = self._client(sent="4211")
  1720. tally = _CategoryTally()
  1721. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1722. manager.get_client = MagicMock(return_value=client)
  1723. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1724. client.await_cali_ack.assert_awaited_once_with("4211")
  1725. assert tally.restored == 1
  1726. @pytest.mark.asyncio
  1727. async def test_a_refused_batch_counts_failed_not_restored(self, db_session, printer_factory):
  1728. await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
  1729. client = self._client(ack=(False, "invalid tray_id"))
  1730. tally = _CategoryTally()
  1731. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1732. manager.get_client = MagicMock(return_value=client)
  1733. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1734. assert tally.restored == 0
  1735. assert tally.failed == 1
  1736. assert "kprofilesRefused" in _codes(tally)
  1737. assert "kprofilesSendFailed" not in _codes(tally), "it was sent — the printer answered no"
  1738. note = next(n for n in tally.notes if n["code"] == "kprofilesRefused")
  1739. assert note["params"]["reason"] == "invalid tray_id", "the printer's own reason has to survive"
  1740. assert "Shelf Printer" in note["message"] and "invalid tray_id" in note["message"]
  1741. @pytest.mark.asyncio
  1742. async def test_a_silent_printer_still_counts_restored(self, db_session, printer_factory):
  1743. # maziggy's rule, and await_cali_ack's own contract: no answer is not
  1744. # evidence of refusal. Firmware that predates the ack never answers.
  1745. await printer_factory(serial_number="00M09A123456789")
  1746. client = self._client(ack=(True, "no acknowledgement from printer"))
  1747. tally = _CategoryTally()
  1748. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1749. manager.get_client = MagicMock(return_value=client)
  1750. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1751. assert tally.restored == 1
  1752. assert tally.failed == 0
  1753. assert "kprofilesRefused" not in _codes(tally)
  1754. @pytest.mark.asyncio
  1755. async def test_an_unreadable_ack_does_not_fail_the_batch(self, db_session, printer_factory):
  1756. # Same situation one layer up: the write most likely landed, so this
  1757. # degrades the way a timeout does rather than inventing a failure.
  1758. await printer_factory(serial_number="00M09A123456789")
  1759. client = self._client()
  1760. client.await_cali_ack = AsyncMock(side_effect=RuntimeError("mqtt down"))
  1761. tally = _CategoryTally()
  1762. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1763. manager.get_client = MagicMock(return_value=client)
  1764. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  1765. assert tally.restored == 1
  1766. assert tally.failed == 0
  1767. @pytest.mark.asyncio
  1768. async def test_one_refused_nozzle_does_not_condemn_the_other(self, db_session, printer_factory):
  1769. await printer_factory(serial_number="00M09A123456789")
  1770. payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
  1771. client = self._client()
  1772. client.await_cali_ack = AsyncMock(side_effect=[(False, "busy"), (True, "")])
  1773. tally = _CategoryTally()
  1774. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1775. manager.get_client = MagicMock(return_value=client)
  1776. await _service()._restore_kprofiles(db_session, payload, tally)
  1777. assert tally.restored == 1
  1778. assert tally.failed == 1
  1779. @pytest.mark.asyncio
  1780. async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory):
  1781. await printer_factory(serial_number="00M09A123456789")
  1782. payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
  1783. client = self._client()
  1784. tally = _CategoryTally()
  1785. with patch("backend.app.services.github_restore.printer_manager") as manager:
  1786. manager.get_client = MagicMock(return_value=client)
  1787. await _service()._restore_kprofiles(db_session, payload, tally)
  1788. assert client.set_kprofiles_batch.call_count == 2
  1789. assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"}
  1790. assert tally.restored == 2
  1791. @pytest.mark.asyncio
  1792. async def test_empty_payload_is_noted(self, db_session):
  1793. tally = _CategoryTally()
  1794. await _service()._restore_kprofiles(db_session, {}, tally)
  1795. assert _codes(tally) == ["noData"]
  1796. class TestSoftDeletedArchiveRoundTrip:
  1797. """The two halves of the soft-delete fix only work together.
  1798. The collector keeps soft-deleted rows on purpose (their stats still count),
  1799. so if it doesn't write ``deleted_at`` there is nothing for the restore to
  1800. carry across and a deleted archive comes back visible. Covered end to end
  1801. because each half looks harmless on its own.
  1802. """
  1803. @pytest.mark.asyncio
  1804. async def test_deleted_at_survives_collect_then_restore(self, db_session):
  1805. from backend.app.services.github_backup import github_backup_service
  1806. deleted_at = datetime(2026, 3, 5, 9, 0, 0)
  1807. db_session.add(
  1808. PrintArchive(
  1809. filename="trashed.3mf",
  1810. file_path="",
  1811. file_size=1024,
  1812. content_hash="hash-trashed",
  1813. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1814. deleted_at=deleted_at,
  1815. )
  1816. )
  1817. await db_session.commit()
  1818. files: dict = {}
  1819. await github_backup_service._collect_archives(db_session, files)
  1820. payload = files[ARCHIVES_PATH]
  1821. assert payload["archives"][0]["deleted_at"] == str(deleted_at)
  1822. # Restore that payload into an instance where the row is gone entirely.
  1823. await db_session.execute(PrintArchive.__table__.delete())
  1824. await db_session.commit()
  1825. tally = _CategoryTally()
  1826. await _service()._restore_archives(db_session, payload, False, tally, {})
  1827. await db_session.commit()
  1828. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1829. assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
  1830. class TestRestoredArchiveOwnership:
  1831. """A restored archive without an owner is invisible to the person who owns it.
  1832. ``created_by_id`` is not attribution, it is the column the access check runs
  1833. on: ``_ensure_archive_visible`` fails closed on NULL (404 for any caller
  1834. without ``archives:read_all``) and the list paths filter
  1835. ``created_by_id == user.id``. So on a multi-user instance the tally reported
  1836. archives restored while their owner could neither list nor open them.
  1837. """
  1838. def _entry(self, **overrides):
  1839. entry = {
  1840. "id": 77,
  1841. "filename": "benchy.3mf",
  1842. "file_size": 2048,
  1843. "content_hash": "abc123",
  1844. "started_at": "2026-03-01 10:00:00",
  1845. "created_at": "2026-03-01 10:00:00",
  1846. }
  1847. entry.update(overrides)
  1848. return entry
  1849. async def _user(self, db, username="alice"):
  1850. user = User(username=username, role="operator")
  1851. db.add(user)
  1852. await db.flush()
  1853. return user
  1854. @pytest.mark.asyncio
  1855. async def test_owner_is_carried_across(self, db_session):
  1856. user = await self._user(db_session)
  1857. tally = _CategoryTally()
  1858. await _service()._restore_archives(
  1859. db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
  1860. )
  1861. await db_session.commit()
  1862. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1863. assert row.created_by_id == user.id
  1864. assert not any("owner cleared" in note for note in _messages(tally))
  1865. @pytest.mark.asyncio
  1866. async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session):
  1867. """The archive is still worth having; an admin can reassign it."""
  1868. tally = _CategoryTally()
  1869. await _service()._restore_archives(
  1870. db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
  1871. )
  1872. await db_session.commit()
  1873. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1874. assert row.created_by_id is None
  1875. assert tally.restored == 1 and tally.failed == 0
  1876. assert any("owner cleared" in note and "archives:read_all" in note for note in _messages(tally))
  1877. @pytest.mark.asyncio
  1878. async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session):
  1879. tally = _CategoryTally()
  1880. archives = [
  1881. self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_id=4242),
  1882. self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_id=4243),
  1883. ]
  1884. await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
  1885. await db_session.commit()
  1886. assert sum(1 for note in _messages(tally) if "owner cleared" in note) == 1
  1887. @pytest.mark.asyncio
  1888. async def test_a_backup_without_the_key_still_restores_and_says_so(self, db_session):
  1889. """Backups taken before the collector recorded it just can't know the owner.
  1890. The archive is worth restoring anyway, but it lands ownerless — which is
  1891. a 404 for everyone without ``archives:read_all``. Reporting N restored
  1892. while the user who asked for them sees none is the failure mode the note
  1893. exists to prevent.
  1894. """
  1895. tally = _CategoryTally()
  1896. await _service()._restore_archives(db_session, {"archives": [self._entry()]}, False, tally, {})
  1897. await db_session.commit()
  1898. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1899. assert row.created_by_id is None
  1900. assert tally.restored == 1
  1901. assert not any("owner cleared" in note for note in _messages(tally))
  1902. assert any("without an owner" in note and "archives:read_all" in note for note in _messages(tally))
  1903. @pytest.mark.asyncio
  1904. async def test_an_explicitly_ownerless_archive_is_reported_too(self, db_session):
  1905. """Same consequence, so the same note: the source row had no owner either."""
  1906. tally = _CategoryTally()
  1907. await _service()._restore_archives(
  1908. db_session, {"archives": [self._entry(created_by_id=None)]}, False, tally, {}
  1909. )
  1910. await db_session.commit()
  1911. assert any("without an owner" in note for note in _messages(tally))
  1912. @pytest.mark.asyncio
  1913. async def test_a_stale_owner_is_not_reported_twice(self, db_session):
  1914. """One row, one cause, one note — the cleared-owner branch already spoke."""
  1915. tally = _CategoryTally()
  1916. await _service()._restore_archives(
  1917. db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
  1918. )
  1919. await db_session.commit()
  1920. assert any("owner cleared" in note for note in _messages(tally))
  1921. assert not any("without an owner" in note for note in _messages(tally))
  1922. @pytest.mark.asyncio
  1923. async def test_a_known_owner_is_not_reported(self, db_session):
  1924. user = await self._user(db_session)
  1925. tally = _CategoryTally()
  1926. await _service()._restore_archives(
  1927. db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
  1928. )
  1929. await db_session.commit()
  1930. assert not any("without an owner" in note for note in _messages(tally))
  1931. @pytest.mark.asyncio
  1932. async def test_the_unknown_owner_note_is_not_emitted_on_overwrite(self, db_session):
  1933. """Overwrite keeps the local owner, so there is nothing to warn about."""
  1934. bob = await self._user(db_session, "bob")
  1935. db_session.add(
  1936. PrintArchive(
  1937. filename="benchy.3mf",
  1938. file_path="/data/benchy.3mf",
  1939. file_size=2048,
  1940. content_hash="abc123",
  1941. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1942. created_by_id=bob.id,
  1943. )
  1944. )
  1945. await db_session.commit()
  1946. tally = _CategoryTally()
  1947. await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
  1948. await db_session.commit()
  1949. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1950. assert row.created_by_id == bob.id
  1951. assert not any("without an owner" in note for note in _messages(tally))
  1952. @pytest.mark.asyncio
  1953. async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):
  1954. alice = await self._user(db_session, "alice")
  1955. bob = await self._user(db_session, "bob")
  1956. db_session.add(
  1957. PrintArchive(
  1958. filename="benchy.3mf",
  1959. file_path="/data/benchy.3mf",
  1960. file_size=2048,
  1961. content_hash="abc123",
  1962. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1963. created_by_id=bob.id,
  1964. )
  1965. )
  1966. await db_session.commit()
  1967. await _service()._restore_archives(
  1968. db_session, {"archives": [self._entry(created_by_id=alice.id)]}, True, _CategoryTally(), {}
  1969. )
  1970. await db_session.commit()
  1971. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1972. assert row.created_by_id == alice.id
  1973. @pytest.mark.asyncio
  1974. async def test_overwrite_leaves_the_owner_alone_when_the_backup_predates_the_key(self, db_session):
  1975. """A pre-#2656 commit must not blank the owner of a row that was fine.
  1976. The entry carries no ``created_by_id`` at all, so there is nothing to
  1977. write. Treating that as an explicit null inflicted the exact bug the
  1978. column was added to fix — a 404 for the owner — on rows the restore had
  1979. no business touching, silently, while still counting them restored.
  1980. """
  1981. bob = await self._user(db_session, "bob")
  1982. db_session.add(
  1983. PrintArchive(
  1984. filename="benchy.3mf",
  1985. file_path="/data/benchy.3mf",
  1986. file_size=2048,
  1987. content_hash="abc123",
  1988. started_at=datetime(2026, 3, 1, 10, 0, 0),
  1989. created_by_id=bob.id,
  1990. )
  1991. )
  1992. await db_session.commit()
  1993. tally = _CategoryTally()
  1994. await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
  1995. await db_session.commit()
  1996. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  1997. assert row.created_by_id == bob.id, "an old backup does not know the owner, so it must not clear one"
  1998. assert tally.restored == 1
  1999. @pytest.mark.asyncio
  2000. async def test_overwrite_leaves_deleted_at_alone_when_the_backup_predates_the_key(self, db_session):
  2001. """The mirror case: an old commit must not un-delete, and must not claim to.
  2002. ``archivesUndeleted`` reads the same absent value, so the un-delete was
  2003. not merely wrong but unannounced.
  2004. """
  2005. db_session.add(
  2006. PrintArchive(
  2007. filename="benchy.3mf",
  2008. file_path="/data/benchy.3mf",
  2009. file_size=2048,
  2010. content_hash="abc123",
  2011. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2012. deleted_at=datetime(2026, 3, 4, 8, 0, 0),
  2013. )
  2014. )
  2015. await db_session.commit()
  2016. tally = _CategoryTally()
  2017. await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
  2018. await db_session.commit()
  2019. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2020. assert row.deleted_at == datetime(2026, 3, 4, 8, 0, 0), "an old backup must not resurrect a deleted archive"
  2021. assert not any("visible again" in note for note in _messages(tally))
  2022. @pytest.mark.asyncio
  2023. async def test_overwrite_still_clears_an_owner_the_backup_explicitly_nulls(self, db_session):
  2024. """Control: absent is ignored, but an explicit null is still honoured.
  2025. A current-format backup of an unowned archive has to be able to say so,
  2026. or overwrite stops meaning "make the local row match the backup".
  2027. """
  2028. bob = await self._user(db_session, "bob")
  2029. db_session.add(
  2030. PrintArchive(
  2031. filename="benchy.3mf",
  2032. file_path="/data/benchy.3mf",
  2033. file_size=2048,
  2034. content_hash="abc123",
  2035. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2036. created_by_id=bob.id,
  2037. )
  2038. )
  2039. await db_session.commit()
  2040. await _service()._restore_archives(
  2041. db_session, {"archives": [self._entry(created_by_id=None)]}, True, _CategoryTally(), {}
  2042. )
  2043. await db_session.commit()
  2044. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2045. assert row.created_by_id is None
  2046. @pytest.mark.asyncio
  2047. async def test_overwrite_still_undeletes_when_the_backup_explicitly_nulls(self, db_session):
  2048. """Control for the deleted_at half, with the note that goes with it."""
  2049. db_session.add(
  2050. PrintArchive(
  2051. filename="benchy.3mf",
  2052. file_path="/data/benchy.3mf",
  2053. file_size=2048,
  2054. content_hash="abc123",
  2055. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2056. deleted_at=datetime(2026, 3, 4, 8, 0, 0),
  2057. )
  2058. )
  2059. await db_session.commit()
  2060. tally = _CategoryTally()
  2061. await _service()._restore_archives(db_session, {"archives": [self._entry(deleted_at=None)]}, True, tally, {})
  2062. await db_session.commit()
  2063. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2064. assert row.deleted_at is None
  2065. assert any("visible again" in note for note in _messages(tally))
  2066. @pytest.mark.asyncio
  2067. async def test_owner_survives_collect_then_restore(self, db_session):
  2068. """Both halves, because each looks harmless alone.
  2069. The collector never wrote the key, so there was nothing for the restore
  2070. to carry across even once it wanted to.
  2071. """
  2072. from backend.app.services.github_backup import github_backup_service
  2073. user = await self._user(db_session)
  2074. db_session.add(
  2075. PrintArchive(
  2076. filename="owned.3mf",
  2077. file_path="",
  2078. file_size=1024,
  2079. content_hash="hash-owned",
  2080. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2081. created_by_id=user.id,
  2082. )
  2083. )
  2084. await db_session.commit()
  2085. files: dict = {}
  2086. await github_backup_service._collect_archives(db_session, files)
  2087. payload = files[ARCHIVES_PATH]
  2088. assert payload["archives"][0]["created_by_id"] == user.id
  2089. await db_session.execute(PrintArchive.__table__.delete())
  2090. await db_session.commit()
  2091. await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
  2092. await db_session.commit()
  2093. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2094. assert row.created_by_id == user.id, "a restored archive its owner cannot see is not restored"
  2095. class TestArchiveOwnerNaturalKey:
  2096. """``created_by_username`` decides the owner; the id is only the fallback.
  2097. Restoring onto a rebuilt instance is this feature's main use case, and the
  2098. users table renumbers there. A raw ``created_by_id`` cannot tell a correct
  2099. match from a live id that now belongs to somebody else, so the id path hands
  2100. one person's print history to another under ``ARCHIVES_READ_OWN`` — silently,
  2101. because ``archivesOwnerCleared`` only fires for an id that is *absent*.
  2102. ``username`` is unique on ``users``, so resolving on it turns that silent
  2103. misattribution into an ownerless row with a note.
  2104. """
  2105. def _entry(self, **overrides):
  2106. entry = {
  2107. "id": 77,
  2108. "filename": "benchy.3mf",
  2109. "file_size": 2048,
  2110. "content_hash": "abc123",
  2111. "started_at": "2026-03-01 10:00:00",
  2112. "created_at": "2026-03-01 10:00:00",
  2113. }
  2114. entry.update(overrides)
  2115. return entry
  2116. async def _user(self, db, username):
  2117. user = User(username=username, role="operator")
  2118. db.add(user)
  2119. await db.flush()
  2120. return user
  2121. @pytest.mark.asyncio
  2122. async def test_the_name_resolves_across_a_renumbered_users_table(self, db_session):
  2123. """The whole point: same person, different id, restore still finds them."""
  2124. alice = await self._user(db_session, "alice")
  2125. tally = _CategoryTally()
  2126. await _service()._restore_archives(
  2127. db_session,
  2128. {"archives": [self._entry(created_by_id=alice.id + 500, created_by_username="alice")]},
  2129. False,
  2130. tally,
  2131. {},
  2132. )
  2133. await db_session.commit()
  2134. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2135. assert row.created_by_id == alice.id
  2136. assert not any("owner cleared" in note for note in _messages(tally))
  2137. @pytest.mark.asyncio
  2138. async def test_the_name_beats_a_live_id_belonging_to_someone_else(self, db_session):
  2139. """The misattribution case, and the one the id path cannot even detect.
  2140. Both ids exist locally, so the id path would write bob's — a valid row,
  2141. no note, alice's print history readable by bob.
  2142. """
  2143. alice = await self._user(db_session, "alice")
  2144. bob = await self._user(db_session, "bob")
  2145. await _service()._restore_archives(
  2146. db_session,
  2147. {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]},
  2148. False,
  2149. _CategoryTally(),
  2150. {},
  2151. )
  2152. await db_session.commit()
  2153. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2154. assert row.created_by_id == alice.id, "the name is the natural key; the id is from another instance"
  2155. @pytest.mark.asyncio
  2156. async def test_a_renamed_owner_lands_ownerless_with_a_note(self, db_session):
  2157. """No local match, so nothing to resolve — and the id is not a fallback here.
  2158. Falling back to it is exactly the guess the name exists to prevent, so
  2159. the row is cleared and said out loud instead.
  2160. """
  2161. bob = await self._user(db_session, "bob")
  2162. tally = _CategoryTally()
  2163. await _service()._restore_archives(
  2164. db_session,
  2165. {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]},
  2166. False,
  2167. tally,
  2168. {},
  2169. )
  2170. await db_session.commit()
  2171. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2172. assert row.created_by_id is None
  2173. assert tally.restored == 1 and tally.failed == 0
  2174. assert any("does not have" in note and "archives:read_all" in note for note in _messages(tally))
  2175. @pytest.mark.asyncio
  2176. async def test_the_unmatched_note_is_emitted_once_for_many_rows(self, db_session):
  2177. tally = _CategoryTally()
  2178. archives = [
  2179. self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_username="alice"),
  2180. self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_username="carol"),
  2181. ]
  2182. await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
  2183. await db_session.commit()
  2184. assert sum(1 for note in _messages(tally) if "does not have" in note) == 1
  2185. @pytest.mark.asyncio
  2186. async def test_an_unmatched_name_does_not_also_claim_no_owner_was_recorded(self, db_session):
  2187. """One row, one cause, one note — as with the stale-id branch."""
  2188. tally = _CategoryTally()
  2189. await _service()._restore_archives(
  2190. db_session, {"archives": [self._entry(created_by_username="alice")]}, False, tally, {}
  2191. )
  2192. await db_session.commit()
  2193. assert any("does not have" in note for note in _messages(tally))
  2194. assert not any("without an owner" in note for note in _messages(tally))
  2195. @pytest.mark.asyncio
  2196. async def test_a_pre_username_backup_still_resolves_on_the_id(self, db_session):
  2197. """The fallback has to keep working — every backup taken before this change."""
  2198. alice = await self._user(db_session, "alice")
  2199. await _service()._restore_archives(
  2200. db_session, {"archives": [self._entry(created_by_id=alice.id)]}, False, _CategoryTally(), {}
  2201. )
  2202. await db_session.commit()
  2203. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2204. assert row.created_by_id == alice.id
  2205. @pytest.mark.asyncio
  2206. async def test_an_explicitly_ownerless_archive_reads_as_no_owner_not_as_unmatched(self, db_session):
  2207. """A current-format backup of an unowned archive writes both keys null."""
  2208. tally = _CategoryTally()
  2209. await _service()._restore_archives(
  2210. db_session,
  2211. {"archives": [self._entry(created_by_id=None, created_by_username=None)]},
  2212. False,
  2213. tally,
  2214. {},
  2215. )
  2216. await db_session.commit()
  2217. assert any("without an owner" in note for note in _messages(tally))
  2218. assert not any("does not have" in note for note in _messages(tally))
  2219. @pytest.mark.asyncio
  2220. async def test_overwrite_leaves_the_owner_alone_when_neither_key_is_present(self, db_session):
  2221. """The absent-is-not-null rule still holds now that there are two keys."""
  2222. bob = await self._user(db_session, "bob")
  2223. db_session.add(
  2224. PrintArchive(
  2225. filename="benchy.3mf",
  2226. file_path="/data/benchy.3mf",
  2227. file_size=2048,
  2228. content_hash="abc123",
  2229. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2230. created_by_id=bob.id,
  2231. )
  2232. )
  2233. await db_session.commit()
  2234. await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, _CategoryTally(), {})
  2235. await db_session.commit()
  2236. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2237. assert row.created_by_id == bob.id
  2238. @pytest.mark.asyncio
  2239. async def test_the_name_survives_collect_then_restore(self, db_session):
  2240. """Both halves, because the collector writing nothing looks harmless alone."""
  2241. from backend.app.services.github_backup import github_backup_service
  2242. alice = await self._user(db_session, "alice")
  2243. db_session.add(
  2244. PrintArchive(
  2245. filename="owned.3mf",
  2246. file_path="",
  2247. file_size=1024,
  2248. content_hash="hash-owned",
  2249. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2250. created_by_id=alice.id,
  2251. )
  2252. )
  2253. await db_session.commit()
  2254. files: dict = {}
  2255. await github_backup_service._collect_archives(db_session, files)
  2256. payload = files[ARCHIVES_PATH]
  2257. assert payload["archives"][0]["created_by_username"] == "alice"
  2258. # Rebuilt instance: same person, and nothing else holds their old id.
  2259. await db_session.execute(PrintArchive.__table__.delete())
  2260. await db_session.execute(User.__table__.delete())
  2261. await db_session.commit()
  2262. rebuilt = await self._user(db_session, "alice")
  2263. await db_session.commit()
  2264. await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
  2265. await db_session.commit()
  2266. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  2267. assert row.created_by_id == rebuilt.id
  2268. @pytest.mark.asyncio
  2269. async def test_the_collector_names_no_owner_for_an_unowned_archive(self, db_session):
  2270. """Null rather than absent, so a restore can tell "none" from "not recorded"."""
  2271. from backend.app.services.github_backup import github_backup_service
  2272. db_session.add(
  2273. PrintArchive(
  2274. filename="unowned.3mf",
  2275. file_path="",
  2276. file_size=1024,
  2277. content_hash="hash-unowned",
  2278. started_at=datetime(2026, 3, 1, 10, 0, 0),
  2279. )
  2280. )
  2281. await db_session.commit()
  2282. files: dict = {}
  2283. await github_backup_service._collect_archives(db_session, files)
  2284. entry = files[ARCHIVES_PATH]["archives"][0]
  2285. assert entry["created_by_username"] is None
  2286. assert "created_by_username" in entry
  2287. class TestCategoryPathMapping:
  2288. def setup_method(self):
  2289. self.service = _service()
  2290. self.available = [
  2291. "backup_metadata.json",
  2292. SETTINGS_PATH,
  2293. SPOOLS_PATH,
  2294. SPOOL_USAGE_PATH,
  2295. ARCHIVES_PATH,
  2296. "kprofiles/SERIAL1/0.4.json",
  2297. "kprofiles/SERIAL1/0.8.json",
  2298. "cloud_profiles/filament.json",
  2299. ]
  2300. def test_spools_includes_usage_history(self):
  2301. paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available)
  2302. assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH]
  2303. def test_kprofiles_globs_all_serials_and_nozzles(self):
  2304. paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available)
  2305. assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"]
  2306. def test_absent_paths_are_omitted(self):
  2307. paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"])
  2308. assert paths == []
  2309. def test_cloud_profiles_are_not_a_restore_category(self):
  2310. assert "cloud_profiles" not in {c.value for c in RestoreCategory}
  2311. class TestMutex:
  2312. @pytest.mark.asyncio
  2313. async def test_restore_refuses_while_a_backup_is_running(self):
  2314. service = _service()
  2315. with patch("backend.app.services.github_backup.github_backup_service") as backup:
  2316. backup.is_running = True
  2317. result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
  2318. assert result["success"] is False
  2319. assert "backup is currently running" in result["message"]
  2320. @pytest.mark.asyncio
  2321. async def test_restore_refuses_while_another_restore_is_running(self):
  2322. service = _service()
  2323. service._running_restore = True
  2324. result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
  2325. assert result["success"] is False
  2326. assert "restore is already running" in result["message"]
  2327. @pytest.mark.asyncio
  2328. async def test_backup_refuses_while_a_restore_is_running(self):
  2329. from backend.app.services.github_backup import GitHubBackupService
  2330. backup_service = GitHubBackupService()
  2331. with patch("backend.app.services.github_restore.github_restore_service") as restore:
  2332. restore.is_running = True
  2333. result = await backup_service.run_backup(1, trigger="manual")
  2334. assert result["success"] is False
  2335. assert "restore is currently running" in result["message"]
  2336. class TestMqttRelayReconfigure:
  2337. """Restoring mqtt_* rows has to reach the live relay, not just the table."""
  2338. @pytest.mark.asyncio
  2339. async def test_reconfigures_from_the_committed_rows(self, db_session):
  2340. db_session.add(Settings(key="mqtt_enabled", value="true"))
  2341. db_session.add(Settings(key="mqtt_broker", value="restored.local"))
  2342. db_session.add(Settings(key="mqtt_port", value="8883"))
  2343. db_session.add(Settings(key="mqtt_use_tls", value="true"))
  2344. # Never restorable (credential blocklist), so it comes from the row that
  2345. # was already there.
  2346. db_session.add(Settings(key="mqtt_password", value="kept"))
  2347. await db_session.commit()
  2348. tally = _CategoryTally()
  2349. relay = MagicMock()
  2350. relay.configure = AsyncMock(return_value=True)
  2351. with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  2352. await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_broker"}, tally)
  2353. relay.configure.assert_awaited_once()
  2354. sent = relay.configure.await_args.args[0]
  2355. assert sent["mqtt_enabled"] is True
  2356. assert sent["mqtt_broker"] == "restored.local"
  2357. assert sent["mqtt_port"] == 8883
  2358. assert sent["mqtt_use_tls"] is True
  2359. assert sent["mqtt_password"] == "kept"
  2360. assert sent["mqtt_topic_prefix"] == "bambuddy"
  2361. assert tally.notes == []
  2362. @pytest.mark.asyncio
  2363. async def test_no_reconnect_when_no_mqtt_key_was_written(self, db_session):
  2364. """configure() tears the connection down, so don't call it for a theme change."""
  2365. tally = _CategoryTally()
  2366. relay = MagicMock()
  2367. relay.configure = AsyncMock()
  2368. with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  2369. await _service()._reconfigure_mqtt_relay(db_session, {"currency", "theme"}, tally)
  2370. relay.configure.assert_not_awaited()
  2371. @pytest.mark.asyncio
  2372. async def test_broker_failure_is_noted_not_fatal(self, db_session):
  2373. tally = _CategoryTally()
  2374. relay = MagicMock()
  2375. relay.configure = AsyncMock(side_effect=OSError("no route to broker"))
  2376. with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  2377. await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_enabled"}, tally)
  2378. assert any("restart Bambuddy" in note for note in _messages(tally))
  2379. @pytest.mark.asyncio
  2380. async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):
  2381. db_session.add(Settings(key="mqtt_broker", value="old.local"))
  2382. await db_session.commit()
  2383. written: set[str] = set()
  2384. payload = {
  2385. "settings": {
  2386. "mqtt_broker": "new.local",
  2387. "currency": "EUR",
  2388. "mqtt_password": "leaked",
  2389. "auth_enabled": "false",
  2390. }
  2391. }
  2392. await _service()._restore_settings(
  2393. db_session, payload, overwrite=True, tally=_CategoryTally(), keys_written=written
  2394. )
  2395. # Skipped keys are not "written", or a blocked mqtt_password would
  2396. # trigger a pointless reconnect.
  2397. assert written == {"mqtt_broker", "currency"}
  2398. @pytest.mark.asyncio
  2399. async def test_keys_skipped_for_overwrite_off_are_not_reported(self, db_session):
  2400. db_session.add(Settings(key="mqtt_broker", value="old.local"))
  2401. await db_session.commit()
  2402. written: set[str] = set()
  2403. await _service()._restore_settings(
  2404. db_session,
  2405. {"settings": {"mqtt_broker": "new.local"}},
  2406. overwrite=False,
  2407. tally=_CategoryTally(),
  2408. keys_written=written,
  2409. )
  2410. assert written == set()
  2411. @pytest.mark.asyncio
  2412. async def test_a_refused_mqtt_enabled_is_not_reported_as_written(self, db_session):
  2413. """So the relay reconfigures from the *local* mqtt_enabled, not the backup's.
  2414. The companion rule refuses ``mqtt_enabled`` when the backup's password
  2415. cannot come across and there is none stored locally. It must not then
  2416. appear in ``keys_written``, or _reconfigure_mqtt_relay would be asked to
  2417. bring up a broker connection the restore deliberately declined to enable.
  2418. """
  2419. written: set[str] = set()
  2420. await _service()._restore_settings(
  2421. db_session,
  2422. {"settings": {"mqtt_enabled": "true", "mqtt_password": "refused", "mqtt_broker": "new.local"}},
  2423. overwrite=True,
  2424. tally=_CategoryTally(),
  2425. keys_written=written,
  2426. )
  2427. assert written == {"mqtt_broker"}
  2428. class TestApplyOrdering:
  2429. """_apply must not hold SQLite's single writer any longer than one category.
  2430. Two ways to overrun the 15 s busy_timeout, and the same fix closes both: the
  2431. K-profile phase awaits an unresponsive printer (3 x 5 s per printer/nozzle),
  2432. and a database category is one SELECT per row or per key against a few
  2433. thousand archives plus a full usage history. Every concurrent writer in the
  2434. app fails with "database is locked" while either runs.
  2435. """
  2436. def _recording_service(self, calls: list[str]):
  2437. service = _service()
  2438. # Sync side effects on purpose: an AsyncMock returns a coroutine its
  2439. # side_effect hands back rather than awaiting it, so an async recorder
  2440. # would never run.
  2441. service._restore_archives = AsyncMock(side_effect=lambda *a, **k: calls.append("archives"))
  2442. service._restore_spools = AsyncMock(side_effect=lambda *a, **k: calls.append("spools"))
  2443. service._restore_settings = AsyncMock(side_effect=lambda *a, **k: calls.append("settings"))
  2444. service._restore_kprofiles = AsyncMock(side_effect=lambda *a, **k: calls.append("kprofiles"))
  2445. return service
  2446. @pytest.mark.asyncio
  2447. async def test_every_database_category_commits_before_the_next_one_starts(self):
  2448. calls: list[str] = []
  2449. service = self._recording_service(calls)
  2450. db = MagicMock()
  2451. db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
  2452. await service._apply(
  2453. db,
  2454. {},
  2455. [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS],
  2456. False,
  2457. )
  2458. assert calls == ["archives", "commit", "spools", "commit", "settings", "commit"]
  2459. @pytest.mark.asyncio
  2460. async def test_the_printer_phase_runs_with_no_write_transaction_open(self):
  2461. """The K-profile phase is last, and everything before it is already committed."""
  2462. calls: list[str] = []
  2463. service = self._recording_service(calls)
  2464. db = MagicMock()
  2465. db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
  2466. await service._apply(
  2467. db,
  2468. {},
  2469. [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS, RestoreCategory.KPROFILES],
  2470. False,
  2471. )
  2472. assert calls == ["archives", "commit", "spools", "commit", "kprofiles"]
  2473. @pytest.mark.asyncio
  2474. async def test_a_tally_is_recorded_only_after_its_category_commits(self):
  2475. """What run_restore's failure path relies on to report honestly.
  2476. A tally present in ``results`` has to mean "these rows are on disk". If
  2477. the commit raises, the category must not appear — otherwise a failed
  2478. restore reports rows that rolled back.
  2479. """
  2480. service = self._recording_service([])
  2481. db = MagicMock()
  2482. db.commit = AsyncMock(side_effect=RuntimeError("database is locked"))
  2483. results: dict = {}
  2484. with pytest.raises(RuntimeError):
  2485. await service._apply(db, {}, [RestoreCategory.ARCHIVES], False, results=results)
  2486. assert results == {}
  2487. @pytest.mark.asyncio
  2488. async def test_the_callers_results_dict_is_populated_in_place(self):
  2489. """So a raise mid-run still leaves the committed categories visible."""
  2490. service = self._recording_service([])
  2491. db = MagicMock()
  2492. db.commit = AsyncMock()
  2493. service._restore_spools = AsyncMock(side_effect=RuntimeError("boom"))
  2494. results: dict = {}
  2495. with pytest.raises(RuntimeError):
  2496. await service._apply(db, {}, [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS], False, results=results)
  2497. assert set(results) == {"archives"}, "archives committed before spools ran; the caller must see it"
  2498. class TestKprofilePhaseFailure:
  2499. """The K-profile phase runs after _apply has committed everything else.
  2500. So an exception there used to reach run_restore's handler, which reports
  2501. ``success: False`` with an empty ``results`` — over archive, spool and
  2502. settings rows that are durable on disk. The honest-reporting theme of this
  2503. feature inverted on exactly the path where it matters, and the post-commit
  2504. MQTT reconfigure (downstream of the raise, inside the same try) was skipped,
  2505. leaving the relay pointed at the pre-restore broker.
  2506. """
  2507. _SETTINGS = {"version": "1.0", "settings": {"mqtt_broker": "restored.local", "currency": "EUR"}}
  2508. def _payload(self, profiles=None):
  2509. return {
  2510. SETTINGS_PATH: dict(self._SETTINGS),
  2511. "kprofiles/00M09A123456789/0.4.json": {
  2512. "profiles": [{"filament_id": "GFA00", "name": "Bambu PLA"}] if profiles is None else profiles
  2513. },
  2514. }
  2515. def _session_patch(self, db_session):
  2516. cm = AsyncMock()
  2517. cm.__aenter__ = AsyncMock(return_value=db_session)
  2518. cm.__aexit__ = AsyncMock(return_value=None)
  2519. return patch("backend.app.services.github_restore.async_session", return_value=cm)
  2520. async def _configured_service(self, db_session, payload):
  2521. from backend.app.models.github_backup import GitHubBackupConfig
  2522. config = GitHubBackupConfig(repository_url="https://github.com/o/r", access_token="tok", provider="github")
  2523. db_session.add(config)
  2524. await db_session.commit()
  2525. service = _service()
  2526. service._resolve_ref = AsyncMock(return_value=("a" * 40, "", None))
  2527. service._read_categories = AsyncMock(return_value=(payload, ""))
  2528. return service, config.id
  2529. @pytest.mark.asyncio
  2530. async def test_the_committed_categories_are_still_reported(self, db_session):
  2531. service = _service()
  2532. service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
  2533. results = await service._apply(
  2534. db_session,
  2535. self._payload(),
  2536. [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES],
  2537. False,
  2538. )
  2539. assert results[RestoreCategory.SETTINGS.value].restored == 2
  2540. rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
  2541. assert rows == {"mqtt_broker": "restored.local", "currency": "EUR"}, "committed before the phase that failed"
  2542. @pytest.mark.asyncio
  2543. async def test_the_failure_is_counted_and_explained(self, db_session):
  2544. service = _service()
  2545. service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
  2546. results = await service._apply(
  2547. db_session,
  2548. self._payload(profiles=[{"filament_id": "GFA00"}, {"filament_id": "GFB99"}]),
  2549. [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES],
  2550. False,
  2551. )
  2552. tally = results[RestoreCategory.KPROFILES.value]
  2553. assert tally.failed == 2, "every profile the payload carried is unaccounted for"
  2554. assert tally.restored == 0
  2555. assert _codes(tally) == ["kprofilesStepFailed"]
  2556. assert tally.notes[0]["params"]["reason"] == "mqtt exploded"
  2557. @pytest.mark.asyncio
  2558. async def test_the_relay_is_reconfigured_even_though_the_phase_failed(self, db_session):
  2559. """The reconfigure sits downstream of the raise in run_restore's try."""
  2560. service, config_id = await self._configured_service(db_session, self._payload())
  2561. service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
  2562. relay = MagicMock()
  2563. relay.configure = AsyncMock(return_value=True)
  2564. with self._session_patch(db_session), patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  2565. result = await service.run_restore(
  2566. config_id, "a" * 40, [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES]
  2567. )
  2568. assert result["success"] is True
  2569. assert result["results"][RestoreCategory.SETTINGS.value]["restored"] == 2
  2570. assert result["results"][RestoreCategory.KPROFILES.value]["failed"] == 1
  2571. relay.configure.assert_awaited_once()
  2572. assert relay.configure.await_args.args[0]["mqtt_broker"] == "restored.local"
  2573. @pytest.mark.asyncio
  2574. async def test_a_failure_before_the_commit_still_reports_nothing_restored(self, db_session):
  2575. """Control: rolling back and saying so is right when nothing landed."""
  2576. service, config_id = await self._configured_service(db_session, self._payload())
  2577. service._restore_settings = AsyncMock(side_effect=RuntimeError("read failed"))
  2578. relay = MagicMock()
  2579. relay.configure = AsyncMock(return_value=True)
  2580. with self._session_patch(db_session), patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
  2581. result = await service.run_restore(
  2582. config_id, "a" * 40, [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES]
  2583. )
  2584. assert result["success"] is False
  2585. assert result["results"] == {}
  2586. assert (await db_session.execute(select(Settings))).scalars().first() is None
  2587. relay.configure.assert_not_awaited()
  2588. @pytest.mark.asyncio
  2589. async def test_a_later_category_failing_still_reports_the_earlier_one(self, db_session):
  2590. """The database phase commits per category, so this is now reachable there too.
  2591. Archives land and are committed; settings then raises. Reporting an empty
  2592. result would be the same false "nothing was restored" the K-profile split
  2593. already had to fix, over rows that are durable on disk.
  2594. """
  2595. service, config_id = await self._configured_service(
  2596. db_session,
  2597. {
  2598. ARCHIVES_PATH: {
  2599. "version": "1.0",
  2600. "archives": [
  2601. {
  2602. "id": 1,
  2603. "filename": "benchy.3mf",
  2604. "content_hash": "hash-later",
  2605. "started_at": "2026-03-01 10:00:00",
  2606. }
  2607. ],
  2608. },
  2609. SETTINGS_PATH: dict(self._SETTINGS),
  2610. },
  2611. )
  2612. service._restore_settings = AsyncMock(side_effect=RuntimeError("read failed"))
  2613. with self._session_patch(db_session):
  2614. result = await service.run_restore(
  2615. config_id, "a" * 40, [RestoreCategory.ARCHIVES, RestoreCategory.SETTINGS]
  2616. )
  2617. assert result["success"] is False
  2618. assert result["results"][RestoreCategory.ARCHIVES.value]["restored"] == 1
  2619. assert RestoreCategory.SETTINGS.value not in result["results"], "settings rolled back; do not claim it"
  2620. assert (await db_session.execute(select(PrintArchive))).scalars().first() is not None
  2621. @pytest.mark.asyncio
  2622. async def test_a_malformed_profiles_value_is_a_skipped_category_not_a_raise(self, db_session, printer_factory):
  2623. """Belt-and-braces: the pre-loop count ran ahead of the per-call guards.
  2624. ``sum(len(c.get("profiles") or []) ...)`` raises TypeError on a
  2625. hand-edited or truncated backup whose ``profiles`` is not a list — and it
  2626. raises after the database categories are already on disk.
  2627. """
  2628. await printer_factory(serial_number="00M09A123456789")
  2629. client = MagicMock()
  2630. client.state.connected = True
  2631. client.set_kprofiles_batch = MagicMock(return_value="7")
  2632. client.get_kprofiles = AsyncMock(return_value=[])
  2633. tally = _CategoryTally()
  2634. with patch("backend.app.services.github_restore.printer_manager") as manager:
  2635. manager.get_client = MagicMock(return_value=client)
  2636. await _service()._restore_kprofiles(db_session, self._payload(profiles=5), tally)
  2637. client.set_kprofiles_batch.assert_not_called()
  2638. assert (tally.restored, tally.failed) == (0, 0)
  2639. assert "kprofilesStepFailed" not in _codes(tally)
  2640. class TestResolveRef:
  2641. @pytest.mark.asyncio
  2642. async def test_concrete_sha_passes_through_without_an_api_call(self):
  2643. service = _service()
  2644. service.list_commits = AsyncMock()
  2645. config = MagicMock(branch="main")
  2646. resolved, error, commit = await service._resolve_ref(config, "abc1234")
  2647. assert resolved == "abc1234"
  2648. assert error == ""
  2649. # Nothing was fetched, so there is no entry to describe it with.
  2650. assert commit is None
  2651. service.list_commits.assert_not_awaited()
  2652. @pytest.mark.asyncio
  2653. async def test_head_resolves_to_the_tip_sha(self):
  2654. service = _service()
  2655. service.list_commits = AsyncMock(
  2656. return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]}
  2657. )
  2658. config = MagicMock(branch="main")
  2659. resolved, error, commit = await service._resolve_ref(config, "HEAD")
  2660. assert resolved == "tipsha1"
  2661. assert error == ""
  2662. # Handed back so preview does not list commits a second time just to
  2663. # describe the one it already fetched.
  2664. assert commit == {"sha": "tipsha1"}
  2665. @pytest.mark.asyncio
  2666. async def test_empty_history_is_an_error(self):
  2667. service = _service()
  2668. service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
  2669. config = MagicMock(branch="main")
  2670. resolved, error, commit = await service._resolve_ref(config, "HEAD")
  2671. assert resolved is None
  2672. assert "no commits" in error
  2673. assert commit is None
  2674. class TestDescribeCommit:
  2675. """A preview that says `commit: null` gives the user no idea what they picked."""
  2676. def _config(self):
  2677. return MagicMock(branch="main", provider="github", repository_url="https://github.com/o/r", access_token="t")
  2678. def _entry(self, sha: str):
  2679. return {"sha": sha, "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-01T10:00:00Z"}
  2680. @pytest.mark.asyncio
  2681. async def test_an_abbreviated_ref_matches_a_full_sha_in_the_window(self):
  2682. """REF_PATTERN accepts 7 characters; providers return 40.
  2683. The old exact `==` therefore never matched an abbreviated ref, even when
  2684. the commit was right there in the top 20.
  2685. """
  2686. service = _service()
  2687. full = "abc1234" + "0" * 33
  2688. service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry(full)]})
  2689. found = await service._describe_commit(self._config(), "abc1234")
  2690. assert found is not None
  2691. assert found["sha"] == full
  2692. @pytest.mark.asyncio
  2693. async def test_a_full_sha_matches_an_abbreviated_entry(self):
  2694. service = _service()
  2695. service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry("abc1234")]})
  2696. found = await service._describe_commit(self._config(), "abc1234" + "0" * 33)
  2697. assert found is not None
  2698. @pytest.mark.asyncio
  2699. async def test_a_commit_outside_the_window_is_fetched_directly(self):
  2700. service = _service()
  2701. service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry("f" * 40)]})
  2702. backend = MagicMock()
  2703. backend.get_commit = AsyncMock(return_value={"success": True, "commit": self._entry("old" + "0" * 37)})
  2704. with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
  2705. found = await service._describe_commit(self._config(), "old" + "0" * 37)
  2706. assert found["sha"] == "old" + "0" * 37
  2707. backend.get_commit.assert_awaited_once()
  2708. @pytest.mark.asyncio
  2709. async def test_a_direct_lookup_failure_is_not_fatal(self):
  2710. """It is a subject line: render the preview without it."""
  2711. service = _service()
  2712. service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
  2713. backend = MagicMock()
  2714. backend.get_commit = AsyncMock(return_value={"success": False, "message": "boom", "commit": None})
  2715. with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
  2716. assert await service._describe_commit(self._config(), "a" * 40) is None
  2717. @pytest.mark.asyncio
  2718. async def test_the_window_scan_is_not_run_twice(self):
  2719. """_resolve_ref already listed commits for HEAD; preview reuses that."""
  2720. service = _service()
  2721. tip = self._entry("t" * 40)
  2722. service.list_commits = AsyncMock(return_value={"success": True, "commits": [tip]})
  2723. resolved, _, commit = await service._resolve_ref(self._config(), "HEAD")
  2724. assert resolved == "t" * 40
  2725. assert commit == tip
  2726. assert service.list_commits.await_count == 1