8d904b1b8f
Unit Tests / test (push) Successful in 13m7s
Three independent bugs surfaced during pic1 clean-install testing: 1. Tor _exit_status hardcoded configured=True regardless of whether Tor was actually installed. Status now flows through the same store-installed / container-running bridge used by every other optional service, so Tor only reports installed when the container is present and running. 2. check_port_open compared the port from wg0.conf against the kernel-reported listening port, causing false "port closed" results whenever the conf and the running container were momentarily out of sync. The function is now an honest liveness check: any wg0 interface that is up and has a "listening port:" line in `wg show` is considered open. The check-port API endpoint now also returns the actual kernel listening_port and a port_mismatch flag so the UI can inform the user when a container recreate is needed. (The recreate machinery already exists via the port-change pending-restart path; this fix makes the mismatch visible rather than silently lying about reachability.) 3. upload_backup only handled .zip archives; encrypted .age blobs were rejected with a generic error. The endpoint now calls backup_crypto.is_encrypted() to detect Age-encrypted blobs and stores them verbatim as <id>.tar.gz.age with mode 0600 so they can be uploaded and then restored with a passphrase. The plaintext zip path is unchanged. Tests added/updated: test_connectivity_manager.py (Tor status bridge), test_wireguard_manager.py + test_wireguard_endpoints.py (port-check liveness and mismatch flag), test_config_backup_restore_http.py (encrypted upload round-trip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
410 lines
15 KiB
Python
410 lines
15 KiB
Python
#!/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/<id>
|
|
GET /api/config/export
|
|
POST /api/config/import
|
|
DELETE /api/config/backups/<id>
|
|
GET /api/config/backups/<id>/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()
|