feat: add comprehensive E2E test suite (Playwright + WireGuard + API)

Adds tests/e2e/ with three layers of E2E coverage:
- API layer (tests/e2e/api/): unauthenticated access, admin endpoints,
  peer endpoints, access control enforcement — 24 tests
- Playwright UI (tests/e2e/ui/): login flows, admin navigation, peer
  dashboard/services, role-based ACL, password change — 60+ tests
- WireGuard connectivity (tests/e2e/wg/): tunnel up/down, DNS resolution
  through VPN, service ACL enforcement via iptables, full-tunnel routing
Shared helpers: PicAPIClient, WGInterface, playwright_login, cleanup.
Makefile targets: test-e2e-api, test-e2e-ui, test-e2e-wg, test-e2e.
Adds scripts/reset_admin_password.py for test bootstrap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:41:13 -04:00
parent 1e81b3b618
commit 0d32038150
34 changed files with 2122 additions and 15 deletions
+114
View File
@@ -0,0 +1,114 @@
"""
Peer access-control tests (scenarios 14 & 15).
PrivateRoute.jsx (confirmed):
- Unauthenticated users → <Navigate to="/login" />
- Authenticated user with wrong role → <Navigate to="/" />
A peer (role='peer') visiting an admin-only route must be redirected to '/'.
A peer must NOT see admin sidebar links (Peers, Settings, WireGuard, etc.).
"""
import pytest
pytestmark = pytest.mark.ui
# All routes that require role='admin' (from App.jsx Routes).
ADMIN_ONLY_ROUTES = [
'/peers',
'/network',
'/wireguard',
'/email',
'/calendar',
'/files',
'/routing',
'/vault',
'/containers',
'/cell-network',
'/logs',
'/settings',
]
# Admin-only sidebar link names (from App.jsx adminNavigation).
ADMIN_ONLY_NAV_LINKS = [
'Peers',
'Network Services',
'WireGuard',
'Email',
'Calendar',
'Files',
'Routing',
'Vault',
'Containers',
'Cell Network',
'Logs',
'Settings',
]
# ── Scenario 14: peer redirected from admin routes ───────────────────────────
@pytest.mark.parametrize('admin_route', ADMIN_ONLY_ROUTES)
def test_peer_redirected_from_admin_route(peer_page, webui_base, admin_route):
"""
A peer navigating to an admin-only route must NOT land on that route.
PrivateRoute redirects them to '/' instead.
"""
page, _ = peer_page
page.goto(f"{webui_base}{admin_route}")
page.wait_for_load_state('networkidle')
current_path = page.url.replace(webui_base, '')
assert current_path.rstrip('/') not in [admin_route.rstrip('/')], (
f"Peer was allowed to reach admin-only route '{admin_route}'. "
f"Expected redirect to '/'. Got: {page.url}"
)
# Must not have been sent to /login either — peer IS authenticated.
assert '/login' not in page.url, (
f"Peer was unexpectedly redirected to /login from '{admin_route}'. "
"PrivateRoute should redirect role-mismatches to '/', not /login."
)
# ── Scenario 15: peer sidebar lacks admin links ──────────────────────────────
def test_peer_nav_does_not_show_admin_only_links(peer_page, webui_base):
"""
The peer sidebar (peerNavigation in App.jsx) only contains Dashboard,
My Services, and Account. Admin-only links must be absent.
"""
page, _ = peer_page
# Navigate to root so the sidebar is fully rendered.
page.goto(f"{webui_base}/")
page.wait_for_load_state('networkidle')
for link_name in ADMIN_ONLY_NAV_LINKS:
assert not page.get_by_role('link', name=link_name).is_visible(), (
f"Admin-only sidebar link '{link_name}' should NOT be visible to a peer"
)
def test_peer_nav_shows_allowed_links(peer_page, webui_base):
"""
The peer sidebar must contain exactly the three peer navigation items:
Dashboard, My Services, Account.
"""
page, _ = peer_page
page.goto(f"{webui_base}/")
page.wait_for_load_state('networkidle')
for link_name in ('Dashboard', 'My Services', 'Account'):
assert page.get_by_role('link', name=link_name).is_visible(), (
f"Peer sidebar should show link '{link_name}'"
)
def test_peer_my_services_is_accessible(peer_page, webui_base):
"""
/my-services is restricted to role='peer' (requireRole="peer" in App.jsx).
A logged-in peer must be able to reach it.
"""
page, _ = peer_page
page.goto(f"{webui_base}/my-services")
page.wait_for_load_state('networkidle')
assert '/login' not in page.url
assert '/my-services' in page.url