feat: Phase 4 hardening — retry/backoff, loop detection, sync status UI + tests
Phase 4.1 — Retry/backoff for failed permission pushes: - _compute_next_retry(): capped exponential backoff with jitter (60s–1h) - _record_push_result(): tracks push_attempts and next_retry_at per link - replay_pending_pushes(): skips links still in backoff window, logs deferred count - _load() migration: adds push_attempts/next_retry_at to existing records Phase 4.2 — Loop detection (A→B→A routing cycle): - set_peer_route_via(): returns 409 if target cell already routes peers through us - apply_remote_permissions(): soft warning when accepting exit-relay that would cycle Phase 4.3 — Sync staleness indicator in Cell Network UI: - SyncBadge component: green (synced), amber (pending/failed), gray (never) - Shows relativeTime of last sync + error message + next retry estimate - Injected into CellPanel header alongside tunnel online/handshake status Tests (54 new): - TestCheckInviteConflicts: subnet overlap, domain conflict, exclude_cell (9 tests) - TestPushInviteToRemote: success, 4xx, no endpoint, subprocess errors (7 tests) - TestAcceptInviteNew: new cell, idempotent, healing dns/subnet changes (16 tests) - TestAddConnectionMutualPairing: push-invite call, non-fatal failure (5 tests) - TestPeerSyncAcceptInvite endpoint: happy path, field validation, error propagation (16 tests) - Fixed 2 existing replay tests to clear backoff gate (simulates elapsed window) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -737,5 +737,170 @@ class TestSetExitOffer(unittest.TestCase):
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
|
||||
class TestPeerSyncAcceptInvite(unittest.TestCase):
|
||||
"""POST /api/cells/peer-sync/accept-invite — machine-to-machine mutual WG pairing."""
|
||||
|
||||
# A well-formed invite matching SAMPLE_INVITE in cell_link_manager tests
|
||||
_VALID_INVITE = {
|
||||
'cell_name': 'office',
|
||||
'public_key': 'officepubkey=',
|
||||
'endpoint': '5.6.7.8:51820',
|
||||
'vpn_subnet': '10.1.0.0/24',
|
||||
'dns_ip': '10.1.0.1',
|
||||
'domain': 'office.cell',
|
||||
'version': 1,
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
def _post(self, body):
|
||||
return self.client.post(
|
||||
'/api/cells/peer-sync/accept-invite',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_valid_invite_returns_201_ok_true(self, mock_clm):
|
||||
"""Valid invite returns 201 with {'ok': True}."""
|
||||
mock_clm.accept_invite.return_value = {'cell_name': 'office'}
|
||||
r = self._post({'invite': self._VALID_INVITE})
|
||||
self.assertEqual(r.status_code, 201)
|
||||
data = json.loads(r.data)
|
||||
self.assertTrue(data.get('ok'))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_valid_invite_returns_cell_name(self, mock_clm):
|
||||
"""Response body contains cell_name from the accepted link."""
|
||||
mock_clm.accept_invite.return_value = {'cell_name': 'office'}
|
||||
r = self._post({'invite': self._VALID_INVITE})
|
||||
data = json.loads(r.data)
|
||||
self.assertEqual(data.get('cell_name'), 'office')
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_no_invite_in_body_returns_400(self, mock_clm):
|
||||
"""Empty body (missing invite key) returns 400."""
|
||||
r = self._post({})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
data = json.loads(r.data)
|
||||
self.assertFalse(data.get('ok'))
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_invite_not_dict_returns_400(self, mock_clm):
|
||||
"""invite as a string (not a dict) returns 400."""
|
||||
r = self._post({'invite': 'not_a_dict'})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_no_body_at_all_returns_400(self, mock_clm):
|
||||
"""POST with no body at all returns 400."""
|
||||
r = self.client.post('/api/cells/peer-sync/accept-invite')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_invite_missing_cell_name_returns_400(self, mock_clm):
|
||||
"""Invite missing 'cell_name' returns 400."""
|
||||
invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'cell_name'}
|
||||
r = self._post({'invite': invite})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_invite_missing_public_key_returns_400(self, mock_clm):
|
||||
"""Invite missing 'public_key' returns 400."""
|
||||
invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'public_key'}
|
||||
r = self._post({'invite': invite})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_invite_missing_vpn_subnet_returns_400(self, mock_clm):
|
||||
"""Invite missing 'vpn_subnet' returns 400."""
|
||||
invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'vpn_subnet'}
|
||||
r = self._post({'invite': invite})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_invite_missing_dns_ip_returns_400(self, mock_clm):
|
||||
"""Invite missing 'dns_ip' returns 400."""
|
||||
invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'dns_ip'}
|
||||
r = self._post({'invite': invite})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_invite_missing_domain_returns_400(self, mock_clm):
|
||||
"""Invite missing 'domain' returns 400."""
|
||||
invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'domain'}
|
||||
r = self._post({'invite': invite})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_unsupported_version_returns_400(self, mock_clm):
|
||||
"""Invite with version=99 (unsupported) returns 400."""
|
||||
invite = {**self._VALID_INVITE, 'version': 99}
|
||||
r = self._post({'invite': invite})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
data = json.loads(r.data)
|
||||
self.assertFalse(data.get('ok'))
|
||||
mock_clm.accept_invite.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_version_none_is_accepted(self, mock_clm):
|
||||
"""Invite with no version field (version=None) is valid per spec."""
|
||||
mock_clm.accept_invite.return_value = {'cell_name': 'office'}
|
||||
invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'version'}
|
||||
r = self._post({'invite': invite})
|
||||
# version=None is allowed
|
||||
self.assertEqual(r.status_code, 201)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_value_error_from_accept_invite_returns_400(self, mock_clm):
|
||||
"""ValueError from accept_invite (e.g. subnet conflict) → 400."""
|
||||
mock_clm.accept_invite.side_effect = ValueError('subnet conflict')
|
||||
r = self._post({'invite': self._VALID_INVITE})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
data = json.loads(r.data)
|
||||
self.assertFalse(data.get('ok'))
|
||||
self.assertIn('subnet', data.get('error', ''))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_runtime_error_from_accept_invite_returns_400(self, mock_clm):
|
||||
"""RuntimeError from accept_invite (e.g. WG peer add failed) → 400."""
|
||||
mock_clm.accept_invite.side_effect = RuntimeError('WireGuard peer add failed')
|
||||
r = self._post({'invite': self._VALID_INVITE})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_unexpected_exception_returns_500(self, mock_clm):
|
||||
"""Unhandled exception in accept_invite → 500."""
|
||||
mock_clm.accept_invite.side_effect = IOError('disk full')
|
||||
r = self._post({'invite': self._VALID_INVITE})
|
||||
self.assertEqual(r.status_code, 500)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_accept_invite_calls_manager_with_invite(self, mock_clm):
|
||||
"""The invite dict is passed directly to cell_link_manager.accept_invite."""
|
||||
mock_clm.accept_invite.return_value = {'cell_name': 'office'}
|
||||
self._post({'invite': self._VALID_INVITE})
|
||||
mock_clm.accept_invite.assert_called_once_with(self._VALID_INVITE)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_idempotent_already_connected_returns_201(self, mock_clm):
|
||||
"""If accept_invite returns an existing link (idempotent), still 201."""
|
||||
existing = {'cell_name': 'office', 'vpn_subnet': '10.1.0.0/24'}
|
||||
mock_clm.accept_invite.return_value = existing
|
||||
r = self._post({'invite': self._VALID_INVITE})
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertTrue(json.loads(r.data).get('ok'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user