feat(cells): Phase 3 tests + Phase 4 UI for cell service-sharing

Phase 3 — tests (50 new, total now 1071):
- test_cell_link_manager: atomicity (WG fail → DNS not called, link not
  persisted), DNS warning non-fatal, inbound_services arg, unknown service
  filtered, update/get permissions, lazy migration of legacy entries
- test_wireguard_manager: subnet overlap rejection (exact, supernet, adjacent
  non-overlapping, different class-A, honours wg0.conf configured network)
- test_firewall_manager: _cell_tag sanitisation, apply_cell_rules emits correct
  ACCEPT/DROP per service + catch-all DROP, clear_cell_rules no-op and exact
  line removal, apply_all_cell_rules iterates with correct args
- test_cells_endpoints: RuntimeError→400, GET /services, GET/PUT permissions
  (200/400/404 paths, service name validation, arg forwarding)

Phase 4 — UI:
- CellNetwork.jsx: replace flat cell list with CellPanel expandable cards;
  add ServiceShareToggle (ARIA switch, saves immediately), InboundServiceBadge
  (read-only), DisconnectConfirmModal (replaces window.confirm); relative
  timestamps; paste validation on blur; WireGuard status merged by public_key
- api.js: add cellLinkAPI.getPermissions, updatePermissions, getServices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 08:45:32 -04:00
parent 0b103ffafb
commit 562d866a65
6 changed files with 1023 additions and 47 deletions
+216
View File
@@ -406,5 +406,221 @@ class TestUpdateServiceIps(unittest.TestCase):
self.assertNotIn('172.20.0.21', dest_ips)
# ---------------------------------------------------------------------------
# TestCellRules
# ---------------------------------------------------------------------------
class TestCellRules(unittest.TestCase):
"""Tests for apply_cell_rules, clear_cell_rules, _cell_tag, and apply_all_cell_rules."""
# ── helpers ───────────────────────────────────────────────────────────────
def _capture_apply(self, cell_name, vpn_subnet, inbound_services):
"""Run apply_cell_rules with _wg_exec mocked; return list of captured iptables arg lists."""
calls_made = []
def fake_wg_exec(args):
calls_made.append(args)
m = MagicMock()
m.returncode = 0
m.stdout = ''
return m
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec):
firewall_manager.apply_cell_rules(cell_name, vpn_subnet, inbound_services)
return [c for c in calls_made if 'iptables' in c]
def _targets_for_dest(self, iptables_calls, dest_ip):
"""Return list of -j targets where -d matches dest_ip."""
targets = []
for c in iptables_calls:
if '-d' in c and dest_ip in c and '-j' in c:
targets.append(c[c.index('-j') + 1])
return targets
# ── _cell_tag ─────────────────────────────────────────────────────────────
def test_cell_tag_sanitises_spaces_and_punctuation(self):
"""_cell_tag replaces non-alphanumeric chars with dashes."""
tag = firewall_manager._cell_tag('my cell!')
self.assertTrue(tag.startswith('pic-cell-'))
self.assertNotIn(' ', tag)
self.assertNotIn('!', tag)
def test_cell_tag_lowercase(self):
"""_cell_tag lowercases the cell name."""
tag = firewall_manager._cell_tag('Office')
self.assertIn('office', tag)
def test_cell_tag_has_pic_cell_prefix(self):
"""_cell_tag always starts with 'pic-cell-'."""
self.assertTrue(firewall_manager._cell_tag('remote').startswith('pic-cell-'))
def test_cell_tag_distinct_from_peer_tag(self):
"""A cell tag must not equal the peer comment for the same string."""
cell_tag = firewall_manager._cell_tag('10.0.0.2')
peer_tag = firewall_manager._peer_comment('10.0.0.2')
self.assertNotEqual(cell_tag, peer_tag)
# ── apply_cell_rules — catch-all DROP ─────────────────────────────────────
def test_apply_cell_rules_sends_catch_all_drop(self):
"""apply_cell_rules always inserts a DROP for the entire vpn_subnet."""
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
subnet_drops = [
c for c in calls
if '-s' in c and '10.0.1.0/24' in c
and '-j' in c and c[c.index('-j') + 1] == 'DROP'
and '-d' not in c # catch-all has no destination
]
self.assertTrue(subnet_drops, "Expected a catch-all DROP rule for the subnet")
def test_apply_cell_rules_sends_accept_for_allowed_service(self):
"""apply_cell_rules inserts ACCEPT for the calendar VIP when calendar is in inbound."""
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
calendar_ip = firewall_manager.SERVICE_IPS['calendar']
calendar_targets = self._targets_for_dest(calls, calendar_ip)
self.assertIn('ACCEPT', calendar_targets)
def test_apply_cell_rules_sends_drop_for_disallowed_service(self):
"""apply_cell_rules inserts DROP for a service not in inbound_services."""
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
files_ip = firewall_manager.SERVICE_IPS['files']
files_targets = self._targets_for_dest(calls, files_ip)
self.assertIn('DROP', files_targets)
# ── apply_cell_rules — empty inbound (all-deny) ───────────────────────────
def test_apply_cell_rules_empty_inbound_all_drop(self):
"""With inbound_services=[], all per-service rules are DROP."""
calls = self._capture_apply('office', '10.0.1.0/24', [])
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
svc_targets = self._targets_for_dest(calls, svc_ip)
self.assertTrue(svc_targets,
f"Expected at least one rule for {service} ({svc_ip})")
self.assertNotIn('ACCEPT', svc_targets,
f"{service} should be DROP when not in inbound_services")
# ── apply_cell_rules — all inbound (all-accept) ───────────────────────────
def test_apply_cell_rules_all_inbound_all_accept(self):
"""With all four services in inbound, all per-service rules are ACCEPT."""
all_services = list(firewall_manager.SERVICE_IPS.keys())
calls = self._capture_apply('office', '10.0.1.0/24', all_services)
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
svc_targets = self._targets_for_dest(calls, svc_ip)
self.assertIn('ACCEPT', svc_targets,
f"{service} should be ACCEPT when in inbound_services")
# ── apply_cell_rules — all rules tagged ───────────────────────────────────
def test_apply_cell_rules_all_rules_tagged_with_cell_tag(self):
"""Every insertion rule must carry the cell's comment tag."""
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
tag = firewall_manager._cell_tag('office')
for c in calls:
if '-I' in c:
self.assertIn(tag, c, f"Rule missing cell tag: {c}")
# ── clear_cell_rules — noop when no matching rules ────────────────────────
def test_clear_cell_rules_noop_when_no_rules(self):
"""When iptables-save returns no pic-cell-office lines, iptables-restore is NOT called."""
save_output = '*filter\n:FORWARD ACCEPT [0:0]\nCOMMIT\n'
def fake_wg_exec(args):
m = MagicMock()
m.returncode = 0
m.stdout = save_output
return m
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
patch('subprocess.run') as mock_restore:
firewall_manager.clear_cell_rules('office')
mock_restore.assert_not_called()
def test_clear_cell_rules_removes_tagged_lines(self):
"""clear_cell_rules removes lines carrying the cell tag and keeps others."""
tag = firewall_manager._cell_tag('office')
save_output = (
'*filter\n'
':FORWARD ACCEPT [0:0]\n'
f'-A FORWARD -s 10.0.1.0/24 -m comment --comment "{tag}" -j DROP\n'
'-A FORWARD -s 10.0.0.2 -m comment --comment "pic-peer-10-0-0-2/32" -j ACCEPT\n'
'COMMIT\n'
)
restored = []
def fake_wg_exec(args):
m = MagicMock()
m.returncode = 0
if args == ['iptables-save']:
m.stdout = save_output
return m
def fake_restore(cmd, input, **kwargs):
restored.append(input)
m = MagicMock()
m.returncode = 0
return m
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
patch('subprocess.run', side_effect=fake_restore):
firewall_manager.clear_cell_rules('office')
self.assertEqual(len(restored), 1)
content = restored[0]
self.assertNotIn(tag, content)
# peer rule for a different entity must survive
self.assertIn('pic-peer-10-0-0-2/32', content)
# ── apply_all_cell_rules ──────────────────────────────────────────────────
def test_apply_all_cell_rules_calls_apply_for_each(self):
"""apply_all_cell_rules calls apply_cell_rules once per link with correct args."""
cell_links = [
{
'cell_name': 'office',
'vpn_subnet': '10.1.0.0/24',
'permissions': {'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {}},
},
{
'cell_name': 'cabin',
'vpn_subnet': '10.2.0.0/24',
'permissions': {'inbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False},
'outbound': {}},
},
]
with patch.object(firewall_manager, 'apply_cell_rules', return_value=True) as mock_apply:
firewall_manager.apply_all_cell_rules(cell_links)
self.assertEqual(mock_apply.call_count, 2)
call_kwargs = {c.args[0]: c.args for c in mock_apply.call_args_list}
self.assertIn('office', call_kwargs)
self.assertIn('cabin', call_kwargs)
office_args = call_kwargs['office']
self.assertEqual(office_args[1], '10.1.0.0/24')
self.assertIn('calendar', office_args[2])
self.assertNotIn('files', office_args[2])
def test_apply_all_cell_rules_skips_links_with_missing_fields(self):
"""Links without cell_name or vpn_subnet are silently skipped."""
cell_links = [
{'vpn_subnet': '10.1.0.0/24'}, # no cell_name
{'cell_name': 'broken'}, # no vpn_subnet
{'cell_name': 'office', 'vpn_subnet': '10.3.0.0/24',
'permissions': {'inbound': {}, 'outbound': {}}},
]
with patch.object(firewall_manager, 'apply_cell_rules', return_value=True) as mock_apply:
firewall_manager.apply_all_cell_rules(cell_links)
# Only the complete entry should be processed
self.assertEqual(mock_apply.call_count, 1)
self.assertEqual(mock_apply.call_args.args[0], 'office')
if __name__ == '__main__':
unittest.main()