#!/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/ — disconnect from a cell GET /api/cells//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/""" 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//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//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//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'], ) if __name__ == '__main__': unittest.main()