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,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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user