feat: add E2E tests for device creation and account management
10 E2E tests using NiceGUI's User fixture: - Device creation flow and name validation - Password change (success, wrong current, mismatch, too short) - API token creation, TOTP registration, invalid code rejection - Account deletion with email confirmation Tests live in tests/e2e/ with a separate conftest that loads the NiceGUI testing plugin. CI runs unit and E2E tests as separate steps.
This commit is contained in:
parent
3d1ca7444b
commit
5adb0c86ce
7 changed files with 283 additions and 5 deletions
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
69
tests/e2e/conftest.py
Normal file
69
tests/e2e/conftest.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""E2E test configuration — loads NiceGUI testing plugin and app."""
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from wiregui.auth.passwords import hash_password
|
||||
from wiregui.db import async_session
|
||||
from wiregui.models.api_token import ApiToken
|
||||
from wiregui.models.configuration import Configuration
|
||||
from wiregui.models.device import Device
|
||||
from wiregui.models.mfa_method import MFAMethod
|
||||
from wiregui.models.oidc_connection import OIDCConnection
|
||||
from wiregui.models.rule import Rule
|
||||
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"
|
||||
|
||||
RELATED_MODELS = (Device, Rule, MFAMethod, ApiToken, OIDCConnection)
|
||||
|
||||
|
||||
async def _delete_user_cascade(session, user_id):
|
||||
"""Delete a user and all related objects."""
|
||||
for model in RELATED_MODELS:
|
||||
for obj in (await session.execute(select(model).where(model.user_id == user_id))).scalars().all():
|
||||
await session.delete(obj)
|
||||
u = await session.get(User, user_id)
|
||||
if u:
|
||||
await session.delete(u)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user():
|
||||
"""Create a test user and ensure server config has a public key."""
|
||||
async with async_session() as session:
|
||||
# Clean up any leftover from a previous failed run
|
||||
existing = (await session.execute(select(User).where(User.email == TEST_EMAIL))).scalar_one_or_none()
|
||||
if existing:
|
||||
await _delete_user_cascade(session, existing.id)
|
||||
await session.commit()
|
||||
|
||||
# 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:
|
||||
config.server_public_key = FAKE_SERVER_KEY
|
||||
session.add(config)
|
||||
else:
|
||||
config = Configuration(server_public_key=FAKE_SERVER_KEY)
|
||||
session.add(config)
|
||||
|
||||
user = User(
|
||||
email=TEST_EMAIL,
|
||||
password_hash=hash_password(TEST_PASSWORD),
|
||||
role="admin",
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
yield user
|
||||
|
||||
# Teardown
|
||||
async with async_session() as session:
|
||||
await _delete_user_cascade(session, user.id)
|
||||
await session.commit()
|
||||
149
tests/e2e/test_account.py
Normal file
149
tests/e2e/test_account.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""End-to-end tests for account page — password, TOTP, API tokens, deletion."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from nicegui import ui
|
||||
from nicegui.testing import User
|
||||
|
||||
from wiregui.models.user import User as UserModel
|
||||
from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
|
||||
|
||||
|
||||
async def _login(user: User):
|
||||
"""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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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."""
|
||||
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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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",
|
||||
password_hash=hash_password("admin2pass"),
|
||||
role="admin",
|
||||
)
|
||||
session.add(second_admin)
|
||||
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")
|
||||
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()
|
||||
if a2:
|
||||
await session.delete(a2)
|
||||
await session.commit()
|
||||
54
tests/e2e/test_devices.py
Normal file
54
tests/e2e/test_devices.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""End-to-end tests for device management UI using NiceGUI's User fixture."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from nicegui.testing import User
|
||||
|
||||
from wiregui.models.user import User as UserModel
|
||||
from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
|
||||
|
||||
# Fake WG keys for testing (valid base64, 32 bytes)
|
||||
FAKE_PRIVATE_KEY = "YFake0PrivateKey00000000000000000000000000w="
|
||||
FAKE_PUBLIC_KEY = "ZFake0PublicKey000000000000000000000000000w="
|
||||
|
||||
|
||||
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."""
|
||||
with patch("wiregui.pages.devices.generate_keypair", return_value=(FAKE_PRIVATE_KEY, FAKE_PUBLIC_KEY)), \
|
||||
patch("wiregui.pages.devices.generate_preshared_key", return_value="cHJlc2hhcmVkMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="):
|
||||
|
||||
await _login(user)
|
||||
|
||||
# Open create dialog
|
||||
user.find("Add Device").click()
|
||||
await user.should_see("New Device")
|
||||
|
||||
# Fill device name and submit
|
||||
user.find("Device Name").type("Test Laptop")
|
||||
user.find("Create").click()
|
||||
|
||||
# Should see config dialog with the device config
|
||||
await user.should_see("Test Laptop")
|
||||
|
||||
|
||||
@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(user)
|
||||
|
||||
# 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue