mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 08:07:37 +09:00
- backup_crypto.py: Fernet file encrypt/decrypt using the existing SERVERKIT_ENCRYPTION_KEY (no new deps) — encrypt_file (-> .enc), decrypt_file, is_encrypted_backup, encryption_available. - BackupService: an opt-in `encrypt_backups` config flag; _finalize_backup encrypts each artifact before _auto_upload (so the stored/remote copy is encrypted) and records encrypted/path/size; restore_application and restore_database transparently decrypt a .enc backup to a temp file first. - 9 tests: byte-exact roundtrip, wrong-key failure, availability reflects env, is_encrypted_backup, and an encrypted backup_files create+restore. Notes: in-memory (streaming large archives is a follow-up); key loss = unrecoverable backups (back the key up out of band). Additional remote targets (WebDAV/Azure/Dropbox/SFTP) and restore drills remain follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
"""Proving tests for client-side backup encryption.
|
|
|
|
Backup artifacts can be Fernet-encrypted with ``SERVERKIT_ENCRYPTION_KEY`` before
|
|
they ever touch remote storage. These tests prove:
|
|
(a) encrypt/decrypt round-trips the exact original bytes, replacing the
|
|
plaintext with a ``.enc`` artifact,
|
|
(b) a wrong key cannot decrypt (key loss = unrecoverable backups),
|
|
(c) ``encryption_available`` and ``is_encrypted_backup`` report correctly,
|
|
(d) BackupService.backup_files with encryption enabled writes an encrypted
|
|
artifact (unreadable as gzip) whose metadata records ``encrypted: True``,
|
|
(e) ``_finalize_backup`` is a no-op when encryption is disabled.
|
|
|
|
A valid Fernet key for the env is produced with ``Fernet.generate_key()``.
|
|
"""
|
|
import gzip
|
|
import json
|
|
import os
|
|
import tarfile
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
|
|
from app.utils import backup_crypto
|
|
|
|
|
|
# ── file-level crypto primitives ────────────────────────────────────────────
|
|
|
|
def test_encrypt_decrypt_roundtrip(tmp_path, monkeypatch):
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
|
|
src = tmp_path / 'dump.sql'
|
|
original = os.urandom(4096) + b'\n-- some sql --\n'
|
|
src.write_bytes(original)
|
|
|
|
enc_path = backup_crypto.encrypt_file(str(src))
|
|
|
|
# plaintext is gone, replaced by the .enc artifact
|
|
assert enc_path == str(src) + '.enc'
|
|
assert os.path.exists(enc_path)
|
|
assert not os.path.exists(str(src))
|
|
# ciphertext on disk is not the original bytes
|
|
assert open(enc_path, 'rb').read() != original
|
|
|
|
dec_path = backup_crypto.decrypt_file(enc_path)
|
|
assert dec_path == str(src) # trailing .enc stripped
|
|
assert os.path.exists(dec_path)
|
|
assert open(dec_path, 'rb').read() == original
|
|
# decrypt leaves the source .enc in place
|
|
assert os.path.exists(enc_path)
|
|
|
|
|
|
def test_decrypt_to_explicit_dest(tmp_path, monkeypatch):
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
|
|
src = tmp_path / 'config.tar.gz'
|
|
src.write_bytes(b'payload-bytes')
|
|
enc_path = backup_crypto.encrypt_file(str(src))
|
|
|
|
dest = tmp_path / 'out.bin'
|
|
out = backup_crypto.decrypt_file(enc_path, dest=str(dest))
|
|
assert out == str(dest)
|
|
assert dest.read_bytes() == b'payload-bytes'
|
|
|
|
|
|
def test_decrypt_with_wrong_key_raises(tmp_path, monkeypatch):
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
|
|
src = tmp_path / 'secret.sql'
|
|
src.write_bytes(b'sensitive backup contents')
|
|
enc_path = backup_crypto.encrypt_file(str(src))
|
|
|
|
# rotate to a different valid Fernet key -> decryption must fail
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
with pytest.raises(InvalidToken):
|
|
backup_crypto.decrypt_file(enc_path, dest=str(tmp_path / 'out.bin'))
|
|
|
|
|
|
def test_encryption_available_reflects_env(monkeypatch):
|
|
monkeypatch.delenv('SERVERKIT_ENCRYPTION_KEY', raising=False)
|
|
assert backup_crypto.encryption_available() is False
|
|
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
assert backup_crypto.encryption_available() is True
|
|
|
|
|
|
def test_is_encrypted_backup():
|
|
assert backup_crypto.is_encrypted_backup('/var/backups/db.sql.gz.enc') is True
|
|
assert backup_crypto.is_encrypted_backup('/var/backups/db.sql.gz') is False
|
|
assert backup_crypto.is_encrypted_backup('') is False
|
|
|
|
|
|
# ── BackupService integration ───────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def backup_dirs(tmp_path, monkeypatch):
|
|
"""Point BackupService at a throwaway base/config dir and stub uploads."""
|
|
from app.services.backup_service import BackupService
|
|
|
|
base = tmp_path / 'backups'
|
|
cfg = tmp_path / 'config'
|
|
base.mkdir()
|
|
cfg.mkdir()
|
|
|
|
monkeypatch.setattr(BackupService, 'BACKUP_BASE_DIR', str(base))
|
|
monkeypatch.setattr(BackupService, 'CONFIG_DIR', str(cfg))
|
|
monkeypatch.setattr(BackupService, 'BACKUP_CONFIG', str(cfg / 'backups.json'))
|
|
# never reach out to remote storage in tests
|
|
monkeypatch.setattr(BackupService, '_auto_upload', classmethod(lambda cls, p, info: None))
|
|
return BackupService
|
|
|
|
|
|
def test_backup_files_encrypts_artifact_and_metadata(backup_dirs, tmp_path, monkeypatch):
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
BackupService = backup_dirs
|
|
|
|
BackupService.save_config({'encrypt_backups': True})
|
|
|
|
# something to back up
|
|
payload = tmp_path / 'data.txt'
|
|
payload.write_text('hello world ' * 100)
|
|
|
|
res = BackupService.backup_files([str(payload)], backup_name='enc')
|
|
assert res['success'] is True
|
|
|
|
artifact = res['backup']['path']
|
|
assert artifact.endswith('.enc')
|
|
assert os.path.exists(artifact)
|
|
assert res['backup']['encrypted'] is True
|
|
|
|
# the artifact is NOT a readable gzip/tar until decrypted
|
|
with pytest.raises(Exception):
|
|
with tarfile.open(artifact, 'r:gz'):
|
|
pass
|
|
|
|
# metadata sidecar records encryption + the encrypted path.
|
|
# backup_name is suffixed with a timestamp; find the sidecar dynamically.
|
|
files_dir = os.path.join(BackupService.BACKUP_BASE_DIR, 'files')
|
|
sidecars = [f for f in os.listdir(files_dir) if f.endswith('.json')]
|
|
assert len(sidecars) == 1
|
|
meta = json.loads(open(os.path.join(files_dir, sidecars[0])).read())
|
|
assert meta['encrypted'] is True
|
|
assert meta['path'].endswith('.enc')
|
|
|
|
# decrypting yields a valid gzip tar again
|
|
dec = backup_crypto.decrypt_file(artifact)
|
|
with tarfile.open(dec, 'r:gz') as tar:
|
|
names = tar.getnames()
|
|
assert 'data.txt' in names
|
|
|
|
|
|
def test_finalize_backup_noop_when_disabled(backup_dirs, tmp_path):
|
|
BackupService = backup_dirs
|
|
BackupService.save_config({'encrypt_backups': False})
|
|
|
|
f = tmp_path / 'plain.sql.gz'
|
|
f.write_bytes(b'not encrypted')
|
|
info = {'path': str(f), 'size': f.stat().st_size}
|
|
|
|
out = BackupService._finalize_backup(str(f), info)
|
|
assert out == str(f)
|
|
assert info['encrypted'] is False
|
|
assert os.path.exists(str(f))
|
|
|
|
|
|
def test_finalize_backup_encrypts_when_enabled(backup_dirs, tmp_path, monkeypatch):
|
|
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
|
BackupService = backup_dirs
|
|
BackupService.save_config({'encrypt_backups': True})
|
|
|
|
f = tmp_path / 'plain.sql.gz'
|
|
f.write_bytes(b'db dump bytes')
|
|
info = {'path': str(f), 'size': f.stat().st_size}
|
|
|
|
out = BackupService._finalize_backup(str(f), info)
|
|
assert out.endswith('.enc')
|
|
assert info['encrypted'] is True
|
|
assert info['path'] == out
|
|
assert info['size'] == os.path.getsize(out)
|
|
assert not os.path.exists(str(f))
|
|
|
|
|
|
def test_encrypt_backups_default_roundtrips_through_config(backup_dirs):
|
|
BackupService = backup_dirs
|
|
# default config exposes the flag (False)
|
|
assert BackupService.get_config()['encrypt_backups'] is False
|
|
# and it persists through save/get
|
|
cfg = BackupService.get_config()
|
|
cfg['encrypt_backups'] = True
|
|
BackupService.save_config(cfg)
|
|
assert BackupService.get_config()['encrypt_backups'] is True
|