Restore detailed backup mail reports with bounded local delivery

This commit is contained in:
admin committed 2026-09-10 16:45:18 +02:00
1 parent 3374019ad6
commit 99da6460d2
10 files changed
+396 -13

No files matched your search

+102
View File
@@ -16,6 +16,8 @@ import tarfile
import tempfile
import time
import unittest
from email import policy
from email.parser import BytesParser
REPO = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("backup_set", REPO / "lib/backup_set.py")
@@ -581,6 +583,7 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv:
return canonical
def test_automatic_package_upgrade_and_combined_rollback(self):
self.enable_report()
canonical=self.configure_upgrade()
original=canonical.read_bytes()
package=self.build_package()
@@ -588,6 +591,10 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv:
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
self.assertIn('SUCCESS',(state/'result.txt').read_text())
message,body=self.read_report()
self.assertEqual(str(message['From']),'app-backup@example.invalid')
self.assertIn('OK (Inhalt und Abschlussmarker',body)
self.assertEqual(Path('/usr/local/lib/app-backup/report.py').read_bytes(),(REPO/'lib/report.py').read_bytes())
self.assertTrue((state/'verified-backup-run').is_file())
self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text())
self.assertEqual(self.cnf.stat().st_mode & 0o777,0o600)
@@ -948,6 +955,101 @@ if '--no-block' not in args:
with self.config.open('a') as f: f.write(f'ENABLE_UPLOAD=true\nRCLONE_CONFIG="{path}"\nRCLONE_REMOTE_BASE="crypt:sets"\nRCLONE_RETRIES=1\nRCLONE_LOW_LEVEL_RETRIES=1\n')
return path
def enable_report(self, behaviour='capture'):
# This fake MTA captures stdin locally. Tests never send any real mail.
path=self.bin/'capture-sendmail'
path.write_text(f'''#!/usr/bin/env python3
import pathlib, sys, time
if {behaviour!r} == 'timeout': time.sleep(10)
pathlib.Path({str(self.base/'mail.eml')!r}).write_bytes(sys.stdin.buffer.read())
pathlib.Path({str(self.base/'mail-args')!r}).write_text(' '.join(sys.argv[1:]))
sys.exit(42 if {behaviour!r} == 'fail' else 0)
''')
path.chmod(0o755)
with self.config.open('a') as f:
f.write(f'''ENABLE_MAIL_REPORT=true
MAIL_TO='Johannes <backup@example.invalid>'
MAIL_FROM='app-backup@example.invalid'
MAIL_SUBJECT_PREFIX='[Backup-Test]'
MAIL_INCLUDE_LOG_TAIL_LINES=200
MAIL_SENDMAIL_BIN='{path}'
MAIL_SEND_TIMEOUT=1
REPORT_SECRET='config-content-must-not-enter-the-mail'
''')
def read_report(self):
raw=(self.base/'mail.eml').read_bytes()
message=BytesParser(policy=policy.default).parsebytes(raw)
return message,message.get_content()
def test_report_success_has_sizes_after_cleanup_and_preserves_headers(self):
self.enable_report()
run=self.backup()
message,body=self.read_report()
self.assertIn('[Backup-Test] SUCCESS',str(message['Subject']))
self.assertEqual(str(message['From']),'app-backup@example.invalid')
self.assertIn('backup@example.invalid',str(message['To']))
for value in ['Status: SUCCESS','Exit code: 0','Dauer:', 'db staged:',
'wordpress staged:','Staging total:','wordpress.tar.zst:',
'Deaktiviert (nur lokal)',f'SUCCESS: {run.name}']:
self.assertIn(value,body)
self.assertNotIn('config-content-must-not-enter-the-mail',body)
self.assertFalse(list((self.base/'work/staging').iterdir()))
self.assertEqual((self.base/'mail-args').read_text(),'-oi -t')
saved=next((self.base/'logs').glob('report_*.eml'))
self.assertEqual(saved.read_bytes(),(self.base/'mail.eml').read_bytes())
self.assertEqual(saved.stat().st_mode & 0o777,0o600)
def test_report_failure_names_phase_and_has_final_error(self):
self.enable_report()
result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_TOOL='mariadb-dump')
message,body=self.read_report()
self.assertEqual(result.returncode,42)
self.assertIn('[Backup-Test] FAIL',str(message['Subject']))
self.assertIn('Exit code: 42',body)
self.assertIn('Letzte Phase: DB-Dump:',body)
self.assertIn('FAILED (exit 42)',body)
self.assertEqual(json.loads(self.state.read_text()),self.initial)
def test_report_upload_readback_failure_is_not_reported_as_ok(self):
self.enable_report()
self.crypt_config()
self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
_,body=self.read_report()
self.assertIn('Status: FAIL',body)
self.assertIn('Upload Status: Nicht abgeschlossen: Rückleseprüfung',body)
self.assertNotIn('Upload Status: OK',body)
def test_report_mta_failure_keeps_backup_result_and_local_message(self):
self.enable_report('fail')
result=self.run_script('app-backup.sh')
self.assertIn('WARNING: mail report failed',result.stdout)
self.assertEqual(json.loads((self.base/'work/last-backup.json').read_text())['exit_code'],0)
self.assertTrue(list((self.base/'logs').glob('report_*.eml')))
failed=self.run_script('app-backup.sh',ok=False,TEST_FAIL_TOOL='mariadb-dump')
self.assertEqual(failed.returncode,42)
self.assertIn('WARNING: mail report failed',failed.stdout)
def test_report_timeout_is_bounded_and_header_injection_is_rejected(self):
self.enable_report('timeout')
result=self.run_script('app-backup.sh')
self.assertIn('mail report failed (TimeoutExpired)',result.stdout)
self.assertFalse((self.base/'mail.eml').exists())
self.enable_report()
with self.config.open('a') as f: f.write("MAIL_TO=$'recipient\\nBcc: attacker@example.invalid'\n")
result=self.run_script('app-backup.sh')
self.assertIn('mail report failed (ValueError)',result.stdout)
self.assertFalse((self.base/'mail.eml').exists())
def test_report_check_and_restore_do_not_send_backup_messages(self):
self.enable_report()
self.run_script('app-backup.sh','--check')
self.assertFalse((self.base/'mail.eml').exists())
run=self.backup()
(self.base/'mail.eml').unlink()
self.run_script('app-restore.sh','--local-run',run,'--verify-only')
self.assertFalse((self.base/'mail.eml').exists())
def test_real_crypt_upload_readback_remote_restore(self):
self.crypt_config()
# Exercise periodic stderr statistics during a real throttled crypt