test_github_restore_validation.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. """A settings restore writes only what the settings editors would accept.
  2. Each value is checked as the editor that owns it checks it. A key no editor
  3. writes (runtime state, a login's account state, a migration flag, anything
  4. unknown) is not restored, and neither is a user's email code sign-in flag.
  5. """
  6. import pytest
  7. from sqlalchemy import select
  8. from backend.app.api.routes.settings import _build_settings_response
  9. from backend.app.models.settings import Settings
  10. from backend.app.schemas.github_backup import RestoreCategory
  11. from backend.app.services.github_restore import _ADDRESS_KEYS, SETTINGS_PATH, GitHubRestoreService, _CategoryTally
  12. async def _restore(db_session, values: dict, local: dict | None = None):
  13. if local:
  14. db_session.add_all(Settings(key=key, value=value) for key, value in local.items())
  15. await db_session.commit()
  16. tally, written = _CategoryTally(), set()
  17. await GitHubRestoreService()._restore_settings(
  18. db_session, {"settings": values}, overwrite=True, tally=tally, keys_written=written
  19. )
  20. await db_session.commit()
  21. rows = (await db_session.execute(select(Settings))).scalars().all()
  22. return tally, written, {row.key: row.value for row in rows}
  23. def _note(tally: _CategoryTally, code: str) -> dict | None:
  24. return next((note for note in tally.notes if note["code"] == code), None)
  25. @pytest.mark.asyncio
  26. @pytest.mark.parametrize(
  27. ("key", "refused", "accepted"),
  28. [
  29. ("ha_url", "http://169.254.169.254/latest", "http://ha.local:8123"),
  30. ("bambuddy_internal_url", "ftp://bambuddy.local", "http://bambuddy.local:8000"),
  31. ("docker_compose_dir", "/opt/bambuddy; echo x", "/opt/bambuddy"),
  32. ("gcode_snippets", "not json", '{"X1C": {"start": ""}}'),
  33. ("obico_action", "explode", "notify"),
  34. ("energy_price_source", "mars", "fixed"),
  35. ("low_stock_threshold", "lots", "20"),
  36. ("manyfold_url", "http://169.254.169.254", "http://manyfold.local"),
  37. ("spoolman_url", "http://169.254.169.254:7912", "http://spoolman.local:7912"),
  38. ("smtp_port", "five", "587"),
  39. ],
  40. )
  41. async def test_a_value_the_editor_refuses_is_not_restored(db_session, key, refused, accepted):
  42. tally, written, stored = await _restore(db_session, {key: refused}, local={key: accepted})
  43. assert stored[key] == accepted
  44. assert written == set()
  45. note = _note(tally, "settingsInvalidSkipped")
  46. assert note is not None and note["params"]["keys"] == key
  47. @pytest.mark.asyncio
  48. @pytest.mark.parametrize(
  49. ("key", "value"),
  50. [
  51. ("ha_url", "http://ha.local:8123"),
  52. ("docker_compose_dir", "/opt/bambuddy"),
  53. ("auto_archive", "true"),
  54. ("ams_temp_alarm", "None"),
  55. ("energy_cost_per_kwh", "0.31"),
  56. ("manyfold_url", "http://manyfold.local"),
  57. ("manyfold_client_id", "client"),
  58. ("smtp_host", "mail.local"),
  59. ("smtp_port", "587"),
  60. ("smtp_security", "ssl"),
  61. ("smtp_auth_enabled", "false"),
  62. ("smtp_from_email", "a@mail.local"),
  63. ("smtp_from_name", "Bambuddy"),
  64. ("smtp_username", "mailer"),
  65. ("smtp_use_tls", "true"),
  66. ],
  67. )
  68. async def test_a_value_the_editor_accepts_is_restored(db_session, key, value):
  69. # An address is restored only as this instance already has it.
  70. local = {key: value} if key in _ADDRESS_KEYS else None
  71. tally, written, stored = await _restore(db_session, {key: value}, local=local)
  72. assert stored[key] == value
  73. assert written == {key}
  74. assert _note(tally, "settingsInvalidSkipped") is None
  75. assert _note(tally, "settingsUnknownSkipped") is None
  76. @pytest.mark.asyncio
  77. @pytest.mark.parametrize("interval", ["5", "20", "30"])
  78. async def test_octoeverywhere_interval_restores_from_backup_text(db_session, interval):
  79. key = "octoeverywhere_poll_interval"
  80. tally, written, stored = await _restore(db_session, {key: interval}, local={key: "10"})
  81. assert stored[key] == interval
  82. assert written == {key}
  83. assert _note(tally, "settingsInvalidSkipped") is None
  84. response = await _build_settings_response(db_session)
  85. assert response.octoeverywhere_poll_interval == int(interval)
  86. @pytest.mark.asyncio
  87. @pytest.mark.parametrize("interval", ["4", "31", "5.5", "invalid"])
  88. async def test_invalid_octoeverywhere_interval_is_not_restored(db_session, interval):
  89. key = "octoeverywhere_poll_interval"
  90. tally, written, stored = await _restore(db_session, {key: interval}, local={key: "20"})
  91. assert stored[key] == "20"
  92. assert written == set()
  93. assert _note(tally, "settingsInvalidSkipped") is not None
  94. @pytest.mark.asyncio
  95. @pytest.mark.parametrize(
  96. "key",
  97. [
  98. "something_from_elsewhere",
  99. "debug_logging_enabled",
  100. "ams_drying_alarm_latch",
  101. "announcements_feed_serial",
  102. "stock_alerts_notified",
  103. "archive_auto_purge_last_run",
  104. "archive_auto_purge_enabled",
  105. "archive_auto_purge_days",
  106. "archive_auto_purge_stats",
  107. "library_trash_retention_days",
  108. "library_auto_purge_enabled",
  109. "library_auto_purge_days",
  110. "library_auto_purge_include_never_printed",
  111. "bambu_cloud_email",
  112. "orca_cloud_email",
  113. "orca_cloud_pending_at",
  114. "virtual_printer_model",
  115. "ha_env_managed",
  116. ],
  117. )
  118. async def test_a_key_no_editor_writes_is_not_restored(db_session, key):
  119. tally, written, stored = await _restore(db_session, {key: "true", "currency": "EUR"})
  120. assert key not in stored
  121. assert written == {"currency"}
  122. note = _note(tally, "settingsUnknownSkipped")
  123. assert note is not None and note["params"]["keys"] == key
  124. @pytest.mark.asyncio
  125. async def test_a_users_email_code_sign_in_is_never_switched_by_a_restore(db_session):
  126. tally, written, stored = await _restore(
  127. db_session, {"user_1_email_2fa_enabled": "false"}, local={"user_1_email_2fa_enabled": "true"}
  128. )
  129. assert stored["user_1_email_2fa_enabled"] == "true"
  130. assert written == set()
  131. assert _note(tally, "settingsTwoFactorSkipped") is not None
  132. assert _note(tally, "settingsUnknownSkipped") is None
  133. @pytest.mark.asyncio
  134. async def test_the_preview_counts_only_what_will_be_written(db_session):
  135. parsed = {
  136. SETTINGS_PATH: {
  137. "settings": {
  138. "currency": "EUR",
  139. "ha_url": "http://169.254.169.254/",
  140. "something_from_elsewhere": "true",
  141. "user_1_email_2fa_enabled": "false",
  142. }
  143. }
  144. }
  145. count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  146. assert count == 1
  147. assert detail is not None and detail.code == "settingsNotRestorableWillSkip"
  148. assert detail.params == {"count": 3}
  149. @pytest.mark.asyncio
  150. async def test_bambuddys_own_one_shot_flags_are_skipped_without_a_word(db_session):
  151. """Every backup carries them; naming them on every restore would be noise."""
  152. tally, written, stored = await _restore(
  153. db_session, {"_backfill_1425_pipeline_permissions_done": "true", "currency": "EUR"}
  154. )
  155. parsed = {SETTINGS_PATH: {"settings": {"_backfill_1425_pipeline_permissions_done": "true", "currency": "EUR"}}}
  156. count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  157. assert "_backfill_1425_pipeline_permissions_done" not in stored
  158. assert written == {"currency"}
  159. assert tally.notes == []
  160. assert (count, detail) == (1, None)
  161. @pytest.mark.asyncio
  162. @pytest.mark.parametrize("key", ["mqtt_port", "use_slicer_api", "energy_price_source", "auto_archive", "ftp_timeout"])
  163. async def test_a_saved_null_is_refused_where_the_settings_cannot_read_one(db_session, key):
  164. tally, written, stored = await _restore(db_session, {key: "None"})
  165. assert key not in stored
  166. assert _note(tally, "settingsInvalidSkipped") is not None
  167. @pytest.mark.asyncio
  168. @pytest.mark.parametrize("key", ["ams_temp_alarm", "default_printer_id", "open_in_slicer"])
  169. async def test_a_saved_null_is_restored_where_the_settings_read_one(db_session, key):
  170. _tally, written, stored = await _restore(db_session, {key: "None"})
  171. assert stored[key] == "None"
  172. assert written == {key}
  173. @pytest.mark.asyncio
  174. @pytest.mark.parametrize(
  175. ("key", "value", "stored_as"),
  176. [
  177. ("smtp_port", "587.0", "587"),
  178. ("smtp_port", " 587", "587"),
  179. ("smtp_auth_enabled", "1", "true"),
  180. ("mqtt_enabled", "yes", "true"),
  181. ("mqtt_port", "1883.0", "1883"),
  182. ("auto_archive", "True", "true"),
  183. ("smtp_use_tls", "0", "false"),
  184. ],
  185. )
  186. async def test_a_value_is_written_in_the_form_its_editor_stores(db_session, key, value, stored_as):
  187. local = {key: stored_as} if key in _ADDRESS_KEYS else None
  188. _tally, written, stored = await _restore(db_session, {key: value}, local=local)
  189. assert stored[key] == stored_as
  190. assert written == {key}
  191. @pytest.mark.asyncio
  192. @pytest.mark.parametrize("local", [{"external_url": "https://bambuddy.here.example"}, None])
  193. async def test_the_address_sign_in_links_point_to_is_never_changed_by_a_restore(db_session, local):
  194. """Password reset and welcome links are built from it: a backup naming
  195. another address would send their tokens there."""
  196. tally, written, stored = await _restore(
  197. db_session, {"external_url": "https://elsewhere.example", "currency": "EUR"}, local=local
  198. )
  199. parsed = {SETTINGS_PATH: {"settings": {"external_url": "https://elsewhere.example", "currency": "EUR"}}}
  200. count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
  201. assert stored.get("external_url") == (local or {}).get("external_url")
  202. assert written == {"currency"}
  203. note = _note(tally, "settingsAddressKept")
  204. assert note is not None and note["params"]["keys"] == "external_url"
  205. assert (count, detail.code if detail else None) == (1, "settingsNotRestorableWillSkip")
  206. @pytest.mark.asyncio
  207. async def test_the_same_sign_in_link_address_restores_quietly(db_session):
  208. tally, _written, stored = await _restore(
  209. db_session,
  210. {"external_url": "https://bambuddy.here.example"},
  211. local={"external_url": "https://bambuddy.here.example"},
  212. )
  213. assert stored["external_url"] == "https://bambuddy.here.example"
  214. assert _note(tally, "settingsAddressKept") is None
  215. @pytest.mark.asyncio
  216. @pytest.mark.parametrize(
  217. ("key", "here", "elsewhere"),
  218. [
  219. ("external_url", "https://bambuddy.here.example", "https://elsewhere.example"),
  220. ("smtp_host", "mail.here.example", "mail.elsewhere.example"),
  221. ("smtp_port", "587", "25"),
  222. ("smtp_security", "starttls", "none"),
  223. ("smtp_use_tls", "true", "false"),
  224. ("local_backup_path", "/data/backups", "/srv/share"),
  225. ("mqtt_broker", "broker.here.example", "broker.elsewhere.example"),
  226. ("mqtt_port", "1883", "1884"),
  227. ("mqtt_use_tls", "false", "true"),
  228. ("ha_url", "http://ha.here.example:8123", "http://ha.elsewhere.example:8123"),
  229. ("obico_ml_url", "http://obico.here.example:3333", "http://obico.elsewhere.example:3333"),
  230. ("bambuddy_internal_url", "http://bambuddy.here.example:8000", "http://bambuddy.elsewhere.example:8000"),
  231. ("orcaslicer_api_url", "http://orca.here.example:3000", "http://orca.elsewhere.example:3000"),
  232. ("bambu_studio_api_url", "http://studio.here.example:3000", "http://studio.elsewhere.example:3000"),
  233. ("spoolman_url", "http://spoolman.here.example:7912", "http://spoolman.elsewhere.example:7912"),
  234. ("manyfold_url", "http://manyfold.here.example", "http://manyfold.elsewhere.example"),
  235. ],
  236. )
  237. @pytest.mark.parametrize("has_row", [True, False])
  238. async def test_a_backup_never_changes_where_bambuddy_sends_things(db_session, key, here, elsewhere, has_row):
  239. """Mail, backups, printer status, snapshots, model files: an address from a
  240. backup is kept as this instance has it, set or not."""
  241. tally, written, stored = await _restore(db_session, {key: elsewhere}, local={key: here} if has_row else None)
  242. assert stored.get(key) == (here if has_row else None)
  243. assert written == set()
  244. note = _note(tally, "settingsAddressKept")
  245. assert note is not None and note["params"]["keys"] == key
  246. def test_every_address_key_is_one_a_restore_would_otherwise_write():
  247. """An address key nothing checks would be dead weight in the list."""
  248. from backend.app.services.github_restore import _checked_setting
  249. assert set(_ADDRESS_KEYS) == {key for key in _ADDRESS_KEYS if _checked_setting(key, "")[0] is not None}
  250. def test_the_backup_and_the_restore_agree_on_which_keys_are_settings():
  251. """The backup keeps exactly the keys a restore writes; the two must not drift."""
  252. from backend.app.schemas.settings import AppSettingsUpdate
  253. from backend.app.services.github_restore import _EXTRA_RESTORABLE_KEYS, _checked_setting, is_restorable_setting_key
  254. keys = {*AppSettingsUpdate.model_fields, *_EXTRA_RESTORABLE_KEYS, "manyfold_url", "manyfold_client_id"}
  255. keys |= {"vapid_private_key", "dark_theme", "_backfill_x_done", "user_1_email_2fa_enabled", "auth_enabled"}
  256. for key in keys:
  257. assert is_restorable_setting_key(key) is (_checked_setting(key, "")[0] is not None), key