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.
260 lines
8.2 KiB
260 lines
8.2 KiB
import json |
|
from hashlib import sha256 |
|
from pathlib import Path |
|
from types import SimpleNamespace |
|
|
|
import pytest |
|
|
|
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 stdout is not None: |
|
stdout.write(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}, |
|
) |
|
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 not None |
|
assert capture_output is False |
|
assert result.path.read_bytes() == runner.output |
|
|
|
|
|
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_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_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
|
|
|