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
View File
+136
View File
@@ -0,0 +1,136 @@
"""
Scenarios 19, 22, 23, 24: Admin role access and peer management.
Tests cover:
- Admin can read configuration and list peers
- Admin is blocked from peer-only routes (/api/peer/*)
- Peer creation validation (missing/weak password)
- Full create-and-delete peer lifecycle
- Admin can list auth users
"""
import pytest
# ---------------------------------------------------------------------------
# Read access
# ---------------------------------------------------------------------------
def test_admin_can_get_config(admin_client):
r = admin_client.get('/api/config')
assert r.status_code == 200, (
f"Admin should be able to GET /api/config, got {r.status_code}"
)
data = r.json()
# Config must contain at least one well-known top-level key
assert 'cell_name' in data or 'service_configs' in data or 'ip_range' in data, (
f"Config response missing expected keys: {list(data.keys())}"
)
def test_admin_can_list_peers(admin_client):
r = admin_client.get('/api/peers')
assert r.status_code == 200, (
f"Admin should be able to GET /api/peers, got {r.status_code}"
)
assert isinstance(r.json(), list), (
f"GET /api/peers should return a list, got {type(r.json())}"
)
# ---------------------------------------------------------------------------
# Peer-only routes must be blocked for admin
# ---------------------------------------------------------------------------
def test_admin_cannot_access_peer_dashboard(admin_client):
r = admin_client.get('/api/peer/dashboard')
assert r.status_code == 403, (
f"Admin should be blocked from /api/peer/dashboard with 403, got {r.status_code}"
)
def test_admin_cannot_access_peer_services(admin_client):
r = admin_client.get('/api/peer/services')
assert r.status_code == 403, (
f"Admin should be blocked from /api/peer/services with 403, got {r.status_code}"
)
# ---------------------------------------------------------------------------
# Peer creation validation
# ---------------------------------------------------------------------------
def test_create_peer_missing_password(admin_client):
"""POST /api/peers with name + public_key but no password must return 400."""
# Use a fixed throwaway key; it doesn't need to be a real WireGuard key for
# validation tests — the password check should happen before key verification.
r = admin_client.post('/api/peers', json={
'name': 'e2etest-no-password',
'public_key': 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
})
assert r.status_code == 400, (
f"Creating peer without password should return 400, got {r.status_code}"
)
def test_create_peer_short_password(admin_client):
"""POST /api/peers with a 5-character password must return 400."""
r = admin_client.post('/api/peers', json={
'name': 'e2etest-short-pass',
'public_key': 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
'password': 'Ab1!x',
})
assert r.status_code == 400, (
f"Creating peer with 5-char password should return 400, got {r.status_code}"
)
# ---------------------------------------------------------------------------
# Full create and delete lifecycle
# ---------------------------------------------------------------------------
def test_create_and_delete_peer(admin_client, make_peer):
"""Create a peer, verify it appears in the list, delete it, verify it's gone."""
peer = make_peer('e2etest-lifecycle')
# Peer must appear in the list
r = admin_client.get('/api/peers')
assert r.status_code == 200
peers = r.json()
names = [p.get('peer') or p.get('name', '') for p in peers]
assert 'e2etest-lifecycle' in names, (
f"Newly created peer 'e2etest-lifecycle' not found in /api/peers: {names}"
)
# Delete the peer manually (make_peer's finalizer will also attempt deletion)
r = admin_client.delete('/api/peers/e2etest-lifecycle')
assert r.status_code == 200, (
f"DELETE /api/peers/e2etest-lifecycle should return 200, got {r.status_code}"
)
# Verify it's gone
r = admin_client.get('/api/peers')
assert r.status_code == 200
peers_after = r.json()
names_after = [p.get('peer') or p.get('name', '') for p in peers_after]
assert 'e2etest-lifecycle' not in names_after, (
f"Deleted peer 'e2etest-lifecycle' still appears in /api/peers: {names_after}"
)
# ---------------------------------------------------------------------------
# Auth user management
# ---------------------------------------------------------------------------
def test_admin_can_list_auth_users(admin_client):
r = admin_client.get('/api/auth/users')
assert r.status_code == 200, (
f"Admin should be able to GET /api/auth/users, got {r.status_code}"
)
users = r.json()
assert isinstance(users, list), (
f"GET /api/auth/users should return a list, got {type(users)}"
)
usernames = [u.get('username') for u in users]
assert 'admin' in usernames, (
f"'admin' not found in user list: {usernames}"
)
+121
View File
@@ -0,0 +1,121 @@
"""
Scenarios 20, 21: Peer role access scoping.
Tests cover:
- Peer is blocked from admin-only routes (config, wireguard, peer list)
- Peer can access /api/peer/dashboard and /api/peer/services
- Dashboard response shape (peer_name, online, rx_bytes, tx_bytes, allowed_ips)
- Services response shape (wireguard, email, caldav, webdav sections)
- Peer can change their own password and use the new credential
- Peer cannot call admin/reset-password
"""
import pytest
from helpers.api_client import PicAPIClient
# ---------------------------------------------------------------------------
# Admin-only routes must be blocked for peer role
# ---------------------------------------------------------------------------
def test_peer_cannot_access_config(peer_client):
r = peer_client.get('/api/config')
assert r.status_code == 403, (
f"Peer should be blocked from /api/config with 403, got {r.status_code}"
)
def test_peer_cannot_access_wireguard_settings(peer_client):
r = peer_client.get('/api/wireguard/status')
assert r.status_code == 403, (
f"Peer should be blocked from /api/wireguard/status with 403, got {r.status_code}"
)
def test_peer_cannot_list_peers(peer_client):
r = peer_client.get('/api/peers')
assert r.status_code == 403, (
f"Peer should be blocked from GET /api/peers with 403, got {r.status_code}"
)
# ---------------------------------------------------------------------------
# Peer-accessible routes
# ---------------------------------------------------------------------------
def test_peer_can_access_own_dashboard(peer_client):
r = peer_client.get('/api/peer/dashboard')
assert r.status_code == 200, (
f"Peer should be able to GET /api/peer/dashboard, got {r.status_code}: {r.text}"
)
def test_peer_dashboard_has_expected_fields(peer_client):
r = peer_client.get('/api/peer/dashboard')
assert r.status_code == 200
data = r.json()
missing = [f for f in ('peer_name', 'online', 'rx_bytes', 'tx_bytes', 'allowed_ips') if f not in data]
assert not missing, (
f"Dashboard response missing fields {missing}. Got keys: {list(data.keys())}"
)
def test_peer_can_access_own_services(peer_client):
r = peer_client.get('/api/peer/services')
assert r.status_code == 200, (
f"Peer should be able to GET /api/peer/services, got {r.status_code}: {r.text}"
)
def test_peer_services_has_expected_sections(peer_client):
r = peer_client.get('/api/peer/services')
assert r.status_code == 200
data = r.json()
missing = [k for k in ('wireguard', 'email', 'caldav', 'webdav') if k not in data]
assert not missing, (
f"Services response missing sections {missing}. Got keys: {list(data.keys())}"
)
# ---------------------------------------------------------------------------
# Auth management — scoping
# ---------------------------------------------------------------------------
def test_peer_cannot_access_auth_users(peer_client):
r = peer_client.get('/api/auth/users')
assert r.status_code == 403, (
f"Peer should be blocked from GET /api/auth/users with 403, got {r.status_code}"
)
def test_peer_cannot_reset_other_password(peer_client):
r = peer_client.post('/api/auth/admin/reset-password',
json={'username': 'admin', 'new_password': 'HackedPass1!'})
assert r.status_code == 403, (
f"Peer should be blocked from admin/reset-password with 403, got {r.status_code}"
)
def test_peer_can_change_own_password(make_peer, api_base):
"""
A peer can change their own password via POST /api/auth/change-password.
After the change the new password must work for login.
"""
peer = make_peer('e2etest-change-pass', password='OldPass123!')
client = PicAPIClient(api_base)
client.login(peer['name'], 'OldPass123!')
r = client.post('/api/auth/change-password',
json={'old_password': 'OldPass123!', 'new_password': 'NewPass456!'})
assert r.status_code == 200, (
f"change-password should return 200, got {r.status_code}: {r.text}"
)
# Verify new password works
new_client = PicAPIClient(api_base)
new_client.login(peer['name'], 'NewPass456!')
me = new_client.me()
assert me.get('username') == peer['name'], (
f"Login with new password failed — me() returned: {me}"
)
+74
View File
@@ -0,0 +1,74 @@
"""
Scenario 18: Unauthenticated requests are blocked.
All protected API endpoints must return 401 when no session cookie is present.
The health endpoint and the login endpoint itself must remain publicly accessible.
"""
import requests
import pytest
@pytest.fixture(scope='module')
def anon(api_base):
"""Plain unauthenticated requests.Session — no cookies, no auth headers."""
s = requests.Session()
s.headers['Content-Type'] = 'application/json'
return s
# ---------------------------------------------------------------------------
# Protected endpoints must return 401 for unauthenticated callers
# ---------------------------------------------------------------------------
def test_config_requires_auth(anon, api_base):
r = anon.get(f"{api_base}/api/config")
assert r.status_code == 401, (
f"GET /api/config should require auth, got {r.status_code}"
)
def test_peers_requires_auth(anon, api_base):
r = anon.get(f"{api_base}/api/peers")
assert r.status_code == 401, (
f"GET /api/peers should require auth, got {r.status_code}"
)
def test_wireguard_requires_auth(anon, api_base):
r = anon.get(f"{api_base}/api/wireguard/status")
assert r.status_code == 401, (
f"GET /api/wireguard/status should require auth, got {r.status_code}"
)
def test_auth_me_unauthenticated(anon, api_base):
r = anon.get(f"{api_base}/api/auth/me")
assert r.status_code == 401, (
f"GET /api/auth/me without session should return 401, got {r.status_code}"
)
# ---------------------------------------------------------------------------
# Public endpoints must remain reachable without auth
# ---------------------------------------------------------------------------
def test_auth_login_is_public(anon, api_base):
"""POST /api/auth/login is reachable without a session.
Wrong credentials → 401, but NOT 403 (which would mean the endpoint
itself is blocked by the auth hook rather than the credential check).
"""
r = anon.post(f"{api_base}/api/auth/login",
json={'username': 'nobody', 'password': 'badpassword'})
assert r.status_code == 401, (
f"POST /api/auth/login with wrong creds should return 401 (not 403), "
f"got {r.status_code}"
)
def test_health_is_public(anon, api_base):
"""GET /health must return 200 without any session (used by Docker + load-balancers)."""
r = anon.get(f"{api_base}/health")
assert r.status_code == 200, (
f"GET /health should be publicly accessible, got {r.status_code}"
)