Files
pic/tests/e2e/api/test_peer_endpoints.py
roof 32272420cb test: add E2E coverage for peer dashboard/services, DNS records, and WG domain access
- test_peer_dashboard_services.py (63 tests): unit tests for all API fixes
  * peer_dashboard field names (name/transfer_rx/transfer_tx vs old stale names)
  * peer_dashboard service_urls dict with correct domain-keyed URLs
  * peer_services email structure (nested smtp/imap, address not username)
  * peer_services files key (not webdav), caldav URL (calendar.dev not radicale.dev:5232)
  * peer_services wireguard DNS (not 10.0.0.1), config text with DNS line
  * DNS zone records (api/webui → Caddy, VIPs for calendar/files/mail/webdav)
  * Caddyfile generation (all service blocks including webui.dev)
  * Access control (401 anon, 403 admin on peer-only routes, 404 missing peer)
- e2e/api/test_peer_endpoints.py: fix stale field assertions, add structure checks
- e2e/wg/test_wg_domain_access.py: E2E WG tests for DNS resolution via VPN tunnel
  * All *.dev domains resolve to correct IPs via CoreDNS
  * api.dev/webui.dev must resolve to Caddy, not container direct IPs
  * CoreDNS reachability through VPN tunnel
  * Peer config DNS field correctness
- e2e/ui/test_peer_dashboard.py: UI checks for service icon links, CalDAV URL, email

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:41:21 -04:00

183 lines
7.0 KiB
Python

"""
Scenarios 20, 21: Peer role access scoping.
Tests cover:
- Peer is blocked from admin-only routes (config, wireguard, peer list)
- Peer can access /api/peer/dashboard and /api/peer/services
- Dashboard response shape (name, online, transfer_rx, transfer_tx, service_urls)
- Services response shape (wireguard, email, caldav, files sections)
- Peer can change their own password and use the new credential
- Peer cannot call admin/reset-password
"""
import pytest
from helpers.api_client import PicAPIClient
# ---------------------------------------------------------------------------
# Admin-only routes must be blocked for peer role
# ---------------------------------------------------------------------------
def test_peer_cannot_access_config(peer_client):
r = peer_client.get('/api/config')
assert r.status_code == 403, (
f"Peer should be blocked from /api/config with 403, got {r.status_code}"
)
def test_peer_cannot_access_wireguard_settings(peer_client):
r = peer_client.get('/api/wireguard/status')
assert r.status_code == 403, (
f"Peer should be blocked from /api/wireguard/status with 403, got {r.status_code}"
)
def test_peer_cannot_list_peers(peer_client):
r = peer_client.get('/api/peers')
assert r.status_code == 403, (
f"Peer should be blocked from GET /api/peers with 403, got {r.status_code}"
)
# ---------------------------------------------------------------------------
# Peer-accessible routes
# ---------------------------------------------------------------------------
def test_peer_can_access_own_dashboard(peer_client):
r = peer_client.get('/api/peer/dashboard')
assert r.status_code == 200, (
f"Peer should be able to GET /api/peer/dashboard, got {r.status_code}: {r.text}"
)
def test_peer_dashboard_has_expected_fields(peer_client):
r = peer_client.get('/api/peer/dashboard')
assert r.status_code == 200
data = r.json()
missing = [f for f in ('name', 'online', 'transfer_rx', 'transfer_tx', 'allowed_ips', 'service_urls') if f not in data]
assert not missing, (
f"Dashboard response missing fields {missing}. Got keys: {list(data.keys())}"
)
def test_peer_dashboard_no_stale_field_names(peer_client):
"""Verify renamed fields are gone — old names cause silent UI blanks."""
r = peer_client.get('/api/peer/dashboard')
assert r.status_code == 200
data = r.json()
stale = [f for f in ('peer_name', 'rx_bytes', 'tx_bytes') if f in data]
assert not stale, (
f"Dashboard response still has stale fields {stale}"
"PeerDashboard.jsx reads name/transfer_rx/transfer_tx"
)
def test_peer_can_access_own_services(peer_client):
r = peer_client.get('/api/peer/services')
assert r.status_code == 200, (
f"Peer should be able to GET /api/peer/services, got {r.status_code}: {r.text}"
)
def test_peer_services_has_expected_sections(peer_client):
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
data = r.json()
missing = [k for k in ('wireguard', 'email', 'caldav', 'files') if k not in data]
assert not missing, (
f"Services response missing sections {missing}. Got keys: {list(data.keys())}"
)
def test_peer_services_no_stale_keys(peer_client):
"""Verify renamed keys are gone — old names cause silent UI blanks."""
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
data = r.json()
assert 'webdav' not in data, (
"'webdav' still present at top level — MyServices.jsx reads 'files'"
)
def test_peer_services_email_structure(peer_client):
"""Email section must use nested smtp/imap objects and email.address."""
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
email = r.json().get('email', {})
assert 'address' in email, f"email.address missing; email keys: {list(email)}"
assert 'smtp' in email and isinstance(email['smtp'], dict), \
f"email.smtp must be a dict; got: {email.get('smtp')}"
assert 'imap' in email and isinstance(email['imap'], dict), \
f"email.imap must be a dict; got: {email.get('imap')}"
assert 'host' in email['smtp'], "email.smtp.host missing"
assert 'host' in email['imap'], "email.imap.host missing"
assert 'imap_host' not in email, "'imap_host' still flat — should be email.imap.host"
assert 'smtp_host' not in email, "'smtp_host' still flat — should be email.smtp.host"
def test_peer_services_caldav_url_uses_calendar_domain(peer_client):
"""CalDAV URL must be calendar.dev, not radicale.dev:5232."""
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
url = r.json().get('caldav', {}).get('url', '')
assert 'radicale' not in url, \
f"CalDAV URL must not contain 'radicale' — no radicale.dev DNS record; got: {url}"
assert ':5232' not in url, \
f"CalDAV URL exposes port 5232 — use Caddy-proxied URL; got: {url}"
def test_peer_services_wireguard_dns_not_vpn_gateway(peer_client):
"""WireGuard DNS must be the CoreDNS IP, not the VPN gateway 10.0.0.1."""
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
dns = r.json().get('wireguard', {}).get('dns', '')
assert dns != '10.0.0.1', (
"wireguard.dns is 10.0.0.1 (WireGuard VPN gateway) — "
"DNS queries to 10.0.0.1 fail because the VPN server doesn't run a DNS resolver; "
"must be the CoreDNS container IP"
)
# ---------------------------------------------------------------------------
# Auth management — scoping
# ---------------------------------------------------------------------------
def test_peer_cannot_access_auth_users(peer_client):
r = peer_client.get('/api/auth/users')
assert r.status_code == 403, (
f"Peer should be blocked from GET /api/auth/users with 403, got {r.status_code}"
)
def test_peer_cannot_reset_other_password(peer_client):
r = peer_client.post('/api/auth/admin/reset-password',
json={'username': 'admin', 'new_password': 'HackedPass1!'})
assert r.status_code == 403, (
f"Peer should be blocked from admin/reset-password with 403, got {r.status_code}"
)
def test_peer_can_change_own_password(make_peer, api_base):
"""
A peer can change their own password via POST /api/auth/change-password.
After the change the new password must work for login.
"""
peer = make_peer('e2etest-change-pass', password='OldPass123!')
client = PicAPIClient(api_base)
client.login(peer['name'], 'OldPass123!')
r = client.post('/api/auth/change-password',
json={'old_password': 'OldPass123!', 'new_password': 'NewPass456!'})
assert r.status_code == 200, (
f"change-password should return 200, got {r.status_code}: {r.text}"
)
# Verify new password works
new_client = PicAPIClient(api_base)
new_client.login(peer['name'], 'NewPass456!')
me = new_client.me()
assert me.get('username') == peer['name'], (
f"Login with new password failed — me() returned: {me}"
)