You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

503 lines
17 KiB

import json
import os
import subprocess
from contextlib import contextmanager
from hashlib import sha256
from pathlib import Path
from types import SimpleNamespace
import pytest
from django.db import connection
from psycopg import connect, sql
from core.backup import (
BACKUP_FORMAT,
BACKUP_VERSION,
BackupError,
DatabaseSnapshot,
DatabaseTarget,
create_backup,
verify_backup,
)
class RecordingRunner:
def __init__(self, output=b"PGDMP synthetic"):
self.calls = []
self.output = output
def __call__(self, argv, *, env, check, stdout=None, capture_output=False):
self.calls.append((list(argv), dict(env), check, stdout, capture_output))
if argv[0] == "pg_dump" and argv != ["pg_dump", "--version"]:
output_name = next(value.removeprefix("--file=") for value in argv if value.startswith("--file="))
Path(output_name).write_bytes(self.output)
return SimpleNamespace(stdout=b"pg_dump (PostgreSQL) 17.6\n")
@pytest.fixture
def database_settings(settings):
settings.DATABASES["default"] = {
"ENGINE": "django.db.backends.postgresql",
"HOST": "live-db",
"PORT": "5432",
"NAME": "gizmoduck",
"USER": "gizmoduck_migrate",
"PASSWORD": "synthetic-secret",
}
return settings.DATABASES["default"]
@pytest.fixture
def backup_root(tmp_path, settings):
settings.BACKUP_ROOT = tmp_path
return tmp_path
@pytest.fixture
def synthetic_snapshot(monkeypatch):
snapshot = DatabaseSnapshot(
migrations=(("finance", "0001_initial"), ("ingestion", "0001_initial")),
row_counts={"finance_account": 2, "ingestion_importbatch": 1},
)
@contextmanager
def exported_snapshot(_target):
yield "synthetic-snapshot", snapshot
monkeypatch.setattr("core.backup._exported_snapshot", exported_snapshot)
monkeypatch.setattr("core.backup._read_snapshot", lambda _target: snapshot)
return snapshot
def test_pg_dump_receives_password_only_in_environment(
backup_root, database_settings, synthetic_snapshot
):
runner = RecordingRunner()
result = create_backup("synthetic.dump", runner=runner)
argv, env, check, stdout, capture_output = runner.calls[0]
assert argv[:4] == ["pg_dump", "--format=custom", "--no-owner", "--no-acl"]
assert database_settings["PASSWORD"] not in argv
assert env["PGPASSWORD"] == database_settings["PASSWORD"]
assert check is True
assert stdout is None
assert capture_output is True
assert result.path.read_bytes() == runner.output
def test_pg_dump_uses_the_exported_snapshot(
backup_root, database_settings, synthetic_snapshot
):
runner = RecordingRunner()
create_backup("synthetic.dump", runner=runner)
assert "--snapshot=synthetic-snapshot" in runner.calls[0][0]
assert runner.calls[0][4] is True
def test_backup_writes_versioned_checksum_manifest(
backup_root, database_settings, synthetic_snapshot
):
runner = RecordingRunner()
result = create_backup("synthetic.dump", runner=runner)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert manifest == {
"application_version": "0.1.0",
"byte_size": len(runner.output),
"created_at": result.created_at.isoformat(),
"database_name": "gizmoduck",
"format": BACKUP_FORMAT,
"migrations": [
["finance", "0001_initial"],
["ingestion", "0001_initial"],
],
"postgresql_client_version": result.postgresql_client_version,
"row_counts": {
"finance_account": 2,
"ingestion_importbatch": 1,
},
"sha256": sha256(runner.output).hexdigest(),
"version": BACKUP_VERSION,
}
@pytest.mark.parametrize(
"name",
["../outside.dump", "nested/file.dump", "nested\\file.dump", ".dump", "file.zip"],
)
def test_backup_rejects_non_plain_dump_names(
backup_root, database_settings, synthetic_snapshot, name
):
with pytest.raises(BackupError, match="invalid_name"):
create_backup(name, runner=RecordingRunner())
assert list(backup_root.iterdir()) == []
def test_backup_does_not_replace_existing_artifacts(
backup_root, database_settings, synthetic_snapshot
):
dump = backup_root / "synthetic.dump"
manifest = backup_root / "synthetic.dump.json"
dump.write_bytes(b"existing dump")
manifest.write_text("existing manifest", encoding="utf-8")
with pytest.raises(BackupError, match="existing_destination"):
create_backup("synthetic.dump", runner=RecordingRunner())
assert dump.read_bytes() == b"existing dump"
assert manifest.read_text(encoding="utf-8") == "existing manifest"
def test_backup_cleans_private_temporary_files_on_dump_failure(
backup_root, database_settings, synthetic_snapshot
):
def failing_runner(argv, **kwargs):
raise OSError("synthetic private detail")
with pytest.raises(BackupError, match="backup_failed"):
create_backup("synthetic.dump", runner=failing_runner)
assert list(backup_root.iterdir()) == []
def test_backup_sanitizes_subprocess_failure_and_captures_diagnostics(
backup_root, database_settings, synthetic_snapshot
):
def failing_runner(argv, *, capture_output, **kwargs):
assert capture_output is True
raise subprocess.CalledProcessError(
1, argv, output=b"SYNTHETIC PRIVATE STDOUT", stderr=b"SYNTHETIC PRIVATE STDERR"
)
with pytest.raises(BackupError, match="backup_failed") as error:
create_backup("synthetic.dump", runner=failing_runner)
assert "PRIVATE" not in str(error.value)
assert list(backup_root.iterdir()) == []
def test_second_temporary_unlink_failure_leaves_no_partial_final_pair(
backup_root, database_settings, synthetic_snapshot, monkeypatch
):
original_unlink = Path.unlink
failed = False
def fail_once(path, *args, **kwargs):
nonlocal failed
if not failed and path.name.endswith(".dump.tmp"):
failed = True
raise OSError("synthetic unlink failure")
return original_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", fail_once)
with pytest.raises(BackupError, match="publication_failed"):
create_backup("synthetic.dump", runner=RecordingRunner())
assert list(backup_root.iterdir()) == []
def test_publication_never_deletes_concurrent_final_files(tmp_path, monkeypatch):
import core.backup as backup
manifest_temp = tmp_path / ".manifest.tmp"
dump_temp = tmp_path / ".dump.tmp"
manifest_final = tmp_path / "synthetic.dump.json"
dump_final = tmp_path / "synthetic.dump"
manifest_temp.write_bytes(b"our manifest")
dump_temp.write_bytes(b"our dump")
original_link = os.link
calls = 0
def competing_link(source, destination):
nonlocal calls
calls += 1
if calls == 2:
manifest_final.unlink()
manifest_final.write_bytes(b"other manifest")
dump_final.write_bytes(b"other dump")
raise FileExistsError
return original_link(source, destination)
monkeypatch.setattr(os, "link", competing_link)
with pytest.raises(BackupError, match="publication_failed"):
backup._publish_pair(
tmp_path,
((manifest_temp, manifest_final), (dump_temp, dump_final)),
)
assert manifest_final.read_bytes() == b"other manifest"
assert dump_final.read_bytes() == b"other dump"
assert sorted(path.name for path in tmp_path.iterdir()) == [
"synthetic.dump",
"synthetic.dump.json",
]
def test_verification_rejects_live_database(
backup_root, database_settings, synthetic_snapshot
):
target = DatabaseTarget.from_default(database_settings)
with pytest.raises(BackupError, match="live_target"):
verify_backup("synthetic.dump", target, runner=RecordingRunner())
def test_verification_requires_explicit_allow_flag(
backup_root, database_settings, synthetic_snapshot, settings
):
settings.BACKUP_VERIFY_ALLOWED = False
target = DatabaseTarget(
host="restore-db",
port="5432",
name="gizmoduck_restore",
user="restore_user",
password="restore-secret",
)
with pytest.raises(BackupError, match="verification_not_allowed"):
verify_backup("synthetic.dump", target, runner=RecordingRunner())
def test_verification_rejects_changed_dump_before_restore(
backup_root, database_settings, synthetic_snapshot, settings
):
settings.BACKUP_VERIFY_ALLOWED = True
result = create_backup("synthetic.dump", runner=RecordingRunner())
result.path.write_bytes(b"PGDMP tampered!")
restore_runner = RecordingRunner()
target = DatabaseTarget(
host="restore-db",
port="5432",
name="gizmoduck_restore",
user="restore_user",
password="restore-secret",
)
with pytest.raises(BackupError, match="digest_mismatch"):
verify_backup(result.name, target, runner=restore_runner)
assert restore_runner.calls == []
def test_verification_sanitizes_digest_read_failure(
backup_root, database_settings, synthetic_snapshot, settings, monkeypatch
):
settings.BACKUP_VERIFY_ALLOWED = True
result = create_backup("synthetic.dump", runner=RecordingRunner())
monkeypatch.setattr(
"core.backup._file_digest",
lambda _path: (_ for _ in ()).throw(OSError("SYNTHETIC PRIVATE PATH")),
)
target = DatabaseTarget(
host="restore-db", port="5432", name="restore", user="user", password="secret"
)
with pytest.raises(BackupError, match="verification_failed") as error:
verify_backup(result.name, target, runner=RecordingRunner())
assert "PRIVATE" not in str(error.value)
def test_verification_rejects_manifest_with_wrong_field_type(
backup_root, database_settings, synthetic_snapshot, settings
):
settings.BACKUP_VERIFY_ALLOWED = True
result = create_backup("synthetic.dump", runner=RecordingRunner())
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
manifest["application_version"] = 1
result.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
target = DatabaseTarget(
host="restore-db",
port="5432",
name="gizmoduck_restore",
user="restore_user",
password="restore-secret",
)
with pytest.raises(BackupError, match="invalid_manifest"):
verify_backup(result.name, target, runner=RecordingRunner())
def test_backup_rejects_nonmatching_postgresql_client_major(
backup_root, database_settings, synthetic_snapshot
):
class WrongVersionRunner(RecordingRunner):
def __call__(self, argv, **kwargs):
result = super().__call__(argv, **kwargs)
if argv == ["pg_dump", "--version"]:
result.stdout = b"pg_dump (PostgreSQL) 16.9\n"
return result
with pytest.raises(BackupError, match="client_version_failed"):
create_backup("synthetic.dump", runner=WrongVersionRunner())
assert list(backup_root.iterdir()) == []
def test_pg_restore_receives_password_only_in_environment(
backup_root, database_settings, synthetic_snapshot, settings, monkeypatch
):
settings.BACKUP_VERIFY_ALLOWED = True
result = create_backup("synthetic.dump", runner=RecordingRunner())
snapshots = iter(
[DatabaseSnapshot(migrations=(), row_counts={}), synthetic_snapshot]
)
monkeypatch.setattr("core.backup._read_snapshot", lambda _target: next(snapshots))
runner = RecordingRunner()
target = DatabaseTarget(
host="restore-db",
port="5432",
name="gizmoduck_restore",
user="restore_user",
password="synthetic-restore-secret",
)
verification = verify_backup(result.name, target, runner=runner)
argv, env, check, stdout, capture_output = runner.calls[0]
assert argv[:5] == [
"pg_restore",
"--exit-on-error",
"--single-transaction",
"--no-owner",
"--no-acl",
]
assert target.password not in argv
assert env["PGPASSWORD"] == target.password
assert check is True
assert stdout is None
assert capture_output is True
assert verification.row_counts == synthetic_snapshot.row_counts
@pytest.mark.django_db(transaction=True)
def test_snapshot_counts_tables_in_non_system_schemas(settings):
from core.backup import _read_snapshot
target = DatabaseTarget.from_default(settings.DATABASES["default"])
with connection.cursor() as cursor:
cursor.execute("CREATE SCHEMA synthetic_extra")
cursor.execute("CREATE TABLE synthetic_extra.marker (id integer)")
cursor.execute("INSERT INTO synthetic_extra.marker VALUES (1)")
try:
snapshot = _read_snapshot(target)
assert snapshot.row_counts["synthetic_extra.marker"] == 1
finally:
with connection.cursor() as cursor:
cursor.execute("DROP SCHEMA synthetic_extra CASCADE")
def test_manifest_rejects_boolean_integer_fields(
backup_root, database_settings, synthetic_snapshot, settings
):
settings.BACKUP_VERIFY_ALLOWED = True
result = create_backup("synthetic.dump", runner=RecordingRunner())
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
manifest["byte_size"] = True
result.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
target = DatabaseTarget(
host="restore-db", port="5432", name="restore", user="user", password="secret"
)
with pytest.raises(BackupError, match="invalid_manifest"):
verify_backup(result.name, target, runner=RecordingRunner())
def test_cleanup_attempts_every_resource_and_reports_safe_failure(monkeypatch, tmp_path):
import core.backup as backup
attempts = []
dump = tmp_path / "synthetic.dump"
manifest = tmp_path / "synthetic.dump.json"
dump.write_bytes(b"dump")
manifest.write_text("{}", encoding="utf-8")
artifact = SimpleNamespace(path=dump, manifest_path=manifest)
source = DatabaseTarget("source", "5432", "source", "user", "password")
target = DatabaseTarget("target", "5432", "target", "user", "password")
original_unlink = Path.unlink
def unlink(path, *args, **kwargs):
attempts.append(f"unlink:{path.name}")
if path == dump:
raise OSError("private path")
return original_unlink(path, *args, **kwargs)
def clear(database):
attempts.append(f"clear:{database.name}")
if database == source:
raise BackupError("cleanup_failed")
monkeypatch.setattr(Path, "unlink", unlink)
monkeypatch.setattr(backup, "_clear_disposable_database", clear)
with pytest.raises(BackupError, match="cleanup_failed"):
backup._cleanup_synthetic_resources(artifact, source, target)
assert attempts == [
"unlink:synthetic.dump",
"unlink:synthetic.dump.json",
"clear:source",
"clear:target",
]
@pytest.mark.django_db(transaction=True)
def test_concurrent_insert_is_excluded_from_both_manifest_and_dump(tmp_path, settings):
from finance.models import Account
settings.BACKUP_ROOT = tmp_path
settings.BACKUP_VERIFY_ALLOWED = True
source = DatabaseTarget.from_default(settings.DATABASES["default"])
Account.objects.create(name="Synthetic Snapshot First", kind="BANK", currency="BRL")
inserted = False
def runner(argv, **kwargs):
nonlocal inserted
if argv[0] == "pg_dump" and argv != ["pg_dump", "--version"]:
assert any(value.startswith("--snapshot=") for value in argv)
Account.objects.create(
name="Synthetic Snapshot Concurrent", kind="BANK", currency="BRL"
)
inserted = True
return subprocess.run(argv, **kwargs)
database_name = f"snapshot_restore_{os.getpid()}"
admin = dict(
host=source.host,
port=source.port,
dbname="postgres",
user=source.user,
password=source.password,
autocommit=True,
)
with connect(**admin) as database:
database.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name)))
target = DatabaseTarget(
host=source.host,
port=source.port,
name=database_name,
user=source.user,
password=source.password,
)
try:
result = create_backup("concurrent.dump", runner=runner)
verification = verify_backup(result.name, target)
assert inserted is True
assert result.row_counts["finance_account"] == 1
assert verification.row_counts["finance_account"] == 1
assert Account.objects.count() == 2
finally:
with connect(**admin) as database:
database.execute(
sql.SQL("DROP DATABASE {} WITH (FORCE)").format(
sql.Identifier(database_name)
)
)