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,190 @@
|
||||
"""
|
||||
Top-level conftest for PIC E2E tests.
|
||||
|
||||
Configure with environment variables (or a .env file in this directory):
|
||||
PIC_HOST API / WebUI host (default: localhost)
|
||||
PIC_API_PORT API port (default: 3000)
|
||||
PIC_WEBUI_PORT WebUI port (default: 8081)
|
||||
PIC_ADMIN_USER Admin username (default: admin)
|
||||
PIC_ADMIN_PASS Admin password (or read from data/api/.admin_initial_password)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Allow helpers to be imported without installing the package
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from helpers.admin_password import resolve_admin_password
|
||||
from helpers.api_client import PicAPIClient
|
||||
from helpers.cleanup import delete_e2e_peers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pytest hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pytest_configure(config):
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '.env'))
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
# Verify PIC API is reachable before running any tests
|
||||
import requests, os
|
||||
host = os.environ.get('PIC_HOST', 'localhost')
|
||||
port = os.environ.get('PIC_API_PORT', '3000')
|
||||
try:
|
||||
r = requests.get(f"http://{host}:{port}/health", timeout=5)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"PIC API unhealthy: {r.status_code}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"PIC API not reachable at {host}:{port}. Run 'make start' first. Error: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-scoped infrastructure fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def pic_host():
|
||||
return os.environ.get('PIC_HOST', 'localhost')
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def api_port():
|
||||
return int(os.environ.get('PIC_API_PORT', '3000'))
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def webui_port():
|
||||
return int(os.environ.get('PIC_WEBUI_PORT', '8081'))
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def api_base(pic_host, api_port):
|
||||
return f"http://{pic_host}:{api_port}"
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def webui_base(pic_host, webui_port):
|
||||
return f"http://{pic_host}:{webui_port}"
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def admin_user():
|
||||
return os.environ.get('PIC_ADMIN_USER', 'admin')
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def admin_password():
|
||||
return resolve_admin_password()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def admin_client(api_base, admin_user, admin_password):
|
||||
"""Authenticated PicAPIClient logged in as admin — shared for the whole session."""
|
||||
client = PicAPIClient(api_base)
|
||||
client.login(admin_user, admin_password)
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peer cleanup — runs before and after the entire session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
def clean_test_peers(admin_client):
|
||||
"""Delete any e2etest-* peers left over from previous runs (and after this run)."""
|
||||
delete_e2e_peers(admin_client)
|
||||
yield
|
||||
delete_e2e_peers(admin_client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peer factory — function-scoped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def make_peer(request, admin_client):
|
||||
"""
|
||||
Factory fixture that creates a WireGuard peer via the API.
|
||||
|
||||
Usage::
|
||||
|
||||
def test_something(make_peer):
|
||||
peer = make_peer('e2etest-foo')
|
||||
# peer = {name, password, public_key, private_key, ip}
|
||||
|
||||
The peer is deleted automatically after the test.
|
||||
All names MUST start with 'e2etest-'.
|
||||
"""
|
||||
created = []
|
||||
|
||||
def _factory(name: str, password: str = 'TestPass123!', service_access=None):
|
||||
assert name.startswith('e2etest-'), (
|
||||
f"Test peer name '{name}' must start with 'e2etest-' for safe cleanup"
|
||||
)
|
||||
|
||||
# Default: grant access to all services
|
||||
if service_access is None:
|
||||
service_access = ['calendar', 'files', 'mail', 'webdav']
|
||||
|
||||
# 1. Generate WireGuard key pair
|
||||
r = admin_client.post('/api/wireguard/keys/peer', json={'name': name})
|
||||
assert r.status_code == 200, (
|
||||
f"Key generation failed for '{name}': {r.status_code} {r.text}"
|
||||
)
|
||||
keys = r.json()
|
||||
assert 'public_key' in keys and 'private_key' in keys, (
|
||||
f"Key response missing keys: {keys}"
|
||||
)
|
||||
|
||||
# 2. Create peer
|
||||
payload = {
|
||||
'name': name,
|
||||
'public_key': keys['public_key'],
|
||||
'password': password,
|
||||
'service_access': service_access,
|
||||
}
|
||||
r = admin_client.post('/api/peers', json=payload)
|
||||
assert r.status_code == 201, (
|
||||
f"Peer creation failed for '{name}': {r.status_code} {r.text}"
|
||||
)
|
||||
data = r.json()
|
||||
|
||||
peer_info = {
|
||||
'name': name,
|
||||
'password': password,
|
||||
'public_key': keys['public_key'],
|
||||
'private_key': keys['private_key'],
|
||||
'ip': data.get('ip', ''),
|
||||
}
|
||||
created.append(name)
|
||||
|
||||
def _cleanup():
|
||||
admin_client.delete(f'/api/peers/{name}')
|
||||
|
||||
request.addfinalizer(_cleanup)
|
||||
return peer_info
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience peer_client fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def peer_client(make_peer, api_base):
|
||||
"""
|
||||
A PicAPIClient already logged in as a freshly created peer.
|
||||
|
||||
The underlying peer is named 'e2etest-peer-client' and is deleted after
|
||||
the test via make_peer's finalizer.
|
||||
"""
|
||||
peer = make_peer('e2etest-peer-client')
|
||||
client = PicAPIClient(api_base)
|
||||
client.login(peer['name'], peer['password'])
|
||||
return client
|
||||
Reference in New Issue
Block a user