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>
191 lines
5.9 KiB
Python
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
|