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:
@@ -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
|
||||
Reference in New Issue
Block a user