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
+117
View File
@@ -0,0 +1,117 @@
"""
Admin login / session tests.
Scenarios covered:
1. Correct credentials → redirected away from /login (dashboard renders)
2. Wrong password → error text "Invalid username or password." stays on /login
3. Lockout (5 consecutive bad attempts) → API returns 423; skipped for UI
(covered in API unit tests; creating a throwaway user risks collateral damage)
4. Logout → redirected back to /login
5. Session persistence: page reload while logged in → stays on dashboard
"""
import pytest
pytestmark = pytest.mark.ui
# ── 1. Successful login ──────────────────────────────────────────────────────
def test_login_success_redirects_to_dashboard(page, webui_base, admin_user, admin_password):
"""Valid credentials navigate away from /login."""
page.goto(f"{webui_base}/login")
page.wait_for_load_state('networkidle')
page.fill('input[autocomplete="username"]', admin_user)
page.fill('input[autocomplete="current-password"]', admin_password)
page.click('button[type="submit"]')
page.wait_for_url(lambda url: '/login' not in url, timeout=10000)
assert '/login' not in page.url
def test_login_success_shows_dashboard_heading(page, webui_base, admin_user, admin_password):
"""After login the page title/heading contains 'Dashboard' or 'Personal Internet Cell'."""
page.goto(f"{webui_base}/login")
page.fill('input[autocomplete="username"]', admin_user)
page.fill('input[autocomplete="current-password"]', admin_password)
page.click('button[type="submit"]')
page.wait_for_url(lambda url: '/login' not in url, timeout=10000)
page.wait_for_load_state('networkidle')
# The sidebar always renders the app title; Dashboard heading is also present.
assert (
page.locator('h1:has-text("Personal Internet Cell")').is_visible()
or page.locator('h1:has-text("Dashboard")').is_visible()
)
# ── 2. Wrong password ────────────────────────────────────────────────────────
def test_login_wrong_password_shows_error(page, webui_base, admin_user):
"""Wrong password keeps user on /login and shows an error message."""
page.goto(f"{webui_base}/login")
page.wait_for_load_state('networkidle')
page.fill('input[autocomplete="username"]', admin_user)
page.fill('input[autocomplete="current-password"]', 'WrongPassword999!')
page.click('button[type="submit"]')
# Login.jsx renders the error in a <p> with class text-red-400
page.wait_for_selector('text=Invalid username or password.', timeout=5000)
assert '/login' in page.url
def test_login_wrong_password_error_text_exact(page, webui_base, admin_user):
"""The exact error message from Login.jsx is shown (not a generic network error)."""
page.goto(f"{webui_base}/login")
page.fill('input[autocomplete="username"]', admin_user)
page.fill('input[autocomplete="current-password"]', 'BadPass0000!')
page.click('button[type="submit"]')
error_el = page.wait_for_selector('p.text-red-400', timeout=5000)
assert 'Invalid username' in error_el.inner_text()
# ── 3. Lockout (deferred to API layer) ──────────────────────────────────────
def test_login_lockout_deferred():
"""
Lockout behavior (HTTP 423 → 'Account locked' banner) is covered by the
API-layer unit tests (test_auth_routes.py). Creating a throwaway account
purely to lock it in the browser risks side-effects; skip here.
"""
pytest.skip("Lockout UI scenario deferred — covered in test_auth_routes.py")
# ── 4. Logout ────────────────────────────────────────────────────────────────
def test_logout_redirects_to_login(admin_page, webui_base):
"""Clicking 'Sign out' in the sidebar redirects to /login."""
page = admin_page
from helpers.playwright_login import do_logout
do_logout(page, webui_base)
assert '/login' in page.url
def test_logout_clears_session(admin_page, webui_base):
"""After logout, navigating to '/' redirects back to /login (no lingering session)."""
page = admin_page
from helpers.playwright_login import do_logout
do_logout(page, webui_base)
page.goto(f"{webui_base}/")
page.wait_for_load_state('networkidle')
assert '/login' in page.url
# ── 5. Session persistence ───────────────────────────────────────────────────
def test_session_persists_after_page_reload(admin_page, webui_base):
"""Reloading the page while logged in should keep the user authenticated."""
page = admin_page
page.reload()
page.wait_for_load_state('networkidle')
assert '/login' not in page.url
def test_session_persists_after_navigating_back(admin_page, webui_base):
"""Browser back-navigation from an inner page should not trigger a re-login."""
page = admin_page
page.goto(f"{webui_base}/settings")
page.wait_for_load_state('networkidle')
page.go_back()
page.wait_for_load_state('networkidle')
assert '/login' not in page.url