Restore detailed backup mail reports with bounded local delivery
This commit is contained in:
1 parent
3374019ad6
commit
99da6460d2
10 files changed
+396
-13
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user