feat: IdP provisioning from YAML file + Playwright e2e tests
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.
This commit is contained in:
parent
c9ef58a244
commit
3bf6fabcff
13 changed files with 940 additions and 332 deletions
|
|
@ -1,6 +1,12 @@
|
|||
"""E2E test configuration — loads NiceGUI testing plugin and app."""
|
||||
"""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
|
||||
|
|
@ -11,22 +17,28 @@ from wiregui.db import async_session
|
|||
from wiregui.models.configuration import Configuration
|
||||
from wiregui.models.user import User
|
||||
|
||||
pytest_plugins = ["nicegui.testing.user_plugin"]
|
||||
|
||||
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")
|
||||
|
||||
|
||||
async def _cleanup_test_user():
|
||||
"""Delete the test user and all related objects using a fresh engine."""
|
||||
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:
|
||||
# Find user id by email
|
||||
row = (await conn.execute(
|
||||
text("SELECT id FROM users WHERE email = :email"), {"email": TEST_EMAIL}
|
||||
text("SELECT id FROM users WHERE email = :email"), {"email": email}
|
||||
)).first()
|
||||
if row:
|
||||
uid = row[0]
|
||||
|
|
@ -36,14 +48,90 @@ async def _cleanup_test_user():
|
|||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user():
|
||||
"""Create a test user and ensure server config has a public key."""
|
||||
# Clean up any leftover from a previous failed run
|
||||
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:
|
||||
# Ensure a Configuration with a server key exists
|
||||
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||
if config:
|
||||
if not config.server_public_key:
|
||||
|
|
@ -65,3 +153,17 @@ async def test_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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue