2f5370bd98
Unit Tests / test (push) Successful in 11m24s
These files were created during Steps 1-4 of the services architecture but were never staged: AccountManager (per-service credential provisioning), ServiceComposer (docker-compose lifecycle), built-in service manifests for email/calendar/files, and their test suites (158 tests). Also un-tracks .coverage binaries that were accidentally committed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""
|
|
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()
|