diff --git a/README.md b/README.md
index 739a4eb..0c759a2 100644
--- a/README.md
+++ b/README.md
@@ -84,34 +84,17 @@ All settings use the `WG_` prefix:
| `WG_ADMIN_EMAIL` | `admin@localhost` | Initial admin email |
| `WG_ADMIN_PASSWORD` | *(auto-generated)* | Initial admin password |
| `WG_EXTERNAL_URL` | `http://localhost:13000` | Public-facing URL |
-| `WG_IDP_CONFIG_FILE` | *(none)* | Path to YAML file with OIDC/SAML IdP definitions |
## Testing
```bash
# Unit + integration tests
-uv run pytest
+uv run pytest tests/ --ignore=tests/e2e -v
-# E2E tests (Playwright — requires running PostgreSQL, Valkey, and mock-oidc)
-docker compose up -d
+# E2E tests (NiceGUI User fixture)
uv run pytest tests/e2e/ -v
-
-# E2E in headed mode (watch tests in a browser)
-uv run pytest tests/e2e/ --headed --slowmo 300
```
-E2E tests automatically start a WireGUI instance on port 13001 and use Playwright's async API to drive a real Chromium browser. The `--headed` flag opens a visible browser window and `--slowmo` adds a delay (in ms) between actions for debugging. The OIDC login flow tests use the `mock-oidc` service from `compose.yml`.
-
-### IdP provisioning from YAML
-
-Identity providers can be seeded at startup from a YAML file, enabling GitOps and infrastructure-as-code workflows:
-
-```bash
-WG_IDP_CONFIG_FILE=/etc/wiregui/idps.yaml uv run python -m wiregui.main
-```
-
-See `tests/e2e/test_idp_seed.py` for the YAML format and seeding behavior.
-
## License
Copyright 2026 Stefano Bertelli / Provvedo
diff --git a/TODO.md b/TODO.md
index 340c382..c547ea0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -6,22 +6,6 @@
## Testing
-# WireGUI Implementation TODO
-
-Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI.
-Source: `/home/stefanob/PycharmProjects/personal/wirezone`
-
-**Test count: 199 (164 unit + 35 E2E) | Coverage: 35%**
-**Run:** `uv run pytest` (unit) / `uv run pytest tests/e2e/` (E2E via Playwright)
-
-
-## Phase 7: Admin UI ✅
-
-- [ ] **TODO:** SAML provider management in Authentication tab
-
-## Phase 10: Polish, Testing & Deployment
-
-### Testing (partially done)
- [ ] HTTP-level integration tests (OIDC redirect/callback flow with respx mocking)
- [ ] `wiregui/api/deps.py` — test get_current_api_user with real Bearer header parsing, require_admin rejection
- [ ] `wiregui/services/wireguard.py` — test ensure_interface, set_private_key, set_listen_port
@@ -31,94 +15,11 @@ Source: `/home/stefanob/PycharmProjects/personal/wirezone`
- [ ] `wiregui/auth/webauthn.py` — test verify_registration, verify_authentication with mock credential data
- [ ] E2E tests for admin pages (users, devices, rules, settings)
-**E2E page tests (Playwright async API in `tests/e2e/`):**
-- [x] `tests/e2e/test_login.py` (6 tests) — valid login, invalid password, nonexistent email, disabled user, logout, unauthenticated redirect
-- [x] `tests/e2e/test_devices.py` (2 tests) — add device full flow, name validation
-- [x] `tests/e2e/test_account.py` (8 tests) — change password (success/wrong/mismatch/short), create API token, TOTP registration + invalid code, account deletion
-- [x] `tests/e2e/test_admin_users.py` (10 tests) — page renders, create user, duplicate email, edit role/password, disable/enable, delete, cascade delete, self-delete guard
-- [x] `tests/e2e/test_idp_seed.py` (9 tests) — IdP YAML seeding (noop/missing/invalid, OIDC/SAML add, upsert, preserve), OIDC button visible, full OIDC login flow via mock-oidc
-
-**E2E tests still needed:**
-
-`tests/e2e/test_login.py` — Login & Auth flows (remaining):
-- [ ] Login with MFA → redirects to /mfa challenge page
-- [ ] MFA challenge: valid TOTP code → completes login
-- [ ] MFA challenge: invalid code → shows error, stays on /mfa
-- [ ] MFA challenge: cancel → returns to /login
-- [ ] Magic link request page renders, shows success on submit
-
-`tests/e2e/test_admin_devices.py` — Admin Device Management:
-- [ ] List all devices across users
-- [ ] Filter by user → shows only that user's devices
-- [ ] Create device with full config overrides (DNS, endpoint, MTU, keepalive, allowed IPs)
-- [ ] Create device with defaults → use_default flags all True
-- [ ] Edit device name and description → persists
-- [ ] Edit device config overrides (toggle use_default off, set custom values)
-- [ ] Delete device → removed from table
-- [ ] Config dialog shows valid WireGuard config with real server public key
-- [ ] QR code renders in config dialog
-
-`tests/e2e/test_admin_rules.py` — Admin Firewall Rules:
-- [ ] List rules → table shows action, destination, protocol, port, user
-- [ ] Create accept rule with CIDR → appears in table
-- [ ] Create drop rule with TCP port range → appears correctly
-- [ ] Create global rule (no user) → shows "Global"
-- [ ] Edit rule action (accept → drop) → persists
-- [ ] Edit rule destination → persists
-- [ ] Delete rule → removed from table
-
-`tests/e2e/test_admin_settings.py` — Admin Settings:
-- [ ] Client defaults: save endpoint, DNS, MTU, keepalive, allowed IPs → persists in DB
-- [ ] Client defaults: saved values reflected on page reload
-- [ ] Security: toggle local auth → persists
-- [ ] Security: change VPN session duration → persists
-- [ ] Security: toggle unprivileged device management/configuration → persists
-- [ ] OIDC: add provider → appears in table
-- [ ] OIDC: delete provider → removed from table
-- [ ] SAML: add provider → appears in table
-- [ ] SAML: delete provider → removed from table
-
-`tests/e2e/test_admin_diagnostics.py` — Admin Diagnostics:
-- [ ] Page renders WireGuard interface status
-- [ ] Active peers table shows devices with handshakes
-- [ ] Connectivity checks table shows recent results
-- [ ] Notifications list shows system notifications
-- [ ] Clear single notification → removed
-- [ ] Clear all notifications → list empty
-
-`tests/e2e/test_devices_user.py` — User Device Pages:
-- [ ] Device list shows only own devices (not other users')
-- [ ] Create device → shows in table with allocated IPs
-- [ ] Device detail page shows public key, IPs, stats, active config
-- [ ] Device detail: edit name → persists
-- [ ] Device detail: toggle config overrides → custom values saved
-- [ ] Device detail: delete with confirmation → redirects to /devices
-- [ ] Auto-refresh: stats labels update after timer fires (mock timer)
-
## UI
- [ ] SSO Providers on account page: add Status column, "Disconnect" action
- [ ] Admin pages (users, devices, rules): apply same card-based styling as account/settings/diagnostics
-`tests/e2e/test_account_extended.py` — Account Page (additional):
-- [ ] SSO providers section shows connected providers
-- [ ] SSO providers section shows "No SSO providers" when empty
-- [ ] MFA: add security key (WebAuthn) → method appears in table (mock navigator.credentials)
-- [ ] MFA: delete method with confirmation → removed from table
-- [ ] API tokens: expired token shows "Expired" badge
-- [ ] API tokens: delete token → removed from table
-- [ ] API tokens: copy button calls clipboard API
-- [ ] Danger zone: disabled when only admin
-- [ ] Danger zone: wrong email in confirmation → shows error
-
## Features
-### Deployment ✅
-
- [ ] First-run CLI setup command
-
----
-
-### Remaining
-- [ ] SSO Providers: add Status column, "Disconnect" action
-- [ ] Admin pages (users, devices, rules): apply same card-based styling
diff --git a/pyproject.toml b/pyproject.toml
index af8c690..b9feaa4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,15 +31,12 @@ dependencies = [
"aiosmtplib>=3.0",
# QR codes
"qrcode[pil]>=8.0",
- # YAML config
- "pyyaml>=6.0",
# Logging
"loguru>=0.7.3",
]
[dependency-groups]
dev = [
- "playwright>=1.58.0",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-cov>=7.1.0",
@@ -51,7 +48,4 @@ asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"]
-# E2E tests run separately: uv run pytest tests/e2e/
-# NiceGUI's testing plugin conflicts with unit tests when loaded together
-addopts = "--ignore=tests/e2e"
main_file = "wiregui/main.py"
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 003b46b..ca4f691 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -1,12 +1,6 @@
-"""E2E test configuration — async Playwright browser tests against a running app."""
-
-import os
-import subprocess
-import time
+"""E2E test configuration — loads NiceGUI testing plugin and app."""
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
@@ -17,28 +11,22 @@ 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")
-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."""
+async def _cleanup_test_user():
+ """Delete the test user and all related objects using a fresh engine."""
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": email}
+ text("SELECT id FROM users WHERE email = :email"), {"email": TEST_EMAIL}
)).first()
if row:
uid = row[0]
@@ -48,90 +36,14 @@ async def _cleanup_user_by_email(email: str):
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."""
+@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
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:
@@ -153,17 +65,3 @@ async def test_user(app_server):
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()
diff --git a/tests/e2e/test_account.py b/tests/e2e/test_account.py
index 383c3b4..f8744e3 100644
--- a/tests/e2e/test_account.py
+++ b/tests/e2e/test_account.py
@@ -1,85 +1,124 @@
-"""End-to-end tests for account page — password, API tokens, TOTP, deletion."""
+"""End-to-end tests for account page — password, TOTP, API tokens, deletion."""
-from playwright.async_api import Page, expect
-from sqlmodel import select
+from unittest.mock import patch
+
+import pytest
+from nicegui import ui
+from nicegui.testing import User
-from wiregui.auth.passwords import hash_password
-from wiregui.db import async_session
from wiregui.models.user import User as UserModel
-from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, TEST_PASSWORD, login
+from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
-async def _login_to_account(page: Page):
+async def _login(user: User):
"""Log in and navigate to account page."""
- await login(page)
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
- await page.goto(f"{TEST_APP_BASE}/account")
- await expect(page.get_by_text("Account Settings")).to_be_visible(timeout=10_000)
+ await user.open("/login")
+ user.find("Email").type(TEST_EMAIL)
+ user.find("Password").type(TEST_PASSWORD)
+ user.find("Sign in").click()
+ await user.should_see("My Devices")
+ await user.open("/account")
+ await user.should_see("Account Settings")
-async def test_change_password(page: Page, test_user: UserModel):
- await _login_to_account(page)
- await page.locator("input[aria-label='Current Password']").fill(TEST_PASSWORD)
- await page.locator("input[aria-label='New Password']").fill("newpass12345")
- await page.locator("input[aria-label='Confirm Password']").fill("newpass12345")
- await page.get_by_role("button", name="Update Password").click()
- await expect(page.get_by_text("Password changed")).to_be_visible(timeout=5_000)
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_change_password(user: User, test_user: UserModel):
+ """Test changing password: fill form, submit, verify success."""
+ await _login(user)
+
+ user.find("Current Password").type(TEST_PASSWORD)
+ user.find("New Password").type("newpass12345")
+ user.find("Confirm Password").type("newpass12345")
+ user.find("Update Password").click()
+ await user.should_see("Password changed")
-async def test_change_password_wrong_current(page: Page, test_user: UserModel):
- await _login_to_account(page)
- await page.locator("input[aria-label='Current Password']").fill("wrongpassword")
- await page.locator("input[aria-label='New Password']").fill("newpass12345")
- await page.locator("input[aria-label='Confirm Password']").fill("newpass12345")
- await page.get_by_role("button", name="Update Password").click()
- await expect(page.get_by_text("Wrong current password")).to_be_visible(timeout=5_000)
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_change_password_wrong_current(user: User, test_user: UserModel):
+ """Test that wrong current password is rejected."""
+ await _login(user)
+
+ user.find("Current Password").type("wrongpassword")
+ user.find("New Password").type("newpass12345")
+ user.find("Confirm Password").type("newpass12345")
+ user.find("Update Password").click()
+ await user.should_see("Wrong current password")
-async def test_change_password_mismatch(page: Page, test_user: UserModel):
- await _login_to_account(page)
- await page.locator("input[aria-label='Current Password']").fill(TEST_PASSWORD)
- await page.locator("input[aria-label='New Password']").fill("newpass12345")
- await page.locator("input[aria-label='Confirm Password']").fill("differentpass")
- await page.get_by_role("button", name="Update Password").click()
- await expect(page.get_by_text("Passwords don't match")).to_be_visible(timeout=5_000)
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_change_password_mismatch(user: User, test_user: UserModel):
+ """Test that mismatched passwords are rejected."""
+ await _login(user)
+
+ user.find("Current Password").type(TEST_PASSWORD)
+ user.find("New Password").type("newpass12345")
+ user.find("Confirm Password").type("differentpass")
+ user.find("Update Password").click()
+ await user.should_see("Passwords don't match")
-async def test_change_password_too_short(page: Page, test_user: UserModel):
- await _login_to_account(page)
- await page.locator("input[aria-label='Current Password']").fill(TEST_PASSWORD)
- await page.locator("input[aria-label='New Password']").fill("short")
- await page.locator("input[aria-label='Confirm Password']").fill("short")
- await page.get_by_role("button", name="Update Password").click()
- await expect(page.get_by_text("Min 8 characters")).to_be_visible(timeout=5_000)
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_change_password_too_short(user: User, test_user: UserModel):
+ """Test that short passwords are rejected."""
+ await _login(user)
+
+ user.find("Current Password").type(TEST_PASSWORD)
+ user.find("New Password").type("short")
+ user.find("Confirm Password").type("short")
+ user.find("Update Password").click()
+ await user.should_see("Min 8 characters")
-async def test_create_api_token(page: Page, test_user: UserModel):
- await _login_to_account(page)
- await expect(page.get_by_text("No API tokens.")).to_be_visible()
- await page.get_by_role("button", name="Add API Token").click()
- await expect(page.get_by_text("Copy now")).to_be_visible(timeout=5_000)
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_create_api_token(user: User, test_user: UserModel):
+ """Test creating an API token and seeing the copy banner."""
+ await _login(user)
+
+ await user.should_see("No API tokens.")
+ user.find("Add API Token").click()
+ await user.should_see("Copy now")
-async def test_totp_registration_flow(page: Page, test_user: UserModel):
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_totp_registration_flow(user: User, test_user: UserModel):
"""Test starting TOTP registration shows QR and verify form."""
- await _login_to_account(page)
- await expect(page.get_by_text("No MFA methods configured.")).to_be_visible()
- await page.get_by_role("button", name="Add TOTP Method").click()
- await expect(page.get_by_text("Register TOTP Authenticator")).to_be_visible(timeout=5_000)
- await expect(page.get_by_role("button", name="Verify & Save")).to_be_visible()
+ with patch("wiregui.pages.account.generate_totp_secret", return_value="JBSWY3DPEHPK3PXP"), \
+ patch("wiregui.pages.account.generate_totp_qr_svg", return_value=''), \
+ patch("wiregui.pages.account.get_totp_uri", return_value="otpauth://totp/WireGUI:test?secret=JBSWY3DPEHPK3PXP"):
+
+ await _login(user)
+
+ await user.should_see("No MFA methods configured.")
+ user.find("Add TOTP Method").click()
+ await user.should_see("Register TOTP Authenticator")
+ await user.should_see("JBSWY3DPEHPK3PXP")
+ await user.should_see("Verify & Save")
-async def test_totp_verify_invalid_code(page: Page, test_user: UserModel):
- await _login_to_account(page)
- await page.get_by_role("button", name="Add TOTP Method").click()
- await expect(page.get_by_text("Register TOTP Authenticator")).to_be_visible(timeout=5_000)
- await page.locator("input[aria-label='6-digit verification code']").fill("000000")
- await page.get_by_role("button", name="Verify & Save").click()
- await expect(page.get_by_text("Invalid code")).to_be_visible(timeout=5_000)
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_totp_verify_invalid_code(user: User, test_user: UserModel):
+ """Test that an invalid TOTP code is rejected."""
+ with patch("wiregui.pages.account.generate_totp_secret", return_value="JBSWY3DPEHPK3PXP"), \
+ patch("wiregui.pages.account.generate_totp_qr_svg", return_value=''), \
+ patch("wiregui.pages.account.get_totp_uri", return_value="otpauth://totp/WireGUI:test?secret=JBSWY3DPEHPK3PXP"):
+
+ await _login(user)
+
+ user.find("Add TOTP Method").click()
+ await user.should_see("Register TOTP Authenticator")
+
+ user.find("6-digit verification code").type("000000")
+ user.find("Verify & Save").click()
+ await user.should_see("Invalid code")
-async def test_delete_account(page: Page, test_user: UserModel):
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_delete_account(user: User, test_user: UserModel):
"""Test account deletion flow with email confirmation."""
+ # Create a second admin first so deletion is allowed
+ from wiregui.db import async_session
+ from wiregui.auth.passwords import hash_password
+
async with async_session() as session:
second_admin = UserModel(
email="admin2@example.com",
@@ -90,17 +129,21 @@ async def test_delete_account(page: Page, test_user: UserModel):
await session.commit()
try:
- await _login_to_account(page)
- await page.get_by_role("button", name="Delete Your Account").click()
- await expect(page.get_by_text("Delete Your Account?")).to_be_visible(timeout=5_000)
- await page.locator(".q-dialog input").fill(TEST_EMAIL)
- await page.get_by_role("button", name="Delete My Account").click()
- await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
+ await _login(user)
+
+ user.find("Delete Your Account").click()
+ await user.should_see("Delete Your Account?")
+
+ user.find(ui.input).type(TEST_EMAIL)
+ user.find("Delete My Account").click()
+
+ # Should redirect to login
+ await user.should_see("Sign in")
finally:
+ # Clean up second admin
async with async_session() as session:
- a2 = (await session.execute(
- select(UserModel).where(UserModel.email == "admin2@example.com")
- )).scalar_one_or_none()
+ from sqlmodel import select
+ a2 = (await session.execute(select(UserModel).where(UserModel.email == "admin2@example.com"))).scalar_one_or_none()
if a2:
await session.delete(a2)
await session.commit()
diff --git a/tests/e2e/test_admin_users.py b/tests/e2e/test_admin_users.py
deleted file mode 100644
index b8f5d51..0000000
--- a/tests/e2e/test_admin_users.py
+++ /dev/null
@@ -1,208 +0,0 @@
-"""End-to-end tests for admin user management page."""
-
-import pytest
-import pytest_asyncio
-from playwright.async_api import Page, expect
-from sqlmodel import func, select
-
-from wiregui.auth.passwords import hash_password, verify_password
-from wiregui.db import async_session
-from wiregui.models.device import Device
-from wiregui.models.rule import Rule
-from wiregui.models.user import User as UserModel
-from wiregui.utils.time import utcnow
-from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, _cleanup_user_by_email, login
-
-CREATED_USER_EMAIL = "e2e-created@example.com"
-
-
-async def _login_and_go_to_users(page: Page):
- await login(page)
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
- await page.goto(f"{TEST_APP_BASE}/admin/users")
- await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible(timeout=10_000)
-
-
-@pytest_asyncio.fixture(autouse=True)
-async def cleanup_created_users():
- yield
- await _cleanup_user_by_email(CREATED_USER_EMAIL)
-
-
-# --- Page renders ---
-
-
-async def test_users_page_renders(page: Page, test_user: UserModel):
- await _login_and_go_to_users(page)
- await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
- await expect(page.get_by_role("button", name="Add User")).to_be_visible()
- await expect(page.locator("table")).to_be_visible()
-
-
-# --- Create user ---
-
-
-async def test_create_user(page: Page, test_user: UserModel):
- await _login_and_go_to_users(page)
-
- await page.get_by_role("button", name="Add User").click()
- await expect(page.get_by_text("New User")).to_be_visible(timeout=5_000)
-
- await page.locator("input[aria-label='Email']").last.fill(CREATED_USER_EMAIL)
- await page.locator("input[aria-label='Password']").last.fill("newuser123")
- await page.get_by_role("button", name="Create").click()
-
- await page.wait_for_timeout(1000)
-
- async with async_session() as session:
- result = await session.execute(select(UserModel).where(UserModel.email == CREATED_USER_EMAIL))
- created = result.scalar_one_or_none()
- assert created is not None
- assert created.role == "unprivileged"
-
-
-async def test_create_user_duplicate_email(page: Page, test_user: UserModel):
- await _login_and_go_to_users(page)
-
- await page.get_by_role("button", name="Add User").click()
- await expect(page.get_by_text("New User")).to_be_visible(timeout=5_000)
-
- await page.locator("input[aria-label='Email']").last.fill(TEST_EMAIL)
- await page.locator("input[aria-label='Password']").last.fill("somepass123")
- await page.get_by_role("button", name="Create").click()
-
- await expect(page.get_by_text("already exists")).to_be_visible(timeout=5_000)
-
-
-# --- Edit user (DB operations with page render verification) ---
-
-
-async def test_edit_user_role(page: Page, test_user: UserModel):
- async with async_session() as session:
- target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
- session.add(target)
- await session.commit()
- target_id = target.id
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- assert u.role == "unprivileged"
- u.role = "admin"
- session.add(u)
- await session.commit()
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- assert u.role == "admin"
-
-
-async def test_edit_user_password(page: Page, test_user: UserModel):
- async with async_session() as session:
- target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("oldpass"), role="unprivileged")
- session.add(target)
- await session.commit()
- target_id = target.id
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- u.password_hash = hash_password("newpass456")
- session.add(u)
- await session.commit()
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- assert verify_password("newpass456", u.password_hash) is True
- assert verify_password("oldpass", u.password_hash) is False
-
-
-async def test_disable_user(page: Page, test_user: UserModel):
- async with async_session() as session:
- target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
- session.add(target)
- await session.commit()
- target_id = target.id
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- u.disabled_at = utcnow()
- session.add(u)
- await session.commit()
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- assert u.disabled_at is not None
-
- await _login_and_go_to_users(page)
- await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
-
-
-async def test_enable_user(page: Page, test_user: UserModel):
- async with async_session() as session:
- target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged", disabled_at=utcnow())
- session.add(target)
- await session.commit()
- target_id = target.id
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- u.disabled_at = None
- session.add(u)
- await session.commit()
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- assert u.disabled_at is None
-
-
-# --- Delete user ---
-
-
-async def test_delete_user(page: Page, test_user: UserModel):
- async with async_session() as session:
- target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
- session.add(target)
- await session.commit()
- target_id = target.id
-
- async with async_session() as session:
- u = await session.get(UserModel, target_id)
- await session.delete(u)
- await session.commit()
-
- async with async_session() as session:
- assert await session.get(UserModel, target_id) is None
-
- await _login_and_go_to_users(page)
- await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
-
-
-async def test_delete_user_cascades(page: Page, test_user: UserModel):
- async with async_session() as session:
- target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
- session.add(target)
- await session.flush()
- session.add(Device(name="cascade-dev", public_key="pk-cascade-e2e", user_id=target.id))
- session.add(Rule(action="accept", destination="10.0.0.0/8", user_id=target.id))
- await session.commit()
- target_id = target.id
-
- async with async_session() as session:
- for d in (await session.execute(select(Device).where(Device.user_id == target_id))).scalars().all():
- await session.delete(d)
- for r in (await session.execute(select(Rule).where(Rule.user_id == target_id))).scalars().all():
- await session.delete(r)
- u = await session.get(UserModel, target_id)
- if u:
- await session.delete(u)
- await session.commit()
-
- async with async_session() as session:
- assert await session.get(UserModel, target_id) is None
- assert (await session.execute(select(func.count()).select_from(Device).where(Device.user_id == target_id))).scalar() == 0
- assert (await session.execute(select(func.count()).select_from(Rule).where(Rule.user_id == target_id))).scalar() == 0
-
-
-async def test_cannot_delete_own_account(page: Page, test_user: UserModel):
- await _login_and_go_to_users(page)
- await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
- assert test_user.role == "admin"
diff --git a/tests/e2e/test_devices.py b/tests/e2e/test_devices.py
index 805910a..8ed6b88 100644
--- a/tests/e2e/test_devices.py
+++ b/tests/e2e/test_devices.py
@@ -1,32 +1,45 @@
-"""End-to-end tests for device management UI."""
+"""End-to-end tests for device management UI using NiceGUI's User fixture."""
-from playwright.async_api import Page, expect
+import pytest
+from nicegui.testing import User
from wiregui.models.user import User as UserModel
-from tests.e2e.conftest import login
+from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
-async def test_add_device_via_ui(page: Page, test_user: UserModel):
+async def _login(user: User):
+ """Helper to log in via the UI."""
+ await user.open("/login")
+ user.find("Email").type(TEST_EMAIL)
+ user.find("Password").type(TEST_PASSWORD)
+ user.find("Sign in").click()
+ await user.should_see("My Devices")
+
+
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_add_device_via_ui(user: User, test_user: UserModel):
"""Test the full flow: login → devices → add device → see it in table."""
- await login(page)
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
+ await _login(user)
- await page.get_by_role("button", name="Add Device").click()
- await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
+ # Open create dialog
+ user.find("Add Device").click()
+ await user.should_see("New Device")
- await page.locator("input[aria-label='Device Name']").fill("Test Laptop")
- await page.get_by_role("button", name="Create").click()
+ # Fill device name and submit
+ user.find("Device Name").type("Test Laptop")
+ user.find("Create").click()
- # Should see config dialog with the device name
- await expect(page.get_by_text("Config for Test Laptop")).to_be_visible(timeout=10_000)
+ # Should see config dialog with the device config
+ await user.should_see("Test Laptop")
-async def test_add_device_requires_name(page: Page, test_user: UserModel):
+@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
+async def test_add_device_requires_name(user: User, test_user: UserModel):
"""Test that creating a device without a name shows an error."""
- await login(page)
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
+ await _login(user)
- await page.get_by_role("button", name="Add Device").click()
- await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
- await page.get_by_role("button", name="Create").click()
- await expect(page.get_by_text("Device name is required")).to_be_visible(timeout=5_000)
+ # Open create dialog and submit without name
+ user.find("Add Device").click()
+ await user.should_see("New Device")
+ user.find("Create").click()
+ await user.should_see("Device name is required")
diff --git a/tests/e2e/test_idp_seed.py b/tests/e2e/test_idp_seed.py
deleted file mode 100644
index 6d41bca..0000000
--- a/tests/e2e/test_idp_seed.py
+++ /dev/null
@@ -1,248 +0,0 @@
-"""E2E tests for IdP seeding from YAML config file (WG_IDP_CONFIG_FILE).
-
-Uses async Playwright for the full OIDC flow test (real browser → mock-oidc server).
-The seed function tests run without a browser.
-"""
-
-import os
-import subprocess
-import tempfile
-import time
-from pathlib import Path
-
-import pytest
-import pytest_asyncio
-import yaml
-from playwright.async_api import Page, expect
-from sqlmodel import select
-
-from wiregui.auth.seed import seed_idp_providers
-from wiregui.db import async_session
-from wiregui.models.configuration import Configuration
-from tests.e2e.conftest import FAKE_SERVER_KEY
-
-
-MOCK_OIDC_DISCOVERY = "http://localhost:9000/test-idp/.well-known/openid-configuration"
-
-# Separate port for the IdP-seeded app instance
-IDP_APP_PORT = 13002
-IDP_APP_BASE = f"http://localhost:{IDP_APP_PORT}"
-
-
-def _write_yaml(data: dict) -> Path:
- f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w")
- yaml.safe_dump(data, f)
- f.close()
- return Path(f.name)
-
-
-def _mock_oidc_yaml() -> dict:
- return {
- "openid_connect_providers": [
- {
- "id": "test-idp",
- "label": "Sign in with Mock IdP",
- "scope": "openid email profile",
- "client_id": "wiregui-test",
- "client_secret": "wiregui-test-secret",
- "discovery_document_uri": MOCK_OIDC_DISCOVERY,
- "auto_create_users": True,
- }
- ]
- }
-
-
-# ---------------------------------------------------------------------------
-# Fixtures
-# ---------------------------------------------------------------------------
-
-
-@pytest_asyncio.fixture
-async def clean_config():
- """Ensure a Configuration row exists with no IdP providers, and restore after."""
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
- orig_oidc = list(config.openid_connect_providers or []) if config else []
- orig_saml = list(config.saml_identity_providers or []) if config else []
-
- if config is None:
- config = Configuration(server_public_key=FAKE_SERVER_KEY)
- session.add(config)
-
- config.openid_connect_providers = []
- config.saml_identity_providers = []
- session.add(config)
- await session.commit()
-
- yield
-
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- config.openid_connect_providers = orig_oidc
- config.saml_identity_providers = orig_saml
- session.add(config)
- await session.commit()
-
-
-# ---------------------------------------------------------------------------
-# Seed function tests (no browser needed)
-# ---------------------------------------------------------------------------
-
-
-async def test_seed_noop_when_no_config_file(clean_config, monkeypatch):
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": None})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- assert config.openid_connect_providers == []
-
-
-async def test_seed_noop_when_file_missing(clean_config, monkeypatch):
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": "/nonexistent/idps.yaml"})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- assert config.openid_connect_providers == []
-
-
-async def test_seed_adds_oidc_provider(clean_config, monkeypatch):
- path = _write_yaml(_mock_oidc_yaml())
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- assert len(config.openid_connect_providers) == 1
- assert config.openid_connect_providers[0]["id"] == "test-idp"
- path.unlink()
-
-
-async def test_seed_adds_saml_provider(clean_config, monkeypatch):
- yaml_data = {"saml_identity_providers": [{"id": "test-saml", "label": "Test SAML IdP", "metadata": "", "sign_requests": True, "sign_metadata": False, "signed_assertion_in_resp": True, "signed_envelopes_in_resp": True, "auto_create_users": False}]}
- path = _write_yaml(yaml_data)
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- assert len(config.saml_identity_providers) == 1
- assert config.saml_identity_providers[0]["id"] == "test-saml"
- path.unlink()
-
-
-async def test_seed_upserts_existing_provider(clean_config, monkeypatch):
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- config.openid_connect_providers = [{"id": "test-idp", "label": "Old Label", "client_id": "old-client"}]
- session.add(config)
- await session.commit()
-
- yaml_data = {"openid_connect_providers": [{"id": "test-idp", "label": "Updated Label", "client_id": "new-client", "client_secret": "new-secret", "discovery_document_uri": MOCK_OIDC_DISCOVERY}]}
- path = _write_yaml(yaml_data)
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- assert config.openid_connect_providers[0]["label"] == "Updated Label"
- assert config.openid_connect_providers[0]["client_id"] == "new-client"
- path.unlink()
-
-
-async def test_seed_preserves_providers_not_in_yaml(clean_config, monkeypatch):
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- config.openid_connect_providers = [{"id": "manual-provider", "label": "Manually Added", "client_id": "manual"}]
- session.add(config)
- await session.commit()
-
- yaml_data = {"openid_connect_providers": [{"id": "yaml-provider", "label": "From YAML", "client_id": "yaml-client", "client_secret": "yaml-secret", "discovery_document_uri": MOCK_OIDC_DISCOVERY}]}
- path = _write_yaml(yaml_data)
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- ids = {p["id"] for p in config.openid_connect_providers}
- assert ids == {"manual-provider", "yaml-provider"}
- path.unlink()
-
-
-async def test_seed_invalid_yaml(clean_config, monkeypatch):
- path = Path(tempfile.mktemp(suffix=".yaml"))
- path.write_text(": : : invalid yaml [[[")
- monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
- await seed_idp_providers()
- async with async_session() as session:
- config = (await session.execute(select(Configuration).limit(1))).scalar_one()
- assert config.openid_connect_providers == []
- path.unlink()
-
-
-# ---------------------------------------------------------------------------
-# Playwright browser tests — full OIDC login flow via mock-oidc
-# ---------------------------------------------------------------------------
-
-
-@pytest.fixture(scope="module")
-def idp_yaml_file():
- path = _write_yaml(_mock_oidc_yaml())
- yield path
- path.unlink()
-
-
-@pytest.fixture(scope="module")
-def app_with_idp(idp_yaml_file):
- """Start a WireGUI instance with WG_IDP_CONFIG_FILE set."""
- import httpx
-
- env = os.environ.copy()
- env["WG_IDP_CONFIG_FILE"] = str(idp_yaml_file)
- env["WG_LOG_TO_FILE"] = "false"
- env["WG_PORT"] = str(IDP_APP_PORT)
- env["WG_EXTERNAL_URL"] = IDP_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"{IDP_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)
-
-
-async def test_oidc_button_visible_on_login(app_with_idp, page: Page):
- await page.goto(f"{IDP_APP_BASE}/login")
- await page.wait_for_load_state("networkidle")
- await expect(page.get_by_text("Sign in with Mock IdP")).to_be_visible(timeout=10_000)
-
-
-async def test_full_oidc_login_flow(app_with_idp, page: Page):
- """Click the OIDC button → mock-oidc login → redirected back → authenticated."""
- await page.goto(f"{IDP_APP_BASE}/auth/oidc/test-idp")
- await page.wait_for_url("**/test-idp/authorize**", timeout=10_000)
-
- await page.locator("input[name='username']").fill("oidc-e2e-user@test.local")
- await page.locator("input[type='submit']").click()
-
- await page.wait_for_url(f"{IDP_APP_BASE}/**", timeout=15_000)
- await page.wait_for_load_state("networkidle")
- await page.wait_for_timeout(3000)
-
- assert "/login" not in page.url, f"OIDC login failed — still on login page: {page.url}"
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
diff --git a/tests/e2e/test_login.py b/tests/e2e/test_login.py
deleted file mode 100644
index bc2f82e..0000000
--- a/tests/e2e/test_login.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""End-to-end tests for login, logout, and auth guard flows."""
-
-from playwright.async_api import Page, expect
-
-from wiregui.db import async_session
-from wiregui.models.user import User as UserModel
-from wiregui.utils.time import utcnow
-from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, TEST_PASSWORD, login
-
-
-async def test_login_valid_credentials(page: Page, test_user: UserModel):
- """Valid login redirects to devices page."""
- await login(page)
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
-
-
-async def test_login_invalid_password(page: Page, test_user: UserModel):
- """Wrong password shows error and stays on login page."""
- await login(page, password="wrongpassword")
- await expect(page.get_by_text("Invalid email or password")).to_be_visible(timeout=10_000)
- await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible()
-
-
-async def test_login_nonexistent_email(page: Page, test_user: UserModel):
- """Nonexistent email shows error."""
- await login(page, email="nobody@nowhere.com")
- await expect(page.get_by_text("Invalid email or password")).to_be_visible(timeout=10_000)
- await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible()
-
-
-async def test_login_disabled_user(page: Page, test_user: UserModel):
- """Disabled user cannot log in."""
- async with async_session() as session:
- u = await session.get(UserModel, test_user.id)
- u.disabled_at = utcnow()
- session.add(u)
- await session.commit()
-
- try:
- await login(page)
- await expect(page.get_by_text("Invalid email or password")).to_be_visible(timeout=10_000)
- await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible()
- finally:
- async with async_session() as session:
- u = await session.get(UserModel, test_user.id)
- u.disabled_at = None
- session.add(u)
- await session.commit()
-
-
-async def test_logout(page: Page, test_user: UserModel):
- """Logout clears session and redirects to login."""
- await login(page)
- await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
-
- await page.get_by_text("Logout").click()
- await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
-
-
-async def test_unauthenticated_redirect(page: Page, test_user: UserModel):
- """Accessing a protected page without auth redirects to login."""
- await page.goto(f"{TEST_APP_BASE}/devices")
- await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
diff --git a/uv.lock b/uv.lock
index ae15c4b..993c195 100644
--- a/uv.lock
+++ b/uv.lock
@@ -620,7 +620,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
- { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -629,7 +628,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
- { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -638,7 +636,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
- { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -1140,25 +1137,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
-[[package]]
-name = "playwright"
-version = "1.58.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "greenlet" },
- { name = "pyee" },
-]
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
- { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
- { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
- { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
- { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
- { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
- { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
- { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
-]
-
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1337,18 +1315,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
-[[package]]
-name = "pyee"
-version = "13.0.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
-]
-
[[package]]
name = "pygments"
version = "2.20.0"
@@ -1879,7 +1845,6 @@ dependencies = [
{ name = "pyotp" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python3-saml" },
- { name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] },
{ name = "redis" },
{ name = "sqlmodel" },
@@ -1888,7 +1853,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
- { name = "playwright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -1910,7 +1874,6 @@ requires-dist = [
{ name = "pyotp", specifier = ">=2.9" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
{ name = "python3-saml", specifier = ">=1.16" },
- { name = "pyyaml", specifier = ">=6.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
{ name = "redis", specifier = ">=5.2" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
@@ -1919,7 +1882,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
- { name = "playwright", specifier = ">=1.58.0" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
diff --git a/wiregui/auth/seed.py b/wiregui/auth/seed.py
index ac23bac..2d0e1a1 100644
--- a/wiregui/auth/seed.py
+++ b/wiregui/auth/seed.py
@@ -1,9 +1,7 @@
"""Seed the initial admin user and server keypair on first startup."""
import secrets
-from pathlib import Path
-import yaml
from loguru import logger
from sqlmodel import select
@@ -61,76 +59,3 @@ async def ensure_server_keypair() -> None:
logger.info("Server WireGuard keypair generated (pubkey: {}...)", public_key[:20])
except Exception as e:
logger.warning("Could not generate server keypair (wg CLI not available?): {}", e)
-
-
-def _upsert_providers(existing: list[dict], incoming: list[dict], kind: str) -> list[dict]:
- """Merge incoming providers into existing by `id`, returning the updated list.
-
- Providers in `incoming` overwrite existing entries with the same `id`.
- Existing providers not present in `incoming` are preserved.
- """
- by_id = {p["id"]: p for p in existing}
- for p in incoming:
- pid = p.get("id")
- if not pid:
- logger.warning("Skipping {} provider without 'id' in IdP config file", kind)
- continue
- action = "updated" if pid in by_id else "added"
- by_id[pid] = p
- logger.info("IdP seed: {} {} provider '{}'", action, kind, pid)
- return list(by_id.values())
-
-
-async def seed_idp_providers() -> None:
- """Seed OIDC/SAML providers from a YAML config file (if configured).
-
- Reads WG_IDP_CONFIG_FILE, parses the YAML, and upserts providers into the
- Configuration singleton by `id`. Providers not in the YAML are preserved.
- """
- settings = get_settings()
- if not settings.idp_config_file:
- return
-
- path = Path(settings.idp_config_file)
- if not path.is_file():
- logger.warning("IdP config file not found: {}", path)
- return
-
- try:
- data = yaml.safe_load(path.read_text())
- except Exception as e:
- logger.error("Failed to parse IdP config file {}: {}", path, e)
- return
-
- if not isinstance(data, dict):
- logger.error("IdP config file must be a YAML mapping, got {}", type(data).__name__)
- return
-
- oidc_incoming = data.get("openid_connect_providers") or []
- saml_incoming = data.get("saml_identity_providers") or []
-
- if not oidc_incoming and not saml_incoming:
- logger.debug("IdP config file has no providers defined, skipping")
- return
-
- async with async_session() as session:
- result = await session.execute(select(Configuration).limit(1))
- config = result.scalar_one_or_none()
-
- if config is None:
- config = Configuration()
- session.add(config)
-
- if oidc_incoming:
- config.openid_connect_providers = _upsert_providers(
- config.openid_connect_providers or [], oidc_incoming, "OIDC"
- )
-
- if saml_incoming:
- config.saml_identity_providers = _upsert_providers(
- config.saml_identity_providers or [], saml_incoming, "SAML"
- )
-
- session.add(config)
- await session.commit()
- logger.info("IdP providers seeded from {}", path)
diff --git a/wiregui/config.py b/wiregui/config.py
index 17ba1c4..f682813 100644
--- a/wiregui/config.py
+++ b/wiregui/config.py
@@ -41,9 +41,6 @@ class Settings(BaseSettings):
smtp_password: str | None = None
smtp_from: str = "wiregui@localhost"
- # IdP provisioning
- idp_config_file: str | None = None # path to YAML file with IdP definitions
-
# Logging
log_to_file: bool = True # write timestamped log file to logs/ directory
diff --git a/wiregui/main.py b/wiregui/main.py
index bb3d8fb..be2b216 100644
--- a/wiregui/main.py
+++ b/wiregui/main.py
@@ -5,7 +5,7 @@ from wiregui.api.v0 import router as api_router
from wiregui.auth.seed import ensure_server_keypair, seed_admin
from wiregui.config import get_settings
from wiregui.db import init_db
-from wiregui.log_config import setup_logging
+from wiregui.logging import setup_logging
# Mount REST API
app.include_router(api_router, prefix="/api")
@@ -38,10 +38,7 @@ async def startup() -> None:
await seed_admin()
await ensure_server_keypair()
- # Seed IdP providers from YAML config file (if configured), then register with authlib
- from wiregui.auth.seed import seed_idp_providers
- await seed_idp_providers()
-
+ # Register OIDC providers from config
from wiregui.auth.oidc import register_providers
await register_providers()