| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- """Restoring a backup made by a different version of Bambuddy.
- A backup carries the schema of the install that made it, and the Postgres
- restore path does NOT use that schema: it drops every table, recreates them
- from the running process's ORM, and inserts the backup's columns into the
- result. So any NOT NULL column the running version has and the backup does not
- arrives with nothing to put in it.
- That is how a 2026-09-23 backup failed to restore on the published image:
- ``user_wallets.currency`` was dropped from the model in #3123, the backup
- therefore had no such column, the older image still declared it NOT NULL, and
- the import died on::
- null value in column "currency" of relation "user_wallets"
- Its ``default="EUR"`` could not help -- SQLAlchemy applies a Python-side default
- to ORM and Core inserts, never to the raw ``text()`` SQL this import builds, and
- ``create_all`` emits no DDL default for one.
- Two things have to hold, and the second is the serious one:
- 1. A column with a default is filled rather than refused.
- 2. A column that cannot be filled is refused BEFORE the restore drops anything.
- The drop is the first thing the import does, in its own transaction, so a
- refusal at the INSERT means the install's data is already gone -- and the
- restore has by then also overwritten the MFA key file, leaving whatever
- survives encrypted under a key that no longer matches.
- """
- from __future__ import annotations
- import sqlite3
- from pathlib import Path
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import Column, DateTime, Integer, MetaData, Numeric, String, Table, func
- from backend.app.api.routes.settings import (
- BackupSchemaIncompatible,
- _missing_required_columns,
- _read_backup_manifest,
- check_backup_schema_compatible,
- )
- def _wallets_table(metadata: MetaData) -> Table:
- """`user_wallets` as the published image still declares it."""
- return Table(
- "user_wallets",
- metadata,
- Column("id", Integer, primary_key=True),
- Column("user_id", Integer, nullable=False),
- Column("balance", Numeric(14, 2), nullable=False, default=0.0),
- Column("currency", String(3), nullable=False, default="EUR"),
- Column("updated_at", DateTime, nullable=False, server_default=func.now()),
- )
- # ---------------------------------------------------------------------------
- # Which missing columns are a problem
- # ---------------------------------------------------------------------------
- def test_a_column_with_a_model_default_is_filled_not_refused():
- """The exact case from the incident, with the values it would have used."""
- table = _wallets_table(MetaData())
- injectable, db_filled, unfillable = _missing_required_columns(table, {"id", "user_id", "balance", "updated_at"})
- assert injectable == {"currency": "EUR"}
- assert unfillable == []
- assert db_filled == []
- def test_a_column_with_a_server_default_is_left_to_the_database():
- """Omitting it from the INSERT is right: Postgres fills it. Sending the
- ORM's idea of the default instead would overwrite a timestamp the database
- is better placed to produce."""
- table = _wallets_table(MetaData())
- injectable, db_filled, unfillable = _missing_required_columns(table, {"id", "user_id", "balance", "currency"})
- assert db_filled == ["updated_at"]
- assert injectable == {}
- assert unfillable == []
- def test_a_callable_default_is_evaluated():
- table = Table(
- "cost_centers",
- MetaData(),
- Column("id", Integer, primary_key=True),
- Column("code", String(32), nullable=False, default=lambda: "generated"),
- )
- injectable, _, unfillable = _missing_required_columns(table, {"id"})
- assert injectable == {"code": "generated"}
- assert unfillable == []
- def test_a_required_column_with_no_default_is_unfillable():
- table = Table(
- "cost_centers",
- MetaData(),
- Column("id", Integer, primary_key=True),
- Column("name", String(150), nullable=False),
- )
- injectable, _, unfillable = _missing_required_columns(table, {"id"})
- assert unfillable == ["name"]
- assert injectable == {}
- def test_a_nullable_column_the_backup_lacks_is_not_a_problem():
- """Most schema drift is this, and it has always worked: the column is
- simply omitted and the row gets NULL."""
- table = Table(
- "printers",
- MetaData(),
- Column("id", Integer, primary_key=True),
- Column("nickname", String(50), nullable=True),
- )
- injectable, db_filled, unfillable = _missing_required_columns(table, {"id"})
- assert (injectable, db_filled, unfillable) == ({}, [], [])
- # ---------------------------------------------------------------------------
- # The preflight, against a real backup file
- # ---------------------------------------------------------------------------
- def _source(tmp_path: Path, ddl: str, rows: list[str]) -> Path:
- path = tmp_path / "bambuddy.db"
- conn = sqlite3.connect(path)
- conn.execute(ddl)
- for row in rows:
- conn.execute(row)
- conn.commit()
- conn.close()
- return path
- def test_a_backup_this_version_can_import_is_accepted(tmp_path):
- """`users` as the ORM has it, minus columns that are nullable or defaulted."""
- path = _source(
- tmp_path,
- "CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)",
- ["INSERT INTO users (id, username) VALUES (1, 'alice')"],
- )
- check_backup_schema_compatible(path) # does not raise
- def test_a_backup_missing_a_required_column_is_refused(tmp_path):
- """`cost_centers.name` is NOT NULL with no default anywhere."""
- path = _source(
- tmp_path,
- "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)",
- ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"],
- )
- with pytest.raises(BackupSchemaIncompatible) as exc:
- check_backup_schema_compatible(path)
- assert "cost_centers.name" in str(exc.value)
- assert "Nothing has been changed" in str(exc.value)
- def test_the_refusal_names_the_version_that_made_the_backup(tmp_path):
- """A column name tells an operator nothing about what to do. Two version
- numbers tell them which install to restore on."""
- path = _source(
- tmp_path,
- "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)",
- ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"],
- )
- with pytest.raises(BackupSchemaIncompatible) as exc:
- check_backup_schema_compatible(path, backup_version="1.2.7")
- assert "1.2.7" in str(exc.value)
- def test_an_empty_table_is_not_a_reason_to_refuse(tmp_path):
- """No rows, no INSERT, no violation. Refusing here would block a restore
- over a feature the backup's install never used."""
- path = _source(tmp_path, "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)", [])
- check_backup_schema_compatible(path) # does not raise
- def test_a_table_the_orm_does_not_know_is_ignored(tmp_path):
- """Backups carry tables from features this version has removed. The import
- skips them (it only imports source ∩ ORM), so the check must not judge them
- either -- a column requirement that no longer exists cannot fail an INSERT
- that will never be made."""
- from backend.app.core.database import Base
- assert "legacy_removed_feature" not in Base.metadata.tables
- path = _source(
- tmp_path,
- "CREATE TABLE legacy_removed_feature (id INTEGER PRIMARY KEY, whatever TEXT)",
- ["INSERT INTO legacy_removed_feature (id, whatever) VALUES (1, 'x')"],
- )
- check_backup_schema_compatible(path) # does not raise
- # ---------------------------------------------------------------------------
- # The import itself
- # ---------------------------------------------------------------------------
- def _mock_engine():
- """An engine that records every statement and parameter set."""
- executed: list[tuple[str, object]] = []
- conn = MagicMock()
- conn.execute = AsyncMock(
- side_effect=lambda stmt, *a, **k: executed.append((getattr(stmt, "text", str(stmt)), a[0] if a else None))
- )
- conn.run_sync = AsyncMock()
- begin_cm = MagicMock()
- begin_cm.__aenter__ = AsyncMock(return_value=conn)
- begin_cm.__aexit__ = AsyncMock(return_value=False)
- engine = MagicMock()
- engine.begin = MagicMock(return_value=begin_cm)
- engine.dispose = AsyncMock()
- return engine, executed
- @pytest.mark.asyncio
- async def test_the_import_refuses_before_it_drops_anything(tmp_path):
- """The whole point. The drop is the import's first act and it is not
- reversible: by the time an INSERT fails, the install is empty."""
- path = _source(
- tmp_path,
- "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)",
- ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"],
- )
- from backend.app.api.routes import settings as settings_module
- engine, executed = _mock_engine()
- create_engine = MagicMock(return_value=engine)
- with (
- patch("backend.app.core.database._create_engine", new=create_engine),
- pytest.raises(BackupSchemaIncompatible),
- ):
- await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
- assert executed == [], f"SQL ran against the destination before the refusal: {executed}"
- create_engine.assert_not_called()
- @pytest.mark.asyncio
- async def test_a_missing_defaulted_column_is_inserted_with_its_default(tmp_path):
- """What would have rescued the failed restore: the column absent from the
- backup joins the INSERT carrying the model's default."""
- path = _source(
- tmp_path,
- "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT, name TEXT, created_at TEXT, updated_at TEXT)",
- [
- "INSERT INTO cost_centers (id, code, name, created_at, updated_at) "
- "VALUES (1, 'abc', 'Lab', '2026-09-07 10:44:42', '2026-09-07 10:44:42')"
- ],
- )
- from backend.app.api.routes import settings as settings_module
- engine, executed = _mock_engine()
- with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
- await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
- inserts = [(sql, params) for sql, params in executed if sql.startswith("INSERT INTO cost_centers")]
- assert inserts, f"no INSERT was emitted: {[sql[:60] for sql, _ in executed]}"
- sql, params = inserts[0]
- # is_active is NOT NULL with default=True in the model and absent above.
- assert "is_active" in sql
- assert params[0]["is_active"] is True
- assert params[0]["code"] == "abc", "the backup's own values must survive the injection"
- # ---------------------------------------------------------------------------
- # The manifest
- # ---------------------------------------------------------------------------
- def test_a_backup_without_a_manifest_reads_as_unknown(tmp_path):
- """Every backup taken before the manifest existed. It must restore exactly
- as it did, with the version simply unknown."""
- assert _read_backup_manifest(tmp_path) == {}
- def test_an_unreadable_manifest_does_not_break_the_restore(tmp_path):
- (tmp_path / "manifest.json").write_text("{ this is not json")
- assert _read_backup_manifest(tmp_path) == {}
- def test_the_manifest_is_read(tmp_path):
- (tmp_path / "manifest.json").write_text('{"format": 1, "app_version": "1.2.7"}')
- assert _read_backup_manifest(tmp_path)["app_version"] == "1.2.7"
|