0d32038150
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>
118 lines
5.3 KiB
Python
118 lines
5.3 KiB
Python
"""
|
|
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
|