feat: add E2E tests for device creation and account management
Some checks failed
CI / test (push) Failing after 2m4s
CI / release (push) Has been skipped
CI / docker (push) Has been skipped

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:
Stefano Bertelli 2026-03-30 22:26:15 -05:00
parent 3d1ca7444b
commit 5adb0c86ce
7 changed files with 283 additions and 5 deletions

View file

@ -40,8 +40,11 @@ jobs:
- name: Install dependencies
run: uv sync
- name: Run tests
run: uv run pytest -v --tb=short
- name: Run unit tests
run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short
- name: Run E2E tests
run: uv run pytest tests/e2e/ -v --tb=short
release:
needs: test

View file

@ -3,7 +3,7 @@
Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI.
Source: `/home/stefanob/PycharmProjects/personal/wirezone`
**Test count: 164 (163 passing, 1 skipped) | Coverage: 35%**
**Test count: 174 (173 passing, 1 skipped) | Coverage: 35%**
---
@ -181,8 +181,10 @@ 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
**Pages (0% — requires E2E testing):**
- [ ] Consider Playwright or NiceGUI's testing utilities for E2E page tests
**E2E page tests (via NiceGUI `User` fixture in `tests/e2e/`):**
- [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)
### Logging (done)
- [x] Loguru configured (wiregui/logging.py), no print statements

View file

@ -47,3 +47,4 @@ asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"]
main_file = "wiregui/main.py"

0
tests/e2e/__init__.py Normal file
View file

69
tests/e2e/conftest.py Normal file
View 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
View 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
View 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")