""" Peer access update E2E tests. Verifies that editing a peer's service_access, internet_access, and peer_access settings via PUT /api/peers/ is persisted and reflected in the live iptables and CoreDNS state immediately after the call returns. Run against a live cell: PIC_HOST=localhost pytest tests/e2e/api/test_peer_access_update.py -v """ import time import subprocess import pytest from helpers.api_client import PicAPIClient # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_peer(admin_client, name: str) -> dict: r = admin_client.get(f'/api/peers/{name}') assert r.status_code == 200, f'GET /api/peers/{name} failed: {r.status_code} {r.text}' return r.json() def _update_peer(admin_client, name: str, **fields) -> dict: r = admin_client.put(f'/api/peers/{name}', json=fields) assert r.status_code == 200, f'PUT /api/peers/{name} failed: {r.status_code} {r.text}' return r.json() def _wg_forward_rules(admin_client) -> str: """Return raw iptables FORWARD rules from inside the WireGuard container.""" r = admin_client.post('/api/debug/iptables-forward', json={}) if r.status_code == 200: return r.text # Fallback: read directly via docker exec (only works on the same host) try: result = subprocess.run( ['docker', 'exec', 'cell-wireguard', 'iptables', '-L', 'FORWARD', '-n', '--line-numbers'], capture_output=True, text=True, timeout=5 ) return result.stdout except Exception: return '' def _corefile_content(admin_client) -> str: """Return the current Corefile content via the API or direct read.""" r = admin_client.get('/api/network/dns/corefile') if r.status_code == 200: return r.text # Fallback: read from the mapped config path try: with open('/home/roof/pic/config/dns/Corefile') as f: return f.read() except Exception: return '' # --------------------------------------------------------------------------- # service_access update tests # --------------------------------------------------------------------------- class TestServiceAccessUpdate: def test_restrict_all_services_creates_drop_rule(self, make_peer, admin_client): """Setting service_access=[] creates a DROP rule to Caddy for the peer.""" peer = make_peer('e2etest-svc-drop') peer_ip = peer['ip'] _update_peer(admin_client, peer['name'], internet_access=True, service_access=[], peer_access=True) rules = _wg_forward_rules(admin_client) assert rules, 'Could not read iptables rules' # There should be a DROP rule for this peer IP targeting Caddy port 80 assert 'DROP' in rules and peer_ip.replace('.', '.') in rules, ( f'Expected DROP rule for {peer_ip} after service_access=[], ' f'but rules show:\n{rules}' ) def test_allow_some_services_creates_accept_rule(self, make_peer, admin_client): """Setting service_access=['calendar'] keeps ACCEPT to Caddy; ACL blocks others.""" peer = make_peer('e2etest-svc-partial', service_access=[]) peer_ip = peer['ip'] # Start with no services, then grant calendar only _update_peer(admin_client, peer['name'], internet_access=True, service_access=['calendar'], peer_access=True) rules = _wg_forward_rules(admin_client) assert rules, 'Could not read iptables rules' assert 'ACCEPT' in rules, ( f'Expected ACCEPT rule for {peer_ip} after service_access=[calendar], ' f'got:\n{rules}' ) def test_service_access_reflected_in_peer_registry(self, make_peer, admin_client): """Updated service_access is persisted in the peer registry.""" peer = make_peer('e2etest-svc-persist', service_access=['calendar', 'files', 'mail', 'webdav']) _update_peer(admin_client, peer['name'], internet_access=True, service_access=['calendar'], peer_access=True) stored = _get_peer(admin_client, peer['name']) assert stored.get('service_access') == ['calendar'], ( f"Expected service_access=['calendar'] in registry, got: {stored.get('service_access')}" ) def test_blocked_service_appears_in_corefile_acl(self, make_peer, admin_client): """Blocked services for a peer appear in the CoreDNS Corefile ACL.""" peer = make_peer('e2etest-svc-acl', service_access=['calendar', 'files', 'mail', 'webdav']) peer_ip = peer['ip'] # Block files and webdav _update_peer(admin_client, peer['name'], internet_access=True, service_access=['calendar', 'mail'], peer_access=True) # Small delay for CoreDNS reload to process SIGUSR1 time.sleep(1) corefile = _corefile_content(admin_client) assert corefile, 'Could not read Corefile' assert f'block net {peer_ip}/32' in corefile, ( f'Expected block rule for {peer_ip} in Corefile after removing files/webdav. ' f'Corefile:\n{corefile}' ) def test_fully_allowed_peer_has_no_acl_block(self, make_peer, admin_client): """Peer with all services allowed has no ACL block entry in Corefile.""" peer = make_peer('e2etest-svc-full', service_access=['calendar']) peer_ip = peer['ip'] # Grant all services _update_peer(admin_client, peer['name'], internet_access=True, service_access=['calendar', 'files', 'mail', 'webdav'], peer_access=True) time.sleep(1) corefile = _corefile_content(admin_client) assert f'block net {peer_ip}/32' not in corefile, ( f'No ACL block expected for fully-allowed peer {peer_ip}, but found one. ' f'Corefile:\n{corefile}' ) def test_multiple_peers_blocked_from_same_service_in_single_acl_block( self, make_peer, admin_client ): """Two peers blocked from the same service share one acl block (not separate blocks).""" peer_a = make_peer('e2etest-svc-multi-a', service_access=['calendar', 'files', 'mail', 'webdav']) peer_b = make_peer('e2etest-svc-multi-b', service_access=['calendar', 'files', 'mail', 'webdav']) # Block both from files _update_peer(admin_client, peer_a['name'], service_access=['calendar', 'mail', 'webdav']) _update_peer(admin_client, peer_b['name'], service_access=['calendar', 'mail', 'webdav']) time.sleep(1) corefile = _corefile_content(admin_client) assert corefile, 'Could not read Corefile' # Count how many acl blocks exist for the files service domain = corefile.split('forward . ')[0].strip().rstrip('{').strip() # Find acl blocks for files acl_files_count = corefile.count('acl files.') assert acl_files_count == 1, ( f'Expected exactly 1 acl block for files (both peers merged), ' f'got {acl_files_count}. Corefile:\n{corefile}' ) assert peer_a['ip'] in corefile, f"peer_a IP {peer_a['ip']} not in Corefile ACL" assert peer_b['ip'] in corefile, f"peer_b IP {peer_b['ip']} not in Corefile ACL" # --------------------------------------------------------------------------- # internet_access update tests # --------------------------------------------------------------------------- class TestInternetAccessUpdate: def test_disable_internet_access_sets_config_reinstall_flag(self, make_peer, admin_client): """Changing internet_access sets config_needs_reinstall on the peer.""" peer = make_peer('e2etest-inet-flag') r = _update_peer(admin_client, peer['name'], internet_access=False, service_access=['calendar', 'files', 'mail', 'webdav']) assert r.get('config_changed') is True, ( f'Expected config_changed=True when internet_access changes, got: {r}' ) stored = _get_peer(admin_client, peer['name']) assert stored.get('internet_access') is False assert stored.get('config_needs_reinstall') is True def test_internet_access_persisted(self, make_peer, admin_client): peer = make_peer('e2etest-inet-persist') _update_peer(admin_client, peer['name'], internet_access=False, service_access=['calendar', 'files', 'mail', 'webdav']) stored = _get_peer(admin_client, peer['name']) assert stored.get('internet_access') is False # --------------------------------------------------------------------------- # peer_access update tests # --------------------------------------------------------------------------- class TestPeerAccessUpdate: def test_peer_access_persisted(self, make_peer, admin_client): peer = make_peer('e2etest-peer-access') _update_peer(admin_client, peer['name'], internet_access=True, service_access=['calendar', 'files', 'mail', 'webdav'], peer_access=False) stored = _get_peer(admin_client, peer['name']) assert stored.get('peer_access') is False def test_re_enabling_peer_access_persisted(self, make_peer, admin_client): peer = make_peer('e2etest-peer-reenable') _update_peer(admin_client, peer['name'], peer_access=False, service_access=['calendar', 'files', 'mail', 'webdav']) _update_peer(admin_client, peer['name'], peer_access=True, service_access=['calendar', 'files', 'mail', 'webdav']) stored = _get_peer(admin_client, peer['name']) assert stored.get('peer_access') is True