Files
pic/tests/test_backup_service_data.py
T
roof 2f5370bd98
Unit Tests / test (push) Successful in 11m24s
feat: add Steps 1-4 implementation files (AccountManager, ServiceComposer, builtins, tests)
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>
2026-05-29 04:39:19 -04:00

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()