#!/usr/bin/env python3 """ Unit tests for config backup / restore / export / import HTTP routes. These tests exercise the Flask layer in api/app.py only. The ConfigManager is mocked throughout. Endpoints under test: POST /api/config/backup GET /api/config/backups POST /api/config/restore/ GET /api/config/export POST /api/config/import DELETE /api/config/backups/ GET /api/config/backups//download POST /api/config/backup/upload """ import sys import io import json import zipfile import tempfile import shutil import unittest from pathlib import Path from unittest.mock import patch, MagicMock, PropertyMock api_dir = Path(__file__).parent.parent / 'api' sys.path.insert(0, str(api_dir)) from app import app import backup_crypto import tarfile class TestCreateConfigBackup(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() @patch('app.config_manager') def test_backup_returns_200_with_backup_id(self, mock_cm): mock_cm.backup_config.return_value = 'backup_20260424_120000' r = self.client.post('/api/config/backup') self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIn('backup_id', data) self.assertEqual(data['backup_id'], 'backup_20260424_120000') @patch('app.config_manager') def test_backup_returns_500_on_exception(self, mock_cm): mock_cm.backup_config.side_effect = Exception('disk full') r = self.client.post('/api/config/backup') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) class TestListConfigBackups(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() @patch('app.config_manager') def test_list_backups_returns_200_with_list(self, mock_cm): mock_cm.list_backups.return_value = [ {'backup_id': 'backup_001', 'timestamp': '2026-04-24T12:00:00'}, {'backup_id': 'backup_002', 'timestamp': '2026-04-23T08:00:00'}, ] r = self.client.get('/api/config/backups') self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIsInstance(data, list) self.assertEqual(len(data), 2) @patch('app.config_manager') def test_list_backups_returns_500_on_exception(self, mock_cm): mock_cm.list_backups.side_effect = Exception('directory error') r = self.client.get('/api/config/backups') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) class TestRestoreConfigBackup(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() @patch('app.config_manager') def test_restore_returns_200_on_success(self, mock_cm): mock_cm.restore_config.return_value = True r = self.client.post('/api/config/restore/backup_001') self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIn('message', data) @patch('app.config_manager') def test_restore_returns_500_when_manager_returns_false(self, mock_cm): mock_cm.restore_config.return_value = False r = self.client.post('/api/config/restore/backup_missing') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) @patch('app.config_manager') def test_restore_returns_500_on_exception(self, mock_cm): mock_cm.restore_config.side_effect = Exception('corrupt backup') r = self.client.post('/api/config/restore/backup_bad') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) @patch('app.config_manager') def test_restore_passes_services_list_to_manager(self, mock_cm): mock_cm.restore_config.return_value = True payload = {'services': ['network', 'wireguard']} self.client.post( '/api/config/restore/backup_001', data=json.dumps(payload), content_type='application/json', ) mock_cm.restore_config.assert_called_once_with( 'backup_001', services=['network', 'wireguard'], service_registry=None, passphrase=None, ) @patch('app.config_manager') def test_restore_passes_none_services_when_no_body(self, mock_cm): from unittest.mock import ANY mock_cm.restore_config.return_value = True self.client.post('/api/config/restore/backup_001') mock_cm.restore_config.assert_called_once_with( 'backup_001', services=None, service_registry=ANY, passphrase=None ) class TestExportConfig(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() @patch('app.config_manager') def test_export_returns_200_with_config_and_format(self, mock_cm): mock_cm.export_config.return_value = '{"cell_name": "mycell"}' r = self.client.get('/api/config/export') self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIn('config', data) self.assertIn('format', data) @patch('app.config_manager') def test_export_uses_json_format_by_default(self, mock_cm): mock_cm.export_config.return_value = '{}' self.client.get('/api/config/export') mock_cm.export_config.assert_called_once_with('json') @patch('app.config_manager') def test_export_passes_format_query_param(self, mock_cm): mock_cm.export_config.return_value = 'yaml: data' self.client.get('/api/config/export?format=yaml') mock_cm.export_config.assert_called_once_with('yaml') @patch('app.config_manager') def test_export_returns_500_on_exception(self, mock_cm): mock_cm.export_config.side_effect = Exception('serialisation error') r = self.client.get('/api/config/export') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) class TestImportConfig(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() @patch('app.config_manager') def test_import_returns_200_on_success(self, mock_cm): mock_cm.import_config.return_value = True r = self.client.post( '/api/config/import', data=json.dumps({'config': '{"cell_name": "mycell"}', 'format': 'json'}), content_type='application/json', ) self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIn('message', data) @patch('app.config_manager') def test_import_returns_400_when_no_body(self, mock_cm): r = self.client.post('/api/config/import') self.assertEqual(r.status_code, 400) self.assertIn('error', json.loads(r.data)) @patch('app.config_manager') def test_import_returns_500_when_manager_returns_false(self, mock_cm): mock_cm.import_config.return_value = False r = self.client.post( '/api/config/import', data=json.dumps({'config': 'bad data'}), content_type='application/json', ) self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) @patch('app.config_manager') def test_import_returns_500_on_exception(self, mock_cm): mock_cm.import_config.side_effect = Exception('parse error') r = self.client.post( '/api/config/import', data=json.dumps({'config': 'something'}), content_type='application/json', ) self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) class TestDeleteConfigBackup(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() @patch('app.config_manager') def test_delete_backup_returns_200_on_success(self, mock_cm): mock_cm.delete_backup.return_value = True r = self.client.delete('/api/config/backups/backup_001') self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIn('message', data) @patch('app.config_manager') def test_delete_backup_returns_500_when_manager_returns_false(self, mock_cm): mock_cm.delete_backup.return_value = False r = self.client.delete('/api/config/backups/backup_missing') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) @patch('app.config_manager') def test_delete_backup_returns_500_on_exception(self, mock_cm): mock_cm.delete_backup.side_effect = Exception('io error') r = self.client.delete('/api/config/backups/backup_001') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) class TestDownloadBackup(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() # Create a real temporary backup directory with a manifest so the route # can read it and serve a zip file. self.tmp = tempfile.mkdtemp() self.backup_id = 'backup_test_dl' def tearDown(self): shutil.rmtree(self.tmp, ignore_errors=True) def _make_backup_dir(self, backup_id): """Create a minimal backup directory with manifest.json.""" backup_path = Path(self.tmp) / backup_id backup_path.mkdir(parents=True) (backup_path / 'manifest.json').write_text(json.dumps({'backup_id': backup_id})) (backup_path / 'config.json').write_text('{}') return backup_path @patch('app.config_manager') def test_download_backup_returns_zip_content_type(self, mock_cm): backup_path = self._make_backup_dir(self.backup_id) mock_cm.backup_dir = Path(self.tmp) r = self.client.get(f'/api/config/backups/{self.backup_id}/download') self.assertEqual(r.status_code, 200) self.assertIn('application/zip', r.content_type) @patch('app.config_manager') def test_download_backup_returns_404_when_not_found(self, mock_cm): mock_cm.backup_dir = Path(self.tmp) r = self.client.get('/api/config/backups/nonexistent_backup/download') self.assertEqual(r.status_code, 404) class TestUploadBackup(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() self.tmp = tempfile.mkdtemp() def tearDown(self): shutil.rmtree(self.tmp, ignore_errors=True) def _make_valid_zip(self): """Return BytesIO containing a valid zip with manifest.json.""" buf = io.BytesIO() with zipfile.ZipFile(buf, 'w') as zf: zf.writestr('manifest.json', json.dumps({'backup_id': 'upload_test'})) zf.writestr('config.json', '{}') buf.seek(0) return buf @patch('app.config_manager') def test_upload_returns_400_when_no_file(self, mock_cm): r = self.client.post('/api/config/backup/upload') self.assertEqual(r.status_code, 400) self.assertIn('error', json.loads(r.data)) @patch('app.config_manager') def test_upload_returns_200_on_valid_zip(self, mock_cm): backup_dir = Path(self.tmp) mock_cm.backup_dir = backup_dir zip_data = self._make_valid_zip() r = self.client.post( '/api/config/backup/upload', data={'file': (zip_data, 'mybackup.zip')}, content_type='multipart/form-data', ) self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertIn('backup_id', data) @patch('app.config_manager') def test_upload_returns_400_on_invalid_zip(self, mock_cm): backup_dir = Path(self.tmp) mock_cm.backup_dir = backup_dir r = self.client.post( '/api/config/backup/upload', data={'file': (io.BytesIO(b'this is not a zip'), 'bad.zip')}, content_type='multipart/form-data', ) self.assertEqual(r.status_code, 400) @patch('app.config_manager') def test_upload_returns_400_when_zip_missing_manifest(self, mock_cm): backup_dir = Path(self.tmp) mock_cm.backup_dir = backup_dir buf = io.BytesIO() with zipfile.ZipFile(buf, 'w') as zf: zf.writestr('config.json', '{}') # no manifest.json buf.seek(0) r = self.client.post( '/api/config/backup/upload', data={'file': (buf, 'nomanifest.zip')}, content_type='multipart/form-data', ) self.assertEqual(r.status_code, 400) @patch('app.config_manager') def test_upload_stores_encrypted_blob_verbatim(self, mock_cm): backup_dir = Path(self.tmp) mock_cm.backup_dir = backup_dir blob = backup_crypto.encrypt_bytes(b'payload-bytes', 'secret') self.assertTrue(blob.startswith(backup_crypto.MAGIC)) r = self.client.post( '/api/config/backup/upload', data={'file': (io.BytesIO(blob), 'backup_20260101_010101.tar.gz.age')}, content_type='multipart/form-data', ) self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertTrue(data['encrypted']) self.assertEqual(data['backup_id'], 'backup_20260101_010101') archive = backup_dir / 'backup_20260101_010101.tar.gz.age' self.assertTrue(archive.exists()) self.assertEqual(archive.read_bytes(), blob) @patch('app.config_manager') def test_upload_encrypted_then_restore_round_trip(self, mock_cm): # Build a real encrypted backup archive (tar.gz of a manifest, then # encrypted), upload it, then restore it through the real ConfigManager # decrypt/resolve path with the correct and an incorrect passphrase. from config_manager import ConfigManager backup_dir = Path(self.tmp) / 'backups' backup_dir.mkdir(parents=True, exist_ok=True) mock_cm.backup_dir = backup_dir tar_buf = io.BytesIO() with tarfile.open(fileobj=tar_buf, mode='w:gz') as tar: inner = json.dumps({'backup_id': 'rt', 'services': []}).encode() info = tarfile.TarInfo('manifest.json') info.size = len(inner) tar.addfile(info, io.BytesIO(inner)) blob = backup_crypto.encrypt_bytes(tar_buf.getvalue(), 'pw123') r = self.client.post( '/api/config/backup/upload', data={'file': (io.BytesIO(blob), 'rt.tar.gz.age')}, content_type='multipart/form-data', ) self.assertEqual(r.status_code, 200) backup_id = json.loads(r.data)['backup_id'] # Resolve+decrypt with the correct passphrase succeeds. real_cm = ConfigManager.__new__(ConfigManager) real_cm.backup_dir = backup_dir path, cleanup = real_cm._resolve_backup_dir(f'{backup_id}.tar.gz.age', 'pw123') self.assertTrue((path / 'manifest.json').exists()) shutil.rmtree(cleanup, ignore_errors=True) # Wrong passphrase raises PermissionError → route returns 400. with self.assertRaises(PermissionError): real_cm._resolve_backup_dir(f'{backup_id}.tar.gz.age', 'wrong') if __name__ == '__main__': unittest.main()