mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
refactor(models): break the schema cycle that backup and restore sort through
print_archives.library_file_id -> library_files.folder_id -> library_folders.archive_id -> print_archives. Three nullable SET NULL links, each reasonable alone, that together made a loop metadata.sorted_tables could not sort: it dropped those edges, warned on every backup and every restore, and could return an order placing a child before its parent -- which once imported library_files ahead of library_folders and killed a restore on a ForeignKeyViolation. The restore no longer depends on that order (it strips every foreign key before importing and adds them back after), but the backup export sorts the same way, and the warning ends with "may raise an error in a future release" -- which would break backup and restore on one upgrade. Marking one edge use_alter removes it from the sort graph, not from the database: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT, as it already did for every constraint on these three tables, and SQLite inlines it into CREATE TABLE, so ON DELETE SET NULL holds on both. Verified against PostgreSQL 16 and SQLite.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""The backup says which version made it, and restore refuses one it cannot import.
|
||||
|
||||
Restoring a backup into a different version of Bambuddy is ordinary -- an
|
||||
upgrade, a rebuild, a move to another host. What is not ordinary is the Postgres
|
||||
restore path, which throws the backup's schema away and rebuilds from the
|
||||
running ORM. A NOT NULL column the running version has and the backup does not
|
||||
then has nothing to put in it, and the import fails on the INSERT: after the
|
||||
drop, with the install's data already gone.
|
||||
|
||||
So the incompatibility has to be found before any of that, and it has to say
|
||||
something an operator can act on. A column name does not; a pair of version
|
||||
numbers does, which is what the manifest is for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_backup_records_the_version_that_made_it(async_client, monkeypatch, tmp_path):
|
||||
from backend.app.api.routes.settings import create_backup_zip
|
||||
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
|
||||
|
||||
zip_path, _filename = await create_backup_zip(output_path=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert "manifest.json" in zf.namelist()
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
assert manifest["app_version"] == APP_VERSION
|
||||
assert manifest["format"] == 1
|
||||
assert manifest["database"] in ("sqlite", "postgresql")
|
||||
assert manifest["created_at"]
|
||||
|
||||
|
||||
def _incompatible_backup(tmp_path: Path, *, version: str) -> bytes:
|
||||
"""A backup whose `cost_centers` has no `name` -- NOT NULL, no default."""
|
||||
db = tmp_path / "bambuddy.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)")
|
||||
conn.execute("INSERT INTO cost_centers (id, code) VALUES (1, 'abc')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
zf.write(db, "bambuddy.db")
|
||||
zf.writestr("manifest.json", json.dumps({"format": 1, "app_version": version}))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_unimportable_backup_is_refused_with_both_versions(async_client, tmp_path):
|
||||
"""A 400 naming the two versions, and -- the point -- nothing touched.
|
||||
|
||||
is_sqlite is patched false because this is the PostgreSQL path: a SQLite
|
||||
install restores by copying the backup's pages, schema and all, and has
|
||||
never had this problem.
|
||||
"""
|
||||
payload = _incompatible_backup(tmp_path, version="99.9.9")
|
||||
|
||||
with patch("backend.app.core.db_dialect.is_sqlite", return_value=False):
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/restore",
|
||||
files={"file": ("backup.zip", payload, "application/zip")},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
detail = response.json()["detail"]
|
||||
assert "cost_centers.name" in detail
|
||||
assert "99.9.9" in detail
|
||||
assert APP_VERSION in detail
|
||||
assert "Nothing has been changed" in detail
|
||||
@@ -1912,6 +1912,27 @@ class TestEncryptionRoundtrip:
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _minimal_sqlite_backup() -> bytes:
|
||||
"""A real, if empty, SQLite database to stand in for a backup's bambuddy.db.
|
||||
|
||||
Restore now refuses a bambuddy.db it cannot open, and refuses it before it
|
||||
stops services or overwrites the MFA key file -- a corrupt or truncated
|
||||
backup used to be found only by the Postgres import, which by then had
|
||||
dropped every table in the live database. These tests are about the key
|
||||
handling around the swap, so they need a file that opens; what is in it does
|
||||
not matter.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)")
|
||||
conn.commit()
|
||||
return conn.serialize()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestBackupKeyFiles:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -1980,7 +2001,7 @@ class TestBackupKeyFiles:
|
||||
# Build a minimal ZIP with a stub DB and the key file.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
zf.writestr(".mfa_encryption_key", "test-restored-key")
|
||||
buf.seek(0)
|
||||
|
||||
@@ -2020,7 +2041,7 @@ class TestBackupKeyFiles:
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
# Intentionally no .mfa_encryption_key entry.
|
||||
buf.seek(0)
|
||||
|
||||
@@ -2061,7 +2082,7 @@ class TestBackupKeyFiles:
|
||||
# Build ZIP with a key file that we will fail to write to DATA_DIR.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3 backup data")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
zf.writestr(".mfa_encryption_key", "backup-key-content")
|
||||
buf.seek(0)
|
||||
|
||||
@@ -2141,7 +2162,7 @@ class TestBackupKeyFiles:
|
||||
assert new_key != old_key
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bambuddy.db", b"SQLite format 3 backup data")
|
||||
zf.writestr("bambuddy.db", _minimal_sqlite_backup())
|
||||
zf.writestr(".mfa_encryption_key", new_key)
|
||||
buf.seek(0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user