Add WG_IDP_CONFIG_FILE env var to seed OIDC/SAML identity providers from a YAML file at startup, enabling GitOps and IaC workflows. Providers are upserted by id (merge strategy preserves manual additions). Convert all e2e tests from NiceGUI User fixture to Playwright async API with --headed and --slowmo flags for visual debugging. Add full OIDC login flow test against the mock-oidc service.
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""E2E test configuration — async Playwright browser tests against a running app."""
|
|
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from playwright.async_api import Browser, Page, async_playwright
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlmodel import select
|
|
|
|
from wiregui.auth.passwords import hash_password
|
|
from wiregui.config import get_settings
|
|
from wiregui.db import async_session
|
|
from wiregui.models.configuration import Configuration
|
|
from wiregui.models.user import User
|
|
|
|
FAKE_SERVER_KEY = "SFake0ServerPubKey0000000000000000000000000w="
|
|
TEST_EMAIL = "e2e-test@example.com"
|
|
TEST_PASSWORD = "testpass123"
|
|
|
|
# Dedicated port so we don't conflict with a dev instance on 13000
|
|
TEST_APP_PORT = 13001
|
|
TEST_APP_BASE = f"http://localhost:{TEST_APP_PORT}"
|
|
|
|
_CHILD_TABLES = ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections")
|
|
|
|
|
|
def pytest_addoption(parser):
|
|
parser.addoption("--headed", action="store_true", default=False, help="Run browser in headed mode")
|
|
parser.addoption("--slowmo", type=int, default=0, help="Slow down Playwright actions by ms")
|
|
|
|
|
|
async def _cleanup_user_by_email(email: str):
|
|
"""Delete a user and all related objects by email."""
|
|
engine = create_async_engine(get_settings().database_url)
|
|
async with engine.begin() as conn:
|
|
row = (await conn.execute(
|
|
text("SELECT id FROM users WHERE email = :email"), {"email": email}
|
|
)).first()
|
|
if row:
|
|
uid = row[0]
|
|
for table in _CHILD_TABLES:
|
|
await conn.execute(text(f"DELETE FROM {table} WHERE user_id = :uid"), {"uid": uid}) # noqa: S608
|
|
await conn.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": uid})
|
|
await engine.dispose()
|
|
|
|
|
|
async def _cleanup_test_user():
|
|
await _cleanup_user_by_email(TEST_EMAIL)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App subprocess — shared across all e2e tests in the session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app_server():
|
|
"""Start WireGUI on TEST_APP_PORT for the entire test session."""
|
|
import httpx
|
|
|
|
env = os.environ.copy()
|
|
env["WG_LOG_TO_FILE"] = "false"
|
|
env["WG_PORT"] = str(TEST_APP_PORT)
|
|
env["WG_EXTERNAL_URL"] = TEST_APP_BASE
|
|
env.pop("PYTEST_CURRENT_TEST", None)
|
|
env.pop("NICEGUI_SCREEN_TEST_PORT", None)
|
|
|
|
proc = subprocess.Popen(
|
|
["uv", "run", "python", "-m", "wiregui.main"],
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
for _ in range(30):
|
|
try:
|
|
r = httpx.get(f"{TEST_APP_BASE}/api/health", timeout=1)
|
|
if r.status_code == 200:
|
|
break
|
|
except Exception:
|
|
pass
|
|
time.sleep(1)
|
|
else:
|
|
proc.kill()
|
|
out = proc.stdout.read().decode() if proc.stdout else ""
|
|
pytest.fail(f"App did not start in time. Output:\n{out}")
|
|
|
|
yield proc
|
|
|
|
proc.terminate()
|
|
proc.wait(timeout=10)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playwright browser — session-scoped, one browser for all tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def browser(request):
|
|
"""Launch a Playwright Chromium browser for the session."""
|
|
headed = request.config.getoption("--headed")
|
|
slowmo = request.config.getoption("--slowmo")
|
|
pw = await async_playwright().start()
|
|
br = await pw.chromium.launch(headless=not headed, slow_mo=slowmo)
|
|
yield br
|
|
await br.close()
|
|
await pw.stop()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def page(browser: Browser):
|
|
"""Create a fresh browser context + page per test (isolated cookies/storage)."""
|
|
context = await browser.new_context()
|
|
pg = await context.new_page()
|
|
yield pg
|
|
await context.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test user fixture
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_user(app_server):
|
|
"""Create a test admin user, yield it, clean up after."""
|
|
await _cleanup_test_user()
|
|
|
|
async with async_session() as session:
|
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
|
if config:
|
|
if not config.server_public_key:
|
|
config.server_public_key = FAKE_SERVER_KEY
|
|
session.add(config)
|
|
else:
|
|
config = Configuration(server_public_key=FAKE_SERVER_KEY)
|
|
session.add(config)
|
|
|
|
user = User(
|
|
email=TEST_EMAIL,
|
|
password_hash=hash_password(TEST_PASSWORD),
|
|
role="admin",
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
|
|
yield user
|
|
|
|
await _cleanup_test_user()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playwright helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def login(page: Page, email: str = TEST_EMAIL, password: str = TEST_PASSWORD):
|
|
"""Fill the login form and submit."""
|
|
await page.goto(f"{TEST_APP_BASE}/login")
|
|
await page.wait_for_load_state("networkidle")
|
|
await page.locator("input[aria-label='Email']").fill(email)
|
|
await page.locator("input[aria-label='Password']").fill(password)
|
|
await page.get_by_role("button", name="Sign in", exact=True).click()
|