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>
This commit is contained in:
@@ -4,8 +4,8 @@ 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 (peer_name, online, rx_bytes, tx_bytes, allowed_ips)
|
||||
- Services response shape (wireguard, email, caldav, webdav sections)
|
||||
- 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
|
||||
"""
|
||||
@@ -54,12 +54,24 @@ 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 ('peer_name', 'online', 'rx_bytes', 'tx_bytes', 'allowed_ips') if f not in data]
|
||||
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, (
|
||||
@@ -71,12 +83,61 @@ 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', 'webdav') if k not in data]
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,16 +3,22 @@ Peer dashboard and My Services page tests.
|
||||
|
||||
Scenarios:
|
||||
12. Peer sees their own dashboard (PeerDashboard.jsx renders peer.name as <h1>)
|
||||
13. Peer's My Services page loads and shows the WireGuard VPN section
|
||||
13. Peer's My Services page loads and shows all service sections
|
||||
14. Peer dashboard shows service icon links (calendar, files, mail, webdav)
|
||||
15. My Services shows correct CalDAV URL (calendar.dev not radicale.dev:5232)
|
||||
16. My Services shows email address field (not username)
|
||||
|
||||
Key selectors from PeerDashboard.jsx:
|
||||
- h1 shows peer.name (line 61: `{peer.name || 'My Dashboard'}`)
|
||||
- "VPN Address" stat card label (line 76)
|
||||
- "Quick Access" → "My Services" link (line 117-119)
|
||||
- h1 shows peer.name (peer.name from /api/peer/dashboard)
|
||||
- "VPN Address" stat card label
|
||||
- "Quick Access" section with service icon links from service_urls
|
||||
- "My Services" link
|
||||
|
||||
Key selectors from MyServices.jsx:
|
||||
- h2 "WireGuard VPN" (line 93)
|
||||
- h2 "WireGuard VPN"
|
||||
- h2 "Email", h2 "Calendar & Contacts", h2 "Files"
|
||||
- "Address" label for email (not "Username")
|
||||
- "CalDAV URL" label with calendar.dev value
|
||||
"""
|
||||
import pytest
|
||||
|
||||
@@ -131,3 +137,78 @@ def test_peer_my_services_shows_files_section(peer_page, webui_base):
|
||||
pytest.xfail(
|
||||
"Files section heading not found on /my-services"
|
||||
)
|
||||
|
||||
|
||||
# ── 14. Service icon links ────────────────────────────────────────────────────
|
||||
|
||||
def test_peer_dashboard_has_calendar_link(peer_page, webui_base):
|
||||
"""PeerDashboard Quick Access section renders a Calendar icon link."""
|
||||
page, _ = peer_page
|
||||
page.wait_for_load_state('networkidle')
|
||||
try:
|
||||
page.wait_for_selector('a:has-text("Calendar")', timeout=5000)
|
||||
except Exception:
|
||||
pytest.xfail(
|
||||
"Calendar link not found on peer dashboard Quick Access — "
|
||||
"check that service_urls.calendar is populated and PeerDashboard.jsx renders it"
|
||||
)
|
||||
|
||||
|
||||
def test_peer_dashboard_has_files_link(peer_page, webui_base):
|
||||
"""PeerDashboard Quick Access section renders a Files icon link."""
|
||||
page, _ = peer_page
|
||||
page.wait_for_load_state('networkidle')
|
||||
try:
|
||||
page.wait_for_selector('a:has-text("Files")', timeout=5000)
|
||||
except Exception:
|
||||
pytest.xfail(
|
||||
"Files link not found on peer dashboard Quick Access"
|
||||
)
|
||||
|
||||
|
||||
def test_peer_dashboard_has_mail_link(peer_page, webui_base):
|
||||
"""PeerDashboard Quick Access section renders a Mail icon link."""
|
||||
page, _ = peer_page
|
||||
page.wait_for_load_state('networkidle')
|
||||
try:
|
||||
page.wait_for_selector('a:has-text("Mail")', timeout=5000)
|
||||
except Exception:
|
||||
pytest.xfail(
|
||||
"Mail link not found on peer dashboard Quick Access"
|
||||
)
|
||||
|
||||
|
||||
# ── 15. CalDAV URL correctness ────────────────────────────────────────────────
|
||||
|
||||
def test_peer_my_services_caldav_url_no_radicale(peer_page, webui_base):
|
||||
"""CalDAV URL shown in My Services must not contain 'radicale' (no DNS record)."""
|
||||
page, _ = peer_page
|
||||
page.goto(f"{webui_base}/my-services")
|
||||
page.wait_for_load_state('networkidle')
|
||||
try:
|
||||
# If radicale.dev appears as CalDAV URL it means the bug is back
|
||||
radicale_url = page.query_selector('text=radicale')
|
||||
assert radicale_url is None, (
|
||||
"Found 'radicale' text on My Services page — "
|
||||
"CalDAV URL should be calendar.dev, not radicale.dev:5232"
|
||||
)
|
||||
except AssertionError:
|
||||
raise
|
||||
except Exception:
|
||||
pass # page didn't load — other tests cover that
|
||||
|
||||
|
||||
# ── 16. Email address display ─────────────────────────────────────────────────
|
||||
|
||||
def test_peer_my_services_shows_address_label(peer_page, webui_base):
|
||||
"""MyServices.jsx renders 'Address' label for email (reads email.address)."""
|
||||
page, _ = peer_page
|
||||
page.goto(f"{webui_base}/my-services")
|
||||
page.wait_for_load_state('networkidle')
|
||||
try:
|
||||
page.wait_for_selector('text=Address', timeout=5000)
|
||||
except Exception:
|
||||
pytest.xfail(
|
||||
"'Address' label not found on My Services email section — "
|
||||
"check that email.address is populated in /api/peer/services"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
WireGuard E2E: domain name resolution and HTTP access through the VPN tunnel.
|
||||
|
||||
Scenarios covered:
|
||||
30. All *.dev domains resolve to the expected IPs via the CoreDNS server
|
||||
31. Direct HTTP access to each service IP works through the VPN
|
||||
32. HTTP access via domain names works through the VPN (DNS + routing)
|
||||
33. WireGuard config downloaded via /api/peer/services has correct DNS field
|
||||
34. Peer config DNS points to CoreDNS, not the WireGuard VPN gateway
|
||||
|
||||
These tests require a live PIC stack with WireGuard and are marked `wg`.
|
||||
They run via `make test-e2e-wg` or `pytest tests/e2e/wg/ -m wg`.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wg
|
||||
|
||||
# Expected domain→IP mapping for the current default config
|
||||
DOMAIN_IPS = {
|
||||
'pic0': '172.20.0.2', # Caddy (main cell domain)
|
||||
'api': '172.20.0.2', # Caddy reverse-proxy for API
|
||||
'webui': '172.20.0.2', # Caddy reverse-proxy for WebUI
|
||||
'calendar': '172.20.0.21', # Caddy VIP for CalDAV
|
||||
'files': '172.20.0.22', # Caddy VIP for Filegator
|
||||
'mail': '172.20.0.23', # Caddy VIP for Rainloop
|
||||
'webmail': '172.20.0.23', # alias for mail VIP
|
||||
'webdav': '172.20.0.24', # Caddy VIP for WebDAV
|
||||
}
|
||||
|
||||
|
||||
def _dns_ip(admin_client) -> str:
|
||||
r = admin_client.get('/api/config')
|
||||
if r.status_code == 200:
|
||||
ips = r.json().get('service_ips', {})
|
||||
if ips.get('dns'):
|
||||
return ips['dns']
|
||||
return '172.20.0.3'
|
||||
|
||||
|
||||
def _domain(admin_client) -> str:
|
||||
r = admin_client.get('/api/config')
|
||||
if r.status_code == 200:
|
||||
return r.json().get('domain', 'dev')
|
||||
return 'dev'
|
||||
|
||||
|
||||
# ── Scenario 30: DNS resolution ───────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize('subdomain,expected_ip', list(DOMAIN_IPS.items()))
|
||||
def test_dev_domain_resolves_to_expected_ip(connected_peer, admin_client, subdomain, expected_ip):
|
||||
"""Every .dev subdomain resolves to the correct IP via CoreDNS."""
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
dom = _domain(admin_client)
|
||||
fqdn = f'{subdomain}.{dom}'
|
||||
result = subprocess.run(
|
||||
['dig', f'@{dns_ip}', fqdn, 'A', '+short', '+time=5'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert result.returncode == 0, f"dig failed for {fqdn}: {result.stderr}"
|
||||
resolved = result.stdout.strip()
|
||||
assert resolved == expected_ip, (
|
||||
f"{fqdn} resolved to {resolved!r}, expected {expected_ip}. "
|
||||
f"DNS server: {dns_ip}"
|
||||
)
|
||||
|
||||
|
||||
def test_api_domain_does_not_resolve_to_api_container(connected_peer, admin_client):
|
||||
"""api.dev must route through Caddy (172.20.0.2) — API container listens on :3000, not :80."""
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
dom = _domain(admin_client)
|
||||
result = subprocess.run(
|
||||
['dig', f'@{dns_ip}', f'api.{dom}', 'A', '+short', '+time=5'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
resolved = result.stdout.strip()
|
||||
assert resolved != '172.20.0.10', (
|
||||
f"api.{dom} resolves to 172.20.0.10 (API container direct) — "
|
||||
"this bypasses Caddy so HTTP requests to api.{dom}:80 return nothing; "
|
||||
"must resolve to Caddy 172.20.0.2"
|
||||
)
|
||||
assert resolved == '172.20.0.2', (
|
||||
f"api.{dom} should resolve to Caddy 172.20.0.2; got {resolved}"
|
||||
)
|
||||
|
||||
|
||||
def test_webui_domain_does_not_resolve_to_webui_container(connected_peer, admin_client):
|
||||
"""webui.dev must route through Caddy — WebUI container also doesn't listen on :80 directly."""
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
dom = _domain(admin_client)
|
||||
result = subprocess.run(
|
||||
['dig', f'@{dns_ip}', f'webui.{dom}', 'A', '+short', '+time=5'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
resolved = result.stdout.strip()
|
||||
assert resolved == '172.20.0.2', (
|
||||
f"webui.{dom} should resolve to Caddy 172.20.0.2; got {resolved}"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 31: HTTP via IP ───────────────────────────────────────────────────
|
||||
|
||||
def test_caddy_ip_serves_http(connected_peer):
|
||||
"""Caddy IP 172.20.0.2 returns an HTTP response (not connection refused)."""
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5',
|
||||
'http://172.20.0.2/'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
assert code not in ('000', ''), f"No HTTP response from 172.20.0.2; curl exit {result.returncode}"
|
||||
|
||||
|
||||
# ── Scenario 32: HTTP via domain ──────────────────────────────────────────────
|
||||
|
||||
def test_http_api_domain_reaches_api(connected_peer, admin_client):
|
||||
"""curl http://api.dev/api/status returns a JSON response via Caddy."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
f'--dns-servers', dns_ip,
|
||||
f'http://api.{dom}/api/status'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
# Any valid JSON response is acceptable (200, 401, etc.)
|
||||
assert result.stdout.strip(), (
|
||||
f"curl http://api.{dom}/api/status returned no output via DNS {dns_ip}. "
|
||||
f"stderr: {result.stderr[:200]}"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 33: Config DNS field ─────────────────────────────────────────────
|
||||
|
||||
def test_peer_services_config_has_coredns(admin_client, make_peer):
|
||||
"""Config returned by /api/peer/services must use CoreDNS IP, not WireGuard VPN gateway."""
|
||||
from helpers.api_client import PicAPIClient
|
||||
import os
|
||||
|
||||
peer = make_peer('e2etest-dns-config', password='DnsTest123!')
|
||||
|
||||
peer_client = PicAPIClient(os.environ.get('PIC_API_BASE', 'http://192.168.31.51:3000'))
|
||||
peer_client.login(peer['name'], 'DnsTest123!')
|
||||
|
||||
r = peer_client.get('/api/peer/services')
|
||||
assert r.status_code == 200, f"peer services returned {r.status_code}: {r.text}"
|
||||
data = r.json()
|
||||
|
||||
dns = data.get('wireguard', {}).get('dns', '')
|
||||
assert dns != '10.0.0.1', (
|
||||
"wireguard.dns is 10.0.0.1 — this is the WireGuard VPN gateway, NOT a DNS server; "
|
||||
"VPN clients using this as DNS will fail to resolve any domain"
|
||||
)
|
||||
|
||||
config = data.get('wireguard', {}).get('config', '')
|
||||
if config:
|
||||
assert 'DNS = 10.0.0.1' not in config, (
|
||||
"WireGuard config has DNS = 10.0.0.1 — VPN clients will fail to resolve domains"
|
||||
)
|
||||
# DNS should be reachable from VPN (must be on the Docker network, not VPN subnet)
|
||||
dns_from_config = None
|
||||
for line in config.splitlines():
|
||||
if line.strip().startswith('DNS ='):
|
||||
dns_from_config = line.split('=', 1)[1].strip()
|
||||
break
|
||||
if dns_from_config:
|
||||
assert dns_from_config.startswith('172.'), (
|
||||
f"DNS in config is {dns_from_config} — expected a 172.x.x.x Docker network IP "
|
||||
f"(CoreDNS is on the Docker bridge, not the WireGuard VPN subnet)"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 34: DNS reachability from VPN ────────────────────────────────────
|
||||
|
||||
def test_coredns_reachable_via_vpn(connected_peer, admin_client):
|
||||
"""CoreDNS at 172.20.0.3 is reachable through the VPN tunnel."""
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
result = subprocess.run(
|
||||
['dig', f'@{dns_ip}', 'health.check', '+time=3', '+tries=1'],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
# NXDOMAIN means DNS responded — that's a success (connectivity is what we're testing)
|
||||
responded = 'status:' in result.stdout or result.returncode in (0, 9)
|
||||
assert responded, (
|
||||
f"CoreDNS at {dns_ip} did not respond via VPN tunnel. "
|
||||
f"Check that AllowedIPs in peer config covers 172.20.0.0/16 or 0.0.0.0/0. "
|
||||
f"stdout: {result.stdout[:200]}"
|
||||
)
|
||||
Reference in New Issue
Block a user