feat(cells): Phase 2 — exit-offer signaling between connected cells

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>
This commit is contained in:
2026-05-01 15:49:21 -04:00
parent 7da0cbb714
commit dcee03dd3f
4 changed files with 230 additions and 3 deletions
+52 -1
View File
@@ -570,7 +570,8 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
mock_clm.apply_remote_permissions.assert_called_once_with(
'officepubkey=', self._VALID_BODY['permissions']
'officepubkey=', self._VALID_BODY['permissions'],
exit_offered=False,
)
@patch('app.cell_link_manager')
@@ -685,5 +686,55 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
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()