Files
pic/tests/e2e/conftest.py
roof 0d32038150 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>
2026-04-25 16:41:13 -04:00

191 lines
5.9 KiB
Python

"""
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