test_github_restore_validation.py 12 KB

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