import os from pathlib import Path import subprocess import tempfile import unittest SCRIPT=Path(__file__).with_name('pre-backup.sh') class BackupHookTests(unittest.TestCase): def exercise(self, succeeds): with tempfile.TemporaryDirectory(prefix='backup-hook-test-') as d: root=Path(d); stage=root/'stage'; stage.mkdir(); binpath=root/'bin';binpath.mkdir() previous=stage/'vaultwarden.pg_dump';previous.write_bytes(b'previous-good-backup') docker=binpath/'docker';docker.write_text('#!/bin/sh\n[ "$1" = inspect ] && [ "$2" = vaultwarden ]\n');docker.chmod(0o755) pg=binpath/'pg_dump';pg.write_text('#!/bin/sh\nprintf new-dump\nexit '+('0' if succeeds else '1')+'\n');pg.chmod(0o755) env=dict(os.environ,PATH=str(binpath)+':'+os.environ['PATH'],RESTIC_STAGE_DIR=str(stage),RESTIC_DB_CREDS_FILE=str(root/'absent'),VW_PGPASS='fake',VW_PGHOST='fake',VW_PGUSER='fake',VW_PGDB='fake') r=subprocess.run(['bash',str(SCRIPT)],env=env,capture_output=True,text=True) if succeeds: self.assertEqual(r.returncode,0,r.stdout+r.stderr) self.assertEqual(previous.read_bytes(),b'new-dump') else: self.assertNotEqual(r.returncode,0,r.stdout+r.stderr) self.assertEqual(previous.read_bytes(),b'previous-good-backup') self.assertIn('required database dump(s) failed',r.stdout) self.assertEqual(list(stage.glob('.pending.*')),[]) def test_failed_required_dump_preserves_previous_backup_and_cleans_scratch(self): self.exercise(False) def test_success_publishes_new_dump_and_cleans_scratch(self): self.exercise(True) def test_gitea_failure_removes_sql_scratch_and_exposes_error(self): with tempfile.TemporaryDirectory(prefix='backup-gitea-test-') as d: root=Path(d);stage=root/'stage';stage.mkdir();binpath=root/'bin';binpath.mkdir() previous=stage/'gitea-dump.tar';previous.write_bytes(b'previous-good-archive') docker=binpath/'docker' docker.write_text('#!/bin/sh\nif [ "$1" = inspect ]; then [ "$2" = gitea ]; exit $?; fi\nshift 4\nexec "$@"\n') docker.chmod(0o755) gitea=binpath/'gitea' gitea.write_text('#!/bin/sh\nwhile [ "$#" -gt 0 ]; do if [ "$1" = --tempdir ]; then shift; scratch=$1; fi; shift; done\nprintf %s "$scratch" > "$TEST_SCRATCH_PATH"\nprintf partial-sql > "$scratch/gitea-db.sql123"\necho simulated-export-failure >&2\nexit 9\n') gitea.chmod(0o755) path_record=root/'scratch-path' env=dict(os.environ,PATH=str(binpath)+':'+os.environ['PATH'],RESTIC_STAGE_DIR=str(stage),RESTIC_DB_CREDS_FILE=str(root/'absent'),TEST_SCRATCH_PATH=str(path_record)) r=subprocess.run(['bash',str(SCRIPT)],env=env,capture_output=True,text=True) self.assertNotEqual(r.returncode,0) self.assertIn('simulated-export-failure',r.stderr) self.assertFalse(Path(path_record.read_text()).exists()) self.assertEqual(previous.read_bytes(),b'previous-good-archive') self.assertEqual(list(stage.glob('.pending.*')),[]) def _fv(self, curl_body, curl_rc=0): """Run the hook with a stubbed curl for the fv-gateway pull. Returns a snapshot taken INSIDE the temp dir's lifetime. Returning the Path instead is a trap: TemporaryDirectory deletes the tree on exit, so every `exists()` assertion afterwards reads False and the negative tests pass no matter what the script did. Caught 2026-09-14 — the positive test failed, which is the only reason the broken negatives were noticed. The guard under test is subtle: the summary loop only rejects EMPTY staged files, and the OPNsense endpoint answers an auth failure with a perfectly non-empty HTML error page. Without the content check that page would be promoted and backed up as if it were a firewall config — a backup that exists, is a plausible size, and restores nothing. """ with tempfile.TemporaryDirectory(prefix='backup-fv-test-') as d: root=Path(d);stage=root/'stage';stage.mkdir();binpath=root/'bin';binpath.mkdir() docker=binpath/'docker';docker.write_text('#!/bin/sh\nexit 1\n');docker.chmod(0o755) curl=binpath/'curl' curl.write_text('#!/bin/sh\nout=""\nwhile [ "$#" -gt 0 ]; do [ "$1" = -o ] && { shift; out=$1; }; shift; done\n' 'printf %s "$FV_STUB_BODY" > "$out"\nexit '+str(curl_rc)+'\n') curl.chmod(0o755) creds=root/'fv.env';creds.write_text('FV_API_KEY=k\nFV_API_SECRET=s\n') env=dict(os.environ,PATH=str(binpath)+':'+os.environ['PATH'], RESTIC_STAGE_DIR=str(stage),RESTIC_DB_CREDS_FILE=str(root/'absent'), RESTIC_FV_CREDS_FILE=str(creds),FV_STUB_BODY=curl_body) r=subprocess.run(['bash',str(SCRIPT)],env=env,capture_output=True,text=True) cfg=stage/'fv-gateway-config.xml' return {'rc':r.returncode,'out':r.stdout,'err':r.stderr, 'staged':sorted(p.name for p in stage.iterdir()), 'body':cfg.read_text() if cfg.exists() else None} def test_fv_gateway_config_is_staged_when_body_is_a_real_config(self): g=self._fv('') self.assertEqual(g['rc'],0,g['out']+g['err']) self.assertIn('fv-gateway-config.xml',g['staged']) self.assertIn('',g['body']) def test_fv_gateway_html_error_page_is_never_staged(self): g=self._fv('401 Unauthorized') self.assertEqual(g['rc'],0,g['out']+g['err']) # non-fatal by design self.assertEqual(g['staged'],[]) self.assertIsNone(g['body']) self.assertIn('not an OPNsense config',g['err']) def test_fv_gateway_unreachable_is_non_fatal_and_stages_nothing(self): g=self._fv('',curl_rc=7) self.assertEqual(g['rc'],0,g['out']+g['err']) self.assertEqual(g['staged'],[]) self.assertIn('config pull failed',g['err']) if __name__=='__main__':unittest.main()