dcee03dd3f
Adds the ability for a cell to signal to a peer that it's willing to route internet traffic on their behalf. This is the signaling layer for Phase 3 (per-peer routing via exit cell). Changes: - cell_links.json: exit_offered (bool) + remote_exit_offered (bool) fields with lazy migration (default false for existing records) - _push_permissions_to_remote: includes exit_offered in the push body - apply_remote_permissions: accepts exit_offered kwarg; stores it as remote_exit_offered on the matching cell link - peer-sync receiver: passes exit_offered from body to apply_remote_permissions - CellLinkManager.set_exit_offered(cell_name, offered): persists + triggers push so the remote learns of our offer immediately - PUT /api/cells/<name>/exit-offer: REST endpoint to toggle the flag - 12 new tests covering all new paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
741 lines
29 KiB
Python
741 lines
29 KiB
Python
#!/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))
|
|
|
|
|
|
class TestAddCellRuntimeError(unittest.TestCase):
|
|
"""POST /api/cells — RuntimeError from the manager must now return 400, not 500."""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_add_cell_runtime_error_returns_400(self, mock_clm):
|
|
"""When add_connection raises RuntimeError (WG failure), endpoint returns 400."""
|
|
mock_clm.add_connection.side_effect = RuntimeError('Failed to add WireGuard peer')
|
|
r = self.client.post(
|
|
'/api/cells',
|
|
data=json.dumps(_VALID_CELL_BODY),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
data = json.loads(r.data)
|
|
self.assertIn('error', data)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_add_cell_runtime_error_body_contains_message(self, mock_clm):
|
|
"""The 400 response for a RuntimeError includes the error message."""
|
|
mock_clm.add_connection.side_effect = RuntimeError('WireGuard peer add failed')
|
|
r = self.client.post(
|
|
'/api/cells',
|
|
data=json.dumps(_VALID_CELL_BODY),
|
|
content_type='application/json',
|
|
)
|
|
data = json.loads(r.data)
|
|
self.assertIn('WireGuard', data['error'])
|
|
|
|
|
|
class TestListServices(unittest.TestCase):
|
|
"""GET /api/cells/services"""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
|
|
def test_list_services_returns_200(self):
|
|
"""GET /api/cells/services returns HTTP 200."""
|
|
r = self.client.get('/api/cells/services')
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
def test_list_services_returns_services_key(self):
|
|
"""Response body has a 'services' key."""
|
|
r = self.client.get('/api/cells/services')
|
|
data = json.loads(r.data)
|
|
self.assertIn('services', data)
|
|
|
|
def test_list_services_returns_list(self):
|
|
"""'services' value is a non-empty list."""
|
|
r = self.client.get('/api/cells/services')
|
|
data = json.loads(r.data)
|
|
self.assertIsInstance(data['services'], list)
|
|
self.assertGreater(len(data['services']), 0)
|
|
|
|
def test_list_services_includes_known_services(self):
|
|
"""'services' includes the four known shareable services."""
|
|
r = self.client.get('/api/cells/services')
|
|
services = json.loads(r.data)['services']
|
|
for expected in ('calendar', 'files', 'mail', 'webdav'):
|
|
self.assertIn(expected, services)
|
|
|
|
|
|
class TestGetCellPermissions(unittest.TestCase):
|
|
"""GET /api/cells/<name>/permissions"""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_get_permissions_returns_200(self, mock_clm):
|
|
"""GET /api/cells/office/permissions returns 200 when cell exists."""
|
|
mock_clm.get_permissions.return_value = {
|
|
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
|
}
|
|
r = self.client.get('/api/cells/office/permissions')
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_get_permissions_response_has_inbound_and_outbound(self, mock_clm):
|
|
"""Response body contains 'inbound' and 'outbound' keys."""
|
|
mock_clm.get_permissions.return_value = {
|
|
'inbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
|
}
|
|
r = self.client.get('/api/cells/office/permissions')
|
|
data = json.loads(r.data)
|
|
self.assertIn('inbound', data)
|
|
self.assertIn('outbound', data)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_get_permissions_unknown_cell_returns_404(self, mock_clm):
|
|
"""ValueError from get_permissions maps to 404."""
|
|
mock_clm.get_permissions.side_effect = ValueError('cell not found')
|
|
r = self.client.get('/api/cells/nosuchcell/permissions')
|
|
self.assertEqual(r.status_code, 404)
|
|
self.assertIn('error', json.loads(r.data))
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_get_permissions_passes_cell_name(self, mock_clm):
|
|
"""The cell_name URL segment is forwarded to get_permissions."""
|
|
mock_clm.get_permissions.return_value = {'inbound': {}, 'outbound': {}}
|
|
self.client.get('/api/cells/faraway/permissions')
|
|
mock_clm.get_permissions.assert_called_once_with('faraway')
|
|
|
|
|
|
class TestUpdateCellPermissions(unittest.TestCase):
|
|
"""PUT /api/cells/<name>/permissions"""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
|
|
_VALID_PERM_BODY = {
|
|
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
|
}
|
|
|
|
@patch('app.cell_link_manager')
|
|
@patch('app.peer_registry')
|
|
@patch('app.firewall_manager')
|
|
@patch('app.config_manager')
|
|
def test_update_permissions_returns_200(self, mock_cfg, mock_fm, mock_pr, mock_clm):
|
|
"""PUT with valid inbound/outbound returns 200."""
|
|
mock_cfg.configs = {'_identity': {'domain': 'cell'}}
|
|
mock_pr.list_peers.return_value = []
|
|
mock_clm.list_connections.return_value = []
|
|
mock_clm.update_permissions.return_value = {
|
|
'cell_name': 'office',
|
|
'permissions': self._VALID_PERM_BODY,
|
|
}
|
|
mock_fm.apply_all_dns_rules.return_value = True
|
|
r = self.client.put(
|
|
'/api/cells/office/permissions',
|
|
data=json.dumps(self._VALID_PERM_BODY),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
data = json.loads(r.data)
|
|
self.assertIn('message', data)
|
|
self.assertIn('link', data)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_update_permissions_unknown_service_returns_400(self, mock_clm):
|
|
"""PUT body containing an unknown service name returns 400."""
|
|
body = {
|
|
'inbound': {'bad_service': True, 'calendar': True},
|
|
'outbound': {},
|
|
}
|
|
r = self.client.put(
|
|
'/api/cells/office/permissions',
|
|
data=json.dumps(body),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
data = json.loads(r.data)
|
|
self.assertIn('error', data)
|
|
# update_permissions should NOT have been called when validation fails
|
|
mock_clm.update_permissions.assert_not_called()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_update_permissions_unknown_cell_returns_404(self, mock_clm):
|
|
"""ValueError from update_permissions (cell not found) maps to 404."""
|
|
mock_clm.update_permissions.side_effect = ValueError('cell not found')
|
|
r = self.client.put(
|
|
'/api/cells/nosuchcell/permissions',
|
|
data=json.dumps(self._VALID_PERM_BODY),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 404)
|
|
self.assertIn('error', json.loads(r.data))
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_update_permissions_no_body_returns_400(self, mock_clm):
|
|
"""PUT with no JSON body returns 400."""
|
|
r = self.client.put('/api/cells/office/permissions')
|
|
self.assertEqual(r.status_code, 400)
|
|
self.assertIn('error', json.loads(r.data))
|
|
mock_clm.update_permissions.assert_not_called()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_update_permissions_outbound_unknown_service_returns_400(self, mock_clm):
|
|
"""Unknown service in outbound (not just inbound) also returns 400."""
|
|
body = {
|
|
'inbound': {'calendar': True},
|
|
'outbound': {'hacked': True},
|
|
}
|
|
r = self.client.put(
|
|
'/api/cells/office/permissions',
|
|
data=json.dumps(body),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
@patch('app.cell_link_manager')
|
|
@patch('app.peer_registry')
|
|
@patch('app.firewall_manager')
|
|
@patch('app.config_manager')
|
|
def test_update_permissions_passes_inbound_outbound_to_manager(
|
|
self, mock_cfg, mock_fm, mock_pr, mock_clm):
|
|
"""update_permissions is called with inbound and outbound dicts from the body."""
|
|
mock_cfg.configs = {'_identity': {'domain': 'cell'}}
|
|
mock_pr.list_peers.return_value = []
|
|
mock_clm.list_connections.return_value = []
|
|
mock_clm.update_permissions.return_value = {
|
|
'cell_name': 'office', 'permissions': self._VALID_PERM_BODY
|
|
}
|
|
mock_fm.apply_all_dns_rules.return_value = True
|
|
self.client.put(
|
|
'/api/cells/office/permissions',
|
|
data=json.dumps(self._VALID_PERM_BODY),
|
|
content_type='application/json',
|
|
)
|
|
mock_clm.update_permissions.assert_called_once_with(
|
|
'office',
|
|
self._VALID_PERM_BODY['inbound'],
|
|
self._VALID_PERM_BODY['outbound'],
|
|
)
|
|
|
|
|
|
class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
|
|
"""POST /api/cells/peer-sync/permissions — machine-to-machine permission sync."""
|
|
|
|
_KNOWN_LINK = {
|
|
'cell_name': 'office',
|
|
'public_key': 'officepubkey=',
|
|
'vpn_subnet': '10.1.0.0/24',
|
|
'dns_ip': '10.1.0.1',
|
|
'domain': 'office.cell',
|
|
'permissions': {'inbound': {}, 'outbound': {}},
|
|
'pending_push': False,
|
|
'remote_api_url': 'http://10.1.0.1:3000',
|
|
}
|
|
|
|
_VALID_BODY = {
|
|
'version': 1,
|
|
'from_cell': 'office',
|
|
'from_public_key': 'officepubkey=',
|
|
'permissions': {
|
|
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
|
},
|
|
'sent_at': '2026-05-01T00:00:00Z',
|
|
}
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_valid_source_ip_returns_200(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(self._VALID_BODY),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
data = json.loads(r.data)
|
|
self.assertTrue(data.get('ok'))
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_valid_source_calls_apply_remote_permissions(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
|
self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(self._VALID_BODY),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
mock_clm.apply_remote_permissions.assert_called_once_with(
|
|
'officepubkey=', self._VALID_BODY['permissions'],
|
|
exit_offered=False,
|
|
)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_unknown_source_ip_returns_403(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(self._VALID_BODY),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.9.9.9'},
|
|
)
|
|
self.assertEqual(r.status_code, 403)
|
|
mock_clm.apply_remote_permissions.assert_not_called()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_pubkey_mismatch_returns_403(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
body = dict(self._VALID_BODY, from_public_key='wrongkey=')
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(body),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 403)
|
|
mock_clm.apply_remote_permissions.assert_not_called()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_xff_header_used_for_source_ip(self, mock_clm):
|
|
"""Caddy appends source IP as last X-Forwarded-For entry."""
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(self._VALID_BODY),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '172.20.0.5'}, # docker bridge — not in cell subnet
|
|
headers={'X-Forwarded-For': '192.168.1.1, 10.1.0.5'}, # last entry is real source
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_missing_version_returns_400(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
body = {k: v for k, v in self._VALID_BODY.items() if k != 'version'}
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(body),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_wrong_version_returns_400(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
body = dict(self._VALID_BODY, version=99)
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(body),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_unknown_service_name_returns_400(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
body = dict(self._VALID_BODY)
|
|
body['permissions'] = {'inbound': {'hacked': True}, 'outbound': {}}
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(body),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
mock_clm.apply_remote_permissions.assert_not_called()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_no_body_returns_400(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_apply_remote_permissions_exception_returns_500(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
mock_clm.apply_remote_permissions.side_effect = IOError('disk full')
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(self._VALID_BODY),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 500)
|
|
self.assertIn('error', json.loads(r.data))
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_value_error_from_apply_returns_404(self, mock_clm):
|
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
|
mock_clm.apply_remote_permissions.side_effect = ValueError('no link')
|
|
r = self.client.post(
|
|
'/api/cells/peer-sync/permissions',
|
|
data=json.dumps(self._VALID_BODY),
|
|
content_type='application/json',
|
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
|
)
|
|
self.assertEqual(r.status_code, 404)
|
|
|
|
|
|
class TestSetExitOffer(unittest.TestCase):
|
|
"""PUT /api/cells/<cell_name>/exit-offer"""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_set_exit_offer_true_calls_set_exit_offered(self, mock_clm):
|
|
mock_clm.set_exit_offered.return_value = {'cell_name': 'remote', 'exit_offered': True}
|
|
r = self.client.put(
|
|
'/api/cells/remote/exit-offer',
|
|
data=json.dumps({'exit_offered': True}),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
mock_clm.set_exit_offered.assert_called_once_with('remote', True)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_set_exit_offer_false_calls_set_exit_offered(self, mock_clm):
|
|
mock_clm.set_exit_offered.return_value = {'cell_name': 'remote', 'exit_offered': False}
|
|
r = self.client.put(
|
|
'/api/cells/remote/exit-offer',
|
|
data=json.dumps({'exit_offered': False}),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
mock_clm.set_exit_offered.assert_called_once_with('remote', False)
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_set_exit_offer_missing_field_returns_400(self, mock_clm):
|
|
r = self.client.put(
|
|
'/api/cells/remote/exit-offer',
|
|
data=json.dumps({}),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
mock_clm.set_exit_offered.assert_not_called()
|
|
|
|
@patch('app.cell_link_manager')
|
|
def test_set_exit_offer_unknown_cell_returns_404(self, mock_clm):
|
|
mock_clm.set_exit_offered.side_effect = ValueError("Cell 'nobody' not found")
|
|
r = self.client.put(
|
|
'/api/cells/nobody/exit-offer',
|
|
data=json.dumps({'exit_offered': True}),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(r.status_code, 404)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|