feat: IdP provisioning from YAML file + Playwright e2e tests
Add WG_IDP_CONFIG_FILE env var to seed OIDC/SAML identity providers from a YAML file at startup, enabling GitOps and IaC workflows. Providers are upserted by id (merge strategy preserves manual additions). Convert all e2e tests from NiceGUI User fixture to Playwright async API with --headed and --slowmo flags for visual debugging. Add full OIDC login flow test against the mock-oidc service.
This commit is contained in:
parent
c9ef58a244
commit
3bf6fabcff
13 changed files with 940 additions and 332 deletions
|
|
@ -1,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='<svg></svg>'), \
|
||||
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='<svg></svg>'), \
|
||||
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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue