feat(cells): Phase 1 — permission sync between connected PICs
When PIC A updates service sharing permissions, it immediately pushes the mirrored state to PIC B over the WireGuard tunnel so B's UI shows what A is sharing with it in real time. Architecture: - Push model: update_permissions() → _push_permissions_to_remote() → POST /api/cells/peer-sync/permissions on remote cell - Auth: source IP must be inside a known cell's vpn_subnet (WireGuard tunnel proves identity) + body's from_public_key must match stored key - Mirror semantics: our inbound (what we share) → their outbound view - Non-fatal: push failures set pending_push=True; replay_pending_pushes() retries at startup so offline cells catch up on reconnect - add_connection() also pushes initial state so remote sees permissions immediately on the first connect New fields on cell_links.json records (lazy-migrated): remote_api_url, last_push_status, last_push_at, last_push_error, pending_push, last_remote_update_at New endpoint: POST /api/cells/peer-sync/permissions 30 new tests (1101 total). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -398,3 +398,273 @@ class TestLoadMigration(unittest.TestCase):
|
||||
with open(links_file) as f:
|
||||
raw = json.load(f)
|
||||
self.assertIn('permissions', raw[0])
|
||||
|
||||
|
||||
class TestPermissionSync(unittest.TestCase):
|
||||
"""Tests for Phase 1: permission sync between connected PIC cells."""
|
||||
|
||||
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):
|
||||
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_office(self, push_ok=True):
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
|
||||
return_value={'ok': push_ok, 'error': None if push_ok else 'conn refused'}), \
|
||||
patch('firewall_manager.apply_cell_rules'):
|
||||
return self.mgr.add_connection(self.INVITE)
|
||||
|
||||
# ── add_connection ────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_connection_includes_sync_fields(self):
|
||||
link = self._add_office()
|
||||
self.assertIn('remote_api_url', link)
|
||||
self.assertIn('pending_push', link)
|
||||
self.assertIn('last_push_status', link)
|
||||
self.assertIn('last_push_at', link)
|
||||
self.assertIn('last_remote_update_at', link)
|
||||
|
||||
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
|
||||
link = self._add_office()
|
||||
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
|
||||
|
||||
def test_add_connection_triggers_push(self):
|
||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock), \
|
||||
patch('firewall_manager.apply_cell_rules'):
|
||||
self.mgr.add_connection(self.INVITE)
|
||||
push_mock.assert_called_once()
|
||||
call_args = push_mock.call_args[0]
|
||||
self.assertEqual(call_args[1], 'home') # from_cell
|
||||
self.assertEqual(call_args[2], 'homepubkey=') # from_public_key
|
||||
|
||||
def test_add_connection_push_failure_does_not_abort_add(self):
|
||||
link = self._add_office(push_ok=False)
|
||||
conns = self.mgr.list_connections()
|
||||
self.assertEqual(len(conns), 1)
|
||||
self.assertEqual(conns[0]['cell_name'], 'office')
|
||||
self.assertTrue(conns[0]['pending_push'])
|
||||
|
||||
def test_add_connection_push_success_clears_pending(self):
|
||||
self._add_office(push_ok=True)
|
||||
link = self.mgr.list_connections()[0]
|
||||
self.assertFalse(link['pending_push'])
|
||||
self.assertEqual(link['last_push_status'], 'ok')
|
||||
|
||||
# ── update_permissions ────────────────────────────────────────────────────
|
||||
|
||||
def test_update_permissions_push_succeeds_clears_pending(self):
|
||||
self._add_office()
|
||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock), \
|
||||
patch('firewall_manager.apply_cell_rules'):
|
||||
self.mgr.update_permissions('office',
|
||||
{'calendar': True}, {'files': False})
|
||||
link = self.mgr.list_connections()[0]
|
||||
self.assertFalse(link['pending_push'])
|
||||
self.assertEqual(link['last_push_status'], 'ok')
|
||||
self.assertIsNotNone(link['last_push_at'])
|
||||
|
||||
def test_update_permissions_push_failure_keeps_local_save(self):
|
||||
self._add_office()
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
|
||||
return_value={'ok': False, 'error': 'timeout'}), \
|
||||
patch('firewall_manager.apply_cell_rules'):
|
||||
result = self.mgr.update_permissions('office',
|
||||
{'calendar': True}, {})
|
||||
# Local save must have happened — calendar is True
|
||||
self.assertTrue(result['permissions']['inbound']['calendar'])
|
||||
link = self.mgr.list_connections()[0]
|
||||
self.assertTrue(link['pending_push'])
|
||||
self.assertEqual(link['last_push_status'], 'failed')
|
||||
|
||||
def test_update_permissions_does_not_raise_on_push_exception(self):
|
||||
self._add_office()
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
side_effect=RuntimeError('no app context')), \
|
||||
patch('firewall_manager.apply_cell_rules'):
|
||||
# Must not raise
|
||||
result = self.mgr.update_permissions('office', {}, {})
|
||||
self.assertIn('permissions', result)
|
||||
|
||||
# ── _push_permissions_to_remote (unit) ────────────────────────────────────
|
||||
|
||||
def test_push_mirrors_inbound_outbound(self):
|
||||
"""Our inbound (what we share) must become their outbound in the body."""
|
||||
self._add_office()
|
||||
link = self.mgr.list_connections()[0]
|
||||
link['permissions'] = {
|
||||
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||
'outbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False},
|
||||
}
|
||||
|
||||
sent_body = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
import json as _j
|
||||
sent_body.update(_j.loads(req.data))
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
resp.status = 200
|
||||
return resp
|
||||
|
||||
with patch('urllib.request.urlopen', fake_urlopen):
|
||||
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
|
||||
|
||||
self.assertTrue(result['ok'])
|
||||
pushed_perms = sent_body['permissions']
|
||||
# Our inbound=calendar:True → their outbound=calendar:True
|
||||
self.assertTrue(pushed_perms['outbound']['calendar'])
|
||||
# Our outbound=files:True → their inbound=files:True
|
||||
self.assertTrue(pushed_perms['inbound']['files'])
|
||||
|
||||
def test_push_http_error_returns_not_ok(self):
|
||||
self._add_office()
|
||||
link = self.mgr.list_connections()[0]
|
||||
with patch('urllib.request.urlopen',
|
||||
side_effect=__import__('urllib.error', fromlist=['HTTPError']).HTTPError(
|
||||
url='', code=503, msg='Service Unavailable', hdrs=None, fp=None)):
|
||||
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('503', result['error'])
|
||||
|
||||
def test_push_no_remote_api_url_returns_not_ok(self):
|
||||
self._add_office()
|
||||
link = self.mgr.list_connections()[0]
|
||||
link['remote_api_url'] = None
|
||||
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
|
||||
self.assertFalse(result['ok'])
|
||||
|
||||
# ── apply_remote_permissions ──────────────────────────────────────────────
|
||||
|
||||
def test_apply_remote_permissions_stores_by_pubkey(self):
|
||||
self._add_office()
|
||||
with patch('firewall_manager.apply_cell_rules'):
|
||||
updated = self.mgr.apply_remote_permissions(
|
||||
'officepubkey=',
|
||||
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||
'outbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False}},
|
||||
)
|
||||
self.assertTrue(updated['permissions']['inbound']['calendar'])
|
||||
self.assertTrue(updated['permissions']['outbound']['files'])
|
||||
# Persisted to disk
|
||||
link = self.mgr.list_connections()[0]
|
||||
self.assertTrue(link['permissions']['inbound']['calendar'])
|
||||
self.assertIsNotNone(link['last_remote_update_at'])
|
||||
|
||||
def test_apply_remote_permissions_unknown_pubkey_raises(self):
|
||||
self._add_office()
|
||||
with self.assertRaises(ValueError):
|
||||
self.mgr.apply_remote_permissions('nosuchkey=', {})
|
||||
|
||||
def test_apply_remote_permissions_calls_apply_cell_rules(self):
|
||||
self._add_office()
|
||||
with patch('firewall_manager.apply_cell_rules') as mock_rules:
|
||||
self.mgr.apply_remote_permissions(
|
||||
'officepubkey=',
|
||||
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||
'outbound': {}},
|
||||
)
|
||||
mock_rules.assert_called_once_with('office', '10.1.0.0/24', ['calendar'])
|
||||
|
||||
# ── replay_pending_pushes ─────────────────────────────────────────────────
|
||||
|
||||
def test_replay_retries_pending_links(self):
|
||||
self._add_office(push_ok=False) # leaves pending_push=True
|
||||
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
|
||||
|
||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock):
|
||||
summary = self.mgr.replay_pending_pushes()
|
||||
|
||||
push_mock.assert_called_once()
|
||||
self.assertEqual(summary['attempted'], 1)
|
||||
self.assertEqual(summary['ok'], 1)
|
||||
self.assertFalse(self.mgr.list_connections()[0]['pending_push'])
|
||||
|
||||
def test_replay_skips_non_pending_links(self):
|
||||
self._add_office(push_ok=True) # pending_push=False after success
|
||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock):
|
||||
summary = self.mgr.replay_pending_pushes()
|
||||
push_mock.assert_not_called()
|
||||
self.assertEqual(summary['attempted'], 0)
|
||||
|
||||
def test_replay_push_failure_leaves_pending(self):
|
||||
self._add_office(push_ok=False)
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
|
||||
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
|
||||
return_value={'ok': False, 'error': 'timeout'}):
|
||||
summary = self.mgr.replay_pending_pushes()
|
||||
self.assertEqual(summary['failed'], 1)
|
||||
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
|
||||
|
||||
def test_replay_identity_failure_returns_empty_summary(self):
|
||||
self._add_office(push_ok=False)
|
||||
with patch('cell_link_manager.CellLinkManager._local_identity',
|
||||
side_effect=RuntimeError('no app context')):
|
||||
summary = self.mgr.replay_pending_pushes()
|
||||
self.assertEqual(summary['attempted'], 0)
|
||||
|
||||
# ── _load migration ───────────────────────────────────────────────────────
|
||||
|
||||
def test_load_migration_injects_sync_fields_on_legacy_record(self):
|
||||
legacy = [{
|
||||
'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': {}},
|
||||
}]
|
||||
links_file = os.path.join(self.test_dir, 'cell_links.json')
|
||||
with open(links_file, 'w') as f:
|
||||
json.dump(legacy, f)
|
||||
|
||||
links = self.mgr.list_connections()
|
||||
link = links[0]
|
||||
self.assertIn('remote_api_url', link)
|
||||
self.assertIn('pending_push', link)
|
||||
self.assertIn('last_push_status', link)
|
||||
self.assertIn('last_push_at', link)
|
||||
self.assertIn('last_remote_update_at', link)
|
||||
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
|
||||
self.assertTrue(link['pending_push']) # pre-existing → marked pending
|
||||
self.assertEqual(link['last_push_status'], 'never')
|
||||
|
||||
# Fields persisted to disk after migration
|
||||
with open(links_file) as f:
|
||||
raw = json.load(f)
|
||||
self.assertIn('pending_push', raw[0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user