feat: IdP provisioning from YAML file + Playwright e2e tests
Some checks failed
CI / test (push) Failing after 1m52s
CI / release (push) Has been skipped
CI / docker (push) Has been skipped

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:
Stefano Bertelli 2026-03-31 14:23:31 -05:00
parent c9ef58a244
commit 3bf6fabcff
13 changed files with 940 additions and 332 deletions

View file

@ -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()