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:
@@ -11,8 +11,9 @@ Each connection is stored in data/cell_links.json and manifests as:
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -25,6 +26,15 @@ _DEFAULT_PERMISSIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_PUSH_TIMEOUT = 5 # seconds
|
_PUSH_TIMEOUT = 5 # seconds
|
||||||
|
_BACKOFF_BASE_S = 60
|
||||||
|
_BACKOFF_MAX_S = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_next_retry(attempts: int) -> str:
|
||||||
|
"""Return an ISO timestamp for the earliest next retry using capped exponential backoff."""
|
||||||
|
delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S)
|
||||||
|
delay += random.uniform(0, _BACKOFF_BASE_S / 2)
|
||||||
|
return (datetime.utcnow() + timedelta(seconds=delay)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _default_perms() -> Dict[str, Any]:
|
def _default_perms() -> Dict[str, Any]:
|
||||||
@@ -91,6 +101,13 @@ class CellLinkManager:
|
|||||||
if 'remote_exit_relay_active' not in link:
|
if 'remote_exit_relay_active' not in link:
|
||||||
link['remote_exit_relay_active'] = False
|
link['remote_exit_relay_active'] = False
|
||||||
changed = True
|
changed = True
|
||||||
|
# Phase 4 migration: retry/backoff state
|
||||||
|
if 'push_attempts' not in link:
|
||||||
|
link['push_attempts'] = 0
|
||||||
|
changed = True
|
||||||
|
if 'next_retry_at' not in link:
|
||||||
|
link['next_retry_at'] = None
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self._save(links)
|
self._save(links)
|
||||||
return links
|
return links
|
||||||
@@ -214,10 +231,15 @@ class CellLinkManager:
|
|||||||
link['last_push_at'] = datetime.utcnow().isoformat()
|
link['last_push_at'] = datetime.utcnow().isoformat()
|
||||||
link['last_push_error'] = None
|
link['last_push_error'] = None
|
||||||
link['pending_push'] = False
|
link['pending_push'] = False
|
||||||
|
link['push_attempts'] = 0
|
||||||
|
link['next_retry_at'] = None
|
||||||
else:
|
else:
|
||||||
link['last_push_status'] = 'failed'
|
link['last_push_status'] = 'failed'
|
||||||
link['last_push_error'] = result.get('error')
|
link['last_push_error'] = result.get('error')
|
||||||
link['pending_push'] = True
|
link['pending_push'] = True
|
||||||
|
attempts = link.get('push_attempts', 0) + 1
|
||||||
|
link['push_attempts'] = attempts
|
||||||
|
link['next_retry_at'] = _compute_next_retry(attempts)
|
||||||
break
|
break
|
||||||
self._save(links)
|
self._save(links)
|
||||||
|
|
||||||
@@ -270,6 +292,24 @@ class CellLinkManager:
|
|||||||
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
||||||
self._save(links)
|
self._save(links)
|
||||||
|
|
||||||
|
# Soft loop-detection warning: if the remote is asking us to act as exit
|
||||||
|
# AND we already have a peer routing via that cell, it's a potential cycle.
|
||||||
|
if use_as_exit_relay:
|
||||||
|
try:
|
||||||
|
from peer_registry import PeerRegistry
|
||||||
|
import os as _os
|
||||||
|
pr = PeerRegistry(_os.environ.get('DATA_DIR', '/app/data'))
|
||||||
|
loop_peers = [p['name'] for p in pr.list_peers()
|
||||||
|
if p.get('route_via') == link['cell_name']]
|
||||||
|
if loop_peers:
|
||||||
|
logger.warning(
|
||||||
|
f"apply_remote_permissions: '{link['cell_name']}' asked us to act as "
|
||||||
|
f"its exit relay, but we already route peers {loop_peers} via it — "
|
||||||
|
f"potential routing loop detected"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
inbound_list = [s for s, v in clean_inbound.items() if v]
|
inbound_list = [s for s, v in clean_inbound.items() if v]
|
||||||
try:
|
try:
|
||||||
import firewall_manager as _fm
|
import firewall_manager as _fm
|
||||||
@@ -295,9 +335,18 @@ class CellLinkManager:
|
|||||||
logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})")
|
logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})")
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
summary['deferred'] = 0
|
||||||
|
now_iso = datetime.utcnow().isoformat()
|
||||||
for link in self._load():
|
for link in self._load():
|
||||||
if not link.get('pending_push'):
|
if not link.get('pending_push'):
|
||||||
continue
|
continue
|
||||||
|
next_retry = link.get('next_retry_at')
|
||||||
|
if next_retry and next_retry > now_iso:
|
||||||
|
summary['deferred'] += 1
|
||||||
|
logger.info(
|
||||||
|
f"replay: skipping '{link['cell_name']}' — backoff until {next_retry}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
summary['attempted'] += 1
|
summary['attempted'] += 1
|
||||||
result = self._push_permissions_to_remote(
|
result = self._push_permissions_to_remote(
|
||||||
link, identity['cell_name'], identity['public_key']
|
link, identity['cell_name'], identity['public_key']
|
||||||
@@ -311,10 +360,11 @@ class CellLinkManager:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"replay: push to '{link['cell_name']}' failed: {result.get('error')}"
|
f"replay: push to '{link['cell_name']}' failed: {result.get('error')}"
|
||||||
)
|
)
|
||||||
if summary['attempted']:
|
if summary['attempted'] or summary.get('deferred'):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"replay_pending_pushes: {summary['attempted']} attempted, "
|
f"replay_pending_pushes: {summary['attempted']} attempted, "
|
||||||
f"{summary['ok']} ok, {summary['failed']} failed"
|
f"{summary['ok']} ok, {summary['failed']} failed, "
|
||||||
|
f"{summary.get('deferred', 0)} deferred (backoff)"
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ def set_peer_route_via(peer_name):
|
|||||||
)
|
)
|
||||||
if not link:
|
if not link:
|
||||||
return jsonify({'error': f"Cell {via_cell!r} not connected"}), 404
|
return jsonify({'error': f"Cell {via_cell!r} not connected"}), 404
|
||||||
|
if link.get('remote_exit_relay_active'):
|
||||||
|
return jsonify({
|
||||||
|
'error': (
|
||||||
|
f"Cannot route via '{via_cell}': it is already routing peers "
|
||||||
|
f"through this cell — enabling both directions would create a loop"
|
||||||
|
)
|
||||||
|
}), 409
|
||||||
wireguard_manager.update_cell_peer_allowed_ips(
|
wireguard_manager.update_cell_peer_allowed_ips(
|
||||||
link['public_key'], link['vpn_subnet'], add_default_route=True)
|
link['public_key'], link['vpn_subnet'], add_default_route=True)
|
||||||
wireguard_manager.apply_peer_route_via(peer_ip, via_wg_ip=link['dns_ip'])
|
wireguard_manager.apply_peer_route_via(peer_ip, via_wg_ip=link['dns_ip'])
|
||||||
|
|||||||
@@ -210,6 +210,513 @@ if __name__ == '__main__':
|
|||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCheckInviteConflicts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCheckInviteConflicts(unittest.TestCase):
|
||||||
|
"""Tests for CellLinkManager._check_invite_conflicts."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.wg = _make_wg_mock()
|
||||||
|
# wg._get_configured_network returns '10.0.0.0/24' (own subnet)
|
||||||
|
self.nm = _make_nm_mock()
|
||||||
|
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def _add_existing_cell(self, cell_name='cabin', vpn_subnet='10.2.0.0/24',
|
||||||
|
domain='cabin.cell'):
|
||||||
|
"""Add a cell link directly to disk without going through add_connection."""
|
||||||
|
links = [{
|
||||||
|
'cell_name': cell_name,
|
||||||
|
'public_key': 'cabinpubkey=',
|
||||||
|
'endpoint': '9.9.9.9:51820',
|
||||||
|
'vpn_subnet': vpn_subnet,
|
||||||
|
'dns_ip': '10.2.0.1',
|
||||||
|
'domain': domain,
|
||||||
|
'permissions': {'inbound': {}, 'outbound': {}},
|
||||||
|
'remote_api_url': f'http://10.2.0.1:3000',
|
||||||
|
'pending_push': False,
|
||||||
|
'last_push_status': 'ok',
|
||||||
|
'last_push_at': None,
|
||||||
|
'last_push_error': None,
|
||||||
|
'last_remote_update_at': None,
|
||||||
|
}]
|
||||||
|
with open(os.path.join(self.test_dir, 'cell_links.json'), 'w') as f:
|
||||||
|
json.dump(links, f)
|
||||||
|
|
||||||
|
# --- subnet conflicts ---
|
||||||
|
|
||||||
|
def test_subnet_overlaps_own_subnet_raises(self):
|
||||||
|
"""Invite whose vpn_subnet overlaps our own subnet raises ValueError."""
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.0.0.0/24'} # same as own
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr._check_invite_conflicts(invite)
|
||||||
|
self.assertIn('subnet', str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_subnet_overlaps_own_subnet_partial_raises(self):
|
||||||
|
"""Invite whose vpn_subnet partially overlaps our own subnet raises ValueError."""
|
||||||
|
# Own is 10.0.0.0/24; this /16 contains it
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.0.0.0/16'}
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.mgr._check_invite_conflicts(invite)
|
||||||
|
|
||||||
|
def test_subnet_overlaps_connected_cell_raises(self):
|
||||||
|
"""Invite whose vpn_subnet overlaps an already-connected cell raises ValueError."""
|
||||||
|
self._add_existing_cell(vpn_subnet='10.2.0.0/24')
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.2.0.0/24'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr._check_invite_conflicts(invite)
|
||||||
|
self.assertIn('cabin', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_subnet_no_conflict_does_not_raise(self):
|
||||||
|
"""Invite with a non-overlapping subnet passes without error."""
|
||||||
|
self._add_existing_cell(vpn_subnet='10.2.0.0/24')
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.3.0.0/24', 'domain': 'other.cell'}
|
||||||
|
# Should not raise
|
||||||
|
self.mgr._check_invite_conflicts(invite)
|
||||||
|
|
||||||
|
# --- domain conflicts ---
|
||||||
|
|
||||||
|
def test_domain_matches_own_domain_raises(self):
|
||||||
|
"""Invite with a domain equal to this cell's own domain raises ValueError."""
|
||||||
|
with patch('cell_link_manager.CellLinkManager._check_invite_conflicts',
|
||||||
|
wraps=self.mgr._check_invite_conflicts):
|
||||||
|
# Patch config_manager inside the function
|
||||||
|
with patch('cell_link_manager.os.environ.get', return_value='home.cell'):
|
||||||
|
# Use a fresh invite whose domain matches env-derived own domain
|
||||||
|
invite = {**SAMPLE_INVITE,
|
||||||
|
'vpn_subnet': '10.3.0.0/24',
|
||||||
|
'domain': 'home.cell'}
|
||||||
|
# Manually test via app import patch
|
||||||
|
import sys
|
||||||
|
fake_cfg = MagicMock()
|
||||||
|
fake_cfg.configs = {'_identity': {'domain': 'home.cell'}}
|
||||||
|
with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr._check_invite_conflicts(invite)
|
||||||
|
self.assertIn('domain', str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_domain_matches_connected_cell_raises(self):
|
||||||
|
"""Invite with a domain already used by a connected cell raises ValueError."""
|
||||||
|
self._add_existing_cell(domain='cabin.cell', vpn_subnet='10.2.0.0/24')
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.3.0.0/24', 'domain': 'cabin.cell'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr._check_invite_conflicts(invite)
|
||||||
|
self.assertIn('cabin', str(ctx.exception))
|
||||||
|
|
||||||
|
# --- exclude_cell parameter ---
|
||||||
|
|
||||||
|
def test_exclude_cell_skips_that_cell_subnet_check(self):
|
||||||
|
"""With exclude_cell set, the named cell is skipped in subnet conflict check."""
|
||||||
|
self._add_existing_cell(cell_name='cabin', vpn_subnet='10.2.0.0/24',
|
||||||
|
domain='cabin.cell')
|
||||||
|
# Same subnet as cabin — normally a conflict, but excluded
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.2.0.0/24', 'domain': 'cabin.cell'}
|
||||||
|
# Should not raise because 'cabin' is excluded
|
||||||
|
self.mgr._check_invite_conflicts(invite, exclude_cell='cabin')
|
||||||
|
|
||||||
|
def test_exclude_cell_skips_that_cell_domain_check(self):
|
||||||
|
"""With exclude_cell set, the named cell is skipped in domain conflict check."""
|
||||||
|
self._add_existing_cell(cell_name='cabin', vpn_subnet='10.2.0.0/24',
|
||||||
|
domain='cabin.cell')
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.9.0.0/24', 'domain': 'cabin.cell'}
|
||||||
|
# Should not raise — cabin excluded
|
||||||
|
self.mgr._check_invite_conflicts(invite, exclude_cell='cabin')
|
||||||
|
|
||||||
|
def test_exclude_cell_still_checks_other_cells(self):
|
||||||
|
"""Excluding 'cabin' does not suppress conflict with a different cell."""
|
||||||
|
self._add_existing_cell(cell_name='cabin', vpn_subnet='10.2.0.0/24',
|
||||||
|
domain='cabin.cell')
|
||||||
|
# Add a second cell manually
|
||||||
|
with open(os.path.join(self.test_dir, 'cell_links.json')) as f:
|
||||||
|
links = json.load(f)
|
||||||
|
links.append({
|
||||||
|
'cell_name': 'office',
|
||||||
|
'public_key': 'officepubkey=',
|
||||||
|
'vpn_subnet': '10.3.0.0/24',
|
||||||
|
'dns_ip': '10.3.0.1',
|
||||||
|
'domain': 'office.cell',
|
||||||
|
'permissions': {'inbound': {}, 'outbound': {}},
|
||||||
|
'remote_api_url': 'http://10.3.0.1:3000',
|
||||||
|
'pending_push': False,
|
||||||
|
'last_push_status': 'ok',
|
||||||
|
'last_push_at': None,
|
||||||
|
'last_push_error': None,
|
||||||
|
'last_remote_update_at': None,
|
||||||
|
})
|
||||||
|
with open(os.path.join(self.test_dir, 'cell_links.json'), 'w') as f:
|
||||||
|
json.dump(links, f)
|
||||||
|
# Conflicts with 'office', but we only exclude 'cabin'
|
||||||
|
invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.3.0.0/24', 'domain': 'new.cell'}
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.mgr._check_invite_conflicts(invite, exclude_cell='cabin')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestPushInviteToRemote
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPushInviteToRemote(unittest.TestCase):
|
||||||
|
"""Tests for CellLinkManager._push_invite_to_remote."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.wg = _make_wg_mock()
|
||||||
|
self.nm = _make_nm_mock()
|
||||||
|
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def _make_link(self, endpoint='192.168.1.50:51820'):
|
||||||
|
return {
|
||||||
|
'cell_name': 'office',
|
||||||
|
'public_key': 'officepubkey=',
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'vpn_subnet': '10.1.0.0/24',
|
||||||
|
'dns_ip': '10.1.0.1',
|
||||||
|
'domain': 'office.cell',
|
||||||
|
'remote_api_url': 'http://10.1.0.1:3000',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fake_identity(self):
|
||||||
|
return {'cell_name': 'home', 'public_key': 'homepubkey='}
|
||||||
|
|
||||||
|
def test_push_invite_success_2xx_returns_ok_true(self):
|
||||||
|
"""curl returning a 2xx status code → {'ok': True}."""
|
||||||
|
link = self._make_link()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = '201'
|
||||||
|
mock_result.stderr = ''
|
||||||
|
|
||||||
|
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value=self._fake_identity()), \
|
||||||
|
patch('cell_link_manager.os.environ.get', return_value='home.cell'), \
|
||||||
|
patch('subprocess.run', return_value=mock_result):
|
||||||
|
import sys
|
||||||
|
fake_cfg = MagicMock()
|
||||||
|
fake_cfg.configs = {'_identity': {'domain': 'home.cell'}}
|
||||||
|
with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}):
|
||||||
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
|
self.assertTrue(result['ok'])
|
||||||
|
|
||||||
|
def test_push_invite_4xx_returns_ok_false_with_http_error(self):
|
||||||
|
"""curl returning a 4xx status code → {'ok': False, 'error': 'HTTP 4xx'}."""
|
||||||
|
link = self._make_link()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = '400'
|
||||||
|
mock_result.stderr = ''
|
||||||
|
|
||||||
|
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value=self._fake_identity()), \
|
||||||
|
patch('subprocess.run', return_value=mock_result):
|
||||||
|
import sys
|
||||||
|
fake_cfg = MagicMock()
|
||||||
|
fake_cfg.configs = {'_identity': {'domain': 'home.cell'}}
|
||||||
|
with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}):
|
||||||
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertIn('400', result['error'])
|
||||||
|
|
||||||
|
def test_push_invite_no_endpoint_returns_ok_false(self):
|
||||||
|
"""Link with no endpoint → {'ok': False, 'error': 'no endpoint'}."""
|
||||||
|
link = self._make_link(endpoint='')
|
||||||
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertIn('endpoint', result['error'].lower())
|
||||||
|
|
||||||
|
def test_push_invite_none_endpoint_returns_ok_false(self):
|
||||||
|
"""Link with endpoint=None → {'ok': False, 'error': 'no endpoint'}."""
|
||||||
|
link = self._make_link(endpoint='')
|
||||||
|
link['endpoint'] = None
|
||||||
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
|
||||||
|
def test_push_invite_subprocess_error_returns_ok_false(self):
|
||||||
|
"""subprocess.run raising an exception → {'ok': False, 'error': ...}."""
|
||||||
|
link = self._make_link()
|
||||||
|
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value=self._fake_identity()), \
|
||||||
|
patch('subprocess.run', side_effect=OSError('command not found')):
|
||||||
|
import sys
|
||||||
|
fake_cfg = MagicMock()
|
||||||
|
fake_cfg.configs = {'_identity': {'domain': 'home.cell'}}
|
||||||
|
with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}):
|
||||||
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertIsNotNone(result['error'])
|
||||||
|
|
||||||
|
def test_push_invite_curl_nonzero_returncode_returns_ok_false(self):
|
||||||
|
"""curl subprocess returning nonzero returncode → {'ok': False}."""
|
||||||
|
link = self._make_link()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 1
|
||||||
|
mock_result.stdout = ''
|
||||||
|
mock_result.stderr = 'connection refused'
|
||||||
|
|
||||||
|
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value=self._fake_identity()), \
|
||||||
|
patch('subprocess.run', return_value=mock_result):
|
||||||
|
import sys
|
||||||
|
fake_cfg = MagicMock()
|
||||||
|
fake_cfg.configs = {'_identity': {'domain': 'home.cell'}}
|
||||||
|
with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}):
|
||||||
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
|
||||||
|
def test_push_invite_sends_to_correct_lan_host(self):
|
||||||
|
"""The curl URL must use the LAN IP from the endpoint, not the WG dns_ip."""
|
||||||
|
link = self._make_link(endpoint='192.168.31.52:51820')
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(cmd, **kw):
|
||||||
|
captured['cmd'] = cmd
|
||||||
|
r = MagicMock()
|
||||||
|
r.returncode = 0
|
||||||
|
r.stdout = '201'
|
||||||
|
r.stderr = ''
|
||||||
|
return r
|
||||||
|
|
||||||
|
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value=self._fake_identity()), \
|
||||||
|
patch('subprocess.run', fake_run):
|
||||||
|
import sys
|
||||||
|
fake_cfg = MagicMock()
|
||||||
|
fake_cfg.configs = {'_identity': {'domain': 'home.cell'}}
|
||||||
|
with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}):
|
||||||
|
self.mgr._push_invite_to_remote(link)
|
||||||
|
|
||||||
|
url_in_cmd = captured['cmd'][-1]
|
||||||
|
self.assertIn('192.168.31.52', url_in_cmd)
|
||||||
|
self.assertIn('accept-invite', url_in_cmd)
|
||||||
|
# Must NOT use the WG dns_ip (10.1.0.1)
|
||||||
|
self.assertNotIn('10.1.0.1', url_in_cmd)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAcceptInviteNew
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAcceptInviteNew(unittest.TestCase):
|
||||||
|
"""Tests for CellLinkManager.accept_invite — new connection path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.wg = _make_wg_mock()
|
||||||
|
self.nm = _make_nm_mock()
|
||||||
|
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def test_accept_invite_new_cell_adds_wg_peer(self):
|
||||||
|
"""accept_invite for a new cell calls add_cell_peer on WG manager."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.wg.add_cell_peer.assert_called_once_with(
|
||||||
|
name='office',
|
||||||
|
public_key='officepubkey=',
|
||||||
|
endpoint='5.6.7.8:51820',
|
||||||
|
vpn_subnet='10.1.0.0/24',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_accept_invite_new_cell_adds_dns_forward(self):
|
||||||
|
"""accept_invite for a new cell calls add_cell_dns_forward on NM."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.nm.add_cell_dns_forward.assert_called_once_with(
|
||||||
|
domain='office.cell', dns_ip='10.1.0.1')
|
||||||
|
|
||||||
|
def test_accept_invite_new_cell_saves_link(self):
|
||||||
|
"""accept_invite for a new cell saves the link and returns it."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
link = self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.assertEqual(link['cell_name'], 'office')
|
||||||
|
self.assertEqual(len(self.mgr.list_connections()), 1)
|
||||||
|
|
||||||
|
def test_accept_invite_new_cell_sets_pending_push_true(self):
|
||||||
|
"""New link from accept_invite starts with pending_push=True (no push done)."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
link = self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.assertTrue(link['pending_push'])
|
||||||
|
|
||||||
|
def test_accept_invite_missing_cell_name_raises(self):
|
||||||
|
"""Invite missing 'cell_name' field raises ValueError."""
|
||||||
|
invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'cell_name'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr.accept_invite(invite)
|
||||||
|
self.assertIn('cell_name', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_accept_invite_missing_public_key_raises(self):
|
||||||
|
"""Invite missing 'public_key' field raises ValueError."""
|
||||||
|
invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'public_key'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr.accept_invite(invite)
|
||||||
|
self.assertIn('public_key', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_accept_invite_missing_vpn_subnet_raises(self):
|
||||||
|
"""Invite missing 'vpn_subnet' field raises ValueError."""
|
||||||
|
invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'vpn_subnet'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr.accept_invite(invite)
|
||||||
|
self.assertIn('vpn_subnet', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_accept_invite_missing_dns_ip_raises(self):
|
||||||
|
"""Invite missing 'dns_ip' field raises ValueError."""
|
||||||
|
invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'dns_ip'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr.accept_invite(invite)
|
||||||
|
self.assertIn('dns_ip', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_accept_invite_missing_domain_raises(self):
|
||||||
|
"""Invite missing 'domain' field raises ValueError."""
|
||||||
|
invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'domain'}
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.mgr.accept_invite(invite)
|
||||||
|
self.assertIn('domain', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_accept_invite_subnet_conflict_raises(self):
|
||||||
|
"""accept_invite raises ValueError when subnet conflicts with own subnet."""
|
||||||
|
conflicting = {**SAMPLE_INVITE, 'vpn_subnet': '10.0.0.0/24'} # same as own
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.mgr.accept_invite(conflicting)
|
||||||
|
|
||||||
|
def test_accept_invite_already_connected_no_change_returns_existing(self):
|
||||||
|
"""Calling accept_invite again with identical data returns existing link unchanged."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.wg.reset_mock()
|
||||||
|
result = self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.assertEqual(result['cell_name'], 'office')
|
||||||
|
# No second WG peer add
|
||||||
|
self.wg.add_cell_peer.assert_not_called()
|
||||||
|
|
||||||
|
def test_accept_invite_already_connected_dns_ip_change_updates(self):
|
||||||
|
"""accept_invite with changed dns_ip updates the link and DNS forward."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
updated = {**SAMPLE_INVITE, 'dns_ip': '10.1.0.5'}
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
result = self.mgr.accept_invite(updated)
|
||||||
|
self.assertEqual(result['dns_ip'], '10.1.0.5')
|
||||||
|
self.assertEqual(result['remote_api_url'], 'http://10.1.0.5:3000')
|
||||||
|
self.nm.remove_cell_dns_forward.assert_called()
|
||||||
|
self.nm.add_cell_dns_forward.assert_called_with(
|
||||||
|
domain='office.cell', dns_ip='10.1.0.5')
|
||||||
|
|
||||||
|
def test_accept_invite_already_connected_dns_ip_change_does_not_duplicate(self):
|
||||||
|
"""DNS ip update via accept_invite must not create a second link."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.mgr.accept_invite({**SAMPLE_INVITE, 'dns_ip': '10.1.0.5'})
|
||||||
|
self.assertEqual(len(self.mgr.list_connections()), 1)
|
||||||
|
|
||||||
|
def test_accept_invite_already_connected_vpn_subnet_change_calls_update_peer_ip(self):
|
||||||
|
"""accept_invite with changed vpn_subnet calls update_peer_ip on WG manager."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.wg.update_peer_ip = MagicMock(return_value=True)
|
||||||
|
updated = {**SAMPLE_INVITE, 'vpn_subnet': '10.5.0.0/24'}
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
result = self.mgr.accept_invite(updated)
|
||||||
|
self.assertEqual(result['vpn_subnet'], '10.5.0.0/24')
|
||||||
|
self.wg.update_peer_ip.assert_called_once_with('officepubkey=', '10.5.0.0/24')
|
||||||
|
|
||||||
|
def test_accept_invite_already_connected_vpn_subnet_change_reapplies_firewall(self):
|
||||||
|
"""accept_invite with changed vpn_subnet triggers apply_cell_rules."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.wg.update_peer_ip = MagicMock(return_value=True)
|
||||||
|
updated = {**SAMPLE_INVITE, 'vpn_subnet': '10.5.0.0/24'}
|
||||||
|
with patch('firewall_manager.apply_cell_rules') as mock_rules:
|
||||||
|
self.mgr.accept_invite(updated)
|
||||||
|
mock_rules.assert_called()
|
||||||
|
|
||||||
|
def test_accept_invite_does_not_duplicate_on_repeated_call(self):
|
||||||
|
"""Multiple calls with the same invite must leave exactly one link."""
|
||||||
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.mgr.accept_invite(SAMPLE_INVITE)
|
||||||
|
self.assertEqual(len(self.mgr.list_connections()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAddConnectionMutualPairing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAddConnectionMutualPairing(unittest.TestCase):
|
||||||
|
"""Tests for add_connection's mutual pairing via _push_invite_to_remote."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.wg = _make_wg_mock()
|
||||||
|
self.nm = _make_nm_mock()
|
||||||
|
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def _add_with_push(self, push_result):
|
||||||
|
push_mock = MagicMock(return_value=push_result)
|
||||||
|
perm_push = MagicMock(return_value={'ok': True, 'error': None})
|
||||||
|
with patch('cell_link_manager.CellLinkManager._push_invite_to_remote', push_mock), \
|
||||||
|
patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||||
|
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', perm_push), \
|
||||||
|
patch('firewall_manager.apply_cell_rules'):
|
||||||
|
link = self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
return link, push_mock
|
||||||
|
|
||||||
|
def test_add_connection_calls_push_invite_to_remote(self):
|
||||||
|
"""add_connection calls _push_invite_to_remote after adding the connection."""
|
||||||
|
_, push_mock = self._add_with_push({'ok': True, 'error': None})
|
||||||
|
push_mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_add_connection_push_invite_failure_is_nonfatal(self):
|
||||||
|
"""_push_invite_to_remote failure does not prevent connection creation."""
|
||||||
|
link, _ = self._add_with_push({'ok': False, 'error': 'connection refused'})
|
||||||
|
conns = self.mgr.list_connections()
|
||||||
|
self.assertEqual(len(conns), 1)
|
||||||
|
self.assertEqual(conns[0]['cell_name'], 'office')
|
||||||
|
|
||||||
|
def test_add_connection_push_invite_failure_link_still_stored(self):
|
||||||
|
"""Even when push fails, the link is persisted to disk."""
|
||||||
|
_, _ = self._add_with_push({'ok': False, 'error': 'timeout'})
|
||||||
|
mgr2 = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
self.assertEqual(len(mgr2.list_connections()), 1)
|
||||||
|
|
||||||
|
def test_add_connection_with_inbound_services_sets_permissions(self):
|
||||||
|
"""inbound_services passed to add_connection sets permissions correctly."""
|
||||||
|
perm_push = MagicMock(return_value={'ok': True, 'error': None})
|
||||||
|
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||||
|
with patch('cell_link_manager.CellLinkManager._push_invite_to_remote', push_mock), \
|
||||||
|
patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||||
|
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', perm_push), \
|
||||||
|
patch('firewall_manager.apply_cell_rules'):
|
||||||
|
link = self.mgr.add_connection(SAMPLE_INVITE, inbound_services=['calendar'])
|
||||||
|
self.assertTrue(link['permissions']['inbound']['calendar'])
|
||||||
|
self.assertFalse(link['permissions']['inbound']['files'])
|
||||||
|
|
||||||
|
def test_add_connection_push_invite_exception_is_nonfatal(self):
|
||||||
|
"""Exception from _push_invite_to_remote is caught and does not raise."""
|
||||||
|
perm_push = MagicMock(return_value={'ok': True, 'error': None})
|
||||||
|
with patch('cell_link_manager.CellLinkManager._push_invite_to_remote',
|
||||||
|
side_effect=RuntimeError('docker not available')), \
|
||||||
|
patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
|
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||||
|
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', perm_push), \
|
||||||
|
patch('firewall_manager.apply_cell_rules'):
|
||||||
|
link = self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
self.assertEqual(link['cell_name'], 'office')
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TestAddConnectionAtomicity
|
# TestAddConnectionAtomicity
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -695,7 +1202,10 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
# ── replay_pending_pushes ─────────────────────────────────────────────────
|
# ── replay_pending_pushes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_replay_retries_pending_links(self):
|
def test_replay_retries_pending_links(self):
|
||||||
self._add_office(push_ok=False) # leaves pending_push=True
|
self._add_office(push_ok=False) # leaves pending_push=True + next_retry_at set
|
||||||
|
links = self.mgr._load()
|
||||||
|
links[0]['next_retry_at'] = None # simulate backoff window elapsed
|
||||||
|
self.mgr._save(links)
|
||||||
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
|
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
|
||||||
|
|
||||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||||
@@ -721,6 +1231,9 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
|
|
||||||
def test_replay_push_failure_leaves_pending(self):
|
def test_replay_push_failure_leaves_pending(self):
|
||||||
self._add_office(push_ok=False)
|
self._add_office(push_ok=False)
|
||||||
|
links = self.mgr._load()
|
||||||
|
links[0]['next_retry_at'] = None # simulate backoff window elapsed
|
||||||
|
self.mgr._save(links)
|
||||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
|
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
|
||||||
|
|||||||
@@ -737,5 +737,170 @@ class TestSetExitOffer(unittest.TestCase):
|
|||||||
self.assertEqual(r.status_code, 404)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -54,6 +54,31 @@ function StatusDot({ online }) {
|
|||||||
: <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />;
|
: <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SyncBadge({ conn }) {
|
||||||
|
const { last_push_status, last_push_at, last_push_error, pending_push, next_retry_at } = conn;
|
||||||
|
let color, label, tip;
|
||||||
|
if (last_push_status === 'never' || (!last_push_at && !pending_push)) {
|
||||||
|
color = 'bg-gray-300'; label = 'Sync pending';
|
||||||
|
tip = 'Permissions not yet synced to remote cell';
|
||||||
|
} else if (!pending_push && last_push_status === 'ok') {
|
||||||
|
color = 'bg-green-500'; label = `Synced${last_push_at ? ' ' + relativeTime(last_push_at) : ''}`;
|
||||||
|
tip = `Permissions last synced ${last_push_at ? relativeTime(last_push_at) : ''}`;
|
||||||
|
} else {
|
||||||
|
color = 'bg-amber-400'; label = 'Out of sync';
|
||||||
|
tip = last_push_error ? `Sync failed: ${last_push_error}` : 'Permissions pending sync';
|
||||||
|
if (next_retry_at) {
|
||||||
|
const retryIn = Math.max(0, Math.round((new Date(next_retry_at) - Date.now()) / 60000));
|
||||||
|
tip += ` — next retry in ~${retryIn}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-500" title={tip}>
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${color}`} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Toast({ toasts }) {
|
function Toast({ toasts }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
@@ -204,6 +229,7 @@ function CellPanel({ conn, onDisconnect, addToast }) {
|
|||||||
{conn.last_handshake && (
|
{conn.last_handshake && (
|
||||||
<span>{relativeTime(conn.last_handshake)}</span>
|
<span>{relativeTime(conn.last_handshake)}</span>
|
||||||
)}
|
)}
|
||||||
|
<SyncBadge conn={conn} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user