fix: full security audit remediation — P0/P1/P2/P3 fixes + 1020 passing tests
P0 — Broken functionality: - Fix 12+ endpoints with wrong manager method signatures (email/calendar/file/routing) - Fix email_manager.delete_email_user() missing domain arg - Fix cell-link DNS forwarding wiped on every peer change (generate_corefile now accepts cell_links param; add/remove_cell_dns_forward no longer clobber the file) - Fix Flask SECRET_KEY regenerating on every restart (persisted to DATA_DIR) - Fix _next_peer_ip exhaustion returning 500 instead of 409 - Fix ConfigManager Caddyfile path (/app/config-caddy/) - Fix UI double-add and wrong-key peer bugs in Peers.jsx / WireGuard.jsx - Remove hardcoded credentials from Dashboard.jsx P1 — Security: - CSRF token validation on all POST/PUT/DELETE/PATCH to /api/* (double-submit pattern) - enforce_auth: 503 only when users file readable but empty; never bypass on IOError - WireGuard add_cell_peer: validate pubkey, name, endpoint against strict regexes - DNS add_cell_dns_forward: validate IP and domain; reject injection chars - DNS zone write: realpath containment + record content validation - iptables comment /32 suffix prevents substring match deleting wrong peer rules - is_local_request() trusts only loopback + 172.16.0.0/12 (Docker bridge) - POST /api/containers: volume allow-list prevents arbitrary host mounts - file_manager: bcrypt ($2b→$2y) for WebDAV; realpath containment in delete_user - email/calendar: stop persisting plaintext passwords in user records - routing_manager: validate IPs, networks, and interface names - peer_registry: write peers.json at mode 0o600 - vault_manager: Fernet key file at mode 0o600 - CORS: lock down to explicit origin list - domain/cell_name validation: reject newline, brace, semicolon injection chars P2 — Architecture: - Peer add: rollback registry entry if firewall rules fail post-add - restart_service(): base class now calls _restart_container(); email and calendar managers call cell-mail / cell-radicale respectively - email/calendar managers sync user list (no passwords) to cell_config.json - Pending-restart flag cleared only after helper subprocess exits with code 0 - docker-compose.yml: add config-caddy volume to API container P3 — Tests (854 → 1020): - Fill test_email_endpoints.py, test_calendar_endpoints.py, test_network_endpoints.py, test_routing_endpoints.py - New: test_peer_management_update.py, test_peer_management_edge_cases.py, test_input_validation.py, test_enforce_auth_configured.py, test_cell_link_dns.py, test_logs_endpoints.py, test_cells_endpoints.py, test_is_local_request_per_endpoint.py, test_caddy_routing.py - E2E conftest: skip WireGuard suite when wg-quick absent - Update existing tests to match fixed signatures and comment formats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for cell management Flask endpoints in api/app.py.
|
||||
|
||||
Covers:
|
||||
GET /api/cells/invite — generate invite package
|
||||
GET /api/cells — list connected cells
|
||||
POST /api/cells — connect to a remote cell
|
||||
DELETE /api/cells/<cell_name> — disconnect from a cell
|
||||
GET /api/cells/<cell_name>/status — live status for a connected cell
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# Minimal set of required fields for POST /api/cells
|
||||
_VALID_CELL_BODY = {
|
||||
'cell_name': 'remotecell',
|
||||
'public_key': 'abc123publickey==',
|
||||
'vpn_subnet': '10.1.0.0/24',
|
||||
'dns_ip': '10.1.0.1',
|
||||
'domain': 'remotecell.cell',
|
||||
}
|
||||
|
||||
|
||||
class TestGetCellInvite(unittest.TestCase):
|
||||
"""GET /api/cells/invite"""
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
@patch('app.config_manager')
|
||||
def test_get_invite_returns_200_with_invite_dict(self, mock_cfg, mock_clm):
|
||||
mock_cfg.configs = {'_identity': {'cell_name': 'mycell', 'domain': 'cell'}}
|
||||
mock_clm.generate_invite.return_value = {
|
||||
'cell_name': 'mycell',
|
||||
'public_key': 'server_pub_key==',
|
||||
'vpn_subnet': '10.0.0.0/24',
|
||||
'dns_ip': '10.0.0.1',
|
||||
'domain': 'cell',
|
||||
}
|
||||
r = self.client.get('/api/cells/invite')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = json.loads(r.data)
|
||||
self.assertIn('cell_name', data)
|
||||
self.assertIn('public_key', data)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
@patch('app.config_manager')
|
||||
def test_get_invite_passes_cell_name_and_domain(self, mock_cfg, mock_clm):
|
||||
mock_cfg.configs = {'_identity': {'cell_name': 'myhome', 'domain': 'home'}}
|
||||
mock_clm.generate_invite.return_value = {}
|
||||
self.client.get('/api/cells/invite')
|
||||
mock_clm.generate_invite.assert_called_once_with('myhome', 'home')
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
@patch('app.config_manager')
|
||||
def test_get_invite_returns_500_on_exception(self, mock_cfg, mock_clm):
|
||||
mock_cfg.configs = {'_identity': {}}
|
||||
mock_clm.generate_invite.side_effect = Exception('WireGuard key unavailable')
|
||||
r = self.client.get('/api/cells/invite')
|
||||
self.assertEqual(r.status_code, 500)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
|
||||
class TestListCellConnections(unittest.TestCase):
|
||||
"""GET /api/cells"""
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_list_cells_returns_200_with_list(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [
|
||||
{'cell_name': 'remotecell', 'domain': 'remotecell.cell', 'status': 'connected'},
|
||||
]
|
||||
r = self.client.get('/api/cells')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = json.loads(r.data)
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['cell_name'], 'remotecell')
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_list_cells_returns_empty_list_when_none_connected(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = []
|
||||
r = self.client.get('/api/cells')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(json.loads(r.data), [])
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_list_cells_returns_500_on_exception(self, mock_clm):
|
||||
mock_clm.list_connections.side_effect = Exception('storage error')
|
||||
r = self.client.get('/api/cells')
|
||||
self.assertEqual(r.status_code, 500)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
|
||||
class TestAddCellConnection(unittest.TestCase):
|
||||
"""POST /api/cells"""
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_201_on_success(self, mock_clm):
|
||||
mock_clm.add_connection.return_value = {'cell_name': 'remotecell'}
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(_VALID_CELL_BODY),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 201)
|
||||
data = json.loads(r.data)
|
||||
self.assertIn('message', data)
|
||||
self.assertIn('link', data)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_when_no_body(self, mock_clm):
|
||||
r = self.client.post('/api/cells')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
mock_clm.add_connection.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_when_cell_name_missing(self, mock_clm):
|
||||
body = {k: v for k, v in _VALID_CELL_BODY.items() if k != 'cell_name'}
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_when_public_key_missing(self, mock_clm):
|
||||
body = {k: v for k, v in _VALID_CELL_BODY.items() if k != 'public_key'}
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_when_vpn_subnet_missing(self, mock_clm):
|
||||
body = {k: v for k, v in _VALID_CELL_BODY.items() if k != 'vpn_subnet'}
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_when_dns_ip_missing(self, mock_clm):
|
||||
body = {k: v for k, v in _VALID_CELL_BODY.items() if k != 'dns_ip'}
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_when_domain_missing(self, mock_clm):
|
||||
body = {k: v for k, v in _VALID_CELL_BODY.items() if k != 'domain'}
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_400_on_value_error_from_manager(self, mock_clm):
|
||||
mock_clm.add_connection.side_effect = ValueError('cell already connected')
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(_VALID_CELL_BODY),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_add_cell_returns_500_on_unexpected_exception(self, mock_clm):
|
||||
mock_clm.add_connection.side_effect = Exception('WireGuard peer add failed')
|
||||
r = self.client.post(
|
||||
'/api/cells',
|
||||
data=json.dumps(_VALID_CELL_BODY),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(r.status_code, 500)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
|
||||
class TestRemoveCellConnection(unittest.TestCase):
|
||||
"""DELETE /api/cells/<cell_name>"""
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_remove_cell_returns_200_on_success(self, mock_clm):
|
||||
mock_clm.remove_connection.return_value = None
|
||||
r = self.client.delete('/api/cells/remotecell')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = json.loads(r.data)
|
||||
self.assertIn('message', data)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_remove_cell_passes_cell_name_to_manager(self, mock_clm):
|
||||
mock_clm.remove_connection.return_value = None
|
||||
self.client.delete('/api/cells/faraway')
|
||||
mock_clm.remove_connection.assert_called_once_with('faraway')
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_remove_cell_returns_404_on_value_error(self, mock_clm):
|
||||
mock_clm.remove_connection.side_effect = ValueError('cell not found')
|
||||
r = self.client.delete('/api/cells/nonexistent')
|
||||
self.assertEqual(r.status_code, 404)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_remove_cell_returns_500_on_unexpected_exception(self, mock_clm):
|
||||
mock_clm.remove_connection.side_effect = Exception('storage corruption')
|
||||
r = self.client.delete('/api/cells/remotecell')
|
||||
self.assertEqual(r.status_code, 500)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
|
||||
class TestGetCellConnectionStatus(unittest.TestCase):
|
||||
"""GET /api/cells/<cell_name>/status"""
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_get_cell_status_returns_200_with_status_dict(self, mock_clm):
|
||||
mock_clm.get_connection_status.return_value = {
|
||||
'cell_name': 'remotecell',
|
||||
'online': True,
|
||||
'last_handshake': '2026-04-27T09:00:00Z',
|
||||
'transfer_rx': 1024,
|
||||
'transfer_tx': 2048,
|
||||
}
|
||||
r = self.client.get('/api/cells/remotecell/status')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = json.loads(r.data)
|
||||
self.assertIn('online', data)
|
||||
self.assertTrue(data['online'])
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_get_cell_status_passes_cell_name(self, mock_clm):
|
||||
mock_clm.get_connection_status.return_value = {}
|
||||
self.client.get('/api/cells/faraway/status')
|
||||
mock_clm.get_connection_status.assert_called_once_with('faraway')
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_get_cell_status_returns_404_on_value_error(self, mock_clm):
|
||||
mock_clm.get_connection_status.side_effect = ValueError('cell not found')
|
||||
r = self.client.get('/api/cells/missing/status')
|
||||
self.assertEqual(r.status_code, 404)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_get_cell_status_returns_500_on_unexpected_exception(self, mock_clm):
|
||||
mock_clm.get_connection_status.side_effect = Exception('WireGuard query failed')
|
||||
r = self.client.get('/api/cells/remotecell/status')
|
||||
self.assertEqual(r.status_code, 500)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user