test_restore_schema_compat.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. """Restoring a backup made by a different version of Bambuddy.
  2. A backup carries the schema of the install that made it, and the Postgres
  3. restore path does NOT use that schema: it drops every table, recreates them
  4. from the running process's ORM, and inserts the backup's columns into the
  5. result. So any NOT NULL column the running version has and the backup does not
  6. arrives with nothing to put in it.
  7. That is how a 2026-09-23 backup failed to restore on the published image:
  8. ``user_wallets.currency`` was dropped from the model in #3123, the backup
  9. therefore had no such column, the older image still declared it NOT NULL, and
  10. the import died on::
  11. null value in column "currency" of relation "user_wallets"
  12. Its ``default="EUR"`` could not help -- SQLAlchemy applies a Python-side default
  13. to ORM and Core inserts, never to the raw ``text()`` SQL this import builds, and
  14. ``create_all`` emits no DDL default for one.
  15. Two things have to hold, and the second is the serious one:
  16. 1. A column with a default is filled rather than refused.
  17. 2. A column that cannot be filled is refused BEFORE the restore drops anything.
  18. The drop is the first thing the import does, in its own transaction, so a
  19. refusal at the INSERT means the install's data is already gone -- and the
  20. restore has by then also overwritten the MFA key file, leaving whatever
  21. survives encrypted under a key that no longer matches.
  22. """
  23. from __future__ import annotations
  24. import sqlite3
  25. from pathlib import Path
  26. from unittest.mock import AsyncMock, MagicMock, patch
  27. import pytest
  28. from sqlalchemy import Column, DateTime, Integer, MetaData, Numeric, String, Table, func
  29. from backend.app.api.routes.settings import (
  30. BackupSchemaIncompatible,
  31. _missing_required_columns,
  32. _read_backup_manifest,
  33. check_backup_schema_compatible,
  34. )
  35. def _wallets_table(metadata: MetaData) -> Table:
  36. """`user_wallets` as the published image still declares it."""
  37. return Table(
  38. "user_wallets",
  39. metadata,
  40. Column("id", Integer, primary_key=True),
  41. Column("user_id", Integer, nullable=False),
  42. Column("balance", Numeric(14, 2), nullable=False, default=0.0),
  43. Column("currency", String(3), nullable=False, default="EUR"),
  44. Column("updated_at", DateTime, nullable=False, server_default=func.now()),
  45. )
  46. # ---------------------------------------------------------------------------
  47. # Which missing columns are a problem
  48. # ---------------------------------------------------------------------------
  49. def test_a_column_with_a_model_default_is_filled_not_refused():
  50. """The exact case from the incident, with the values it would have used."""
  51. table = _wallets_table(MetaData())
  52. injectable, db_filled, unfillable = _missing_required_columns(table, {"id", "user_id", "balance", "updated_at"})
  53. assert injectable == {"currency": "EUR"}
  54. assert unfillable == []
  55. assert db_filled == []
  56. def test_a_column_with_a_server_default_is_left_to_the_database():
  57. """Omitting it from the INSERT is right: Postgres fills it. Sending the
  58. ORM's idea of the default instead would overwrite a timestamp the database
  59. is better placed to produce."""
  60. table = _wallets_table(MetaData())
  61. injectable, db_filled, unfillable = _missing_required_columns(table, {"id", "user_id", "balance", "currency"})
  62. assert db_filled == ["updated_at"]
  63. assert injectable == {}
  64. assert unfillable == []
  65. def test_a_callable_default_is_evaluated():
  66. table = Table(
  67. "cost_centers",
  68. MetaData(),
  69. Column("id", Integer, primary_key=True),
  70. Column("code", String(32), nullable=False, default=lambda: "generated"),
  71. )
  72. injectable, _, unfillable = _missing_required_columns(table, {"id"})
  73. assert injectable == {"code": "generated"}
  74. assert unfillable == []
  75. def test_a_required_column_with_no_default_is_unfillable():
  76. table = Table(
  77. "cost_centers",
  78. MetaData(),
  79. Column("id", Integer, primary_key=True),
  80. Column("name", String(150), nullable=False),
  81. )
  82. injectable, _, unfillable = _missing_required_columns(table, {"id"})
  83. assert unfillable == ["name"]
  84. assert injectable == {}
  85. def test_a_nullable_column_the_backup_lacks_is_not_a_problem():
  86. """Most schema drift is this, and it has always worked: the column is
  87. simply omitted and the row gets NULL."""
  88. table = Table(
  89. "printers",
  90. MetaData(),
  91. Column("id", Integer, primary_key=True),
  92. Column("nickname", String(50), nullable=True),
  93. )
  94. injectable, db_filled, unfillable = _missing_required_columns(table, {"id"})
  95. assert (injectable, db_filled, unfillable) == ({}, [], [])
  96. # ---------------------------------------------------------------------------
  97. # The preflight, against a real backup file
  98. # ---------------------------------------------------------------------------
  99. def _source(tmp_path: Path, ddl: str, rows: list[str]) -> Path:
  100. path = tmp_path / "bambuddy.db"
  101. conn = sqlite3.connect(path)
  102. conn.execute(ddl)
  103. for row in rows:
  104. conn.execute(row)
  105. conn.commit()
  106. conn.close()
  107. return path
  108. def test_a_backup_this_version_can_import_is_accepted(tmp_path):
  109. """`users` as the ORM has it, minus columns that are nullable or defaulted."""
  110. path = _source(
  111. tmp_path,
  112. "CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)",
  113. ["INSERT INTO users (id, username) VALUES (1, 'alice')"],
  114. )
  115. check_backup_schema_compatible(path) # does not raise
  116. def test_a_backup_missing_a_required_column_is_refused(tmp_path):
  117. """`cost_centers.name` is NOT NULL with no default anywhere."""
  118. path = _source(
  119. tmp_path,
  120. "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)",
  121. ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"],
  122. )
  123. with pytest.raises(BackupSchemaIncompatible) as exc:
  124. check_backup_schema_compatible(path)
  125. assert "cost_centers.name" in str(exc.value)
  126. assert "Nothing has been changed" in str(exc.value)
  127. def test_the_refusal_names_the_version_that_made_the_backup(tmp_path):
  128. """A column name tells an operator nothing about what to do. Two version
  129. numbers tell them which install to restore on."""
  130. path = _source(
  131. tmp_path,
  132. "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)",
  133. ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"],
  134. )
  135. with pytest.raises(BackupSchemaIncompatible) as exc:
  136. check_backup_schema_compatible(path, backup_version="1.2.7")
  137. assert "1.2.7" in str(exc.value)
  138. def test_an_empty_table_is_not_a_reason_to_refuse(tmp_path):
  139. """No rows, no INSERT, no violation. Refusing here would block a restore
  140. over a feature the backup's install never used."""
  141. path = _source(tmp_path, "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)", [])
  142. check_backup_schema_compatible(path) # does not raise
  143. def test_a_table_the_orm_does_not_know_is_ignored(tmp_path):
  144. """Backups carry tables from features this version has removed. The import
  145. skips them (it only imports source ∩ ORM), so the check must not judge them
  146. either -- a column requirement that no longer exists cannot fail an INSERT
  147. that will never be made."""
  148. from backend.app.core.database import Base
  149. assert "legacy_removed_feature" not in Base.metadata.tables
  150. path = _source(
  151. tmp_path,
  152. "CREATE TABLE legacy_removed_feature (id INTEGER PRIMARY KEY, whatever TEXT)",
  153. ["INSERT INTO legacy_removed_feature (id, whatever) VALUES (1, 'x')"],
  154. )
  155. check_backup_schema_compatible(path) # does not raise
  156. # ---------------------------------------------------------------------------
  157. # The import itself
  158. # ---------------------------------------------------------------------------
  159. def _mock_engine():
  160. """An engine that records every statement and parameter set."""
  161. executed: list[tuple[str, object]] = []
  162. conn = MagicMock()
  163. conn.execute = AsyncMock(
  164. side_effect=lambda stmt, *a, **k: executed.append((getattr(stmt, "text", str(stmt)), a[0] if a else None))
  165. )
  166. conn.run_sync = AsyncMock()
  167. begin_cm = MagicMock()
  168. begin_cm.__aenter__ = AsyncMock(return_value=conn)
  169. begin_cm.__aexit__ = AsyncMock(return_value=False)
  170. engine = MagicMock()
  171. engine.begin = MagicMock(return_value=begin_cm)
  172. engine.dispose = AsyncMock()
  173. return engine, executed
  174. @pytest.mark.asyncio
  175. async def test_the_import_refuses_before_it_drops_anything(tmp_path):
  176. """The whole point. The drop is the import's first act and it is not
  177. reversible: by the time an INSERT fails, the install is empty."""
  178. path = _source(
  179. tmp_path,
  180. "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)",
  181. ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"],
  182. )
  183. from backend.app.api.routes import settings as settings_module
  184. engine, executed = _mock_engine()
  185. create_engine = MagicMock(return_value=engine)
  186. with (
  187. patch("backend.app.core.database._create_engine", new=create_engine),
  188. pytest.raises(BackupSchemaIncompatible),
  189. ):
  190. await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
  191. assert executed == [], f"SQL ran against the destination before the refusal: {executed}"
  192. create_engine.assert_not_called()
  193. @pytest.mark.asyncio
  194. async def test_a_missing_defaulted_column_is_inserted_with_its_default(tmp_path):
  195. """What would have rescued the failed restore: the column absent from the
  196. backup joins the INSERT carrying the model's default."""
  197. path = _source(
  198. tmp_path,
  199. "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT, name TEXT, created_at TEXT, updated_at TEXT)",
  200. [
  201. "INSERT INTO cost_centers (id, code, name, created_at, updated_at) "
  202. "VALUES (1, 'abc', 'Lab', '2026-09-07 10:44:42', '2026-09-07 10:44:42')"
  203. ],
  204. )
  205. from backend.app.api.routes import settings as settings_module
  206. engine, executed = _mock_engine()
  207. with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
  208. await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
  209. inserts = [(sql, params) for sql, params in executed if sql.startswith("INSERT INTO cost_centers")]
  210. assert inserts, f"no INSERT was emitted: {[sql[:60] for sql, _ in executed]}"
  211. sql, params = inserts[0]
  212. # is_active is NOT NULL with default=True in the model and absent above.
  213. assert "is_active" in sql
  214. assert params[0]["is_active"] is True
  215. assert params[0]["code"] == "abc", "the backup's own values must survive the injection"
  216. # ---------------------------------------------------------------------------
  217. # The manifest
  218. # ---------------------------------------------------------------------------
  219. def test_a_backup_without_a_manifest_reads_as_unknown(tmp_path):
  220. """Every backup taken before the manifest existed. It must restore exactly
  221. as it did, with the version simply unknown."""
  222. assert _read_backup_manifest(tmp_path) == {}
  223. def test_an_unreadable_manifest_does_not_break_the_restore(tmp_path):
  224. (tmp_path / "manifest.json").write_text("{ this is not json")
  225. assert _read_backup_manifest(tmp_path) == {}
  226. def test_the_manifest_is_read(tmp_path):
  227. (tmp_path / "manifest.json").write_text('{"format": 1, "app_version": "1.2.7"}')
  228. assert _read_backup_manifest(tmp_path)["app_version"] == "1.2.7"