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:
2026-04-26 17:41:21 -04:00
parent 3690c6d955
commit 32272420cb
4 changed files with 956 additions and 9 deletions
+189
View File
@@ -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]}"
)