""" Tests for service-volume backup/restore in ConfigManager. Covers: - _backup_service_volumes: happy path, container not running, timeout - _restore_service_volumes: happy path, missing archive, unknown service - backup_config: passes service_registry, records includes_service_data - restore_config: passes service_registry on full restore, not on selective """ import json import subprocess import unittest from pathlib import Path from unittest.mock import MagicMock, patch, call import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) from config_manager import ConfigManager def _make_cm(tmp_path: Path) -> ConfigManager: cfg_file = tmp_path / 'cell_config.json' cfg_file.write_text('{}') cm = ConfigManager(config_file=str(cfg_file), data_dir=str(tmp_path)) return cm def _make_registry(plan=None): """Return a mock ServiceRegistry with a preset backup plan.""" reg = MagicMock() reg.get_backup_plan.return_value = plan if plan is not None else [ { 'service_id': 'email', 'volumes': [ {'container': 'cell-mail', 'path': '/var/mail', 'name': 'maildata'}, {'container': 'cell-mail', 'path': '/var/mail-state', 'name': 'mailstate'}, ], 'config_paths': [], }, { 'service_id': 'calendar', 'volumes': [ {'container': 'cell-radicale', 'path': '/data', 'name': 'radicale_data'}, ], 'config_paths': [], }, ] return reg class TestBackupServiceVolumesHappyPath(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.cm = _make_cm(self.tmp) self.backup_path = self.tmp / 'test_backup' self.backup_path.mkdir() def _run_backup(self, registry=None): if registry is None: registry = _make_registry() self.cm._backup_service_volumes(self.backup_path, registry) @patch('config_manager.subprocess.run') def test_creates_service_data_dir(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stderr=b'') self._run_backup() self.assertTrue((self.backup_path / 'service_data' / 'email').is_dir()) self.assertTrue((self.backup_path / 'service_data' / 'calendar').is_dir()) @patch('config_manager.subprocess.run') def test_calls_docker_exec_tar_for_each_volume(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stderr=b'') self._run_backup() commands = [tuple(c.args[0]) for c in mock_run.call_args_list] self.assertIn( ('docker', 'exec', '--', 'cell-mail', 'tar', '-C', '/var/mail', '-czf', '-', '.'), commands, ) self.assertIn( ('docker', 'exec', '--', 'cell-mail', 'tar', '-C', '/var/mail-state', '-czf', '-', '.'), commands, ) self.assertIn( ('docker', 'exec', '--', 'cell-radicale', 'tar', '-C', '/data', '-czf', '-', '.'), commands, ) @patch('config_manager.subprocess.run') def test_writes_archive_files(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stderr=b'') self._run_backup() self.assertTrue((self.backup_path / 'service_data' / 'email' / 'maildata.tar.gz').exists()) self.assertTrue((self.backup_path / 'service_data' / 'email' / 'mailstate.tar.gz').exists()) self.assertTrue((self.backup_path / 'service_data' / 'calendar' / 'radicale_data.tar.gz').exists()) @patch('config_manager.subprocess.run') def test_removes_archive_on_nonzero_returncode(self, mock_run): mock_run.return_value = MagicMock(returncode=1, stderr=b'container not running') self._run_backup() self.assertFalse( (self.backup_path / 'service_data' / 'email' / 'maildata.tar.gz').exists() ) @patch('config_manager.subprocess.run') def test_continues_after_one_volume_fails(self, mock_run): def side_effect(cmd, **kwargs): if 'cell-mail' in cmd: return MagicMock(returncode=1, stderr=b'error') return MagicMock(returncode=0, stderr=b'') mock_run.side_effect = side_effect self._run_backup() # radicale should still succeed self.assertTrue( (self.backup_path / 'service_data' / 'calendar' / 'radicale_data.tar.gz').exists() ) @patch('config_manager.subprocess.run', side_effect=subprocess.TimeoutExpired('docker', 300)) def test_timeout_removes_partial_archive(self, _mock_run): self._run_backup() # no archive should remain after a timeout for svc in ('email', 'calendar'): for name in ('maildata', 'mailstate', 'radicale_data'): self.assertFalse( (self.backup_path / 'service_data' / svc / f'{name}.tar.gz').exists() ) @patch('config_manager.subprocess.run') def test_empty_volumes_list_skipped(self, mock_run): registry = _make_registry(plan=[ {'service_id': 'widget', 'volumes': [], 'config_paths': []} ]) self.cm._backup_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_get_backup_plan_exception_is_handled(self, mock_run): registry = MagicMock() registry.get_backup_plan.side_effect = RuntimeError('registry unavailable') # should not raise self.cm._backup_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_unsafe_container_name_rejected(self, mock_run): registry = _make_registry(plan=[{ 'service_id': 'evil', 'config_paths': [], 'volumes': [{'container': '-it cell-api', 'path': '/data', 'name': 'data'}], }]) self.cm._backup_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_path_traversal_in_volume_path_rejected(self, mock_run): registry = _make_registry(plan=[{ 'service_id': 'evil', 'config_paths': [], 'volumes': [{'container': 'cell-mail', 'path': '/../etc', 'name': 'etc'}], }]) self.cm._backup_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_relative_volume_path_rejected(self, mock_run): registry = _make_registry(plan=[{ 'service_id': 'evil', 'config_paths': [], 'volumes': [{'container': 'cell-mail', 'path': 'data/maildata', 'name': 'data'}], }]) self.cm._backup_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_unsafe_volume_name_rejected(self, mock_run): registry = _make_registry(plan=[{ 'service_id': 'evil', 'config_paths': [], 'volumes': [{'container': 'cell-mail', 'path': '/var/mail', 'name': '../../etc/passwd'}], }]) self.cm._backup_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_atomic_write_no_archive_on_partial_failure(self, mock_run): """If an exception occurs during subprocess, no .tar.gz file should remain.""" mock_run.side_effect = OSError('disk full') self._run_backup() for f in self.backup_path.rglob('*.tar.gz'): self.fail(f'Archive {f} should not exist after exception during backup') class TestRestoreServiceVolumes(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.cm = _make_cm(self.tmp) self.backup_path = self.tmp / 'test_backup' # Prepare a realistic backup structure svc_data = self.backup_path / 'service_data' (svc_data / 'email').mkdir(parents=True) (svc_data / 'email' / 'maildata.tar.gz').write_bytes(b'fake-archive') (svc_data / 'calendar').mkdir(parents=True) (svc_data / 'calendar' / 'radicale_data.tar.gz').write_bytes(b'fake-archive') def _make_registry_with_manifests(self): reg = MagicMock() def get_side_effect(service_id): manifests = { 'email': {'backup': {'volumes': [ {'container': 'cell-mail', 'path': '/var/mail', 'name': 'maildata'}, ]}}, 'calendar': {'backup': {'volumes': [ {'container': 'cell-radicale', 'path': '/data', 'name': 'radicale_data'}, ]}}, } return manifests.get(service_id) reg.get.side_effect = get_side_effect return reg @patch('config_manager.subprocess.run') def test_calls_docker_exec_tar_for_each_archive(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stderr=b'') registry = self._make_registry_with_manifests() self.cm._restore_service_volumes(self.backup_path, registry) commands = [tuple(c.args[0]) for c in mock_run.call_args_list] self.assertIn( ('docker', 'exec', '-i', '--', 'cell-mail', 'tar', '-C', '/var/mail', '-xzf', '-'), commands, ) self.assertIn( ('docker', 'exec', '-i', '--', 'cell-radicale', 'tar', '-C', '/data', '-xzf', '-'), commands, ) @patch('config_manager.subprocess.run') def test_skips_missing_archive(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stderr=b'') registry = MagicMock() registry.get.return_value = {'backup': {'volumes': [ {'container': 'cell-mail', 'path': '/var/mail', 'name': 'no_such_archive'}, ]}} self.cm._restore_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_skips_unknown_service(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stderr=b'') registry = MagicMock() registry.get.return_value = None self.cm._restore_service_volumes(self.backup_path, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run') def test_no_service_data_dir_is_noop(self, mock_run): empty_backup = self.tmp / 'empty_backup' empty_backup.mkdir() registry = self._make_registry_with_manifests() self.cm._restore_service_volumes(empty_backup, registry) mock_run.assert_not_called() @patch('config_manager.subprocess.run', side_effect=subprocess.TimeoutExpired('docker', 300)) def test_timeout_is_handled_gracefully(self, _mock_run): registry = self._make_registry_with_manifests() # should not raise self.cm._restore_service_volumes(self.backup_path, registry) @patch('config_manager.subprocess.run') def test_continues_after_docker_exec_failure(self, mock_run): call_count = [0] def side_effect(cmd, **kwargs): call_count[0] += 1 if call_count[0] == 1: return MagicMock(returncode=1, stderr=b'container not running') return MagicMock(returncode=0, stderr=b'') mock_run.side_effect = side_effect registry = self._make_registry_with_manifests() self.cm._restore_service_volumes(self.backup_path, registry) self.assertEqual(call_count[0], 2) class TestBackupConfigWithRegistry(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.cm = _make_cm(self.tmp) @patch.object(ConfigManager, '_backup_service_volumes') def test_backup_calls_volume_backup_when_registry_given(self, mock_bsv): registry = _make_registry() self.cm.backup_config(service_registry=registry) mock_bsv.assert_called_once() args = mock_bsv.call_args self.assertIs(args[0][1], registry) @patch.object(ConfigManager, '_backup_service_volumes') def test_backup_skips_volume_backup_when_no_registry(self, mock_bsv): self.cm.backup_config(service_registry=None) mock_bsv.assert_not_called() @patch.object(ConfigManager, '_backup_service_volumes') def test_manifest_records_includes_service_data_true(self, _mock_bsv): registry = _make_registry() backup_id = self.cm.backup_config(service_registry=registry) manifest = json.loads((self.cm.backup_dir / backup_id / 'manifest.json').read_text()) self.assertTrue(manifest['includes_service_data']) @patch.object(ConfigManager, '_backup_service_volumes') def test_manifest_records_includes_service_data_false(self, _mock_bsv): backup_id = self.cm.backup_config(service_registry=None) manifest = json.loads((self.cm.backup_dir / backup_id / 'manifest.json').read_text()) self.assertFalse(manifest['includes_service_data']) class TestRestoreConfigWithRegistry(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) self.cm = _make_cm(self.tmp) # Create a minimal backup backup_id = 'backup_20260101_000000' bp = self.cm.backup_dir / backup_id bp.mkdir(parents=True) (bp / 'cell_config.json').write_text('{}') manifest = {'backup_id': backup_id, 'timestamp': '2026-01-01T00:00:00', 'services': []} (bp / 'manifest.json').write_text(json.dumps(manifest)) self.backup_id = backup_id @patch.object(ConfigManager, '_restore_service_volumes') def test_full_restore_calls_volume_restore_when_registry_given(self, mock_rsv): registry = _make_registry() self.cm.restore_config(self.backup_id, service_registry=registry) mock_rsv.assert_called_once() args = mock_rsv.call_args self.assertIs(args[0][1], registry) @patch.object(ConfigManager, '_restore_service_volumes') def test_full_restore_skips_volume_restore_when_no_registry(self, mock_rsv): self.cm.restore_config(self.backup_id, service_registry=None) mock_rsv.assert_not_called() @patch.object(ConfigManager, '_restore_service_volumes') def test_selective_restore_never_calls_volume_restore(self, mock_rsv): """Volume restore is skipped for selective restores (service list specified).""" registry = _make_registry() self.cm.restore_config(self.backup_id, services=['email'], service_registry=registry) mock_rsv.assert_not_called() if __name__ == '__main__': unittest.main()