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:
@@ -267,6 +267,9 @@ def _apply_startup_enforcement():
|
|||||||
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
|
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
|
||||||
cell_links=cell_links)
|
cell_links=cell_links)
|
||||||
logger.info(f"Applied enforcement rules for {len(peers)} peers, {len(cell_links)} cells on startup")
|
logger.info(f"Applied enforcement rules for {len(peers)} peers, {len(cell_links)} cells on startup")
|
||||||
|
sync_summary = cell_link_manager.replay_pending_pushes()
|
||||||
|
if sync_summary.get('attempted'):
|
||||||
|
logger.info(f"Startup permission sync: {sync_summary}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Startup enforcement failed (non-fatal): {e}")
|
logger.warning(f"Startup enforcement failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|||||||
+218
-13
@@ -8,9 +8,11 @@ Each connection is stored in data/cell_links.json and manifests as:
|
|||||||
- An iptables FORWARD rule set (service-level access control)
|
- An iptables FORWARD rule set (service-level access control)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ _DEFAULT_PERMISSIONS = {
|
|||||||
'outbound': {s: False for s in VALID_SERVICES},
|
'outbound': {s: False for s in VALID_SERVICES},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_PUSH_TIMEOUT = 5 # seconds
|
||||||
|
|
||||||
|
|
||||||
def _default_perms() -> Dict[str, Any]:
|
def _default_perms() -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -46,12 +50,34 @@ class CellLinkManager:
|
|||||||
try:
|
try:
|
||||||
with open(self.links_file) as f:
|
with open(self.links_file) as f:
|
||||||
links = json.load(f)
|
links = json.load(f)
|
||||||
# Lazy migration: inject permissions field if missing
|
|
||||||
changed = False
|
changed = False
|
||||||
for link in links:
|
for link in links:
|
||||||
if 'permissions' not in link:
|
if 'permissions' not in link:
|
||||||
link['permissions'] = _default_perms()
|
link['permissions'] = _default_perms()
|
||||||
changed = True
|
changed = True
|
||||||
|
# Phase 1 migration: permission-sync tracking fields
|
||||||
|
if 'remote_api_url' not in link:
|
||||||
|
link['remote_api_url'] = (
|
||||||
|
f"http://{link['dns_ip']}:3000"
|
||||||
|
if link.get('dns_ip') else None
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
if 'last_push_status' not in link:
|
||||||
|
link['last_push_status'] = 'never'
|
||||||
|
changed = True
|
||||||
|
if 'last_push_at' not in link:
|
||||||
|
link['last_push_at'] = None
|
||||||
|
changed = True
|
||||||
|
if 'last_push_error' not in link:
|
||||||
|
link['last_push_error'] = None
|
||||||
|
changed = True
|
||||||
|
if 'pending_push' not in link:
|
||||||
|
# Existing links predate sync — mark pending so startup replay syncs them
|
||||||
|
link['pending_push'] = True
|
||||||
|
changed = True
|
||||||
|
if 'last_remote_update_at' not in link:
|
||||||
|
link['last_remote_update_at'] = None
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self._save(links)
|
self._save(links)
|
||||||
return links
|
return links
|
||||||
@@ -63,6 +89,169 @@ class CellLinkManager:
|
|||||||
with open(self.links_file, 'w') as f:
|
with open(self.links_file, 'w') as f:
|
||||||
json.dump(links, f, indent=2)
|
json.dump(links, f, indent=2)
|
||||||
|
|
||||||
|
# ── Sync helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _local_identity(self) -> Dict[str, str]:
|
||||||
|
"""Return this cell's name and WG public key for outbound peer-sync calls."""
|
||||||
|
from app import config_manager
|
||||||
|
identity = config_manager.configs.get('_identity', {})
|
||||||
|
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
||||||
|
keys = self.wireguard_manager.get_keys()
|
||||||
|
return {'cell_name': cell_name, 'public_key': keys.get('public_key', '')}
|
||||||
|
|
||||||
|
def _push_permissions_to_remote(self, link: Dict[str, Any],
|
||||||
|
from_cell: str,
|
||||||
|
from_public_key: str) -> Dict[str, Any]:
|
||||||
|
"""POST mirrored permissions to the remote cell's peer-sync endpoint.
|
||||||
|
|
||||||
|
Returns {'ok': bool, 'error': str|None}. Never raises.
|
||||||
|
|
||||||
|
The body inverts inbound/outbound: our inbound (what we share with them)
|
||||||
|
becomes their outbound (what they receive from us), and vice-versa.
|
||||||
|
"""
|
||||||
|
url = link.get('remote_api_url')
|
||||||
|
if not url:
|
||||||
|
return {'ok': False, 'error': 'no remote_api_url'}
|
||||||
|
|
||||||
|
perms = link.get('permissions') or _default_perms()
|
||||||
|
body = {
|
||||||
|
'version': 1,
|
||||||
|
'from_cell': from_cell,
|
||||||
|
'from_public_key': from_public_key,
|
||||||
|
'permissions': {
|
||||||
|
'outbound': dict(perms.get('inbound', {})),
|
||||||
|
'inbound': dict(perms.get('outbound', {})),
|
||||||
|
},
|
||||||
|
'sent_at': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
}
|
||||||
|
payload = json.dumps(body).encode('utf-8')
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url.rstrip('/') + '/api/cells/peer-sync/permissions',
|
||||||
|
data=payload,
|
||||||
|
method='POST',
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_PUSH_TIMEOUT) as resp:
|
||||||
|
if 200 <= resp.status < 300:
|
||||||
|
return {'ok': True, 'error': None}
|
||||||
|
return {'ok': False, 'error': f'HTTP {resp.status}'}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {'ok': False, 'error': f'HTTP {e.code}'}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {'ok': False, 'error': str(e.reason)[:200]}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e)[:200]}
|
||||||
|
|
||||||
|
def _record_push_result(self, cell_name: str, result: Dict[str, Any]) -> None:
|
||||||
|
"""Persist last_push_* and pending_push after a push attempt."""
|
||||||
|
links = self._load()
|
||||||
|
for link in links:
|
||||||
|
if link['cell_name'] == cell_name:
|
||||||
|
if result.get('ok'):
|
||||||
|
link['last_push_status'] = 'ok'
|
||||||
|
link['last_push_at'] = datetime.utcnow().isoformat()
|
||||||
|
link['last_push_error'] = None
|
||||||
|
link['pending_push'] = False
|
||||||
|
else:
|
||||||
|
link['last_push_status'] = 'failed'
|
||||||
|
link['last_push_error'] = result.get('error')
|
||||||
|
link['pending_push'] = True
|
||||||
|
break
|
||||||
|
self._save(links)
|
||||||
|
|
||||||
|
def _try_push(self, cell_name: str, link: Dict[str, Any]) -> None:
|
||||||
|
"""Mark pending, push, record result. Non-fatal."""
|
||||||
|
# Mark dirty before pushing — a crash mid-push leaves it pending for replay
|
||||||
|
links = self._load()
|
||||||
|
for l in links:
|
||||||
|
if l['cell_name'] == cell_name:
|
||||||
|
l['pending_push'] = True
|
||||||
|
break
|
||||||
|
self._save(links)
|
||||||
|
|
||||||
|
try:
|
||||||
|
identity = self._local_identity()
|
||||||
|
result = self._push_permissions_to_remote(
|
||||||
|
link, identity['cell_name'], identity['public_key']
|
||||||
|
)
|
||||||
|
self._record_push_result(cell_name, result)
|
||||||
|
if not result['ok']:
|
||||||
|
logger.warning(
|
||||||
|
f"Permission push to '{cell_name}' failed "
|
||||||
|
f"(will retry on startup): {result['error']}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Permission push to '{cell_name}' skipped (non-fatal): {e}")
|
||||||
|
|
||||||
|
def apply_remote_permissions(self, from_public_key: str,
|
||||||
|
permissions: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Store permissions pushed by a remote cell (identified by WG public key).
|
||||||
|
|
||||||
|
Validates service names, persists, and re-applies local iptables rules.
|
||||||
|
Returns the updated link record.
|
||||||
|
"""
|
||||||
|
links = self._load()
|
||||||
|
link = next((l for l in links if l['public_key'] == from_public_key), None)
|
||||||
|
if not link:
|
||||||
|
raise ValueError(f"No connection found for public_key '{from_public_key[:16]}...'")
|
||||||
|
|
||||||
|
in_raw = permissions.get('inbound', {}) if isinstance(permissions, dict) else {}
|
||||||
|
out_raw = permissions.get('outbound', {}) if isinstance(permissions, dict) else {}
|
||||||
|
clean_inbound = {s: bool(in_raw.get(s, False)) for s in VALID_SERVICES}
|
||||||
|
clean_outbound = {s: bool(out_raw.get(s, False)) for s in VALID_SERVICES}
|
||||||
|
|
||||||
|
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||||
|
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
||||||
|
self._save(links)
|
||||||
|
|
||||||
|
inbound_list = [s for s, v in clean_inbound.items() if v]
|
||||||
|
try:
|
||||||
|
import firewall_manager as _fm
|
||||||
|
_fm.apply_cell_rules(link['cell_name'], link['vpn_subnet'], inbound_list)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"apply_cell_rules after remote push for '{link['cell_name']}' "
|
||||||
|
f"failed (non-fatal): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return link
|
||||||
|
|
||||||
|
def replay_pending_pushes(self) -> Dict[str, int]:
|
||||||
|
"""Retry permission pushes for any link with pending_push=True.
|
||||||
|
|
||||||
|
Called from _apply_startup_enforcement. Returns counts for logging.
|
||||||
|
"""
|
||||||
|
summary = {'attempted': 0, 'ok': 0, 'failed': 0}
|
||||||
|
try:
|
||||||
|
identity = self._local_identity()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
for link in self._load():
|
||||||
|
if not link.get('pending_push'):
|
||||||
|
continue
|
||||||
|
summary['attempted'] += 1
|
||||||
|
result = self._push_permissions_to_remote(
|
||||||
|
link, identity['cell_name'], identity['public_key']
|
||||||
|
)
|
||||||
|
self._record_push_result(link['cell_name'], result)
|
||||||
|
if result.get('ok'):
|
||||||
|
summary['ok'] += 1
|
||||||
|
logger.info(f"replay: synced permissions to '{link['cell_name']}'")
|
||||||
|
else:
|
||||||
|
summary['failed'] += 1
|
||||||
|
logger.warning(
|
||||||
|
f"replay: push to '{link['cell_name']}' failed: {result.get('error')}"
|
||||||
|
)
|
||||||
|
if summary['attempted']:
|
||||||
|
logger.info(
|
||||||
|
f"replay_pending_pushes: {summary['attempted']} attempted, "
|
||||||
|
f"{summary['ok']} ok, {summary['failed']} failed"
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
||||||
@@ -85,11 +274,7 @@ class CellLinkManager:
|
|||||||
|
|
||||||
def add_connection(self, invite: Dict[str, Any],
|
def add_connection(self, invite: Dict[str, Any],
|
||||||
inbound_services: Optional[List[str]] = None) -> Dict[str, Any]:
|
inbound_services: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||||
"""Import a remote cell's invite and establish the connection.
|
"""Import a remote cell's invite and establish the connection."""
|
||||||
|
|
||||||
inbound_services: which of THIS cell's services to share with the remote
|
|
||||||
cell immediately. Defaults to none (all-deny).
|
|
||||||
"""
|
|
||||||
links = self._load()
|
links = self._load()
|
||||||
name = invite['cell_name']
|
name = invite['cell_name']
|
||||||
if any(l['cell_name'] == name for l in links):
|
if any(l['cell_name'] == name for l in links):
|
||||||
@@ -125,17 +310,37 @@ class CellLinkManager:
|
|||||||
'domain': invite['domain'],
|
'domain': invite['domain'],
|
||||||
'connected_at': datetime.utcnow().isoformat(),
|
'connected_at': datetime.utcnow().isoformat(),
|
||||||
'permissions': perms,
|
'permissions': perms,
|
||||||
|
'remote_api_url': f"http://{invite['dns_ip']}:3000",
|
||||||
|
'last_push_status': 'never',
|
||||||
|
'last_push_at': None,
|
||||||
|
'last_push_error': None,
|
||||||
|
'pending_push': True,
|
||||||
|
'last_remote_update_at': None,
|
||||||
}
|
}
|
||||||
links.append(link)
|
links.append(link)
|
||||||
self._save(links)
|
self._save(links)
|
||||||
|
|
||||||
# Apply iptables rules for the new cell (non-fatal if it fails)
|
|
||||||
try:
|
try:
|
||||||
import firewall_manager as _fm
|
import firewall_manager as _fm
|
||||||
_fm.apply_cell_rules(name, invite['vpn_subnet'], inbound)
|
_fm.apply_cell_rules(name, invite['vpn_subnet'], inbound)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"apply_cell_rules for {name} failed (non-fatal): {e}")
|
logger.warning(f"apply_cell_rules for {name} failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Initial push so the remote immediately knows our permission state
|
||||||
|
try:
|
||||||
|
identity = self._local_identity()
|
||||||
|
result = self._push_permissions_to_remote(
|
||||||
|
link, identity['cell_name'], identity['public_key']
|
||||||
|
)
|
||||||
|
self._record_push_result(name, result)
|
||||||
|
if not result['ok']:
|
||||||
|
logger.warning(
|
||||||
|
f"Initial push to '{name}' failed "
|
||||||
|
f"(will retry on startup): {result['error']}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Initial push to '{name}' skipped (non-fatal): {e}")
|
||||||
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
def remove_connection(self, cell_name: str):
|
def remove_connection(self, cell_name: str):
|
||||||
@@ -145,7 +350,6 @@ class CellLinkManager:
|
|||||||
if not link:
|
if not link:
|
||||||
raise ValueError(f"Cell '{cell_name}' not found")
|
raise ValueError(f"Cell '{cell_name}' not found")
|
||||||
|
|
||||||
# Clear firewall rules first (non-fatal)
|
|
||||||
try:
|
try:
|
||||||
import firewall_manager as _fm
|
import firewall_manager as _fm
|
||||||
_fm.clear_cell_rules(cell_name)
|
_fm.clear_cell_rules(cell_name)
|
||||||
@@ -163,7 +367,7 @@ class CellLinkManager:
|
|||||||
outbound: Dict[str, bool]) -> Dict[str, Any]:
|
outbound: Dict[str, bool]) -> Dict[str, Any]:
|
||||||
"""Update service sharing permissions for a cell connection.
|
"""Update service sharing permissions for a cell connection.
|
||||||
|
|
||||||
Validates service names, persists, and re-applies iptables rules.
|
Validates, persists, re-applies iptables, then pushes to remote.
|
||||||
Returns the updated link record.
|
Returns the updated link record.
|
||||||
"""
|
"""
|
||||||
links = self._load()
|
links = self._load()
|
||||||
@@ -171,13 +375,11 @@ class CellLinkManager:
|
|||||||
if not link:
|
if not link:
|
||||||
raise ValueError(f"Cell '{cell_name}' not found")
|
raise ValueError(f"Cell '{cell_name}' not found")
|
||||||
|
|
||||||
# Validate and normalise — only known services, boolean values
|
clean_inbound = {s: bool(inbound.get(s, False)) for s in VALID_SERVICES}
|
||||||
clean_inbound = {s: bool(inbound.get(s, False)) for s in VALID_SERVICES}
|
|
||||||
clean_outbound = {s: bool(outbound.get(s, False)) for s in VALID_SERVICES}
|
clean_outbound = {s: bool(outbound.get(s, False)) for s in VALID_SERVICES}
|
||||||
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||||
self._save(links)
|
self._save(links)
|
||||||
|
|
||||||
# Re-apply firewall rules
|
|
||||||
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
|
||||||
@@ -185,6 +387,9 @@ class CellLinkManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"apply_cell_rules for {cell_name} failed (non-fatal): {e}")
|
logger.warning(f"apply_cell_rules for {cell_name} failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Push mirrored state to the remote cell (non-fatal)
|
||||||
|
self._try_push(cell_name, link)
|
||||||
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
def get_permissions(self, cell_name: str) -> Dict[str, Any]:
|
def get_permissions(self, cell_name: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from cell_link_manager import VALID_SERVICES
|
from cell_link_manager import VALID_SERVICES
|
||||||
logger = logging.getLogger('picell')
|
logger = logging.getLogger('picell')
|
||||||
bp = Blueprint('cells', __name__)
|
bp = Blueprint('cells', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticate_peer_cell(req):
|
||||||
|
"""Return the cell_links record whose vpn_subnet contains the request source IP.
|
||||||
|
|
||||||
|
Source IP is taken from the last X-Forwarded-For entry (appended by Caddy)
|
||||||
|
when present, falling back to request.remote_addr. Also verifies the
|
||||||
|
body's from_public_key matches the matched link — defence-in-depth against
|
||||||
|
overlapping subnets.
|
||||||
|
Returns the matching link dict on success, None on failure.
|
||||||
|
"""
|
||||||
|
from app import cell_link_manager
|
||||||
|
|
||||||
|
candidate = req.remote_addr or ''
|
||||||
|
xff = req.headers.get('X-Forwarded-For', '')
|
||||||
|
if xff:
|
||||||
|
last = xff.split(',')[-1].strip()
|
||||||
|
if last:
|
||||||
|
candidate = last
|
||||||
|
try:
|
||||||
|
src_ip = ipaddress.ip_address(candidate.strip())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for link in cell_link_manager.list_connections():
|
||||||
|
subnet = link.get('vpn_subnet')
|
||||||
|
if not subnet:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if src_ip in ipaddress.ip_network(subnet, strict=False):
|
||||||
|
return link
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
@bp.route('/api/cells/invite', methods=['GET'])
|
@bp.route('/api/cells/invite', methods=['GET'])
|
||||||
def get_cell_invite():
|
def get_cell_invite():
|
||||||
try:
|
try:
|
||||||
@@ -124,3 +160,48 @@ def update_cell_permissions(cell_name):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating cell permissions: {e}")
|
logger.error(f"Error updating cell permissions: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/cells/peer-sync/permissions', methods=['POST'])
|
||||||
|
def peer_sync_permissions():
|
||||||
|
"""Machine-to-machine endpoint: a connected cell pushes its mirrored permission state.
|
||||||
|
|
||||||
|
Auth: source IP must be inside a known cell's vpn_subnet AND the body's
|
||||||
|
from_public_key must match that cell's stored public key.
|
||||||
|
No session/CSRF required — the WireGuard tunnel is the authentication layer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
link = _authenticate_peer_cell(request)
|
||||||
|
if not link:
|
||||||
|
logger.warning(f"peer-sync: rejected from {request.remote_addr} — no matching cell")
|
||||||
|
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if data.get('version') != 1:
|
||||||
|
return jsonify({'ok': False, 'error': 'unsupported or missing version'}), 400
|
||||||
|
|
||||||
|
sender_pubkey = data.get('from_public_key', '')
|
||||||
|
if not sender_pubkey or sender_pubkey != link.get('public_key'):
|
||||||
|
logger.warning(
|
||||||
|
f"peer-sync: pubkey mismatch from {request.remote_addr} "
|
||||||
|
f"(claimed cell={data.get('from_cell')!r})"
|
||||||
|
)
|
||||||
|
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||||
|
|
||||||
|
perms = data.get('permissions') or {}
|
||||||
|
if not isinstance(perms, dict):
|
||||||
|
return jsonify({'ok': False, 'error': 'permissions must be an object'}), 400
|
||||||
|
for direction in ('inbound', 'outbound'):
|
||||||
|
for svc in (perms.get(direction) or {}):
|
||||||
|
if svc not in VALID_SERVICES:
|
||||||
|
return jsonify({'ok': False, 'error': f'unknown service: {svc!r}'}), 400
|
||||||
|
|
||||||
|
from app import cell_link_manager
|
||||||
|
cell_link_manager.apply_remote_permissions(sender_pubkey, perms)
|
||||||
|
return jsonify({'ok': True, 'applied_at': datetime.utcnow().isoformat()})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"peer-sync error: {e}")
|
||||||
|
return jsonify({'ok': False, 'error': 'internal error'}), 500
|
||||||
|
|||||||
@@ -398,3 +398,273 @@ class TestLoadMigration(unittest.TestCase):
|
|||||||
with open(links_file) as f:
|
with open(links_file) as f:
|
||||||
raw = json.load(f)
|
raw = json.load(f)
|
||||||
self.assertIn('permissions', raw[0])
|
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()
|
||||||
|
|||||||
@@ -516,5 +516,174 @@ class TestUpdateCellPermissions(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
|
||||||
|
"""POST /api/cells/peer-sync/permissions — machine-to-machine permission sync."""
|
||||||
|
|
||||||
|
_KNOWN_LINK = {
|
||||||
|
'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': {}},
|
||||||
|
'pending_push': False,
|
||||||
|
'remote_api_url': 'http://10.1.0.1:3000',
|
||||||
|
}
|
||||||
|
|
||||||
|
_VALID_BODY = {
|
||||||
|
'version': 1,
|
||||||
|
'from_cell': 'office',
|
||||||
|
'from_public_key': 'officepubkey=',
|
||||||
|
'permissions': {
|
||||||
|
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
},
|
||||||
|
'sent_at': '2026-05-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_valid_source_ip_returns_200(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(self._VALID_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertTrue(data.get('ok'))
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_valid_source_calls_apply_remote_permissions(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
||||||
|
self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(self._VALID_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
mock_clm.apply_remote_permissions.assert_called_once_with(
|
||||||
|
'officepubkey=', self._VALID_BODY['permissions']
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_unknown_source_ip_returns_403(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(self._VALID_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.9.9.9'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
mock_clm.apply_remote_permissions.assert_not_called()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_pubkey_mismatch_returns_403(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
body = dict(self._VALID_BODY, from_public_key='wrongkey=')
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(body),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
mock_clm.apply_remote_permissions.assert_not_called()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_xff_header_used_for_source_ip(self, mock_clm):
|
||||||
|
"""Caddy appends source IP as last X-Forwarded-For entry."""
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(self._VALID_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '172.20.0.5'}, # docker bridge — not in cell subnet
|
||||||
|
headers={'X-Forwarded-For': '192.168.1.1, 10.1.0.5'}, # last entry is real source
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_missing_version_returns_400(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
body = {k: v for k, v in self._VALID_BODY.items() if k != 'version'}
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(body),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_wrong_version_returns_400(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
body = dict(self._VALID_BODY, version=99)
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(body),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_unknown_service_name_returns_400(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
body = dict(self._VALID_BODY)
|
||||||
|
body['permissions'] = {'inbound': {'hacked': True}, 'outbound': {}}
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(body),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
mock_clm.apply_remote_permissions.assert_not_called()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_no_body_returns_400(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_apply_remote_permissions_exception_returns_500(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
mock_clm.apply_remote_permissions.side_effect = IOError('disk full')
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(self._VALID_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 500)
|
||||||
|
self.assertIn('error', json.loads(r.data))
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_value_error_from_apply_returns_404(self, mock_clm):
|
||||||
|
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||||
|
mock_clm.apply_remote_permissions.side_effect = ValueError('no link')
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells/peer-sync/permissions',
|
||||||
|
data=json.dumps(self._VALID_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user