feat: add Steps 1-4 implementation files (AccountManager, ServiceComposer, builtins, tests)
Unit Tests / test (push) Successful in 11m24s
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>
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user