From 3bf6fabcffc3b8ae878a37d9037e9a5c17270505 Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Tue, 31 Mar 2026 14:23:31 -0500 Subject: [PATCH] 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. --- README.md | 21 ++- TODO.md | 249 ++++++++++------------------------ pyproject.toml | 6 + tests/e2e/conftest.py | 126 +++++++++++++++-- tests/e2e/test_account.py | 177 +++++++++--------------- tests/e2e/test_admin_users.py | 208 ++++++++++++++++++++++++++++ tests/e2e/test_devices.py | 51 +++---- tests/e2e/test_idp_seed.py | 248 +++++++++++++++++++++++++++++++++ tests/e2e/test_login.py | 63 +++++++++ uv.lock | 38 ++++++ wiregui/auth/seed.py | 75 ++++++++++ wiregui/config.py | 3 + wiregui/main.py | 7 +- 13 files changed, 940 insertions(+), 332 deletions(-) create mode 100644 tests/e2e/test_admin_users.py create mode 100644 tests/e2e/test_idp_seed.py create mode 100644 tests/e2e/test_login.py diff --git a/README.md b/README.md index 0c759a2..739a4eb 100644 --- a/README.md +++ b/README.md @@ -84,17 +84,34 @@ 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 tests/ --ignore=tests/e2e -v +uv run pytest -# E2E tests (NiceGUI User fixture) +# E2E tests (Playwright — requires running PostgreSQL, Valkey, and mock-oidc) +docker compose up -d 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 b436ccb..5d1124a 100644 --- a/TODO.md +++ b/TODO.md @@ -3,150 +3,17 @@ Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI. Source: `/home/stefanob/PycharmProjects/personal/wirezone` -**Test count: 174 (173 passing, 1 skipped) | Coverage: 35%** +**Test count: 199 (164 unit + 35 E2E) | Coverage: 35%** +**Run:** `uv run pytest` (unit) / `uv run pytest tests/e2e/` (E2E via Playwright) ---- - -## Phase 1: Foundation — Models, DB, Config ✅ - -- [x] `pyproject.toml` with dependencies, `uv sync` -- [x] Package directory structure -- [x] `wiregui/config.py` — pydantic-settings (DB, Redis, WG, auth, SMTP, logging) -- [x] `wiregui/db.py` — async engine, sessionmaker, `init_db()` -- [x] `wiregui/redis.py` — Valkey connection pool -- [x] All 8 SQLModel models (User, Device, Rule, MFAMethod, OIDCConnection, ApiToken, ConnectivityCheck, Configuration) -- [x] Alembic init + initial migration + `alembic upgrade head` -- [x] `wiregui/main.py` — app entrypoint -- [x] `compose.yml` — PostgreSQL 17 + Valkey 8 -- [x] `wiregui/utils/time.py` — `utcnow()` helper for naive UTC timestamps - ---- - -## Phase 2: Auth System — Login + Sessions ✅ - -- [x] `wiregui/auth/passwords.py` — bcrypt hash/verify (direct bcrypt, not passlib) -- [x] `wiregui/auth/jwt.py` — create/decode JWT via python-jose -- [x] `wiregui/auth/session.py` — `authenticate_user()` email/password verification -- [x] `wiregui/auth/middleware.py` — HTTP-level auth middleware (available for REST API) -- [x] `wiregui/auth/seed.py` — auto-create admin on first startup -- [x] `wiregui/pages/login.py` — login page with email/password form -- [x] `wiregui/pages/home.py` — authenticated home (redirects to /devices) -- [x] Auth guards via `app.storage.user` on each page -- [x] Logout clears storage and redirects - ---- - -## Phase 3: Device UI — User-Facing CRUD ✅ - -- [x] `wiregui/pages/layout.py` — shared sidebar + header -- [x] `wiregui/utils/network.py` — IPv4/IPv6 allocation (random offset + scan) -- [x] `wiregui/utils/crypto.py` — WG keypair + PSK generation via `wg` CLI -- [x] `wiregui/utils/wg_conf.py` — WG client `.conf` builder -- [x] `wiregui/pages/devices.py` — `/devices` list + create dialog + delete -- [x] `/devices/{device_id}` — detail page with stats and config flags -- [x] QR code generation + `.conf` download -- [x] Full device create/edit form with all wirezone options (description, per-device config overrides, use_default_* toggles with bound inputs, better layout) - ---- - -## Phase 4: WireGuard Integration ✅ - -- [x] `wiregui/services/wireguard.py` — async subprocess: ensure_interface, add/remove_peer, get_peers, set_private_key, set_listen_port -- [x] `wiregui/services/events.py` — event bridge: device CRUD → WG + firewall -- [x] Device create/delete in UI fires WG events -- [x] `wiregui/tasks/__init__.py` — background task registry + cancel_all -- [x] `wiregui/tasks/stats.py` — poll WG stats every 60s, update DB -- [x] `wiregui/tasks/reconcile.py` — startup reconciliation (diff DB vs WG, add/remove) -- [x] `config.py` — `wg_enabled` flag (default False for dev) -- [x] Startup: ensure_interface → reconcile → stats_loop (when wg_enabled) - ---- - -## Phase 5: Firewall (nftables) ✅ - -- [x] `wiregui/services/firewall.py` — nft CLI: setup_base_tables, masquerade, per-user chains, jump rules, apply_rule, rebuild_all_rules -- [x] IPv4/IPv6 aware, TCP/UDP port range support -- [x] `wiregui/pages/admin/rules.py` — `/admin/rules` CRUD (action, CIDR, protocol, port, user) -- [x] Events: on_rule_created/deleted, on_device_created adds jump rules -- [x] Startup: setup_base_tables + setup_masquerade (when wg_enabled) -- [x] Edit rule — edit dialog in admin rules page with all fields -- [x] Full user chain rebuild on rule update/delete via `_rebuild_user_chain()` in events.py - ---- - -## Phase 6: REST API (v0) ✅ - -- [x] `wiregui/auth/api_token.py` — token generation (random → sha256), Bearer resolution with expiry + disabled user checks -- [x] `wiregui/api/deps.py` — get_db, get_current_api_user, require_admin -- [x] `wiregui/schemas/` — Pydantic schemas: UserRead/Create/Update, DeviceRead/Create/Update, RuleRead/Create/Update, ConfigurationRead/Update -- [x] `wiregui/api/v0/users.py` — full CRUD (admin only) -- [x] `wiregui/api/v0/devices.py` — full CRUD (owner or admin, triggers WG/firewall events) -- [x] `wiregui/api/v0/rules.py` — full CRUD (admin only, triggers firewall events) -- [x] `wiregui/api/v0/configuration.py` — GET/PUT (admin only, auto-creates singleton) -- [x] Mounted on NiceGUI app at `/api/v0` - ---- ## Phase 7: Admin UI ✅ -- [x] `/admin/users` — table (email, role, devices, status, last sign-in, method, created), create (email/password/role), edit (email/role/password/disabled), delete with cascading cleanup (devices → WG events, rules) -- [x] `/admin/devices` — all devices with user filter, full create form (owner, name, description, all use_default_* toggles with bound override inputs), full edit form, delete with WG events, config + QR on creation -- [x] `/admin/settings` — 3 tabs: - - Client Defaults (endpoint, DNS, allowed IPs, MTU, keepalive) - - Security (VPN session duration, local auth, unpriv device mgmt/config, OIDC auto-disable) - - Authentication (OIDC provider CRUD with table + dialog; SAML placeholder for Phase 8) -- [x] `/admin/diagnostics` — WG interface status, active peers, connectivity checks, system notifications with clear/clear-all -- [x] `wiregui/services/notifications.py` — in-memory deque (capped at 100), add/clear/count/current -- [x] Header notification bell badge (admin only, links to diagnostics) - [ ] **TODO:** SAML provider management in Authentication tab ---- - -## Phase 8: Advanced Auth (MFA, OIDC, Magic Links, SAML) ✅ - -- [x] TOTP MFA (`wiregui/auth/mfa.py`) — secret generation, URI/QR, verification with clock drift tolerance -- [x] MFA challenge page (`/mfa`) — 6-digit code entry, multi-method support, last-used tracking -- [x] Login page updated: checks for MFA methods after password auth, redirects to `/mfa` if present -- [x] OIDC (`wiregui/auth/oidc.py`) — provider registry from Configuration, authlib Starlette integration -- [x] OIDC routes (`/auth/oidc/{provider}` + `/auth/oidc/{provider}/callback`) — auth code flow, user lookup/auto-create, refresh token storage in OIDCConnection -- [x] Login page shows OIDC provider buttons dynamically from config -- [x] OIDC refresh task (`wiregui/tasks/oidc_refresh.py`) — every 10min, refreshes all stored tokens, creates notifications on failure, respects `disable_vpn_on_oidc_error` -- [x] Magic links (`/auth/magic-link` + `/auth/magic/{user_id}/{token}`) — request page, signed JWT with 15min expiry, email via aiosmtplib -- [x] Email service (`wiregui/services/email.py`) — aiosmtplib send, magic link template -- [x] `/account` page — 3 tabs: Profile (details + password change), Two-Factor Auth (TOTP registration with QR + verification, list/delete methods), API Tokens (create with configurable expiry, list, delete) -- [x] OIDC providers registered on startup from Configuration -- [x] WebAuthn MFA (`wiregui/auth/webauthn.py`) — registration/authentication options generation, response verification, credential storage -- [x] SAML (`wiregui/auth/saml.py` + `wiregui/pages/auth_saml.py`) — SP-initiated SSO, metadata endpoint, ACS callback, IdP metadata parsing, attribute mapping -- [x] WebAuthn browser-side JS integration in account page — `ui.run_javascript()` calls `navigator.credentials.create()`, serializes response, server verifies and stores credential -- [x] SAML provider management UI in admin settings Authentication tab — table + add/delete dialog (config ID, label, XML metadata, sign requests/metadata/assertions/envelopes toggles, auto-create users) - ---- - -## Phase 9: Background Tasks & VPN Session Management - -- [x] Task scheduler (`wiregui/tasks/__init__.py`) — register/cancel -- [x] Stats polling task (Phase 4) -- [x] OIDC refresh task (Phase 8) -- [x] VPN session expiry task (`wiregui/tasks/vpn_session.py`) — every 60s, finds expired sessions based on `vpn_session_duration` + `last_signed_in_at`, removes WG peers, creates notifications -- [x] Connectivity check poller (`wiregui/tasks/connectivity.py`) — fetches URL, stores result in DB, notification on failure -- [x] Live stats push — `ui.timer(30, ...)` on `/devices` (table refresh), `/devices/{id}` (RX/TX/handshake/remote IP labels), `/admin/devices` (table refresh) - ---- - ## Phase 10: Polish, Testing & Deployment ### Testing (partially done) -- [x] pytest + pytest-asyncio setup, conftest with test DB -- [x] test_models.py (10 tests), test_auth.py (8 tests), test_utils.py (6 tests), test_services.py (6 tests), test_firewall.py (7 tests) -- [x] test_api.py (6 tests) — token generation, resolution, expiry, disabled user -- [x] test_notifications.py (9 tests) — add, ordering, count, clear, max cap, to_dict -- [x] test_admin.py (13 tests) — user CRUD, cascading deletes, config CRUD, OIDC providers, device overrides -- [x] test_mfa.py (11 tests) — TOTP secret gen, URI, code verification (valid/invalid/wrong secret/empty), QR SVG, DB integration, multi-method -- [x] test_magic_link.py (4 tests) — token creation/expiry/user mismatch, disabled user rejection -- [x] test_account.py (8 tests) — password change flow, API token CRUD, OIDC connection CRUD, refresh token update -- [x] test_integration_mfa.py (7 tests) — full TOTP registration flow, MFA blocks login, wrong code, multi-method, last-used tracking, delete allows bypass, disabled user -- [x] test_integration_oidc.py (10 tests) — provider config loading, connection create/update, auto-create user, disabled user, refresh token, multi-provider -- [x] test_tasks.py (6 tests) — VPN session expiry (expired/unlimited/no-config/disabled user), connectivity check (success/failure with notification) - [ ] HTTP-level integration tests (OIDC redirect/callback flow with respx mocking) ### Coverage gaps (35% overall — run `uv run pytest --cov=wiregui --cov-report=term-missing --cov-branch`) @@ -181,54 +48,88 @@ Source: `/home/stefanob/PycharmProjects/personal/wirezone` - [ ] `wiregui/auth/saml.py` (0%) — needs mock SAML IdP metadata + response parsing - [ ] `wiregui/auth/webauthn.py` — test verify_registration, verify_authentication with mock credential data -**E2E page tests (via NiceGUI `User` fixture in `tests/e2e/`):** +**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 -- [ ] E2E tests for admin pages (users, devices, rules, settings) +- [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) + +`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 -### Logging (done) -- [x] Loguru configured (wiregui/logging.py), no print statements -- [x] File logging to `logs/` when `WG_LOG_TO_FILE=true` ### Deployment ✅ -- [x] Dockerfile (multi-stage python:3.13-slim) -- [x] compose.prod.yml (bridge networking, NET_ADMIN, nftables) -- [x] Health endpoint `GET /api/health` -- [x] Forgejo CI: test → semver → Docker registry push -- [x] AGPL-3.0-or-later license -- [x] README.md with features, quick start, env vars, anti-enshittification manifesto + - [ ] First-run CLI setup command --- -## UI Polish & Styling - -### Global styling ✅ -- [x] Manrope font loaded from Google Fonts as primary UI font (`wiregui/pages/style.py`) -- [x] Font applied on all pages (layout, login, MFA challenge) -- [x] Dark/light/auto theme toggle in header — cycles with icon button -- [x] Theme preference stored in `users.theme_preference` column (migration `a3f1d8e92b01`) -- [x] Theme persisted to DB and loaded into session on all login flows (password, MFA, magic link, OIDC, SAML) - -### Account page (`/account`) ✅ -- [x] Card-based layout matching admin pages (diagnostics, settings) -- [x] Account Details: `ui.grid(columns=2)` with bold labels, same as diagnostics -- [x] Change Password: inline card section (no modal), outlined inputs, validation -- [x] Connected SSO Providers: always visible card with empty state -- [x] API Tokens: table with status badges, inline create, copy-to-clipboard with green accent card -- [x] MFA: methods table, inline TOTP registration (QR + verify), WebAuthn, empty state -- [x] Danger Zone: red left border accent, typed email confirmation, disabled if only admin - -### Settings page (`/admin/settings`) ✅ -- [x] Converted from tabbed layout to stacked cards (Client Defaults, Security, Authentication) - -### Consistency pass ✅ -- [x] All buttons solid (`unelevated`) — no outline buttons anywhere -- [x] All pages use `w-full p-4` container with `text-h5 q-mb-md` page title -- [x] All `text-grey-7` / `text-grey-8` replaced with dark-mode-safe `text-grey` -- [x] Sidebar: removed hardcoded `bg-grey-1`, uses theme-aware background -- [x] Card titles: `text-subtitle1 text-bold` + `ui.separator()` everywhere - ### 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 b9feaa4..af8c690 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,12 +31,15 @@ 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", @@ -48,4 +51,7 @@ 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 ca4f691..003b46b 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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() diff --git a/tests/e2e/test_account.py b/tests/e2e/test_account.py index f8744e3..383c3b4 100644 --- a/tests/e2e/test_account.py +++ b/tests/e2e/test_account.py @@ -1,124 +1,85 @@ -"""End-to-end tests for account page — password, TOTP, API tokens, deletion.""" +"""End-to-end tests for account page — password, API tokens, TOTP, deletion.""" -from unittest.mock import patch - -import pytest -from nicegui import ui -from nicegui.testing import User +from playwright.async_api import Page, expect +from sqlmodel import select +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_EMAIL, TEST_PASSWORD +from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, TEST_PASSWORD, login -async def _login(user: User): +async def _login_to_account(page: Page): """Log in and navigate to account page.""" - 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") + 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) -@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(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_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_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_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_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_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_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_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_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_totp_registration_flow(user: User, test_user: UserModel): +async def test_totp_registration_flow(page: Page, test_user: UserModel): """Test starting TOTP registration shows QR and verify form.""" - 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") + 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() -@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_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_delete_account(user: User, test_user: UserModel): +async def test_delete_account(page: Page, 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", @@ -129,21 +90,17 @@ async def test_delete_account(user: User, test_user: UserModel): await session.commit() try: - 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") + 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) finally: - # Clean up second admin async with async_session() as session: - from sqlmodel import select - a2 = (await session.execute(select(UserModel).where(UserModel.email == "admin2@example.com"))).scalar_one_or_none() + 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 new file mode 100644 index 0000000..b8f5d51 --- /dev/null +++ b/tests/e2e/test_admin_users.py @@ -0,0 +1,208 @@ +"""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 8ed6b88..805910a 100644 --- a/tests/e2e/test_devices.py +++ b/tests/e2e/test_devices.py @@ -1,45 +1,32 @@ -"""End-to-end tests for device management UI using NiceGUI's User fixture.""" +"""End-to-end tests for device management UI.""" -import pytest -from nicegui.testing import User +from playwright.async_api import Page, expect from wiregui.models.user import User as UserModel -from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD +from tests.e2e.conftest import login -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): +async def test_add_device_via_ui(page: Page, test_user: UserModel): """Test the full flow: login → devices → add device → see it in table.""" - await _login(user) + await login(page) + await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000) - # Open create dialog - user.find("Add Device").click() - await user.should_see("New Device") + await page.get_by_role("button", name="Add Device").click() + await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000) - # Fill device name and submit - user.find("Device Name").type("Test Laptop") - user.find("Create").click() + await page.locator("input[aria-label='Device Name']").fill("Test Laptop") + await page.get_by_role("button", name="Create").click() - # Should see config dialog with the device config - await user.should_see("Test Laptop") + # Should see config dialog with the device name + await expect(page.get_by_text("Config for Test Laptop")).to_be_visible(timeout=10_000) -@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) -async def test_add_device_requires_name(user: User, test_user: UserModel): +async def test_add_device_requires_name(page: Page, test_user: UserModel): """Test that creating a device without a name shows an error.""" - await _login(user) + await login(page) + await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_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") + 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) diff --git a/tests/e2e/test_idp_seed.py b/tests/e2e/test_idp_seed.py new file mode 100644 index 0000000..6d41bca --- /dev/null +++ b/tests/e2e/test_idp_seed.py @@ -0,0 +1,248 @@ +"""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 new file mode 100644 index 0000000..bc2f82e --- /dev/null +++ b/tests/e2e/test_login.py @@ -0,0 +1,63 @@ +"""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 993c195..ae15c4b 100644 --- a/uv.lock +++ b/uv.lock @@ -620,6 +620,7 @@ 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" }, @@ -628,6 +629,7 @@ 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" }, @@ -636,6 +638,7 @@ 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" }, @@ -1137,6 +1140,25 @@ 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" @@ -1315,6 +1337,18 @@ 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" @@ -1845,6 +1879,7 @@ dependencies = [ { name = "pyotp" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python3-saml" }, + { name = "pyyaml" }, { name = "qrcode", extra = ["pil"] }, { name = "redis" }, { name = "sqlmodel" }, @@ -1853,6 +1888,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "playwright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -1874,6 +1910,7 @@ 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" }, @@ -1882,6 +1919,7 @@ 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 2d0e1a1..ac23bac 100644 --- a/wiregui/auth/seed.py +++ b/wiregui/auth/seed.py @@ -1,7 +1,9 @@ """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 @@ -59,3 +61,76 @@ 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 f682813..17ba1c4 100644 --- a/wiregui/config.py +++ b/wiregui/config.py @@ -41,6 +41,9 @@ 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 be2b216..bb3d8fb 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.logging import setup_logging +from wiregui.log_config import setup_logging # Mount REST API app.include_router(api_router, prefix="/api") @@ -38,7 +38,10 @@ async def startup() -> None: await seed_admin() await ensure_server_keypair() - # Register OIDC providers from config + # 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() + from wiregui.auth.oidc import register_providers await register_providers()