test_backup_manifest.py 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. """The backup says which version made it, and restore refuses one it cannot import.
  2. Restoring a backup into a different version of Bambuddy is ordinary -- an
  3. upgrade, a rebuild, a move to another host. What is not ordinary is the Postgres
  4. restore path, which throws the backup's schema away and rebuilds from the
  5. running ORM. A NOT NULL column the running version has and the backup does not
  6. then has nothing to put in it, and the import fails on the INSERT: after the
  7. drop, with the install's data already gone.
  8. So the incompatibility has to be found before any of that, and it has to say
  9. something an operator can act on. A column name does not; a pair of version
  10. numbers does, which is what the manifest is for.
  11. """
  12. from __future__ import annotations
  13. import io
  14. import json
  15. import sqlite3
  16. import zipfile
  17. from pathlib import Path
  18. from unittest.mock import patch
  19. import pytest
  20. from backend.app.core.config import APP_VERSION, settings as app_settings
  21. @pytest.mark.asyncio
  22. @pytest.mark.integration
  23. async def test_the_backup_records_the_version_that_made_it(async_client, monkeypatch, tmp_path):
  24. from backend.app.api.routes.settings import create_backup_zip
  25. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  26. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  27. zip_path, _filename = await create_backup_zip(output_path=tmp_path)
  28. try:
  29. with zipfile.ZipFile(zip_path) as zf:
  30. assert "manifest.json" in zf.namelist()
  31. manifest = json.loads(zf.read("manifest.json"))
  32. finally:
  33. zip_path.unlink(missing_ok=True)
  34. assert manifest["app_version"] == APP_VERSION
  35. assert manifest["format"] == 1
  36. assert manifest["database"] in ("sqlite", "postgresql")
  37. assert manifest["created_at"]
  38. def _incompatible_backup(tmp_path: Path, *, version: str) -> bytes:
  39. """A backup whose `cost_centers` has no `name` -- NOT NULL, no default."""
  40. db = tmp_path / "bambuddy.db"
  41. conn = sqlite3.connect(db)
  42. conn.execute("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)")
  43. conn.execute("INSERT INTO cost_centers (id, code) VALUES (1, 'abc')")
  44. conn.commit()
  45. conn.close()
  46. buffer = io.BytesIO()
  47. with zipfile.ZipFile(buffer, "w") as zf:
  48. zf.write(db, "bambuddy.db")
  49. zf.writestr("manifest.json", json.dumps({"format": 1, "app_version": version}))
  50. return buffer.getvalue()
  51. @pytest.mark.asyncio
  52. @pytest.mark.integration
  53. async def test_an_unimportable_backup_is_refused_with_both_versions(async_client, tmp_path):
  54. """A 400 naming the two versions, and -- the point -- nothing touched.
  55. is_sqlite is patched false because this is the PostgreSQL path: a SQLite
  56. install restores by copying the backup's pages, schema and all, and has
  57. never had this problem.
  58. """
  59. payload = _incompatible_backup(tmp_path, version="99.9.9")
  60. with patch("backend.app.core.db_dialect.is_sqlite", return_value=False):
  61. response = await async_client.post(
  62. "/api/v1/settings/restore",
  63. files={"file": ("backup.zip", payload, "application/zip")},
  64. )
  65. assert response.status_code == 400, response.text
  66. detail = response.json()["detail"]
  67. assert "cost_centers.name" in detail
  68. assert "99.9.9" in detail
  69. assert APP_VERSION in detail
  70. assert "Nothing has been changed" in detail