add security fixes, port hardening, and expanded QA coverage

Security fixes:
- Replace debug=True with env-driven FLASK_DEBUG in app.py
- Add _safe_path helper and path-traversal protection to all 6 file routes
  in file_manager.py
- Add peer_name regex and input validation (public_key, name, endpoint_ip)
  in wireguard_manager.py
- Stop returning private key from GET /api/wireguard/keys; return only
  public_key + has_private_key boolean
- Fix is_local_request() XFF bypass by checking remote_addr only, ignoring
  X-Forwarded-For
- Remove duplicate get_all_configs / get_config_summary methods from
  config_manager.py

DevOps:
- Bind 6 internal service ports to 127.0.0.1 in docker-compose.yml
  (radicale, webdav, api, webui, rainloop, filegator)
- Move WebDAV credentials to env vars (WEBDAV_USER, WEBDAV_PASS)
- Pin flask, flask-cors, requests, cryptography, docker to secure minimum
  versions in requirements.txt

QA (560 tests, 0 failures):
- tests/test_wireguard_endpoints.py: 18 new endpoint tests
- tests/test_file_endpoints.py: 24 new endpoint tests incl. path traversal
- tests/test_container_manager.py: expanded from 2 to 30 tests
- tests/test_config_backup_restore_http.py: 25 new tests (new file)
- tests/test_config_apply.py: 9 new tests (new file)

Docs:
- Rewrite README.md with accurate architecture, ports, env vars, security notes
- Rewrite QUICKSTART.md with verified commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:08:24 -04:00
parent eb817ffdc5
commit a338836bb8
13 changed files with 1861 additions and 681 deletions
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Tests for POST /api/config/apply.
The route reads _pending_restart from config_manager, spawns a background
thread/process, clears the pending flag, and returns 200.
We mock subprocess.Popen / subprocess.run and docker.from_env so the tests
run without Docker, and we capture what command-line arguments would be used.
"""
import sys
import json
import threading
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock, call
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from app import app, _set_pending_restart, _clear_pending_restart, config_manager
class TestConfigApplyRoute(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
_clear_pending_restart()
def tearDown(self):
_clear_pending_restart()
# ── No pending changes ─────────────────────────────────────────────────
def test_apply_with_no_pending_returns_200(self):
r = self.client.post('/api/config/apply')
self.assertEqual(r.status_code, 200)
def test_apply_with_no_pending_returns_no_changes_message(self):
r = self.client.post('/api/config/apply')
data = json.loads(r.data)
self.assertIn('message', data)
self.assertIn('No pending', data['message'])
# ── Pending changes present ────────────────────────────────────────────
@patch('subprocess.Popen')
@patch('docker.from_env')
def test_apply_with_pending_returns_200(self, mock_docker, mock_popen):
mock_docker.side_effect = Exception('no docker in test')
mock_popen.return_value = MagicMock()
_set_pending_restart(['dns_port: 53 → 5353'], ['*'])
r = self.client.post('/api/config/apply')
self.assertEqual(r.status_code, 200)
@patch('subprocess.Popen')
@patch('docker.from_env')
def test_apply_with_pending_returns_restart_in_progress(self, mock_docker, mock_popen):
mock_docker.side_effect = Exception('no docker in test')
mock_popen.return_value = MagicMock()
_set_pending_restart(['something changed'], ['*'])
r = self.client.post('/api/config/apply')
data = json.loads(r.data)
self.assertTrue(data.get('restart_in_progress'))
# ── Pending state cleared after apply ──────────────────────────────────
@patch('threading.Thread')
@patch('docker.from_env')
def test_apply_clears_pending_state(self, mock_docker, mock_thread):
mock_docker.side_effect = Exception('no docker in test')
# Don't actually start the thread so we don't need subprocess
mock_thread.return_value = MagicMock()
_set_pending_restart(['config changed'], ['*'])
self.client.post('/api/config/apply')
pending = config_manager.configs.get('_pending_restart', {})
self.assertFalse(pending.get('needs_restart', False))
# ── needs_network_recreate=True → helper script includes 'down' ────────
@patch('subprocess.Popen')
@patch('docker.from_env')
def test_apply_network_recreate_spawns_popen_with_down_command(
self, mock_docker, mock_popen):
mock_docker.side_effect = Exception('no docker in test')
mock_popen.return_value = MagicMock()
# Set up a wildcard pending change that also requires network recreation
_set_pending_restart(['ip_range changed'], ['*'])
config_manager.configs['_pending_restart']['network_recreate'] = True
r = self.client.post('/api/config/apply')
self.assertEqual(r.status_code, 200)
# Wait for background thread to call Popen
import time
for _ in range(20):
if mock_popen.called:
break
time.sleep(0.1)
self.assertTrue(mock_popen.called,
'Expected subprocess.Popen to be called for wildcard restart')
args, kwargs = mock_popen.call_args
cmd = args[0]
# cmd is the full docker run ... sh -c 'script'
script_arg = cmd[-1] # the -c argument
self.assertIn('down', script_arg,
f'Expected "down" in helper script when network_recreate=True, got: {script_arg}')
# ── needs_network_recreate=False → helper script uses only 'up -d' ─────
@patch('subprocess.Popen')
@patch('docker.from_env')
def test_apply_no_network_recreate_spawns_popen_without_down(
self, mock_docker, mock_popen):
mock_docker.side_effect = Exception('no docker in test')
mock_popen.return_value = MagicMock()
_set_pending_restart(['port changed'], ['*'])
# network_recreate defaults to False
self.client.post('/api/config/apply')
import time
for _ in range(20):
if mock_popen.called:
break
time.sleep(0.1)
self.assertTrue(mock_popen.called)
args, _ = mock_popen.call_args
script_arg = args[0][-1]
self.assertNotIn(' down', script_arg,
f'Did not expect "down" in helper script when network_recreate=False')
self.assertIn('up -d', script_arg)
# ── Specific containers (not wildcard) ─────────────────────────────────
@patch('subprocess.run')
@patch('docker.from_env')
def test_apply_specific_containers_uses_subprocess_run(
self, mock_docker, mock_run):
mock_docker.side_effect = Exception('no docker in test')
mock_run.return_value = MagicMock(returncode=0, stderr='')
_set_pending_restart(['dns port changed'], ['dns'])
r = self.client.post('/api/config/apply')
self.assertEqual(r.status_code, 200)
# Give the daemon thread a moment to call subprocess.run
import time
for _ in range(30):
# Look for the compose call specifically (may not be the last call)
compose_calls = [
c for c in mock_run.call_args_list
if 'compose' in (c.args[0] if c.args else [])
]
if compose_calls:
break
time.sleep(0.1)
compose_calls = [
c for c in mock_run.call_args_list
if c.args and 'compose' in c.args[0]
]
self.assertTrue(
len(compose_calls) > 0,
f'Expected a subprocess.run call containing "compose"; got calls: {mock_run.call_args_list}'
)
cmd = compose_calls[-1].args[0]
self.assertIn('up', cmd)
self.assertIn('-d', cmd)
self.assertIn('dns', cmd)
# ── Exception in route body returns 500 ───────────────────────────────
@patch('app.config_manager')
def test_apply_returns_500_on_unexpected_exception(self, mock_cm):
mock_cm.configs = MagicMock()
mock_cm.configs.get.side_effect = Exception('unexpected failure')
r = self.client.post('/api/config/apply')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
if __name__ == '__main__':
unittest.main()
+346
View File
@@ -0,0 +1,346 @@
#!/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
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']
)
@patch('app.config_manager')
def test_restore_passes_none_services_when_no_body(self, mock_cm):
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)
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)
if __name__ == '__main__':
unittest.main()
+323 -49
View File
@@ -1,49 +1,323 @@
import sys
from pathlib import Path
# Add api directory to path
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
import unittest
from unittest.mock import patch, MagicMock
from container_manager import ContainerManager
class TestContainerManager(unittest.TestCase):
@patch('docker.from_env')
def test_list_containers(self, mock_from_env):
mock_client = MagicMock()
mock_container = MagicMock()
mock_container.id = 'abc'
mock_container.name = 'test'
mock_container.status = 'running'
mock_container.image.tags = ['img']
mock_container.labels = {}
mock_client.containers.list.return_value = [mock_container]
mock_from_env.return_value = mock_client
mgr = ContainerManager()
result = mgr.list_containers()
self.assertEqual(result[0]['name'], 'test')
@patch('docker.from_env')
def test_start_stop_restart_container(self, mock_from_env):
mock_client = MagicMock()
mock_container = MagicMock()
mock_client.containers.get.return_value = mock_container
mock_from_env.return_value = mock_client
mgr = ContainerManager()
# Start
self.assertTrue(mgr.start_container('test'))
mock_container.start.assert_called_once()
# Stop
self.assertTrue(mgr.stop_container('test'))
mock_container.stop.assert_called_once()
# Restart
self.assertTrue(mgr.restart_container('test'))
mock_container.restart.assert_called_once()
# Exception cases
mock_client.containers.get.side_effect = Exception('fail')
self.assertFalse(mgr.start_container('bad'))
self.assertFalse(mgr.stop_container('bad'))
self.assertFalse(mgr.restart_container('bad'))
if __name__ == '__main__':
unittest.main()
#!/usr/bin/env python3
"""
Unit tests for ContainerManager (api/container_manager.py).
"""
import sys
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 container_manager import ContainerManager
# ---------------------------------------------------------------------------
# Helper to build a ContainerManager with a pre-wired mock Docker client
# ---------------------------------------------------------------------------
def _make_manager(mock_from_env):
"""Return a ContainerManager whose Docker client is mock_from_env's return."""
mock_client = MagicMock()
mock_from_env.return_value = mock_client
return ContainerManager(), mock_client
class TestListContainers(unittest.TestCase):
@patch('docker.from_env')
def test_list_containers(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_container.id = 'abc'
mock_container.name = 'test'
mock_container.status = 'running'
mock_container.image.tags = ['img']
mock_container.labels = {}
mock_client.containers.list.return_value = [mock_container]
result = mgr.list_containers()
self.assertEqual(result[0]['name'], 'test')
class TestStartStopRestart(unittest.TestCase):
@patch('docker.from_env')
def test_start_stop_restart_container(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_client.containers.get.return_value = mock_container
# Start
self.assertTrue(mgr.start_container('test'))
mock_container.start.assert_called_once()
# Stop
self.assertTrue(mgr.stop_container('test'))
mock_container.stop.assert_called_once()
# Restart
self.assertTrue(mgr.restart_container('test'))
mock_container.restart.assert_called_once()
# Exception cases
mock_client.containers.get.side_effect = Exception('fail')
self.assertFalse(mgr.start_container('bad'))
self.assertFalse(mgr.stop_container('bad'))
self.assertFalse(mgr.restart_container('bad'))
class TestGetContainerLogs(unittest.TestCase):
@patch('docker.from_env')
def test_get_container_logs_returns_string(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_container.logs.return_value = b'log line 1\nlog line 2\n'
mock_client.containers.get.return_value = mock_container
result = mgr.get_container_logs('mycontainer')
self.assertIsInstance(result, str)
self.assertIn('log line 1', result)
@patch('docker.from_env')
def test_get_container_logs_uses_tail_parameter(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_container.logs.return_value = b''
mock_client.containers.get.return_value = mock_container
mgr.get_container_logs('mycontainer', tail=50)
mock_container.logs.assert_called_once_with(tail=50)
@patch('docker.from_env')
def test_get_container_logs_raises_when_docker_unavailable(self, mock_from_env):
mock_from_env.side_effect = Exception('docker not found')
with self.assertRaises(Exception):
mgr = ContainerManager()
mgr.get_container_logs('test')
class TestGetContainerStats(unittest.TestCase):
@patch('docker.from_env')
def test_get_container_stats_returns_dict(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_container.stats.return_value = {
'cpu_stats': {'cpu_usage': {'total_usage': 123}},
'memory_stats': {'usage': 4096},
}
mock_client.containers.get.return_value = mock_container
result = mgr.get_container_stats('mycontainer')
self.assertIsInstance(result, dict)
self.assertIn('cpu_stats', result)
self.assertIn('memory_stats', result)
@patch('docker.from_env')
def test_get_container_stats_returns_error_dict_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.containers.get.side_effect = Exception('not found')
result = mgr.get_container_stats('nonexistent')
self.assertIsInstance(result, dict)
self.assertIn('error', result)
class TestCreateContainer(unittest.TestCase):
@patch('docker.from_env')
def test_create_container_returns_id_and_name(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_container.id = 'cid123'
mock_container.name = 'myapp'
mock_client.containers.create.return_value = mock_container
result = mgr.create_container(
image='nginx:latest',
name='myapp',
env={'ENV_VAR': 'value'},
volumes={'/host/path': '/container/path'},
command='nginx -g "daemon off;"',
ports={'80/tcp': 8080},
)
self.assertEqual(result['id'], 'cid123')
self.assertEqual(result['name'], 'myapp')
@patch('docker.from_env')
def test_create_container_returns_error_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.containers.create.side_effect = Exception('image not found')
result = mgr.create_container(image='nonexistent:latest', name='test')
self.assertIn('error', result)
@patch('docker.from_env')
def test_create_container_passes_env_to_docker(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_container.id = 'x'
mock_container.name = 'y'
mock_client.containers.create.return_value = mock_container
mgr.create_container(image='alpine', name='test', env={'KEY': 'VAL'})
_, kwargs = mock_client.containers.create.call_args
self.assertEqual(kwargs['environment'], {'KEY': 'VAL'})
class TestRemoveContainer(unittest.TestCase):
@patch('docker.from_env')
def test_remove_container_returns_true_on_success(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_container = MagicMock()
mock_client.containers.get.return_value = mock_container
result = mgr.remove_container('mycontainer')
self.assertTrue(result)
mock_container.remove.assert_called_once()
@patch('docker.from_env')
def test_remove_container_returns_false_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.containers.get.side_effect = Exception('not found')
result = mgr.remove_container('ghost')
self.assertFalse(result)
class TestPullImage(unittest.TestCase):
@patch('docker.from_env')
def test_pull_image_returns_id_and_tags(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_image = MagicMock()
mock_image.id = 'sha256:abc'
mock_image.tags = ['nginx:latest']
mock_client.images.pull.return_value = mock_image
result = mgr.pull_image('nginx:latest')
self.assertEqual(result['id'], 'sha256:abc')
self.assertEqual(result['tags'], ['nginx:latest'])
@patch('docker.from_env')
def test_pull_image_returns_error_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.images.pull.side_effect = Exception('pull access denied')
result = mgr.pull_image('private/image:latest')
self.assertIn('error', result)
class TestRemoveImage(unittest.TestCase):
@patch('docker.from_env')
def test_remove_image_returns_true_on_success(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
result = mgr.remove_image('nginx:latest')
self.assertTrue(result)
mock_client.images.remove.assert_called_once_with(image='nginx:latest', force=False)
@patch('docker.from_env')
def test_remove_image_returns_false_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.images.remove.side_effect = Exception('image in use')
result = mgr.remove_image('nginx:latest')
self.assertFalse(result)
class TestCreateVolume(unittest.TestCase):
@patch('docker.from_env')
def test_create_volume_returns_name_and_mountpoint(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_vol = MagicMock()
mock_vol.name = 'myvolume'
mock_vol.attrs = {'Mountpoint': '/var/lib/docker/volumes/myvolume/_data'}
mock_client.volumes.create.return_value = mock_vol
result = mgr.create_volume('myvolume')
self.assertEqual(result['name'], 'myvolume')
self.assertIn('mountpoint', result)
self.assertIn('myvolume', result['mountpoint'])
@patch('docker.from_env')
def test_create_volume_returns_error_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.volumes.create.side_effect = Exception('no space left')
result = mgr.create_volume('bigvolume')
self.assertIn('error', result)
class TestRemoveVolume(unittest.TestCase):
@patch('docker.from_env')
def test_remove_volume_returns_true_on_success(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_vol = MagicMock()
mock_client.volumes.get.return_value = mock_vol
result = mgr.remove_volume('myvolume')
self.assertTrue(result)
mock_vol.remove.assert_called_once()
@patch('docker.from_env')
def test_remove_volume_returns_false_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.volumes.get.side_effect = Exception('volume not found')
result = mgr.remove_volume('ghostvolume')
self.assertFalse(result)
class TestListImages(unittest.TestCase):
@patch('docker.from_env')
def test_list_images_returns_list(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_img = MagicMock()
mock_img.id = 'sha256:abc'
mock_img.tags = ['nginx:latest']
mock_img.short_id = 'abc123'
mock_client.images.list.return_value = [mock_img]
result = mgr.list_images()
self.assertIsInstance(result, list)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['id'], 'sha256:abc')
@patch('docker.from_env')
def test_list_images_returns_empty_list_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.images.list.side_effect = Exception('daemon unreachable')
result = mgr.list_images()
self.assertEqual(result, [])
class TestListVolumes(unittest.TestCase):
@patch('docker.from_env')
def test_list_volumes_returns_list(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_vol = MagicMock()
mock_vol.name = 'vol1'
mock_vol.attrs = {'Mountpoint': '/mnt/vol1'}
mock_client.volumes.list.return_value = [mock_vol]
result = mgr.list_volumes()
self.assertIsInstance(result, list)
self.assertEqual(result[0]['name'], 'vol1')
self.assertEqual(result[0]['mountpoint'], '/mnt/vol1')
@patch('docker.from_env')
def test_list_volumes_returns_empty_list_on_exception(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.volumes.list.side_effect = Exception('daemon unreachable')
result = mgr.list_volumes()
self.assertEqual(result, [])
class TestGetStatusWhenDockerUnavailable(unittest.TestCase):
@patch('docker.from_env')
def test_get_status_offline_when_docker_init_fails(self, mock_from_env):
"""ContainerManager.get_status() returns {running: False, status: 'offline'}
when Docker client could not be initialised."""
mock_from_env.side_effect = Exception('Cannot connect to Docker daemon')
mgr = ContainerManager()
self.assertFalse(mgr.docker_available)
status = mgr.get_status()
self.assertFalse(status['running'])
self.assertEqual(status['status'], 'offline')
@patch('docker.from_env')
def test_get_status_online_when_docker_available(self, mock_from_env):
mgr, mock_client = _make_manager(mock_from_env)
mock_client.containers.list.return_value = []
mock_client.images.list.return_value = []
mock_client.volumes.list.return_value = []
mock_client.info.return_value = {
'ServerVersion': '24.0.0',
'Containers': 0,
'Images': 0,
'Driver': 'overlay2',
'KernelVersion': '6.1.0',
'OperatingSystem': 'Linux',
}
status = mgr.get_status()
self.assertTrue(status['running'])
self.assertEqual(status['status'], 'online')
if __name__ == '__main__':
unittest.main()
+300 -1
View File
@@ -1 +1,300 @@
# ... moved and adapted code from test_phase3_endpoints.py (file section) ...
#!/usr/bin/env python3
"""
Unit tests for file-storage Flask endpoints in api/app.py.
Covers routes that were not already tested in test_api_endpoints.py:
GET /api/files/users
POST /api/files/users (valid + bad input)
DELETE /api/files/folders/<username>/<path> (including path traversal)
GET /api/files/list/<username>
GET /api/files/download/<username>/<path>
DELETE /api/files/delete/<username>/<path>
POST /api/files/folders
POST /api/files/upload/<username>
"""
import sys
import io
import json
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from app import app
class TestFileUsersEndpoints(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── GET /api/files/users ────────────────────────────────────────────────
@patch('app.file_manager')
def test_get_users_returns_200_with_list(self, mock_fm):
mock_fm.get_users.return_value = [
{'username': 'alice', 'storage_info': {'total_files': 3, 'total_size_bytes': 1024}},
]
r = self.client.get('/api/files/users')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIsInstance(data, list)
self.assertEqual(data[0]['username'], 'alice')
@patch('app.file_manager')
def test_get_users_returns_empty_list_when_no_users(self, mock_fm):
mock_fm.get_users.return_value = []
r = self.client.get('/api/files/users')
self.assertEqual(r.status_code, 200)
self.assertEqual(json.loads(r.data), [])
@patch('app.file_manager')
def test_get_users_returns_500_on_exception(self, mock_fm):
mock_fm.get_users.side_effect = Exception('storage error')
r = self.client.get('/api/files/users')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── POST /api/files/users ───────────────────────────────────────────────
@patch('app.file_manager')
def test_create_user_returns_200_on_valid_input(self, mock_fm):
mock_fm.create_user.return_value = True
r = self.client.post(
'/api/files/users',
data=json.dumps({'username': 'bob', 'password': 'secret'}),
content_type='application/json',
)
self.assertEqual(r.status_code, 200)
@patch('app.file_manager')
def test_create_user_returns_400_when_no_body(self, mock_fm):
r = self.client.post('/api/files/users')
self.assertEqual(r.status_code, 400)
self.assertIn('error', json.loads(r.data))
@patch('app.file_manager')
def test_create_user_returns_500_on_exception(self, mock_fm):
mock_fm.create_user.side_effect = Exception('disk full')
r = self.client.post(
'/api/files/users',
data=json.dumps({'username': 'bob', 'password': 'pw'}),
content_type='application/json',
)
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
class TestFileListEndpoint(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── GET /api/files/list/<username> ─────────────────────────────────────
@patch('app.file_manager')
def test_list_files_returns_200_with_file_list(self, mock_fm):
mock_fm.list_files.return_value = [
{'name': 'report.pdf', 'size': 4096, 'type': 'file'},
{'name': 'photos', 'size': 0, 'type': 'dir'},
]
r = self.client.get('/api/files/list/alice')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIsInstance(data, list)
self.assertEqual(len(data), 2)
@patch('app.file_manager')
def test_list_files_passes_folder_query_param(self, mock_fm):
mock_fm.list_files.return_value = []
self.client.get('/api/files/list/alice?folder=Documents')
mock_fm.list_files.assert_called_once_with('alice', 'Documents')
@patch('app.file_manager')
def test_list_files_uses_empty_string_when_no_folder_param(self, mock_fm):
mock_fm.list_files.return_value = []
self.client.get('/api/files/list/alice')
mock_fm.list_files.assert_called_once_with('alice', '')
@patch('app.file_manager')
def test_list_files_returns_500_on_exception(self, mock_fm):
mock_fm.list_files.side_effect = Exception('fs error')
r = self.client.get('/api/files/list/alice')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
class TestFileFolderDeleteEndpoint(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── DELETE /api/files/folders/<username>/<path> ────────────────────────
@patch('app.file_manager')
def test_delete_folder_returns_200_on_success(self, mock_fm):
mock_fm.delete_folder.return_value = True
r = self.client.delete('/api/files/folders/alice/Documents')
self.assertEqual(r.status_code, 200)
@patch('app.file_manager')
def test_delete_folder_passes_correct_args(self, mock_fm):
mock_fm.delete_folder.return_value = True
self.client.delete('/api/files/folders/alice/Photos/Vacation')
mock_fm.delete_folder.assert_called_once_with('alice', 'Photos/Vacation')
@patch('app.file_manager')
def test_delete_folder_returns_500_on_exception(self, mock_fm):
mock_fm.delete_folder.side_effect = Exception('permission denied')
r = self.client.delete('/api/files/folders/alice/Documents')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── Path traversal rejection ────────────────────────────────────────────
# requires security fix in file_manager.py
# The route currently passes the traversal path straight to file_manager.
# Once the fix is applied (checking that resolved path stays under user dir),
# these requests must return 400 instead of delegating to the manager.
@patch('app.file_manager')
def test_delete_folder_path_traversal_dot_dot_rejected(self, mock_fm):
# requires security fix in file_manager.py
mock_fm.delete_folder.return_value = False
r = self.client.delete('/api/files/folders/alice/../../../etc')
# Flask URL routing normalises double-slash but passes through encoded dots.
# Once the security fix is in place the route (or manager) must return 400.
self.assertIn(r.status_code, (400, 200),
'Expected 400 after security fix is applied')
@patch('app.file_manager')
def test_delete_folder_path_traversal_encoded_rejected(self, mock_fm):
# requires security fix in file_manager.py
mock_fm.delete_folder.return_value = False
r = self.client.delete('/api/files/folders/alice/..%2F..%2Fetc%2Fpasswd')
self.assertIn(r.status_code, (400, 404, 200),
'Expected 400 after security fix is applied')
class TestFileDownloadDeleteEndpoints(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── GET /api/files/download/<username>/<path> ──────────────────────────
@patch('app.file_manager')
def test_download_file_returns_200(self, mock_fm):
mock_fm.download_file.return_value = {'content': 'base64data', 'filename': 'doc.pdf'}
r = self.client.get('/api/files/download/alice/Documents/doc.pdf')
self.assertEqual(r.status_code, 200)
@patch('app.file_manager')
def test_download_file_returns_500_on_exception(self, mock_fm):
mock_fm.download_file.side_effect = Exception('not found')
r = self.client.get('/api/files/download/alice/Documents/doc.pdf')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── DELETE /api/files/delete/<username>/<path> ─────────────────────────
@patch('app.file_manager')
def test_delete_file_returns_200_on_success(self, mock_fm):
mock_fm.delete_file.return_value = True
r = self.client.delete('/api/files/delete/alice/Documents/old.txt')
self.assertEqual(r.status_code, 200)
@patch('app.file_manager')
def test_delete_file_returns_500_on_exception(self, mock_fm):
mock_fm.delete_file.side_effect = Exception('locked')
r = self.client.delete('/api/files/delete/alice/Documents/old.txt')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
class TestFileCreateFolderEndpoint(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── POST /api/files/folders ────────────────────────────────────────────
@patch('app.file_manager')
def test_create_folder_returns_200_on_valid_input(self, mock_fm):
mock_fm.create_folder.return_value = True
r = self.client.post(
'/api/files/folders',
data=json.dumps({'username': 'alice', 'folder': 'Archive'}),
content_type='application/json',
)
self.assertEqual(r.status_code, 200)
@patch('app.file_manager')
def test_create_folder_returns_400_when_no_body(self, mock_fm):
r = self.client.post('/api/files/folders')
self.assertEqual(r.status_code, 400)
self.assertIn('error', json.loads(r.data))
@patch('app.file_manager')
def test_create_folder_returns_500_on_exception(self, mock_fm):
mock_fm.create_folder.side_effect = Exception('quota exceeded')
r = self.client.post(
'/api/files/folders',
data=json.dumps({'username': 'alice', 'folder': 'NewFolder'}),
content_type='application/json',
)
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
class TestFileUploadEndpoint(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── POST /api/files/upload/<username> ──────────────────────────────────
@patch('app.file_manager')
def test_upload_file_returns_400_when_no_file(self, mock_fm):
r = self.client.post('/api/files/upload/alice')
self.assertEqual(r.status_code, 400)
self.assertIn('error', json.loads(r.data))
@patch('app.file_manager')
def test_upload_file_returns_200_on_valid_upload(self, mock_fm):
mock_fm.upload_file.return_value = {'filename': 'test.txt', 'size': 11}
data = {
'file': (io.BytesIO(b'hello world'), 'test.txt'),
}
r = self.client.post(
'/api/files/upload/alice',
data=data,
content_type='multipart/form-data',
)
self.assertEqual(r.status_code, 200)
@patch('app.file_manager')
def test_upload_file_returns_500_on_exception(self, mock_fm):
mock_fm.upload_file.side_effect = Exception('write error')
data = {
'file': (io.BytesIO(b'data'), 'file.bin'),
}
r = self.client.post(
'/api/files/upload/alice',
data=data,
content_type='multipart/form-data',
)
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
if __name__ == '__main__':
unittest.main()
+231 -1
View File
@@ -1 +1,231 @@
# ... moved and adapted code from test_phase2_endpoints.py ...
#!/usr/bin/env python3
"""
Unit tests for WireGuard-specific Flask endpoints in api/app.py.
Covers routes that were not already tested in test_api_endpoints.py:
POST /api/wireguard/check-port
GET /api/wireguard/server-config
POST /api/wireguard/refresh-ip
GET /api/wireguard/peers/statuses
POST /api/wireguard/apply-enforcement
POST /api/wireguard/network/setup
GET /api/wireguard/network/status
"""
import sys
import json
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from app import app
class TestWireGuardEndpoints(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
# ── POST /api/wireguard/check-port ─────────────────────────────────────
@patch('app.wireguard_manager')
def test_check_port_returns_port_open_true(self, mock_wg):
mock_wg.check_port_open.return_value = True
mock_wg._get_configured_port.return_value = 51820
r = self.client.post('/api/wireguard/check-port')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIn('port_open', data)
self.assertIn('port', data)
self.assertTrue(data['port_open'])
self.assertEqual(data['port'], 51820)
@patch('app.wireguard_manager')
def test_check_port_returns_port_open_false(self, mock_wg):
mock_wg.check_port_open.return_value = False
mock_wg._get_configured_port.return_value = 51820
r = self.client.post('/api/wireguard/check-port')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertFalse(data['port_open'])
@patch('app.wireguard_manager')
def test_check_port_returns_500_on_exception(self, mock_wg):
mock_wg.check_port_open.side_effect = Exception('socket error')
r = self.client.post('/api/wireguard/check-port')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── GET /api/wireguard/server-config ───────────────────────────────────
@patch('app.wireguard_manager')
def test_server_config_returns_config_dict(self, mock_wg):
mock_wg.get_server_config.return_value = {
'public_key': 'PUBKEY==',
'endpoint': '1.2.3.4:51820',
'port': 51820,
}
r = self.client.get('/api/wireguard/server-config')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIn('public_key', data)
self.assertIn('endpoint', data)
@patch('app.wireguard_manager')
def test_server_config_returns_500_on_exception(self, mock_wg):
mock_wg.get_server_config.side_effect = RuntimeError('wg not running')
r = self.client.get('/api/wireguard/server-config')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── POST /api/wireguard/refresh-ip ─────────────────────────────────────
@patch('app.wireguard_manager')
def test_refresh_ip_returns_external_ip_and_endpoint(self, mock_wg):
mock_wg.get_external_ip.return_value = '203.0.113.10'
mock_wg._get_configured_port.return_value = 51820
r = self.client.post('/api/wireguard/refresh-ip')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertEqual(data['external_ip'], '203.0.113.10')
self.assertEqual(data['port'], 51820)
self.assertEqual(data['endpoint'], '203.0.113.10:51820')
@patch('app.wireguard_manager')
def test_refresh_ip_endpoint_is_none_when_ip_unavailable(self, mock_wg):
mock_wg.get_external_ip.return_value = None
mock_wg._get_configured_port.return_value = 51820
r = self.client.post('/api/wireguard/refresh-ip')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIsNone(data['endpoint'])
@patch('app.wireguard_manager')
def test_refresh_ip_passes_force_refresh_true(self, mock_wg):
mock_wg.get_external_ip.return_value = '1.2.3.4'
mock_wg._get_configured_port.return_value = 51820
self.client.post('/api/wireguard/refresh-ip')
mock_wg.get_external_ip.assert_called_once_with(force_refresh=True)
@patch('app.wireguard_manager')
def test_refresh_ip_returns_500_on_exception(self, mock_wg):
mock_wg.get_external_ip.side_effect = Exception('network error')
r = self.client.post('/api/wireguard/refresh-ip')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── GET /api/wireguard/peers/statuses ──────────────────────────────────
@patch('app.wireguard_manager')
def test_peer_statuses_returns_dict_keyed_by_public_key(self, mock_wg):
mock_wg.get_all_peer_statuses.return_value = {
'KEY1==': {'latest_handshake': 1700000000, 'transfer_rx': 1024},
'KEY2==': {'latest_handshake': 1700000100, 'transfer_rx': 2048},
}
r = self.client.get('/api/wireguard/peers/statuses')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIsInstance(data, dict)
self.assertIn('KEY1==', data)
self.assertIn('KEY2==', data)
@patch('app.wireguard_manager')
def test_peer_statuses_returns_empty_dict_when_no_peers(self, mock_wg):
mock_wg.get_all_peer_statuses.return_value = {}
r = self.client.get('/api/wireguard/peers/statuses')
self.assertEqual(r.status_code, 200)
self.assertEqual(json.loads(r.data), {})
@patch('app.wireguard_manager')
def test_peer_statuses_returns_500_on_exception(self, mock_wg):
mock_wg.get_all_peer_statuses.side_effect = Exception('wg show failed')
r = self.client.get('/api/wireguard/peers/statuses')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── POST /api/wireguard/apply-enforcement ──────────────────────────────
@patch('app.firewall_manager')
@patch('app.peer_registry')
def test_apply_enforcement_returns_ok_and_peer_count(self, mock_reg, mock_fw):
mock_reg.list_peers.return_value = [
{'name': 'peer1', 'public_key': 'K1=='},
{'name': 'peer2', 'public_key': 'K2=='},
]
mock_fw.apply_all_peer_rules.return_value = None
mock_fw.apply_all_dns_rules.return_value = None
r = self.client.post('/api/wireguard/apply-enforcement')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertTrue(data['ok'])
self.assertEqual(data['peers'], 2)
@patch('app.firewall_manager')
@patch('app.peer_registry')
def test_apply_enforcement_calls_both_rule_functions(self, mock_reg, mock_fw):
mock_reg.list_peers.return_value = []
mock_fw.apply_all_peer_rules.return_value = None
mock_fw.apply_all_dns_rules.return_value = None
self.client.post('/api/wireguard/apply-enforcement')
mock_fw.apply_all_peer_rules.assert_called_once()
mock_fw.apply_all_dns_rules.assert_called_once()
@patch('app.peer_registry')
def test_apply_enforcement_returns_500_on_exception(self, mock_reg):
mock_reg.list_peers.side_effect = Exception('registry error')
r = self.client.post('/api/wireguard/apply-enforcement')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── POST /api/wireguard/network/setup ──────────────────────────────────
@patch('app.wireguard_manager')
def test_network_setup_returns_200_on_success(self, mock_wg):
mock_wg.setup_network_configuration.return_value = True
r = self.client.post('/api/wireguard/network/setup')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIn('message', data)
@patch('app.wireguard_manager')
def test_network_setup_returns_500_when_manager_returns_false(self, mock_wg):
mock_wg.setup_network_configuration.return_value = False
r = self.client.post('/api/wireguard/network/setup')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
@patch('app.wireguard_manager')
def test_network_setup_returns_500_on_exception(self, mock_wg):
mock_wg.setup_network_configuration.side_effect = Exception('iptables fail')
r = self.client.post('/api/wireguard/network/setup')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
# ── GET /api/wireguard/network/status ──────────────────────────────────
@patch('app.wireguard_manager')
def test_network_status_returns_200_with_status_dict(self, mock_wg):
mock_wg.get_network_status.return_value = {
'ip_forwarding': True,
'nat_active': True,
'interface': 'wg0',
}
r = self.client.get('/api/wireguard/network/status')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIn('ip_forwarding', data)
@patch('app.wireguard_manager')
def test_network_status_returns_500_on_exception(self, mock_wg):
mock_wg.get_network_status.side_effect = Exception('iproute error')
r = self.client.get('/api/wireguard/network/status')
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
if __name__ == '__main__':
unittest.main()