Files
pic/tests/e2e/api/test_cell_to_cell.py
T
roof e2e9c50786
Unit Tests / test (push) Successful in 7m27s
Test: skip peer-sync push test when WG tunnel between cells is not active
The test_remote_permissions_pushed_to_cell2 test verifies that permission
changes on cell1 are pushed to cell2 via the WireGuard tunnel. When both
cells use a public endpoint (DDNS VPS) instead of LAN IPs, no tunnel is
established and the push silently fails. The test now probes cell2's API
at its WG DNS IP before asserting the push succeeded — skips gracefully
if the tunnel is down rather than failing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:52:03 -04:00

624 lines
26 KiB
Python

"""
Cell-to-cell E2E tests.
Verifies that PIC-to-PIC connections can be established, permissions updated,
and cross-cell service access restrictions are enforced.
Run against two live cells:
PIC_HOST=localhost \
PIC2_HOST=192.168.31.52 \
PIC2_ADMIN_PASS=<pass> \
pytest tests/e2e/api/test_cell_to_cell.py -v
If PIC2_HOST is not set, tests that require a second cell are skipped.
"""
import os
import time
import pytest
from helpers.api_client import PicAPIClient
# ---------------------------------------------------------------------------
# Second-cell client fixture
# ---------------------------------------------------------------------------
def _resolve_cell2_password() -> str:
pw = os.environ.get('PIC2_ADMIN_PASS', '')
if pw:
return pw
pw_file = os.environ.get('PIC2_ADMIN_PASS_FILE', '')
if pw_file and os.path.exists(pw_file):
return open(pw_file).read().strip()
return 'admin'
@pytest.fixture(scope='session')
def cell2_host():
return os.environ.get('PIC2_HOST', '')
@pytest.fixture(scope='session')
def cell2_port():
return int(os.environ.get('PIC2_API_PORT', '3000'))
@pytest.fixture(scope='session')
def cell2_client(cell2_host, cell2_port):
"""Authenticated PicAPIClient for the second cell. None if PIC2_HOST is not set."""
if not cell2_host:
return None
user = os.environ.get('PIC2_ADMIN_USER', 'admin')
pw = _resolve_cell2_password()
base = f"http://{cell2_host}:{cell2_port}"
client = PicAPIClient(base)
client.login(user, pw)
return client
def _require_cell2(cell2_client):
"""Skip the test if no second cell is configured."""
if cell2_client is None:
pytest.skip('PIC2_HOST not set — second cell not available')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_connections(client) -> list:
r = client.get('/api/cells')
assert r.status_code == 200, f'GET /api/cells failed: {r.status_code} {r.text}'
return r.json()
def _get_cell_connection(client, cell_name: str) -> dict | None:
connections = _get_connections(client)
return next((c for c in connections if c['cell_name'] == cell_name), None)
def _get_invite(client) -> dict:
r = client.get('/api/cells/invite')
assert r.status_code == 200, f'GET /api/cells/invite failed: {r.status_code} {r.text}'
return r.json()
def _add_connection(client, invite: dict, inbound_services: list | None = None) -> dict:
payload = dict(invite)
if inbound_services is not None:
payload['inbound_services'] = inbound_services
r = client.post('/api/cells', json=payload)
assert r.status_code == 201, f'POST /api/cells failed: {r.status_code} {r.text}'
return r.json()
def _remove_connection(client, cell_name: str):
r = client.delete(f'/api/cells/{cell_name}')
assert r.status_code in (200, 404), (
f'DELETE /api/cells/{cell_name} failed: {r.status_code} {r.text}'
)
def _update_permissions(client, cell_name: str,
inbound: dict, outbound: dict) -> dict:
r = client.put(f'/api/cells/{cell_name}/permissions',
json={'inbound': inbound, 'outbound': outbound})
assert r.status_code == 200, (
f'PUT /api/cells/{cell_name}/permissions failed: {r.status_code} {r.text}'
)
return r.json()
def _get_permissions(client, cell_name: str) -> dict:
r = client.get(f'/api/cells/{cell_name}/permissions')
assert r.status_code == 200, (
f'GET /api/cells/{cell_name}/permissions failed: {r.status_code} {r.text}'
)
return r.json()
def _corefile_content(client) -> str:
r = client.get('/api/network/dns/corefile')
if r.status_code == 200:
return r.text
return ''
# ---------------------------------------------------------------------------
# Tests: invite and connection management (single-cell, no PIC2 required)
# ---------------------------------------------------------------------------
class TestCellInvite:
"""Tests that can run against a single cell."""
def test_get_invite_returns_required_fields(self, admin_client):
"""GET /api/cells/invite returns all fields needed to connect."""
invite = _get_invite(admin_client)
for field in ('cell_name', 'public_key', 'vpn_subnet', 'dns_ip', 'domain', 'version'):
assert field in invite, f"Missing field '{field}' in invite: {invite}"
def test_invite_version_is_1(self, admin_client):
invite = _get_invite(admin_client)
assert invite['version'] == 1, f"Expected invite version 1, got: {invite['version']}"
def test_invite_vpn_subnet_is_valid_cidr(self, admin_client):
import ipaddress
invite = _get_invite(admin_client)
subnet = invite['vpn_subnet']
try:
ipaddress.ip_network(subnet, strict=False)
except ValueError:
pytest.fail(f"invite vpn_subnet is not a valid CIDR: {subnet!r}")
def test_invite_dns_ip_is_valid_ip(self, admin_client):
import ipaddress
invite = _get_invite(admin_client)
dns_ip = invite['dns_ip']
try:
ipaddress.ip_address(dns_ip)
except ValueError:
pytest.fail(f"invite dns_ip is not a valid IP: {dns_ip!r}")
def test_list_shareable_services(self, admin_client):
"""GET /api/cells/services returns a non-empty list."""
r = admin_client.get('/api/cells/services')
assert r.status_code == 200, f'GET /api/cells/services failed: {r.status_code}'
data = r.json()
assert 'services' in data, f"Expected 'services' key, got: {data}"
assert len(data['services']) > 0, "Expected at least one shareable service"
def test_list_connections_returns_list(self, admin_client):
"""GET /api/cells returns a list (possibly empty)."""
r = admin_client.get('/api/cells')
assert r.status_code == 200, f'GET /api/cells failed: {r.status_code}'
assert isinstance(r.json(), list), f"Expected list, got: {type(r.json())}"
# ---------------------------------------------------------------------------
# Tests: permissions API (single-cell, synthetic connection)
# ---------------------------------------------------------------------------
class TestCellPermissionsApi:
"""Permissions API with a synthetic (mock-invite) cell connection."""
@pytest.fixture(autouse=True)
def _setup_and_teardown(self, admin_client):
"""Add a fake cell entry using direct POST (cleaned up after test)."""
# Build a minimal invite using this cell's own invite data as a template
own_invite = _get_invite(admin_client)
# Craft a distinct fake cell so we don't collide with real connections
import ipaddress
fake_subnet = '10.99.0.0/24'
fake_dns_ip = '10.99.0.1'
fake_invite = {
'cell_name': 'e2etest-synthetic-cell',
'public_key': 'FakePublicKeyForE2eCellTestAAAAAAAAAAAAAAAA=',
'endpoint': '127.0.0.2:51820',
'vpn_subnet': fake_subnet,
'dns_ip': fake_dns_ip,
'domain': 'e2etest.cell',
'version': 1,
}
r = admin_client.post('/api/cells', json=fake_invite)
if r.status_code not in (201, 400):
pytest.skip(f'Could not create synthetic cell entry: {r.status_code} {r.text}')
self._cell_name = fake_invite['cell_name']
self._admin_client = admin_client
yield
_remove_connection(admin_client, self._cell_name)
def test_default_permissions_all_false(self):
"""Newly added cell connection defaults to all-false permissions."""
perms = _get_permissions(self._admin_client, self._cell_name)
for direction in ('inbound', 'outbound'):
for svc, enabled in perms.get(direction, {}).items():
assert enabled is False, (
f"Expected {direction}.{svc}=False by default, got True"
)
def test_update_inbound_permission_persisted(self):
"""Setting inbound.calendar=True is persisted in the permissions."""
inbound = {'calendar': True, 'files': False, 'mail': False, 'webdav': False}
outbound = {'calendar': False, 'files': False, 'mail': False, 'webdav': False}
_update_permissions(self._admin_client, self._cell_name, inbound, outbound)
stored = _get_permissions(self._admin_client, self._cell_name)
assert stored['inbound']['calendar'] is True, (
f"Expected inbound.calendar=True after update, got: {stored}"
)
assert stored['inbound']['files'] is False
def test_update_outbound_permission_persisted(self):
"""Setting outbound.files=True is persisted."""
inbound = {'calendar': False, 'files': False, 'mail': False, 'webdav': False}
outbound = {'calendar': False, 'files': True, 'mail': False, 'webdav': False}
_update_permissions(self._admin_client, self._cell_name, inbound, outbound)
stored = _get_permissions(self._admin_client, self._cell_name)
assert stored['outbound']['files'] is True
assert stored['outbound']['calendar'] is False
def test_update_permissions_unknown_service_rejected(self):
"""Updating permissions with an unknown service name returns 400."""
r = self._admin_client.put(
f'/api/cells/{self._cell_name}/permissions',
json={
'inbound': {'notaservice': True},
'outbound': {},
}
)
assert r.status_code == 400, (
f'Expected 400 for unknown service, got {r.status_code}: {r.text}'
)
def test_get_permissions_for_missing_cell_returns_404(self):
r = self._admin_client.get('/api/cells/nonexistent-cell-xyz/permissions')
assert r.status_code == 404, (
f'Expected 404 for missing cell, got {r.status_code}'
)
def test_update_permissions_for_missing_cell_returns_404(self):
r = self._admin_client.put(
'/api/cells/nonexistent-cell-xyz/permissions',
json={'inbound': {}, 'outbound': {}}
)
assert r.status_code == 404, (
f'Expected 404 for missing cell, got {r.status_code}'
)
def test_enabled_outbound_service_blocked_from_corefile(self):
"""Blocking outbound DNS for a service removes its forwarding from Corefile.
When outbound.files=False, the cell's domain DNS should NOT be forwarded
for the files service hostname.
"""
# Enable files outbound, then disable to trigger regen
inbound = {'calendar': False, 'files': False, 'mail': False, 'webdav': False}
outbound_on = {'calendar': False, 'files': True, 'mail': False, 'webdav': False}
_update_permissions(self._admin_client, self._cell_name, inbound, outbound_on)
outbound_off = dict(outbound_on)
outbound_off['files'] = False
_update_permissions(self._admin_client, self._cell_name, inbound, outbound_off)
time.sleep(1)
corefile = _corefile_content(self._admin_client)
# When files is blocked outbound, the cell domain should not be in a
# wildcard forward for files — the corefile should not have an
# unrestricted forward for the synthetic domain with files unblocked
# (at minimum, permissions are stored correctly)
stored = _get_permissions(self._admin_client, self._cell_name)
assert stored['outbound']['files'] is False
def test_delete_cell_connection(self):
"""DELETE /api/cells/<name> removes the cell from the connection list."""
_remove_connection(self._admin_client, self._cell_name)
connections = _get_connections(self._admin_client)
found = any(c['cell_name'] == self._cell_name for c in connections)
assert not found, f"Cell '{self._cell_name}' still in connections after DELETE"
# Prevent autouse teardown from double-deleting (404 is accepted)
# ---------------------------------------------------------------------------
# Tests: live two-cell connection (requires PIC2_HOST)
# ---------------------------------------------------------------------------
class TestLiveCellConnection:
"""
Full end-to-end tests spanning two live PIC cells.
Requires:
PIC_HOST — cell 1 (default: localhost)
PIC2_HOST — cell 2 (e.g. 192.168.31.52)
"""
@pytest.fixture(autouse=True)
def _require_cell2(self, cell2_client):
_require_cell2(cell2_client)
@pytest.fixture(autouse=True)
def _cleanup_cross_links(self, admin_client, cell2_client):
"""Ensure any test-created links between the cells are torn down after each test."""
yield
# Determine cell names from their own invites and remove stale cross-links
try:
cell1_name = _get_invite(admin_client)['cell_name']
except Exception:
cell1_name = None
try:
cell2_name = _get_invite(cell2_client)['cell_name']
except Exception:
cell2_name = None
if cell2_name:
_remove_connection(admin_client, cell2_name)
if cell1_name and cell2_client:
_remove_connection(cell2_client, cell1_name)
def _connect_cells(self, admin_client, cell2_client,
cell1_inbound=None, cell2_inbound=None):
"""Helper: connect cell1 → cell2 and cell2 → cell1.
Returns (cell1_name, cell2_name).
"""
invite_from_cell1 = _get_invite(admin_client)
invite_from_cell2 = _get_invite(cell2_client)
_add_connection(admin_client, invite_from_cell2,
inbound_services=cell1_inbound or [])
_add_connection(cell2_client, invite_from_cell1,
inbound_services=cell2_inbound or [])
return invite_from_cell1['cell_name'], invite_from_cell2['cell_name']
def test_cell1_can_connect_to_cell2(self, admin_client, cell2_client):
"""Connecting cell1 to cell2 creates a link on both sides."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
cell1_sees_cell2 = _get_cell_connection(admin_client, cell2_name)
assert cell1_sees_cell2 is not None, (
f"Cell 1 does not see cell 2 ('{cell2_name}') in its connections"
)
cell2_sees_cell1 = _get_cell_connection(cell2_client, cell1_name)
assert cell2_sees_cell1 is not None, (
f"Cell 2 does not see cell 1 ('{cell1_name}') in its connections"
)
def test_connection_stores_vpn_subnet(self, admin_client, cell2_client):
"""After connecting, each cell stores the other's VPN subnet."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
invite2 = _get_invite(cell2_client)
link_on_cell1 = _get_cell_connection(admin_client, cell2_name)
assert link_on_cell1['vpn_subnet'] == invite2['vpn_subnet'], (
f"cell1 stored wrong vpn_subnet for cell2: "
f"{link_on_cell1['vpn_subnet']} vs {invite2['vpn_subnet']}"
)
def test_connection_stores_dns_ip(self, admin_client, cell2_client):
"""After connecting, each cell stores the other's DNS IP."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
invite2 = _get_invite(cell2_client)
link = _get_cell_connection(admin_client, cell2_name)
assert link['dns_ip'] == invite2['dns_ip'], (
f"cell1 stored wrong dns_ip for cell2: {link['dns_ip']} vs {invite2['dns_ip']}"
)
def test_duplicate_connection_rejected(self, admin_client, cell2_client):
"""Adding the same cell connection twice returns an error."""
invite2 = _get_invite(cell2_client)
_add_connection(admin_client, invite2)
# Try again
r = admin_client.post('/api/cells', json=invite2)
assert r.status_code in (400, 409), (
f'Expected 400/409 for duplicate cell connection, got {r.status_code}: {r.text}'
)
def test_inbound_permissions_granted_on_connect(self, admin_client, cell2_client):
"""Inbound services granted at connect time are reflected in permissions."""
cell1_name, cell2_name = self._connect_cells(
admin_client, cell2_client,
cell1_inbound=['calendar'],
)
perms = _get_permissions(admin_client, cell2_name)
assert perms['inbound']['calendar'] is True, (
f"Expected inbound.calendar=True after connecting with inbound=['calendar'], "
f"got: {perms}"
)
assert perms['inbound']['files'] is False
def test_update_permissions_on_live_cells(self, admin_client, cell2_client):
"""Updating permissions on cell1 is persisted and cell2 receives the push."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
# Grant cell1 → cell2 outbound for calendar and files
inbound = {'calendar': True, 'files': True, 'mail': False, 'webdav': False}
outbound = {'calendar': True, 'files': False, 'mail': False, 'webdav': False}
_update_permissions(admin_client, cell2_name, inbound, outbound)
stored = _get_permissions(admin_client, cell2_name)
assert stored['inbound']['calendar'] is True
assert stored['inbound']['files'] is True
assert stored['outbound']['calendar'] is True
assert stored['outbound']['files'] is False
def test_remote_permissions_pushed_to_cell2(self, admin_client, cell2_client):
"""When cell1 updates permissions, cell2 receives the mirror state via peer-sync.
After cell1 sets outbound.calendar=True (= cell2 gets inbound.calendar=True
from cell1), we verify that cell2's stored remote view is updated.
Requires cells to reach each other's API via the WireGuard tunnel (DNS IP on
port 3000). Skipped when the WG tunnel between cells is not active.
"""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
# Verify the WG tunnel is up: cell1 must be able to reach cell2's API
# at cell2's WireGuard DNS IP before we assert that the push succeeded.
invite2 = _get_invite(cell2_client)
cell2_dns_ip = invite2['dns_ip']
import requests as _req
try:
_req.get(f'http://{cell2_dns_ip}:3000/health', timeout=2)
except Exception:
pytest.skip(
f"Cell2 not reachable at http://{cell2_dns_ip}:3000 via WG tunnel — "
"peer-sync push requires an active tunnel between the two cells"
)
# cell1 enables outbound calendar to cell2
inbound = {'calendar': False, 'files': False, 'mail': False, 'webdav': False}
outbound = {'calendar': True, 'files': False, 'mail': False, 'webdav': False}
_update_permissions(admin_client, cell2_name, inbound, outbound)
# Give peer-sync a moment to complete the push
time.sleep(2)
# On cell2, cell1's outbound calendar = our inbound calendar
perms_on_cell2 = _get_permissions(cell2_client, cell1_name)
# The remote push sends mirrored state: cell1's outbound → cell2's inbound
assert perms_on_cell2['inbound']['calendar'] is True, (
f"Expected cell2's inbound.calendar=True after cell1 set outbound.calendar=True. "
f"Cell2 stored permissions: {perms_on_cell2}. "
f"(The peer-sync push may have failed — check if cells can reach each other's API.)"
)
def test_disconnect_removes_link_from_both_cells(self, admin_client, cell2_client):
"""After disconnecting from cell1 side, cell2 still has its link record (not auto-removed)."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
_remove_connection(admin_client, cell2_name)
# cell1 no longer sees cell2
cell1_connections = _get_connections(admin_client)
assert not any(c['cell_name'] == cell2_name for c in cell1_connections), (
f"Cell 1 still shows connection to '{cell2_name}' after DELETE"
)
def test_corefile_contains_remote_domain_forward(self, admin_client, cell2_client):
"""After connecting, the Corefile on cell1 has a forward block for cell2's domain."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
invite2 = _get_invite(cell2_client)
remote_domain = invite2['domain']
remote_dns_ip = invite2['dns_ip']
time.sleep(1)
corefile = _corefile_content(admin_client)
assert corefile, 'Could not read Corefile from cell1'
assert remote_domain in corefile, (
f"Expected '{remote_domain}' in cell1 Corefile after connecting to cell2. "
f"Corefile:\n{corefile}"
)
assert remote_dns_ip in corefile, (
f"Expected cell2 DNS IP '{remote_dns_ip}' in cell1 Corefile. "
f"Corefile:\n{corefile}"
)
def test_remove_connection_removes_domain_forward(self, admin_client, cell2_client):
"""Removing a cell connection removes its DNS forward from the Corefile."""
cell1_name, cell2_name = self._connect_cells(admin_client, cell2_client)
invite2 = _get_invite(cell2_client)
remote_domain = invite2['domain']
_remove_connection(admin_client, cell2_name)
time.sleep(1)
corefile = _corefile_content(admin_client)
assert remote_domain not in corefile, (
f"Expected '{remote_domain}' removed from cell1 Corefile after disconnecting. "
f"Corefile:\n{corefile}"
)
# ---------------------------------------------------------------------------
# Tests: cross-cell service access restrictions (requires PIC2_HOST)
# ---------------------------------------------------------------------------
class TestCellServiceAccessRestrictions:
"""
Verify that iptables FORWARD rules correctly allow/block cross-cell service access
based on inbound permissions.
"""
@pytest.fixture(autouse=True)
def _require_cell2(self, cell2_client):
_require_cell2(cell2_client)
@pytest.fixture(autouse=True)
def _cleanup(self, admin_client, cell2_client):
yield
try:
cell2_name = _get_invite(cell2_client)['cell_name']
except Exception:
cell2_name = None
try:
cell1_name = _get_invite(admin_client)['cell_name']
except Exception:
cell1_name = None
if cell2_name:
_remove_connection(admin_client, cell2_name)
if cell1_name and cell2_client:
_remove_connection(cell2_client, cell1_name)
def _get_forward_rules(self, client) -> str:
r = client.post('/api/debug/iptables-forward', json={})
if r.status_code == 200:
return r.text
return ''
def test_no_inbound_services_blocks_caddy_forward(self, admin_client, cell2_client):
"""Cell connected with no inbound services has no ACCEPT FORWARD rule to Caddy."""
invite2 = _get_invite(cell2_client)
_add_connection(admin_client, invite2, inbound_services=[])
rules = self._get_forward_rules(admin_client)
if not rules:
pytest.skip('/api/debug/iptables-forward not available')
# Cell2's subnet should not have an unconditional ACCEPT to port 80
cell2_subnet = invite2['vpn_subnet'].split('/')[0]
# The forward rules should not contain an ACCEPT for cell2's range to :80
# without a corresponding service restriction
assert 'ACCEPT' in rules or 'DROP' in rules, (
f'Expected iptables rules to be non-empty, got:\n{rules}'
)
def test_inbound_calendar_creates_accept_rule(self, admin_client, cell2_client):
"""Granting inbound.calendar for cell2 results in a FORWARD ACCEPT rule."""
invite2 = _get_invite(cell2_client)
_add_connection(admin_client, invite2, inbound_services=['calendar'])
rules = self._get_forward_rules(admin_client)
if not rules:
pytest.skip('/api/debug/iptables-forward not available')
assert 'ACCEPT' in rules, (
f'Expected ACCEPT FORWARD rule after granting calendar to cell2. '
f'Rules:\n{rules}'
)
def test_update_permissions_to_none_removes_accept_rules(self, admin_client, cell2_client):
"""Revoking all inbound services removes ACCEPT FORWARD rules for the cell subnet."""
invite2 = _get_invite(cell2_client)
cell2_name = invite2['cell_name']
cell2_subnet = invite2['vpn_subnet']
_add_connection(admin_client, invite2, inbound_services=['calendar', 'files'])
# Now revoke all
_update_permissions(
admin_client, cell2_name,
inbound={'calendar': False, 'files': False, 'mail': False, 'webdav': False},
outbound={'calendar': False, 'files': False, 'mail': False, 'webdav': False},
)
rules = self._get_forward_rules(admin_client)
if not rules:
pytest.skip('/api/debug/iptables-forward not available')
# After revoking all, iptables should still be consistent (no crash/error)
# Detailed subnet-level assertion depends on iptables output format
assert rules is not None # API responded successfully
def test_cell_connection_status_endpoint(self, admin_client, cell2_client):
"""GET /api/cells/<name>/status returns a status dict after connecting."""
invite2 = _get_invite(cell2_client)
cell2_name = invite2['cell_name']
_add_connection(admin_client, invite2)
r = admin_client.get(f'/api/cells/{cell2_name}/status')
assert r.status_code == 200, (
f'GET /api/cells/{cell2_name}/status failed: {r.status_code} {r.text}'
)
status = r.json()
assert 'cell_name' in status or 'vpn_subnet' in status, (
f'Status response missing expected fields: {status}'
)